From f5edd7bbf8aa007e1f89f4a68a52534855e7c0d8 Mon Sep 17 00:00:00 2001
From: hzm <934585316@qq.com>
Date: Wed, 25 Feb 2026 22:33:06 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9A=E6=8A=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/api/fund.js | 159 ++++----
app/assets/github.svg | 2 +-
app/components/DcaModal.jsx | 431 +++++++++++++++++++++
app/components/FundIntradayChart.jsx | 5 +-
app/components/FundTrendChart.jsx | 12 +-
app/components/HoldingActionModal.jsx | 21 +-
app/components/TradeModal.jsx | 3 +-
app/components/TransactionHistoryModal.jsx | 42 +-
app/lib/valuationTimeseries.js | 7 +-
app/page.jsx | 322 ++++++++++++---
package-lock.json | 7 +
package.json | 1 +
12 files changed, 869 insertions(+), 143 deletions(-)
create mode 100644 app/components/DcaModal.jsx
diff --git a/app/api/fund.js b/app/api/fund.js
index 5c572ad..1208bfc 100644
--- a/app/api/fund.js
+++ b/app/api/fund.js
@@ -1,6 +1,7 @@
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
+import { isString } from 'lodash';
import { cachedRequest, clearCachedRequest } from '../lib/cacheRequest';
dayjs.extend(utc);
@@ -20,7 +21,7 @@ const nowInTz = () => dayjs().tz(TZ);
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
export const loadScript = (url) => {
- if (typeof document === 'undefined' || !document.body) return Promise.resolve();
+ if (typeof document === 'undefined' || !document.body) return Promise.resolve(null);
let cacheKey = url;
try {
@@ -69,9 +70,7 @@ export const loadScript = (url) => {
clearCachedRequest(cacheKey);
throw new Error(result?.error || '数据加载失败');
}
- if (typeof window !== 'undefined' && result.apidata !== undefined) {
- window.apidata = result.apidata;
- }
+ return result.apidata;
});
};
@@ -79,9 +78,9 @@ export const fetchFundNetValue = async (code, date) => {
if (typeof window === 'undefined') return null;
const url = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=1&per=1&sdate=${date}&edate=${date}`;
try {
- await loadScript(url);
- if (window.apidata && window.apidata.content) {
- const content = window.apidata.content;
+ const apidata = await loadScript(url);
+ if (apidata && apidata.content) {
+ const content = apidata.content;
if (content.includes('暂无数据')) return null;
const rows = content.split('
/gi) || [];
+ for (const row of rowMatches) {
+ const cells = row.match(/]*>(.*?)<\/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) => {
const today = nowInTz().startOf('day');
let current = toTz(startDate).startOf('day');
@@ -157,43 +182,31 @@ export const fetchFundDataFallback = async (c) => {
});
} catch (e) {
}
- const tUrl = `https://qt.gtimg.cn/q=jj${c}`;
- const tScript = document.createElement('script');
- tScript.src = tUrl;
- tScript.onload = () => {
- const v = window[`v_jj${c}`];
- if (v && v.length > 5) {
- const p = v.split('~');
- const name = fundName || p[1] || `未知基金(${c})`;
- const dwjz = p[5];
- const zzl = parseFloat(p[7]);
- const jzrq = p[8] ? p[8].slice(0, 10) : '';
- if (dwjz) {
- resolve({
- code: c,
- name: name,
- dwjz: dwjz,
- gsz: null,
- gztime: null,
- jzrq: jzrq,
- gszzl: null,
- zzl: !isNaN(zzl) ? zzl : null,
- noValuation: true,
- holdings: []
- });
- } else {
- reject(new Error('未能获取到基金数据'));
- }
+ try {
+ const url = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${c}&page=1&per=1&sdate=&edate=`;
+ const apidata = await loadScript(url);
+ const content = apidata?.content || '';
+ const latest = parseLatestNetValueFromLsjzContent(content);
+ if (latest && latest.nav) {
+ const name = fundName || `未知基金(${c})`;
+ resolve({
+ code: c,
+ name,
+ dwjz: String(latest.nav),
+ gsz: null,
+ gztime: null,
+ jzrq: latest.date,
+ gszzl: null,
+ zzl: Number.isFinite(latest.growth) ? latest.growth : null,
+ noValuation: true,
+ holdings: []
+ });
} else {
reject(new Error('未能获取到基金数据'));
}
- if (document.body.contains(tScript)) document.body.removeChild(tScript);
- };
- tScript.onerror = () => {
- if (document.body.contains(tScript)) document.body.removeChild(tScript);
+ } catch (e) {
reject(new Error('基金数据加载失败'));
- };
- document.body.appendChild(tScript);
+ }
});
};
@@ -222,35 +235,29 @@ export const fetchFundData = async (c) => {
jzrq: json.jzrq,
gszzl: Number.isFinite(gszzlNum) ? gszzlNum : json.gszzl
};
- const tencentPromise = new Promise((resolveT) => {
- const tUrl = `https://qt.gtimg.cn/q=jj${c}`;
- const tScript = document.createElement('script');
- tScript.src = tUrl;
- tScript.onload = () => {
- const v = window[`v_jj${c}`];
- if (v) {
- const p = v.split('~');
- resolveT({
- dwjz: p[5],
- zzl: parseFloat(p[7]),
- jzrq: p[8] ? p[8].slice(0, 10) : ''
- });
- } else {
- resolveT(null);
- }
- if (document.body.contains(tScript)) document.body.removeChild(tScript);
- };
- tScript.onerror = () => {
- if (document.body.contains(tScript)) document.body.removeChild(tScript);
- resolveT(null);
- };
- document.body.appendChild(tScript);
+ const lsjzPromise = new Promise((resolveT) => {
+ const url = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${c}&page=1&per=1&sdate=&edate=`;
+ loadScript(url)
+ .then((apidata) => {
+ const content = apidata?.content || '';
+ const latest = parseLatestNetValueFromLsjzContent(content);
+ if (latest && latest.nav) {
+ resolveT({
+ dwjz: String(latest.nav),
+ zzl: Number.isFinite(latest.growth) ? latest.growth : null,
+ jzrq: latest.date
+ });
+ } else {
+ resolveT(null);
+ }
+ })
+ .catch(() => resolveT(null));
});
const holdingsPromise = new Promise((resolveH) => {
const holdingsUrl = `https://fundf10.eastmoney.com/FundArchivesDatas.aspx?type=jjcc&code=${c}&topline=10&year=&month=&_=${Date.now()}`;
- loadScript(holdingsUrl).then(async () => {
+ loadScript(holdingsUrl).then(async (apidata) => {
let holdings = [];
- const html = window.apidata?.content || '';
+ const html = apidata?.content || '';
const headerRow = (html.match(/[\s\S]*?<\/thead>/i) || [])[0] || '';
const headerCells = (headerRow.match(/([\s\S]*?)<\/th>/gi) || []).map(th => th.replace(/<[^>]*>/g, '').trim());
let idxCode = -1, idxName = -1, idxWeight = -1;
@@ -350,7 +357,7 @@ export const fetchFundData = async (c) => {
resolveH(holdings);
}).catch(() => resolveH([]));
});
- Promise.all([tencentPromise, holdingsPromise]).then(([tData, holdings]) => {
+ Promise.all([lsjzPromise, holdingsPromise]).then(([tData, holdings]) => {
if (tData) {
if (tData.jzrq && (!gzData.jzrq || tData.jzrq >= gzData.jzrq)) {
gzData.dwjz = tData.dwjz;
@@ -499,7 +506,7 @@ export const extractFundNamesWithLLM = async (ocrText) => {
const data = await resp.json();
let content = data?.choices?.[0]?.message?.content?.match(/\{[\s\S]*?\}/)?.[0];
- if (!content || typeof content !== 'string') return [];
+ if (!isString(content)) return [];
let parsed;
try {
@@ -511,7 +518,7 @@ export const extractFundNamesWithLLM = async (ocrText) => {
const names = parsed?.fund_names;
if (!Array.isArray(names)) return [];
return names
- .map((n) => (typeof n === 'string' ? n.trim().replaceAll(' ','') : ''))
+ .map((n) => (isString(n) ? n.trim().replaceAll(' ','') : ''))
.filter(Boolean);
} catch (e) {
return [];
@@ -566,26 +573,26 @@ export const fetchFundHistory = async (code, range = '1m') => {
// Fetch first page to get metadata
const firstUrl = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=${page}&per=${per}&sdate=${sdate}&edate=${edate}`;
- await loadScript(firstUrl);
+ const firstApidata = await loadScript(firstUrl);
- if (!window.apidata || !window.apidata.content || window.apidata.content.includes('暂无数据')) {
+ if (!firstApidata || !firstApidata.content || firstApidata.content.includes('暂无数据')) {
resolve([]);
return;
}
// Parse total pages
- if (window.apidata.pages) {
- totalPages = parseInt(window.apidata.pages, 10) || 1;
+ if (firstApidata.pages) {
+ totalPages = parseInt(firstApidata.pages, 10) || 1;
}
- allData = allData.concat(parseContent(window.apidata.content));
+ allData = allData.concat(parseContent(firstApidata.content));
// Fetch remaining pages
for (page = 2; page <= totalPages; page++) {
const nextUrl = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=${page}&per=${per}&sdate=${sdate}&edate=${edate}`;
- await loadScript(nextUrl);
- if (window.apidata && window.apidata.content) {
- allData = allData.concat(parseContent(window.apidata.content));
+ const nextApidata = await loadScript(nextUrl);
+ if (nextApidata && nextApidata.content) {
+ allData = allData.concat(parseContent(nextApidata.content));
}
}
diff --git a/app/assets/github.svg b/app/assets/github.svg
index c9d822f..d8f2d22 100644
--- a/app/assets/github.svg
+++ b/app/assets/github.svg
@@ -1,4 +1,4 @@
diff --git a/app/components/DcaModal.jsx b/app/components/DcaModal.jsx
new file mode 100644
index 0000000..55c7093
--- /dev/null
+++ b/app/components/DcaModal.jsx
@@ -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 (
+
+ e.stopPropagation()}
+ style={{ maxWidth: '420px' }}
+ >
+
+
+
+ {fund?.name}
+ #{fund?.code}
+
+
+
+
+
+ );
+}
+
diff --git a/app/components/FundIntradayChart.jsx b/app/components/FundIntradayChart.jsx
index c2f01f1..e371446 100644
--- a/app/components/FundIntradayChart.jsx
+++ b/app/components/FundIntradayChart.jsx
@@ -11,6 +11,7 @@ import {
Filler
} from 'chart.js';
import { Line } from 'react-chartjs-2';
+import { isNumber } from 'lodash';
ChartJS.register(
CategoryScale,
@@ -99,7 +100,7 @@ export default function FundIntradayChart({ series = [], referenceNav }) {
ticks: {
color: '#9ca3af',
font: { size: 10 },
- callback: (v) => (typeof v === 'number' ? `${v >= 0 ? '+' : ''}${v.toFixed(2)}%` : v)
+ callback: (v) => (isNumber(v) ? `${v >= 0 ? '+' : ''}${v.toFixed(2)}%` : v)
}
}
},
@@ -206,7 +207,7 @@ export default function FundIntradayChart({ series = [], referenceNav }) {
}
if (data && index in data) {
const val = data[index];
- const valueStr = typeof val === 'number' ? `${val >= 0 ? '+' : ''}${val.toFixed(2)}%` : String(val);
+ 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);
diff --git a/app/components/FundTrendChart.jsx b/app/components/FundTrendChart.jsx
index c45d346..08d42c4 100644
--- a/app/components/FundTrendChart.jsx
+++ b/app/components/FundTrendChart.jsx
@@ -311,7 +311,15 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
const textW = ctx.measureText(text).width;
const w = textW + paddingH * 2;
const h = 18;
- const left = x - w / 2;
+
+ // 计算原始 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);
@@ -323,7 +331,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
ctx.fillStyle = textColor;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
- ctx.fillText(text, x, top + h / 2);
+ ctx.fillText(text, centerX, top + h / 2);
ctx.restore();
};
diff --git a/app/components/HoldingActionModal.jsx b/app/components/HoldingActionModal.jsx
index 9d5105e..b700fab 100644
--- a/app/components/HoldingActionModal.jsx
+++ b/app/components/HoldingActionModal.jsx
@@ -27,7 +27,6 @@ export default function HoldingActionModal({ fund, onClose, onAction, hasHistory
持仓操作
- {hasHistory && (
- )}
| |