22 Commits

Author SHA1 Message Date
hzm
c91903a077 feat: 公告 2026-02-25 22:36:15 +08:00
hzm
f5edd7bbf8 feat: 定投 2026-02-25 22:33:06 +08:00
hzm
5f12e9d900 fix: 折线图最右侧x轴展示问题 2026-02-25 17:43:42 +08:00
hzm
1176a4ba18 fix: 涨跌幅排序问题 2026-02-25 10:33:39 +08:00
hzm
d73a9ef9fa feat: 检测软件更新地址需要传入变量 2026-02-25 09:50:56 +08:00
hzm
43206e816f Merge remote-tracking branch 'origin/main' 2026-02-25 09:33:27 +08:00
hzm
048bd8db57 feat: 完善折线图2秒后自动失焦 2026-02-24 23:03:03 +08:00
hzm
42327fc110 feat: 折线图2秒后自动失焦 2026-02-24 22:05:11 +08:00
hzm
194f9246ef feat: y轴坐标改为左侧展示 2026-02-24 21:36:56 +08:00
hzm
17edeccecc feat: OCR 支持搜索基金名称 2026-02-24 20:26:32 +08:00
hzm
b9ee4546b7 fix: 初始化渲染数字的时候不展示动画 2026-02-24 15:18:19 +08:00
hzm
5214f618ba fix: 初始化当日收益计算逻辑问题 2026-02-24 14:57:56 +08:00
hzm
3d2fc36f69 feat: 新增 beta 版实时估值分时图 2026-02-24 11:38:34 +08:00
hzm
1db379c048 fix: 持仓收益允许输入负数 2026-02-24 10:48:54 +08:00
hzm
aaa91868a3 feat: 优化折线图标签绘制,添加圆角矩形背景 2026-02-24 10:14:27 +08:00
hzm
faecf13df8 feat: 添加 inputMode 属性以支持小数输入 2026-02-24 09:55:17 +08:00
hzm
b59f1c809f feat: 调整背景色 2026-02-24 08:33:37 +08:00
hzm
d1bf5db4c5 feat: OCR识别支持拖拽导入 2026-02-24 08:20:54 +08:00
hzm
13992b6155 feat: 调整刷新样式和买入卖出折线图颜色 2026-02-24 08:02:06 +08:00
hzm
6e6ec6cb03 feat: 调整买入卖出折线图样式 2026-02-24 00:18:11 +08:00
hzm
fe1f67407d fix: 删除 debugger 2026-02-23 23:47:24 +08:00
hzm
62180be8ac feat: 仅在登录时检查本地与云端是否一致 2026-02-23 23:44:21 +08:00
27 changed files with 1877 additions and 271 deletions

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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

View File

@@ -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));
} }
} }

View File

@@ -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

View File

@@ -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}

View File

@@ -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_v9';
export default function Announcement() { export default function Announcement() {
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
@@ -67,13 +67,11 @@ 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.7 版本更新内容如下</p>
<p>1. OCR 识别截图导入基金</p> <p>1. 实时基金估值折线图测试版</p>
<p>2. 基金历史曲线图</p> <p>2. 定投</p>
<p>3. 买入卖出历史记录</p>
以下内容会在近期更新 以下内容会在近期更新
<p>1. 定投</p> <p>1. 自定义布局</p>
<p>2. 自定义布局</p>
</div> </div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}> <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}>

View File

@@ -271,6 +271,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}

431
app/components/DcaModal.jsx Normal file
View File

