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(''); 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(//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}
+
+ +
+
+ +
+ +
+ +
+ +
+
+ +
+
+ +
+ +
+
+
+ +
+ {CYCLES.map((opt) => ( + + ))} +
+
+
+ + {(cycle === 'weekly' || cycle === 'biweekly') && ( +
+ +
+ {WEEKDAY_OPTIONS.map((opt) => ( + + ))} +
+
+ )} + + {cycle === 'monthly' && ( +
+ +
+ {Array.from({ length: 28 }).map((_, idx) => { + const day = idx + 1; + const active = monthlyDay === day; + return ( + + ); + })} +
+
+ )} + +
+ +
+ {firstDate} +
+
+ * 基于当前日期和所选周期/扣款日自动计算:每日=当天;每周/每两周=从今天起最近的所选工作日;每月=从今天起最近的所选日期(1-28日)。 +
+
+ +
+ + +
+
+
+
+ ); +} + 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 && ( - )}
- + diff --git a/app/components/TradeModal.jsx b/app/components/TradeModal.jsx index ee699db..f34ffb0 100644 --- a/app/components/TradeModal.jsx +++ b/app/components/TradeModal.jsx @@ -5,6 +5,7 @@ import { AnimatePresence, motion } from 'framer-motion'; import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; +import { isNumber } from 'lodash'; import { fetchSmartFundNetValue } from '../api/fund'; import { DatePicker, NumericInput } from './Common'; import ConfirmModal from './ConfirmModal'; @@ -58,7 +59,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe } }, [showPendingList, currentPendingTrades]); - const getEstimatePrice = () => fund?.estPricedCoverage > 0.05 ? fund?.estGsz : (typeof fund?.gsz === 'number' ? fund?.gsz : Number(fund?.dwjz)); + const getEstimatePrice = () => fund?.estPricedCoverage > 0.05 ? fund?.estGsz : (isNumber(fund?.gsz) ? fund?.gsz : Number(fund?.dwjz)); const [price, setPrice] = useState(getEstimatePrice()); const [loadingPrice, setLoadingPrice] = useState(false); const [actualDate, setActualDate] = useState(null); diff --git a/app/components/TransactionHistoryModal.jsx b/app/components/TransactionHistoryModal.jsx index 012b0d7..e9ce64d 100644 --- a/app/components/TransactionHistoryModal.jsx +++ b/app/components/TransactionHistoryModal.jsx @@ -90,9 +90,24 @@ export default function TransactionHistoryModal({ {pendingTransactions.map((item) => (
- - {item.type === 'buy' ? '买入' : '卖出'} - +
+ + {item.type === 'buy' ? '买入' : '卖出'} + + {item.type === 'buy' && item.isDca && ( + + 定投 + + )} +
{item.date} {item.isAfter3pm ? '(15:00后)' : ''}
@@ -123,9 +138,24 @@ export default function TransactionHistoryModal({ sortedTransactions.map((item) => (
- - {item.type === 'buy' ? '买入' : '卖出'} - +
+ + {item.type === 'buy' ? '买入' : '卖出'} + + {item.type === 'buy' && item.isDca && ( + + 定投 + + )} +
{item.date}
diff --git a/app/lib/valuationTimeseries.js b/app/lib/valuationTimeseries.js index 7f51b0f..9d3e3ff 100644 --- a/app/lib/valuationTimeseries.js +++ b/app/lib/valuationTimeseries.js @@ -2,6 +2,7 @@ * 记录每次调用基金估值接口的结果,用于分时图。 * 规则:获取到最新日期的数据时,清掉所有老日期的数据,只保留当日分时点。 */ +import { isPlainObject, isString } from 'lodash'; const STORAGE_KEY = 'fundValuationTimeseries'; @@ -10,7 +11,7 @@ function getStored() { try { const raw = window.localStorage.getItem(STORAGE_KEY); const parsed = raw ? JSON.parse(raw) : {}; - return typeof parsed === 'object' && parsed !== null ? parsed : {}; + return isPlainObject(parsed) ? parsed : {}; } catch { return {}; } @@ -29,7 +30,7 @@ function setStored(data) { * 从 gztime 或 Date 得到日期字符串 YYYY-MM-DD */ function toDateStr(gztimeOrNow) { - if (typeof gztimeOrNow === 'string' && /^\d{4}-\d{2}-\d{2}/.test(gztimeOrNow)) { + if (isString(gztimeOrNow) && /^\d{4}-\d{2}-\d{2}/.test(gztimeOrNow)) { return gztimeOrNow.slice(0, 10); } try { @@ -59,7 +60,7 @@ export function recordValuation(code, payload) { const dateStr = toDateStr(gztime); if (!dateStr) return getValuationSeries(code); - const timeLabel = typeof gztime === 'string' && gztime.length > 10 + const timeLabel = isString(gztime) && gztime.length > 10 ? gztime.slice(11, 16) : (() => { const d = new Date(); diff --git a/app/page.jsx b/app/page.jsx index 5f1f90d..dbeda50 100644 --- a/app/page.jsx +++ b/app/page.jsx @@ -9,6 +9,7 @@ import { glass } from '@dicebear/collection'; import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; +import { isNumber, isString, isPlainObject } from 'lodash'; import Announcement from "./components/Announcement"; import { Stat } from "./components/Common"; import FundTrendChart from "./components/FundTrendChart"; @@ -36,6 +37,7 @@ import TransactionHistoryModal from "./components/TransactionHistoryModal"; import AddHistoryModal from "./components/AddHistoryModal"; import UpdatePromptModal from "./components/UpdatePromptModal"; import WeChatModal from "./components/WeChatModal"; +import DcaModal from "./components/DcaModal"; import githubImg from "./assets/github.svg"; import { supabase, isSupabaseConfigured } from './lib/supabase'; import { recordValuation, getAllValuationSeries, clearFund } from './lib/valuationTimeseries'; @@ -171,7 +173,7 @@ function GroupSummary({ funds, holdings, groupName, getProfit, stickyTop }) { } if (profit.profitTotal !== null) { totalHoldingReturn += profit.profitTotal; - if (holding && typeof holding.cost === 'number' && typeof holding.share === 'number') { + if (holding && isNumber(holding.cost) && isNumber(holding.share)) { totalCost += holding.cost * holding.share; } } @@ -419,11 +421,13 @@ export default function HomePage() { const [holdingModal, setHoldingModal] = useState({ open: false, fund: null }); const [actionModal, setActionModal] = useState({ open: false, fund: null }); const [tradeModal, setTradeModal] = useState({ open: false, fund: null, type: 'buy' }); // type: 'buy' | 'sell' + const [dcaModal, setDcaModal] = useState({ open: false, fund: null }); const [clearConfirm, setClearConfirm] = useState(null); // { fund } const [donateOpen, setDonateOpen] = useState(false); const [holdings, setHoldings] = useState({}); // { [code]: { share: number, cost: number } } const [pendingTrades, setPendingTrades] = useState([]); // [{ id, fundCode, share, date, ... }] const [transactions, setTransactions] = useState({}); // { [code]: [{ id, type, amount, share, price, date, timestamp }] } + const [dcaPlans, setDcaPlans] = useState({}); // { [code]: { amount, feeRate, cycle, firstDate, enabled } } const [historyModal, setHistoryModal] = useState({ open: false, fund: null }); const [addHistoryModal, setAddHistoryModal] = useState({ open: false, fund: null }); const [percentModes, setPercentModes] = useState({}); // { [code]: boolean } @@ -556,10 +560,10 @@ export default function HomePage() { // 计算持仓收益 const getHoldingProfit = (fund, holding) => { - if (!holding || typeof holding.share !== 'number') return null; + if (!holding || !isNumber(holding.share)) return null; const hasTodayData = fund.jzrq === todayStr; - const hasTodayValuation = typeof fund.gztime === 'string' && fund.gztime.startsWith(todayStr); + const hasTodayValuation = isString(fund.gztime) && fund.gztime.startsWith(todayStr); const canCalcTodayProfit = hasTodayData || hasTodayValuation; // 如果是交易日且9点以后,且今日净值未出,则强制使用估值(隐藏涨跌幅列模式) @@ -577,8 +581,8 @@ export default function HomePage() { const amount = holding.share * currentNav; // 优先用 zzl (真实涨跌幅), 降级用 gszzl // 若 gztime 日期 > jzrq,说明估值更新晚于净值日期,优先使用 gszzl 计算当日盈亏 - const gz = typeof fund.gztime === 'string' ? toTz(fund.gztime) : null; - const jz = typeof fund.jzrq === 'string' ? toTz(fund.jzrq) : null; + const gz = isString(fund.gztime) ? toTz(fund.gztime) : null; + const jz = isString(fund.jzrq) ? toTz(fund.jzrq) : null; const preferGszzl = !!gz && !!jz && @@ -602,7 +606,7 @@ export default function HomePage() { // 否则使用估值 currentNav = fund.estPricedCoverage > 0.05 ? fund.estGsz - : (typeof fund.gsz === 'number' ? fund.gsz : Number(fund.dwjz)); + : (isNumber(fund.gsz) ? fund.gsz : Number(fund.dwjz)); if (!currentNav) return null; @@ -620,7 +624,7 @@ export default function HomePage() { const amount = holding.share * currentNav; // 总收益 = (当前净值 - 成本价) * 份额 - const profitTotal = typeof holding.cost === 'number' + const profitTotal = isNumber(holding.cost) ? (currentNav - holding.cost) * holding.share : null; @@ -642,8 +646,8 @@ export default function HomePage() { }) .sort((a, b) => { if (sortBy === 'yield') { - const valA = typeof a.estGszzl === 'number' ? a.estGszzl : (a.gszzl ?? a.zzl ?? 0); - const valB = typeof b.estGszzl === 'number' ? b.estGszzl : (b.gszzl ?? a.zzl ?? 0); + const valA = isNumber(a.estGszzl) ? a.estGszzl : (a.gszzl ?? a.zzl ?? 0); + const valB = isNumber(b.estGszzl) ? b.estGszzl : (b.gszzl ?? a.zzl ?? 0); return sortOrder === 'asc' ? valA - valB : valB - valA; } if (sortBy === 'holding') { @@ -704,6 +708,8 @@ export default function HomePage() { setTradeModal({ open: true, fund, type }); } else if (type === 'history') { setHistoryModal({ open: true, fund }); + } else if (type === 'dca') { + setDcaModal({ open: true, fund }); } }; @@ -772,6 +778,7 @@ export default function HomePage() { price: result.value, date: result.date, // 使用获取到净值的日期 isAfter3pm: trade.isAfter3pm, + isDca: !!trade.isDca, timestamp: Date.now() }); } @@ -845,6 +852,7 @@ export default function HomePage() { price: data.price, date: data.date, isAfter3pm: false, // 历史记录通常不需要此标记,或者默认为 false + isDca: false, timestamp: data.timestamp || Date.now() }; // 按时间倒序排列 @@ -872,6 +880,7 @@ export default function HomePage() { feeValue: data.feeValue, date: data.date, isAfter3pm: data.isAfter3pm, + isDca: false, timestamp: Date.now() }; @@ -918,6 +927,7 @@ export default function HomePage() { price: data.price, date: data.date, isAfter3pm: data.isAfter3pm, + isDca: false, timestamp: Date.now() }; const next = [record, ...current]; @@ -978,6 +988,8 @@ export default function HomePage() { }, 3000); }; + // 定投计划自动生成买入队列的逻辑会在 storageHelper 定义之后实现 + const handleOpenLogin = () => { setUserMenuOpen(false); if (!isSupabaseConfigured) { @@ -1125,7 +1137,7 @@ export default function HomePage() { parsedNames = []; } parsedNames.forEach((name) => { - if (name && typeof name === 'string') { + if (isString(name)) { allNames.add(name.trim()); } }); @@ -1352,7 +1364,7 @@ export default function HomePage() { const storageHelper = useMemo(() => { // 仅以下 key 参与云端同步;fundValuationTimeseries 不同步到云端(测试中功能,暂不同步) - const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'collapsedTrends', 'refreshMs', 'holdings', 'pendingTrades', 'transactions', 'viewMode']); + const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'collapsedTrends', 'refreshMs', 'holdings', 'pendingTrades', 'transactions', 'viewMode', 'dcaPlans']); const triggerSync = (key, prevValue, nextValue) => { if (keys.has(key)) { // 标记为脏数据 @@ -1399,7 +1411,7 @@ export default function HomePage() { useEffect(() => { // 仅以下 key 的变更会触发云端同步;fundValuationTimeseries 不在其中 - const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'collapsedTrends', 'refreshMs', 'holdings', 'pendingTrades', 'viewMode']); + const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'collapsedTrends', 'refreshMs', 'holdings', 'pendingTrades', 'viewMode', 'dcaPlans']); const onStorage = (e) => { if (!e.key) return; if (e.key === 'localUpdatedAt') { @@ -1467,6 +1479,104 @@ export default function HomePage() { }); }; + const scheduleDcaTrades = useCallback(() => { + if (!isPlainObject(dcaPlans)) return; + const codesSet = new Set(funds.map((f) => f.code)); + if (codesSet.size === 0) return; + + const today = toTz(todayStr).startOf('day'); + const nextPlans = { ...dcaPlans }; + const newPending = []; + + Object.entries(dcaPlans).forEach(([code, plan]) => { + if (!plan || !plan.enabled) return; + if (!codesSet.has(code)) return; + + const amount = Number(plan.amount); + const feeRate = Number(plan.feeRate) || 0; + if (!amount || amount <= 0) return; + + const cycle = plan.cycle || 'monthly'; + if (!plan.firstDate) return; + + const first = toTz(plan.firstDate).startOf('day'); + if (today.isBefore(first, 'day')) return; + + const last = plan.lastDate ? toTz(plan.lastDate).startOf('day') : null; + + // 回溯补单:从 lastDate (若不存在则从 firstDate 前一天) 开始,按周期一直推到今天 + let anchor = last ? last : first.clone().subtract(1, 'day'); + let current = anchor; + let lastGenerated = null; + + const stepOnce = () => { + if (cycle === 'daily') return current.add(1, 'day'); + if (cycle === 'weekly') return current.add(1, 'week'); + if (cycle === 'biweekly') return current.add(2, 'week'); + if (cycle === 'monthly') return current.add(1, 'month'); + return current.add(1, 'day'); + }; + + // 循环生成所有 <= today 的应扣款日 + while (true) { + current = stepOnce(); + if (current.isAfter(today, 'day')) break; + if (current.isBefore(first, 'day')) continue; + + const dateStr = current.format('YYYY-MM-DD'); + + const pending = { + id: `dca_${code}_${dateStr}_${Date.now()}`, + fundCode: code, + fundName: (funds.find(f => f.code === code) || {}).name, + type: 'buy', + share: null, + amount, + feeRate, + feeMode: undefined, + feeValue: undefined, + date: dateStr, + isAfter3pm: false, + isDca: true, + timestamp: Date.now() + }; + newPending.push(pending); + lastGenerated = current; + } + + if (lastGenerated) { + nextPlans[code] = { + ...plan, + lastDate: lastGenerated.format('YYYY-MM-DD') + }; + } + }); + + if (newPending.length === 0) { + if (JSON.stringify(nextPlans) !== JSON.stringify(dcaPlans)) { + setDcaPlans(nextPlans); + storageHelper.setItem('dcaPlans', JSON.stringify(nextPlans)); + } + return; + } + + setDcaPlans(nextPlans); + storageHelper.setItem('dcaPlans', JSON.stringify(nextPlans)); + + setPendingTrades(prev => { + const merged = [...(prev || []), ...newPending]; + storageHelper.setItem('pendingTrades', JSON.stringify(merged)); + return merged; + }); + + showToast(`已生成 ${newPending.length} 笔定投买入`, 'success'); + }, [dcaPlans, funds, todayStr, storageHelper]); + + useEffect(() => { + if (!isTradingDay) return; + scheduleDcaTrades(); + }, [isTradingDay, scheduleDcaTrades]); + const handleAddGroup = (name) => { const newGroup = { id: `group_${Date.now()}`, @@ -1597,13 +1707,17 @@ export default function HomePage() { } // 加载持仓数据 const savedHoldings = JSON.parse(localStorage.getItem('holdings') || '{}'); - if (savedHoldings && typeof savedHoldings === 'object') { + if (isPlainObject(savedHoldings)) { setHoldings(savedHoldings); } const savedTransactions = JSON.parse(localStorage.getItem('transactions') || '{}'); - if (savedTransactions && typeof savedTransactions === 'object') { + if (isPlainObject(savedTransactions)) { setTransactions(savedTransactions); } + const savedDcaPlans = JSON.parse(localStorage.getItem('dcaPlans') || '{}'); + if (isPlainObject(savedDcaPlans)) { + setDcaPlans(savedDcaPlans); + } const savedViewMode = localStorage.getItem('viewMode'); if (savedViewMode === 'card' || savedViewMode === 'list') { setViewMode(savedViewMode); @@ -1696,14 +1810,14 @@ export default function HomePage() { .channel(`user-configs-${user.id}`) .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'user_configs', filter: `user_id=eq.${user.id}` }, async (payload) => { const incoming = payload?.new?.data; - if (!incoming || typeof incoming !== 'object') return; + if (!isPlainObject(incoming)) return; const incomingComparable = getComparablePayload(incoming); if (!incomingComparable || incomingComparable === lastSyncedRef.current) return; await applyCloudConfig(incoming, payload.new.updated_at); }) .on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'user_configs', filter: `user_id=eq.${user.id}` }, async (payload) => { const incoming = payload?.new?.data; - if (!incoming || typeof incoming !== 'object') return; + if (!isPlainObject(incoming)) return; const incomingComparable = getComparablePayload(incoming); if (!incomingComparable || incomingComparable === lastSyncedRef.current) return; await applyCloudConfig(incoming, payload.new.updated_at); @@ -2068,7 +2182,7 @@ export default function HomePage() { const requestRemoveFund = (fund) => { const h = holdings[fund.code]; - const hasHolding = h && typeof h.share === 'number' && h.share > 0; + const hasHolding = h && isNumber(h.share) && h.share > 0; if (hasHolding) { setFundDeleteConfirm({ code: fund.code, name: fund.name }); } else { @@ -2210,6 +2324,15 @@ export default function HomePage() { delete next[removeCode]; return next; }); + + // 同步删除该基金的定投计划 + setDcaPlans(prev => { + if (!prev || !prev[removeCode]) return prev; + const next = { ...prev }; + delete next[removeCode]; + storageHelper.setItem('dcaPlans', JSON.stringify(next)); + return next; + }); }; const manualRefresh = async () => { @@ -2238,7 +2361,7 @@ export default function HomePage() { }; function getComparablePayload(payload) { - if (!payload || typeof payload !== 'object') return ''; + if (!isPlainObject(payload)) return ''; const rawFunds = Array.isArray(payload.funds) ? payload.funds : []; const fundCodes = rawFunds .map((fund) => normalizeCode(fund?.code || fund?.CODE)) @@ -2262,7 +2385,7 @@ export default function HomePage() { .map((group) => { const id = normalizeCode(group?.id); if (!id) return null; - const name = typeof group?.name === 'string' ? group.name : ''; + const name = isString(group?.name) ? group.name : ''; const codes = Array.isArray(group?.codes) ? Array.from(new Set(group.codes.map(normalizeCode).filter((code) => uniqueFundCodes.includes(code)))).sort() : []; @@ -2272,9 +2395,7 @@ export default function HomePage() { .sort((a, b) => a.id.localeCompare(b.id)) : []; - const holdingsSource = payload.holdings && typeof payload.holdings === 'object' && !Array.isArray(payload.holdings) - ? payload.holdings - : {}; + const holdingsSource = isPlainObject(payload.holdings) ? payload.holdings : {}; const holdings = {}; Object.keys(holdingsSource) .map(normalizeCode) @@ -2303,20 +2424,19 @@ export default function HomePage() { feeMode: trade?.feeMode || '', feeValue: normalizeNumber(trade?.feeValue), date: trade?.date || '', - isAfter3pm: !!trade?.isAfter3pm + isAfter3pm: !!trade?.isAfter3pm, + isDca: !!trade?.isDca }; }) .filter((trade) => trade && uniqueFundCodes.includes(trade.fundCode)) .sort((a, b) => { - const keyA = a.id || `${a.fundCode}|${a.type}|${a.date}|${a.share ?? ''}|${a.amount ?? ''}|${a.feeMode}|${a.feeValue ?? ''}|${a.feeRate ?? ''}|${a.isAfter3pm ? 1 : 0}`; - const keyB = b.id || `${b.fundCode}|${b.type}|${b.date}|${b.share ?? ''}|${b.amount ?? ''}|${b.feeMode}|${b.feeValue ?? ''}|${b.feeRate ?? ''}|${b.isAfter3pm ? 1 : 0}`; + const keyA = a.id || `${a.fundCode}|${a.type}|${a.date}|${a.share ?? ''}|${a.amount ?? ''}|${a.feeMode}|${a.feeValue ?? ''}|${a.feeRate ?? ''}|${a.isAfter3pm ? 1 : 0}|${a.isDca ? 1 : 0}`; + const keyB = b.id || `${b.fundCode}|${b.type}|${b.date}|${b.share ?? ''}|${b.amount ?? ''}|${b.feeMode}|${b.feeValue ?? ''}|${b.feeRate ?? ''}|${b.isAfter3pm ? 1 : 0}|${b.isDca ? 1 : 0}`; return keyA.localeCompare(keyB); }) : []; - const transactionsSource = payload.transactions && typeof payload.transactions === 'object' && !Array.isArray(payload.transactions) - ? payload.transactions - : {}; + const transactionsSource = isPlainObject(payload.transactions) ? payload.transactions : {}; const transactions = {}; Object.keys(transactionsSource) .map(normalizeCode) @@ -2333,13 +2453,43 @@ export default function HomePage() { const price = normalizeNumber(t?.price); const date = t?.date || ''; const timestamp = Number.isFinite(t?.timestamp) ? t.timestamp : 0; - return { id, type, share, amount, price, date, timestamp }; + const isDca = !!t?.isDca; + return { id, type, share, amount, price, date, timestamp, isDca }; }) .filter((t) => t.id || t.timestamp) .sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); if (normalized.length > 0) transactions[code] = normalized; }); + const dcaSource = isPlainObject(payload.dcaPlans) ? payload.dcaPlans : {}; + const dcaPlans = {}; + Object.keys(dcaSource) + .map(normalizeCode) + .filter((code) => uniqueFundCodes.includes(code)) + .sort() + .forEach((code) => { + const plan = dcaSource[code] || {}; + const amount = normalizeNumber(plan.amount); + const feeRate = normalizeNumber(plan.feeRate); + const cycle = ['daily', 'weekly', 'biweekly', 'monthly'].includes(plan.cycle) ? plan.cycle : ''; + const firstDate = plan.firstDate ? String(plan.firstDate) : ''; + const enabled = !!plan.enabled; + const weeklyDay = normalizeNumber(plan.weeklyDay); + const monthlyDay = normalizeNumber(plan.monthlyDay); + const lastDate = plan.lastDate ? String(plan.lastDate) : ''; + if (amount === null && feeRate === null && !cycle && !firstDate && !enabled && weeklyDay === null && monthlyDay === null && !lastDate) return; + dcaPlans[code] = { + amount, + feeRate, + cycle, + firstDate, + enabled, + weeklyDay: weeklyDay !== null ? weeklyDay : null, + monthlyDay: monthlyDay !== null ? monthlyDay : null, + lastDate + }; + }); + const viewMode = payload.viewMode === 'list' ? 'list' : 'card'; return JSON.stringify({ @@ -2352,6 +2502,7 @@ export default function HomePage() { holdings, pendingTrades, transactions, + dcaPlans, viewMode }); } @@ -2390,6 +2541,9 @@ export default function HomePage() { if (!keys || keys.has('transactions')) { all.transactions = JSON.parse(localStorage.getItem('transactions') || '{}'); } + if (!keys || keys.has('dcaPlans')) { + all.dcaPlans = JSON.parse(localStorage.getItem('dcaPlans') || '{}'); + } // 如果是全量收集(keys 为 null),进行完整的数据清洗和验证逻辑 if (!keys) { @@ -2399,17 +2553,17 @@ export default function HomePage() { : [] ); - const cleanedHoldings = all.holdings && typeof all.holdings === 'object' && !Array.isArray(all.holdings) + const cleanedHoldings = isPlainObject(all.holdings) ? Object.entries(all.holdings).reduce((acc, [code, value]) => { - if (!fundCodes.has(code) || !value || typeof value !== 'object') return acc; - const parsedShare = typeof value.share === 'number' + if (!fundCodes.has(code) || !isPlainObject(value)) return acc; + const parsedShare = isNumber(value.share) ? value.share - : typeof value.share === 'string' + : isString(value.share) ? Number(value.share) : NaN; - const parsedCost = typeof value.cost === 'number' + const parsedCost = isNumber(value.cost) ? value.cost - : typeof value.cost === 'string' + : isString(value.cost) ? Number(value.cost) : NaN; const nextShare = Number.isFinite(parsedShare) ? parsedShare : null; @@ -2440,6 +2594,14 @@ export default function HomePage() { })) : []; + const cleanedDcaPlans = isPlainObject(all.dcaPlans) + ? Object.entries(all.dcaPlans).reduce((acc, [code, plan]) => { + if (!fundCodes.has(code) || !isPlainObject(plan)) return acc; + acc[code] = plan; + return acc; + }, {}) + : {}; + return { funds: all.funds, favorites: cleanedFavorites, @@ -2449,6 +2611,8 @@ export default function HomePage() { refreshMs: all.refreshMs, holdings: cleanedHoldings, pendingTrades: all.pendingTrades, + transactions: all.transactions, + dcaPlans: cleanedDcaPlans, viewMode: all.viewMode }; } @@ -2463,9 +2627,12 @@ export default function HomePage() { favorites: [], groups: [], collapsedCodes: [], + collapsedTrends: [], refreshMs: 30000, holdings: {}, pendingTrades: [], + transactions: {}, + dcaPlans: {}, viewMode: 'card', exportedAt: nowInTz().toISOString() }; @@ -2473,7 +2640,7 @@ export default function HomePage() { }; const applyCloudConfig = async (cloudData, cloudUpdatedAt) => { - if (!cloudData || typeof cloudData !== 'object') return; + if (!isPlainObject(cloudData)) return; skipSyncRef.current = true; try { if (cloudUpdatedAt) { @@ -2505,7 +2672,7 @@ export default function HomePage() { applyViewMode(cloudData.viewMode); } - const nextHoldings = cloudData.holdings && typeof cloudData.holdings === 'object' ? cloudData.holdings : {}; + const nextHoldings = isPlainObject(cloudData.holdings) ? cloudData.holdings : {}; setHoldings(nextHoldings); storageHelper.setItem('holdings', JSON.stringify(nextHoldings)); @@ -2515,10 +2682,19 @@ export default function HomePage() { setPendingTrades(nextPendingTrades); storageHelper.setItem('pendingTrades', JSON.stringify(nextPendingTrades)); - const nextTransactions = cloudData.transactions && typeof cloudData.transactions === 'object' ? cloudData.transactions : {}; + const nextTransactions = isPlainObject(cloudData.transactions) ? cloudData.transactions : {}; setTransactions(nextTransactions); storageHelper.setItem('transactions', JSON.stringify(nextTransactions)); + const cloudDca = isPlainObject(cloudData.dcaPlans) ? cloudData.dcaPlans : {}; + const nextDcaPlans = Object.entries(cloudDca).reduce((acc, [code, plan]) => { + if (!nextFundCodes.has(code) || !isPlainObject(plan)) return acc; + acc[code] = plan; + return acc; + }, {}); + setDcaPlans(nextDcaPlans); + storageHelper.setItem('dcaPlans', JSON.stringify(nextDcaPlans)); + if (nextFunds.length) { const codes = Array.from(new Set(nextFunds.map((f) => f.code))); if (codes.length) await refreshAll(codes); @@ -2548,7 +2724,7 @@ export default function HomePage() { setCloudConfigModal({ open: true, userId, type: 'empty' }); return; } - if (data?.data && typeof data.data === 'object' && Object.keys(data.data).length > 0) { + if (data?.data && isPlainObject(data.data) && Object.keys(data.data).length > 0) { const localPayload = collectLocalPayload(); const localComparable = getComparablePayload(localPayload); const cloudComparable = getComparablePayload(data.data); @@ -2669,6 +2845,7 @@ export default function HomePage() { holdings: JSON.parse(localStorage.getItem('holdings') || '{}'), pendingTrades: JSON.parse(localStorage.getItem('pendingTrades') || '[]'), transactions: JSON.parse(localStorage.getItem('transactions') || '{}'), + dcaPlans: JSON.parse(localStorage.getItem('dcaPlans') || '{}'), exportedAt: nowInTz().toISOString() }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); @@ -2715,7 +2892,7 @@ export default function HomePage() { if (!file) return; const text = await file.text(); const data = JSON.parse(text); - if (data && typeof data === 'object') { + if (isPlainObject(data)) { // 从 localStorage 读取最新数据进行合并,防止状态滞后导致的数据丢失 const currentFunds = JSON.parse(localStorage.getItem('funds') || '[]'); const currentFavorites = JSON.parse(localStorage.getItem('favorites') || '[]'); @@ -2723,6 +2900,7 @@ export default function HomePage() { const currentCollapsed = JSON.parse(localStorage.getItem('collapsedCodes') || '[]'); const currentTrends = JSON.parse(localStorage.getItem('collapsedTrends') || '[]'); const currentPendingTrades = JSON.parse(localStorage.getItem('pendingTrades') || '[]'); + const currentDcaPlans = JSON.parse(localStorage.getItem('dcaPlans') || '{}'); let mergedFunds = currentFunds; let appendedCodes = []; @@ -2773,7 +2951,7 @@ export default function HomePage() { storageHelper.setItem('collapsedTrends', JSON.stringify(mergedTrends)); } - if (typeof data.refreshMs === 'number' && data.refreshMs >= 5000) { + if (isNumber(data.refreshMs) && data.refreshMs >= 5000) { setRefreshMs(data.refreshMs); setTempSeconds(Math.round(data.refreshMs / 1000)); storageHelper.setItem('refreshMs', String(data.refreshMs)); @@ -2782,13 +2960,13 @@ export default function HomePage() { applyViewMode(data.viewMode); } - if (data.holdings && typeof data.holdings === 'object') { + if (isPlainObject(data.holdings)) { const mergedHoldings = { ...JSON.parse(localStorage.getItem('holdings') || '{}'), ...data.holdings }; setHoldings(mergedHoldings); storageHelper.setItem('holdings', JSON.stringify(mergedHoldings)); } - if (data.transactions && typeof data.transactions === 'object') { + if (isPlainObject(data.transactions)) { const currentTransactions = JSON.parse(localStorage.getItem('transactions') || '{}'); const mergedTransactions = { ...currentTransactions }; Object.entries(data.transactions).forEach(([code, txs]) => { @@ -2824,6 +3002,12 @@ export default function HomePage() { storageHelper.setItem('pendingTrades', JSON.stringify(mergedPending)); } + if (isPlainObject(data.dcaPlans)) { + const mergedDca = { ...(isPlainObject(currentDcaPlans) ? currentDcaPlans : {}), ...data.dcaPlans }; + setDcaPlans(mergedDca); + storageHelper.setItem('dcaPlans', JSON.stringify(mergedDca)); + } + // 导入成功后,仅刷新新追加的基金 if (appendedCodes.length) { // 这里需要确保 refreshAll 不会因为闭包问题覆盖掉刚刚合并好的 mergedFunds @@ -2858,6 +3042,7 @@ export default function HomePage() { holdingModal.open || actionModal.open || tradeModal.open || + dcaModal.open || !!clearConfirm || donateOpen || !!fundDeleteConfirm || @@ -3632,7 +3817,7 @@ export default function HomePage() {
0.05 ? (f.estGszzl > 0 ? 'up' : f.estGszzl < 0 ? 'down' : '') : (Number(f.gszzl) > 0 ? 'up' : Number(f.gszzl) < 0 ? 'down' : '')} style={{ fontWeight: 700 }}> - {f.estPricedCoverage > 0.05 ? `${f.estGszzl > 0 ? '+' : ''}${f.estGszzl.toFixed(2)}%` : (typeof f.gszzl === 'number' ? `${f.gszzl > 0 ? '+' : ''}${f.gszzl.toFixed(2)}%` : f.gszzl ?? '—')} + {f.estPricedCoverage > 0.05 ? `${f.estGszzl > 0 ? '+' : ''}${f.estGszzl.toFixed(2)}%` : (isNumber(f.gszzl) ? `${f.gszzl > 0 ? '+' : ''}${f.gszzl.toFixed(2)}%` : f.gszzl ?? '—')}
@@ -3807,13 +3992,21 @@ export default function HomePage() { <> {(() => { const hasTodayData = f.jzrq === todayStr; - const shouldHideChange = isTradingDay && !hasTodayData; + let isYesterdayChange = false; + if (!hasTodayData && isString(f.gztime) && isString(f.jzrq)) { + const gzDate = toTz(f.gztime).startOf('day'); + const jzDate = toTz(f.jzrq).startOf('day'); + if (gzDate.clone().subtract(1, 'day').isSame(jzDate, 'day')) { + isYesterdayChange = true; + } + } + const shouldHideChange = isTradingDay && !hasTodayData && !isYesterdayChange; if (shouldHideChange) return null; return ( 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%` : ''} delta={f.zzl} /> @@ -3822,7 +4015,7 @@ export default function HomePage() { 0.05 ? f.estGsz.toFixed(4) : (f.gsz ?? '—')} /> 0.05 ? `${f.estGszzl > 0 ? '+' : ''}${f.estGszzl.toFixed(2)}%` : (typeof f.gszzl === 'number' ? `${f.gszzl > 0 ? '+' : ''}${f.gszzl.toFixed(2)}%` : f.gszzl ?? '—')} + value={f.estPricedCoverage > 0.05 ? `${f.estGszzl > 0 ? '+' : ''}${f.estGszzl.toFixed(2)}%` : (isNumber(f.gszzl) ? `${f.gszzl > 0 ? '+' : ''}${f.gszzl.toFixed(2)}%` : f.gszzl ?? '—')} delta={f.estPricedCoverage > 0.05 ? f.estGszzl : (Number(f.gszzl) || 0)} /> @@ -3941,7 +4134,7 @@ export default function HomePage() {
{h.name}
- {typeof h.change === 'number' && ( + {isNumber(h.change) && ( 0 ? 'up' : h.change < 0 ? 'down' : ''}`} style={{ marginRight: 8 }}> {h.change > 0 ? '+' : ''}{h.change.toFixed(2)}% @@ -4121,6 +4314,39 @@ export default function HomePage() { )} + + {dcaModal.open && ( + setDcaModal({ open: false, fund: null })} + onConfirm={(config) => { + const code = config?.fundCode || dcaModal.fund?.code; + if (!code) { + setDcaModal({ open: false, fund: null }); + return; + } + setDcaPlans(prev => { + const next = { ...(prev || {}) }; + next[code] = { + amount: config.amount, + feeRate: config.feeRate, + cycle: config.cycle, + firstDate: config.firstDate, + weeklyDay: config.weeklyDay ?? null, + monthlyDay: config.monthlyDay ?? null, + enabled: config.enabled !== false + }; + storageHelper.setItem('dcaPlans', JSON.stringify(next)); + return next; + }); + setDcaModal({ open: false, fund: null }); + showToast('已保存定投计划', 'success'); + }} + /> + )} + + {addHistoryModal.open && (