feat: 定投

This commit is contained in:
hzm
2026-02-25 22:33:06 +08:00
parent 5f12e9d900
commit f5edd7bbf8
12 changed files with 869 additions and 143 deletions

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;
@@ -499,7 +506,7 @@ export const extractFundNamesWithLLM = async (ocrText) => {
const data = await resp.json(); const data = await resp.json();
let content = data?.choices?.[0]?.message?.content?.match(/\{[\s\S]*?\}/)?.[0]; let content = data?.choices?.[0]?.message?.content?.match(/\{[\s\S]*?\}/)?.[0];
if (!content || typeof content !== 'string') return []; if (!isString(content)) return [];
let parsed; let parsed;
try { try {
@@ -511,7 +518,7 @@ export const extractFundNamesWithLLM = async (ocrText) => {
const names = parsed?.fund_names; const names = parsed?.fund_names;
if (!Array.isArray(names)) return []; if (!Array.isArray(names)) return [];
return names return names
.map((n) => (typeof n === 'string' ? n.trim().replaceAll(' ','') : '')) .map((n) => (isString(n) ? n.trim().replaceAll(' ','') : ''))
.filter(Boolean); .filter(Boolean);
} catch (e) { } catch (e) {
return []; return [];
@@ -566,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="#ffffff" 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

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

@@ -11,6 +11,7 @@ import {
Filler Filler
} from 'chart.js'; } from 'chart.js';
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
import { isNumber } from 'lodash';
ChartJS.register( ChartJS.register(
CategoryScale, CategoryScale,
@@ -99,7 +100,7 @@ export default function FundIntradayChart({ series = [], referenceNav }) {
ticks: { ticks: {
color: '#9ca3af', color: '#9ca3af',
font: { size: 10 }, 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) { if (data && index in data) {
const val = data[index]; 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; const vw = ctx.measureText(valueStr).width + 8;
ctx.fillStyle = prim; ctx.fillStyle = prim;
ctx.fillRect(leftX, y - 8, vw, 16); ctx.fillRect(leftX, y - 8, vw, 16);

View File

@@ -311,7 +311,15 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
const textW = ctx.measureText(text).width; const textW = ctx.measureText(text).width;
const w = textW + paddingH * 2; const w = textW + paddingH * 2;
const h = 18; 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; const top = y - 24;
drawRoundRect(left, top, w, h, radius); drawRoundRect(left, top, w, h, radius);
@@ -323,7 +331,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
ctx.fillStyle = textColor; ctx.fillStyle = textColor;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.fillText(text, x, top + h / 2); ctx.fillText(text, centerX, top + h / 2);
ctx.restore(); ctx.restore();
}; };

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')}
@@ -49,7 +48,6 @@ export default function HoldingActionModal({ fund, onClose, onAction, hasHistory
<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

@@ -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(--primary)' : 'var(--danger)' }}> <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(--primary)' : 'var(--danger)' }}> <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

@@ -2,6 +2,7 @@
* 记录每次调用基金估值接口的结果,用于分时图。 * 记录每次调用基金估值接口的结果,用于分时图。
* 规则:获取到最新日期的数据时,清掉所有老日期的数据,只保留当日分时点。 * 规则:获取到最新日期的数据时,清掉所有老日期的数据,只保留当日分时点。
*/ */
import { isPlainObject, isString } from 'lodash';
const STORAGE_KEY = 'fundValuationTimeseries'; const STORAGE_KEY = 'fundValuationTimeseries';
@@ -10,7 +11,7 @@ function getStored() {
try { try {
const raw = window.localStorage.getItem(STORAGE_KEY); const raw = window.localStorage.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : {}; const parsed = raw ? JSON.parse(raw) : {};
return typeof parsed === 'object' && parsed !== null ? parsed : {}; return isPlainObject(parsed) ? parsed : {};
} catch { } catch {
return {}; return {};
} }
@@ -29,7 +30,7 @@ function setStored(data) {
* 从 gztime 或 Date 得到日期字符串 YYYY-MM-DD * 从 gztime 或 Date 得到日期字符串 YYYY-MM-DD
*/ */
function toDateStr(gztimeOrNow) { 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); return gztimeOrNow.slice(0, 10);
} }
try { try {
@@ -59,7 +60,7 @@ export function recordValuation(code, payload) {
const dateStr = toDateStr(gztime); const dateStr = toDateStr(gztime);
if (!dateStr) return getValuationSeries(code); if (!dateStr) return getValuationSeries(code);
const timeLabel = typeof gztime === 'string' && gztime.length > 10 const timeLabel = isString(gztime) && gztime.length > 10
? gztime.slice(11, 16) ? gztime.slice(11, 16)
: (() => { : (() => {
const d = new Date(); const d = new Date();

View File

@@ -9,6 +9,7 @@ import { glass } from '@dicebear/collection';
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, isString, isPlainObject } from 'lodash';
import Announcement from "./components/Announcement"; import Announcement from "./components/Announcement";
import { Stat } from "./components/Common"; import { Stat } from "./components/Common";
import FundTrendChart from "./components/FundTrendChart"; import FundTrendChart from "./components/FundTrendChart";
@@ -36,6 +37,7 @@ import TransactionHistoryModal from "./components/TransactionHistoryModal";
import AddHistoryModal from "./components/AddHistoryModal"; import AddHistoryModal from "./components/AddHistoryModal";
import UpdatePromptModal from "./components/UpdatePromptModal"; import UpdatePromptModal from "./components/UpdatePromptModal";
import WeChatModal from "./components/WeChatModal"; import WeChatModal from "./components/WeChatModal";
import DcaModal from "./components/DcaModal";
import githubImg from "./assets/github.svg"; import githubImg from "./assets/github.svg";
import { supabase, isSupabaseConfigured } from './lib/supabase'; import { supabase, isSupabaseConfigured } from './lib/supabase';
import { recordValuation, getAllValuationSeries, clearFund } from './lib/valuationTimeseries'; import { recordValuation, getAllValuationSeries, clearFund } from './lib/valuationTimeseries';
@@ -171,7 +173,7 @@ function GroupSummary({ funds, holdings, groupName, getProfit, stickyTop }) {
} }
if (profit.profitTotal !== null) { if (profit.profitTotal !== null) {
totalHoldingReturn += profit.profitTotal; 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; totalCost += holding.cost * holding.share;
} }
} }
@@ -419,11 +421,13 @@ export default function HomePage() {
const [holdingModal, setHoldingModal] = useState({ open: false, fund: null }); const [holdingModal, setHoldingModal] = useState({ open: false, fund: null });
const [actionModal, setActionModal] = 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 [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 [clearConfirm, setClearConfirm] = useState(null); // { fund }
const [donateOpen, setDonateOpen] = useState(false); const [donateOpen, setDonateOpen] = useState(false);
const [holdings, setHoldings] = useState({}); // { [code]: { share: number, cost: number } } const [holdings, setHoldings] = useState({}); // { [code]: { share: number, cost: number } }
const [pendingTrades, setPendingTrades] = useState([]); // [{ id, fundCode, share, date, ... }] const [pendingTrades, setPendingTrades] = useState([]); // [{ id, fundCode, share, date, ... }]
const [transactions, setTransactions] = useState({}); // { [code]: [{ id, type, amount, share, price, date, timestamp }] } 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 [historyModal, setHistoryModal] = useState({ open: false, fund: null });
const [addHistoryModal, setAddHistoryModal] = useState({ open: false, fund: null }); const [addHistoryModal, setAddHistoryModal] = useState({ open: false, fund: null });
const [percentModes, setPercentModes] = useState({}); // { [code]: boolean } const [percentModes, setPercentModes] = useState({}); // { [code]: boolean }
@@ -556,10 +560,10 @@ export default function HomePage() {
// 计算持仓收益 // 计算持仓收益
const getHoldingProfit = (fund, holding) => { 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 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; const canCalcTodayProfit = hasTodayData || hasTodayValuation;
// 如果是交易日且9点以后且今日净值未出则强制使用估值隐藏涨跌幅列模式 // 如果是交易日且9点以后且今日净值未出则强制使用估值隐藏涨跌幅列模式
@@ -577,8 +581,8 @@ export default function HomePage() {
const amount = holding.share * currentNav; const amount = holding.share * currentNav;
// 优先用 zzl (真实涨跌幅), 降级用 gszzl // 优先用 zzl (真实涨跌幅), 降级用 gszzl
// 若 gztime 日期 > jzrq说明估值更新晚于净值日期优先使用 gszzl 计算当日盈亏 // 若 gztime 日期 > jzrq说明估值更新晚于净值日期优先使用 gszzl 计算当日盈亏
const gz = typeof fund.gztime === 'string' ? toTz(fund.gztime) : null; const gz = isString(fund.gztime) ? toTz(fund.gztime) : null;
const jz = typeof fund.jzrq === 'string' ? toTz(fund.jzrq) : null; const jz = isString(fund.jzrq) ? toTz(fund.jzrq) : null;
const preferGszzl = const preferGszzl =
!!gz && !!gz &&
!!jz && !!jz &&
@@ -602,7 +606,7 @@ export default function HomePage() {
// 否则使用估值 // 否则使用估值
currentNav = fund.estPricedCoverage > 0.05 currentNav = fund.estPricedCoverage > 0.05
? fund.estGsz ? fund.estGsz
: (typeof fund.gsz === 'number' ? fund.gsz : Number(fund.dwjz)); : (isNumber(fund.gsz) ? fund.gsz : Number(fund.dwjz));
if (!currentNav) return null; if (!currentNav) return null;
@@ -620,7 +624,7 @@ export default function HomePage() {
const amount = holding.share * currentNav; const amount = holding.share * currentNav;
// 总收益 = (当前净值 - 成本价) * 份额 // 总收益 = (当前净值 - 成本价) * 份额
const profitTotal = typeof holding.cost === 'number' const profitTotal = isNumber(holding.cost)
? (currentNav - holding.cost) * holding.share ? (currentNav - holding.cost) * holding.share
: null; : null;
@@ -642,8 +646,8 @@ export default function HomePage() {
}) })
.sort((a, b) => { .sort((a, b) => {
if (sortBy === 'yield') { if (sortBy === 'yield') {
const valA = typeof a.estGszzl === 'number' ? a.estGszzl : (a.gszzl ?? a.zzl ?? 0); const valA = isNumber(a.estGszzl) ? a.estGszzl : (a.gszzl ?? a.zzl ?? 0);
const valB = typeof b.estGszzl === 'number' ? b.estGszzl : (b.gszzl ?? a.zzl ?? 0); const valB = isNumber(b.estGszzl) ? b.estGszzl : (b.gszzl ?? a.zzl ?? 0);
return sortOrder === 'asc' ? valA - valB : valB - valA; return sortOrder === 'asc' ? valA - valB : valB - valA;
} }
if (sortBy === 'holding') { if (sortBy === 'holding') {
@@ -704,6 +708,8 @@ export default function HomePage() {
setTradeModal({ open: true, fund, type }); setTradeModal({ open: true, fund, type });
} else if (type === 'history') { } else if (type === 'history') {
setHistoryModal({ open: true, fund }); setHistoryModal({ open: true, fund });
} else if (type === 'dca') {
setDcaModal({ open: true, fund });
} }
}; };
@@ -772,6 +778,7 @@ export default function HomePage() {
price: result.value, price: result.value,
date: result.date, // 使用获取到净值的日期 date: result.date, // 使用获取到净值的日期
isAfter3pm: trade.isAfter3pm, isAfter3pm: trade.isAfter3pm,
isDca: !!trade.isDca,
timestamp: Date.now() timestamp: Date.now()
}); });
} }
@@ -845,6 +852,7 @@ export default function HomePage() {
price: data.price, price: data.price,
date: data.date, date: data.date,
isAfter3pm: false, // 历史记录通常不需要此标记,或者默认为 false isAfter3pm: false, // 历史记录通常不需要此标记,或者默认为 false
isDca: false,
timestamp: data.timestamp || Date.now() timestamp: data.timestamp || Date.now()
}; };
// 按时间倒序排列 // 按时间倒序排列
@@ -872,6 +880,7 @@ export default function HomePage() {
feeValue: data.feeValue, feeValue: data.feeValue,
date: data.date, date: data.date,
isAfter3pm: data.isAfter3pm, isAfter3pm: data.isAfter3pm,
isDca: false,
timestamp: Date.now() timestamp: Date.now()
}; };
@@ -918,6 +927,7 @@ export default function HomePage() {
price: data.price, price: data.price,
date: data.date, date: data.date,
isAfter3pm: data.isAfter3pm, isAfter3pm: data.isAfter3pm,
isDca: false,
timestamp: Date.now() timestamp: Date.now()
}; };
const next = [record, ...current]; const next = [record, ...current];
@@ -978,6 +988,8 @@ export default function HomePage() {
}, 3000); }, 3000);
}; };
// 定投计划自动生成买入队列的逻辑会在 storageHelper 定义之后实现
const handleOpenLogin = () => { const handleOpenLogin = () => {
setUserMenuOpen(false); setUserMenuOpen(false);
if (!isSupabaseConfigured) { if (!isSupabaseConfigured) {
@@ -1125,7 +1137,7 @@ export default function HomePage() {
parsedNames = []; parsedNames = [];
} }
parsedNames.forEach((name) => { parsedNames.forEach((name) => {
if (name && typeof name === 'string') { if (isString(name)) {
allNames.add(name.trim()); allNames.add(name.trim());
} }
}); });
@@ -1352,7 +1364,7 @@ export default function HomePage() {
const storageHelper = useMemo(() => { const storageHelper = useMemo(() => {
// 仅以下 key 参与云端同步fundValuationTimeseries 不同步到云端(测试中功能,暂不同步) // 仅以下 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) => { const triggerSync = (key, prevValue, nextValue) => {
if (keys.has(key)) { if (keys.has(key)) {
// 标记为脏数据 // 标记为脏数据
@@ -1399,7 +1411,7 @@ export default function HomePage() {
useEffect(() => { useEffect(() => {
// 仅以下 key 的变更会触发云端同步fundValuationTimeseries 不在其中 // 仅以下 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) => { const onStorage = (e) => {
if (!e.key) return; if (!e.key) return;
if (e.key === 'localUpdatedAt') { 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 handleAddGroup = (name) => {
const newGroup = { const newGroup = {
id: `group_${Date.now()}`, id: `group_${Date.now()}`,
@@ -1597,13 +1707,17 @@ export default function HomePage() {
} }
// 加载持仓数据 // 加载持仓数据
const savedHoldings = JSON.parse(localStorage.getItem('holdings') || '{}'); const savedHoldings = JSON.parse(localStorage.getItem('holdings') || '{}');
if (savedHoldings && typeof savedHoldings === 'object') { if (isPlainObject(savedHoldings)) {
setHoldings(savedHoldings); setHoldings(savedHoldings);
} }
const savedTransactions = JSON.parse(localStorage.getItem('transactions') || '{}'); const savedTransactions = JSON.parse(localStorage.getItem('transactions') || '{}');
if (savedTransactions && typeof savedTransactions === 'object') { if (isPlainObject(savedTransactions)) {
setTransactions(savedTransactions); setTransactions(savedTransactions);
} }
const savedDcaPlans = JSON.parse(localStorage.getItem('dcaPlans') || '{}');
if (isPlainObject(savedDcaPlans)) {
setDcaPlans(savedDcaPlans);
}
const savedViewMode = localStorage.getItem('viewMode'); const savedViewMode = localStorage.getItem('viewMode');
if (savedViewMode === 'card' || savedViewMode === 'list') { if (savedViewMode === 'card' || savedViewMode === 'list') {
setViewMode(savedViewMode); setViewMode(savedViewMode);
@@ -1696,14 +1810,14 @@ export default function HomePage() {
.channel(`user-configs-${user.id}`) .channel(`user-configs-${user.id}`)
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'user_configs', filter: `user_id=eq.${user.id}` }, async (payload) => { .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'user_configs', filter: `user_id=eq.${user.id}` }, async (payload) => {
const incoming = payload?.new?.data; const incoming = payload?.new?.data;
if (!incoming || typeof incoming !== 'object') return; if (!isPlainObject(incoming)) return;
const incomingComparable = getComparablePayload(incoming); const incomingComparable = getComparablePayload(incoming);
if (!incomingComparable || incomingComparable === lastSyncedRef.current) return; if (!incomingComparable || incomingComparable === lastSyncedRef.current) return;
await applyCloudConfig(incoming, payload.new.updated_at); 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) => { .on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'user_configs', filter: `user_id=eq.${user.id}` }, async (payload) => {
const incoming = payload?.new?.data; const incoming = payload?.new?.data;
if (!incoming || typeof incoming !== 'object') return; if (!isPlainObject(incoming)) return;
const incomingComparable = getComparablePayload(incoming); const incomingComparable = getComparablePayload(incoming);
if (!incomingComparable || incomingComparable === lastSyncedRef.current) return; if (!incomingComparable || incomingComparable === lastSyncedRef.current) return;
await applyCloudConfig(incoming, payload.new.updated_at); await applyCloudConfig(incoming, payload.new.updated_at);
@@ -2068,7 +2182,7 @@ export default function HomePage() {
const requestRemoveFund = (fund) => { const requestRemoveFund = (fund) => {
const h = holdings[fund.code]; 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) { if (hasHolding) {
setFundDeleteConfirm({ code: fund.code, name: fund.name }); setFundDeleteConfirm({ code: fund.code, name: fund.name });
} else { } else {
@@ -2210,6 +2324,15 @@ export default function HomePage() {
delete next[removeCode]; delete next[removeCode];
return next; 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 () => { const manualRefresh = async () => {
@@ -2238,7 +2361,7 @@ export default function HomePage() {
}; };
function getComparablePayload(payload) { function getComparablePayload(payload) {
if (!payload || typeof payload !== 'object') return ''; if (!isPlainObject(payload)) return '';
const rawFunds = Array.isArray(payload.funds) ? payload.funds : []; const rawFunds = Array.isArray(payload.funds) ? payload.funds : [];
const fundCodes = rawFunds const fundCodes = rawFunds
.map((fund) => normalizeCode(fund?.code || fund?.CODE)) .map((fund) => normalizeCode(fund?.code || fund?.CODE))
@@ -2262,7 +2385,7 @@ export default function HomePage() {
.map((group) => { .map((group) => {
const id = normalizeCode(group?.id); const id = normalizeCode(group?.id);
if (!id) return null; 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) const codes = Array.isArray(group?.codes)
? Array.from(new Set(group.codes.map(normalizeCode).filter((code) => uniqueFundCodes.includes(code)))).sort() ? 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)) .sort((a, b) => a.id.localeCompare(b.id))
: []; : [];
const holdingsSource = payload.holdings && typeof payload.holdings === 'object' && !Array.isArray(payload.holdings) const holdingsSource = isPlainObject(payload.holdings) ? payload.holdings : {};
? payload.holdings
: {};
const holdings = {}; const holdings = {};
Object.keys(holdingsSource) Object.keys(holdingsSource)
.map(normalizeCode) .map(normalizeCode)
@@ -2303,20 +2424,19 @@ export default function HomePage() {
feeMode: trade?.feeMode || '', feeMode: trade?.feeMode || '',
feeValue: normalizeNumber(trade?.feeValue), feeValue: normalizeNumber(trade?.feeValue),
date: trade?.date || '', date: trade?.date || '',
isAfter3pm: !!trade?.isAfter3pm isAfter3pm: !!trade?.isAfter3pm,
isDca: !!trade?.isDca
}; };
}) })
.filter((trade) => trade && uniqueFundCodes.includes(trade.fundCode)) .filter((trade) => trade && uniqueFundCodes.includes(trade.fundCode))
.sort((a, b) => { .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 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}`; 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); return keyA.localeCompare(keyB);
}) })
: []; : [];
const transactionsSource = payload.transactions && typeof payload.transactions === 'object' && !Array.isArray(payload.transactions) const transactionsSource = isPlainObject(payload.transactions) ? payload.transactions : {};
? payload.transactions
: {};
const transactions = {}; const transactions = {};
Object.keys(transactionsSource) Object.keys(transactionsSource)
.map(normalizeCode) .map(normalizeCode)
@@ -2333,13 +2453,43 @@ export default function HomePage() {
const price = normalizeNumber(t?.price); const price = normalizeNumber(t?.price);
const date = t?.date || ''; const date = t?.date || '';
const timestamp = Number.isFinite(t?.timestamp) ? t.timestamp : 0; 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) .filter((t) => t.id || t.timestamp)
.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); .sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
if (normalized.length > 0) transactions[code] = normalized; 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'; const viewMode = payload.viewMode === 'list' ? 'list' : 'card';
return JSON.stringify({ return JSON.stringify({
@@ -2352,6 +2502,7 @@ export default function HomePage() {
holdings, holdings,
pendingTrades, pendingTrades,
transactions, transactions,
dcaPlans,
viewMode viewMode
}); });
} }
@@ -2390,6 +2541,9 @@ export default function HomePage() {
if (!keys || keys.has('transactions')) { if (!keys || keys.has('transactions')) {
all.transactions = JSON.parse(localStorage.getItem('transactions') || '{}'); all.transactions = JSON.parse(localStorage.getItem('transactions') || '{}');
} }
if (!keys || keys.has('dcaPlans')) {
all.dcaPlans = JSON.parse(localStorage.getItem('dcaPlans') || '{}');
}
// 如果是全量收集keys 为 null进行完整的数据清洗和验证逻辑 // 如果是全量收集keys 为 null进行完整的数据清洗和验证逻辑
if (!keys) { 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]) => { ? Object.entries(all.holdings).reduce((acc, [code, value]) => {
if (!fundCodes.has(code) || !value || typeof value !== 'object') return acc; if (!fundCodes.has(code) || !isPlainObject(value)) return acc;
const parsedShare = typeof value.share === 'number' const parsedShare = isNumber(value.share)
? value.share ? value.share
: typeof value.share === 'string' : isString(value.share)
? Number(value.share) ? Number(value.share)
: NaN; : NaN;
const parsedCost = typeof value.cost === 'number' const parsedCost = isNumber(value.cost)
? value.cost ? value.cost
: typeof value.cost === 'string' : isString(value.cost)
? Number(value.cost) ? Number(value.cost)
: NaN; : NaN;
const nextShare = Number.isFinite(parsedShare) ? parsedShare : null; 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 { return {
funds: all.funds, funds: all.funds,
favorites: cleanedFavorites, favorites: cleanedFavorites,
@@ -2449,6 +2611,8 @@ export default function HomePage() {
refreshMs: all.refreshMs, refreshMs: all.refreshMs,
holdings: cleanedHoldings, holdings: cleanedHoldings,
pendingTrades: all.pendingTrades, pendingTrades: all.pendingTrades,
transactions: all.transactions,
dcaPlans: cleanedDcaPlans,
viewMode: all.viewMode viewMode: all.viewMode
}; };
} }
@@ -2463,9 +2627,12 @@ export default function HomePage() {
favorites: [], favorites: [],
groups: [], groups: [],
collapsedCodes: [], collapsedCodes: [],
collapsedTrends: [],
refreshMs: 30000, refreshMs: 30000,
holdings: {}, holdings: {},
pendingTrades: [], pendingTrades: [],
transactions: {},
dcaPlans: {},
viewMode: 'card', viewMode: 'card',
exportedAt: nowInTz().toISOString() exportedAt: nowInTz().toISOString()
}; };
@@ -2473,7 +2640,7 @@ export default function HomePage() {
}; };
const applyCloudConfig = async (cloudData, cloudUpdatedAt) => { const applyCloudConfig = async (cloudData, cloudUpdatedAt) => {
if (!cloudData || typeof cloudData !== 'object') return; if (!isPlainObject(cloudData)) return;
skipSyncRef.current = true; skipSyncRef.current = true;
try { try {
if (cloudUpdatedAt) { if (cloudUpdatedAt) {
@@ -2505,7 +2672,7 @@ export default function HomePage() {
applyViewMode(cloudData.viewMode); applyViewMode(cloudData.viewMode);
} }
const nextHoldings = cloudData.holdings && typeof cloudData.holdings === 'object' ? cloudData.holdings : {}; const nextHoldings = isPlainObject(cloudData.holdings) ? cloudData.holdings : {};
setHoldings(nextHoldings); setHoldings(nextHoldings);
storageHelper.setItem('holdings', JSON.stringify(nextHoldings)); storageHelper.setItem('holdings', JSON.stringify(nextHoldings));
@@ -2515,10 +2682,19 @@ export default function HomePage() {
setPendingTrades(nextPendingTrades); setPendingTrades(nextPendingTrades);
storageHelper.setItem('pendingTrades', JSON.stringify(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); setTransactions(nextTransactions);
storageHelper.setItem('transactions', JSON.stringify(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) { if (nextFunds.length) {
const codes = Array.from(new Set(nextFunds.map((f) => f.code))); const codes = Array.from(new Set(nextFunds.map((f) => f.code)));
if (codes.length) await refreshAll(codes); if (codes.length) await refreshAll(codes);
@@ -2548,7 +2724,7 @@ export default function HomePage() {
setCloudConfigModal({ open: true, userId, type: 'empty' }); setCloudConfigModal({ open: true, userId, type: 'empty' });
return; 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 localPayload = collectLocalPayload();
const localComparable = getComparablePayload(localPayload); const localComparable = getComparablePayload(localPayload);
const cloudComparable = getComparablePayload(data.data); const cloudComparable = getComparablePayload(data.data);
@@ -2669,6 +2845,7 @@ export default function HomePage() {
holdings: JSON.parse(localStorage.getItem('holdings') || '{}'), holdings: JSON.parse(localStorage.getItem('holdings') || '{}'),
pendingTrades: JSON.parse(localStorage.getItem('pendingTrades') || '[]'), pendingTrades: JSON.parse(localStorage.getItem('pendingTrades') || '[]'),
transactions: JSON.parse(localStorage.getItem('transactions') || '{}'), transactions: JSON.parse(localStorage.getItem('transactions') || '{}'),
dcaPlans: JSON.parse(localStorage.getItem('dcaPlans') || '{}'),
exportedAt: nowInTz().toISOString() exportedAt: nowInTz().toISOString()
}; };
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
@@ -2715,7 +2892,7 @@ export default function HomePage() {
if (!file) return; if (!file) return;
const text = await file.text(); const text = await file.text();
const data = JSON.parse(text); const data = JSON.parse(text);
if (data && typeof data === 'object') { if (isPlainObject(data)) {
// 从 localStorage 读取最新数据进行合并,防止状态滞后导致的数据丢失 // 从 localStorage 读取最新数据进行合并,防止状态滞后导致的数据丢失
const currentFunds = JSON.parse(localStorage.getItem('funds') || '[]'); const currentFunds = JSON.parse(localStorage.getItem('funds') || '[]');
const currentFavorites = JSON.parse(localStorage.getItem('favorites') || '[]'); const currentFavorites = JSON.parse(localStorage.getItem('favorites') || '[]');
@@ -2723,6 +2900,7 @@ export default function HomePage() {
const currentCollapsed = JSON.parse(localStorage.getItem('collapsedCodes') || '[]'); const currentCollapsed = JSON.parse(localStorage.getItem('collapsedCodes') || '[]');
const currentTrends = JSON.parse(localStorage.getItem('collapsedTrends') || '[]'); const currentTrends = JSON.parse(localStorage.getItem('collapsedTrends') || '[]');
const currentPendingTrades = JSON.parse(localStorage.getItem('pendingTrades') || '[]'); const currentPendingTrades = JSON.parse(localStorage.getItem('pendingTrades') || '[]');
const currentDcaPlans = JSON.parse(localStorage.getItem('dcaPlans') || '{}');
let mergedFunds = currentFunds; let mergedFunds = currentFunds;
let appendedCodes = []; let appendedCodes = [];
@@ -2773,7 +2951,7 @@ export default function HomePage() {
storageHelper.setItem('collapsedTrends', JSON.stringify(mergedTrends)); storageHelper.setItem('collapsedTrends', JSON.stringify(mergedTrends));
} }
if (typeof data.refreshMs === 'number' && data.refreshMs >= 5000) { if (isNumber(data.refreshMs) && data.refreshMs >= 5000) {
setRefreshMs(data.refreshMs); setRefreshMs(data.refreshMs);
setTempSeconds(Math.round(data.refreshMs / 1000)); setTempSeconds(Math.round(data.refreshMs / 1000));
storageHelper.setItem('refreshMs', String(data.refreshMs)); storageHelper.setItem('refreshMs', String(data.refreshMs));
@@ -2782,13 +2960,13 @@ export default function HomePage() {
applyViewMode(data.viewMode); applyViewMode(data.viewMode);
} }
if (data.holdings && typeof data.holdings === 'object') { if (isPlainObject(data.holdings)) {
const mergedHoldings = { ...JSON.parse(localStorage.getItem('holdings') || '{}'), ...data.holdings }; const mergedHoldings = { ...JSON.parse(localStorage.getItem('holdings') || '{}'), ...data.holdings };
setHoldings(mergedHoldings); setHoldings(mergedHoldings);
storageHelper.setItem('holdings', JSON.stringify(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 currentTransactions = JSON.parse(localStorage.getItem('transactions') || '{}');
const mergedTransactions = { ...currentTransactions }; const mergedTransactions = { ...currentTransactions };
Object.entries(data.transactions).forEach(([code, txs]) => { Object.entries(data.transactions).forEach(([code, txs]) => {
@@ -2824,6 +3002,12 @@ export default function HomePage() {
storageHelper.setItem('pendingTrades', JSON.stringify(mergedPending)); 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) { if (appendedCodes.length) {
// 这里需要确保 refreshAll 不会因为闭包问题覆盖掉刚刚合并好的 mergedFunds // 这里需要确保 refreshAll 不会因为闭包问题覆盖掉刚刚合并好的 mergedFunds
@@ -2858,6 +3042,7 @@ export default function HomePage() {
holdingModal.open || holdingModal.open ||
actionModal.open || actionModal.open ||
tradeModal.open || tradeModal.open ||
dcaModal.open ||
!!clearConfirm || !!clearConfirm ||
donateOpen || donateOpen ||
!!fundDeleteConfirm || !!fundDeleteConfirm ||
@@ -3632,7 +3817,7 @@ export default function HomePage() {
</div> </div>
<div className="table-cell text-right change-cell"> <div className="table-cell text-right change-cell">
<span className={f.estPricedCoverage > 0.05 ? (f.estGszzl > 0 ? 'up' : f.estGszzl < 0 ? 'down' : '') : (Number(f.gszzl) > 0 ? 'up' : Number(f.gszzl) < 0 ? 'down' : '')} style={{ fontWeight: 700 }}> <span className={f.estPricedCoverage > 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 ?? '—')}
</span> </span>
</div> </div>
</> </>
@@ -3807,13 +3992,21 @@ export default function HomePage() {
<> <>
{(() => { {(() => {
const hasTodayData = f.jzrq === todayStr; 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; if (shouldHideChange) return null;
return ( return (
<Stat <Stat
label="涨跌幅" label={isYesterdayChange ? '昨日涨跌幅' : '涨跌幅'}
value={f.zzl !== undefined ? `${f.zzl > 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%` : ''} value={f.zzl !== undefined ? `${f.zzl > 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%` : ''}
delta={f.zzl} delta={f.zzl}
/> />
@@ -3822,7 +4015,7 @@ export default function HomePage() {
<Stat label="估值净值" value={f.estPricedCoverage > 0.05 ? f.estGsz.toFixed(4) : (f.gsz ?? '—')} /> <Stat label="估值净值" value={f.estPricedCoverage > 0.05 ? f.estGsz.toFixed(4) : (f.gsz ?? '—')} />
<Stat <Stat
label="估值涨跌幅" label="估值涨跌幅"
value={f.estPricedCoverage > 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)} delta={f.estPricedCoverage > 0.05 ? f.estGszzl : (Number(f.gszzl) || 0)}
/> />
</> </>
@@ -3941,7 +4134,7 @@ export default function HomePage() {
<div className="item" key={idx}> <div className="item" key={idx}>
<span className="name">{h.name}</span> <span className="name">{h.name}</span>
<div className="values"> <div className="values">
{typeof h.change === 'number' && ( {isNumber(h.change) && (
<span className={`badge ${h.change > 0 ? 'up' : h.change < 0 ? 'down' : ''}`} style={{ marginRight: 8 }}> <span className={`badge ${h.change > 0 ? 'up' : h.change < 0 ? 'down' : ''}`} style={{ marginRight: 8 }}>
{h.change > 0 ? '+' : ''}{h.change.toFixed(2)}% {h.change > 0 ? '+' : ''}{h.change.toFixed(2)}%
</span> </span>
@@ -4121,6 +4314,39 @@ export default function HomePage() {
)} )}
</AnimatePresence> </AnimatePresence>
<AnimatePresence>
{dcaModal.open && (
<DcaModal
fund={dcaModal.fund}
plan={dcaPlans[dcaModal.fund?.code]}
onClose={() => 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');
}}
/>
)}
</AnimatePresence>
<AnimatePresence> <AnimatePresence>
{addHistoryModal.open && ( {addHistoryModal.open && (
<AddHistoryModal <AddHistoryModal

7
package-lock.json generated
View File

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