@@ -0,0 +1,431 @@
'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"
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
style={{
width: 32,
height: 18,
borderRadius: 999,
background: enabled ? 'var(--primary)' : 'rgba(148,163,184,0.6)',
position: 'relative',
transition: 'background 0.2s'
}}
>
<span
style={{
position: 'absolute',
top: 2,
left: enabled ? 16 : 2,
width: 14,
height: 14,
borderRadius: '50%',
background: '#0f172a',
transition: 'left 0.2s'
}}
/>
</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="row" style={{ gap: 4, background: 'rgba(0,0,0,0.2)', borderRadius: 8, padding: 4 }}>
{CYCLES.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setCycle(opt.value)}
style={{
flex: 1,
border: 'none',
background: cycle === opt.value ? 'var(--primary)' : 'transparent',
color: cycle === opt.value ? '#05263b' : 'var(--muted)',
borderRadius: 6,
fontSize: 11,
cursor: 'pointer',
padding: '4px 6px',
whiteSpace: 'nowrap'
}}
>
{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="row" style={{ gap: 4, background: 'rgba(0,0,0,0.2)', borderRadius: 8, padding: 4 }}>
{WEEKDAY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setWeeklyDay(opt.value)}
style={{
flex: 1,
border: 'none',
background: weeklyDay === opt.value ? 'var(--primary)' : 'transparent',
color: weeklyDay === opt.value ? '#05263b' : 'var(--muted)',
borderRadius: 6,
fontSize: 12,
cursor: 'pointer',
padding: '6px 4px',
whiteSpace: 'nowrap'
}}
>
{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
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 4,
background: 'rgba(0,0,0,0.2)',
borderRadius: 8,
padding: 4,
maxHeight: 140,
overflowY: 'auto',
scrollBehavior: 'smooth'
}}
>
{Array.from({ length: 28 }).map((_, idx) => {
const day = idx + 1;
const active = monthlyDay === day;
return (
<button
key={day}
ref={active ? monthlyDayRef : null}
type="button"
onClick={() => setMonthlyDay(day)}
style={{
flex: '0 0 calc(25% - 4px)',
border: 'none',
background: active ? 'var(--primary)' : 'transparent',
color: active ? '#05263b' : 'var(--muted)',
borderRadius: 6,
fontSize: 11,
cursor: 'pointer',
padding: '4px 0'
}}
>
{day}
</button>
);
})}
</div>
</div>
)}
<div className="form-group" style={{ marginBottom: 16 }}>
<label className="muted" style={{ display: 'block', marginBottom: 4, fontSize: '14px' }}>
首次扣款日期
</label>
<div
style={{
borderRadius: 12,
border: '1px solid var(--border)',
padding: '10px 12px',
fontSize: 14,
background: 'rgba(15,23,42,0.6)'
}}
>
{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"
onClick={onClose}
style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}
>
取消
</button>
<button
type="submit"
className="button"
disabled={!isValid()}
style={{ flex: 1, opacity: isValid() ? 1 : 0.6 }}
>
保存定投
</button>
</div>
</form>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,251 @@
'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
);
/**
* 分时图:展示当日(或最近一次记录日)的估值序列,纵轴为相对参考净值的涨跌幅百分比。
* series: Array<{ time: string, value: number, date?: string }>
* referenceNav: 参考净值(最新单位净值),用于计算涨跌幅;未传则用当日第一个估值作为参考。
*/
export default function FundIntradayChart({ series = [], referenceNav }) {
const chartRef = useRef(null);
const hoverTimeoutRef = useRef(null);
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 = '#f87171'; // 涨用红色
const fallColor = '#34d399'; // 跌用绿色
// 以最新点相对参考净值的涨跌定色:涨(>=0)红,跌(<0)绿
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]);
const options = useMemo(() => ({
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: '#9ca3af',
font: { size: 10 },
maxTicksLimit: 6
}
},
y: {
display: true,
position: 'left',
grid: { color: '#1f2937', drawBorder: false },
ticks: {
color: '#9ca3af',
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);
}
}
}), []);
useEffect(() => {
return () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
};
}, []);
const plugins = useMemo(() => [{
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 = '#9ca3af';
ctx.moveTo(x, topY);
ctx.lineTo(x, bottomY);
ctx.moveTo(leftX, y);
ctx.lineTo(rightX, y);
ctx.stroke();
const prim = typeof document !== 'undefined'
? (getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#22d3ee')
: '#22d3ee';
const bgText = '#0f172a';
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 = bgText;
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 = bgText;
ctx.fillText(valueStr, leftX + vw / 2, y);
}
ctx.restore();
}
}], []);
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>
);
}

