feat: 定投
This commit is contained in:
159
app/api/fund.js
159
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('<tr>');
|
||||
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) => {
|
||||
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(/<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());
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?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">
|
||||
<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>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
431
app/components/DcaModal.jsx
Normal file
431
app/components/DcaModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ export default function HoldingActionModal({ fund, onClose, onAction, hasHistory
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<SettingsIcon width="20" height="20" />
|
||||
<span>持仓操作</span>
|
||||
{hasHistory && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAction('history')}
|
||||
@@ -49,7 +48,6 @@ export default function HoldingActionModal({ fund, onClose, onAction, hasHistory
|
||||
<span>📜</span>
|
||||
<span>交易记录</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||
<CloseIcon width="20" height="20" />
|
||||
@@ -62,12 +60,27 @@ export default function HoldingActionModal({ fund, onClose, onAction, hasHistory
|
||||
</div>
|
||||
|
||||
<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 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
|
||||
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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -90,9 +90,24 @@ export default function TransactionHistoryModal({
|
||||
{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 className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '14px', color: item.type === 'buy' ? 'var(--primary)' : 'var(--danger)' }}>
|
||||
{item.type === 'buy' ? '买入' : '卖出'}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '14px', color: item.type === 'buy' ? 'var(--primary)' : 'var(--danger)' }}>
|
||||
{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>
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px' }}>
|
||||
@@ -123,9 +138,24 @@ export default function TransactionHistoryModal({
|
||||
sortedTransactions.map((item) => (
|
||||
<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 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '14px', color: item.type === 'buy' ? 'var(--primary)' : 'var(--danger)' }}>
|
||||
{item.type === 'buy' ? '买入' : '卖出'}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '14px', color: item.type === 'buy' ? 'var(--primary)' : 'var(--danger)' }}>
|
||||
{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>
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginBottom: 2 }}>
|
||||
|
||||
@@ -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();
|
||||
|
||||
322
app/page.jsx
322
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() {
|
||||
</div>
|
||||
<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 }}>
|
||||
{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>
|
||||
</div>
|
||||
</>
|
||||
@@ -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 (
|
||||
<Stat
|
||||
label="涨跌幅"
|
||||
label={isYesterdayChange ? '昨日涨跌幅' : '涨跌幅'}
|
||||
value={f.zzl !== undefined ? `${f.zzl > 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%` : ''}
|
||||
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.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() {
|
||||
<div className="item" key={idx}>
|
||||
<span className="name">{h.name}</span>
|
||||
<div className="values">
|
||||
{typeof h.change === 'number' && (
|
||||
{isNumber(h.change) && (
|
||||
<span className={`badge ${h.change > 0 ? 'up' : h.change < 0 ? 'down' : ''}`} style={{ marginRight: 8 }}>
|
||||
{h.change > 0 ? '+' : ''}{h.change.toFixed(2)}%
|
||||
</span>
|
||||
@@ -4121,6 +4314,39 @@ export default function HomePage() {
|
||||
)}
|
||||
</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>
|
||||
{addHistoryModal.open && (
|
||||
<AddHistoryModal
|
||||
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"chart.js": "^4.5.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"framer-motion": "^12.29.2",
|
||||
"lodash": "^4.17.23",
|
||||
"next": "^16.1.5",
|
||||
"react": "18.3.1",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
@@ -5033,6 +5034,12 @@
|
||||
"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": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"chart.js": "^4.5.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"framer-motion": "^12.29.2",
|
||||
"lodash": "^4.17.23",
|
||||
"next": "^16.1.5",
|
||||
"react": "18.3.1",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
|
||||
Reference in New Issue
Block a user