Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4e33d23cb | ||
|
|
63e7f000df | ||
|
|
152059b199 | ||
|
|
6c685c61e0 | ||
|
|
a176e7d013 | ||
|
|
d5df393723 | ||
|
|
7f3dfb31cf | ||
|
|
e97de8744a | ||
|
|
354936c9af | ||
|
|
a8a24605d4 | ||
|
|
b20fd42eec | ||
|
|
a3719c58fb | ||
|
|
6d2cf60d21 | ||
|
|
89d938a6c3 | ||
|
|
86e479c21a | ||
|
|
1f3c0bbbc9 | ||
|
|
24eb21fd29 | ||
|
|
56e20211e4 | ||
|
|
e5e2e472aa | ||
|
|
dab3ba3142 | ||
|
|
5b86a1c84a | ||
|
|
e5858df592 | ||
|
|
f20b852e98 | ||
|
|
792986dd79 | ||
|
|
1f9a4ff97a | ||
|
|
baea6f5107 | ||
|
|
f0b469fc93 | ||
|
|
d9364ce504 | ||
|
|
99ec356fbb | ||
|
|
44dfb944c7 | ||
|
|
aac5c5003a | ||
|
|
6580658f55 | ||
|
|
d9bc246088 | ||
|
|
fe3c2b64f6 | ||
|
|
c08c97d706 | ||
|
|
873728a6a2 | ||
|
|
9cfac48b59 | ||
|
|
0a828f33bf | ||
|
|
6d44803a27 | ||
|
|
b13ada16df | ||
|
|
a206076a56 | ||
|
|
df7abaecdc | ||
|
|
171ebac326 | ||
|
|
31553bb1a4 | ||
|
|
c65f2b8ab1 | ||
|
|
c9038757dd | ||
|
|
be4fc5eabe | ||
|
|
b8d239de40 | ||
|
|
e2d8858432 | ||
|
|
9f47ee3f08 | ||
|
|
eb7483a5dd | ||
|
|
0bdbb6d168 | ||
|
|
d6d64f1897 | ||
|
|
0504b9ae06 | ||
|
|
162f1c3b99 | ||
|
|
b0b4cfded1 | ||
|
|
39f8152e70 | ||
|
|
3958580571 | ||
|
|
6a53479bd7 | ||
|
|
20e101bb65 | ||
|
|
cbb9d2a105 | ||
|
|
848226cfbb | ||
|
|
5d46515e63 | ||
|
|
92d22b0bef | ||
|
|
8bcffffaa7 | ||
|
|
0ea310f9b3 | ||
|
|
4fcb076d99 | ||
|
|
e7661e7b38 | ||
|
|
2a406be0b1 | ||
|
|
dd9ec06c65 | ||
|
|
e0260f01ec | ||
|
|
9e743e29f4 | ||
|
|
ad746c0fcd | ||
|
|
5ab0ad45c2 | ||
|
|
1256b807a9 | ||
|
|
37243c5fc0 | ||
|
|
1c2195dd64 | ||
|
|
c3157439c3 | ||
|
|
7236684178 | ||
|
|
dae7576c7a | ||
|
|
67ca3ce81d | ||
|
|
c740999e90 | ||
|
|
e7192987f4 | ||
|
|
510664c4d3 | ||
|
|
bf791949d0 | ||
|
|
8084f96dce | ||
|
|
8dbe1c7cbb | ||
|
|
c2f4fec86d | ||
|
|
cbfa9a433a | ||
|
|
b27ab48d27 | ||
|
|
f33c6397c0 | ||
|
|
1146f88466 | ||
|
|
8f2ca3ab23 | ||
|
|
f3adc1c7aa | ||
|
|
d5131b87db | ||
|
|
d8d5e7b100 | ||
|
|
026dbfceeb | ||
|
|
21eb5d7fd7 | ||
|
|
c91903a077 | ||
|
|
f5edd7bbf8 | ||
|
|
5f12e9d900 | ||
|
|
1176a4ba18 | ||
|
|
d73a9ef9fa | ||
|
|
43206e816f | ||
|
|
048bd8db57 | ||
|
|
42327fc110 | ||
|
|
194f9246ef | ||
|
|
17edeccecc | ||
|
|
b9ee4546b7 | ||
|
|
5214f618ba | ||
|
|
3d2fc36f69 | ||
|
|
1db379c048 | ||
|
|
aaa91868a3 | ||
|
|
faecf13df8 | ||
|
|
b59f1c809f | ||
|
|
d1bf5db4c5 | ||
|
|
13992b6155 | ||
|
|
6e6ec6cb03 | ||
|
|
fe1f67407d | ||
|
|
62180be8ac |
2
.github/workflows/docker-ci.yml
vendored
2
.github/workflows/docker-ci.yml
vendored
@@ -21,6 +21,7 @@ jobs:
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=${{ secrets.NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY }}
|
||||
NEXT_PUBLIC_GA_ID=${{ secrets.NEXT_PUBLIC_GA_ID }}
|
||||
NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=${{ secrets.NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL }}
|
||||
EOF
|
||||
|
||||
- name: Build Dockerfile image
|
||||
@@ -55,6 +56,7 @@ jobs:
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=${{ secrets.NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY }}
|
||||
NEXT_PUBLIC_GA_ID=${{ secrets.NEXT_PUBLIC_GA_ID }}
|
||||
NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=${{ secrets.NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL }}
|
||||
EOF
|
||||
|
||||
- name: Docker Compose up
|
||||
|
||||
1
.github/workflows/nextjs.yml
vendored
1
.github/workflows/nextjs.yml
vendored
@@ -78,6 +78,7 @@ jobs:
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=${{ secrets.NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY }}
|
||||
NEXT_PUBLIC_GA_ID=${{ secrets.NEXT_PUBLIC_GA_ID }}
|
||||
NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=${{ secrets.NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL }}
|
||||
EOF
|
||||
- name: Install dependencies
|
||||
run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -83,5 +83,5 @@ fabric.properties
|
||||
|
||||
.env.local
|
||||
.DS_Store
|
||||
.idea
|
||||
.idea/*
|
||||
.husky/_/
|
||||
|
||||
5
.idea/real-time-fund.iml
generated
5
.idea/real-time-fund.iml
generated
@@ -1,7 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.cursor" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.trae" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
|
||||
@@ -5,10 +5,12 @@ ARG NEXT_PUBLIC_SUPABASE_URL
|
||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ARG NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY
|
||||
ARG NEXT_PUBLIC_GA_ID
|
||||
ARG NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ENV NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=$NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY
|
||||
ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID
|
||||
ENV NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=$NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL
|
||||
COPY package*.json ./
|
||||
RUN npm install --legacy-peer-deps
|
||||
COPY . .
|
||||
@@ -21,6 +23,7 @@ ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ENV NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=$NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY
|
||||
ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID
|
||||
ENV NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=$NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/.next ./.next
|
||||
|
||||
@@ -48,7 +48,8 @@
|
||||
- `NEXT_PUBLIC_Supabase_URL`:Supabase 项目 URL
|
||||
- `NEXT_PUBLIC_Supabase_ANON_KEY`:Supabase 匿名公钥
|
||||
- `NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY`:Web3Forms Access Key
|
||||
- `NEXT_PUBLIC_GA_ID`:Google Analytics Measurement ID(形如 `G-xxxx`)
|
||||
- `NEXT_PUBLIC_GA_ID`:Google Analytics Measurement ID(如 `G-xxxx`)
|
||||
- `NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL`:GitHub 最新 Release 接口地址,用于在页面中展示“发现新版本”提示(如:`https://api.github.com/repos/hzm0321/real-time-fund/releases/latest`)
|
||||
|
||||
注:如不使用登录、反馈或 GA 统计功能,可不设置对应变量
|
||||
|
||||
@@ -71,7 +72,7 @@
|
||||
|
||||
3. 修改接收到的邮件为验证码
|
||||
|
||||
在 Supabase控制台 → Authentication → Email → Confirm sign up,选择 `{{.token}}`。
|
||||
在 Supabase控制台 → Authentication → Email Templates 中,选择 **Magic Link** 模板进行编辑,在邮件正文中使用变量 `{{ .Token }}` 展示验证码。
|
||||
|
||||
4. 修改验证码位数
|
||||
|
||||
@@ -86,7 +87,7 @@
|
||||
|
||||
本项目已配置 GitHub Actions。每次推送到 `main` 分支时,会自动执行构建并部署到 GitHub Pages。
|
||||
如需使用 GitHub Actions 部署,请在 GitHub 项目 Settings → Secrets and variables → Actions 中创建对应的 Repository secrets(字段名称与 `.env.local` 保持一致)。
|
||||
包括:`NEXT_PUBLIC_Supabase_URL`、`NEXT_PUBLIC_Supabase_ANON_KEY`、`NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY`、`NEXT_PUBLIC_GA_ID`。
|
||||
包括:`NEXT_PUBLIC_Supabase_URL`、`NEXT_PUBLIC_Supabase_ANON_KEY`、`NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY`、`NEXT_PUBLIC_GA_ID`、`NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL`。
|
||||
|
||||
若要手动构建:
|
||||
```bash
|
||||
|
||||
444
app/api/fund.js
444
app/api/fund.js
@@ -1,6 +1,7 @@
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import { isString } from 'lodash';
|
||||
import { cachedRequest, clearCachedRequest } from '../lib/cacheRequest';
|
||||
|
||||
dayjs.extend(utc);
|
||||
@@ -20,7 +21,7 @@ const nowInTz = () => dayjs().tz(TZ);
|
||||
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
|
||||
|
||||
export const loadScript = (url) => {
|
||||
if (typeof document === 'undefined' || !document.body) return Promise.resolve();
|
||||
if (typeof document === 'undefined' || !document.body) return Promise.resolve(null);
|
||||
|
||||
let cacheKey = url;
|
||||
try {
|
||||
@@ -69,9 +70,7 @@ export const loadScript = (url) => {
|
||||
clearCachedRequest(cacheKey);
|
||||
throw new Error(result?.error || '数据加载失败');
|
||||
}
|
||||
if (typeof window !== 'undefined' && result.apidata !== undefined) {
|
||||
window.apidata = result.apidata;
|
||||
}
|
||||
return result.apidata;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -79,9 +78,9 @@ export const fetchFundNetValue = async (code, date) => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const url = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=1&per=1&sdate=${date}&edate=${date}`;
|
||||
try {
|
||||
await loadScript(url);
|
||||
if (window.apidata && window.apidata.content) {
|
||||
const content = window.apidata.content;
|
||||
const apidata = await loadScript(url);
|
||||
if (apidata && apidata.content) {
|
||||
const content = apidata.content;
|
||||
if (content.includes('暂无数据')) return null;
|
||||
const rows = content.split('<tr>');
|
||||
for (const row of rows) {
|
||||
@@ -101,6 +100,81 @@ export const fetchFundNetValue = async (code, date) => {
|
||||
}
|
||||
};
|
||||
|
||||
const parseLatestNetValueFromLsjzContent = (content) => {
|
||||
if (!content || content.includes('暂无数据')) return null;
|
||||
const rowMatches = content.match(/<tr[\s\S]*?<\/tr>/gi) || [];
|
||||
for (const row of rowMatches) {
|
||||
const cells = row.match(/<td[^>]*>(.*?)<\/td>/gi) || [];
|
||||
if (!cells.length) continue;
|
||||
const getText = (td) => td.replace(/<[^>]+>/g, '').trim();
|
||||
const dateStr = getText(cells[0] || '');
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) continue;
|
||||
const navStr = getText(cells[1] || '');
|
||||
const nav = parseFloat(navStr);
|
||||
if (!Number.isFinite(nav)) continue;
|
||||
let growth = null;
|
||||
for (const c of cells) {
|
||||
const txt = getText(c);
|
||||
const m = txt.match(/([-+]?\d+(?:\.\d+)?)\s*%/);
|
||||
if (m) {
|
||||
growth = parseFloat(m[1]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { date: dateStr, nav, growth };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const extractHoldingsReportDate = (html) => {
|
||||
if (!html) return null;
|
||||
|
||||
// 优先匹配带有“报告期 / 截止日期”等关键字附近的日期
|
||||
const m1 = html.match(/(报告期|截止日期)[^0-9]{0,20}(\d{4}-\d{2}-\d{2})/);
|
||||
if (m1) return m1[2];
|
||||
|
||||
// 兜底:取文中出现的第一个 yyyy-MM-dd 格式日期
|
||||
const m2 = html.match(/(\d{4}-\d{2}-\d{2})/);
|
||||
return m2 ? m2[1] : null;
|
||||
};
|
||||
|
||||
const isLastQuarterReport = (reportDateStr) => {
|
||||
if (!reportDateStr) return false;
|
||||
|
||||
const report = dayjs(reportDateStr, 'YYYY-MM-DD');
|
||||
if (!report.isValid()) return false;
|
||||
|
||||
const now = nowInTz();
|
||||
const m = now.month(); // 0-11
|
||||
const q = Math.floor(m / 3); // 当前季度 0-3 => Q1-Q4
|
||||
|
||||
let lastQ;
|
||||
let year;
|
||||
if (q === 0) {
|
||||
// 当前为 Q1,则上一季度是上一年的 Q4
|
||||
lastQ = 3;
|
||||
year = now.year() - 1;
|
||||
} else {
|
||||
lastQ = q - 1;
|
||||
year = now.year();
|
||||
}
|
||||
|
||||
const quarterEnds = [
|
||||
{ month: 2, day: 31 }, // Q1 -> 03-31
|
||||
{ month: 5, day: 30 }, // Q2 -> 06-30
|
||||
{ month: 8, day: 30 }, // Q3 -> 09-30
|
||||
{ month: 11, day: 31 } // Q4 -> 12-31
|
||||
];
|
||||
|
||||
const { month: endMonth, day: endDay } = quarterEnds[lastQ];
|
||||
const lastQuarterEnd = dayjs(
|
||||
`${year}-${String(endMonth + 1).padStart(2, '0')}-${endDay}`,
|
||||
'YYYY-MM-DD'
|
||||
);
|
||||
|
||||
return report.isSame(lastQuarterEnd, 'day');
|
||||
};
|
||||
|
||||
export const fetchSmartFundNetValue = async (code, startDate) => {
|
||||
const today = nowInTz().startOf('day');
|
||||
let current = toTz(startDate).startOf('day');
|
||||
@@ -157,43 +231,33 @@ export const fetchFundDataFallback = async (c) => {
|
||||
});
|
||||
} catch (e) {
|
||||
}
|
||||
const tUrl = `https://qt.gtimg.cn/q=jj${c}`;
|
||||
const tScript = document.createElement('script');
|
||||
tScript.src = tUrl;
|
||||
tScript.onload = () => {
|
||||
const v = window[`v_jj${c}`];
|
||||
if (v && v.length > 5) {
|
||||
const p = v.split('~');
|
||||
const name = fundName || p[1] || `未知基金(${c})`;
|
||||
const dwjz = p[5];
|
||||
const zzl = parseFloat(p[7]);
|
||||
const jzrq = p[8] ? p[8].slice(0, 10) : '';
|
||||
if (dwjz) {
|
||||
try {
|
||||
const url = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${c}&page=1&per=1&sdate=&edate=`;
|
||||
const apidata = await loadScript(url);
|
||||
const content = apidata?.content || '';
|
||||
const latest = parseLatestNetValueFromLsjzContent(content);
|
||||
if (latest && latest.nav) {
|
||||
const name = fundName || `未知基金(${c})`;
|
||||
resolve({
|
||||
code: c,
|
||||
name: name,
|
||||
dwjz: dwjz,
|
||||
name,
|
||||
dwjz: String(latest.nav),
|
||||
gsz: null,
|
||||
gztime: null,
|
||||
jzrq: jzrq,
|
||||
jzrq: latest.date,
|
||||
gszzl: null,
|
||||
zzl: !isNaN(zzl) ? zzl : null,
|
||||
zzl: Number.isFinite(latest.growth) ? latest.growth : null,
|
||||
noValuation: true,
|
||||
holdings: []
|
||||
holdings: [],
|
||||
holdingsReportDate: null,
|
||||
holdingsIsLastQuarter: false
|
||||
});
|
||||
} else {
|
||||
reject(new Error('未能获取到基金数据'));
|
||||
}
|
||||
} else {
|
||||
reject(new Error('未能获取到基金数据'));
|
||||
}
|
||||
if (document.body.contains(tScript)) document.body.removeChild(tScript);
|
||||
};
|
||||
tScript.onerror = () => {
|
||||
if (document.body.contains(tScript)) document.body.removeChild(tScript);
|
||||
} catch (e) {
|
||||
reject(new Error('基金数据加载失败'));
|
||||
};
|
||||
document.body.appendChild(tScript);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -222,35 +286,43 @@ export const fetchFundData = async (c) => {
|
||||
jzrq: json.jzrq,
|
||||
gszzl: Number.isFinite(gszzlNum) ? gszzlNum : json.gszzl
|
||||
};
|
||||
const tencentPromise = new Promise((resolveT) => {
|
||||
const tUrl = `https://qt.gtimg.cn/q=jj${c}`;
|
||||
const tScript = document.createElement('script');
|
||||
tScript.src = tUrl;
|
||||
tScript.onload = () => {
|
||||
const v = window[`v_jj${c}`];
|
||||
if (v) {
|
||||
const p = v.split('~');
|
||||
const lsjzPromise = new Promise((resolveT) => {
|
||||
const url = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${c}&page=1&per=1&sdate=&edate=`;
|
||||
loadScript(url)
|
||||
.then((apidata) => {
|
||||
const content = apidata?.content || '';
|
||||
const latest = parseLatestNetValueFromLsjzContent(content);
|
||||
if (latest && latest.nav) {
|
||||
resolveT({
|
||||
dwjz: p[5],
|
||||
zzl: parseFloat(p[7]),
|
||||
jzrq: p[8] ? p[8].slice(0, 10) : ''
|
||||
dwjz: String(latest.nav),
|
||||
zzl: Number.isFinite(latest.growth) ? latest.growth : null,
|
||||
jzrq: latest.date
|
||||
});
|
||||
} else {
|
||||
resolveT(null);
|
||||
}
|
||||
if (document.body.contains(tScript)) document.body.removeChild(tScript);
|
||||
};
|
||||
tScript.onerror = () => {
|
||||
if (document.body.contains(tScript)) document.body.removeChild(tScript);
|
||||
resolveT(null);
|
||||
};
|
||||
document.body.appendChild(tScript);
|
||||
})
|
||||
.catch(() => resolveT(null));
|
||||
});
|
||||
const holdingsPromise = new Promise((resolveH) => {
|
||||
const holdingsUrl = `https://fundf10.eastmoney.com/FundArchivesDatas.aspx?type=jjcc&code=${c}&topline=10&year=&month=&_=${Date.now()}`;
|
||||
loadScript(holdingsUrl).then(async () => {
|
||||
const holdingsCacheKey = `fund_holdings_archives_${c}`;
|
||||
cachedRequest(
|
||||
() => loadScript(holdingsUrl),
|
||||
holdingsCacheKey,
|
||||
{ cacheTime: 60 * 60 * 1000 }
|
||||
).then(async (apidata) => {
|
||||
let holdings = [];
|
||||
const html = window.apidata?.content || '';
|
||||
const html = apidata?.content || '';
|
||||
const holdingsReportDate = extractHoldingsReportDate(html);
|
||||
const holdingsIsLastQuarter = isLastQuarterReport(holdingsReportDate);
|
||||
|
||||
// 如果不是上一季度末的披露数据,则不展示重仓(并避免继续解析/请求行情)
|
||||
if (!holdingsIsLastQuarter) {
|
||||
resolveH({ holdings: [], holdingsReportDate, holdingsIsLastQuarter: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const headerRow = (html.match(/<thead[\s\S]*?<tr[\s\S]*?<\/tr>[\s\S]*?<\/thead>/i) || [])[0] || '';
|
||||
const headerCells = (headerRow.match(/<th[\s\S]*?>([\s\S]*?)<\/th>/gi) || []).map(th => th.replace(/<[^>]*>/g, '').trim());
|
||||
let idxCode = -1, idxName = -1, idxWeight = -1;
|
||||
@@ -347,10 +419,15 @@ export const fetchFundData = async (c) => {
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
resolveH(holdings);
|
||||
}).catch(() => resolveH([]));
|
||||
resolveH({ holdings, holdingsReportDate, holdingsIsLastQuarter });
|
||||
}).catch(() => resolveH({ holdings: [], holdingsReportDate: null, holdingsIsLastQuarter: false }));
|
||||
});
|
||||
Promise.all([tencentPromise, holdingsPromise]).then(([tData, holdings]) => {
|
||||
Promise.all([lsjzPromise, holdingsPromise]).then(([tData, holdingsResult]) => {
|
||||
const {
|
||||
holdings,
|
||||
holdingsReportDate,
|
||||
holdingsIsLastQuarter
|
||||
} = holdingsResult || {};
|
||||
if (tData) {
|
||||
if (tData.jzrq && (!gzData.jzrq || tData.jzrq >= gzData.jzrq)) {
|
||||
gzData.dwjz = tData.dwjz;
|
||||
@@ -358,7 +435,12 @@ export const fetchFundData = async (c) => {
|
||||
gzData.zzl = tData.zzl;
|
||||
}
|
||||
}
|
||||
resolve({ ...gzData, holdings });
|
||||
resolve({
|
||||
...gzData,
|
||||
holdings,
|
||||
holdingsReportDate,
|
||||
holdingsIsLastQuarter
|
||||
});
|
||||
});
|
||||
};
|
||||
scriptGz.onerror = () => {
|
||||
@@ -432,7 +514,10 @@ export const fetchShanghaiIndexDate = async () => {
|
||||
};
|
||||
|
||||
export const fetchLatestRelease = async () => {
|
||||
const res = await fetch('https://api.github.com/repos/hzm0321/real-time-fund/releases/latest');
|
||||
const url = process.env.NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL;
|
||||
if (!url) return null;
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return {
|
||||
@@ -449,7 +534,140 @@ export const submitFeedback = async (formData) => {
|
||||
return response.json();
|
||||
};
|
||||
|
||||
let historyQueue = Promise.resolve();
|
||||
const PINGZHONGDATA_GLOBAL_KEYS = [
|
||||
'ishb',
|
||||
'fS_name',
|
||||
'fS_code',
|
||||
'fund_sourceRate',
|
||||
'fund_Rate',
|
||||
'fund_minsg',
|
||||
'stockCodes',
|
||||
'zqCodes',
|
||||
'stockCodesNew',
|
||||
'zqCodesNew',
|
||||
'syl_1n',
|
||||
'syl_6y',
|
||||
'syl_3y',
|
||||
'syl_1y',
|
||||
'Data_fundSharesPositions',
|
||||
'Data_netWorthTrend',
|
||||
'Data_ACWorthTrend',
|
||||
'Data_grandTotal',
|
||||
'Data_rateInSimilarType',
|
||||
'Data_rateInSimilarPersent',
|
||||
'Data_fluctuationScale',
|
||||
'Data_holderStructure',
|
||||
'Data_assetAllocation',
|
||||
'Data_performanceEvaluation',
|
||||
'Data_currentFundManager',
|
||||
'Data_buySedemption',
|
||||
'swithSameType',
|
||||
];
|
||||
|
||||
let pingzhongdataQueue = Promise.resolve();
|
||||
|
||||
const enqueuePingzhongdataLoad = (fn) => {
|
||||
const p = pingzhongdataQueue.then(fn, fn);
|
||||
// 避免队列被 reject 永久阻塞
|
||||
pingzhongdataQueue = p.catch(() => undefined);
|
||||
return p;
|
||||
};
|
||||
|
||||
const snapshotPingzhongdataGlobals = (fundCode) => {
|
||||
const out = {};
|
||||
for (const k of PINGZHONGDATA_GLOBAL_KEYS) {
|
||||
if (typeof window?.[k] === 'undefined') continue;
|
||||
try {
|
||||
out[k] = JSON.parse(JSON.stringify(window[k]));
|
||||
} catch (e) {
|
||||
out[k] = window[k];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fundCode: out.fS_code || fundCode,
|
||||
fundName: out.fS_name || '',
|
||||
...out,
|
||||
};
|
||||
};
|
||||
|
||||
const jsonpLoadPingzhongdata = (fundCode, timeoutMs = 10000) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof document === 'undefined' || !document.body) {
|
||||
reject(new Error('无浏览器环境'));
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `https://fund.eastmoney.com/pingzhongdata/${fundCode}.js?v=${Date.now()}`;
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.async = true;
|
||||
|
||||
let done = false;
|
||||
let timer = null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
script.onload = null;
|
||||
script.onerror = null;
|
||||
if (document.body.contains(script)) document.body.removeChild(script);
|
||||
};
|
||||
|
||||
timer = setTimeout(() => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
cleanup();
|
||||
reject(new Error('pingzhongdata 请求超时'));
|
||||
}, timeoutMs);
|
||||
|
||||
script.onload = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
const data = snapshotPingzhongdataGlobals(fundCode);
|
||||
cleanup();
|
||||
resolve(data);
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
cleanup();
|
||||
reject(new Error('pingzhongdata 加载失败'));
|
||||
};
|
||||
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchAndParsePingzhongdata = async (fundCode) => {
|
||||
// 使用 JSONP(script 注入) 方式获取并解析 pingzhongdata
|
||||
return enqueuePingzhongdataLoad(() => jsonpLoadPingzhongdata(fundCode));
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取并解析「基金走势图/资产等」数据(pingzhongdata)
|
||||
* 来源:https://fund.eastmoney.com/pingzhongdata/${fundCode}.js
|
||||
*/
|
||||
export const fetchFundPingzhongdata = async (fundCode, { cacheTime = 60 * 60 * 1000 } = {}) => {
|
||||
if (!fundCode) throw new Error('fundCode 不能为空');
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
throw new Error('无浏览器环境');
|
||||
}
|
||||
|
||||
const cacheKey = `pingzhongdata_${fundCode}`;
|
||||
|
||||
try {
|
||||
return await cachedRequest(
|
||||
() => fetchAndParsePingzhongdata(fundCode),
|
||||
cacheKey,
|
||||
{ cacheTime }
|
||||
);
|
||||
} catch (e) {
|
||||
clearCachedRequest(cacheKey);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchFundHistory = async (code, range = '1m') => {
|
||||
if (typeof window === 'undefined') return [];
|
||||
@@ -463,73 +681,65 @@ export const fetchFundHistory = async (code, range = '1m') => {
|
||||
case '6m': start = start.subtract(6, 'month'); break;
|
||||
case '1y': start = start.subtract(1, 'year'); break;
|
||||
case '3y': start = start.subtract(3, 'year'); break;
|
||||
case 'all': start = dayjs(0).tz(TZ); break;
|
||||
default: start = start.subtract(1, 'month');
|
||||
}
|
||||
|
||||
const sdate = start.format('YYYY-MM-DD');
|
||||
const edate = end.format('YYYY-MM-DD');
|
||||
const per = 49;
|
||||
// 业绩走势统一走 pingzhongdata.Data_netWorthTrend
|
||||
try {
|
||||
const pz = await fetchFundPingzhongdata(code);
|
||||
const trend = pz?.Data_netWorthTrend;
|
||||
if (Array.isArray(trend) && trend.length) {
|
||||
const startMs = start.startOf('day').valueOf();
|
||||
// end 可能是当日任意时刻,这里用 end-of-day 包含最后一天
|
||||
const endMs = end.endOf('day').valueOf();
|
||||
const out = trend
|
||||
.filter((d) => d && typeof d.x === 'number' && d.x >= startMs && d.x <= endMs)
|
||||
.map((d) => {
|
||||
const value = Number(d.y);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const date = dayjs(d.x).tz(TZ).format('YYYY-MM-DD');
|
||||
return { date, value };
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
historyQueue = historyQueue.then(async () => {
|
||||
let allData = [];
|
||||
let page = 1;
|
||||
let totalPages = 1;
|
||||
if (out.length) return out;
|
||||
}
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const parseFundTextWithLLM = async (text) => {
|
||||
const apiKey = 'sk-a72c4e279bc62a03cc105be6263d464c';
|
||||
if (!apiKey || !text) return null;
|
||||
|
||||
try {
|
||||
const parseContent = (content) => {
|
||||
if (!content) return [];
|
||||
const rows = content.split('<tr>');
|
||||
const data = [];
|
||||
for (const row of rows) {
|
||||
const cells = row.match(/<td[^>]*>(.*?)<\/td>/g);
|
||||
if (cells && cells.length >= 2) {
|
||||
const dateStr = cells[0].replace(/<[^>]+>/g, '').trim();
|
||||
const valStr = cells[1].replace(/<[^>]+>/g, '').trim();
|
||||
const val = parseFloat(valStr);
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr) && !isNaN(val)) {
|
||||
data.push({ date: dateStr, value: val });
|
||||
}
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
const response = await fetch('https://apis.iflow.cn/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'qwen3-max',
|
||||
messages: [
|
||||
{ role: 'system', content: "你是一个基金文本解析助手。请从提供的OCR文本中执行以下任务:\n抽取所有基金信息,包括:基金名称:中文字符串(可含英文或括号),名称后常跟随金额数字。基金代码:6位数字(如果存在)。持有金额:数字格式(可能含千分位逗号或小数,如果存在)。持有收益:数字格式(可能含千分位逗号或小数,如果存在)。忽略无关文本。输出格式:以JSON数组形式返回结果,每个基金信息为一个对象,包含以下字段:基金名称(必填,字符串)基金代码(可选,字符串,不存在时为空字符串)持有金额(可选,字符串,不存在时为空字符串)持有收益(可选,字符串,不存在时为空字符串)示例输出:[{'fundName':'华夏成长混合','fundCode':'000001','holdAmounts':'50,000.00','holdGains':'2,500.00'},{'fundName':'易方达消费行业','fundCode':'','holdAmounts':'10,000.00','holdGains':'}]。除了示例输出的内容外,不要输出任何多余内容"},
|
||||
{ role: 'user', content: text }
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 2000
|
||||
})
|
||||
});
|
||||
|
||||
// Fetch first page to get metadata
|
||||
const firstUrl = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=${page}&per=${per}&sdate=${sdate}&edate=${edate}`;
|
||||
await loadScript(firstUrl);
|
||||
|
||||
if (!window.apidata || !window.apidata.content || window.apidata.content.includes('暂无数据')) {
|
||||
resolve([]);
|
||||
return;
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse total pages
|
||||
if (window.apidata.pages) {
|
||||
totalPages = parseInt(window.apidata.pages, 10) || 1;
|
||||
}
|
||||
|
||||
allData = allData.concat(parseContent(window.apidata.content));
|
||||
|
||||
// Fetch remaining pages
|
||||
for (page = 2; page <= totalPages; page++) {
|
||||
const nextUrl = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=${page}&per=${per}&sdate=${sdate}&edate=${edate}`;
|
||||
await loadScript(nextUrl);
|
||||
if (window.apidata && window.apidata.content) {
|
||||
allData = allData.concat(parseContent(window.apidata.content));
|
||||
}
|
||||
}
|
||||
|
||||
// The data comes in reverse chronological order (newest first), so we need to reverse it for the chart (oldest first)
|
||||
resolve(allData.reverse());
|
||||
|
||||
const data = await response.json();
|
||||
return data?.choices?.[0]?.message?.content || null;
|
||||
} catch (e) {
|
||||
console.error('Fetch history error:', e);
|
||||
resolve([]);
|
||||
return null;
|
||||
}
|
||||
}).catch((e) => {
|
||||
console.error('Queue error:', e);
|
||||
resolve([]);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1770335913293" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1562" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200">
|
||||
<path d="M512 85.333333C276.266667 85.333333 85.333333 276.266667 85.333333 512a426.410667 426.410667 0 0 0 291.754667 404.821333c21.333333 3.712 29.312-9.088 29.312-20.309333 0-10.112-0.554667-43.690667-0.554667-79.445333-107.178667 19.754667-134.912-26.112-143.445333-50.133334-4.821333-12.288-25.6-50.133333-43.733333-60.288-14.933333-7.978667-36.266667-27.733333-0.554667-28.245333 33.621333-0.554667 57.6 30.933333 65.621333 43.733333 38.4 64.512 99.754667 46.378667 124.245334 35.2 3.754667-27.733333 14.933333-46.378667 27.221333-57.045333-94.933333-10.666667-194.133333-47.488-194.133333-210.688 0-46.421333 16.512-84.778667 43.733333-114.688-4.266667-10.666667-19.2-54.4 4.266667-113.066667 0 0 35.712-11.178667 117.333333 43.776a395.946667 395.946667 0 0 1 106.666667-14.421333c36.266667 0 72.533333 4.778667 106.666666 14.378667 81.578667-55.466667 117.333333-43.690667 117.333334-43.690667 23.466667 58.666667 8.533333 102.4 4.266666 113.066667 27.178667 29.866667 43.733333 67.712 43.733334 114.645333 0 163.754667-99.712 200.021333-194.645334 210.688 15.445333 13.312 28.8 38.912 28.8 78.933333 0 57.045333-0.554667 102.912-0.554666 117.333334 0 11.178667 8.021333 24.490667 29.354666 20.224A427.349333 427.349333 0 0 0 938.666667 512c0-235.733333-190.933333-426.666667-426.666667-426.666667z" fill="#000000" p-id="1563"></path>
|
||||
<path d="M512 85.333333C276.266667 85.333333 85.333333 276.266667 85.333333 512a426.410667 426.410667 0 0 0 291.754667 404.821333c21.333333 3.712 29.312-9.088 29.312-20.309333 0-10.112-0.554667-43.690667-0.554667-79.445333-107.178667 19.754667-134.912-26.112-143.445333-50.133334-4.821333-12.288-25.6-50.133333-43.733333-60.288-14.933333-7.978667-36.266667-27.733333-0.554667-28.245333 33.621333-0.554667 57.6 30.933333 65.621333 43.733333 38.4 64.512 99.754667 46.378667 124.245334 35.2 3.754667-27.733333 14.933333-46.378667 27.221333-57.045333-94.933333-10.666667-194.133333-47.488-194.133333-210.688 0-46.421333 16.512-84.778667 43.733333-114.688-4.266667-10.666667-19.2-54.4 4.266667-113.066667 0 0 35.712-11.178667 117.333333 43.776a395.946667 395.946667 0 0 1 106.666667-14.421333c36.266667 0 72.533333 4.778667 106.666666 14.378667 81.578667-55.466667 117.333333-43.690667 117.333334-43.690667 23.466667 58.666667 8.533333 102.4 4.266666 113.066667 27.178667 29.866667 43.733333 67.712 43.733334 114.645333 0 163.754667-99.712 200.021333-194.645334 210.688 15.445333 13.312 28.8 38.912 28.8 78.933333 0 57.045333-0.554667 102.912-0.554666 117.333334 0 11.178667 8.021333 24.490667 29.354666 20.224A427.349333 427.349333 0 0 0 938.666667 512c0-235.733333-190.933333-426.666667-426.666667-426.666667z" fill="#d2d2d2" p-id="1563"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 61 KiB |
@@ -187,6 +187,7 @@ export default function AddHistoryModal({ fund, onClose, onConfirm }) {
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
className="input"
|
||||
value={amount}
|
||||
onChange={handleAmountChange}
|
||||
@@ -196,6 +197,10 @@ export default function AddHistoryModal({ fund, onClose, onConfirm }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="muted" style={{ fontSize: '11px', lineHeight: 1.5, marginBottom: 16, paddingTop: 12, borderTop: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
*此处补录的买入/卖出仅作记录展示,不会改变当前持仓金额与份额;实际持仓请在持仓设置中维护。
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="button primary full-width"
|
||||
onClick={handleSubmit}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v8';
|
||||
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v13';
|
||||
|
||||
export default function Announcement() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
@@ -16,6 +16,16 @@ export default function Announcement() {
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
// 清理历史 ANNOUNCEMENT_KEY
|
||||
const keysToRemove = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith('hasClosedAnnouncement_v') && key !== ANNOUNCEMENT_KEY) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach((k) => localStorage.removeItem(k));
|
||||
|
||||
localStorage.setItem(ANNOUNCEMENT_KEY, 'true');
|
||||
setIsVisible(false);
|
||||
};
|
||||
@@ -52,6 +62,8 @@ export default function Announcement() {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
maxHeight: 'calc(100dvh - 40px)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div className="title" style={{ display: 'flex', alignItems: 'center', gap: '12px', fontWeight: 700, fontSize: '18px', color: 'var(--accent)' }}>
|
||||
@@ -62,18 +74,13 @@ export default function Announcement() {
|
||||
</svg>
|
||||
<span>公告</span>
|
||||
</div>
|
||||
<div style={{ color: 'var(--text)', lineHeight: '1.6', fontSize: '15px' }}>
|
||||
为了增加更多用户方便访问, 新增国内加速地址:<a className="link-button"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--primary)', textDecoration: 'underline', padding: '0 4px', fontWeight: 600 }} href="https://fund.cc.cd/">https://fund.cc.cd/</a>
|
||||
<p>节后第一次更新内容如下:</p>
|
||||
<p>1. OCR 识别截图导入基金。</p>
|
||||
<p>2. 基金历史曲线图。</p>
|
||||
<p>3. 买入、卖出历史记录。</p>
|
||||
以下内容会在近期更新:
|
||||
<p>1. 定投。</p>
|
||||
<p>2. 自定义布局。</p>
|
||||
<div style={{ color: 'var(--text)', lineHeight: '1.6', fontSize: '15px', overflowY: 'auto', minHeight: 0, flex: 1, paddingRight: '4px' }}>
|
||||
<p>v0.2.2 版本更新内容如下:</p>
|
||||
<p>1. 新增 ios pwa 应用支持。</p>
|
||||
<p>2. 引入 shadcn ui 组件库,逐步调整项目样式。</p>
|
||||
<p>3. 列表模式表头固定。</p>
|
||||
<p>4. 列表模式点击名称,展示基金详情弹框。</p>
|
||||
<p>注:强烈建议苹果用户通过 Safari 浏览器→分享→添加应用到主屏幕,实现保存网页成APP效果。(安卓同理)</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}>
|
||||
|
||||
@@ -73,20 +73,8 @@ export function DatePicker({ value, onChange }) {
|
||||
return (
|
||||
<div className="date-picker" style={{ position: 'relative' }} onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className="input-trigger"
|
||||
className="date-picker-trigger"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 12px',
|
||||
height: '40px',
|
||||
background: 'rgba(0,0,0,0.2)',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
border: '1px solid transparent',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<span>{value || '选择日期'}</span>
|
||||
<CalendarIcon width="16" height="16" className="muted" />
|
||||
@@ -98,7 +86,7 @@ export function DatePicker({ value, onChange }) {
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
className="glass card"
|
||||
className="date-picker-dropdown glass card"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
@@ -106,10 +94,7 @@ export function DatePicker({ value, onChange }) {
|
||||
width: '100%',
|
||||
marginTop: 8,
|
||||
padding: 12,
|
||||
zIndex: 10,
|
||||
background: 'rgba(30, 41, 59, 0.95)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)'
|
||||
zIndex: 10
|
||||
}}
|
||||
>
|
||||
<div className="calendar-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
@@ -141,26 +126,8 @@ export function DatePicker({ value, onChange }) {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`date-picker-cell ${isSelected ? 'selected' : ''} ${isToday ? 'today' : ''} ${isFuture ? 'future' : ''}`}
|
||||
onClick={(e) => !isFuture && handleSelect(e, d)}
|
||||
style={{
|
||||
height: 28,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '13px',
|
||||
borderRadius: '6px',
|
||||
cursor: isFuture ? 'not-allowed' : 'pointer',
|
||||
background: isSelected ? 'var(--primary)' : isToday ? 'rgba(255,255,255,0.1)' : 'transparent',
|
||||
color: isFuture ? 'var(--muted)' : isSelected ? '#000' : 'var(--text)',
|
||||
fontWeight: isSelected || isToday ? 600 : 400,
|
||||
opacity: isFuture ? 0.3 : 1
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected && !isFuture) e.currentTarget.style.background = 'rgba(255,255,255,0.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected && !isFuture) e.currentTarget.style.background = isToday ? 'rgba(255,255,255,0.1)' : 'transparent';
|
||||
}}
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
@@ -271,6 +238,7 @@ export function NumericInput({ value, onChange, step = 1, min = 0, placeholder }
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
className="input no-zoom"
|
||||
value={value}
|
||||
|
||||
@@ -1,43 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { TrashIcon } from './Icons';
|
||||
|
||||
export default function ConfirmModal({ title, message, onConfirm, onCancel, confirmText = "确定删除" }) {
|
||||
export default function ConfirmModal({
|
||||
title,
|
||||
message,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmText = '确定删除',
|
||||
}) {
|
||||
const handleOpenChange = (open) => {
|
||||
if (!open) onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
}}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
style={{ zIndex: 10002 }}
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="max-w-[400px] flex flex-col gap-5 p-6"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="glass card modal"
|
||||
style={{ maxWidth: '400px' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="title" style={{ marginBottom: 12 }}>
|
||||
<TrashIcon width="20" height="20" className="danger" />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
<p className="muted" style={{ marginBottom: 24, fontSize: '14px', lineHeight: '1.6' }}>
|
||||
<DialogHeader className="flex flex-row items-center gap-3 text-left">
|
||||
<TrashIcon width="20" height="20" className="shrink-0 text-[var(--danger)]" />
|
||||
<DialogTitle className="flex-1 text-base font-semibold">{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="text-left text-sm leading-relaxed text-[var(--muted-foreground)]">
|
||||
{message}
|
||||
</p>
|
||||
<div className="row" style={{ gap: 12 }}>
|
||||
<button className="button secondary" onClick={onCancel} style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>取消</button>
|
||||
<button className="button danger" onClick={onConfirm} style={{ flex: 1 }}>{confirmText}</button>
|
||||
</DialogDescription>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<button
|
||||
type="button"
|
||||
className="button secondary min-w-0 flex-1 cursor-pointer h-auto min-h-[48px] py-3 sm:h-11 sm:min-h-0 sm:py-0"
|
||||
onClick={onCancel}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button danger min-w-0 flex-1 cursor-pointer h-auto min-h-[48px] py-3 sm:h-11 sm:min-h-0 sm:py-0"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
362
app/components/DcaModal.jsx
Normal file
362
app/components/DcaModal.jsx
Normal file
@@ -0,0 +1,362 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import { DatePicker, NumericInput } from './Common';
|
||||
import { isNumber } from 'lodash';
|
||||
import { CloseIcon } from './Icons';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const DEFAULT_TZ = 'Asia/Shanghai';
|
||||
const getBrowserTimeZone = () => {
|
||||
if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return tz || DEFAULT_TZ;
|
||||
}
|
||||
return DEFAULT_TZ;
|
||||
};
|
||||
const TZ = getBrowserTimeZone();
|
||||
dayjs.tz.setDefault(TZ);
|
||||
const nowInTz = () => dayjs().tz(TZ);
|
||||
const formatDate = (input) => dayjs.tz(input, TZ).format('YYYY-MM-DD');
|
||||
|
||||
const CYCLES = [
|
||||
{ value: 'daily', label: '每日' },
|
||||
{ value: 'weekly', label: '每周' },
|
||||
{ value: 'biweekly', label: '每两周' },
|
||||
{ value: 'monthly', label: '每月' }
|
||||
];
|
||||
|
||||
const WEEKDAY_OPTIONS = [
|
||||
{ value: 1, label: '周一' },
|
||||
{ value: 2, label: '周二' },
|
||||
{ value: 3, label: '周三' },
|
||||
{ value: 4, label: '周四' },
|
||||
{ value: 5, label: '周五' }
|
||||
];
|
||||
|
||||
const computeFirstDate = (cycle, weeklyDay, monthlyDay) => {
|
||||
const today = nowInTz().startOf('day');
|
||||
|
||||
if (cycle === 'weekly' || cycle === 'biweekly') {
|
||||
const todayDay = today.day(); // 0-6, 1=周一
|
||||
let target = isNumber(weeklyDay) ? weeklyDay : todayDay;
|
||||
if (target < 1 || target > 5) {
|
||||
// 如果当前是周末且未设定,默认周一
|
||||
target = 1;
|
||||
}
|
||||
let candidate = today;
|
||||
for (let i = 0; i < 14; i += 1) {
|
||||
if (candidate.day() === target && !candidate.isBefore(today)) {
|
||||
break;
|
||||
}
|
||||
candidate = candidate.add(1, 'day');
|
||||
}
|
||||
return candidate.format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
if (cycle === 'monthly') {
|
||||
const baseDay = today.date();
|
||||
const day =
|
||||
isNumber(monthlyDay) && monthlyDay >= 1 && monthlyDay <= 28
|
||||
? monthlyDay
|
||||
: Math.min(28, baseDay);
|
||||
|
||||
let candidate = today.date(day);
|
||||
if (candidate.isBefore(today)) {
|
||||
candidate = today.add(1, 'month').date(day);
|
||||
}
|
||||
return candidate.format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
return formatDate(today);
|
||||
};
|
||||
|
||||
export default function DcaModal({ fund, plan, onClose, onConfirm }) {
|
||||
const [amount, setAmount] = useState('');
|
||||
const [feeRate, setFeeRate] = useState('0');
|
||||
const [cycle, setCycle] = useState('monthly');
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [weeklyDay, setWeeklyDay] = useState(() => {
|
||||
const d = nowInTz().day();
|
||||
return d >= 1 && d <= 5 ? d : 1;
|
||||
});
|
||||
const [monthlyDay, setMonthlyDay] = useState(() => {
|
||||
const d = nowInTz().date();
|
||||
return d >= 1 && d <= 28 ? d : 1;
|
||||
});
|
||||
const [firstDate, setFirstDate] = useState(() => computeFirstDate('monthly', null, null));
|
||||
const monthlyDayRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!plan) {
|
||||
// 新建定投时,以当前默认 weeklyDay/monthlyDay 计算一次首扣日期
|
||||
setFirstDate(computeFirstDate('monthly', weeklyDay, monthlyDay));
|
||||
return;
|
||||
}
|
||||
if (plan.amount != null) {
|
||||
setAmount(String(plan.amount));
|
||||
}
|
||||
if (plan.feeRate != null) {
|
||||
setFeeRate(String(plan.feeRate));
|
||||
}
|
||||
if (typeof plan.enabled === 'boolean') {
|
||||
setEnabled(plan.enabled);
|
||||
}
|
||||
if (isNumber(plan.weeklyDay)) {
|
||||
setWeeklyDay(plan.weeklyDay);
|
||||
}
|
||||
if (isNumber(plan.monthlyDay)) {
|
||||
setMonthlyDay(plan.monthlyDay);
|
||||
}
|
||||
if (plan.cycle && CYCLES.some(c => c.value === plan.cycle)) {
|
||||
setCycle(plan.cycle);
|
||||
setFirstDate(plan.firstDate || computeFirstDate(plan.cycle, plan.weeklyDay, plan.monthlyDay));
|
||||
} else {
|
||||
setFirstDate(plan.firstDate || computeFirstDate('monthly', null, null));
|
||||
}
|
||||
}, [plan]);
|
||||
|
||||
useEffect(() => {
|
||||
setFirstDate(computeFirstDate(cycle, weeklyDay, monthlyDay));
|
||||
}, [cycle, weeklyDay, monthlyDay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cycle !== 'monthly') return;
|
||||
if (monthlyDayRef.current) {
|
||||
try {
|
||||
monthlyDayRef.current.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
} catch {}
|
||||
}
|
||||
}, [cycle, monthlyDay]);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const amt = parseFloat(amount);
|
||||
const rate = parseFloat(feeRate);
|
||||
if (!fund?.code) return;
|
||||
if (!amt || amt <= 0) return;
|
||||
if (isNaN(rate) || rate < 0) return;
|
||||
if (!cycle) return;
|
||||
if ((cycle === 'weekly' || cycle === 'biweekly') && (weeklyDay < 1 || weeklyDay > 5)) return;
|
||||
if (cycle === 'monthly' && (monthlyDay < 1 || monthlyDay > 28)) return;
|
||||
|
||||
onConfirm?.({
|
||||
type: 'dca',
|
||||
fundCode: fund.code,
|
||||
fundName: fund.name,
|
||||
amount: amt,
|
||||
feeRate: rate,
|
||||
cycle,
|
||||
firstDate,
|
||||
weeklyDay: cycle === 'weekly' || cycle === 'biweekly' ? weeklyDay : null,
|
||||
monthlyDay: cycle === 'monthly' ? monthlyDay : null,
|
||||
enabled
|
||||
});
|
||||
};
|
||||
|
||||
const isValid = () => {
|
||||
const amt = parseFloat(amount);
|
||||
const rate = parseFloat(feeRate);
|
||||
if (!fund?.code || !cycle || !firstDate) return false;
|
||||
if (!(amt > 0) || isNaN(rate) || rate < 0) return false;
|
||||
if ((cycle === 'weekly' || cycle === 'biweekly') && (weeklyDay < 1 || weeklyDay > 5)) return false;
|
||||
if (cycle === 'monthly' && (monthlyDay < 1 || monthlyDay > 28)) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="定投设置"
|
||||
onClick={onClose}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="glass card modal dca-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ maxWidth: '420px' }}
|
||||
>
|
||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: '20px' }}>🔁</span>
|
||||
<span>定投</span>
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||
<CloseIcon width="20" height="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="fund-name" style={{ fontWeight: 600, fontSize: '16px', marginBottom: 4 }}>{fund?.name}</div>
|
||||
<div className="muted" style={{ fontSize: '12px' }}>#{fund?.code}</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group" style={{ marginBottom: 8 }}>
|
||||
<label className="muted" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: '14px' }}>
|
||||
<span>是否启用定投</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEnabled(v => !v)}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6
|
||||
}}
|
||||
>
|
||||
<span className={`dca-toggle-track ${enabled ? 'enabled' : ''}`}>
|
||||
<span className="dca-toggle-thumb" style={{ left: enabled ? 16 : 2 }} />
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: enabled ? 'var(--primary)' : 'var(--muted)' }}>
|
||||
{enabled ? '已启用' : '未启用'}
|
||||
</span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||
定投金额 (¥) <span style={{ color: 'var(--danger)' }}>*</span>
|
||||
</label>
|
||||
<div style={{ border: (!amount || parseFloat(amount) <= 0) ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
|
||||
<NumericInput
|
||||
value={amount}
|
||||
onChange={setAmount}
|
||||
step={100}
|
||||
min={0}
|
||||
placeholder="请输入每次定投金额"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ gap: 12, marginBottom: 16 }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||
买入费率 (%) <span style={{ color: 'var(--danger)' }}>*</span>
|
||||
</label>
|
||||
<div style={{ border: feeRate === '' ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
|
||||
<NumericInput
|
||||
value={feeRate}
|
||||
onChange={setFeeRate}
|
||||
step={0.01}
|
||||
min={0}
|
||||
placeholder="0.12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||
定投周期 <span style={{ color: 'var(--danger)' }}>*</span>
|
||||
</label>
|
||||
<div className="dca-option-group row" style={{ gap: 4 }}>
|
||||
{CYCLES.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`dca-option-btn ${cycle === opt.value ? 'active' : ''}`}
|
||||
onClick={() => setCycle(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(cycle === 'weekly' || cycle === 'biweekly') && (
|
||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||
扣款星期 <span style={{ color: 'var(--danger)' }}>*</span>
|
||||
</label>
|
||||
<div className="dca-option-group row" style={{ gap: 4 }}>
|
||||
{WEEKDAY_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`dca-option-btn dca-weekday-btn ${weeklyDay === opt.value ? 'active' : ''}`}
|
||||
onClick={() => setWeeklyDay(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cycle === 'monthly' && (
|
||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||
扣款日 <span style={{ color: 'var(--danger)' }}>*</span>
|
||||
</label>
|
||||
<div className="dca-monthly-day-group scrollbar-y-styled">
|
||||
{Array.from({ length: 28 }).map((_, idx) => {
|
||||
const day = idx + 1;
|
||||
const active = monthlyDay === day;
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
ref={active ? monthlyDayRef : null}
|
||||
type="button"
|
||||
className={`dca-option-btn dca-monthly-btn ${active ? 'active' : ''}`}
|
||||
onClick={() => setMonthlyDay(day)}
|
||||
>
|
||||
{day}日
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 4, fontSize: '14px' }}>
|
||||
首次扣款日期
|
||||
</label>
|
||||
<div className="dca-first-date-display">
|
||||
{firstDate}
|
||||
</div>
|
||||
<div className="muted" style={{ marginTop: 4, fontSize: 12 }}>
|
||||
* 基于当前日期和所选周期/扣款日自动计算:每日=当天;每周/每两周=从今天起最近的所选工作日;每月=从今天起最近的所选日期(1-28日)。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ gap: 12, marginTop: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="button secondary dca-cancel-btn"
|
||||
onClick={onClose}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="button"
|
||||
disabled={!isValid()}
|
||||
style={{ flex: 1, opacity: isValid() ? 1 : 0.6 }}
|
||||
>
|
||||
保存定投
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
33
app/components/EmptyStateCard.jsx
Normal file
33
app/components/EmptyStateCard.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
export default function EmptyStateCard({
|
||||
fundsLength = 0,
|
||||
currentTab = 'all',
|
||||
onAddToGroup,
|
||||
}) {
|
||||
const isEmpty = fundsLength === 0;
|
||||
const isGroupTab = currentTab !== 'all' && currentTab !== 'fav';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="glass card empty"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '60px 20px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '48px', marginBottom: 16, opacity: 0.5 }}>📂</div>
|
||||
<div className="muted" style={{ marginBottom: 20 }}>
|
||||
{isEmpty ? '尚未添加基金' : '该分组下暂无数据'}
|
||||
</div>
|
||||
{isGroupTab && fundsLength > 0 && (
|
||||
<button className="button" onClick={onAddToGroup}>
|
||||
添加基金到此分组
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
app/components/FitText.jsx
Normal file
87
app/components/FitText.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* 根据容器宽度动态缩小字体,使内容不溢出。
|
||||
* 使用 ResizeObserver 监听容器宽度,内容超出时按比例缩小 fontSize,不低于 minFontSize。
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {React.ReactNode} props.children - 要显示的文本(会单行、不换行)
|
||||
* @param {number} [props.maxFontSize=14] - 最大字号(px)
|
||||
* @param {number} [props.minFontSize=10] - 最小字号(px),再窄也不低于此值
|
||||
* @param {string} [props.className] - 外层容器 className
|
||||
* @param {Object} [props.style] - 外层容器 style(宽度由父级决定,建议父级有明确宽度)
|
||||
* @param {string} [props.as='span'] - 外层容器标签 'span' | 'div'
|
||||
*/
|
||||
export default function FitText({
|
||||
children,
|
||||
maxFontSize = 14,
|
||||
minFontSize = 10,
|
||||
className,
|
||||
style = {},
|
||||
as: Tag = 'span',
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const contentRef = useRef(null);
|
||||
|
||||
const adjust = () => {
|
||||
const container = containerRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!container || !content) return;
|
||||
|
||||
const containerWidth = container.clientWidth;
|
||||
if (containerWidth <= 0) return;
|
||||
|
||||
// 先恢复到最大字号再测量,确保在「未缩放」状态下取到真实内容宽度
|
||||
content.style.fontSize = `${maxFontSize}px`;
|
||||
|
||||
const run = () => {
|
||||
const contentWidth = content.scrollWidth;
|
||||
if (contentWidth <= 0) return;
|
||||
let size = maxFontSize;
|
||||
if (contentWidth > containerWidth) {
|
||||
size = (containerWidth / contentWidth) * maxFontSize;
|
||||
size = Math.max(minFontSize, Math.min(maxFontSize, size));
|
||||
}
|
||||
content.style.fontSize = `${size}px`;
|
||||
};
|
||||
|
||||
requestAnimationFrame(run);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
adjust();
|
||||
const ro = new ResizeObserver(adjust);
|
||||
ro.observe(container);
|
||||
return () => ro.disconnect();
|
||||
}, [children, maxFontSize, minFontSize]);
|
||||
|
||||
return (
|
||||
<Tag
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
ref={contentRef}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: 'inherit',
|
||||
fontSize: `${maxFontSize}px`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
465
app/components/FundCard.jsx
Normal file
465
app/components/FundCard.jsx
Normal file
@@ -0,0 +1,465 @@
|
||||
'use client';
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
||||
import { isNumber, isString } from 'lodash';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Stat } from './Common';
|
||||
import FundTrendChart from './FundTrendChart';
|
||||
import FundIntradayChart from './FundIntradayChart';
|
||||
import {
|
||||
ChevronIcon,
|
||||
ExitIcon,
|
||||
SettingsIcon,
|
||||
StarIcon,
|
||||
SwitchIcon,
|
||||
TrashIcon,
|
||||
} from './Icons';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(isSameOrAfter);
|
||||
|
||||
const DEFAULT_TZ = 'Asia/Shanghai';
|
||||
const getBrowserTimeZone = () => {
|
||||
if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return tz || DEFAULT_TZ;
|
||||
}
|
||||
return DEFAULT_TZ;
|
||||
};
|
||||
const TZ = getBrowserTimeZone();
|
||||
const toTz = (input) => (input ? dayjs.tz(input, TZ) : dayjs().tz(TZ));
|
||||
|
||||
export default function FundCard({
|
||||
fund: f,
|
||||
todayStr,
|
||||
currentTab,
|
||||
favorites,
|
||||
dcaPlans,
|
||||
holdings,
|
||||
percentModes,
|
||||
valuationSeries,
|
||||
collapsedCodes,
|
||||
collapsedTrends,
|
||||
transactions,
|
||||
theme,
|
||||
isTradingDay,
|
||||
refreshing,
|
||||
getHoldingProfit,
|
||||
onRemoveFromGroup,
|
||||
onToggleFavorite,
|
||||
onRemoveFund,
|
||||
onHoldingClick,
|
||||
onActionClick,
|
||||
onPercentModeToggle,
|
||||
onToggleCollapse,
|
||||
onToggleTrendCollapse,
|
||||
layoutMode = 'card', // 'card' | 'drawer',drawer 时前10重仓与业绩走势以 Tabs 展示
|
||||
}) {
|
||||
const holding = holdings[f?.code];
|
||||
const profit = getHoldingProfit?.(f, holding) ?? null;
|
||||
const hasHoldings = f.holdingsIsLastQuarter && Array.isArray(f.holdings) && f.holdings.length > 0;
|
||||
|
||||
const style = layoutMode === 'drawer' ? {
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
background: 'transparent',
|
||||
} : {};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="glass card"
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div className="row" style={{ marginBottom: 10 }}>
|
||||
<div className="title">
|
||||
{currentTab !== 'all' && currentTab !== 'fav' ? (
|
||||
<button
|
||||
className="icon-button fav-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveFromGroup?.(f.code);
|
||||
}}
|
||||
title="从当前分组移除"
|
||||
>
|
||||
<ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={`icon-button fav-button ${favorites?.has(f.code) ? 'active' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleFavorite?.(f.code);
|
||||
}}
|
||||
title={favorites?.has(f.code) ? '取消自选' : '添加自选'}
|
||||
>
|
||||
<StarIcon width="18" height="18" filled={favorites?.has(f.code)} />
|
||||
</button>
|
||||
)}
|
||||
<div className="title-text">
|
||||
<span
|
||||
className="name-text"
|
||||
title={f.jzrq === todayStr ? '今日净值已更新' : ''}
|
||||
>
|
||||
{f.name}
|
||||
</span>
|
||||
<span className="muted">
|
||||
#{f.code}
|
||||
{dcaPlans?.[f.code]?.enabled === true && <span className="dca-indicator">定</span>}
|
||||
{f.jzrq === todayStr && <span className="updated-indicator">✓</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="actions">
|
||||
<div className="badge-v">
|
||||
<span>{f.noValuation ? '净值日期' : '估值时间'}</span>
|
||||
<strong>{f.noValuation ? (f.jzrq || '-') : (f.gztime || f.time || '-')}</strong>
|
||||
</div>
|
||||
<div className="row" style={{ gap: 4 }}>
|
||||
<button
|
||||
className="icon-button danger"
|
||||
onClick={() => !refreshing && onRemoveFund?.(f)}
|
||||
title="删除"
|
||||
disabled={refreshing}
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
opacity: refreshing ? 0.6 : 1,
|
||||
cursor: refreshing ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<TrashIcon width="14" height="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<Stat label="单位净值" value={f.dwjz ?? '—'} />
|
||||
{f.noValuation ? (
|
||||
<Stat
|
||||
label="涨跌幅"
|
||||
value={
|
||||
f.zzl !== undefined && f.zzl !== null
|
||||
? `${f.zzl > 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%`
|
||||
: '—'
|
||||
}
|
||||
delta={f.zzl}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{(() => {
|
||||
const hasTodayData = f.jzrq === todayStr;
|
||||
let isYesterdayChange = false;
|
||||
let isPreviousTradingDay = false;
|
||||
if (!hasTodayData && isString(f.jzrq)) {
|
||||
const today = toTz(todayStr).startOf('day');
|
||||
const jzDate = toTz(f.jzrq).startOf('day');
|
||||
const yesterday = today.clone().subtract(1, 'day');
|
||||
if (jzDate.isSame(yesterday, 'day')) {
|
||||
isYesterdayChange = true;
|
||||
} else if (jzDate.isBefore(yesterday, 'day')) {
|
||||
isPreviousTradingDay = true;
|
||||
}
|
||||
}
|
||||
const shouldHideChange =
|
||||
isTradingDay && !hasTodayData && !isYesterdayChange && !isPreviousTradingDay;
|
||||
|
||||
if (shouldHideChange) return null;
|
||||
|
||||
const changeLabel = hasTodayData ? '涨跌幅' : '昨日涨幅';
|
||||
return (
|
||||
<Stat
|
||||
label={changeLabel}
|
||||
value={
|
||||
f.zzl !== undefined
|
||||
? `${f.zzl > 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%`
|
||||
: ''
|
||||
}
|
||||
delta={f.zzl}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
<Stat
|
||||
label="估值净值"
|
||||
value={
|
||||
f.estPricedCoverage > 0.05 ? f.estGsz.toFixed(4) : (f.gsz ?? '—')
|
||||
}
|
||||
/>
|
||||
<Stat
|
||||
label="估值涨幅"
|
||||
value={
|
||||
f.estPricedCoverage > 0.05
|
||||
? `${f.estGszzl > 0 ? '+' : ''}${f.estGszzl.toFixed(2)}%`
|
||||
: isNumber(f.gszzl)
|
||||
? `${f.gszzl > 0 ? '+' : ''}${f.gszzl.toFixed(2)}%`
|
||||
: f.gszzl ?? '—'
|
||||
}
|
||||
delta={f.estPricedCoverage > 0.05 ? f.estGszzl : Number(f.gszzl) || 0}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
{!profit ? (
|
||||
<div
|
||||
className="stat"
|
||||
style={{ flexDirection: 'column', gap: 4 }}
|
||||
>
|
||||
<span className="label">持仓金额</span>
|
||||
<div
|
||||
className="value muted"
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
cursor: layoutMode === 'drawer' ? 'default' : 'pointer',
|
||||
}}
|
||||
onClick={() => layoutMode !== 'drawer' && onHoldingClick?.(f)}
|
||||
>
|
||||
未设置 {layoutMode !== 'drawer' && <SettingsIcon width="12" height="12" />}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="stat"
|
||||
style={{ cursor: layoutMode === 'drawer' ? 'default' : 'pointer', flexDirection: 'column', gap: 4 }}
|
||||
onClick={() => layoutMode !== 'drawer' && onActionClick?.(f)}
|
||||
>
|
||||
<span
|
||||
className="label"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
持仓金额 {layoutMode !== 'drawer' && <SettingsIcon width="12" height="12" style={{ opacity: 0.7 }} />}
|
||||
</span>
|
||||
<span className="value">¥{profit.amount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="stat" style={{ flexDirection: 'column', gap: 4 }}>
|
||||
<span className="label">当日收益</span>
|
||||
<span
|
||||
className={`value ${
|
||||
profit.profitToday != null
|
||||
? profit.profitToday > 0
|
||||
? 'up'
|
||||
: profit.profitToday < 0
|
||||
? 'down'
|
||||
: ''
|
||||
: 'muted'
|
||||
}`}
|
||||
>
|
||||
{profit.profitToday != null
|
||||
? `${profit.profitToday > 0 ? '+' : profit.profitToday < 0 ? '-' : ''}¥${Math.abs(profit.profitToday).toFixed(2)}`
|
||||
: '--'}
|
||||
</span>
|
||||
</div>
|
||||
{profit.profitTotal !== null && (
|
||||
<div
|
||||
className="stat"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPercentModeToggle?.(f.code);
|
||||
}}
|
||||
style={{ cursor: 'pointer', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}
|
||||
title="点击切换金额/百分比"
|
||||
>
|
||||
<span
|
||||
className="label"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 1, justifyContent: 'flex-end' }}
|
||||
>
|
||||
持有收益{percentModes?.[f.code] ? '(%)' : ''}
|
||||
<SwitchIcon />
|
||||
</span>
|
||||
<span
|
||||
className={`value ${
|
||||
profit.profitTotal > 0 ? 'up' : profit.profitTotal < 0 ? 'down' : ''
|
||||
}`}
|
||||
>
|
||||
{profit.profitTotal > 0 ? '+' : profit.profitTotal < 0 ? '-' : ''}
|
||||
{percentModes?.[f.code]
|
||||
? `${Math.abs(
|
||||
holding?.cost * holding?.share
|
||||
? (profit.profitTotal / (holding.cost * holding.share)) * 100
|
||||
: 0,
|
||||
).toFixed(2)}%`
|
||||
: `¥${Math.abs(profit.profitTotal).toFixed(2)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{f.estPricedCoverage > 0.05 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
color: 'var(--muted)',
|
||||
marginTop: -8,
|
||||
marginBottom: 10,
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
基于 {Math.round(f.estPricedCoverage * 100)}% 持仓估算
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const showIntraday =
|
||||
Array.isArray(valuationSeries?.[f.code]) && valuationSeries[f.code].length >= 2;
|
||||
if (!showIntraday) return null;
|
||||
|
||||
if (
|
||||
f.gztime &&
|
||||
toTz(todayStr).startOf('day').isAfter(toTz(f.gztime).startOf('day'))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
f.jzrq &&
|
||||
f.gztime &&
|
||||
toTz(f.jzrq).startOf('day').isSameOrAfter(toTz(f.gztime).startOf('day'))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FundIntradayChart
|
||||
key={`${f.code}-intraday-${theme}`}
|
||||
series={valuationSeries[f.code]}
|
||||
referenceNav={f.dwjz != null ? Number(f.dwjz) : undefined}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{layoutMode === 'drawer' ? (
|
||||
<Tabs defaultValue={hasHoldings ? 'holdings' : 'trend'} className="w-full">
|
||||
<TabsList className={`w-full ${hasHoldings ? 'grid grid-cols-2' : ''}`}>
|
||||
{hasHoldings && (
|
||||
<TabsTrigger value="holdings">前10重仓股票</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="trend">业绩走势</TabsTrigger>
|
||||
</TabsList>
|
||||
{hasHoldings && (
|
||||
<TabsContent value="holdings" className="mt-3 outline-none">
|
||||
<div className="list">
|
||||
{f.holdings.map((h, idx) => (
|
||||
<div className="item" key={idx}>
|
||||
<span className="name">{h.name}</span>
|
||||
<div className="values">
|
||||
{isNumber(h.change) && (
|
||||
<span
|
||||
className={`badge ${h.change > 0 ? 'up' : h.change < 0 ? 'down' : ''}`}
|
||||
style={{ marginRight: 8 }}
|
||||
>
|
||||
{h.change > 0 ? '+' : ''}
|
||||
{h.change.toFixed(2)}%
|
||||
</span>
|
||||
)}
|
||||
<span className="weight">{h.weight}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
<TabsContent value="trend" className="mt-3 outline-none">
|
||||
<FundTrendChart
|
||||
key={`${f.code}-${theme}`}
|
||||
code={f.code}
|
||||
isExpanded
|
||||
onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
|
||||
transactions={transactions?.[f.code] || []}
|
||||
theme={theme}
|
||||
hideHeader
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<>
|
||||
{hasHoldings && (
|
||||
<>
|
||||
<div
|
||||
style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }}
|
||||
className="title"
|
||||
onClick={() => onToggleCollapse?.(f.code)}
|
||||
>
|
||||
<div className="row" style={{ width: '100%', flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span>前10重仓股票</span>
|
||||
<ChevronIcon
|
||||
width="16"
|
||||
height="16"
|
||||
className="muted"
|
||||
style={{
|
||||
transform: collapsedCodes?.has(f.code)
|
||||
? 'rotate(-90deg)'
|
||||
: 'rotate(0deg)',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="muted">涨跌幅 / 占比</span>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{!collapsedCodes?.has(f.code) && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<div className="list">
|
||||
{f.holdings.map((h, idx) => (
|
||||
<div className="item" key={idx}>
|
||||
<span className="name">{h.name}</span>
|
||||
<div className="values">
|
||||
{isNumber(h.change) && (
|
||||
<span
|
||||
className={`badge ${h.change > 0 ? 'up' : h.change < 0 ? 'down' : ''}`}
|
||||
style={{ marginRight: 8 }}
|
||||
>
|
||||
{h.change > 0 ? '+' : ''}
|
||||
{h.change.toFixed(2)}%
|
||||
</span>
|
||||
)}
|
||||
<span className="weight">{h.weight}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
<FundTrendChart
|
||||
key={`${f.code}-${theme}`}
|
||||
code={f.code}
|
||||
isExpanded={!collapsedTrends?.has(f.code)}
|
||||
onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
|
||||
transactions={transactions?.[f.code] || []}
|
||||
theme={theme}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
290
app/components/FundIntradayChart.jsx
Normal file
290
app/components/FundIntradayChart.jsx
Normal file
@@ -0,0 +1,290 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Tooltip,
|
||||
Filler
|
||||
} from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { isNumber } from 'lodash';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Tooltip,
|
||||
Filler
|
||||
);
|
||||
|
||||
const CHART_COLORS = {
|
||||
dark: {
|
||||
danger: '#f87171',
|
||||
success: '#34d399',
|
||||
primary: '#22d3ee',
|
||||
muted: '#9ca3af',
|
||||
border: '#1f2937',
|
||||
text: '#e5e7eb',
|
||||
crosshairText: '#0f172a',
|
||||
},
|
||||
light: {
|
||||
danger: '#dc2626',
|
||||
success: '#059669',
|
||||
primary: '#0891b2',
|
||||
muted: '#475569',
|
||||
border: '#e2e8f0',
|
||||
text: '#0f172a',
|
||||
crosshairText: '#ffffff',
|
||||
}
|
||||
};
|
||||
|
||||
function getChartThemeColors(theme) {
|
||||
return CHART_COLORS[theme] || CHART_COLORS.dark;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分时图:展示当日(或最近一次记录日)的估值序列,纵轴为相对参考净值的涨跌幅百分比。
|
||||
* series: Array<{ time: string, value: number, date?: string }>
|
||||
* referenceNav: 参考净值(最新单位净值),用于计算涨跌幅;未传则用当日第一个估值作为参考。
|
||||
* theme: 'light' | 'dark',用于亮色主题下坐标轴与 crosshair 样式
|
||||
*/
|
||||
export default function FundIntradayChart({ series = [], referenceNav, theme = 'dark' }) {
|
||||
const chartRef = useRef(null);
|
||||
const hoverTimeoutRef = useRef(null);
|
||||
const chartColors = useMemo(() => getChartThemeColors(theme), [theme]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!series.length) return { labels: [], datasets: [] };
|
||||
const labels = series.map((d) => d.time);
|
||||
const values = series.map((d) => d.value);
|
||||
const ref = referenceNav != null && Number.isFinite(Number(referenceNav))
|
||||
? Number(referenceNav)
|
||||
: values[0];
|
||||
const percentages = values.map((v) => (ref ? ((v - ref) / ref) * 100 : 0));
|
||||
const lastPct = percentages[percentages.length - 1];
|
||||
const riseColor = chartColors.danger;
|
||||
const fallColor = chartColors.success;
|
||||
const lineColor = lastPct != null && lastPct >= 0 ? riseColor : fallColor;
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
type: 'line',
|
||||
label: '涨跌幅',
|
||||
data: percentages,
|
||||
borderColor: lineColor,
|
||||
backgroundColor: (ctx) => {
|
||||
if (!ctx.chart.ctx) return lineColor + '33';
|
||||
const gradient = ctx.chart.ctx.createLinearGradient(0, 0, 0, 120);
|
||||
gradient.addColorStop(0, lineColor + '33');
|
||||
gradient.addColorStop(1, lineColor + '00');
|
||||
return gradient;
|
||||
},
|
||||
borderWidth: 2,
|
||||
pointRadius: series.length <= 2 ? 3 : 0,
|
||||
pointHoverRadius: 4,
|
||||
fill: true,
|
||||
tension: 0.2
|
||||
}
|
||||
]
|
||||
};
|
||||
}, [series, referenceNav, chartColors.danger, chartColors.success]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const colors = getChartThemeColors(theme);
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
external: () => {}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
color: colors.muted,
|
||||
font: { size: 10 },
|
||||
maxTicksLimit: 6
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
position: 'left',
|
||||
grid: { color: colors.border, drawBorder: false },
|
||||
ticks: {
|
||||
color: colors.muted,
|
||||
font: { size: 10 },
|
||||
callback: (v) => (isNumber(v) ? `${v >= 0 ? '+' : ''}${v.toFixed(2)}%` : v)
|
||||
}
|
||||
}
|
||||
},
|
||||
onHover: (event, chartElement, chart) => {
|
||||
const target = event?.native?.target;
|
||||
const currentChart = chart || chartRef.current;
|
||||
if (!currentChart) return;
|
||||
|
||||
const tooltipActive = currentChart.tooltip?._active ?? [];
|
||||
const activeElements = currentChart.getActiveElements
|
||||
? currentChart.getActiveElements()
|
||||
: [];
|
||||
const hasActive =
|
||||
(chartElement && chartElement.length > 0) ||
|
||||
(tooltipActive && tooltipActive.length > 0) ||
|
||||
(activeElements && activeElements.length > 0);
|
||||
|
||||
if (target) {
|
||||
target.style.cursor = hasActive ? 'crosshair' : 'default';
|
||||
}
|
||||
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (hasActive) {
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
const c = chartRef.current || currentChart;
|
||||
if (!c) return;
|
||||
c.setActiveElements([]);
|
||||
if (c.tooltip) {
|
||||
c.tooltip.setActiveElements([], { x: 0, y: 0 });
|
||||
}
|
||||
c.update();
|
||||
if (target) {
|
||||
target.style.cursor = 'default';
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const plugins = useMemo(() => {
|
||||
const colors = getChartThemeColors(theme);
|
||||
return [{
|
||||
id: 'crosshair',
|
||||
afterDraw: (chart) => {
|
||||
const ctx = chart.ctx;
|
||||
const activeElements = chart.tooltip?._active?.length
|
||||
? chart.tooltip._active
|
||||
: chart.getActiveElements();
|
||||
if (!activeElements?.length) return;
|
||||
|
||||
const activePoint = activeElements[0];
|
||||
const x = activePoint.element.x;
|
||||
const y = activePoint.element.y;
|
||||
const topY = chart.scales.y.top;
|
||||
const bottomY = chart.scales.y.bottom;
|
||||
const leftX = chart.scales.x.left;
|
||||
const rightX = chart.scales.x.right;
|
||||
const index = activePoint.index;
|
||||
const labels = chart.data.labels;
|
||||
const data = chart.data.datasets[0]?.data;
|
||||
|
||||
ctx.save();
|
||||
ctx.setLineDash([3, 3]);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = colors.muted;
|
||||
ctx.moveTo(x, topY);
|
||||
ctx.lineTo(x, bottomY);
|
||||
ctx.moveTo(leftX, y);
|
||||
ctx.lineTo(rightX, y);
|
||||
ctx.stroke();
|
||||
|
||||
const prim = colors.primary;
|
||||
const textCol = colors.crosshairText;
|
||||
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
if (labels && index in labels) {
|
||||
const timeStr = String(labels[index]);
|
||||
const tw = ctx.measureText(timeStr).width + 8;
|
||||
const chartLeft = chart.scales.x.left;
|
||||
const chartRight = chart.scales.x.right;
|
||||
let labelLeft = x - tw / 2;
|
||||
if (labelLeft < chartLeft) labelLeft = chartLeft;
|
||||
if (labelLeft + tw > chartRight) labelLeft = chartRight - tw;
|
||||
const labelCenterX = labelLeft + tw / 2;
|
||||
ctx.fillStyle = prim;
|
||||
ctx.fillRect(labelLeft, bottomY, tw, 16);
|
||||
ctx.fillStyle = textCol;
|
||||
ctx.fillText(timeStr, labelCenterX, bottomY + 8);
|
||||
}
|
||||
if (data && index in data) {
|
||||
const val = data[index];
|
||||
const valueStr = isNumber(val) ? `${val >= 0 ? '+' : ''}${val.toFixed(2)}%` : String(val);
|
||||
const vw = ctx.measureText(valueStr).width + 8;
|
||||
ctx.fillStyle = prim;
|
||||
ctx.fillRect(leftX, y - 8, vw, 16);
|
||||
ctx.fillStyle = textCol;
|
||||
ctx.fillText(valueStr, leftX + vw / 2, y);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}];
|
||||
}, [theme]);
|
||||
|
||||
if (series.length < 2) return null;
|
||||
|
||||
const displayDate = series[0]?.date || series[series.length - 1]?.date;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 12, marginBottom: 4 }}>
|
||||
<div className="muted" style={{ fontSize: 11, marginBottom: 6, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 6 }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
实时估值分时(按刷新记录)
|
||||
<span
|
||||
style={{
|
||||
fontSize: 9,
|
||||
padding: '2px 6px',
|
||||
borderRadius: 4,
|
||||
...(theme === 'light'
|
||||
? {
|
||||
border: '1px solid',
|
||||
borderColor: chartColors.primary,
|
||||
color: chartColors.primary,
|
||||
background: 'transparent',
|
||||
}
|
||||
: {
|
||||
background: 'var(--primary)',
|
||||
color: '#0f172a',
|
||||
}),
|
||||
fontWeight: 600,
|
||||
}}
|
||||
title="正在测试中的功能"
|
||||
>
|
||||
Beta
|
||||
</span>
|
||||
</span>
|
||||
{displayDate && <span style={{ fontSize: 11 }}>估值日期 {displayDate}</span>}
|
||||
</div>
|
||||
<div style={{ position: 'relative', height: 100, width: '100%', touchAction: 'pan-y' }}>
|
||||
<Line ref={chartRef} data={chartData} options={options} plugins={plugins} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,12 +29,40 @@ ChartJS.register(
|
||||
Filler
|
||||
);
|
||||
|
||||
export default function FundTrendChart({ code, isExpanded, onToggleExpand, transactions = [] }) {
|
||||
const CHART_COLORS = {
|
||||
dark: {
|
||||
danger: '#f87171',
|
||||
success: '#34d399',
|
||||
primary: '#22d3ee',
|
||||
muted: '#9ca3af',
|
||||
border: '#1f2937',
|
||||
text: '#e5e7eb',
|
||||
crosshairText: '#0f172a',
|
||||
},
|
||||
light: {
|
||||
danger: '#dc2626',
|
||||
success: '#059669',
|
||||
primary: '#0891b2',
|
||||
muted: '#475569',
|
||||
border: '#e2e8f0',
|
||||
text: '#0f172a',
|
||||
crosshairText: '#ffffff',
|
||||
}
|
||||
};
|
||||
|
||||
function getChartThemeColors(theme) {
|
||||
return CHART_COLORS[theme] || CHART_COLORS.dark;
|
||||
}
|
||||
|
||||
export default function FundTrendChart({ code, isExpanded, onToggleExpand, transactions = [], theme = 'dark', hideHeader = false }) {
|
||||
const [range, setRange] = useState('1m');
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const chartRef = useRef(null);
|
||||
const hoverTimeoutRef = useRef(null);
|
||||
|
||||
const chartColors = useMemo(() => getChartThemeColors(theme), [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
// If collapsed, don't fetch data unless we have no data yet
|
||||
@@ -73,7 +101,8 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
{ label: '近3月', value: '3m' },
|
||||
{ label: '近6月', value: '6m' },
|
||||
{ label: '近1年', value: '1y' },
|
||||
{ label: '近3年', value: '3y'}
|
||||
{ label: '近3年', value: '3y' },
|
||||
{ label: '成立来', value: 'all' }
|
||||
];
|
||||
|
||||
const change = useMemo(() => {
|
||||
@@ -83,11 +112,11 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
return ((last - first) / first) * 100;
|
||||
}, [data]);
|
||||
|
||||
// Red for up, Green for down (CN market style)
|
||||
// Hardcoded hex values from globals.css for Chart.js
|
||||
const upColor = '#f87171'; // --danger
|
||||
const downColor = '#34d399'; // --success
|
||||
// Red for up, Green for down (CN market style),随主题使用 CSS 变量
|
||||
const upColor = chartColors.danger;
|
||||
const downColor = chartColors.success;
|
||||
const lineColor = change >= 0 ? upColor : downColor;
|
||||
const primaryColor = chartColors.primary;
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
// Calculate percentage change based on the first data point
|
||||
@@ -139,8 +168,9 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
type: 'line', // Use line type with showLine: false to simulate scatter on Category scale
|
||||
label: '买入',
|
||||
data: buyPoints,
|
||||
borderColor: '#ef4444', // Red
|
||||
backgroundColor: '#ef4444',
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
backgroundColor: primaryColor,
|
||||
pointStyle: 'circle',
|
||||
pointRadius: 2.5,
|
||||
pointHoverRadius: 4,
|
||||
@@ -151,8 +181,9 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
type: 'line',
|
||||
label: '卖出',
|
||||
data: sellPoints,
|
||||
borderColor: '#22c55e', // Green
|
||||
backgroundColor: '#22c55e',
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
backgroundColor: upColor,
|
||||
pointStyle: 'circle',
|
||||
pointRadius: 2.5,
|
||||
pointHoverRadius: 4,
|
||||
@@ -161,9 +192,10 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
}
|
||||
]
|
||||
};
|
||||
}, [data, lineColor, transactions]);
|
||||
}, [data, transactions, lineColor, primaryColor, upColor]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const colors = getChartThemeColors(theme);
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -186,7 +218,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: '#9ca3af',
|
||||
color: colors.muted,
|
||||
font: { size: 10 },
|
||||
maxTicksLimit: 4,
|
||||
maxRotation: 0
|
||||
@@ -195,14 +227,14 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
position: 'right',
|
||||
position: 'left',
|
||||
grid: {
|
||||
color: '#1f2937',
|
||||
color: colors.border,
|
||||
drawBorder: false,
|
||||
tickLength: 0
|
||||
},
|
||||
ticks: {
|
||||
color: '#9ca3af',
|
||||
color: colors.muted,
|
||||
font: { size: 10 },
|
||||
count: 5,
|
||||
callback: (value) => `${value.toFixed(2)}%`
|
||||
@@ -214,69 +246,126 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
onHover: (event, chartElement) => {
|
||||
event.native.target.style.cursor = chartElement[0] ? 'crosshair' : 'default';
|
||||
onHover: (event, chartElement, chart) => {
|
||||
const target = event?.native?.target;
|
||||
const currentChart = chart || chartRef.current;
|
||||
if (!currentChart) return;
|
||||
|
||||
const tooltipActive = currentChart.tooltip?._active ?? [];
|
||||
const activeElements = currentChart.getActiveElements
|
||||
? currentChart.getActiveElements()
|
||||
: [];
|
||||
const hasActive =
|
||||
(chartElement && chartElement.length > 0) ||
|
||||
(tooltipActive && tooltipActive.length > 0) ||
|
||||
(activeElements && activeElements.length > 0);
|
||||
|
||||
if (target) {
|
||||
target.style.cursor = hasActive ? 'crosshair' : 'default';
|
||||
}
|
||||
|
||||
// 仅用于桌面端 hover 改变光标,不在这里做 2 秒清除,避免移动端 hover 事件不稳定
|
||||
},
|
||||
onClick: () => {}
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const plugins = useMemo(() => [{
|
||||
const plugins = useMemo(() => {
|
||||
const colors = getChartThemeColors(theme);
|
||||
return [{
|
||||
id: 'crosshair',
|
||||
afterEvent: (chart, args) => {
|
||||
const { event, replay } = args || {};
|
||||
if (!event || replay) return; // 忽略动画重放
|
||||
|
||||
const type = event.type;
|
||||
if (type === 'mousemove' || type === 'click') {
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
if (!chart) return;
|
||||
chart.setActiveElements([]);
|
||||
if (chart.tooltip) {
|
||||
chart.tooltip.setActiveElements([], { x: 0, y: 0 });
|
||||
}
|
||||
chart.update();
|
||||
}, 2000);
|
||||
}
|
||||
},
|
||||
afterDraw: (chart) => {
|
||||
const ctx = chart.ctx;
|
||||
const datasets = chart.data.datasets;
|
||||
const primaryColor = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#22d3ee';
|
||||
const primaryColor = colors.primary;
|
||||
|
||||
// 绘制圆角矩形(兼容无 roundRect 的环境)
|
||||
const drawRoundRect = (left, top, w, h, r) => {
|
||||
const rad = Math.min(r, w / 2, h / 2);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(left + rad, top);
|
||||
ctx.lineTo(left + w - rad, top);
|
||||
ctx.quadraticCurveTo(left + w, top, left + w, top + rad);
|
||||
ctx.lineTo(left + w, top + h - rad);
|
||||
ctx.quadraticCurveTo(left + w, top + h, left + w - rad, top + h);
|
||||
ctx.lineTo(left + rad, top + h);
|
||||
ctx.quadraticCurveTo(left, top + h, left, top + h - rad);
|
||||
ctx.lineTo(left, top + rad);
|
||||
ctx.quadraticCurveTo(left, top, left + rad, top);
|
||||
ctx.closePath();
|
||||
};
|
||||
|
||||
// Helper function to draw point label
|
||||
const drawPointLabel = (datasetIndex, index, text, bgColor, textColor = '#ffffff', yOffset = 0) => {
|
||||
const meta = chart.getDatasetMeta(datasetIndex);
|
||||
if (!meta.data[index]) return;
|
||||
const element = meta.data[index];
|
||||
// Check if element is visible/not skipped
|
||||
if (element.skip) return;
|
||||
|
||||
const x = element.x;
|
||||
const y = element.y + yOffset;
|
||||
const paddingH = 10;
|
||||
const paddingV = 6;
|
||||
const radius = 8;
|
||||
|
||||
ctx.save();
|
||||
ctx.font = 'bold 11px sans-serif';
|
||||
const labelWidth = ctx.measureText(text).width + 12;
|
||||
const textW = ctx.measureText(text).width;
|
||||
const w = textW + paddingH * 2;
|
||||
const h = 18;
|
||||
|
||||
// Draw label above the point
|
||||
ctx.globalAlpha = 0.8;
|
||||
// 计算原始 left,并对左右边界做收缩,避免在最右/最左侧被裁剪
|
||||
const chartLeft = chart.scales.x.left;
|
||||
const chartRight = chart.scales.x.right;
|
||||
let left = x - w / 2;
|
||||
if (left < chartLeft) left = chartLeft;
|
||||
if (left + w > chartRight) left = chartRight - w;
|
||||
const centerX = left + w / 2;
|
||||
|
||||
const top = y - 24;
|
||||
|
||||
drawRoundRect(left, top, w, h, radius);
|
||||
ctx.globalAlpha = 0.7;
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.fillRect(x - labelWidth/2, y - 24, labelWidth, 18);
|
||||
ctx.fill();
|
||||
|
||||
ctx.globalAlpha = 1.0;
|
||||
ctx.globalAlpha = 0.7;
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(text, x, y - 15);
|
||||
ctx.fillText(text, centerX, top + h / 2);
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
// 1. Draw default labels for first buy and sell points
|
||||
// Index 1 is Buy, Index 2 is Sell
|
||||
if (datasets[1] && datasets[1].data) {
|
||||
const firstBuyIndex = datasets[1].data.findIndex(v => v !== null && v !== undefined);
|
||||
if (firstBuyIndex !== -1) {
|
||||
// Check collision with Sell
|
||||
let sellIndex = -1;
|
||||
if (datasets[2] && datasets[2].data) {
|
||||
sellIndex = datasets[2].data.findIndex(v => v !== null && v !== undefined);
|
||||
}
|
||||
const isCollision = (firstBuyIndex === sellIndex);
|
||||
drawPointLabel(1, firstBuyIndex, '买入', '#ef4444', '#ffffff', isCollision ? -20 : 0);
|
||||
}
|
||||
}
|
||||
if (datasets[2] && datasets[2].data) {
|
||||
const firstSellIndex = datasets[2].data.findIndex(v => v !== null && v !== undefined);
|
||||
if (firstSellIndex !== -1) {
|
||||
drawPointLabel(2, firstSellIndex, '卖出', primaryColor);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Handle active elements (hover crosshair)
|
||||
// Resolve active elements (hover/focus) first — used to decide whether to show default labels
|
||||
let activeElements = [];
|
||||
if (chart.tooltip?._active?.length) {
|
||||
activeElements = chart.tooltip._active;
|
||||
@@ -284,6 +373,27 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
activeElements = chart.getActiveElements();
|
||||
}
|
||||
|
||||
// 1. Draw default labels for first buy and sell points only when NOT focused/hovering
|
||||
// Index 1 is Buy, Index 2 is Sell
|
||||
if (!activeElements?.length && datasets[1] && datasets[1].data) {
|
||||
const firstBuyIndex = datasets[1].data.findIndex(v => v !== null && v !== undefined);
|
||||
if (firstBuyIndex !== -1) {
|
||||
let sellIndex = -1;
|
||||
if (datasets[2] && datasets[2].data) {
|
||||
sellIndex = datasets[2].data.findIndex(v => v !== null && v !== undefined);
|
||||
}
|
||||
const isCollision = (firstBuyIndex === sellIndex);
|
||||
drawPointLabel(1, firstBuyIndex, '买入', primaryColor, '#ffffff', isCollision ? -20 : 0);
|
||||
}
|
||||
}
|
||||
if (!activeElements?.length && datasets[2] && datasets[2].data) {
|
||||
const firstSellIndex = datasets[2].data.findIndex(v => v !== null && v !== undefined);
|
||||
if (firstSellIndex !== -1) {
|
||||
drawPointLabel(2, firstSellIndex, '卖出', '#f87171');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Handle active elements (hover crosshair)
|
||||
if (activeElements && activeElements.length) {
|
||||
const activePoint = activeElements[0];
|
||||
const x = activePoint.element.x;
|
||||
@@ -297,7 +407,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
ctx.beginPath();
|
||||
ctx.setLineDash([3, 3]);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = '#9ca3af';
|
||||
ctx.strokeStyle = colors.muted;
|
||||
|
||||
// Draw vertical line
|
||||
ctx.moveTo(x, topY);
|
||||
@@ -325,21 +435,27 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
const value = datasets[datasetIndex].data[index];
|
||||
|
||||
if (dateStr !== undefined && value !== undefined) {
|
||||
// X axis label (date)
|
||||
// X axis label (date) with boundary clamping
|
||||
const textWidth = ctx.measureText(dateStr).width + 8;
|
||||
const chartLeft = chart.scales.x.left;
|
||||
const chartRight = chart.scales.x.right;
|
||||
let labelLeft = x - textWidth / 2;
|
||||
if (labelLeft < chartLeft) labelLeft = chartLeft;
|
||||
if (labelLeft + textWidth > chartRight) labelLeft = chartRight - textWidth;
|
||||
const labelCenterX = labelLeft + textWidth / 2;
|
||||
ctx.fillStyle = primaryColor;
|
||||
ctx.fillRect(x - textWidth / 2, bottomY, textWidth, 16);
|
||||
ctx.fillStyle = '#0f172a'; // --background
|
||||
ctx.fillText(dateStr, x, bottomY + 8);
|
||||
ctx.fillRect(labelLeft, bottomY, textWidth, 16);
|
||||
ctx.fillStyle = colors.crosshairText;
|
||||
ctx.fillText(dateStr, labelCenterX, bottomY + 8);
|
||||
|
||||
// Y axis label (value)
|
||||
const valueStr = (typeof value === 'number' ? value.toFixed(2) : value) + '%';
|
||||
const valWidth = ctx.measureText(valueStr).width + 8;
|
||||
ctx.fillStyle = primaryColor;
|
||||
ctx.fillRect(rightX - valWidth, y - 8, valWidth, 16);
|
||||
ctx.fillStyle = '#0f172a'; // --background
|
||||
ctx.fillRect(leftX, y - 8, valWidth, 16);
|
||||
ctx.fillStyle = colors.crosshairText;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(valueStr, rightX - valWidth / 2, y);
|
||||
ctx.fillText(valueStr, leftX + valWidth / 2, y);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,8 +471,8 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
if (dsIndex > 0 && datasets[dsIndex]) {
|
||||
const label = datasets[dsIndex].label;
|
||||
// Determine background color based on dataset index
|
||||
// 1 = Buy (Red), 2 = Sell (Theme Color)
|
||||
const bgColor = dsIndex === 1 ? '#ef4444' : primaryColor;
|
||||
// 1 = Buy (主题色), 2 = Sell (与折线图红色一致)
|
||||
const bgColor = dsIndex === 1 ? primaryColor : colors.danger;
|
||||
|
||||
// If collision, offset Buy label upwards
|
||||
let yOffset = 0;
|
||||
@@ -371,10 +487,47 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
}], []); // 移除 data 依赖,因为我们直接从 chart 实例读取数据
|
||||
}];
|
||||
}, [theme]); // theme 变化时重算以应用亮色/暗色坐标轴与 crosshair
|
||||
|
||||
const chartBlock = (
|
||||
<>
|
||||
<div style={{ position: 'relative', height: 180, width: '100%', touchAction: 'pan-y' }}>
|
||||
{loading && (
|
||||
<div className="chart-overlay" style={{ backdropFilter: 'blur(2px)' }}>
|
||||
<span className="muted" style={{ fontSize: '12px' }}>加载中...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && data.length === 0 && (
|
||||
<div className="chart-overlay">
|
||||
<span className="muted" style={{ fontSize: '12px' }}>暂无数据</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.length > 0 && (
|
||||
<Line ref={chartRef} data={chartData} options={options} plugins={plugins} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="trend-range-bar">
|
||||
{ranges.map(r => (
|
||||
<button
|
||||
key={r.value}
|
||||
type="button"
|
||||
className={`trend-range-btn ${range === r.value ? 'active' : ''}`}
|
||||
onClick={(e) => { e.stopPropagation(); setRange(r.value); }}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16 }} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ marginTop: hideHeader ? 0 : 16 }} onClick={(e) => e.stopPropagation()}>
|
||||
{!hideHeader && (
|
||||
<div
|
||||
style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }}
|
||||
className="title"
|
||||
@@ -403,7 +556,22 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hideHeader && data.length > 0 && (
|
||||
<div className="row" style={{ marginBottom: 8, justifyContent: 'flex-end' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="muted">{ranges.find(r => r.value === range)?.label}涨跌幅</span>
|
||||
<span style={{ color: lineColor, fontWeight: 600 }}>
|
||||
{change > 0 ? '+' : ''}{change.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hideHeader ? (
|
||||
chartBlock
|
||||
) : (
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
@@ -413,55 +581,11 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<div style={{ position: 'relative', height: 180, width: '100%' }}>
|
||||
{loading && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(255,255,255,0.02)', zIndex: 10, backdropFilter: 'blur(2px)'
|
||||
}}>
|
||||
<span className="muted" style={{ fontSize: '12px' }}>加载中...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && data.length === 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(255,255,255,0.02)', zIndex: 10
|
||||
}}>
|
||||
<span className="muted" style={{ fontSize: '12px' }}>暂无数据</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.length > 0 && (
|
||||
<Line ref={chartRef} data={chartData} options={options} plugins={plugins} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 12, justifyContent: 'space-between', background: 'rgba(0,0,0,0.2)', padding: 4, borderRadius: 8 }}>
|
||||
{ranges.map(r => (
|
||||
<button
|
||||
key={r.value}
|
||||
onClick={(e) => { e.stopPropagation(); setRange(r.value); }}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 0',
|
||||
fontSize: '11px',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
background: range === r.value ? 'rgba(255,255,255,0.1)' : 'transparent',
|
||||
color: range === r.value ? 'var(--primary)' : 'var(--muted)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
fontWeight: range === r.value ? 600 : 400
|
||||
}}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{chartBlock}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
332
app/components/GroupSummary.jsx
Normal file
332
app/components/GroupSummary.jsx
Normal file
@@ -0,0 +1,332 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useMemo, useLayoutEffect } from 'react';
|
||||
import { PinIcon, PinOffIcon, EyeIcon, EyeOffIcon, SwitchIcon } from './Icons';
|
||||
|
||||
// 数字滚动组件(初始化时无动画,后续变更再动画)
|
||||
function CountUp({ value, prefix = '', suffix = '', decimals = 2, className = '', style = {} }) {
|
||||
const [displayValue, setDisplayValue] = useState(value);
|
||||
const previousValue = useRef(value);
|
||||
const isFirstChange = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousValue.current === value) return;
|
||||
|
||||
if (isFirstChange.current) {
|
||||
isFirstChange.current = false;
|
||||
previousValue.current = value;
|
||||
setDisplayValue(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = previousValue.current;
|
||||
const end = value;
|
||||
const duration = 400;
|
||||
const startTime = performance.now();
|
||||
|
||||
const animate = (currentTime) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const ease = 1 - Math.pow(1 - progress, 4);
|
||||
const current = start + (end - start) * ease;
|
||||
setDisplayValue(current);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
previousValue.current = value;
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<span className={className} style={style}>
|
||||
{prefix}
|
||||
{Math.abs(displayValue).toFixed(decimals)}
|
||||
{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GroupSummary({
|
||||
funds,
|
||||
holdings,
|
||||
groupName,
|
||||
getProfit,
|
||||
stickyTop,
|
||||
}) {
|
||||
const [showPercent, setShowPercent] = useState(true);
|
||||
const [isMasked, setIsMasked] = useState(false);
|
||||
const [isSticky, setIsSticky] = useState(false);
|
||||
const rowRef = useRef(null);
|
||||
const [assetSize, setAssetSize] = useState(24);
|
||||
const [metricSize, setMetricSize] = useState(18);
|
||||
const [winW, setWinW] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setWinW(window.innerWidth);
|
||||
const onR = () => setWinW(window.innerWidth);
|
||||
window.addEventListener('resize', onR);
|
||||
return () => window.removeEventListener('resize', onR);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
let totalAsset = 0;
|
||||
let totalProfitToday = 0;
|
||||
let totalHoldingReturn = 0;
|
||||
let totalCost = 0;
|
||||
let hasHolding = false;
|
||||
let hasAnyTodayData = false;
|
||||
|
||||
funds.forEach((fund) => {
|
||||
const holding = holdings[fund.code];
|
||||
const profit = getProfit(fund, holding);
|
||||
|
||||
if (profit) {
|
||||
hasHolding = true;
|
||||
totalAsset += profit.amount;
|
||||
if (profit.profitToday != null) {
|
||||
totalProfitToday += Math.round(profit.profitToday * 100) / 100;
|
||||
hasAnyTodayData = true;
|
||||
}
|
||||
if (profit.profitTotal !== null) {
|
||||
totalHoldingReturn += profit.profitTotal;
|
||||
if (holding && typeof holding.cost === 'number' && typeof holding.share === 'number') {
|
||||
totalCost += holding.cost * holding.share;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const returnRate = totalCost > 0 ? (totalHoldingReturn / totalCost) * 100 : 0;
|
||||
|
||||
return {
|
||||
totalAsset,
|
||||
totalProfitToday,
|
||||
totalHoldingReturn,
|
||||
hasHolding,
|
||||
returnRate,
|
||||
hasAnyTodayData,
|
||||
};
|
||||
}, [funds, holdings, getProfit]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = rowRef.current;
|
||||
if (!el) return;
|
||||
const height = el.clientHeight;
|
||||
const tooTall = height > 80;
|
||||
if (tooTall) {
|
||||
setAssetSize((s) => Math.max(16, s - 1));
|
||||
setMetricSize((s) => Math.max(12, s - 1));
|
||||
}
|
||||
}, [
|
||||
winW,
|
||||
summary.totalAsset,
|
||||
summary.totalProfitToday,
|
||||
summary.totalHoldingReturn,
|
||||
summary.returnRate,
|
||||
showPercent,
|
||||
assetSize,
|
||||
metricSize,
|
||||
]);
|
||||
|
||||
if (!summary.hasHolding) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={isSticky ? 'group-summary-sticky' : ''}
|
||||
style={isSticky && stickyTop ? { top: stickyTop } : {}}
|
||||
>
|
||||
<div
|
||||
className="glass card group-summary-card"
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
padding: '16px 20px',
|
||||
background: 'rgba(255, 255, 255, 0.03)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="sticky-toggle-btn"
|
||||
onClick={() => setIsSticky(!isSticky)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 24,
|
||||
height: 24,
|
||||
padding: 4,
|
||||
opacity: 0.6,
|
||||
zIndex: 10,
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
{isSticky ? (
|
||||
<PinIcon width="14" height="14" />
|
||||
) : (
|
||||
<PinOffIcon width="14" height="14" />
|
||||
)}
|
||||
</span>
|
||||
<div
|
||||
ref={rowRef}
|
||||
className="row"
|
||||
style={{ alignItems: 'flex-end', justifyContent: 'space-between' }}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}
|
||||
>
|
||||
<div className="muted" style={{ fontSize: '12px' }}>
|
||||
{groupName}
|
||||
</div>
|
||||
<button
|
||||
className="fav-button"
|
||||
onClick={() => setIsMasked((value) => !value)}
|
||||
aria-label={isMasked ? '显示资产' : '隐藏资产'}
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 2,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{isMasked ? (
|
||||
<EyeOffIcon width="16" height="16" />
|
||||
) : (
|
||||
<EyeIcon width="16" height="16" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 700,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px', marginRight: 2 }}>¥</span>
|
||||
{isMasked ? (
|
||||
<span
|
||||
style={{ fontSize: assetSize, position: 'relative', top: 4 }}
|
||||
>
|
||||
******
|
||||
</span>
|
||||
) : (
|
||||
<CountUp value={summary.totalAsset} style={{ fontSize: assetSize }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 24 }}>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div
|
||||
className="muted"
|
||||
style={{ fontSize: '12px', marginBottom: 4 }}
|
||||
>
|
||||
当日收益
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
summary.hasAnyTodayData
|
||||
? summary.totalProfitToday > 0
|
||||
? 'up'
|
||||
: summary.totalProfitToday < 0
|
||||
? 'down'
|
||||
: ''
|
||||
: 'muted'
|
||||
}
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 700,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}}
|
||||
>
|
||||
{isMasked ? (
|
||||
<span style={{ fontSize: metricSize }}>******</span>
|
||||
) : summary.hasAnyTodayData ? (
|
||||
<>
|
||||
<span style={{ marginRight: 1 }}>
|
||||
{summary.totalProfitToday > 0
|
||||
? '+'
|
||||
: summary.totalProfitToday < 0
|
||||
? '-'
|
||||
: ''}
|
||||
</span>
|
||||
<CountUp
|
||||
value={Math.abs(summary.totalProfitToday)}
|
||||
style={{ fontSize: metricSize }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ fontSize: metricSize }}>--</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div
|
||||
className="muted"
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
marginBottom: 4,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
持有收益{showPercent ? '(%)' : ''}{' '}
|
||||
<SwitchIcon style={{ opacity: 0.4 }} />
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
summary.totalHoldingReturn > 0
|
||||
? 'up'
|
||||
: summary.totalHoldingReturn < 0
|
||||
? 'down'
|
||||
: ''
|
||||
}
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 700,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => setShowPercent(!showPercent)}
|
||||
title="点击切换金额/百分比"
|
||||
>
|
||||
{isMasked ? (
|
||||
<span style={{ fontSize: metricSize }}>******</span>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ marginRight: 1 }}>
|
||||
{summary.totalHoldingReturn > 0
|
||||
? '+'
|
||||
: summary.totalHoldingReturn < 0
|
||||
? '-'
|
||||
: ''}
|
||||
</span>
|
||||
{showPercent ? (
|
||||
<CountUp
|
||||
value={Math.abs(summary.returnRate)}
|
||||
suffix="%"
|
||||
style={{ fontSize: metricSize }}
|
||||
/>
|
||||
) : (
|
||||
<CountUp
|
||||
value={Math.abs(summary.totalHoldingReturn)}
|
||||
style={{ fontSize: metricSize }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,6 @@ export default function HoldingActionModal({ fund, onClose, onAction, hasHistory
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<SettingsIcon width="20" height="20" />
|
||||
<span>持仓操作</span>
|
||||
{hasHistory && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAction('history')}
|
||||
@@ -47,9 +46,8 @@ export default function HoldingActionModal({ fund, onClose, onAction, hasHistory
|
||||
title="查看交易记录"
|
||||
>
|
||||
<span>📜</span>
|
||||
<span>记录</span>
|
||||
<span>交易记录</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||
<CloseIcon width="20" height="20" />
|
||||
@@ -62,12 +60,27 @@ export default function HoldingActionModal({ fund, onClose, onAction, hasHistory
|
||||
</div>
|
||||
|
||||
<div className="grid" style={{ gap: 12 }}>
|
||||
<button className="button col-6" onClick={() => onAction('buy')} style={{ background: 'rgba(34, 211, 238, 0.1)', border: '1px solid var(--primary)', color: 'var(--primary)' }}>
|
||||
<button
|
||||
className="button col-4"
|
||||
onClick={() => onAction('buy')}
|
||||
style={{ background: 'rgba(34, 211, 238, 0.1)', border: '1px solid var(--primary)', color: 'var(--primary)', fontSize: 14 }}
|
||||
>
|
||||
加仓
|
||||
</button>
|
||||
<button className="button col-6" onClick={() => onAction('sell')} style={{ background: 'rgba(248, 113, 113, 0.1)', border: '1px solid var(--danger)', color: 'var(--danger)' }}>
|
||||
<button
|
||||
className="button col-4"
|
||||
onClick={() => onAction('sell')}
|
||||
style={{ background: 'rgba(248, 113, 113, 0.1)', border: '1px solid var(--danger)', color: 'var(--danger)', fontSize: 14 }}
|
||||
>
|
||||
减仓
|
||||
</button>
|
||||
<button
|
||||
className="button col-4 dca-btn"
|
||||
onClick={() => onAction('dca')}
|
||||
style={{ fontSize: 14 }}
|
||||
>
|
||||
定投
|
||||
</button>
|
||||
<button className="button col-12" onClick={() => onAction('edit')} style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>
|
||||
编辑持仓
|
||||
</button>
|
||||
|
||||
@@ -158,6 +158,7 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
className={`input ${!amount ? 'error' : ''}`}
|
||||
value={amount}
|
||||
@@ -192,6 +193,7 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
className={`input ${!share ? 'error' : ''}`}
|
||||
value={share}
|
||||
@@ -209,6 +211,7 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
className={`input ${!cost ? 'error' : ''}`}
|
||||
value={cost}
|
||||
|
||||
@@ -77,6 +77,13 @@ export function RefreshIcon(props) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ResetIcon(props) {
|
||||
return (
|
||||
<svg t="1772152323013" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4796" width="16" height="16"><path fill="currentColor" d="M864 512a352 352 0 0 0-600.96-248.96c-15.744 15.872-40.704 42.88-63.232 67.648H320a32 32 0 1 1 0 64H128a31.872 31.872 0 0 1-32-32v-192a32 32 0 1 1 64 0v108.672c20.544-22.528 42.688-46.4 57.856-61.504a416 416 0 1 1 0 588.288 32 32 0 1 1 45.248-45.248A352 352 0 0 0 864 512z" p-id="4797"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronIcon(props) {
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
@@ -236,3 +243,37 @@ export function CameraIcon(props) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SunIcon(props) {
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MoonIcon(props) {
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SwitchIcon({ props }) {
|
||||
return (
|
||||
<svg t="1772945896369" className="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="2524" width="13" height="13">
|
||||
<path
|
||||
d="M885.247 477.597H132c-17.673 0-32-14.327-32-32s14.327-32 32-32h753.247c17.673 0 32 14.327 32 32s-14.327 32-32 32z"
|
||||
fill="currentColor" p-id="2525"></path>
|
||||
<path
|
||||
d="M893.366 477.392c-8.189 0-16.379-3.124-22.627-9.373L709.954 307.235c-12.497-12.497-12.497-32.758 0-45.255 12.496-12.497 32.758-12.497 45.254 0l160.785 160.785c12.497 12.497 12.497 32.758 0 45.255-6.248 6.248-14.437 9.372-22.627 9.372zM893.366 609.607H140.119c-17.673 0-32-14.327-32-32s14.327-32 32-32h753.248c17.673 0 32 14.327 32 32s-14.328 32-32.001 32z"
|
||||
fill="currentColor" p-id="2526"></path>
|
||||
<path
|
||||
d="M292.784 770.597c-8.189 0-16.379-3.124-22.627-9.373L109.373 600.439c-12.497-12.496-12.497-32.758 0-45.254 12.497-12.498 32.758-12.498 45.255 0L315.412 715.97c12.497 12.496 12.497 32.758 0 45.254-6.249 6.249-14.438 9.373-22.628 9.373z"
|
||||
fill="currentColor" p-id="2527"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
|
||||
import { MailIcon } from './Icons';
|
||||
|
||||
export default function LoginModal({
|
||||
@@ -56,15 +57,21 @@ export default function LoginModal({
|
||||
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>
|
||||
请输入邮箱验证码以完成注册/登录
|
||||
</div>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="输入验证码"
|
||||
value={loginOtp}
|
||||
onChange={(e) => setLoginOtp(e.target.value)}
|
||||
disabled={loginLoading}
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
/>
|
||||
value={loginOtp}
|
||||
onChange={(value) => setLoginOtp(value)}
|
||||
disabled={loginLoading}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
)}
|
||||
{loginError && (
|
||||
|
||||
1055
app/components/MobileFundTable.jsx
Normal file
1055
app/components/MobileFundTable.jsx
Normal file
File diff suppressed because it is too large
Load Diff
213
app/components/MobileSettingModal.jsx
Normal file
213
app/components/MobileSettingModal.jsx
Normal file
@@ -0,0 +1,213 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AnimatePresence, Reorder } from 'framer-motion';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerClose,
|
||||
} from '@/components/ui/drawer';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from './Icons';
|
||||
|
||||
/**
|
||||
* 移动端表格个性化设置弹框(底部抽屉,基于 Drawer 组件)
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.open - 是否打开
|
||||
* @param {() => void} props.onClose - 关闭回调
|
||||
* @param {Array<{id: string, header: string}>} props.columns - 非冻结列(id + 表头名称)
|
||||
* @param {Record<string, boolean>} [props.columnVisibility] - 列显示状态映射(id => 是否显示)
|
||||
* @param {(newOrder: string[]) => void} props.onColumnReorder - 列顺序变更回调
|
||||
* @param {(id: string, visible: boolean) => void} props.onToggleColumnVisibility - 列显示/隐藏切换回调
|
||||
* @param {() => void} props.onResetColumnOrder - 重置列顺序回调
|
||||
* @param {() => void} props.onResetColumnVisibility - 重置列显示/隐藏回调
|
||||
* @param {boolean} [props.showFullFundName] - 是否展示完整基金名称
|
||||
* @param {(show: boolean) => void} [props.onToggleShowFullFundName] - 切换是否展示完整基金名称回调
|
||||
*/
|
||||
export default function MobileSettingModal({
|
||||
open,
|
||||
onClose,
|
||||
columns = [],
|
||||
columnVisibility,
|
||||
onColumnReorder,
|
||||
onToggleColumnVisibility,
|
||||
onResetColumnOrder,
|
||||
onResetColumnVisibility,
|
||||
showFullFundName,
|
||||
onToggleShowFullFundName,
|
||||
}) {
|
||||
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setResetConfirmOpen(false);
|
||||
}, [open]);
|
||||
|
||||
const handleReorder = (newItems) => {
|
||||
const newOrder = newItems.map((item) => item.id);
|
||||
onColumnReorder?.(newOrder);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) onClose();
|
||||
}}
|
||||
direction="bottom"
|
||||
>
|
||||
<DrawerContent
|
||||
className="glass"
|
||||
defaultHeight="77vh"
|
||||
minHeight="40vh"
|
||||
maxHeight="90vh"
|
||||
>
|
||||
<DrawerHeader className="mobile-setting-header flex-row items-center justify-between gap-2 py-5 pt-5 text-base font-semibold">
|
||||
<DrawerTitle className="flex items-center gap-2.5 text-left">
|
||||
<SettingsIcon width="20" height="20" />
|
||||
<span>个性化设置</span>
|
||||
</DrawerTitle>
|
||||
<DrawerClose
|
||||
className="icon-button border-none bg-transparent p-1"
|
||||
title="关闭"
|
||||
style={{ borderColor: 'transparent', backgroundColor: 'transparent' }}
|
||||
>
|
||||
<CloseIcon width="20" height="20" />
|
||||
</DrawerClose>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="mobile-setting-body flex flex-1 flex-col overflow-y-auto">
|
||||
{onToggleShowFullFundName && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 0',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '14px' }}>展示完整基金名称</span>
|
||||
<Switch
|
||||
checked={!!showFullFundName}
|
||||
onCheckedChange={(checked) => {
|
||||
onToggleShowFullFundName?.(!!checked);
|
||||
}}
|
||||
title={showFullFundName ? '关闭' : '开启'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="mobile-setting-subtitle">表头设置</h3>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<p className="muted" style={{ fontSize: '13px', margin: 0 }}>
|
||||
拖拽调整列顺序
|
||||
</p>
|
||||
{(onResetColumnOrder || onResetColumnVisibility) && (
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={() => setResetConfirmOpen(true)}
|
||||
title="重置表头设置"
|
||||
style={{
|
||||
border: 'none',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--muted)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<ResetIcon width="16" height="16" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{columns.length === 0 ? (
|
||||
<div className="muted" style={{ textAlign: 'center', padding: '24px 0', fontSize: '14px' }}>
|
||||
暂无可配置列
|
||||
</div>
|
||||
) : (
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={columns}
|
||||
onReorder={handleReorder}
|
||||
className="mobile-setting-list"
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{columns.map((item, index) => (
|
||||
<Reorder.Item
|
||||
key={item.id || `col-${index}`}
|
||||
value={item}
|
||||
className="mobile-setting-item glass"
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.98 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 500,
|
||||
damping: 35,
|
||||
mass: 1,
|
||||
layout: { duration: 0.2 },
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="drag-handle"
|
||||
style={{
|
||||
cursor: 'grab',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 8px',
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
<DragIcon width="18" height="18" />
|
||||
</div>
|
||||
<span style={{ flex: 1, fontSize: '14px' }}>{item.header}</span>
|
||||
{onToggleColumnVisibility && (
|
||||
<Switch
|
||||
checked={columnVisibility?.[item.id] !== false}
|
||||
onCheckedChange={(checked) => {
|
||||
onToggleColumnVisibility(item.id, !!checked);
|
||||
}}
|
||||
title={columnVisibility?.[item.id] === false ? '显示' : '隐藏'}
|
||||
/>
|
||||
)}
|
||||
</Reorder.Item>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
)}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<AnimatePresence>
|
||||
{resetConfirmOpen && (
|
||||
<ConfirmModal
|
||||
key="mobile-reset-confirm"
|
||||
title="重置表头设置"
|
||||
message="是否重置表头顺序和显示/隐藏为默认值?"
|
||||
onConfirm={() => {
|
||||
onResetColumnOrder?.();
|
||||
onResetColumnVisibility?.();
|
||||
setResetConfirmOpen(false);
|
||||
}}
|
||||
onCancel={() => setResetConfirmOpen(false)}
|
||||
confirmText="重置"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1215
app/components/PcFundTable.jsx
Normal file
1215
app/components/PcFundTable.jsx
Normal file
File diff suppressed because it is too large
Load Diff
284
app/components/PcTableSettingModal.jsx
Normal file
284
app/components/PcTableSettingModal.jsx
Normal file
@@ -0,0 +1,284 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AnimatePresence, motion, Reorder } from 'framer-motion';
|
||||
import { createPortal } from 'react-dom';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from './Icons';
|
||||
|
||||
/**
|
||||
* PC 表格个性化设置侧弹框
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.open - 是否打开
|
||||
* @param {() => void} props.onClose - 关闭回调
|
||||
* @param {Array<{id: string, header: string}>} props.columns - 非冻结列(id + 表头名称)
|
||||
* @param {Record<string, boolean>} [props.columnVisibility] - 列显示状态映射(id => 是否显示)
|
||||
* @param {(newOrder: string[]) => void} props.onColumnReorder - 列顺序变更回调,参数为新的列 id 顺序
|
||||
* @param {(id: string, visible: boolean) => void} props.onToggleColumnVisibility - 列显示/隐藏切换回调
|
||||
* @param {() => void} props.onResetColumnOrder - 重置列顺序回调,需二次确认
|
||||
* @param {() => void} props.onResetColumnVisibility - 重置列显示/隐藏回调
|
||||
* @param {() => void} props.onResetSizing - 点击重置列宽时的回调(通常用于打开确认弹框)
|
||||
* @param {boolean} [props.showFullFundName] - 是否展示完整基金名称
|
||||
* @param {(show: boolean) => void} [props.onToggleShowFullFundName] - 切换是否展示完整基金名称回调
|
||||
*/
|
||||
export default function PcTableSettingModal({
|
||||
open,
|
||||
onClose,
|
||||
columns = [],
|
||||
columnVisibility,
|
||||
onColumnReorder,
|
||||
onToggleColumnVisibility,
|
||||
onResetColumnOrder,
|
||||
onResetColumnVisibility,
|
||||
onResetSizing,
|
||||
showFullFundName,
|
||||
onToggleShowFullFundName,
|
||||
}) {
|
||||
const [resetOrderConfirmOpen, setResetOrderConfirmOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setResetOrderConfirmOpen(false);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleReorder = (newItems) => {
|
||||
const newOrder = newItems.map((item) => item.id);
|
||||
onColumnReorder?.(newOrder);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
key="drawer"
|
||||
className="pc-table-setting-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="个性化设置"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={onClose}
|
||||
style={{ zIndex: 10001 }}
|
||||
>
|
||||
<motion.aside
|
||||
className="pc-table-setting-drawer glass"
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="pc-table-setting-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<SettingsIcon width="20" height="20" />
|
||||
<span>个性化设置</span>
|
||||
</div>
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={onClose}
|
||||
title="关闭"
|
||||
style={{ border: 'none', background: 'transparent' }}
|
||||
>
|
||||
<CloseIcon width="20" height="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pc-table-setting-body">
|
||||
{onToggleShowFullFundName && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 0',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '14px' }}>展示完整基金名称</span>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button pc-table-column-switch"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleShowFullFundName(!showFullFundName);
|
||||
}}
|
||||
title={showFullFundName ? '关闭' : '开启'}
|
||||
style={{
|
||||
border: 'none',
|
||||
padding: '0 4px',
|
||||
backgroundColor: 'transparent',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span className={`dca-toggle-track ${showFullFundName ? 'enabled' : ''}`}>
|
||||
<span
|
||||
className="dca-toggle-thumb"
|
||||
style={{ left: showFullFundName ? 16 : 2 }}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="pc-table-setting-subtitle">表头设置</h3>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<p className="muted" style={{ fontSize: '13px', margin: 0 }}>
|
||||
拖拽调整列顺序
|
||||
</p>
|
||||
{onResetColumnOrder && (
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={() => setResetOrderConfirmOpen(true)}
|
||||
title="重置列顺序"
|
||||
style={{
|
||||
border: 'none',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--muted)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<ResetIcon width="16" height="16" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{columns.length === 0 ? (
|
||||
<div className="muted" style={{ textAlign: 'center', padding: '24px 0', fontSize: '14px' }}>
|
||||
暂无可配置列
|
||||
</div>
|
||||
) : (
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={columns}
|
||||
onReorder={handleReorder}
|
||||
className="pc-table-setting-list"
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{columns.map((item, index) => (
|
||||
<Reorder.Item
|
||||
key={item.id || `col-${index}`}
|
||||
value={item}
|
||||
className="pc-table-setting-item glass"
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.98 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 500,
|
||||
damping: 35,
|
||||
mass: 1,
|
||||
layout: { duration: 0.2 },
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="drag-handle"
|
||||
style={{
|
||||
cursor: 'grab',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 8px',
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
<DragIcon width="18" height="18" />
|
||||
</div>
|
||||
<span style={{ flex: 1, fontSize: '14px' }}>{item.header}</span>
|
||||
{onToggleColumnVisibility && (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button pc-table-column-switch"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleColumnVisibility(item.id, columnVisibility?.[item.id] === false);
|
||||
}}
|
||||
title={columnVisibility?.[item.id] === false ? '显示' : '隐藏'}
|
||||
style={{
|
||||
border: 'none',
|
||||
padding: '0 4px',
|
||||
backgroundColor: 'transparent',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span className={`dca-toggle-track ${columnVisibility?.[item.id] !== false ? 'enabled' : ''}`}>
|
||||
<span
|
||||
className="dca-toggle-thumb"
|
||||
style={{ left: columnVisibility?.[item.id] !== false ? 16 : 2 }}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</Reorder.Item>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
)}
|
||||
{onResetSizing && (
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => {
|
||||
onResetSizing();
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
marginTop: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<ResetIcon width="16" height="16" />
|
||||
重置列宽
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.aside>
|
||||
</motion.div>
|
||||
)}
|
||||
{resetOrderConfirmOpen && (
|
||||
<ConfirmModal
|
||||
key="reset-order-confirm"
|
||||
title="重置表头设置"
|
||||
message="是否重置表头顺序和显示/隐藏为默认值?"
|
||||
onConfirm={() => {
|
||||
onResetColumnOrder?.();
|
||||
onResetColumnVisibility?.();
|
||||
setResetOrderConfirmOpen(false);
|
||||
}}
|
||||
onCancel={() => setResetOrderConfirmOpen(false)}
|
||||
confirmText="重置"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
if (typeof document === 'undefined') return null;
|
||||
return createPortal(content, document.body);
|
||||
}
|
||||
40
app/components/RefreshButton.jsx
Normal file
40
app/components/RefreshButton.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshIcon } from './Icons';
|
||||
|
||||
export default function RefreshButton({ refreshCycleStartRef, refreshMs, manualRefresh, refreshing, fundsLength }) {
|
||||
|
||||
// 刷新周期进度 0~1,用于环形进度条
|
||||
const [refreshProgress, setRefreshProgress] = useState(0);
|
||||
|
||||
// 刷新进度条:每 100ms 更新一次进度
|
||||
useEffect(() => {
|
||||
if (fundsLength === 0 || refreshMs <= 0) return;
|
||||
const t = setInterval(() => {
|
||||
const elapsed = Date.now() - refreshCycleStartRef.current;
|
||||
const p = Math.min(1, elapsed / refreshMs);
|
||||
setRefreshProgress(p);
|
||||
}, 100);
|
||||
return () => clearInterval(t);
|
||||
}, [fundsLength, refreshMs]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="refresh-btn-wrap"
|
||||
style={{ '--progress': refreshProgress }}
|
||||
title={`刷新周期 ${Math.round(refreshMs / 1000)} 秒`}
|
||||
>
|
||||
<button
|
||||
className="icon-button"
|
||||
aria-label="立即刷新"
|
||||
onClick={manualRefresh}
|
||||
disabled={refreshing || fundsLength === 0}
|
||||
aria-busy={refreshing}
|
||||
title="立即刷新"
|
||||
>
|
||||
<RefreshIcon className={refreshing ? 'spin' : ''} width="18" height="18" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { CloseIcon } from './Icons';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
export default function ScanImportConfirmModal({
|
||||
scannedFunds,
|
||||
@@ -9,8 +17,23 @@ export default function ScanImportConfirmModal({
|
||||
onClose,
|
||||
onToggle,
|
||||
onConfirm,
|
||||
refreshing
|
||||
refreshing,
|
||||
groups = [],
|
||||
isOcrScan = false
|
||||
}) {
|
||||
const [selectedGroupId, setSelectedGroupId] = useState('all');
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(selectedGroupId);
|
||||
};
|
||||
|
||||
const formatAmount = (val) => {
|
||||
if (!val) return null;
|
||||
const num = parseFloat(String(val).replace(/,/g, ''));
|
||||
if (isNaN(num)) return null;
|
||||
return num;
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
@@ -28,7 +51,7 @@ export default function ScanImportConfirmModal({
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="glass card modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ width: 460, maxWidth: '90vw' }}
|
||||
style={{ width: 480, maxWidth: '90vw' }}
|
||||
>
|
||||
<div className="title" style={{ marginBottom: 12, justifyContent: 'space-between' }}>
|
||||
<span>确认导入基金</span>
|
||||
@@ -36,18 +59,27 @@ export default function ScanImportConfirmModal({
|
||||
<CloseIcon width="20" height="20" />
|
||||
</button>
|
||||
</div>
|
||||
{isOcrScan && (
|
||||
<div className="ocr-warning" style={{ marginBottom: 12 }}>
|
||||
<span>拍照识别方案目前还在优化,请确认识别结果是否正确。</span>
|
||||
</div>
|
||||
)}
|
||||
{scannedFunds.length === 0 ? (
|
||||
<div className="muted" style={{ fontSize: 13, lineHeight: 1.6 }}>
|
||||
未识别到有效的基金代码,请尝试更清晰的截图或手动搜索。
|
||||
</div>
|
||||
) : (
|
||||
<div className="search-results pending-list" style={{ maxHeight: 320, overflowY: 'auto' }}>
|
||||
<>
|
||||
<div className="search-results pending-list" style={{ maxHeight: 360, overflowY: 'auto' }}>
|
||||
{scannedFunds.map((item) => {
|
||||
const isSelected = selectedScannedCodes.has(item.code);
|
||||
const isAlreadyAdded = item.status === 'added';
|
||||
const isInvalid = item.status === 'invalid';
|
||||
const isDisabled = isAlreadyAdded || isInvalid;
|
||||
const displayName = item.name || (isInvalid ? '未找到基金' : '未知基金');
|
||||
const holdAmounts = formatAmount(item.holdAmounts);
|
||||
const holdGains = formatAmount(item.holdGains);
|
||||
const hasHoldingData = holdAmounts !== null && holdGains !== null;
|
||||
return (
|
||||
<div
|
||||
key={item.code}
|
||||
@@ -57,8 +89,9 @@ export default function ScanImportConfirmModal({
|
||||
if (isDisabled) return;
|
||||
onToggle(item.code);
|
||||
}}
|
||||
style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
|
||||
style={{ cursor: isDisabled ? 'not-allowed' : 'pointer', flexDirection: 'column', alignItems: 'stretch' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div className="fund-info">
|
||||
<span className="fund-name">{displayName}</span>
|
||||
<span className="fund-code muted">#{item.code}</span>
|
||||
@@ -73,13 +106,46 @@ export default function ScanImportConfirmModal({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasHoldingData && !isDisabled && (
|
||||
<div style={{ display: 'flex', gap: 16, marginTop: 6, paddingLeft: 0 }}>
|
||||
{holdAmounts !== null && (
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
持有金额:<span style={{ color: 'var(--text)', fontWeight: 500 }}>¥{holdAmounts.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
|
||||
</span>
|
||||
)}
|
||||
{holdGains !== null && (
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
持有收益:<span style={{ color: holdGains >= 0 ? 'var(--danger)' : 'var(--success)', fontWeight: 500 }}>
|
||||
{holdGains >= 0 ? '+' : '-'}¥{Math.abs(holdGains).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ marginTop: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="muted" style={{ fontSize: 13, whiteSpace: 'nowrap' }}>添加到分组:</span>
|
||||
<Select value={selectedGroupId} onValueChange={(value) => setSelectedGroupId(value)}>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="选择分组" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
<SelectItem value="fav">自选</SelectItem>
|
||||
{groups.filter(g => g.id !== 'all' && g.id !== 'fav').map(g => (
|
||||
<SelectItem key={g.id} value={g.id}>{g.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 12 }}>
|
||||
<button className="button secondary" onClick={onClose}>取消</button>
|
||||
<button className="button" onClick={onConfirm} disabled={selectedScannedCodes.size === 0 || refreshing}>确认导入</button>
|
||||
<button className="button" onClick={handleConfirm} disabled={selectedScannedCodes.size === 0 || refreshing}>确认导入</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,8 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function ScanPickModal({ onClose, onPick, isScanning }) {
|
||||
const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
function getDroppedImageFiles(dataTransfer) {
|
||||
if (!dataTransfer?.files?.length) return [];
|
||||
return Array.from(dataTransfer.files).filter((f) =>
|
||||
IMAGE_TYPES.includes(f.type)
|
||||
);
|
||||
}
|
||||
|
||||
export default function ScanPickModal({ onClose, onPick, onFilesDrop, isScanning }) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleDragOver = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isScanning) setIsDragging(true);
|
||||
}, [isScanning]);
|
||||
|
||||
const handleDragLeave = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
if (isScanning || !onFilesDrop) return;
|
||||
const files = getDroppedImageFiles(e.dataTransfer);
|
||||
if (files.length) onFilesDrop(files);
|
||||
}, [isScanning, onFilesDrop]);
|
||||
|
||||
const dropZoneStyle = {
|
||||
marginBottom: 12,
|
||||
padding: '20px 16px',
|
||||
borderRadius: 12,
|
||||
transition: 'border-color 0.2s ease, background 0.2s ease',
|
||||
cursor: isScanning ? 'not-allowed' : 'pointer',
|
||||
pointerEvents: isScanning ? 'none' : 'auto',
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
@@ -18,7 +60,7 @@ export default function ScanPickModal({ onClose, onPick, isScanning }) {
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="glass card modal"
|
||||
className="glass card modal scan-pick-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ width: 420, maxWidth: '90vw' }}
|
||||
>
|
||||
@@ -26,7 +68,28 @@ export default function ScanPickModal({ onClose, onPick, isScanning }) {
|
||||
<span>选择持仓截图</span>
|
||||
</div>
|
||||
<div className="muted" style={{ fontSize: 13, lineHeight: 1.6, marginBottom: 12 }}>
|
||||
从相册选择一张或多张持仓截图,系统将自动识别其中的基金代码(6位数字),并支持批量导入。
|
||||
从相册选择一张或多张持仓截图,系统将自动识别其中的<span style={{ color: 'var(--primary)' }}>基金代码(6位数字)</span>,并支持批量导入。
|
||||
</div>
|
||||
<div
|
||||
className={`scan-pick-dropzone muted ${isDragging ? 'dragging' : ''}`}
|
||||
style={dropZoneStyle}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={!isScanning ? onPick : undefined}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="拖拽图片到此处或点击选择"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (!isScanning) onPick?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, lineHeight: 1.5, color: isDragging ? 'var(--primary)' : 'var(--muted)', textAlign: 'center' }}>
|
||||
{isDragging ? '松开即可导入' : '拖拽图片到此处,或点击选择'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button className="button secondary" onClick={onClose}>取消</button>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { SettingsIcon } from './Icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import { ResetIcon, SettingsIcon } from './Icons';
|
||||
|
||||
export default function SettingsModal({
|
||||
onClose,
|
||||
@@ -10,21 +12,44 @@ export default function SettingsModal({
|
||||
exportLocalData,
|
||||
importFileRef,
|
||||
handleImportFileChange,
|
||||
importMsg
|
||||
importMsg,
|
||||
isMobile,
|
||||
containerWidth = 1200,
|
||||
setContainerWidth,
|
||||
onResetContainerWidth,
|
||||
}) {
|
||||
const [sliderDragging, setSliderDragging] = useState(false);
|
||||
const [resetWidthConfirmOpen, setResetWidthConfirmOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sliderDragging) return;
|
||||
const onPointerUp = () => setSliderDragging(false);
|
||||
document.addEventListener('pointerup', onPointerUp);
|
||||
document.addEventListener('pointercancel', onPointerUp);
|
||||
return () => {
|
||||
document.removeEventListener('pointerup', onPointerUp);
|
||||
document.removeEventListener('pointercancel', onPointerUp);
|
||||
};
|
||||
}, [sliderDragging]);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="设置" onClick={onClose}>
|
||||
<div
|
||||
className={`modal-overlay ${sliderDragging ? 'modal-overlay-translucent' : ''}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="设置"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="glass card modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="title" style={{ marginBottom: 12 }}>
|
||||
<SettingsIcon width="20" height="20" />
|
||||
<span>设置</span>
|
||||
<span className="muted">配置刷新频率</span>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>刷新频率</div>
|
||||
<div className="chips" style={{ marginBottom: 12 }}>
|
||||
{[10, 30, 60, 120, 300].map((s) => (
|
||||
{[30, 60, 120, 300].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
@@ -39,19 +64,66 @@ export default function SettingsModal({
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="10"
|
||||
inputMode="numeric"
|
||||
min="30"
|
||||
step="5"
|
||||
value={tempSeconds}
|
||||
onChange={(e) => setTempSeconds(Number(e.target.value))}
|
||||
placeholder="自定义秒数"
|
||||
/>
|
||||
{tempSeconds < 10 && (
|
||||
{tempSeconds < 30 && (
|
||||
<div className="error-text" style={{ marginTop: 8 }}>
|
||||
最小 10 秒
|
||||
最小 30 秒
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isMobile && setContainerWidth && (
|
||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
|
||||
<div className="muted" style={{ fontSize: '0.8rem' }}>页面宽度</div>
|
||||
{onResetContainerWidth && (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={() => setResetWidthConfirmOpen(true)}
|
||||
title="重置页面宽度"
|
||||
style={{
|
||||
border: 'none',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
padding: 0,
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
<ResetIcon width="14" height="14" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<input
|
||||
type="range"
|
||||
min={600}
|
||||
max={2000}
|
||||
step={10}
|
||||
value={Math.min(2000, Math.max(600, Number(containerWidth) || 1200))}
|
||||
onChange={(e) => setContainerWidth(Number(e.target.value))}
|
||||
onPointerDown={() => setSliderDragging(true)}
|
||||
className="page-width-slider"
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 6,
|
||||
accentColor: 'var(--primary)',
|
||||
}}
|
||||
/>
|
||||
<span className="muted" style={{ fontSize: '0.8rem', minWidth: 48 }}>
|
||||
{Math.min(2000, Math.max(600, Number(containerWidth) || 1200))}px
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>数据导出</div>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
@@ -76,9 +148,21 @@ export default function SettingsModal({
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ justifyContent: 'flex-end', marginTop: 24 }}>
|
||||
<button className="button" onClick={saveSettings} disabled={tempSeconds < 10}>保存并关闭</button>
|
||||
<button className="button" onClick={saveSettings} disabled={tempSeconds < 30}>保存并关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
{resetWidthConfirmOpen && onResetContainerWidth && (
|
||||
<ConfirmModal
|
||||
title="重置页面宽度"
|
||||
message="是否重置页面宽度为默认值 1200px?"
|
||||
onConfirm={() => {
|
||||
onResetContainerWidth();
|
||||
setResetWidthConfirmOpen(false);
|
||||
}}
|
||||
onCancel={() => setResetWidthConfirmOpen(false)}
|
||||
confirmText="重置"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AnimatePresence, motion } from 'framer-motion';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import { isNumber } from 'lodash';
|
||||
import { fetchSmartFundNetValue } from '../api/fund';
|
||||
import { DatePicker, NumericInput } from './Common';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
@@ -58,7 +59,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
}
|
||||
}, [showPendingList, currentPendingTrades]);
|
||||
|
||||
const getEstimatePrice = () => fund?.estPricedCoverage > 0.05 ? fund?.estGsz : (typeof fund?.gsz === 'number' ? fund?.gsz : Number(fund?.dwjz));
|
||||
const getEstimatePrice = () => fund?.estPricedCoverage > 0.05 ? fund?.estGsz : (isNumber(fund?.gsz) ? fund?.gsz : Number(fund?.dwjz));
|
||||
const [price, setPrice] = useState(getEstimatePrice());
|
||||
const [loadingPrice, setLoadingPrice] = useState(false);
|
||||
const [actualDate, setActualDate] = useState(null);
|
||||
@@ -167,7 +168,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="glass card modal"
|
||||
className="glass card modal trade-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ maxWidth: '420px' }}
|
||||
>
|
||||
@@ -183,19 +184,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
|
||||
{!showPendingList && !showConfirm && currentPendingTrades.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
background: 'rgba(230, 162, 60, 0.1)',
|
||||
border: '1px solid rgba(230, 162, 60, 0.2)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 12px',
|
||||
fontSize: '12px',
|
||||
color: '#e6a23c',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
className="trade-pending-alert"
|
||||
onClick={() => setShowPendingList(true)}
|
||||
>
|
||||
<span>⚠️ 当前有 {currentPendingTrades.length} 笔待处理交易</span>
|
||||
@@ -205,7 +194,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
|
||||
{showPendingList ? (
|
||||
<div className="pending-list" style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||
<div className="pending-list-header" style={{ position: 'sticky', top: 0, zIndex: 1, background: 'rgba(15,23,42,0.95)', backdropFilter: 'blur(6px)', paddingBottom: 8, marginBottom: 8, borderBottom: '1px solid var(--border)' }}>
|
||||
<div className="pending-list-header trade-pending-header">
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => setShowPendingList(false)}
|
||||
@@ -216,7 +205,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
</div>
|
||||
<div className="pending-list-items" style={{ paddingTop: 0 }}>
|
||||
{currentPendingTrades.map((trade, idx) => (
|
||||
<div key={trade.id || idx} style={{ background: 'rgba(255,255,255,0.05)', padding: 12, borderRadius: 8, marginBottom: 8 }}>
|
||||
<div key={trade.id || idx} className="trade-pending-item">
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '14px', color: trade.type === 'buy' ? 'var(--danger)' : 'var(--success)' }}>
|
||||
{trade.type === 'buy' ? '买入' : '卖出'}
|
||||
@@ -230,17 +219,11 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 4 }}>
|
||||
<span className="muted">状态</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ color: '#e6a23c' }}>等待净值更新...</span>
|
||||
<span className="trade-pending-status">等待净值更新...</span>
|
||||
<button
|
||||
className="button secondary"
|
||||
className="button secondary trade-revoke-btn"
|
||||
onClick={() => setRevokeTrade(trade)}
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
fontSize: '10px',
|
||||
height: 'auto',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
color: 'var(--text)'
|
||||
}}
|
||||
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto' }}
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
@@ -262,7 +245,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
{showConfirm ? (
|
||||
isBuy ? (
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
<div style={{ background: 'rgba(255,255,255,0.05)', borderRadius: 12, padding: 16, marginBottom: 20 }}>
|
||||
<div className="trade-confirm-card">
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span className="muted">基金名称</span>
|
||||
<span style={{ fontWeight: 600 }}>{fund?.name}</span>
|
||||
@@ -287,7 +270,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
<span className="muted">买入日期</span>
|
||||
<span>{date}</span>
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 8 }}>
|
||||
<div className="row trade-confirm-divider" style={{ justifyContent: 'space-between', marginBottom: 8, paddingTop: 8 }}>
|
||||
<span className="muted">交易时段</span>
|
||||
<span>{isAfter3pm ? '15:00后' : '15:00前'}</span>
|
||||
</div>
|
||||
@@ -300,7 +283,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div className="muted" style={{ marginBottom: 8, fontSize: '12px' }}>持仓变化预览</div>
|
||||
<div className="row" style={{ gap: 12 }}>
|
||||
<div style={{ flex: 1, background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 8 }}>
|
||||
<div className="trade-preview-card" style={{ flex: 1 }}>
|
||||
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有份额</div>
|
||||
<div style={{ fontSize: '12px' }}>
|
||||
<span style={{ opacity: 0.7 }}>{holding.share.toFixed(2)}</span>
|
||||
@@ -309,7 +292,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
</div>
|
||||
</div>
|
||||
{price ? (
|
||||
<div style={{ flex: 1, background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 8 }}>
|
||||
<div className="trade-preview-card" style={{ flex: 1 }}>
|
||||
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有市值 (估)</div>
|
||||
<div style={{ fontSize: '12px' }}>
|
||||
<span style={{ opacity: 0.7 }}>¥{(holding.share * Number(price)).toFixed(2)}</span>
|
||||
@@ -325,9 +308,9 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
<div className="row" style={{ gap: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="button secondary"
|
||||
className="button secondary trade-back-btn"
|
||||
onClick={() => setShowConfirm(false)}
|
||||
style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
返回修改
|
||||
</button>
|
||||
@@ -344,7 +327,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
<div style={{ background: 'rgba(255,255,255,0.05)', borderRadius: 12, padding: 16, marginBottom: 20 }}>
|
||||
<div className="trade-confirm-card">
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span className="muted">基金名称</span>
|
||||
<span style={{ fontWeight: 600 }}>{fund?.name}</span>
|
||||
@@ -369,7 +352,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
<span className="muted">卖出日期</span>
|
||||
<span>{date}</span>
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 8 }}>
|
||||
<div className="row trade-confirm-divider" style={{ justifyContent: 'space-between', marginBottom: 8, paddingTop: 8 }}>
|
||||
<span className="muted">预计回款</span>
|
||||
<span style={{ color: 'var(--danger)', fontWeight: 700 }}>{loadingPrice ? '计算中...' : (price ? `¥${estimatedReturn.toFixed(2)}` : '待计算')}</span>
|
||||
</div>
|
||||
@@ -382,7 +365,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div className="muted" style={{ marginBottom: 8, fontSize: '12px' }}>持仓变化预览</div>
|
||||
<div className="row" style={{ gap: 12 }}>
|
||||
<div style={{ flex: 1, background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 8 }}>
|
||||
<div className="trade-preview-card" style={{ flex: 1 }}>
|
||||
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有份额</div>
|
||||
<div style={{ fontSize: '12px' }}>
|
||||
<span style={{ opacity: 0.7 }}>{holding.share.toFixed(2)}</span>
|
||||
@@ -391,7 +374,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
</div>
|
||||
</div>
|
||||
{price ? (
|
||||
<div style={{ flex: 1, background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 8 }}>
|
||||
<div className="trade-preview-card" style={{ flex: 1 }}>
|
||||
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有市值 (估)</div>
|
||||
<div style={{ fontSize: '12px' }}>
|
||||
<span style={{ opacity: 0.7 }}>¥{(holding.share * sellPrice).toFixed(2)}</span>
|
||||
@@ -407,9 +390,9 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
<div className="row" style={{ gap: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="button secondary"
|
||||
className="button secondary trade-back-btn"
|
||||
onClick={() => setShowConfirm(false)}
|
||||
style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
返回修改
|
||||
</button>
|
||||
@@ -471,36 +454,18 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||
交易时段
|
||||
</label>
|
||||
<div className="row" style={{ gap: 8, background: 'rgba(0,0,0,0.2)', borderRadius: '8px', padding: '4px' }}>
|
||||
<div className="trade-time-slot row" style={{ gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={!isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
||||
onClick={() => setIsAfter3pm(false)}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: 'none',
|
||||
background: !isAfter3pm ? 'var(--primary)' : 'transparent',
|
||||
color: !isAfter3pm ? '#05263b' : 'var(--muted)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
padding: '6px 8px'
|
||||
}}
|
||||
>
|
||||
15:00前
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
||||
onClick={() => setIsAfter3pm(true)}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: 'none',
|
||||
background: isAfter3pm ? 'var(--primary)' : 'transparent',
|
||||
color: isAfter3pm ? '#05263b' : 'var(--muted)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
padding: '6px 8px'
|
||||
}}
|
||||
>
|
||||
15:00后
|
||||
</button>
|
||||
@@ -543,17 +508,8 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
<button
|
||||
key={opt.label}
|
||||
type="button"
|
||||
className="trade-amount-btn"
|
||||
onClick={() => handleSetShareFraction(opt.value)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '4px 8px',
|
||||
fontSize: '12px',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--text)',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
@@ -562,7 +518,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
)}
|
||||
{holding && (
|
||||
<div className="muted" style={{ fontSize: '12px', marginTop: 6 }}>
|
||||
当前持仓: {holding.share.toFixed(2)} 份 {pendingSellShare > 0 && <span style={{ color: '#e6a23c', marginLeft: 8 }}>冻结: {pendingSellShare.toFixed(2)} 份</span>}
|
||||
当前持仓: {holding.share.toFixed(2)} 份 {pendingSellShare > 0 && <span className="trade-pending-status" style={{ marginLeft: 8 }}>冻结: {pendingSellShare.toFixed(2)} 份</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -613,36 +569,18 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||
交易时段
|
||||
</label>
|
||||
<div className="row" style={{ gap: 8, background: 'rgba(0,0,0,0.2)', borderRadius: '8px', padding: '4px' }}>
|
||||
<div className="trade-time-slot row" style={{ gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={!isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
||||
onClick={() => setIsAfter3pm(false)}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: 'none',
|
||||
background: !isAfter3pm ? 'var(--primary)' : 'transparent',
|
||||
color: !isAfter3pm ? '#05263b' : 'var(--muted)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
padding: '6px 8px'
|
||||
}}
|
||||
>
|
||||
15:00前
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
||||
onClick={() => setIsAfter3pm(true)}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: 'none',
|
||||
background: isAfter3pm ? 'var(--primary)' : 'transparent',
|
||||
color: isAfter3pm ? '#05263b' : 'var(--muted)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
padding: '6px 8px'
|
||||
}}
|
||||
>
|
||||
15:00后
|
||||
</button>
|
||||
@@ -662,7 +600,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
)}
|
||||
|
||||
<div className="row" style={{ gap: 12, marginTop: 12 }}>
|
||||
<button type="button" className="button secondary" onClick={onClose} style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>取消</button>
|
||||
<button type="button" className="button secondary trade-cancel-btn" onClick={onClose} style={{ flex: 1 }}>取消</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="button"
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function TransactionHistoryModal({
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="glass card modal"
|
||||
className="glass card modal tx-history-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ maxWidth: '480px', maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
@@ -78,7 +78,7 @@ export default function TransactionHistoryModal({
|
||||
onClick={onAddHistory}
|
||||
style={{ fontSize: '12px', padding: '4px 12px', height: 'auto' }}
|
||||
>
|
||||
➕ 添加记录
|
||||
添加记录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -88,11 +88,18 @@ export default function TransactionHistoryModal({
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div className="muted" style={{ fontSize: '12px', marginBottom: 8, paddingLeft: 4 }}>待处理队列</div>
|
||||
{pendingTransactions.map((item) => (
|
||||
<div key={item.id} style={{ background: 'rgba(230, 162, 60, 0.1)', border: '1px solid rgba(230, 162, 60, 0.2)', borderRadius: 8, padding: 12, marginBottom: 8 }}>
|
||||
<div key={item.id} className="tx-history-pending-item">
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '14px', color: item.type === 'buy' ? 'var(--danger)' : 'var(--success)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '14px', color: item.type === 'buy' ? 'var(--primary)' : 'var(--danger)' }}>
|
||||
{item.type === 'buy' ? '买入' : '卖出'}
|
||||
</span>
|
||||
{item.type === 'buy' && item.isDca && (
|
||||
<span className="tx-history-dca-badge">
|
||||
定投
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="muted" style={{ fontSize: '12px' }}>{item.date} {item.isAfter3pm ? '(15:00后)' : ''}</span>
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px' }}>
|
||||
@@ -100,11 +107,11 @@ export default function TransactionHistoryModal({
|
||||
<span>{item.share ? `${Number(item.share).toFixed(2)} 份` : `¥${Number(item.amount).toFixed(2)}`}</span>
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 8 }}>
|
||||
<span style={{ color: '#e6a23c' }}>等待净值更新...</span>
|
||||
<span className="tx-history-pending-status">等待净值更新...</span>
|
||||
<button
|
||||
className="button secondary"
|
||||
className="button secondary tx-history-action-btn"
|
||||
onClick={() => handleDeleteClick(item, 'pending')}
|
||||
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto', background: 'rgba(255,255,255,0.1)' }}
|
||||
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto' }}
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
@@ -121,11 +128,18 @@ export default function TransactionHistoryModal({
|
||||
<div className="muted" style={{ textAlign: 'center', padding: '20px 0', fontSize: '12px' }}>暂无历史交易记录</div>
|
||||
) : (
|
||||
sortedTransactions.map((item) => (
|
||||
<div key={item.id} style={{ background: 'rgba(255, 255, 255, 0.05)', borderRadius: 8, padding: 12, marginBottom: 8 }}>
|
||||
<div key={item.id} className="tx-history-record-item">
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '14px', color: item.type === 'buy' ? 'var(--danger)' : 'var(--success)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '14px', color: item.type === 'buy' ? 'var(--primary)' : 'var(--danger)' }}>
|
||||
{item.type === 'buy' ? '买入' : '卖出'}
|
||||
</span>
|
||||
{item.type === 'buy' && item.isDca && (
|
||||
<span className="tx-history-dca-badge">
|
||||
定投
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="muted" style={{ fontSize: '12px' }}>{item.date}</span>
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginBottom: 2 }}>
|
||||
@@ -145,9 +159,9 @@ export default function TransactionHistoryModal({
|
||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 8 }}>
|
||||
<span className="muted"></span>
|
||||
<button
|
||||
className="button secondary"
|
||||
className="button secondary tx-history-action-btn"
|
||||
onClick={() => handleDeleteClick(item, 'history')}
|
||||
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto', background: 'rgba(255,255,255,0.1)', color: 'var(--muted)' }}
|
||||
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto' }}
|
||||
>
|
||||
删除记录
|
||||
</button>
|
||||
|
||||
1925
app/globals.css
1925
app/globals.css
File diff suppressed because it is too large
Load Diff
204
app/hooks/useFundFuzzyMatcher.js
Normal file
204
app/hooks/useFundFuzzyMatcher.js
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { cachedRequest, clearCachedRequest } from '../lib/cacheRequest';
|
||||
|
||||
const FUND_CODE_SEARCH_URL = 'https://fund.eastmoney.com/js/fundcode_search.js';
|
||||
const FUND_LIST_CACHE_KEY = 'eastmoney_fundcode_search_list';
|
||||
const FUND_LIST_CACHE_TIME = 24 * 60 * 60 * 1000;
|
||||
|
||||
const formatEastMoneyFundList = (rawList) => {
|
||||
if (!Array.isArray(rawList)) return [];
|
||||
|
||||
return rawList
|
||||
.map((item) => {
|
||||
if (!Array.isArray(item)) return null;
|
||||
const code = String(item[0] ?? '').trim();
|
||||
const name = String(item[2] ?? '').trim();
|
||||
if (!code || !name) return null;
|
||||
return { code, name };
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
export const useFundFuzzyMatcher = () => {
|
||||
const allFundFuseRef = useRef(null);
|
||||
const allFundLoadPromiseRef = useRef(null);
|
||||
|
||||
const getAllFundFuse = useCallback(async () => {
|
||||
if (allFundFuseRef.current) return allFundFuseRef.current;
|
||||
if (allFundLoadPromiseRef.current) return allFundLoadPromiseRef.current;
|
||||
|
||||
allFundLoadPromiseRef.current = (async () => {
|
||||
const [fuseModule, allFundList] = await Promise.all([
|
||||
import('fuse.js'),
|
||||
cachedRequest(
|
||||
() =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined' || !document.body) {
|
||||
reject(new Error('NO_BROWSER_ENV'));
|
||||
return;
|
||||
}
|
||||
|
||||
const prevR = window.r;
|
||||
const script = document.createElement('script');
|
||||
script.src = `${FUND_CODE_SEARCH_URL}?_=${Date.now()}`;
|
||||
script.async = true;
|
||||
|
||||
const cleanup = () => {
|
||||
if (document.body.contains(script)) {
|
||||
document.body.removeChild(script);
|
||||
}
|
||||
if (prevR === undefined) {
|
||||
try {
|
||||
delete window.r;
|
||||
} catch (e) {
|
||||
window.r = undefined;
|
||||
}
|
||||
} else {
|
||||
window.r = prevR;
|
||||
}
|
||||
};
|
||||
|
||||
script.onload = () => {
|
||||
const snapshot = Array.isArray(window.r) ? JSON.parse(JSON.stringify(window.r)) : [];
|
||||
cleanup();
|
||||
const parsed = formatEastMoneyFundList(snapshot);
|
||||
if (!parsed.length) {
|
||||
reject(new Error('PARSE_ALL_FUND_FAILED'));
|
||||
return;
|
||||
}
|
||||
resolve(parsed);
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
cleanup();
|
||||
reject(new Error('LOAD_ALL_FUND_FAILED'));
|
||||
};
|
||||
|
||||
document.body.appendChild(script);
|
||||
}),
|
||||
FUND_LIST_CACHE_KEY,
|
||||
{ cacheTime: FUND_LIST_CACHE_TIME }
|
||||
),
|
||||
]);
|
||||
const Fuse = fuseModule.default;
|
||||
const fuse = new Fuse(Array.isArray(allFundList) ? allFundList : [], {
|
||||
keys: ['name', 'code'],
|
||||
includeScore: true,
|
||||
threshold: 0.5,
|
||||
ignoreLocation: true,
|
||||
minMatchCharLength: 2,
|
||||
});
|
||||
|
||||
allFundFuseRef.current = fuse;
|
||||
return fuse;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await allFundLoadPromiseRef.current;
|
||||
} catch (e) {
|
||||
allFundLoadPromiseRef.current = null;
|
||||
clearCachedRequest(FUND_LIST_CACHE_KEY);
|
||||
throw e;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const normalizeFundText = useCallback((value) => {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value
|
||||
.toUpperCase()
|
||||
.replace(/[((]/g, '(')
|
||||
.replace(/[))]/g, ')')
|
||||
.replace(/[·•]/g, '')
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[^\u4e00-\u9fa5A-Z0-9()]/g, '');
|
||||
}, []);
|
||||
|
||||
const parseFundQuerySignals = useCallback((rawName) => {
|
||||
const normalized = normalizeFundText(rawName);
|
||||
const hasETF = normalized.includes('ETF');
|
||||
const hasLOF = normalized.includes('LOF');
|
||||
const hasLink = normalized.includes('联接');
|
||||
const shareMatch = normalized.match(/([A-Z])(?:类)?$/i);
|
||||
const shareClass = shareMatch ? shareMatch[1].toUpperCase() : null;
|
||||
|
||||
const core = normalized
|
||||
.replace(/基金/g, '')
|
||||
.replace(/ETF联接/g, '')
|
||||
.replace(/联接[A-Z]?/g, '')
|
||||
.replace(/ETF/g, '')
|
||||
.replace(/LOF/g, '')
|
||||
.replace(/[A-Z](?:类)?$/g, '');
|
||||
|
||||
return {
|
||||
normalized,
|
||||
core,
|
||||
hasETF,
|
||||
hasLOF,
|
||||
hasLink,
|
||||
shareClass,
|
||||
};
|
||||
}, [normalizeFundText]);
|
||||
|
||||
const resolveFundCodeByFuzzy = useCallback(async (name) => {
|
||||
const querySignals = parseFundQuerySignals(name);
|
||||
if (!querySignals.normalized) return null;
|
||||
|
||||
const len = querySignals.normalized.length;
|
||||
const strictThreshold = len <= 4 ? 0.16 : len <= 8 ? 0.22 : 0.28;
|
||||
const relaxedThreshold = Math.min(0.45, strictThreshold + 0.16);
|
||||
const scoreGapThreshold = len <= 5 ? 0.08 : 0.06;
|
||||
|
||||
const fuse = await getAllFundFuse();
|
||||
const recalled = fuse.search(name, { limit: 50 });
|
||||
if (!recalled.length) return null;
|
||||
|
||||
const stage1 = recalled.filter((item) => (item.score ?? 1) <= relaxedThreshold);
|
||||
if (!stage1.length) return null;
|
||||
|
||||
const ranked = stage1
|
||||
.map((item) => {
|
||||
const candidateSignals = parseFundQuerySignals(item?.item?.name || '');
|
||||
let finalScore = item.score ?? 1;
|
||||
|
||||
if (querySignals.hasETF) {
|
||||
finalScore += candidateSignals.hasETF ? -0.04 : 0.2;
|
||||
}
|
||||
if (querySignals.hasLOF) {
|
||||
finalScore += candidateSignals.hasLOF ? -0.04 : 0.2;
|
||||
}
|
||||
if (querySignals.hasLink) {
|
||||
finalScore += candidateSignals.hasLink ? -0.03 : 0.18;
|
||||
}
|
||||
if (querySignals.shareClass) {
|
||||
finalScore += candidateSignals.shareClass === querySignals.shareClass ? -0.03 : 0.18;
|
||||
}
|
||||
|
||||
if (querySignals.core && candidateSignals.core) {
|
||||
if (candidateSignals.core.includes(querySignals.core)) {
|
||||
finalScore -= 0.06;
|
||||
} else if (!querySignals.core.includes(candidateSignals.core)) {
|
||||
finalScore += 0.06;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...item, finalScore };
|
||||
})
|
||||
.sort((a, b) => a.finalScore - b.finalScore);
|
||||
|
||||
const top1 = ranked[0];
|
||||
if (!top1 || top1.finalScore > strictThreshold) return null;
|
||||
|
||||
const top2 = ranked[1];
|
||||
if (top2 && (top2.finalScore - top1.finalScore) < scoreGapThreshold) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return top1?.item?.code || null;
|
||||
}, [getAllFundFuse, parseFundQuerySignals]);
|
||||
|
||||
return {
|
||||
resolveFundCodeByFuzzy,
|
||||
};
|
||||
};
|
||||
|
||||
export default useFundFuzzyMatcher;
|
||||
@@ -11,9 +11,20 @@ export default function RootLayout({ children }) {
|
||||
const GA_ID = process.env.NEXT_PUBLIC_GA_ID;
|
||||
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-title" content="基估宝" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default"/>
|
||||
<link rel="apple-touch-icon" href="/Icon-60@3x.png?v=1"/>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/Icon-60@3x.png?v=1"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
{/* 尽早设置 data-theme,减少首屏主题闪烁;与 suppressHydrationWarning 配合避免服务端/客户端 html 属性不一致报错 */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){try{var t=localStorage.getItem("theme");if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t);}catch(e){}})();`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<AnalyticsGate GA_ID={GA_ID} />
|
||||
|
||||
56
app/lib/tradingCalendar.js
Normal file
56
app/lib/tradingCalendar.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* A股交易日历:基于 chinese-days 节假日数据,严格判断某日期是否为交易日
|
||||
* 交易日 = 周一至周五 且 不在法定节假日
|
||||
* 调休补班日(周末变工作日)A股仍休市,故不视为交易日
|
||||
*/
|
||||
|
||||
const CDN_BASE = 'https://cdn.jsdelivr.net/npm/chinese-days@1/dist/years';
|
||||
const yearCache = new Map(); // year -> Set<dateStr> (holidays)
|
||||
|
||||
/**
|
||||
* 加载某年的节假日数据
|
||||
* @param {number} year
|
||||
* @returns {Promise<Set<string>>} 节假日日期集合,格式 YYYY-MM-DD
|
||||
*/
|
||||
export async function loadHolidaysForYear(year) {
|
||||
if (yearCache.has(year)) {
|
||||
return yearCache.get(year);
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${CDN_BASE}/${year}.json`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const holidays = new Set(Object.keys(data?.holidays ?? {}));
|
||||
yearCache.set(year, holidays);
|
||||
return holidays;
|
||||
} catch (e) {
|
||||
console.warn(`[tradingCalendar] 加载 ${year} 年节假日失败:`, e);
|
||||
yearCache.set(year, new Set());
|
||||
return yearCache.get(year);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载多个年份的节假日数据
|
||||
* @param {number[]} years
|
||||
*/
|
||||
export async function loadHolidaysForYears(years) {
|
||||
await Promise.all([...new Set(years)].map(loadHolidaysForYear));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断某日期是否为 A股交易日
|
||||
* @param {dayjs.Dayjs} date - dayjs 对象
|
||||
* @param {Map<number, Set<string>>} [cache] - 可选,已加载的年份缓存,默认使用内部 yearCache
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isTradingDay(date, cache = yearCache) {
|
||||
const dayOfWeek = date.day(); // 0=周日, 6=周六
|
||||
if (dayOfWeek === 0 || dayOfWeek === 6) return false;
|
||||
|
||||
const dateStr = date.format('YYYY-MM-DD');
|
||||
const year = date.year();
|
||||
const holidays = cache.get(year);
|
||||
if (!holidays) return true; // 未加载该年数据时,仅排除周末
|
||||
return !holidays.has(dateStr);
|
||||
}
|
||||
123
app/lib/valuationTimeseries.js
Normal file
123
app/lib/valuationTimeseries.js
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 记录每次调用基金估值接口的结果,用于分时图。
|
||||
* 规则:获取到最新日期的数据时,清掉所有老日期的数据,只保留当日分时点。
|
||||
*/
|
||||
import { isPlainObject, isString } from 'lodash';
|
||||
|
||||
const STORAGE_KEY = 'fundValuationTimeseries';
|
||||
|
||||
function getStored() {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : {};
|
||||
return isPlainObject(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function setStored(data) {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
} catch (e) {
|
||||
console.warn('valuationTimeseries persist failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 gztime 或 Date 得到日期字符串 YYYY-MM-DD
|
||||
*/
|
||||
function toDateStr(gztimeOrNow) {
|
||||
if (isString(gztimeOrNow) && /^\d{4}-\d{2}-\d{2}/.test(gztimeOrNow)) {
|
||||
return gztimeOrNow.slice(0, 10);
|
||||
}
|
||||
try {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一条估值。仅当 value 为有效数字时写入。
|
||||
* 数据清理:若当前点所属日期大于已存点的最大日期,则清空该基金下所有旧日期的数据,只保留当日分时。
|
||||
*
|
||||
* @param {string} code - 基金代码
|
||||
* @param {{ gsz?: number | null, gztime?: string | null }} payload - 估值与时间(来自接口)
|
||||
* @returns {Array<{ time: string, value: number, date: string }>} 该基金当前分时序列(按时间升序)
|
||||
*/
|
||||
export function recordValuation(code, payload) {
|
||||
const value = payload?.gsz != null ? Number(payload.gsz) : NaN;
|
||||
if (!Number.isFinite(value)) return getValuationSeries(code);
|
||||
|
||||
const gztime = payload?.gztime ?? null;
|
||||
const dateStr = toDateStr(gztime);
|
||||
if (!dateStr) return getValuationSeries(code);
|
||||
|
||||
const timeLabel = isString(gztime) && gztime.length > 10
|
||||
? gztime.slice(11, 16)
|
||||
: (() => {
|
||||
const d = new Date();
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
})();
|
||||
|
||||
const newPoint = { time: timeLabel, value, date: dateStr };
|
||||
|
||||
const all = getStored();
|
||||
const list = Array.isArray(all[code]) ? all[code] : [];
|
||||
|
||||
const existingDates = list.map((p) => p.date).filter(Boolean);
|
||||
const latestStoredDate = existingDates.length ? existingDates.reduce((a, b) => (a > b ? a : b), '') : '';
|
||||
|
||||
let nextList;
|
||||
if (dateStr > latestStoredDate) {
|
||||
nextList = [newPoint];
|
||||
} else if (dateStr === latestStoredDate) {
|
||||
const hasSameTime = list.some((p) => p.time === timeLabel);
|
||||
if (hasSameTime) return list;
|
||||
nextList = [...list, newPoint];
|
||||
} else {
|
||||
return list;
|
||||
}
|
||||
|
||||
all[code] = nextList;
|
||||
setStored(all);
|
||||
return nextList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某基金的分时序列(只读)
|
||||
* @param {string} code - 基金代码
|
||||
* @returns {Array<{ time: string, value: number, date: string }>}
|
||||
*/
|
||||
export function getValuationSeries(code) {
|
||||
const all = getStored();
|
||||
const list = Array.isArray(all[code]) ? all[code] : [];
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除某基金的全部分时数据(如用户删除该基金时调用)
|
||||
* @param {string} code - 基金代码
|
||||
*/
|
||||
export function clearFund(code) {
|
||||
const all = getStored();
|
||||
if (!(code in all)) return;
|
||||
const next = { ...all };
|
||||
delete next[code];
|
||||
setStored(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全部分时数据,用于页面初始 state
|
||||
* @returns {{ [code: string]: Array<{ time: string, value: number, date: string }> }}
|
||||
*/
|
||||
export function getAllValuationSeries() {
|
||||
return getStored();
|
||||
}
|
||||
2148
app/page.jsx
2148
app/page.jsx
File diff suppressed because it is too large
Load Diff
23
components.json
Normal file
23
components.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": false,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
148
components/ui/dialog.jsx
Normal file
148
components/ui/dialog.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-[var(--dialog-overlay)] backdrop-blur-[4px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[16px] border border-[var(--border)] text-[var(--foreground)] p-6 dialog-content-shadow outline-none duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="absolute top-4 right-4 rounded-md p-1.5 text-[var(--muted-foreground)] opacity-70 transition-colors duration-200 hover:opacity-100 hover:text-[var(--foreground)] hover:bg-[var(--secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--card)] disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<button type="button" className="button secondary px-4 h-11 rounded-xl cursor-pointer">
|
||||
Close
|
||||
</button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold text-[var(--foreground)]", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-sm text-[var(--muted-foreground)]", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
222
components/ui/drawer.jsx
Normal file
222
components/ui/drawer.jsx
Normal file
@@ -0,0 +1,222 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function parseVhToPx(vhStr) {
|
||||
if (typeof vhStr === "number") return vhStr
|
||||
const match = String(vhStr).match(/^([\d.]+)\s*vh$/)
|
||||
if (!match) return null
|
||||
return (window.innerHeight * Number(match[1])) / 100
|
||||
}
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-[var(--drawer-overlay)] backdrop-blur-[4px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
defaultHeight = "77vh",
|
||||
minHeight = "20vh",
|
||||
maxHeight = "90vh",
|
||||
...props
|
||||
}) {
|
||||
const [heightPx, setHeightPx] = React.useState(() =>
|
||||
typeof window !== "undefined" ? parseVhToPx(defaultHeight) : null
|
||||
);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const dragRef = React.useRef({ startY: 0, startHeight: 0 });
|
||||
|
||||
const minPx = React.useMemo(() => parseVhToPx(minHeight), [minHeight]);
|
||||
const maxPx = React.useMemo(() => parseVhToPx(maxHeight), [maxHeight]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const px = parseVhToPx(defaultHeight);
|
||||
if (px != null) setHeightPx(px);
|
||||
}, [defaultHeight]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const sync = () => {
|
||||
const max = parseVhToPx(maxHeight);
|
||||
const min = parseVhToPx(minHeight);
|
||||
setHeightPx((prev) => {
|
||||
if (prev == null) return parseVhToPx(defaultHeight);
|
||||
const clamped = Math.min(prev, max ?? prev);
|
||||
return Math.max(clamped, min ?? clamped);
|
||||
});
|
||||
};
|
||||
window.addEventListener("resize", sync);
|
||||
return () => window.removeEventListener("resize", sync);
|
||||
}, [defaultHeight, minHeight, maxHeight]);
|
||||
|
||||
const handlePointerDown = React.useCallback(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
dragRef.current = { startY: e.clientY ?? e.touches?.[0]?.clientY, startHeight: heightPx ?? parseVhToPx(defaultHeight) ?? 0 };
|
||||
},
|
||||
[heightPx, defaultHeight]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
const move = (e) => {
|
||||
const clientY = e.clientY ?? e.touches?.[0]?.clientY;
|
||||
const { startY, startHeight } = dragRef.current;
|
||||
const delta = startY - clientY;
|
||||
const next = Math.min(maxPx ?? Infinity, Math.max(minPx ?? 0, startHeight + delta));
|
||||
setHeightPx(next);
|
||||
};
|
||||
const up = () => setIsDragging(false);
|
||||
document.addEventListener("mousemove", move, { passive: true });
|
||||
document.addEventListener("mouseup", up);
|
||||
document.addEventListener("touchmove", move, { passive: true });
|
||||
document.addEventListener("touchend", up);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", move);
|
||||
document.removeEventListener("mouseup", up);
|
||||
document.removeEventListener("touchmove", move);
|
||||
document.removeEventListener("touchend", up);
|
||||
};
|
||||
}, [isDragging, minPx, maxPx]);
|
||||
|
||||
const contentStyle = React.useMemo(() => {
|
||||
if (heightPx == null) return undefined;
|
||||
return { height: `${heightPx}px`, maxHeight: maxPx != null ? `${maxPx}px` : undefined };
|
||||
}, [heightPx, maxPx]);
|
||||
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
style={contentStyle}
|
||||
className={cn(
|
||||
"group/drawer-content fixed z-50 flex h-auto flex-col bg-[var(--card)] text-[var(--text)] border-[var(--border)]",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-[var(--radius)] data-[vaul-drawer-direction=top]:border-b drawer-shadow-top",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[88vh] data-[vaul-drawer-direction=bottom]:rounded-t-[20px] data-[vaul-drawer-direction=bottom]:border-t drawer-shadow-bottom",
|
||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
"drawer-content-theme",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<div
|
||||
role="separator"
|
||||
aria-label="拖动调整高度"
|
||||
onMouseDown={handlePointerDown}
|
||||
onTouchStart={handlePointerDown}
|
||||
className={cn(
|
||||
"mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full bg-[var(--muted)] cursor-n-resize touch-none select-none",
|
||||
"group-data-[vaul-drawer-direction=bottom]/drawer-content:block",
|
||||
"hover:bg-[var(--muted-foreground)/0.4] active:bg-[var(--muted-foreground)/0.6]"
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerHeader({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 p-4 border-b border-[var(--border)] group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||
"drawer-header-theme",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerFooter({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("font-semibold text-[var(--text)]", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-sm text-[var(--muted)]", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
79
components/ui/input-otp.jsx
Normal file
79
components/ui/input-otp.jsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { OTPInput, OTPInputContext } from "input-otp"
|
||||
import { MinusIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn("flex items-center gap-2 has-disabled:opacity-50", containerClassName)}
|
||||
className={cn("disabled:cursor-not-allowed disabled:opacity-50", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPGroup({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-group"
|
||||
className={cn("flex items-center", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext)
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"relative flex h-12 w-10 items-center justify-center rounded-md border-2 bg-background text-lg font-semibold shadow-sm transition-all duration-200",
|
||||
"border-input/60 dark:border-input/80",
|
||||
"text-foreground dark:text-foreground",
|
||||
"first:rounded-l-md last:rounded-r-md",
|
||||
"focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary",
|
||||
"data-[active=true]:border-primary data-[active=true]:ring-2 data-[active=true]:ring-primary/30 dark:data-[active=true]:ring-primary/40",
|
||||
"aria-invalid:border-destructive aria-invalid:text-destructive",
|
||||
"dark:bg-slate-900/50 dark:data-[active=true]:bg-slate-800/50",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-6 w-px animate-caret-blink bg-primary duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSeparator({
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div data-slot="input-otp-separator" role="separator" className="text-muted-foreground dark:text-muted-foreground/50" {...props}>
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||
197
components/ui/select.jsx
Normal file
197
components/ui/select.jsx
Normal file
@@ -0,0 +1,197 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded-lg border px-3 py-2.5 text-sm font-medium whitespace-nowrap shadow-sm transition-all duration-200 outline-none",
|
||||
"border-input bg-background text-foreground",
|
||||
"hover:border-primary/60 hover:ring-1 hover:ring-primary/30",
|
||||
"focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/50",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-input disabled:hover:ring-0",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
|
||||
"data-[placeholder]:text-muted-foreground",
|
||||
"data-[size=default]:h-11 data-[size=sm]:h-10",
|
||||
"*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-60 transition-transform duration-200" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-[100] max-h-(--radix-select-content-available-height) min-w-[var(--radix-select-trigger-width)] origin-(--radix-select-content-transform-origin) overflow-hidden rounded-xl border shadow-2xl",
|
||||
"bg-popover/80 text-popover-foreground dark:bg-popover/70",
|
||||
"backdrop-blur-xl backdrop-saturate-[180%]",
|
||||
"border-border/60",
|
||||
"ring-1 ring-black/5 dark:ring-white/10",
|
||||
"shadow-black/5 dark:shadow-black/60",
|
||||
"animate-in fade-in zoom-in-95 duration-200 ease-out",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:duration-150",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
{...props}>
|
||||
<SelectScrollUpButton className="bg-transparent text-muted-foreground/50" />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn("p-1.5", position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1")}>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton className="bg-transparent text-muted-foreground/50" />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-pointer select-none items-center rounded-lg py-2.5 px-3 text-sm font-medium transition-colors duration-150 outline-none",
|
||||
"text-foreground",
|
||||
"hover:bg-primary/10 dark:hover:bg-primary/20",
|
||||
"focus:bg-primary/10 dark:focus:bg-primary/20",
|
||||
"data-[highlighted]:bg-primary/10 dark:data-[highlighted]:bg-primary/20",
|
||||
"data-[state=checked]:bg-primary/10 dark:data-[state=checked]:bg-primary/20",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"*:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-3 flex size-4 items-center justify-center text-primary">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border/60", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
42
components/ui/switch.jsx
Normal file
42
components/ui/switch.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef(({ className, size = "default", ...props }, ref) => (
|
||||
<SwitchPrimitive.Root
|
||||
ref={ref}
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch inline-flex shrink-0 cursor-pointer items-center rounded-full border shadow-xs outline-none",
|
||||
"border-[var(--border)]",
|
||||
"transition-[color,box-shadow,border-color] duration-200 ease-out",
|
||||
"focus-visible:border-[var(--ring)] focus-visible:ring-[3px] focus-visible:ring-[var(--ring)] focus-visible:ring-opacity-50",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"hover:data-[state=unchecked]:bg-[var(--input)] hover:data-[state=unchecked]:border-[var(--muted)]",
|
||||
"data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
|
||||
"data-[state=checked]:border-transparent data-[state=checked]:bg-[var(--primary)]",
|
||||
"data-[state=unchecked]:bg-[var(--input)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"pointer-events-none block rounded-full ring-0",
|
||||
"bg-[var(--background)]",
|
||||
"transition-transform duration-200 ease-out",
|
||||
"group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3",
|
||||
"data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=checked]:bg-[var(--primary-foreground)]",
|
||||
"data-[state=unchecked]:translate-x-0 data-[state=unchecked]:bg-[var(--switch-thumb)]"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitive.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
89
components/ui/tabs.jsx
Normal file
89
components/ui/tabs.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn("group/tabs flex gap-2 data-[orientation=horizontal]:flex-col", className)}
|
||||
{...props} />
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-[var(--radius)] p-[3px] text-[var(--muted)] group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none border border-[var(--tabs-list-border)]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-[var(--tabs-list-bg)]",
|
||||
line: "gap-1 bg-transparent border-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all duration-200",
|
||||
"text-[var(--muted)] hover:text-[var(--text)] hover:bg-[var(--tabs-list-bg)]",
|
||||
"focus-visible:border-[var(--ring)] focus-visible:ring-[3px] focus-visible:ring-[var(--ring)]/50 focus-visible:outline-1 focus-visible:outline-[var(--ring)]",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
"group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start",
|
||||
"group-data-[variant=default]/tabs-list:data-[state=active]:bg-[var(--tabs-trigger-active-bg)] group-data-[variant=default]/tabs-list:data-[state=active]:text-[var(--tabs-trigger-active-text)] group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none",
|
||||
"group-data-[variant=line]/tabs-list:data-[state=active]:text-[var(--tabs-trigger-active-text)]",
|
||||
"after:absolute after:h-0.5 after:bg-[var(--tabs-trigger-active-text)] after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none text-[var(--text)]", className)}
|
||||
{...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -7,6 +7,7 @@ services:
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY: ${NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY}
|
||||
NEXT_PUBLIC_GA_ID: ${NEXT_PUBLIC_GA_ID}
|
||||
NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL: ${NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL}
|
||||
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
@@ -13,4 +13,10 @@ NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=your_web3forms_access_key
|
||||
# 从 Google Analytics 中获取这些值 https://analytics.google.com/analytics/web/
|
||||
NEXT_PUBLIC_GA_ID=G-xxxxxxxxxx
|
||||
|
||||
# GitHub Release 检查配置
|
||||
# 若需要在页面中展示「发现新版本」更新提示,请配置为对应仓库的最新 Release 接口地址
|
||||
# 例如本仓库默认值:
|
||||
# https://api.github.com/repos/hzm0321/real-time-fund/releases/latest
|
||||
NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=
|
||||
|
||||
# 如果要用 Github Actions 部署,需要在 Github 项目 Settings → secrets and actions → Actions → 创建 Repository secrets
|
||||
|
||||
@@ -12,7 +12,8 @@ const config = [
|
||||
...nextCoreWebVitals,
|
||||
{
|
||||
rules: {
|
||||
'react-hooks/set-state-in-effect': 'off'
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'no-debugger': 'error'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
9
jsconfig.json
Normal file
9
jsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"jsx": "react"
|
||||
}
|
||||
}
|
||||
6
lib/utils.js
Normal file
6
lib/utils.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
6015
package-lock.json
generated
6015
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
27
package.json
27
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-time-fund",
|
||||
"version": "0.1.6",
|
||||
"version": "0.2.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -13,25 +13,46 @@
|
||||
"dependencies": {
|
||||
"@dicebear/collection": "^9.3.1",
|
||||
"@dicebear/core": "^9.3.1",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@supabase/supabase-js": "^2.78.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"chart.js": "^4.5.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"framer-motion": "^12.29.2",
|
||||
"fuse.js": "^7.1.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lodash": "^4.17.23",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "^16.1.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "18.3.1",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"tesseract.js": "^5.1.1"
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tesseract.js": "^5.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
"vaul": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-next": "^16.1.5",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7"
|
||||
"lint-staged": "^16.2.7",
|
||||
"postcss": "^8.5.8",
|
||||
"shadcn": "^3.8.5",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
|
||||
5
postcss.config.mjs
Normal file
5
postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
BIN
public/Icon-60@3x.png
Normal file
BIN
public/Icon-60@3x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
104742
public/allFund.json
Normal file
104742
public/allFund.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user