Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||||
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=${{ secrets.NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY }}
|
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=${{ secrets.NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY }}
|
||||||
NEXT_PUBLIC_GA_ID=${{ secrets.NEXT_PUBLIC_GA_ID }}
|
NEXT_PUBLIC_GA_ID=${{ secrets.NEXT_PUBLIC_GA_ID }}
|
||||||
|
NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=${{ secrets.NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL }}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Build Dockerfile image
|
- name: Build Dockerfile image
|
||||||
@@ -55,6 +56,7 @@ jobs:
|
|||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||||
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=${{ secrets.NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY }}
|
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=${{ secrets.NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY }}
|
||||||
NEXT_PUBLIC_GA_ID=${{ secrets.NEXT_PUBLIC_GA_ID }}
|
NEXT_PUBLIC_GA_ID=${{ secrets.NEXT_PUBLIC_GA_ID }}
|
||||||
|
NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=${{ secrets.NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL }}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Docker Compose up
|
- 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_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||||
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=${{ secrets.NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY }}
|
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=${{ secrets.NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY }}
|
||||||
NEXT_PUBLIC_GA_ID=${{ secrets.NEXT_PUBLIC_GA_ID }}
|
NEXT_PUBLIC_GA_ID=${{ secrets.NEXT_PUBLIC_GA_ID }}
|
||||||
|
NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=${{ secrets.NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL }}
|
||||||
EOF
|
EOF
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }}
|
run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ ARG NEXT_PUBLIC_SUPABASE_URL
|
|||||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||||
ARG NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY
|
ARG NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY
|
||||||
ARG NEXT_PUBLIC_GA_ID
|
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_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
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_WEB3FORMS_ACCESS_KEY=$NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY
|
||||||
ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID
|
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 ./
|
COPY package*.json ./
|
||||||
RUN npm install --legacy-peer-deps
|
RUN npm install --legacy-peer-deps
|
||||||
COPY . .
|
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_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||||
ENV NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=$NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY
|
ENV NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=$NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY
|
||||||
ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID
|
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/package.json ./
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
COPY --from=builder /app/.next ./.next
|
COPY --from=builder /app/.next ./.next
|
||||||
|
|||||||
@@ -48,7 +48,8 @@
|
|||||||
- `NEXT_PUBLIC_Supabase_URL`:Supabase 项目 URL
|
- `NEXT_PUBLIC_Supabase_URL`:Supabase 项目 URL
|
||||||
- `NEXT_PUBLIC_Supabase_ANON_KEY`:Supabase 匿名公钥
|
- `NEXT_PUBLIC_Supabase_ANON_KEY`:Supabase 匿名公钥
|
||||||
- `NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY`:Web3Forms Access Key
|
- `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 统计功能,可不设置对应变量
|
注:如不使用登录、反馈或 GA 统计功能,可不设置对应变量
|
||||||
|
|
||||||
@@ -86,7 +87,7 @@
|
|||||||
|
|
||||||
本项目已配置 GitHub Actions。每次推送到 `main` 分支时,会自动执行构建并部署到 GitHub Pages。
|
本项目已配置 GitHub Actions。每次推送到 `main` 分支时,会自动执行构建并部署到 GitHub Pages。
|
||||||
如需使用 GitHub Actions 部署,请在 GitHub 项目 Settings → Secrets and variables → Actions 中创建对应的 Repository secrets(字段名称与 `.env.local` 保持一致)。
|
如需使用 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
|
```bash
|
||||||
|
|||||||
228
app/api/fund.js
228
app/api/fund.js
@@ -1,6 +1,7 @@
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import utc from 'dayjs/plugin/utc';
|
import utc from 'dayjs/plugin/utc';
|
||||||
import timezone from 'dayjs/plugin/timezone';
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
|
import { isString } from 'lodash';
|
||||||
import { cachedRequest, clearCachedRequest } from '../lib/cacheRequest';
|
import { cachedRequest, clearCachedRequest } from '../lib/cacheRequest';
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
@@ -20,7 +21,7 @@ const nowInTz = () => dayjs().tz(TZ);
|
|||||||
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
|
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
|
||||||
|
|
||||||
export const loadScript = (url) => {
|
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;
|
let cacheKey = url;
|
||||||
try {
|
try {
|
||||||
@@ -69,9 +70,7 @@ export const loadScript = (url) => {
|
|||||||
clearCachedRequest(cacheKey);
|
clearCachedRequest(cacheKey);
|
||||||
throw new Error(result?.error || '数据加载失败');
|
throw new Error(result?.error || '数据加载失败');
|
||||||
}
|
}
|
||||||
if (typeof window !== 'undefined' && result.apidata !== undefined) {
|
return result.apidata;
|
||||||
window.apidata = result.apidata;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,9 +78,9 @@ export const fetchFundNetValue = async (code, date) => {
|
|||||||
if (typeof window === 'undefined') return null;
|
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}`;
|
const url = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=1&per=1&sdate=${date}&edate=${date}`;
|
||||||
try {
|
try {
|
||||||
await loadScript(url);
|
const apidata = await loadScript(url);
|
||||||
if (window.apidata && window.apidata.content) {
|
if (apidata && apidata.content) {
|
||||||
const content = window.apidata.content;
|
const content = apidata.content;
|
||||||
if (content.includes('暂无数据')) return null;
|
if (content.includes('暂无数据')) return null;
|
||||||
const rows = content.split('<tr>');
|
const rows = content.split('<tr>');
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -101,6 +100,32 @@ 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;
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchSmartFundNetValue = async (code, startDate) => {
|
export const fetchSmartFundNetValue = async (code, startDate) => {
|
||||||
const today = nowInTz().startOf('day');
|
const today = nowInTz().startOf('day');
|
||||||
let current = toTz(startDate).startOf('day');
|
let current = toTz(startDate).startOf('day');
|
||||||
@@ -157,43 +182,31 @@ export const fetchFundDataFallback = async (c) => {
|
|||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
}
|
}
|
||||||
const tUrl = `https://qt.gtimg.cn/q=jj${c}`;
|
try {
|
||||||
const tScript = document.createElement('script');
|
const url = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${c}&page=1&per=1&sdate=&edate=`;
|
||||||
tScript.src = tUrl;
|
const apidata = await loadScript(url);
|
||||||
tScript.onload = () => {
|
const content = apidata?.content || '';
|
||||||
const v = window[`v_jj${c}`];
|
const latest = parseLatestNetValueFromLsjzContent(content);
|
||||||
if (v && v.length > 5) {
|
if (latest && latest.nav) {
|
||||||
const p = v.split('~');
|
const name = fundName || `未知基金(${c})`;
|
||||||
const name = fundName || p[1] || `未知基金(${c})`;
|
resolve({
|
||||||
const dwjz = p[5];
|
code: c,
|
||||||
const zzl = parseFloat(p[7]);
|
name,
|
||||||
const jzrq = p[8] ? p[8].slice(0, 10) : '';
|
dwjz: String(latest.nav),
|
||||||
if (dwjz) {
|
gsz: null,
|
||||||
resolve({
|
gztime: null,
|
||||||
code: c,
|
jzrq: latest.date,
|
||||||
name: name,
|
gszzl: null,
|
||||||
dwjz: dwjz,
|
zzl: Number.isFinite(latest.growth) ? latest.growth : null,
|
||||||
gsz: null,
|
noValuation: true,
|
||||||
gztime: null,
|
holdings: []
|
||||||
jzrq: jzrq,
|
});
|
||||||
gszzl: null,
|
|
||||||
zzl: !isNaN(zzl) ? zzl : null,
|
|
||||||
noValuation: true,
|
|
||||||
holdings: []
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
reject(new Error('未能获取到基金数据'));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
reject(new Error('未能获取到基金数据'));
|
reject(new Error('未能获取到基金数据'));
|
||||||
}
|
}
|
||||||
if (document.body.contains(tScript)) document.body.removeChild(tScript);
|
} catch (e) {
|
||||||
};
|
|
||||||
tScript.onerror = () => {
|
|
||||||
if (document.body.contains(tScript)) document.body.removeChild(tScript);
|
|
||||||
reject(new Error('基金数据加载失败'));
|
reject(new Error('基金数据加载失败'));
|
||||||
};
|
}
|
||||||
document.body.appendChild(tScript);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -222,35 +235,29 @@ export const fetchFundData = async (c) => {
|
|||||||
jzrq: json.jzrq,
|
jzrq: json.jzrq,
|
||||||
gszzl: Number.isFinite(gszzlNum) ? gszzlNum : json.gszzl
|
gszzl: Number.isFinite(gszzlNum) ? gszzlNum : json.gszzl
|
||||||
};
|
};
|
||||||
const tencentPromise = new Promise((resolveT) => {
|
const lsjzPromise = new Promise((resolveT) => {
|
||||||
const tUrl = `https://qt.gtimg.cn/q=jj${c}`;
|
const url = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${c}&page=1&per=1&sdate=&edate=`;
|
||||||
const tScript = document.createElement('script');
|
loadScript(url)
|
||||||
tScript.src = tUrl;
|
.then((apidata) => {
|
||||||
tScript.onload = () => {
|
const content = apidata?.content || '';
|
||||||
const v = window[`v_jj${c}`];
|
const latest = parseLatestNetValueFromLsjzContent(content);
|
||||||
if (v) {
|
if (latest && latest.nav) {
|
||||||
const p = v.split('~');
|
resolveT({
|
||||||
resolveT({
|
dwjz: String(latest.nav),
|
||||||
dwjz: p[5],
|
zzl: Number.isFinite(latest.growth) ? latest.growth : null,
|
||||||
zzl: parseFloat(p[7]),
|
jzrq: latest.date
|
||||||
jzrq: p[8] ? p[8].slice(0, 10) : ''
|
});
|
||||||
});
|
} else {
|
||||||
} else {
|
resolveT(null);
|
||||||
resolveT(null);
|
}
|
||||||
}
|
})
|
||||||
if (document.body.contains(tScript)) document.body.removeChild(tScript);
|
.catch(() => resolveT(null));
|
||||||
};
|
|
||||||
tScript.onerror = () => {
|
|
||||||
if (document.body.contains(tScript)) document.body.removeChild(tScript);
|
|
||||||
resolveT(null);
|
|
||||||
};
|
|
||||||
document.body.appendChild(tScript);
|
|
||||||
});
|
});
|
||||||
const holdingsPromise = new Promise((resolveH) => {
|
const holdingsPromise = new Promise((resolveH) => {
|
||||||
const holdingsUrl = `https://fundf10.eastmoney.com/FundArchivesDatas.aspx?type=jjcc&code=${c}&topline=10&year=&month=&_=${Date.now()}`;
|
const holdingsUrl = `https://fundf10.eastmoney.com/FundArchivesDatas.aspx?type=jjcc&code=${c}&topline=10&year=&month=&_=${Date.now()}`;
|
||||||
loadScript(holdingsUrl).then(async () => {
|
loadScript(holdingsUrl).then(async (apidata) => {
|
||||||
let holdings = [];
|
let holdings = [];
|
||||||
const html = window.apidata?.content || '';
|
const html = apidata?.content || '';
|
||||||
const headerRow = (html.match(/<thead[\s\S]*?<tr[\s\S]*?<\/tr>[\s\S]*?<\/thead>/i) || [])[0] || '';
|
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());
|
const headerCells = (headerRow.match(/<th[\s\S]*?>([\s\S]*?)<\/th>/gi) || []).map(th => th.replace(/<[^>]*>/g, '').trim());
|
||||||
let idxCode = -1, idxName = -1, idxWeight = -1;
|
let idxCode = -1, idxName = -1, idxWeight = -1;
|
||||||
@@ -350,7 +357,7 @@ export const fetchFundData = async (c) => {
|
|||||||
resolveH(holdings);
|
resolveH(holdings);
|
||||||
}).catch(() => resolveH([]));
|
}).catch(() => resolveH([]));
|
||||||
});
|
});
|
||||||
Promise.all([tencentPromise, holdingsPromise]).then(([tData, holdings]) => {
|
Promise.all([lsjzPromise, holdingsPromise]).then(([tData, holdings]) => {
|
||||||
if (tData) {
|
if (tData) {
|
||||||
if (tData.jzrq && (!gzData.jzrq || tData.jzrq >= gzData.jzrq)) {
|
if (tData.jzrq && (!gzData.jzrq || tData.jzrq >= gzData.jzrq)) {
|
||||||
gzData.dwjz = tData.dwjz;
|
gzData.dwjz = tData.dwjz;
|
||||||
@@ -432,7 +439,10 @@ export const fetchShanghaiIndexDate = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const fetchLatestRelease = 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;
|
if (!res.ok) return null;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return {
|
return {
|
||||||
@@ -449,6 +459,72 @@ export const submitFeedback = async (formData) => {
|
|||||||
return response.json();
|
return response.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 使用智谱 GLM 从 OCR 文本中抽取基金名称
|
||||||
|
export const extractFundNamesWithLLM = async (ocrText) => {
|
||||||
|
const apiKey = '8df8ccf74a174722847c83b7e222f2af.4A39rJvUeBVDmef1';
|
||||||
|
if (!apiKey || !ocrText) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const models = ['glm-4.5-flash', 'glm-4.7-flash'];
|
||||||
|
const model = models[Math.floor(Math.random() * models.length)];
|
||||||
|
|
||||||
|
const resp = await fetch('https://open.bigmodel.cn/api/paas/v4/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content:
|
||||||
|
'你是一个基金 OCR 文本解析助手。' +
|
||||||
|
'从下面的 OCR 文本中抽取其中出现的「基金名称列表」。' +
|
||||||
|
'要求:1)基金名称一般为中文,中间不能有空字符串,可包含部分英文或括号' +
|
||||||
|
'2)名称后面通常会跟着金额或持有金额(数字,可能带千分位逗号和小数);' +
|
||||||
|
'3)忽略无关信息,只返回你判断为基金名称的字符串;' +
|
||||||
|
'4)去重后输出。输出格式:严格返回 JSON,如 {"fund_names": ["基金名称1","基金名称2"]},不要输出任何多余说明',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: String(ocrText),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
temperature: 0.2,
|
||||||
|
max_tokens: 1024,
|
||||||
|
thinking: {
|
||||||
|
type: 'disabled',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
let content = data?.choices?.[0]?.message?.content?.match(/\{[\s\S]*?\}/)?.[0];
|
||||||
|
if (!isString(content)) return [];
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = parsed?.fund_names;
|
||||||
|
if (!Array.isArray(names)) return [];
|
||||||
|
return names
|
||||||
|
.map((n) => (isString(n) ? n.trim().replaceAll(' ','') : ''))
|
||||||
|
.filter(Boolean);
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let historyQueue = Promise.resolve();
|
let historyQueue = Promise.resolve();
|
||||||
|
|
||||||
export const fetchFundHistory = async (code, range = '1m') => {
|
export const fetchFundHistory = async (code, range = '1m') => {
|
||||||
@@ -497,26 +573,26 @@ export const fetchFundHistory = async (code, range = '1m') => {
|
|||||||
|
|
||||||
// Fetch first page to get metadata
|
// 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}`;
|
const firstUrl = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=${page}&per=${per}&sdate=${sdate}&edate=${edate}`;
|
||||||
await loadScript(firstUrl);
|
const firstApidata = await loadScript(firstUrl);
|
||||||
|
|
||||||
if (!window.apidata || !window.apidata.content || window.apidata.content.includes('暂无数据')) {
|
if (!firstApidata || !firstApidata.content || firstApidata.content.includes('暂无数据')) {
|
||||||
resolve([]);
|
resolve([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse total pages
|
// Parse total pages
|
||||||
if (window.apidata.pages) {
|
if (firstApidata.pages) {
|
||||||
totalPages = parseInt(window.apidata.pages, 10) || 1;
|
totalPages = parseInt(firstApidata.pages, 10) || 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
allData = allData.concat(parseContent(window.apidata.content));
|
allData = allData.concat(parseContent(firstApidata.content));
|
||||||
|
|
||||||
// Fetch remaining pages
|
// Fetch remaining pages
|
||||||
for (page = 2; page <= totalPages; page++) {
|
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}`;
|
const nextUrl = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=${page}&per=${per}&sdate=${sdate}&edate=${edate}`;
|
||||||
await loadScript(nextUrl);
|
const nextApidata = await loadScript(nextUrl);
|
||||||
if (window.apidata && window.apidata.content) {
|
if (nextApidata && nextApidata.content) {
|
||||||
allData = allData.concat(parseContent(window.apidata.content));
|
allData = allData.concat(parseContent(nextApidata.content));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" standalone="no"?>
|
<?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">
|
<!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>
|
</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: 58 KiB |
@@ -187,6 +187,7 @@ export default function AddHistoryModal({ fund, onClose, onConfirm }) {
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
className="input"
|
className="input"
|
||||||
value={amount}
|
value={amount}
|
||||||
onChange={handleAmountChange}
|
onChange={handleAmountChange}
|
||||||
@@ -196,6 +197,10 @@ export default function AddHistoryModal({ fund, onClose, onConfirm }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<button
|
||||||
className="button primary full-width"
|
className="button primary full-width"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v8';
|
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v11';
|
||||||
|
|
||||||
export default function Announcement() {
|
export default function Announcement() {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
@@ -67,13 +67,14 @@ export default function Announcement() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{ color: 'var(--primary)', textDecoration: 'underline', padding: '0 4px', fontWeight: 600 }} href="https://fund.cc.cd/">https://fund.cc.cd/</a>
|
style={{ color: 'var(--primary)', textDecoration: 'underline', padding: '0 4px', fontWeight: 600 }} href="https://fund.cc.cd/">https://fund.cc.cd/</a>
|
||||||
<p>节后第一次更新内容如下:</p>
|
<p>v0.1.9 版本更新内容如下:</p>
|
||||||
<p>1. OCR 识别截图导入基金。</p>
|
<p>1. 新增亮色主题。</p>
|
||||||
<p>2. 基金历史曲线图。</p>
|
<p>2. PC、移动表格模式重构,支持自定义布局。</p>
|
||||||
<p>3. 买入、卖出历史记录。</p>
|
<p>3. PC端设置弹框支持修改页面容器宽度。</p>
|
||||||
以下内容会在近期更新:
|
<p>4. 分组下自定义布局数据相互独立(旧数据需重新配置)。</p>
|
||||||
<p>1. 定投。</p>
|
<p>5. 更换随机头像风格。</p>
|
||||||
<p>2. 自定义布局。</p>
|
感谢以下用户上月对项目赞助支持(排名不分顺序):
|
||||||
|
<p>*业、M*.、S*o、b*g、*落、D*A、*山、匿名、*🍍、*啦、L*.、*洛、大大方块先生、带火星的小木条、F、無芯、广告制作装饰、**中、**礼</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}>
|
||||||
|
|||||||
@@ -73,20 +73,8 @@ export function DatePicker({ value, onChange }) {
|
|||||||
return (
|
return (
|
||||||
<div className="date-picker" style={{ position: 'relative' }} onClick={(e) => e.stopPropagation()}>
|
<div className="date-picker" style={{ position: 'relative' }} onClick={(e) => e.stopPropagation()}>
|
||||||
<div
|
<div
|
||||||
className="input-trigger"
|
className="date-picker-trigger"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
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>
|
<span>{value || '选择日期'}</span>
|
||||||
<CalendarIcon width="16" height="16" className="muted" />
|
<CalendarIcon width="16" height="16" className="muted" />
|
||||||
@@ -98,7 +86,7 @@ export function DatePicker({ value, onChange }) {
|
|||||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||||
className="glass card"
|
className="date-picker-dropdown glass card"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '100%',
|
top: '100%',
|
||||||
@@ -106,10 +94,7 @@ export function DatePicker({ value, onChange }) {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
padding: 12,
|
padding: 12,
|
||||||
zIndex: 10,
|
zIndex: 10
|
||||||
background: 'rgba(30, 41, 59, 0.95)',
|
|
||||||
backdropFilter: 'blur(12px)',
|
|
||||||
border: '1px solid rgba(255,255,255,0.1)'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="calendar-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
<div className="calendar-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
@@ -141,26 +126,8 @@ export function DatePicker({ value, onChange }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
|
className={`date-picker-cell ${isSelected ? 'selected' : ''} ${isToday ? 'today' : ''} ${isFuture ? 'future' : ''}`}
|
||||||
onClick={(e) => !isFuture && handleSelect(e, d)}
|
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}
|
{d}
|
||||||
</div>
|
</div>
|
||||||
@@ -271,6 +238,7 @@ export function NumericInput({ value, onChange, step = 1, min = 0, placeholder }
|
|||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
step="any"
|
step="any"
|
||||||
className="input no-zoom"
|
className="input no-zoom"
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { TrashIcon } from './Icons';
|
import { TrashIcon } from './Icons';
|
||||||
|
|
||||||
export default function ConfirmModal({ title, message, onConfirm, onCancel, confirmText = "确定删除" }) {
|
export default function ConfirmModal({ title, message, onConfirm, onCancel, confirmText = "确定删除" }) {
|
||||||
return (
|
const content = (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="modal-overlay"
|
className="modal-overlay"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
@@ -40,4 +41,6 @@ export default function ConfirmModal({ title, message, onConfirm, onCancel, conf
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
if (typeof document === 'undefined') return null;
|
||||||
|
return createPortal(content, document.body);
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
281
app/components/FundIntradayChart.jsx
Normal file
281
app/components/FundIntradayChart.jsx
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
'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();
|
||||||
|
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: '1px 5px',
|
||||||
|
borderRadius: 4,
|
||||||
|
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%' }}>
|
||||||
|
<Line ref={chartRef} data={chartData} options={options} plugins={plugins} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,17 +29,45 @@ ChartJS.register(
|
|||||||
Filler
|
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' }) {
|
||||||
const [range, setRange] = useState('1m');
|
const [range, setRange] = useState('1m');
|
||||||
const [data, setData] = useState([]);
|
const [data, setData] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const chartRef = useRef(null);
|
const chartRef = useRef(null);
|
||||||
|
const hoverTimeoutRef = useRef(null);
|
||||||
|
|
||||||
|
const chartColors = useMemo(() => getChartThemeColors(theme), [theme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If collapsed, don't fetch data unless we have no data yet
|
// If collapsed, don't fetch data unless we have no data yet
|
||||||
if (!isExpanded && data.length > 0) return;
|
if (!isExpanded && data.length > 0) return;
|
||||||
|
|
||||||
let active = true;
|
let active = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -83,12 +111,12 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
return ((last - first) / first) * 100;
|
return ((last - first) / first) * 100;
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
// Red for up, Green for down (CN market style)
|
// Red for up, Green for down (CN market style),随主题使用 CSS 变量
|
||||||
// Hardcoded hex values from globals.css for Chart.js
|
const upColor = chartColors.danger;
|
||||||
const upColor = '#f87171'; // --danger
|
const downColor = chartColors.success;
|
||||||
const downColor = '#34d399'; // --success
|
|
||||||
const lineColor = change >= 0 ? upColor : downColor;
|
const lineColor = change >= 0 ? upColor : downColor;
|
||||||
|
const primaryColor = chartColors.primary;
|
||||||
|
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
// Calculate percentage change based on the first data point
|
// Calculate percentage change based on the first data point
|
||||||
const firstValue = data.length > 0 ? data[0].value : 1;
|
const firstValue = data.length > 0 ? data[0].value : 1;
|
||||||
@@ -139,8 +167,9 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
type: 'line', // Use line type with showLine: false to simulate scatter on Category scale
|
type: 'line', // Use line type with showLine: false to simulate scatter on Category scale
|
||||||
label: '买入',
|
label: '买入',
|
||||||
data: buyPoints,
|
data: buyPoints,
|
||||||
borderColor: '#ef4444', // Red
|
borderColor: '#ffffff',
|
||||||
backgroundColor: '#ef4444',
|
borderWidth: 1,
|
||||||
|
backgroundColor: primaryColor,
|
||||||
pointStyle: 'circle',
|
pointStyle: 'circle',
|
||||||
pointRadius: 2.5,
|
pointRadius: 2.5,
|
||||||
pointHoverRadius: 4,
|
pointHoverRadius: 4,
|
||||||
@@ -151,8 +180,9 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
type: 'line',
|
type: 'line',
|
||||||
label: '卖出',
|
label: '卖出',
|
||||||
data: sellPoints,
|
data: sellPoints,
|
||||||
borderColor: '#22c55e', // Green
|
borderColor: '#ffffff',
|
||||||
backgroundColor: '#22c55e',
|
borderWidth: 1,
|
||||||
|
backgroundColor: upColor,
|
||||||
pointStyle: 'circle',
|
pointStyle: 'circle',
|
||||||
pointRadius: 2.5,
|
pointRadius: 2.5,
|
||||||
pointHoverRadius: 4,
|
pointHoverRadius: 4,
|
||||||
@@ -161,9 +191,10 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
}, [data, lineColor, transactions]);
|
}, [data, transactions, lineColor, primaryColor, upColor]);
|
||||||
|
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
|
const colors = getChartThemeColors(theme);
|
||||||
return {
|
return {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
@@ -186,7 +217,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
drawBorder: false
|
drawBorder: false
|
||||||
},
|
},
|
||||||
ticks: {
|
ticks: {
|
||||||
color: '#9ca3af',
|
color: colors.muted,
|
||||||
font: { size: 10 },
|
font: { size: 10 },
|
||||||
maxTicksLimit: 4,
|
maxTicksLimit: 4,
|
||||||
maxRotation: 0
|
maxRotation: 0
|
||||||
@@ -195,14 +226,14 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
display: true,
|
display: true,
|
||||||
position: 'right',
|
position: 'left',
|
||||||
grid: {
|
grid: {
|
||||||
color: '#1f2937',
|
color: colors.border,
|
||||||
drawBorder: false,
|
drawBorder: false,
|
||||||
tickLength: 0
|
tickLength: 0
|
||||||
},
|
},
|
||||||
ticks: {
|
ticks: {
|
||||||
color: '#9ca3af',
|
color: colors.muted,
|
||||||
font: { size: 10 },
|
font: { size: 10 },
|
||||||
count: 5,
|
count: 5,
|
||||||
callback: (value) => `${value.toFixed(2)}%`
|
callback: (value) => `${value.toFixed(2)}%`
|
||||||
@@ -214,69 +245,126 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
mode: 'index',
|
mode: 'index',
|
||||||
intersect: false,
|
intersect: false,
|
||||||
},
|
},
|
||||||
onHover: (event, chartElement) => {
|
onHover: (event, chartElement, chart) => {
|
||||||
event.native.target.style.cursor = chartElement[0] ? 'crosshair' : 'default';
|
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',
|
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) => {
|
afterDraw: (chart) => {
|
||||||
const ctx = chart.ctx;
|
const ctx = chart.ctx;
|
||||||
const datasets = chart.data.datasets;
|
const datasets = chart.data.datasets;
|
||||||
const primaryColor = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#22d3ee';
|
const primaryColor = colors.primary;
|
||||||
|
|
||||||
// Helper function to draw point label
|
// 绘制圆角矩形(兼容无 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();
|
||||||
|
};
|
||||||
|
|
||||||
const drawPointLabel = (datasetIndex, index, text, bgColor, textColor = '#ffffff', yOffset = 0) => {
|
const drawPointLabel = (datasetIndex, index, text, bgColor, textColor = '#ffffff', yOffset = 0) => {
|
||||||
const meta = chart.getDatasetMeta(datasetIndex);
|
const meta = chart.getDatasetMeta(datasetIndex);
|
||||||
if (!meta.data[index]) return;
|
if (!meta.data[index]) return;
|
||||||
const element = meta.data[index];
|
const element = meta.data[index];
|
||||||
// Check if element is visible/not skipped
|
|
||||||
if (element.skip) return;
|
if (element.skip) return;
|
||||||
|
|
||||||
const x = element.x;
|
const x = element.x;
|
||||||
const y = element.y + yOffset;
|
const y = element.y + yOffset;
|
||||||
|
const paddingH = 10;
|
||||||
|
const paddingV = 6;
|
||||||
|
const radius = 8;
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.font = 'bold 11px sans-serif';
|
ctx.font = 'bold 11px sans-serif';
|
||||||
const labelWidth = ctx.measureText(text).width + 12;
|
const textW = ctx.measureText(text).width;
|
||||||
|
const w = textW + paddingH * 2;
|
||||||
// Draw label above the point
|
const h = 18;
|
||||||
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.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.fillStyle = textColor;
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
ctx.fillText(text, x, y - 15);
|
ctx.fillText(text, centerX, top + h / 2);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Draw default labels for first buy and sell points
|
// Resolve active elements (hover/focus) first — used to decide whether to show default labels
|
||||||
// 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)
|
|
||||||
let activeElements = [];
|
let activeElements = [];
|
||||||
if (chart.tooltip?._active?.length) {
|
if (chart.tooltip?._active?.length) {
|
||||||
activeElements = chart.tooltip._active;
|
activeElements = chart.tooltip._active;
|
||||||
@@ -284,6 +372,27 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
activeElements = chart.getActiveElements();
|
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) {
|
if (activeElements && activeElements.length) {
|
||||||
const activePoint = activeElements[0];
|
const activePoint = activeElements[0];
|
||||||
const x = activePoint.element.x;
|
const x = activePoint.element.x;
|
||||||
@@ -297,16 +406,16 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.setLineDash([3, 3]);
|
ctx.setLineDash([3, 3]);
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.strokeStyle = '#9ca3af';
|
ctx.strokeStyle = colors.muted;
|
||||||
|
|
||||||
// Draw vertical line
|
// Draw vertical line
|
||||||
ctx.moveTo(x, topY);
|
ctx.moveTo(x, topY);
|
||||||
ctx.lineTo(x, bottomY);
|
ctx.lineTo(x, bottomY);
|
||||||
|
|
||||||
// Draw horizontal line (based on first point - usually the main line)
|
// Draw horizontal line (based on first point - usually the main line)
|
||||||
ctx.moveTo(leftX, y);
|
ctx.moveTo(leftX, y);
|
||||||
ctx.lineTo(rightX, y);
|
ctx.lineTo(rightX, y);
|
||||||
|
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Draw labels
|
// Draw labels
|
||||||
@@ -317,7 +426,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
// Draw Axis Labels based on the first point (main line)
|
// Draw Axis Labels based on the first point (main line)
|
||||||
const datasetIndex = activePoint.datasetIndex;
|
const datasetIndex = activePoint.datasetIndex;
|
||||||
const index = activePoint.index;
|
const index = activePoint.index;
|
||||||
|
|
||||||
const labels = chart.data.labels;
|
const labels = chart.data.labels;
|
||||||
|
|
||||||
if (labels && datasets && datasets[datasetIndex] && datasets[datasetIndex].data) {
|
if (labels && datasets && datasets[datasetIndex] && datasets[datasetIndex].data) {
|
||||||
@@ -325,21 +434,27 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
const value = datasets[datasetIndex].data[index];
|
const value = datasets[datasetIndex].data[index];
|
||||||
|
|
||||||
if (dateStr !== undefined && value !== undefined) {
|
if (dateStr !== undefined && value !== undefined) {
|
||||||
// X axis label (date)
|
// X axis label (date) with boundary clamping
|
||||||
const textWidth = ctx.measureText(dateStr).width + 8;
|
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.fillStyle = primaryColor;
|
||||||
ctx.fillRect(x - textWidth / 2, bottomY, textWidth, 16);
|
ctx.fillRect(labelLeft, bottomY, textWidth, 16);
|
||||||
ctx.fillStyle = '#0f172a'; // --background
|
ctx.fillStyle = colors.crosshairText;
|
||||||
ctx.fillText(dateStr, x, bottomY + 8);
|
ctx.fillText(dateStr, labelCenterX, bottomY + 8);
|
||||||
|
|
||||||
// Y axis label (value)
|
// Y axis label (value)
|
||||||
const valueStr = (typeof value === 'number' ? value.toFixed(2) : value) + '%';
|
const valueStr = (typeof value === 'number' ? value.toFixed(2) : value) + '%';
|
||||||
const valWidth = ctx.measureText(valueStr).width + 8;
|
const valWidth = ctx.measureText(valueStr).width + 8;
|
||||||
ctx.fillStyle = primaryColor;
|
ctx.fillStyle = primaryColor;
|
||||||
ctx.fillRect(rightX - valWidth, y - 8, valWidth, 16);
|
ctx.fillRect(leftX, y - 8, valWidth, 16);
|
||||||
ctx.fillStyle = '#0f172a'; // --background
|
ctx.fillStyle = colors.crosshairText;
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.fillText(valueStr, rightX - valWidth / 2, y);
|
ctx.fillText(valueStr, leftX + valWidth / 2, y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,15 +470,15 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
if (dsIndex > 0 && datasets[dsIndex]) {
|
if (dsIndex > 0 && datasets[dsIndex]) {
|
||||||
const label = datasets[dsIndex].label;
|
const label = datasets[dsIndex].label;
|
||||||
// Determine background color based on dataset index
|
// Determine background color based on dataset index
|
||||||
// 1 = Buy (Red), 2 = Sell (Theme Color)
|
// 1 = Buy (主题色), 2 = Sell (与折线图红色一致)
|
||||||
const bgColor = dsIndex === 1 ? '#ef4444' : primaryColor;
|
const bgColor = dsIndex === 1 ? primaryColor : colors.danger;
|
||||||
|
|
||||||
// If collision, offset Buy label upwards
|
// If collision, offset Buy label upwards
|
||||||
let yOffset = 0;
|
let yOffset = 0;
|
||||||
if (isCollision && dsIndex === 1) {
|
if (isCollision && dsIndex === 1) {
|
||||||
yOffset = -20;
|
yOffset = -20;
|
||||||
}
|
}
|
||||||
|
|
||||||
drawPointLabel(dsIndex, element.index, label, bgColor, '#ffffff', yOffset);
|
drawPointLabel(dsIndex, element.index, label, bgColor, '#ffffff', yOffset);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -371,11 +486,12 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}], []); // 移除 data 依赖,因为我们直接从 chart 实例读取数据
|
}];
|
||||||
|
}, [theme]); // theme 变化时重算以应用亮色/暗色坐标轴与 crosshair
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop: 16 }} onClick={(e) => e.stopPropagation()}>
|
<div style={{ marginTop: 16 }} onClick={(e) => e.stopPropagation()}>
|
||||||
<div
|
<div
|
||||||
style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }}
|
style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }}
|
||||||
className="title"
|
className="title"
|
||||||
onClick={onToggleExpand}
|
onClick={onToggleExpand}
|
||||||
@@ -403,7 +519,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -415,19 +531,13 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
>
|
>
|
||||||
<div style={{ position: 'relative', height: 180, width: '100%' }}>
|
<div style={{ position: 'relative', height: 180, width: '100%' }}>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div style={{
|
<div className="chart-overlay" style={{ backdropFilter: 'blur(2px)' }}>
|
||||||
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>
|
<span className="muted" style={{ fontSize: '12px' }}>加载中...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && data.length === 0 && (
|
{!loading && data.length === 0 && (
|
||||||
<div style={{
|
<div className="chart-overlay">
|
||||||
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>
|
<span className="muted" style={{ fontSize: '12px' }}>暂无数据</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -437,23 +547,13 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 4, marginTop: 12, justifyContent: 'space-between', background: 'rgba(0,0,0,0.2)', padding: 4, borderRadius: 8 }}>
|
<div className="trend-range-bar">
|
||||||
{ranges.map(r => (
|
{ranges.map(r => (
|
||||||
<button
|
<button
|
||||||
key={r.value}
|
key={r.value}
|
||||||
|
type="button"
|
||||||
|
className={`trend-range-btn ${range === r.value ? 'active' : ''}`}
|
||||||
onClick={(e) => { e.stopPropagation(); setRange(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}
|
{r.label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export default function HoldingActionModal({ fund, onClose, onAction, hasHistory
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<SettingsIcon width="20" height="20" />
|
<SettingsIcon width="20" height="20" />
|
||||||
<span>持仓操作</span>
|
<span>持仓操作</span>
|
||||||
{hasHistory && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onAction('history')}
|
onClick={() => onAction('history')}
|
||||||
@@ -47,9 +46,8 @@ export default function HoldingActionModal({ fund, onClose, onAction, hasHistory
|
|||||||
title="查看交易记录"
|
title="查看交易记录"
|
||||||
>
|
>
|
||||||
<span>📜</span>
|
<span>📜</span>
|
||||||
<span>记录</span>
|
<span>交易记录</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||||
<CloseIcon width="20" height="20" />
|
<CloseIcon width="20" height="20" />
|
||||||
@@ -62,12 +60,27 @@ export default function HoldingActionModal({ fund, onClose, onAction, hasHistory
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid" style={{ gap: 12 }}>
|
<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>
|
||||||
<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>
|
||||||
|
<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 className="button col-12" onClick={() => onAction('edit')} style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>
|
||||||
编辑持仓
|
编辑持仓
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
step="any"
|
step="any"
|
||||||
className={`input ${!amount ? 'error' : ''}`}
|
className={`input ${!amount ? 'error' : ''}`}
|
||||||
value={amount}
|
value={amount}
|
||||||
@@ -192,6 +193,7 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
step="any"
|
step="any"
|
||||||
className={`input ${!share ? 'error' : ''}`}
|
className={`input ${!share ? 'error' : ''}`}
|
||||||
value={share}
|
value={share}
|
||||||
@@ -209,6 +211,7 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
step="any"
|
step="any"
|
||||||
className={`input ${!cost ? 'error' : ''}`}
|
className={`input ${!cost ? 'error' : ''}`}
|
||||||
value={cost}
|
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) {
|
export function ChevronIcon(props) {
|
||||||
return (
|
return (
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
@@ -236,3 +243,20 @@ export function CameraIcon(props) {
|
|||||||
</svg>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
775
app/components/MobileFundTable.jsx
Normal file
775
app/components/MobileFundTable.jsx
Normal file
@@ -0,0 +1,775 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
closestCenter,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import { restrictToVerticalAxis, restrictToParentElement } from '@dnd-kit/modifiers';
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import FitText from './FitText';
|
||||||
|
import MobileSettingModal from './MobileSettingModal';
|
||||||
|
import { ExitIcon, SettingsIcon, StarIcon } from './Icons';
|
||||||
|
|
||||||
|
const MOBILE_NON_FROZEN_COLUMN_IDS = [
|
||||||
|
'yesterdayChangePercent',
|
||||||
|
'estimateChangePercent',
|
||||||
|
'todayProfit',
|
||||||
|
'holdingProfit',
|
||||||
|
'latestNav',
|
||||||
|
'estimateNav',
|
||||||
|
];
|
||||||
|
const MOBILE_COLUMN_HEADERS = {
|
||||||
|
latestNav: '最新净值',
|
||||||
|
estimateNav: '估算净值',
|
||||||
|
yesterdayChangePercent: '昨日涨跌幅',
|
||||||
|
estimateChangePercent: '估值涨跌幅',
|
||||||
|
todayProfit: '当日收益',
|
||||||
|
holdingProfit: '持有收益',
|
||||||
|
};
|
||||||
|
|
||||||
|
function SortableRow({ row, children, isTableDragging, disabled }) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
setNodeRef,
|
||||||
|
setActivatorNodeRef,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: row.original.code, disabled });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
...(isDragging ? { position: 'relative', zIndex: 9999, opacity: 0.8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)' } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className="table-row-wrapper"
|
||||||
|
layout={isTableDragging ? undefined : 'position'}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||||
|
style={{ ...style, position: 'relative' }}
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
{typeof children === 'function' ? children(setActivatorNodeRef, listeners) : children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动端基金列表表格组件(基于 @tanstack/react-table,与 PcFundTable 相同数据结构)
|
||||||
|
*
|
||||||
|
* @param {Object} props - 与 PcFundTable 一致
|
||||||
|
* @param {Array<Object>} props.data - 表格数据(与 pcFundTableData 同结构)
|
||||||
|
* @param {(row: any) => void} [props.onRemoveFund] - 删除基金
|
||||||
|
* @param {string} [props.currentTab] - 当前分组
|
||||||
|
* @param {Set<string>} [props.favorites] - 自选集合
|
||||||
|
* @param {(row: any) => void} [props.onToggleFavorite] - 添加/取消自选
|
||||||
|
* @param {(row: any) => void} [props.onRemoveFromGroup] - 从当前分组移除
|
||||||
|
* @param {(row: any, meta: { hasHolding: boolean }) => void} [props.onHoldingAmountClick] - 点击持仓金额
|
||||||
|
* @param {boolean} [props.refreshing] - 是否刷新中
|
||||||
|
* @param {string} [props.sortBy] - 排序方式,'default' 时长按行触发拖拽排序
|
||||||
|
* @param {(oldIndex: number, newIndex: number) => void} [props.onReorder] - 拖拽排序回调
|
||||||
|
*/
|
||||||
|
export default function MobileFundTable({
|
||||||
|
data = [],
|
||||||
|
onRemoveFund,
|
||||||
|
currentTab,
|
||||||
|
favorites = new Set(),
|
||||||
|
onToggleFavorite,
|
||||||
|
onRemoveFromGroup,
|
||||||
|
onHoldingAmountClick,
|
||||||
|
onHoldingProfitClick, // 保留以兼容调用方,表格内已不再使用点击切换
|
||||||
|
refreshing = false,
|
||||||
|
sortBy = 'default',
|
||||||
|
onReorder,
|
||||||
|
onCustomSettingsChange,
|
||||||
|
}) {
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: { delay: 400, tolerance: 5 },
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor)
|
||||||
|
);
|
||||||
|
|
||||||
|
const [activeId, setActiveId] = useState(null);
|
||||||
|
|
||||||
|
const onToggleFavoriteRef = useRef(onToggleFavorite);
|
||||||
|
const onRemoveFromGroupRef = useRef(onRemoveFromGroup);
|
||||||
|
const onHoldingAmountClickRef = useRef(onHoldingAmountClick);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onToggleFavoriteRef.current = onToggleFavorite;
|
||||||
|
onRemoveFromGroupRef.current = onRemoveFromGroup;
|
||||||
|
onHoldingAmountClickRef.current = onHoldingAmountClick;
|
||||||
|
}, [
|
||||||
|
onToggleFavorite,
|
||||||
|
onRemoveFromGroup,
|
||||||
|
onHoldingAmountClick,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDragStart = (e) => setActiveId(e.active.id);
|
||||||
|
const handleDragCancel = () => setActiveId(null);
|
||||||
|
const handleDragEnd = (e) => {
|
||||||
|
const { active, over } = e;
|
||||||
|
if (active && over && active.id !== over.id && onReorder) {
|
||||||
|
const oldIndex = data.findIndex((item) => item.code === active.id);
|
||||||
|
const newIndex = data.findIndex((item) => item.code === over.id);
|
||||||
|
if (oldIndex !== -1 && newIndex !== -1) onReorder(oldIndex, newIndex);
|
||||||
|
}
|
||||||
|
setActiveId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupKey = currentTab ?? 'all';
|
||||||
|
|
||||||
|
const getCustomSettingsWithMigration = () => {
|
||||||
|
if (typeof window === 'undefined') return {};
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem('customSettings');
|
||||||
|
const parsed = raw ? JSON.parse(raw) : {};
|
||||||
|
if (!parsed || typeof parsed !== 'object') return {};
|
||||||
|
if (parsed.pcTableColumnOrder != null || parsed.pcTableColumnVisibility != null || parsed.pcTableColumns != null || parsed.mobileTableColumnOrder != null || parsed.mobileTableColumnVisibility != null) {
|
||||||
|
const all = {
|
||||||
|
...(parsed.all && typeof parsed.all === 'object' ? parsed.all : {}),
|
||||||
|
pcTableColumnOrder: parsed.pcTableColumnOrder,
|
||||||
|
pcTableColumnVisibility: parsed.pcTableColumnVisibility,
|
||||||
|
pcTableColumns: parsed.pcTableColumns,
|
||||||
|
mobileTableColumnOrder: parsed.mobileTableColumnOrder,
|
||||||
|
mobileTableColumnVisibility: parsed.mobileTableColumnVisibility,
|
||||||
|
};
|
||||||
|
delete parsed.pcTableColumnOrder;
|
||||||
|
delete parsed.pcTableColumnVisibility;
|
||||||
|
delete parsed.pcTableColumns;
|
||||||
|
delete parsed.mobileTableColumnOrder;
|
||||||
|
delete parsed.mobileTableColumnVisibility;
|
||||||
|
parsed.all = all;
|
||||||
|
window.localStorage.setItem('customSettings', JSON.stringify(parsed));
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitialMobileConfigByGroup = () => {
|
||||||
|
const parsed = getCustomSettingsWithMigration();
|
||||||
|
const byGroup = {};
|
||||||
|
Object.keys(parsed).forEach((k) => {
|
||||||
|
if (k === 'pcContainerWidth') return;
|
||||||
|
const group = parsed[k];
|
||||||
|
if (!group || typeof group !== 'object') return;
|
||||||
|
const order = Array.isArray(group.mobileTableColumnOrder) && group.mobileTableColumnOrder.length > 0
|
||||||
|
? group.mobileTableColumnOrder
|
||||||
|
: null;
|
||||||
|
const visibility = group.mobileTableColumnVisibility && typeof group.mobileTableColumnVisibility === 'object'
|
||||||
|
? group.mobileTableColumnVisibility
|
||||||
|
: null;
|
||||||
|
byGroup[k] = {
|
||||||
|
mobileTableColumnOrder: order ? (() => {
|
||||||
|
const valid = order.filter((id) => MOBILE_NON_FROZEN_COLUMN_IDS.includes(id));
|
||||||
|
const missing = MOBILE_NON_FROZEN_COLUMN_IDS.filter((id) => !valid.includes(id));
|
||||||
|
return [...valid, ...missing];
|
||||||
|
})() : null,
|
||||||
|
mobileTableColumnVisibility: visibility,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return byGroup;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [configByGroup, setConfigByGroup] = useState(getInitialMobileConfigByGroup);
|
||||||
|
|
||||||
|
const currentGroupMobile = configByGroup[groupKey];
|
||||||
|
const defaultOrder = [...MOBILE_NON_FROZEN_COLUMN_IDS];
|
||||||
|
const defaultVisibility = (() => {
|
||||||
|
const o = {};
|
||||||
|
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => { o[id] = true; });
|
||||||
|
return o;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const mobileColumnOrder = (() => {
|
||||||
|
const order = currentGroupMobile?.mobileTableColumnOrder ?? defaultOrder;
|
||||||
|
if (!Array.isArray(order) || order.length === 0) return [...MOBILE_NON_FROZEN_COLUMN_IDS];
|
||||||
|
const valid = order.filter((id) => MOBILE_NON_FROZEN_COLUMN_IDS.includes(id));
|
||||||
|
const missing = MOBILE_NON_FROZEN_COLUMN_IDS.filter((id) => !valid.includes(id));
|
||||||
|
return [...valid, ...missing];
|
||||||
|
})();
|
||||||
|
const mobileColumnVisibility = (() => {
|
||||||
|
const vis = currentGroupMobile?.mobileTableColumnVisibility ?? null;
|
||||||
|
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) return vis;
|
||||||
|
return defaultVisibility;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const persistMobileGroupConfig = (updates) => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem('customSettings');
|
||||||
|
const parsed = raw ? JSON.parse(raw) : {};
|
||||||
|
const group = parsed[groupKey] && typeof parsed[groupKey] === 'object' ? { ...parsed[groupKey] } : {};
|
||||||
|
if (updates.mobileTableColumnOrder !== undefined) group.mobileTableColumnOrder = updates.mobileTableColumnOrder;
|
||||||
|
if (updates.mobileTableColumnVisibility !== undefined) group.mobileTableColumnVisibility = updates.mobileTableColumnVisibility;
|
||||||
|
parsed[groupKey] = group;
|
||||||
|
window.localStorage.setItem('customSettings', JSON.stringify(parsed));
|
||||||
|
setConfigByGroup((prev) => ({ ...prev, [groupKey]: { ...prev[groupKey], ...updates } }));
|
||||||
|
onCustomSettingsChange?.();
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setMobileColumnOrder = (nextOrderOrUpdater) => {
|
||||||
|
const next = typeof nextOrderOrUpdater === 'function'
|
||||||
|
? nextOrderOrUpdater(mobileColumnOrder)
|
||||||
|
: nextOrderOrUpdater;
|
||||||
|
persistMobileGroupConfig({ mobileTableColumnOrder: next });
|
||||||
|
};
|
||||||
|
const setMobileColumnVisibility = (nextOrUpdater) => {
|
||||||
|
const next = typeof nextOrUpdater === 'function'
|
||||||
|
? nextOrUpdater(mobileColumnVisibility)
|
||||||
|
: nextOrUpdater;
|
||||||
|
persistMobileGroupConfig({ mobileTableColumnVisibility: next });
|
||||||
|
};
|
||||||
|
const [settingModalOpen, setSettingModalOpen] = useState(false);
|
||||||
|
const tableContainerRef = useRef(null);
|
||||||
|
const [tableContainerWidth, setTableContainerWidth] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = tableContainerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const updateWidth = () => setTableContainerWidth(el.clientWidth || 0);
|
||||||
|
updateWidth();
|
||||||
|
const ro = new ResizeObserver(updateWidth);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const NAME_CELL_WIDTH = 140;
|
||||||
|
const GAP = 12;
|
||||||
|
const LAST_COLUMN_EXTRA = 12;
|
||||||
|
const FALLBACK_WIDTHS = {
|
||||||
|
fundName: 140,
|
||||||
|
latestNav: 64,
|
||||||
|
estimateNav: 64,
|
||||||
|
yesterdayChangePercent: 72,
|
||||||
|
estimateChangePercent: 80,
|
||||||
|
todayProfit: 80,
|
||||||
|
holdingProfit: 80,
|
||||||
|
};
|
||||||
|
|
||||||
|
const columnWidthMap = useMemo(() => {
|
||||||
|
const visibleNonNameIds = mobileColumnOrder.filter((id) => mobileColumnVisibility[id] !== false);
|
||||||
|
const nonNameCount = visibleNonNameIds.length;
|
||||||
|
if (tableContainerWidth > 0 && nonNameCount > 0) {
|
||||||
|
const gapTotal = nonNameCount >= 3 ? 3 * GAP : (nonNameCount) * GAP;
|
||||||
|
const remaining = tableContainerWidth - NAME_CELL_WIDTH - gapTotal - LAST_COLUMN_EXTRA;
|
||||||
|
const divisor = nonNameCount >= 3 ? 3 : nonNameCount;
|
||||||
|
const otherColumnWidth = Math.max(48, Math.floor(remaining / divisor));
|
||||||
|
const map = { fundName: NAME_CELL_WIDTH };
|
||||||
|
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => {
|
||||||
|
map[id] = otherColumnWidth;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
return { ...FALLBACK_WIDTHS };
|
||||||
|
}, [tableContainerWidth, mobileColumnOrder, mobileColumnVisibility]);
|
||||||
|
|
||||||
|
const handleResetMobileColumnOrder = () => {
|
||||||
|
setMobileColumnOrder([...MOBILE_NON_FROZEN_COLUMN_IDS]);
|
||||||
|
};
|
||||||
|
const handleResetMobileColumnVisibility = () => {
|
||||||
|
const allVisible = {};
|
||||||
|
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => {
|
||||||
|
allVisible[id] = true;
|
||||||
|
});
|
||||||
|
setMobileColumnVisibility(allVisible);
|
||||||
|
};
|
||||||
|
const handleToggleMobileColumnVisibility = (columnId, visible) => {
|
||||||
|
setMobileColumnVisibility((prev = {}) => ({ ...prev, [columnId]: visible }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 移动端名称列:无拖拽把手,长按整行触发排序
|
||||||
|
const MobileFundNameCell = ({ info }) => {
|
||||||
|
const original = info.row.original || {};
|
||||||
|
const code = original.code;
|
||||||
|
const isUpdated = original.isUpdated;
|
||||||
|
const hasHoldingAmount = original.holdingAmountValue != null;
|
||||||
|
const holdingAmountDisplay = hasHoldingAmount ? (original.holdingAmount ?? '—') : null;
|
||||||
|
const isFavorites = favorites?.has?.(code);
|
||||||
|
const isGroupTab = currentTab && currentTab !== 'all' && currentTab !== 'fav';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="name-cell-content" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{isGroupTab ? (
|
||||||
|
<button
|
||||||
|
className="icon-button fav-button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation?.();
|
||||||
|
onRemoveFromGroupRef.current?.(original);
|
||||||
|
}}
|
||||||
|
title="从当前分组移除"
|
||||||
|
style={{ backgroundColor: 'transparent'}}
|
||||||
|
>
|
||||||
|
<ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className={`icon-button fav-button ${isFavorites ? 'active' : ''}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation?.();
|
||||||
|
onToggleFavoriteRef.current?.(original);
|
||||||
|
}}
|
||||||
|
title={isFavorites ? '取消自选' : '添加自选'}
|
||||||
|
style={{ backgroundColor: 'transparent'}}
|
||||||
|
>
|
||||||
|
<StarIcon width="18" height="18" filled={isFavorites} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="title-text">
|
||||||
|
<span className="name-text" title={isUpdated ? '今日净值已更新' : ''}>
|
||||||
|
{info.getValue() ?? '—'}
|
||||||
|
</span>
|
||||||
|
{holdingAmountDisplay ? (
|
||||||
|
<span
|
||||||
|
className="muted code-text"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
title="点击设置持仓"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation?.();
|
||||||
|
onHoldingAmountClickRef.current?.(original, { hasHolding: true });
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onHoldingAmountClickRef.current?.(original, { hasHolding: true });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{holdingAmountDisplay}
|
||||||
|
{isUpdated && <span className="updated-indicator">✓</span>}
|
||||||
|
</span>
|
||||||
|
) : code ? (
|
||||||
|
<span
|
||||||
|
className="muted code-text"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
title="设置持仓"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation?.();
|
||||||
|
onHoldingAmountClickRef.current?.(original, { hasHolding: false });
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onHoldingAmountClickRef.current?.(original, { hasHolding: false });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
#{code}
|
||||||
|
{isUpdated && <span className="updated-indicator">✓</span>}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'fundName',
|
||||||
|
header: () => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||||
|
<span>基金名称</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation?.();
|
||||||
|
setSettingModalOpen(true);
|
||||||
|
}}
|
||||||
|
title="个性化设置"
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
minWidth: '28px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'var(--text)',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SettingsIcon width="18" height="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: (info) => <MobileFundNameCell info={info} />,
|
||||||
|
meta: { align: 'left', cellClassName: 'name-cell', width: columnWidthMap.fundName },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'latestNav',
|
||||||
|
header: '最新净值',
|
||||||
|
cell: (info) => (
|
||||||
|
<span style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||||
|
<FitText maxFontSize={14} minFontSize={10}>
|
||||||
|
{info.getValue() ?? '—'}
|
||||||
|
</FitText>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { align: 'right', cellClassName: 'value-cell', width: columnWidthMap.latestNav },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'estimateNav',
|
||||||
|
header: '估算净值',
|
||||||
|
cell: (info) => (
|
||||||
|
<span style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||||
|
<FitText maxFontSize={14} minFontSize={10}>
|
||||||
|
{info.getValue() ?? '—'}
|
||||||
|
</FitText>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { align: 'right', cellClassName: 'value-cell', width: columnWidthMap.estimateNav },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'yesterdayChangePercent',
|
||||||
|
header: '昨日涨跌幅',
|
||||||
|
cell: (info) => {
|
||||||
|
const original = info.row.original || {};
|
||||||
|
const value = original.yesterdayChangeValue;
|
||||||
|
const date = original.yesterdayDate ?? '-';
|
||||||
|
const cls = value > 0 ? 'up' : value < 0 ? 'down' : '';
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||||
|
<span className={cls} style={{ fontWeight: 700 }}>
|
||||||
|
{info.getValue() ?? '—'}
|
||||||
|
</span>
|
||||||
|
<span className="muted" style={{ fontSize: '10px' }}>{date}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
meta: { align: 'right', cellClassName: 'change-cell', width: columnWidthMap.yesterdayChangePercent },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'estimateChangePercent',
|
||||||
|
header: '估值涨跌幅',
|
||||||
|
cell: (info) => {
|
||||||
|
const original = info.row.original || {};
|
||||||
|
const value = original.estimateChangeValue;
|
||||||
|
const isMuted = original.estimateChangeMuted;
|
||||||
|
const time = original.estimateTime ?? '-';
|
||||||
|
const cls = isMuted ? 'muted' : value > 0 ? 'up' : value < 0 ? 'down' : '';
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||||
|
<span className={cls} style={{ fontWeight: 700 }}>
|
||||||
|
{info.getValue() ?? '—'}
|
||||||
|
</span>
|
||||||
|
<span className="muted" style={{ fontSize: '10px' }}>{time}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
meta: { align: 'right', cellClassName: 'est-change-cell', width: columnWidthMap.estimateChangePercent },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'todayProfit',
|
||||||
|
header: '当日收益',
|
||||||
|
cell: (info) => {
|
||||||
|
const original = info.row.original || {};
|
||||||
|
const value = original.todayProfitValue;
|
||||||
|
const hasProfit = value != null;
|
||||||
|
const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
|
||||||
|
const amountStr = hasProfit ? (info.getValue() ?? '') : '—';
|
||||||
|
const percentStr = original.todayProfitPercent ?? '';
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||||
|
<FitText maxFontSize={14} minFontSize={10}>
|
||||||
|
{amountStr}
|
||||||
|
</FitText>
|
||||||
|
</span>
|
||||||
|
{percentStr ? (
|
||||||
|
<span className={`${cls} today-profit-percent`} style={{ display: 'block', width: '100%', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||||
|
<FitText maxFontSize={11} minFontSize={9}>
|
||||||
|
{percentStr}
|
||||||
|
</FitText>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
meta: { align: 'right', cellClassName: 'profit-cell', width: columnWidthMap.todayProfit },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'holdingProfit',
|
||||||
|
header: '持有收益',
|
||||||
|
cell: (info) => {
|
||||||
|
const original = info.row.original || {};
|
||||||
|
const value = original.holdingProfitValue;
|
||||||
|
const hasTotal = value != null;
|
||||||
|
const cls = hasTotal ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
|
||||||
|
const amountStr = hasTotal ? (info.getValue() ?? '') : '—';
|
||||||
|
const percentStr = original.holdingProfitPercent ?? '';
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||||
|
<FitText maxFontSize={14} minFontSize={10}>
|
||||||
|
{amountStr}
|
||||||
|
</FitText>
|
||||||
|
</span>
|
||||||
|
{percentStr ? (
|
||||||
|
<span className={`${cls} holding-profit-percent`} style={{ display: 'block', width: '100%', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||||
|
<FitText maxFontSize={11} minFontSize={9}>
|
||||||
|
{percentStr}
|
||||||
|
</FitText>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
meta: { align: 'right', cellClassName: 'holding-cell', width: columnWidthMap.holdingProfit },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[currentTab, favorites, refreshing, columnWidthMap]
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
state: {
|
||||||
|
columnOrder: ['fundName', ...mobileColumnOrder],
|
||||||
|
columnVisibility: { fundName: true, ...mobileColumnVisibility },
|
||||||
|
},
|
||||||
|
onColumnOrderChange: (updater) => {
|
||||||
|
const next = typeof updater === 'function' ? updater(['fundName', ...mobileColumnOrder]) : updater;
|
||||||
|
const newNonFrozen = next.filter((id) => id !== 'fundName');
|
||||||
|
if (newNonFrozen.length) {
|
||||||
|
setMobileColumnOrder(newNonFrozen);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onColumnVisibilityChange: (updater) => {
|
||||||
|
const next = typeof updater === 'function' ? updater({ fundName: true, ...mobileColumnVisibility }) : updater;
|
||||||
|
const rest = { ...next };
|
||||||
|
delete rest.fundName;
|
||||||
|
setMobileColumnVisibility(rest);
|
||||||
|
},
|
||||||
|
initialState: {
|
||||||
|
columnPinning: {
|
||||||
|
left: ['fundName'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultColumn: {
|
||||||
|
cell: (info) => info.getValue() ?? '—',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const headerGroup = table.getHeaderGroups()[0];
|
||||||
|
|
||||||
|
const snapPositionsRef = useRef([]);
|
||||||
|
const scrollEndTimerRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!headerGroup?.headers?.length) {
|
||||||
|
snapPositionsRef.current = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const gap = 12;
|
||||||
|
const widths = headerGroup.headers.map((h) => h.column.columnDef.meta?.width ?? 80);
|
||||||
|
if (widths.length > 0) widths[widths.length - 1] += LAST_COLUMN_EXTRA;
|
||||||
|
const positions = [0];
|
||||||
|
let acc = 0;
|
||||||
|
// 从第二列开始累加,因为第一列是固定的,滚动是为了让后续列贴合到第一列右侧
|
||||||
|
// 累加的是"被滚出去"的非固定列的宽度
|
||||||
|
for (let i = 1; i < widths.length - 1; i++) {
|
||||||
|
acc += widths[i] + gap;
|
||||||
|
positions.push(acc);
|
||||||
|
}
|
||||||
|
snapPositionsRef.current = positions;
|
||||||
|
}, [headerGroup?.headers?.length, columnWidthMap, mobileColumnOrder]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = tableContainerRef.current;
|
||||||
|
if (!el || snapPositionsRef.current.length === 0) return;
|
||||||
|
|
||||||
|
const snapToNearest = () => {
|
||||||
|
const positions = snapPositionsRef.current;
|
||||||
|
if (positions.length === 0) return;
|
||||||
|
const scrollLeft = el.scrollLeft;
|
||||||
|
const maxScroll = el.scrollWidth - el.clientWidth;
|
||||||
|
if (maxScroll <= 0) return;
|
||||||
|
const nearest = positions.reduce((prev, curr) =>
|
||||||
|
Math.abs(curr - scrollLeft) < Math.abs(prev - scrollLeft) ? curr : prev
|
||||||
|
);
|
||||||
|
const clamped = Math.max(0, Math.min(maxScroll, nearest));
|
||||||
|
if (Math.abs(clamped - scrollLeft) > 2) {
|
||||||
|
el.scrollTo({ left: clamped, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (scrollEndTimerRef.current) clearTimeout(scrollEndTimerRef.current);
|
||||||
|
scrollEndTimerRef.current = setTimeout(snapToNearest, 120);
|
||||||
|
};
|
||||||
|
|
||||||
|
el.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('scroll', handleScroll);
|
||||||
|
if (scrollEndTimerRef.current) clearTimeout(scrollEndTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const mobileGridLayout = (() => {
|
||||||
|
if (!headerGroup?.headers?.length) return { gridTemplateColumns: '', minWidth: undefined };
|
||||||
|
const gap = 12;
|
||||||
|
const widths = headerGroup.headers.map((h) => h.column.columnDef.meta?.width ?? 80);
|
||||||
|
if (widths.length > 0) widths[widths.length - 1] += LAST_COLUMN_EXTRA;
|
||||||
|
return {
|
||||||
|
gridTemplateColumns: widths.map((w) => `${w}px`).join(' '),
|
||||||
|
minWidth: widths.reduce((a, b) => a + b, 0) + (widths.length - 1) * gap,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const getPinClass = (columnId, isHeader) => {
|
||||||
|
if (columnId === 'fundName') return isHeader ? 'table-header-cell-pin-left' : 'table-cell-pin-left';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAlignClass = (columnId) => {
|
||||||
|
if (columnId === 'fundName') return '';
|
||||||
|
if (['latestNav', 'estimateNav', 'yesterdayChangePercent', 'estimateChangePercent', 'todayProfit', 'holdingProfit'].includes(columnId)) return 'text-right';
|
||||||
|
return 'text-right';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mobile-fund-table" ref={tableContainerRef}>
|
||||||
|
<div
|
||||||
|
className="mobile-fund-table-scroll"
|
||||||
|
style={mobileGridLayout.minWidth != null ? { minWidth: mobileGridLayout.minWidth } : undefined}
|
||||||
|
>
|
||||||
|
{headerGroup && (
|
||||||
|
<div
|
||||||
|
className="table-header-row mobile-fund-table-header"
|
||||||
|
style={mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : undefined}
|
||||||
|
>
|
||||||
|
{headerGroup.headers.map((header, headerIndex) => {
|
||||||
|
const columnId = header.column.id;
|
||||||
|
const pinClass = getPinClass(columnId, true);
|
||||||
|
const alignClass = getAlignClass(columnId);
|
||||||
|
const isLastColumn = headerIndex === headerGroup.headers.length - 1;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={header.id}
|
||||||
|
className={`table-header-cell ${alignClass} ${pinClass}`}
|
||||||
|
style={isLastColumn ? { paddingRight: LAST_COLUMN_EXTRA } : undefined}
|
||||||
|
>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={handleDragCancel}
|
||||||
|
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={data.map((item) => item.code)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<SortableRow
|
||||||
|
key={row.original.code || row.id}
|
||||||
|
row={row}
|
||||||
|
isTableDragging={!!activeId}
|
||||||
|
disabled={sortBy !== 'default'}
|
||||||
|
>
|
||||||
|
{(setActivatorNodeRef, listeners) => (
|
||||||
|
<div
|
||||||
|
ref={sortBy === 'default' ? setActivatorNodeRef : undefined}
|
||||||
|
className="table-row"
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg)',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
...(mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : {}),
|
||||||
|
}}
|
||||||
|
{...(sortBy === 'default' ? listeners : {})}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell, cellIndex) => {
|
||||||
|
const columnId = cell.column.id;
|
||||||
|
const pinClass = getPinClass(columnId, false);
|
||||||
|
const alignClass = getAlignClass(columnId);
|
||||||
|
const cellClassName = cell.column.columnDef.meta?.cellClassName || '';
|
||||||
|
const isLastColumn = cellIndex === row.getVisibleCells().length - 1;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={cell.id}
|
||||||
|
className={`table-cell ${alignClass} ${cellClassName} ${pinClass}`}
|
||||||
|
style={isLastColumn ? { paddingRight: LAST_COLUMN_EXTRA } : undefined}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SortableRow>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{table.getRowModel().rows.length === 0 && (
|
||||||
|
<div className="table-row empty-row">
|
||||||
|
<div className="table-cell" style={{ textAlign: 'center' }}>
|
||||||
|
<span className="muted">暂无数据</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MobileSettingModal
|
||||||
|
open={settingModalOpen}
|
||||||
|
onClose={() => setSettingModalOpen(false)}
|
||||||
|
columns={mobileColumnOrder.map((id) => ({ id, header: MOBILE_COLUMN_HEADERS[id] ?? id }))}
|
||||||
|
columnVisibility={mobileColumnVisibility}
|
||||||
|
onColumnReorder={(newOrder) => {
|
||||||
|
setMobileColumnOrder(newOrder);
|
||||||
|
}}
|
||||||
|
onToggleColumnVisibility={handleToggleMobileColumnVisibility}
|
||||||
|
onResetColumnOrder={handleResetMobileColumnOrder}
|
||||||
|
onResetColumnVisibility={handleResetMobileColumnVisibility}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
app/components/MobileSettingModal.jsx
Normal file
220
app/components/MobileSettingModal.jsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
'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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动端表格个性化设置弹框(底部抽屉)
|
||||||
|
* @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 - 重置列显示/隐藏回调
|
||||||
|
*/
|
||||||
|
export default function MobileSettingModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
columns = [],
|
||||||
|
columnVisibility,
|
||||||
|
onColumnReorder,
|
||||||
|
onToggleColumnVisibility,
|
||||||
|
onResetColumnOrder,
|
||||||
|
onResetColumnVisibility,
|
||||||
|
}) {
|
||||||
|
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setResetConfirmOpen(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="mobile-setting-overlay"
|
||||||
|
className="mobile-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.div
|
||||||
|
className="mobile-setting-drawer glass"
|
||||||
|
initial={{ y: '100%' }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
exit={{ y: '100%' }}
|
||||||
|
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="mobile-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="mobile-setting-body">
|
||||||
|
<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 && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
{resetConfirmOpen && (
|
||||||
|
<ConfirmModal
|
||||||
|
key="mobile-reset-confirm"
|
||||||
|
title="重置表头设置"
|
||||||
|
message="是否重置表头顺序和显示/隐藏为默认值?"
|
||||||
|
onConfirm={() => {
|
||||||
|
onResetColumnOrder?.();
|
||||||
|
onResetColumnVisibility?.();
|
||||||
|
setResetConfirmOpen(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setResetConfirmOpen(false)}
|
||||||
|
confirmText="重置"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (typeof document === 'undefined') return null;
|
||||||
|
return createPortal(content, document.body);
|
||||||
|
}
|
||||||
955
app/components/PcFundTable.jsx
Normal file
955
app/components/PcFundTable.jsx
Normal file
@@ -0,0 +1,955 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
closestCenter,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import { restrictToVerticalAxis, restrictToParentElement } from '@dnd-kit/modifiers';
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import ConfirmModal from './ConfirmModal';
|
||||||
|
import FitText from './FitText';
|
||||||
|
import PcTableSettingModal from './PcTableSettingModal';
|
||||||
|
import { DragIcon, ExitIcon, SettingsIcon, StarIcon, TrashIcon } from './Icons';
|
||||||
|
|
||||||
|
const NON_FROZEN_COLUMN_IDS = [
|
||||||
|
'yesterdayChangePercent',
|
||||||
|
'estimateChangePercent',
|
||||||
|
'holdingAmount',
|
||||||
|
'todayProfit',
|
||||||
|
'holdingProfit',
|
||||||
|
'latestNav',
|
||||||
|
'estimateNav',
|
||||||
|
];
|
||||||
|
const COLUMN_HEADERS = {
|
||||||
|
latestNav: '最新净值',
|
||||||
|
estimateNav: '估算净值',
|
||||||
|
yesterdayChangePercent: '昨日涨跌幅',
|
||||||
|
estimateChangePercent: '估值涨跌幅',
|
||||||
|
holdingAmount: '持仓金额',
|
||||||
|
todayProfit: '当日收益',
|
||||||
|
holdingProfit: '持有收益',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SortableRowContext = createContext({
|
||||||
|
setActivatorNodeRef: null,
|
||||||
|
listeners: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
function SortableRow({ row, children, isTableDragging, disabled }) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
setNodeRef,
|
||||||
|
setActivatorNodeRef,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: row.original.code, disabled });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
...(isDragging ? { position: 'relative', zIndex: 9999, opacity: 0.8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)' } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const contextValue = useMemo(
|
||||||
|
() => ({ setActivatorNodeRef, listeners }),
|
||||||
|
[setActivatorNodeRef, listeners]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableRowContext.Provider value={contextValue}>
|
||||||
|
<motion.div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className="table-row-wrapper"
|
||||||
|
layout={isTableDragging ? undefined : "position"}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||||
|
style={{ ...style, position: 'relative' }}
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</SortableRowContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PC 端基金列表表格组件(基于 @tanstack/react-table)
|
||||||
|
*
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {Array<Object>} props.data - 表格数据
|
||||||
|
* 每一行推荐结构(字段命名与 page.jsx 中的数据一致):
|
||||||
|
* {
|
||||||
|
* fundName: string; // 基金名称
|
||||||
|
* code?: string; // 基金代码(可选,只用于展示在名称下方)
|
||||||
|
* latestNav: string|number; // 最新净值
|
||||||
|
* estimateNav: string|number; // 估算净值
|
||||||
|
* yesterdayChangePercent: string|number; // 昨日涨跌幅
|
||||||
|
* estimateChangePercent: string|number; // 估值涨跌幅
|
||||||
|
* holdingAmount: string|number; // 持仓金额
|
||||||
|
* todayProfit: string|number; // 当日收益
|
||||||
|
* holdingProfit: string|number; // 持有收益
|
||||||
|
* }
|
||||||
|
* @param {(row: any) => void} [props.onRemoveFund] - 删除基金的回调
|
||||||
|
* @param {string} [props.currentTab] - 当前分组
|
||||||
|
* @param {Set<string>} [props.favorites] - 自选集合
|
||||||
|
* @param {(row: any) => void} [props.onToggleFavorite] - 添加/取消自选
|
||||||
|
* @param {(row: any) => void} [props.onRemoveFromGroup] - 从当前分组移除
|
||||||
|
* @param {(row: any, meta: { hasHolding: boolean }) => void} [props.onHoldingAmountClick] - 点击持仓金额
|
||||||
|
* @param {boolean} [props.refreshing] - 是否处于刷新状态(控制删除按钮禁用态)
|
||||||
|
*/
|
||||||
|
export default function PcFundTable({
|
||||||
|
data = [],
|
||||||
|
onRemoveFund,
|
||||||
|
currentTab,
|
||||||
|
favorites = new Set(),
|
||||||
|
onToggleFavorite,
|
||||||
|
onRemoveFromGroup,
|
||||||
|
onHoldingAmountClick,
|
||||||
|
onHoldingProfitClick, // 保留以兼容调用方,表格内已不再使用点击切换
|
||||||
|
refreshing = false,
|
||||||
|
sortBy = 'default',
|
||||||
|
onReorder,
|
||||||
|
onCustomSettingsChange,
|
||||||
|
}) {
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 5,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor)
|
||||||
|
);
|
||||||
|
|
||||||
|
const [activeId, setActiveId] = useState(null);
|
||||||
|
|
||||||
|
const handleDragStart = (event) => {
|
||||||
|
setActiveId(event.active.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragCancel = () => {
|
||||||
|
setActiveId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (event) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (active && over && active.id !== over.id) {
|
||||||
|
const oldIndex = data.findIndex(item => item.code === active.id);
|
||||||
|
const newIndex = data.findIndex(item => item.code === over.id);
|
||||||
|
if (oldIndex !== -1 && newIndex !== -1 && onReorder) {
|
||||||
|
onReorder(oldIndex, newIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setActiveId(null);
|
||||||
|
};
|
||||||
|
const groupKey = currentTab ?? 'all';
|
||||||
|
|
||||||
|
const getCustomSettingsWithMigration = () => {
|
||||||
|
if (typeof window === 'undefined') return {};
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem('customSettings');
|
||||||
|
const parsed = raw ? JSON.parse(raw) : {};
|
||||||
|
if (!parsed || typeof parsed !== 'object') return {};
|
||||||
|
if (parsed.pcTableColumnOrder != null || parsed.pcTableColumnVisibility != null || parsed.pcTableColumns != null || parsed.mobileTableColumnOrder != null || parsed.mobileTableColumnVisibility != null) {
|
||||||
|
const all = {
|
||||||
|
...(parsed.all && typeof parsed.all === 'object' ? parsed.all : {}),
|
||||||
|
pcTableColumnOrder: parsed.pcTableColumnOrder,
|
||||||
|
pcTableColumnVisibility: parsed.pcTableColumnVisibility,
|
||||||
|
pcTableColumns: parsed.pcTableColumns,
|
||||||
|
mobileTableColumnOrder: parsed.mobileTableColumnOrder,
|
||||||
|
mobileTableColumnVisibility: parsed.mobileTableColumnVisibility,
|
||||||
|
};
|
||||||
|
delete parsed.pcTableColumnOrder;
|
||||||
|
delete parsed.pcTableColumnVisibility;
|
||||||
|
delete parsed.pcTableColumns;
|
||||||
|
delete parsed.mobileTableColumnOrder;
|
||||||
|
delete parsed.mobileTableColumnVisibility;
|
||||||
|
parsed.all = all;
|
||||||
|
window.localStorage.setItem('customSettings', JSON.stringify(parsed));
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPcConfigFromGroup = (group) => {
|
||||||
|
if (!group || typeof group !== 'object') return null;
|
||||||
|
const sizing = group.pcTableColumns;
|
||||||
|
const sizingObj = sizing && typeof sizing === 'object'
|
||||||
|
? Object.fromEntries(Object.entries(sizing).filter(([, v]) => Number.isFinite(v)))
|
||||||
|
: {};
|
||||||
|
if (sizingObj.actions) {
|
||||||
|
const { actions, ...rest } = sizingObj;
|
||||||
|
Object.assign(sizingObj, rest);
|
||||||
|
delete sizingObj.actions;
|
||||||
|
}
|
||||||
|
const order = Array.isArray(group.pcTableColumnOrder) && group.pcTableColumnOrder.length > 0
|
||||||
|
? group.pcTableColumnOrder
|
||||||
|
: null;
|
||||||
|
const visibility = group.pcTableColumnVisibility && typeof group.pcTableColumnVisibility === 'object'
|
||||||
|
? group.pcTableColumnVisibility
|
||||||
|
: null;
|
||||||
|
return { sizing: sizingObj, order, visibility };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDefaultPcGroupConfig = () => ({
|
||||||
|
order: [...NON_FROZEN_COLUMN_IDS],
|
||||||
|
visibility: null,
|
||||||
|
sizing: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getInitialConfigByGroup = () => {
|
||||||
|
const parsed = getCustomSettingsWithMigration();
|
||||||
|
const byGroup = {};
|
||||||
|
Object.keys(parsed).forEach((k) => {
|
||||||
|
if (k === 'pcContainerWidth') return;
|
||||||
|
const group = parsed[k];
|
||||||
|
const pc = buildPcConfigFromGroup(group);
|
||||||
|
if (pc) {
|
||||||
|
byGroup[k] = {
|
||||||
|
pcTableColumnOrder: pc.order ? (() => {
|
||||||
|
const valid = pc.order.filter((id) => NON_FROZEN_COLUMN_IDS.includes(id));
|
||||||
|
const missing = NON_FROZEN_COLUMN_IDS.filter((id) => !valid.includes(id));
|
||||||
|
return [...valid, ...missing];
|
||||||
|
})() : null,
|
||||||
|
pcTableColumnVisibility: pc.visibility,
|
||||||
|
pcTableColumns: Object.keys(pc.sizing).length ? pc.sizing : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return byGroup;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [configByGroup, setConfigByGroup] = useState(getInitialConfigByGroup);
|
||||||
|
|
||||||
|
const currentGroupPc = configByGroup[groupKey];
|
||||||
|
const defaultPc = getDefaultPcGroupConfig();
|
||||||
|
const columnOrder = (() => {
|
||||||
|
const order = currentGroupPc?.pcTableColumnOrder ?? defaultPc.order;
|
||||||
|
if (!Array.isArray(order) || order.length === 0) return [...NON_FROZEN_COLUMN_IDS];
|
||||||
|
const valid = order.filter((id) => NON_FROZEN_COLUMN_IDS.includes(id));
|
||||||
|
const missing = NON_FROZEN_COLUMN_IDS.filter((id) => !valid.includes(id));
|
||||||
|
return [...valid, ...missing];
|
||||||
|
})();
|
||||||
|
const columnVisibility = (() => {
|
||||||
|
const vis = currentGroupPc?.pcTableColumnVisibility ?? null;
|
||||||
|
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) return vis;
|
||||||
|
const allVisible = {};
|
||||||
|
NON_FROZEN_COLUMN_IDS.forEach((id) => { allVisible[id] = true; });
|
||||||
|
return allVisible;
|
||||||
|
})();
|
||||||
|
const columnSizing = (() => {
|
||||||
|
const s = currentGroupPc?.pcTableColumns;
|
||||||
|
if (s && typeof s === 'object') {
|
||||||
|
const out = Object.fromEntries(Object.entries(s).filter(([, v]) => Number.isFinite(v)));
|
||||||
|
if (out.actions) {
|
||||||
|
const { actions, ...rest } = out;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const persistPcGroupConfig = (updates) => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem('customSettings');
|
||||||
|
const parsed = raw ? JSON.parse(raw) : {};
|
||||||
|
const group = parsed[groupKey] && typeof parsed[groupKey] === 'object' ? { ...parsed[groupKey] } : {};
|
||||||
|
if (updates.pcTableColumnOrder !== undefined) group.pcTableColumnOrder = updates.pcTableColumnOrder;
|
||||||
|
if (updates.pcTableColumnVisibility !== undefined) group.pcTableColumnVisibility = updates.pcTableColumnVisibility;
|
||||||
|
if (updates.pcTableColumns !== undefined) group.pcTableColumns = updates.pcTableColumns;
|
||||||
|
parsed[groupKey] = group;
|
||||||
|
window.localStorage.setItem('customSettings', JSON.stringify(parsed));
|
||||||
|
setConfigByGroup((prev) => ({ ...prev, [groupKey]: { ...prev[groupKey], ...updates } }));
|
||||||
|
onCustomSettingsChange?.();
|
||||||
|
} catch { }
|
||||||
|
};
|
||||||
|
|
||||||
|
const setColumnOrder = (nextOrderOrUpdater) => {
|
||||||
|
const next = typeof nextOrderOrUpdater === 'function'
|
||||||
|
? nextOrderOrUpdater(columnOrder)
|
||||||
|
: nextOrderOrUpdater;
|
||||||
|
persistPcGroupConfig({ pcTableColumnOrder: next });
|
||||||
|
};
|
||||||
|
const setColumnVisibility = (nextOrUpdater) => {
|
||||||
|
const next = typeof nextOrUpdater === 'function'
|
||||||
|
? nextOrUpdater(columnVisibility)
|
||||||
|
: nextOrUpdater;
|
||||||
|
persistPcGroupConfig({ pcTableColumnVisibility: next });
|
||||||
|
};
|
||||||
|
const setColumnSizing = (nextOrUpdater) => {
|
||||||
|
const next = typeof nextOrUpdater === 'function'
|
||||||
|
? nextOrUpdater(columnSizing)
|
||||||
|
: nextOrUpdater;
|
||||||
|
const { actions, ...rest } = next || {};
|
||||||
|
persistPcGroupConfig({ pcTableColumns: rest || {} });
|
||||||
|
};
|
||||||
|
const [settingModalOpen, setSettingModalOpen] = useState(false);
|
||||||
|
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||||
|
const handleResetSizing = () => {
|
||||||
|
setColumnSizing({});
|
||||||
|
setResetConfirmOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetColumnOrder = () => {
|
||||||
|
setColumnOrder([...NON_FROZEN_COLUMN_IDS]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetColumnVisibility = () => {
|
||||||
|
const allVisible = {};
|
||||||
|
NON_FROZEN_COLUMN_IDS.forEach((id) => {
|
||||||
|
allVisible[id] = true;
|
||||||
|
});
|
||||||
|
setColumnVisibility(allVisible);
|
||||||
|
};
|
||||||
|
const handleToggleColumnVisibility = (columnId, visible) => {
|
||||||
|
setColumnVisibility((prev = {}) => ({ ...prev, [columnId]: visible }));
|
||||||
|
};
|
||||||
|
const onRemoveFundRef = useRef(onRemoveFund);
|
||||||
|
const onToggleFavoriteRef = useRef(onToggleFavorite);
|
||||||
|
const onRemoveFromGroupRef = useRef(onRemoveFromGroup);
|
||||||
|
const onHoldingAmountClickRef = useRef(onHoldingAmountClick);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onRemoveFundRef.current = onRemoveFund;
|
||||||
|
onToggleFavoriteRef.current = onToggleFavorite;
|
||||||
|
onRemoveFromGroupRef.current = onRemoveFromGroup;
|
||||||
|
onHoldingAmountClickRef.current = onHoldingAmountClick;
|
||||||
|
}, [
|
||||||
|
onRemoveFund,
|
||||||
|
onToggleFavorite,
|
||||||
|
onRemoveFromGroup,
|
||||||
|
onHoldingAmountClick,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const FundNameCell = ({ info }) => {
|
||||||
|
const original = info.row.original || {};
|
||||||
|
const code = original.code;
|
||||||
|
const isUpdated = original.isUpdated;
|
||||||
|
const isFavorites = favorites?.has?.(code);
|
||||||
|
const isGroupTab = currentTab && currentTab !== 'all' && currentTab !== 'fav';
|
||||||
|
const rowContext = useContext(SortableRowContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="name-cell-content" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{sortBy === 'default' && (
|
||||||
|
<button
|
||||||
|
className="icon-button drag-handle"
|
||||||
|
ref={rowContext?.setActivatorNodeRef}
|
||||||
|
{...rowContext?.listeners}
|
||||||
|
style={{ cursor: 'grab', padding: 2, margin: '-2px -4px -2px 0', color: 'var(--muted)', background: 'transparent', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
title="拖拽排序"
|
||||||
|
onClick={(e) => e.stopPropagation?.()}
|
||||||
|
>
|
||||||
|
<DragIcon width="16" height="16" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isGroupTab ? (
|
||||||
|
<button
|
||||||
|
className="icon-button fav-button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation?.();
|
||||||
|
onRemoveFromGroupRef.current?.(original);
|
||||||
|
}}
|
||||||
|
title="从小分组移除"
|
||||||
|
style={{ backgroundColor: 'transparent'}}
|
||||||
|
>
|
||||||
|
<ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className={`icon-button fav-button ${isFavorites ? 'active' : ''}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation?.();
|
||||||
|
onToggleFavoriteRef.current?.(original);
|
||||||
|
}}
|
||||||
|
title={isFavorites ? '取消自选' : '添加自选'}
|
||||||
|
>
|
||||||
|
<StarIcon width="18" height="18" filled={isFavorites} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="title-text">
|
||||||
|
<span
|
||||||
|
className={`name-text`}
|
||||||
|
title={isUpdated ? '今日净值已更新' : ''}
|
||||||
|
>
|
||||||
|
{info.getValue() ?? '—'}
|
||||||
|
</span>
|
||||||
|
{code ? <span className="muted code-text">
|
||||||
|
#{code}
|
||||||
|
{isUpdated && <span className="updated-indicator">✓</span>}
|
||||||
|
</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'fundName',
|
||||||
|
header: '基金名称',
|
||||||
|
size: 265,
|
||||||
|
minSize: 140,
|
||||||
|
enablePinning: true,
|
||||||
|
cell: (info) => <FundNameCell info={info} />,
|
||||||
|
meta: {
|
||||||
|
align: 'left',
|
||||||
|
cellClassName: 'name-cell',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'latestNav',
|
||||||
|
header: '最新净值',
|
||||||
|
size: 100,
|
||||||
|
minSize: 80,
|
||||||
|
cell: (info) => (
|
||||||
|
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10}>
|
||||||
|
{info.getValue() ?? '—'}
|
||||||
|
</FitText>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
align: 'right',
|
||||||
|
cellClassName: 'value-cell',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'estimateNav',
|
||||||
|
header: '估算净值',
|
||||||
|
size: 100,
|
||||||
|
minSize: 80,
|
||||||
|
cell: (info) => (
|
||||||
|
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10}>
|
||||||
|
{info.getValue() ?? '—'}
|
||||||
|
</FitText>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
align: 'right',
|
||||||
|
cellClassName: 'value-cell',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'yesterdayChangePercent',
|
||||||
|
header: '昨日涨跌幅',
|
||||||
|
size: 135,
|
||||||
|
minSize: 100,
|
||||||
|
cell: (info) => {
|
||||||
|
const original = info.row.original || {};
|
||||||
|
const value = original.yesterdayChangeValue;
|
||||||
|
const date = original.yesterdayDate ?? '-';
|
||||||
|
const cls = value > 0 ? 'up' : value < 0 ? 'down' : '';
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||||
|
<FitText className={cls} style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
|
||||||
|
{info.getValue() ?? '—'}
|
||||||
|
</FitText>
|
||||||
|
<span className="muted" style={{ fontSize: '11px' }}>
|
||||||
|
{date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
align: 'right',
|
||||||
|
cellClassName: 'change-cell',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'estimateChangePercent',
|
||||||
|
header: '估值涨跌幅',
|
||||||
|
size: 135,
|
||||||
|
minSize: 100,
|
||||||
|
cell: (info) => {
|
||||||
|
const original = info.row.original || {};
|
||||||
|
const value = original.estimateChangeValue;
|
||||||
|
const isMuted = original.estimateChangeMuted;
|
||||||
|
const time = original.estimateTime ?? '-';
|
||||||
|
const cls = isMuted ? 'muted' : value > 0 ? 'up' : value < 0 ? 'down' : '';
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||||
|
<FitText className={cls} style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
|
||||||
|
{info.getValue() ?? '—'}
|
||||||
|
</FitText>
|
||||||
|
<span className="muted" style={{ fontSize: '11px' }}>
|
||||||
|
{time}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
align: 'right',
|
||||||
|
cellClassName: 'est-change-cell',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'holdingAmount',
|
||||||
|
header: '持仓金额',
|
||||||
|
size: 135,
|
||||||
|
minSize: 100,
|
||||||
|
cell: (info) => {
|
||||||
|
const original = info.row.original || {};
|
||||||
|
if (original.holdingAmountValue == null) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className="muted"
|
||||||
|
title="设置持仓"
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '12px', cursor: 'pointer' }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation?.();
|
||||||
|
onHoldingAmountClickRef.current?.(original, { hasHolding: false });
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onHoldingAmountClickRef.current?.(original, { hasHolding: false });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
未设置 <SettingsIcon width="12" height="12" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
title="点击设置持仓"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', width: '100%', minWidth: 0 }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation?.();
|
||||||
|
onHoldingAmountClickRef.current?.(original, { hasHolding: true });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: '1 1 0', minWidth: 0 }}>
|
||||||
|
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10}>
|
||||||
|
{info.getValue() ?? '—'}
|
||||||
|
</FitText>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="icon-button no-hover"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation?.();
|
||||||
|
onHoldingAmountClickRef.current?.(original, { hasHolding: true });
|
||||||
|
}}
|
||||||
|
title="编辑持仓"
|
||||||
|
style={{ border: 'none', width: '28px', height: '28px', marginLeft: 4, flexShrink: 0, backgroundColor: 'transparent' }}
|
||||||
|
>
|
||||||
|
<SettingsIcon width="14" height="14" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
align: 'right',
|
||||||
|
cellClassName: 'holding-amount-cell',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'todayProfit',
|
||||||
|
header: '当日收益',
|
||||||
|
size: 135,
|
||||||
|
minSize: 100,
|
||||||
|
cell: (info) => {
|
||||||
|
const original = info.row.original || {};
|
||||||
|
const value = original.todayProfitValue;
|
||||||
|
const hasProfit = value != null;
|
||||||
|
const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
|
||||||
|
const amountStr = hasProfit ? (info.getValue() ?? '') : '—';
|
||||||
|
const percentStr = original.todayProfitPercent ?? '';
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
|
||||||
|
{amountStr}
|
||||||
|
</FitText>
|
||||||
|
{percentStr ? (
|
||||||
|
<span className={`${cls} today-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||||
|
<FitText maxFontSize={11} minFontSize={9}>
|
||||||
|
{percentStr}
|
||||||
|
</FitText>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
align: 'right',
|
||||||
|
cellClassName: 'profit-cell',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'holdingProfit',
|
||||||
|
header: '持有收益',
|
||||||
|
size: 135,
|
||||||
|
minSize: 100,
|
||||||
|
cell: (info) => {
|
||||||
|
const original = info.row.original || {};
|
||||||
|
const value = original.holdingProfitValue;
|
||||||
|
const hasTotal = value != null;
|
||||||
|
const cls = hasTotal ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
|
||||||
|
const amountStr = hasTotal ? (info.getValue() ?? '') : '—';
|
||||||
|
const percentStr = original.holdingProfitPercent ?? '';
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
|
||||||
|
{amountStr}
|
||||||
|
</FitText>
|
||||||
|
{percentStr ? (
|
||||||
|
<span className={`${cls} holding-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||||
|
<FitText maxFontSize={11} minFontSize={9}>
|
||||||
|
{percentStr}
|
||||||
|
</FitText>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
align: 'right',
|
||||||
|
cellClassName: 'holding-cell',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: () => (
|
||||||
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span>操作</span>
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation?.();
|
||||||
|
setSettingModalOpen(true);
|
||||||
|
}}
|
||||||
|
title="个性化设置"
|
||||||
|
style={{ border: 'none', width: '24px', height: '24px', backgroundColor: 'transparent', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
<SettingsIcon width="14" height="14" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
size: 80,
|
||||||
|
minSize: 80,
|
||||||
|
maxSize: 80,
|
||||||
|
enableResizing: false,
|
||||||
|
enablePinning: true,
|
||||||
|
meta: {
|
||||||
|
align: 'center',
|
||||||
|
isAction: true,
|
||||||
|
cellClassName: 'action-cell',
|
||||||
|
},
|
||||||
|
cell: (info) => {
|
||||||
|
const original = info.row.original || {};
|
||||||
|
|
||||||
|
const handleClick = (e) => {
|
||||||
|
e.stopPropagation?.();
|
||||||
|
if (refreshing) return;
|
||||||
|
onRemoveFundRef.current?.(original);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row" style={{ justifyContent: 'center', gap: 4 }}>
|
||||||
|
<button
|
||||||
|
className="icon-button danger"
|
||||||
|
onClick={handleClick}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[currentTab, favorites, refreshing, sortBy],
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
enableColumnPinning: true,
|
||||||
|
enableColumnResizing: true,
|
||||||
|
columnResizeMode: 'onChange',
|
||||||
|
onColumnSizingChange: (updater) => {
|
||||||
|
setColumnSizing((prev) => {
|
||||||
|
const next = typeof updater === 'function' ? updater(prev) : updater;
|
||||||
|
const { actions, ...rest } = next || {};
|
||||||
|
return rest || {};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
columnSizing,
|
||||||
|
columnOrder,
|
||||||
|
columnVisibility,
|
||||||
|
},
|
||||||
|
onColumnOrderChange: (updater) => {
|
||||||
|
setColumnOrder(updater);
|
||||||
|
},
|
||||||
|
onColumnVisibilityChange: (updater) => {
|
||||||
|
setColumnVisibility(updater);
|
||||||
|
},
|
||||||
|
initialState: {
|
||||||
|
columnPinning: {
|
||||||
|
left: ['fundName'],
|
||||||
|
right: ['actions'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
defaultColumn: {
|
||||||
|
cell: (info) => info.getValue() ?? '—',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const headerGroup = table.getHeaderGroups()[0];
|
||||||
|
|
||||||
|
const getCommonPinningStyles = (column, isHeader) => {
|
||||||
|
const isPinned = column.getIsPinned();
|
||||||
|
const isNameColumn =
|
||||||
|
column.id === 'fundName' || column.columnDef?.accessorKey === 'fundName';
|
||||||
|
const style = {
|
||||||
|
width: `${column.getSize()}px`,
|
||||||
|
};
|
||||||
|
if (!isPinned) return style;
|
||||||
|
|
||||||
|
const isLeft = isPinned === 'left';
|
||||||
|
const isRight = isPinned === 'right';
|
||||||
|
|
||||||
|
return {
|
||||||
|
...style,
|
||||||
|
position: 'sticky',
|
||||||
|
left: isLeft ? `${column.getStart('left')}px` : undefined,
|
||||||
|
right: isRight ? `${column.getAfter('right')}px` : undefined,
|
||||||
|
zIndex: isHeader ? 11 : 10,
|
||||||
|
backgroundColor: isHeader ? 'var(--table-pinned-header-bg)' : 'var(--row-bg)',
|
||||||
|
boxShadow: 'none',
|
||||||
|
textAlign: isNameColumn ? 'left' : 'center',
|
||||||
|
justifyContent: isNameColumn ? 'flex-start' : 'center',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pc-fund-table">
|
||||||
|
<style>{`
|
||||||
|
.table-row-scroll {
|
||||||
|
--row-bg: var(--bg);
|
||||||
|
background-color: var(--row-bg);
|
||||||
|
}
|
||||||
|
.table-row-scroll:hover {
|
||||||
|
--row-bg: var(--table-row-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 覆盖 grid 布局为 flex 以支持动态列宽 */
|
||||||
|
.table-header-row-scroll,
|
||||||
|
.table-row-scroll {
|
||||||
|
display: flex !important;
|
||||||
|
width: fit-content !important;
|
||||||
|
min-width: 100%;
|
||||||
|
gap: 0 !important; /* Reset gap because we control width explicitly */
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header-cell,
|
||||||
|
.table-cell {
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 8px;
|
||||||
|
position: relative; /* For resizer */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 拖拽把手样式 */
|
||||||
|
.resizer {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 8px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: col-resize;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizer::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: 3px;
|
||||||
|
top: 12%;
|
||||||
|
bottom: 12%;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--border);
|
||||||
|
opacity: 0.35;
|
||||||
|
transition: opacity 0.2s, background-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizer:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--primary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(34, 211, 238, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header-cell:hover .resizer::after {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizer.disabled {
|
||||||
|
cursor: default;
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizer.disabled::after {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
{/* 表头 */}
|
||||||
|
{headerGroup && (
|
||||||
|
<div className="table-header-row table-header-row-scroll">
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
const style = getCommonPinningStyles(header.column, true);
|
||||||
|
const isNameColumn =
|
||||||
|
header.column.id === 'fundName' ||
|
||||||
|
header.column.columnDef?.accessorKey === 'fundName';
|
||||||
|
const align = isNameColumn ? '' : 'text-center';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={header.id}
|
||||||
|
className={`table-header-cell ${align}`}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
onMouseDown={header.column.getCanResize() ? header.getResizeHandler() : undefined}
|
||||||
|
onTouchStart={header.column.getCanResize() ? header.getResizeHandler() : undefined}
|
||||||
|
className={`resizer ${header.column.getIsResizing() ? 'isResizing' : ''
|
||||||
|
} ${header.column.getCanResize() ? '' : 'disabled'}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 表体 */}
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={handleDragCancel}
|
||||||
|
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={data.map((item) => item.code)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<SortableRow key={row.original.code || row.id} row={row} isTableDragging={!!activeId} disabled={sortBy !== 'default'}>
|
||||||
|
<div
|
||||||
|
className="table-row table-row-scroll"
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => {
|
||||||
|
const columnId = cell.column.id || cell.column.columnDef?.accessorKey;
|
||||||
|
const isNameColumn = columnId === 'fundName';
|
||||||
|
const rightAlignedColumns = new Set([
|
||||||
|
'latestNav',
|
||||||
|
'estimateNav',
|
||||||
|
'yesterdayChangePercent',
|
||||||
|
'estimateChangePercent',
|
||||||
|
'holdingAmount',
|
||||||
|
'todayProfit',
|
||||||
|
'holdingProfit',
|
||||||
|
]);
|
||||||
|
const align = isNameColumn
|
||||||
|
? ''
|
||||||
|
: rightAlignedColumns.has(columnId)
|
||||||
|
? 'text-right'
|
||||||
|
: 'text-center';
|
||||||
|
const cellClassName =
|
||||||
|
(cell.column.columnDef.meta && cell.column.columnDef.meta.cellClassName) || '';
|
||||||
|
const style = getCommonPinningStyles(cell.column, false);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={cell.id}
|
||||||
|
className={`table-cell ${align} ${cellClassName}`}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</SortableRow>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
{table.getRowModel().rows.length === 0 && (
|
||||||
|
<div className="table-row empty-row">
|
||||||
|
<div className="table-cell" style={{ textAlign: 'center' }}>
|
||||||
|
<span className="muted">暂无数据</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{resetConfirmOpen && (
|
||||||
|
<ConfirmModal
|
||||||
|
title="重置列宽"
|
||||||
|
message="是否重置表格列宽为默认值?"
|
||||||
|
onConfirm={handleResetSizing}
|
||||||
|
onCancel={() => setResetConfirmOpen(false)}
|
||||||
|
confirmText="重置"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<PcTableSettingModal
|
||||||
|
open={settingModalOpen}
|
||||||
|
onClose={() => setSettingModalOpen(false)}
|
||||||
|
columns={columnOrder.map((id) => ({ id, header: COLUMN_HEADERS[id] ?? id }))}
|
||||||
|
onColumnReorder={(newOrder) => {
|
||||||
|
setColumnOrder(newOrder);
|
||||||
|
}}
|
||||||
|
columnVisibility={columnVisibility}
|
||||||
|
onToggleColumnVisibility={handleToggleColumnVisibility}
|
||||||
|
onResetColumnOrder={handleResetColumnOrder}
|
||||||
|
onResetColumnVisibility={handleResetColumnVisibility}
|
||||||
|
onResetSizing={() => setResetConfirmOpen(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
241
app/components/PcTableSettingModal.jsx
Normal file
241
app/components/PcTableSettingModal.jsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
'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 - 点击重置列宽时的回调(通常用于打开确认弹框)
|
||||||
|
*/
|
||||||
|
export default function PcTableSettingModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
columns = [],
|
||||||
|
columnVisibility,
|
||||||
|
onColumnReorder,
|
||||||
|
onToggleColumnVisibility,
|
||||||
|
onResetColumnOrder,
|
||||||
|
onResetColumnVisibility,
|
||||||
|
onResetSizing,
|
||||||
|
}) {
|
||||||
|
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">
|
||||||
|
<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,8 +1,50 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
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 (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="modal-overlay"
|
className="modal-overlay"
|
||||||
@@ -18,7 +60,7 @@ export default function ScanPickModal({ onClose, onPick, isScanning }) {
|
|||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
className="glass card modal"
|
className="glass card modal scan-pick-modal"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{ width: 420, maxWidth: '90vw' }}
|
style={{ width: 420, maxWidth: '90vw' }}
|
||||||
>
|
>
|
||||||
@@ -26,7 +68,28 @@ export default function ScanPickModal({ onClose, onPick, isScanning }) {
|
|||||||
<span>选择持仓截图</span>
|
<span>选择持仓截图</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="muted" style={{ fontSize: 13, lineHeight: 1.6, marginBottom: 12 }}>
|
<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>
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
<button className="button secondary" onClick={onClose}>取消</button>
|
<button className="button secondary" onClick={onClose}>取消</button>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { SettingsIcon } from './Icons';
|
import { useEffect, useState } from 'react';
|
||||||
|
import ConfirmModal from './ConfirmModal';
|
||||||
|
import { ResetIcon, SettingsIcon } from './Icons';
|
||||||
|
|
||||||
export default function SettingsModal({
|
export default function SettingsModal({
|
||||||
onClose,
|
onClose,
|
||||||
@@ -10,21 +12,44 @@ export default function SettingsModal({
|
|||||||
exportLocalData,
|
exportLocalData,
|
||||||
importFileRef,
|
importFileRef,
|
||||||
handleImportFileChange,
|
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 (
|
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="glass card modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="title" style={{ marginBottom: 12 }}>
|
<div className="title" style={{ marginBottom: 12 }}>
|
||||||
<SettingsIcon width="20" height="20" />
|
<SettingsIcon width="20" height="20" />
|
||||||
<span>设置</span>
|
<span>设置</span>
|
||||||
<span className="muted">配置刷新频率</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||||
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>刷新频率</div>
|
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>刷新频率</div>
|
||||||
<div className="chips" style={{ marginBottom: 12 }}>
|
<div className="chips" style={{ marginBottom: 12 }}>
|
||||||
{[10, 30, 60, 120, 300].map((s) => (
|
{[30, 60, 120, 300].map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s}
|
key={s}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -39,19 +64,66 @@ export default function SettingsModal({
|
|||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="10"
|
inputMode="numeric"
|
||||||
|
min="30"
|
||||||
step="5"
|
step="5"
|
||||||
value={tempSeconds}
|
value={tempSeconds}
|
||||||
onChange={(e) => setTempSeconds(Number(e.target.value))}
|
onChange={(e) => setTempSeconds(Number(e.target.value))}
|
||||||
placeholder="自定义秒数"
|
placeholder="自定义秒数"
|
||||||
/>
|
/>
|
||||||
{tempSeconds < 10 && (
|
{tempSeconds < 30 && (
|
||||||
<div className="error-text" style={{ marginTop: 8 }}>
|
<div className="error-text" style={{ marginTop: 8 }}>
|
||||||
最小 10 秒
|
最小 30 秒
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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="form-group" style={{ marginBottom: 16 }}>
|
||||||
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>数据导出</div>
|
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>数据导出</div>
|
||||||
<div className="row" style={{ gap: 8 }}>
|
<div className="row" style={{ gap: 8 }}>
|
||||||
@@ -76,9 +148,21 @@ export default function SettingsModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="row" style={{ justifyContent: 'flex-end', marginTop: 24 }}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
{resetWidthConfirmOpen && onResetContainerWidth && (
|
||||||
|
<ConfirmModal
|
||||||
|
title="重置页面宽度"
|
||||||
|
message="是否重置页面宽度为默认值 1200px?"
|
||||||
|
onConfirm={() => {
|
||||||
|
onResetContainerWidth();
|
||||||
|
setResetWidthConfirmOpen(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setResetWidthConfirmOpen(false)}
|
||||||
|
confirmText="重置"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AnimatePresence, motion } from 'framer-motion';
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import utc from 'dayjs/plugin/utc';
|
import utc from 'dayjs/plugin/utc';
|
||||||
import timezone from 'dayjs/plugin/timezone';
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
|
import { isNumber } from 'lodash';
|
||||||
import { fetchSmartFundNetValue } from '../api/fund';
|
import { fetchSmartFundNetValue } from '../api/fund';
|
||||||
import { DatePicker, NumericInput } from './Common';
|
import { DatePicker, NumericInput } from './Common';
|
||||||
import ConfirmModal from './ConfirmModal';
|
import ConfirmModal from './ConfirmModal';
|
||||||
@@ -58,7 +59,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
}
|
}
|
||||||
}, [showPendingList, currentPendingTrades]);
|
}, [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 [price, setPrice] = useState(getEstimatePrice());
|
||||||
const [loadingPrice, setLoadingPrice] = useState(false);
|
const [loadingPrice, setLoadingPrice] = useState(false);
|
||||||
const [actualDate, setActualDate] = useState(null);
|
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 }}
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
className="glass card modal"
|
className="glass card modal trade-modal"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{ maxWidth: '420px' }}
|
style={{ maxWidth: '420px' }}
|
||||||
>
|
>
|
||||||
@@ -183,19 +184,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
|
|
||||||
{!showPendingList && !showConfirm && currentPendingTrades.length > 0 && (
|
{!showPendingList && !showConfirm && currentPendingTrades.length > 0 && (
|
||||||
<div
|
<div
|
||||||
style={{
|
className="trade-pending-alert"
|
||||||
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'
|
|
||||||
}}
|
|
||||||
onClick={() => setShowPendingList(true)}
|
onClick={() => setShowPendingList(true)}
|
||||||
>
|
>
|
||||||
<span>⚠️ 当前有 {currentPendingTrades.length} 笔待处理交易</span>
|
<span>⚠️ 当前有 {currentPendingTrades.length} 笔待处理交易</span>
|
||||||
@@ -205,7 +194,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
|
|
||||||
{showPendingList ? (
|
{showPendingList ? (
|
||||||
<div className="pending-list" style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
<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
|
<button
|
||||||
className="button secondary"
|
className="button secondary"
|
||||||
onClick={() => setShowPendingList(false)}
|
onClick={() => setShowPendingList(false)}
|
||||||
@@ -216,7 +205,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
</div>
|
</div>
|
||||||
<div className="pending-list-items" style={{ paddingTop: 0 }}>
|
<div className="pending-list-items" style={{ paddingTop: 0 }}>
|
||||||
{currentPendingTrades.map((trade, idx) => (
|
{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 }}>
|
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
<span style={{ fontWeight: 600, fontSize: '14px', color: trade.type === 'buy' ? 'var(--danger)' : 'var(--success)' }}>
|
<span style={{ fontWeight: 600, fontSize: '14px', color: trade.type === 'buy' ? 'var(--danger)' : 'var(--success)' }}>
|
||||||
{trade.type === 'buy' ? '买入' : '卖出'}
|
{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 }}>
|
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 4 }}>
|
||||||
<span className="muted">状态</span>
|
<span className="muted">状态</span>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<span style={{ color: '#e6a23c' }}>等待净值更新...</span>
|
<span className="trade-pending-status">等待净值更新...</span>
|
||||||
<button
|
<button
|
||||||
className="button secondary"
|
className="button secondary trade-revoke-btn"
|
||||||
onClick={() => setRevokeTrade(trade)}
|
onClick={() => setRevokeTrade(trade)}
|
||||||
style={{
|
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto' }}
|
||||||
padding: '2px 8px',
|
|
||||||
fontSize: '10px',
|
|
||||||
height: 'auto',
|
|
||||||
background: 'rgba(255,255,255,0.1)',
|
|
||||||
color: 'var(--text)'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
撤销
|
撤销
|
||||||
</button>
|
</button>
|
||||||
@@ -262,7 +245,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
{showConfirm ? (
|
{showConfirm ? (
|
||||||
isBuy ? (
|
isBuy ? (
|
||||||
<div style={{ fontSize: '14px' }}>
|
<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 }}>
|
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
<span className="muted">基金名称</span>
|
<span className="muted">基金名称</span>
|
||||||
<span style={{ fontWeight: 600 }}>{fund?.name}</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 className="muted">买入日期</span>
|
||||||
<span>{date}</span>
|
<span>{date}</span>
|
||||||
</div>
|
</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 className="muted">交易时段</span>
|
||||||
<span>{isAfter3pm ? '15:00后' : '15:00前'}</span>
|
<span>{isAfter3pm ? '15:00后' : '15:00前'}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -300,7 +283,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
<div style={{ marginBottom: 20 }}>
|
<div style={{ marginBottom: 20 }}>
|
||||||
<div className="muted" style={{ marginBottom: 8, fontSize: '12px' }}>持仓变化预览</div>
|
<div className="muted" style={{ marginBottom: 8, fontSize: '12px' }}>持仓变化预览</div>
|
||||||
<div className="row" style={{ gap: 12 }}>
|
<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 className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有份额</div>
|
||||||
<div style={{ fontSize: '12px' }}>
|
<div style={{ fontSize: '12px' }}>
|
||||||
<span style={{ opacity: 0.7 }}>{holding.share.toFixed(2)}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{price ? (
|
{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 className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有市值 (估)</div>
|
||||||
<div style={{ fontSize: '12px' }}>
|
<div style={{ fontSize: '12px' }}>
|
||||||
<span style={{ opacity: 0.7 }}>¥{(holding.share * Number(price)).toFixed(2)}</span>
|
<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 }}>
|
<div className="row" style={{ gap: 12 }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="button secondary"
|
className="button secondary trade-back-btn"
|
||||||
onClick={() => setShowConfirm(false)}
|
onClick={() => setShowConfirm(false)}
|
||||||
style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}
|
style={{ flex: 1 }}
|
||||||
>
|
>
|
||||||
返回修改
|
返回修改
|
||||||
</button>
|
</button>
|
||||||
@@ -344,7 +327,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ fontSize: '14px' }}>
|
<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 }}>
|
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
<span className="muted">基金名称</span>
|
<span className="muted">基金名称</span>
|
||||||
<span style={{ fontWeight: 600 }}>{fund?.name}</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 className="muted">卖出日期</span>
|
||||||
<span>{date}</span>
|
<span>{date}</span>
|
||||||
</div>
|
</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 className="muted">预计回款</span>
|
||||||
<span style={{ color: 'var(--danger)', fontWeight: 700 }}>{loadingPrice ? '计算中...' : (price ? `¥${estimatedReturn.toFixed(2)}` : '待计算')}</span>
|
<span style={{ color: 'var(--danger)', fontWeight: 700 }}>{loadingPrice ? '计算中...' : (price ? `¥${estimatedReturn.toFixed(2)}` : '待计算')}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -382,7 +365,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
<div style={{ marginBottom: 20 }}>
|
<div style={{ marginBottom: 20 }}>
|
||||||
<div className="muted" style={{ marginBottom: 8, fontSize: '12px' }}>持仓变化预览</div>
|
<div className="muted" style={{ marginBottom: 8, fontSize: '12px' }}>持仓变化预览</div>
|
||||||
<div className="row" style={{ gap: 12 }}>
|
<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 className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有份额</div>
|
||||||
<div style={{ fontSize: '12px' }}>
|
<div style={{ fontSize: '12px' }}>
|
||||||
<span style={{ opacity: 0.7 }}>{holding.share.toFixed(2)}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{price ? (
|
{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 className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有市值 (估)</div>
|
||||||
<div style={{ fontSize: '12px' }}>
|
<div style={{ fontSize: '12px' }}>
|
||||||
<span style={{ opacity: 0.7 }}>¥{(holding.share * sellPrice).toFixed(2)}</span>
|
<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 }}>
|
<div className="row" style={{ gap: 12 }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="button secondary"
|
className="button secondary trade-back-btn"
|
||||||
onClick={() => setShowConfirm(false)}
|
onClick={() => setShowConfirm(false)}
|
||||||
style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}
|
style={{ flex: 1 }}
|
||||||
>
|
>
|
||||||
返回修改
|
返回修改
|
||||||
</button>
|
</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 className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||||
交易时段
|
交易时段
|
||||||
</label>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
className={!isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
||||||
onClick={() => setIsAfter3pm(false)}
|
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前
|
15:00前
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
className={isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
||||||
onClick={() => setIsAfter3pm(true)}
|
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后
|
15:00后
|
||||||
</button>
|
</button>
|
||||||
@@ -543,17 +508,8 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
<button
|
<button
|
||||||
key={opt.label}
|
key={opt.label}
|
||||||
type="button"
|
type="button"
|
||||||
|
className="trade-amount-btn"
|
||||||
onClick={() => handleSetShareFraction(opt.value)}
|
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}
|
{opt.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -562,7 +518,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
)}
|
)}
|
||||||
{holding && (
|
{holding && (
|
||||||
<div className="muted" style={{ fontSize: '12px', marginTop: 6 }}>
|
<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>
|
||||||
)}
|
)}
|
||||||
</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 className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||||
交易时段
|
交易时段
|
||||||
</label>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
className={!isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
||||||
onClick={() => setIsAfter3pm(false)}
|
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前
|
15:00前
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
className={isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
||||||
onClick={() => setIsAfter3pm(true)}
|
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后
|
15:00后
|
||||||
</button>
|
</button>
|
||||||
@@ -662,7 +600,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="row" style={{ gap: 12, marginTop: 12 }}>
|
<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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="button"
|
className="button"
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default function TransactionHistoryModal({
|
|||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
className="glass card modal"
|
className="glass card modal tx-history-modal"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{ maxWidth: '480px', maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}
|
style={{ maxWidth: '480px', maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}
|
||||||
>
|
>
|
||||||
@@ -88,11 +88,18 @@ export default function TransactionHistoryModal({
|
|||||||
<div style={{ marginBottom: 20 }}>
|
<div style={{ marginBottom: 20 }}>
|
||||||
<div className="muted" style={{ fontSize: '12px', marginBottom: 8, paddingLeft: 4 }}>待处理队列</div>
|
<div className="muted" style={{ fontSize: '12px', marginBottom: 8, paddingLeft: 4 }}>待处理队列</div>
|
||||||
{pendingTransactions.map((item) => (
|
{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 }}>
|
<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 }}>
|
||||||
{item.type === 'buy' ? '买入' : '卖出'}
|
<span style={{ fontWeight: 600, fontSize: '14px', color: item.type === 'buy' ? 'var(--primary)' : 'var(--danger)' }}>
|
||||||
</span>
|
{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>
|
<span className="muted" style={{ fontSize: '12px' }}>{item.date} {item.isAfter3pm ? '(15:00后)' : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px' }}>
|
<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>
|
<span>{item.share ? `${Number(item.share).toFixed(2)} 份` : `¥${Number(item.amount).toFixed(2)}`}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 8 }}>
|
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 8 }}>
|
||||||
<span style={{ color: '#e6a23c' }}>等待净值更新...</span>
|
<span className="tx-history-pending-status">等待净值更新...</span>
|
||||||
<button
|
<button
|
||||||
className="button secondary"
|
className="button secondary tx-history-action-btn"
|
||||||
onClick={() => handleDeleteClick(item, 'pending')}
|
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>
|
</button>
|
||||||
@@ -121,11 +128,18 @@ export default function TransactionHistoryModal({
|
|||||||
<div className="muted" style={{ textAlign: 'center', padding: '20px 0', fontSize: '12px' }}>暂无历史交易记录</div>
|
<div className="muted" style={{ textAlign: 'center', padding: '20px 0', fontSize: '12px' }}>暂无历史交易记录</div>
|
||||||
) : (
|
) : (
|
||||||
sortedTransactions.map((item) => (
|
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 }}>
|
<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 }}>
|
||||||
{item.type === 'buy' ? '买入' : '卖出'}
|
<span style={{ fontWeight: 600, fontSize: '14px', color: item.type === 'buy' ? 'var(--primary)' : 'var(--danger)' }}>
|
||||||
</span>
|
{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>
|
<span className="muted" style={{ fontSize: '12px' }}>{item.date}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginBottom: 2 }}>
|
<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 }}>
|
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 8 }}>
|
||||||
<span className="muted"></span>
|
<span className="muted"></span>
|
||||||
<button
|
<button
|
||||||
className="button secondary"
|
className="button secondary tx-history-action-btn"
|
||||||
onClick={() => handleDeleteClick(item, 'history')}
|
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>
|
</button>
|
||||||
|
|||||||
1505
app/globals.css
1505
app/globals.css
File diff suppressed because it is too large
Load Diff
@@ -11,9 +11,15 @@ export default function RootLayout({ children }) {
|
|||||||
const GA_ID = process.env.NEXT_PUBLIC_GA_ID;
|
const GA_ID = process.env.NEXT_PUBLIC_GA_ID;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<AnalyticsGate GA_ID={GA_ID} />
|
<AnalyticsGate GA_ID={GA_ID} />
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
1465
app/page.jsx
1465
app/page.jsx
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ services:
|
|||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||||
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY: ${NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY}
|
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY: ${NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY}
|
||||||
NEXT_PUBLIC_GA_ID: ${NEXT_PUBLIC_GA_ID}
|
NEXT_PUBLIC_GA_ID: ${NEXT_PUBLIC_GA_ID}
|
||||||
|
NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL: ${NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL}
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
|||||||
@@ -13,4 +13,10 @@ NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=your_web3forms_access_key
|
|||||||
# 从 Google Analytics 中获取这些值 https://analytics.google.com/analytics/web/
|
# 从 Google Analytics 中获取这些值 https://analytics.google.com/analytics/web/
|
||||||
NEXT_PUBLIC_GA_ID=G-xxxxxxxxxx
|
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
|
# 如果要用 Github Actions 部署,需要在 Github 项目 Settings → secrets and actions → Actions → 创建 Repository secrets
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ const config = [
|
|||||||
...nextCoreWebVitals,
|
...nextCoreWebVitals,
|
||||||
{
|
{
|
||||||
rules: {
|
rules: {
|
||||||
'react-hooks/set-state-in-effect': 'off'
|
'react-hooks/set-state-in-effect': 'off',
|
||||||
|
'no-debugger': 'error'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
116
package-lock.json
generated
116
package-lock.json
generated
@@ -1,19 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "real-time-fund",
|
"name": "real-time-fund",
|
||||||
"version": "0.1.5",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-time-fund",
|
"name": "real-time-fund",
|
||||||
"version": "0.1.5",
|
"version": "0.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dicebear/collection": "^9.3.1",
|
"@dicebear/collection": "^9.3.1",
|
||||||
"@dicebear/core": "^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",
|
"@supabase/supabase-js": "^2.78.0",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"framer-motion": "^12.29.2",
|
"framer-motion": "^12.29.2",
|
||||||
|
"lodash": "^4.17.23",
|
||||||
"next": "^16.1.5",
|
"next": "^16.1.5",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
@@ -720,6 +726,73 @@
|
|||||||
"@dicebear/core": "^9.0.0"
|
"@dicebear/core": "^9.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/modifiers": {
|
||||||
|
"version": "9.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
|
||||||
@@ -1771,6 +1844,39 @@
|
|||||||
"tslib": "^2.8.0"
|
"tslib": "^2.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-table": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/table-core": "8.21.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8",
|
||||||
|
"react-dom": ">=16.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/table-core": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
@@ -5033,6 +5139,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.17.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||||
|
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-time-fund",
|
"name": "real-time-fund",
|
||||||
"version": "0.1.6",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -13,10 +13,16 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dicebear/collection": "^9.3.1",
|
"@dicebear/collection": "^9.3.1",
|
||||||
"@dicebear/core": "^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",
|
"@supabase/supabase-js": "^2.78.0",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"framer-motion": "^12.29.2",
|
"framer-motion": "^12.29.2",
|
||||||
|
"lodash": "^4.17.23",
|
||||||
"next": "^16.1.5",
|
"next": "^16.1.5",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user