View File

@@ -35,11 +35,12 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
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);
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);
@@ -85,10 +86,11 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
// Red for up, Green for down (CN market style) // Red for up, Green for down (CN market style)
// Hardcoded hex values from globals.css for Chart.js // Hardcoded hex values from globals.css for Chart.js
const upColor = '#f87171'; // --danger const upColor = '#f87171'; // --danger,与折线图红色一致
const downColor = '#34d399'; // --success const downColor = '#34d399'; // --success
const lineColor = change >= 0 ? upColor : downColor; const lineColor = change >= 0 ? upColor : downColor;
const primaryColor = typeof document !== 'undefined' ? (getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#22d3ee') : '#22d3ee';
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 +141,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 +154,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,7 +165,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
} }
] ]
}; };
}, [data, lineColor, transactions]); }, [data, lineColor, transactions, primaryColor]);
const options = useMemo(() => { const options = useMemo(() => {
return { return {
@@ -195,7 +199,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
}, },
y: { y: {
display: true, display: true,
position: 'right', position: 'left',
grid: { grid: {
color: '#1f2937', color: '#1f2937',
drawBorder: false, drawBorder: false,
@@ -214,69 +218,124 @@ 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: () => {}
};
}, []);
useEffect(() => {
return () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
} }
}; };
}, []); }, []);
const plugins = useMemo(() => [{ const plugins = useMemo(() => [{
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 = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#22d3ee';
// 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 +343,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;
@@ -302,11 +382,11 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
// 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 +397,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 +405,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 = '#0f172a'; // --background
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 = '#0f172a'; // --background
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(valueStr, rightX - valWidth / 2, y); ctx.fillText(valueStr, leftX + valWidth / 2, y);
} }
} }
@@ -355,15 +441,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 : '#f87171';
// 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);
} }
}); });
@@ -372,10 +458,10 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
} }
} }
}], []); // 移除 data 依赖,因为我们直接从 chart 实例读取数据 }], []); // 移除 data 依赖,因为我们直接从 chart 实例读取数据
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 +489,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
)} )}
</div> </div>
</div> </div>
<AnimatePresence> <AnimatePresence>
{isExpanded && ( {isExpanded && (
<motion.div <motion.div
@@ -415,16 +501,16 @@ 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 style={{
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(255,255,255,0.02)', zIndex: 10, backdropFilter: 'blur(2px)' 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 style={{
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(255,255,255,0.02)', zIndex: 10 background: 'rgba(255,255,255,0.02)', zIndex: 10
}}> }}>

View File

@@ -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"
onClick={() => onAction('dca')}
style={{ background: 'rgba(34, 211, 238, 0.12)', border: '1px solid #ffffff', color: '#ffffff', 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>

View File

@@ -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}

View File

@@ -1,8 +1,54 @@
'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,
border: `2px dashed ${isDragging ? 'var(--primary)' : 'var(--border)'}`,
background: isDragging
? 'rgba(34, 211, 238, 0.08)'
: 'rgba(255, 255, 255, 0.02)',
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"
@@ -26,7 +72,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="muted"
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>

View File

@@ -39,6 +39,7 @@ export default function SettingsModal({
<input <input
className="input" className="input"
type="number" type="number"
inputMode="numeric"
min="10" min="10"
step="5" step="5"
value={tempSeconds} value={tempSeconds}

View File

@@ -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);

View File

@@ -90,9 +90,24 @@ export default function TransactionHistoryModal({
{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} style={{ background: 'rgba(230, 162, 60, 0.1)', border: '1px solid rgba(230, 162, 60, 0.2)', borderRadius: 8, padding: 12, marginBottom: 8 }}>
<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
style={{
fontSize: 10,
padding: '2px 6px',
borderRadius: 999,
background: 'rgba(34,197,94,0.15)',
color: '#4ade80'
}}
>
定投
</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' }}>
@@ -123,9 +138,24 @@ export default function TransactionHistoryModal({
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} style={{ background: 'rgba(255, 255, 255, 0.05)', borderRadius: 8, padding: 12, marginBottom: 8 }}>
<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
style={{
fontSize: 10,
padding: '2px 6px',
borderRadius: 999,
background: 'rgba(34,197,94,0.15)',
color: '#4ade80'
}}
>
定投
</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 }}>

View File

@@ -22,8 +22,34 @@ body {
body { body {
margin: 0; margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
background: radial-gradient(1200px 600px at 10% -10%, rgba(96, 165, 250, 0.15), transparent 40%), radial-gradient(1000px 500px at 90% 0%, rgba(34, 211, 238, 0.12), transparent 45%), var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
position: relative;
}
/* 渐变层固定为视口大小,随宽高变化自动重绘,保证任意尺寸下都连贯 */
body::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
/* 降低顶部光晕强度,上方颜色更深 */
background:
radial-gradient(
ellipse 140% 140% at 10% -10%,
rgba(96, 165, 250, 0.08) 0%,
rgba(96, 165, 250, 0.035) 35%,
rgba(96, 165, 250, 0.01) 55%,
transparent 72%
),
radial-gradient(
ellipse 140% 140% at 90% 0%,
rgba(34, 211, 238, 0.07) 0%,
rgba(34, 211, 238, 0.03) 38%,
rgba(34, 211, 238, 0.008) 58%,
transparent 75%
);
} }
.container { .container {
@@ -102,6 +128,9 @@ body {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
/* PC 端固定高度,避免聚焦搜索时展开动画导致导航栏高度跳动 */
min-height: 68px;
box-sizing: border-box;
} }
.brand { .brand {
@@ -133,6 +162,7 @@ body {
.navbar-add-fund.search-focused { .navbar-add-fund.search-focused {
max-width: 800px; max-width: 800px;
min-width: 320px;
flex: 1; flex: 1;
} }
@@ -177,6 +207,17 @@ body {
height: auto; height: auto;
} }
/* PC 端聚焦搜索时禁止换行,避免导航栏高度变化 */
@media (min-width: 641px) {
.navbar-add-fund.search-focused .search-input-wrapper {
flex-wrap: nowrap !important;
}
.navbar-add-fund.search-focused .navbar-input-shell {
flex-wrap: nowrap !important;
min-width: 0;
}
}
/* Mobile Search Logic */ /* Mobile Search Logic */
@media (max-width: 640px) { @media (max-width: 640px) {
/* Default: Search hidden, Trigger visible */ /* Default: Search hidden, Trigger visible */
@@ -1012,6 +1053,39 @@ input[type="number"] {
box-shadow: 0 0 0 3px rgba(96,165,250,0.15); box-shadow: 0 0 0 3px rgba(96,165,250,0.15);
cursor: default; cursor: default;
} }
/* 刷新按钮外圈进度条 */
.refresh-btn-wrap {
--progress: 0;
position: relative;
width: 40px;
height: 40px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.refresh-btn-wrap::before {
content: '';
position: absolute;
inset: 0;
border-radius: 12px;
padding: 1.5px;
background: conic-gradient(
var(--primary) 0deg,
var(--primary) calc(var(--progress) * 360deg),
var(--border) calc(var(--progress) * 360deg)
);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.refresh-btn-wrap .icon-button {
position: relative;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.spin { .spin {
animation: none; animation: none;

View 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();
}

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -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

View File

@@ -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'
} }
} }
]; ];

11
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "real-time-fund", "name": "real-time-fund",
"version": "0.1.5", "version": "0.1.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-time-fund", "name": "real-time-fund",
"version": "0.1.5", "version": "0.1.6",
"dependencies": { "dependencies": {
"@dicebear/collection": "^9.3.1", "@dicebear/collection": "^9.3.1",
"@dicebear/core": "^9.3.1", "@dicebear/core": "^9.3.1",
@@ -14,6 +14,7 @@
"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",
@@ -5033,6 +5034,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",

View File

@@ -17,6 +17,7 @@
"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",