Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9bc246088 | ||
|
|
fe3c2b64f6 | ||
|
|
c08c97d706 | ||
|
|
873728a6a2 | ||
|
|
9cfac48b59 | ||
|
|
0a828f33bf | ||
|
|
6d44803a27 | ||
|
|
b13ada16df | ||
|
|
a206076a56 | ||
|
|
df7abaecdc | ||
|
|
171ebac326 | ||
|
|
31553bb1a4 | ||
|
|
c65f2b8ab1 | ||
|
|
c9038757dd | ||
|
|
be4fc5eabe | ||
|
|
b8d239de40 | ||
|
|
e2d8858432 | ||
|
|
9f47ee3f08 | ||
|
|
eb7483a5dd | ||
|
|
0bdbb6d168 | ||
|
|
d6d64f1897 | ||
|
|
0504b9ae06 | ||
|
|
162f1c3b99 | ||
|
|
b0b4cfded1 | ||
|
|
39f8152e70 | ||
|
|
3958580571 | ||
|
|
6a53479bd7 | ||
|
|
20e101bb65 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -83,5 +83,5 @@ fabric.properties
|
||||
|
||||
.env.local
|
||||
.DS_Store
|
||||
.idea
|
||||
.idea/*
|
||||
.husky/_/
|
||||
|
||||
5
.idea/real-time-fund.iml
generated
5
.idea/real-time-fund.iml
generated
@@ -1,7 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.cursor" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.trae" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
|
||||
3. 修改接收到的邮件为验证码
|
||||
|
||||
在 Supabase控制台 → Authentication → Email → Confirm sign up,选择 `{{.token}}`。
|
||||
在 Supabase控制台 → Authentication → Email Templates 中,选择 **Magic Link** 模板进行编辑,在邮件正文中使用变量 `{{ .Token }}` 展示验证码。
|
||||
|
||||
4. 修改验证码位数
|
||||
|
||||
|
||||
388
app/api/fund.js
388
app/api/fund.js
@@ -126,6 +126,55 @@ const parseLatestNetValueFromLsjzContent = (content) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const extractHoldingsReportDate = (html) => {
|
||||
if (!html) return null;
|
||||
|
||||
// 优先匹配带有“报告期 / 截止日期”等关键字附近的日期
|
||||
const m1 = html.match(/(报告期|截止日期)[^0-9]{0,20}(\d{4}-\d{2}-\d{2})/);
|
||||
if (m1) return m1[2];
|
||||
|
||||
// 兜底:取文中出现的第一个 yyyy-MM-dd 格式日期
|
||||
const m2 = html.match(/(\d{4}-\d{2}-\d{2})/);
|
||||
return m2 ? m2[1] : null;
|
||||
};
|
||||
|
||||
const isLastQuarterReport = (reportDateStr) => {
|
||||
if (!reportDateStr) return false;
|
||||
|
||||
const report = dayjs(reportDateStr, 'YYYY-MM-DD');
|
||||
if (!report.isValid()) return false;
|
||||
|
||||
const now = nowInTz();
|
||||
const m = now.month(); // 0-11
|
||||
const q = Math.floor(m / 3); // 当前季度 0-3 => Q1-Q4
|
||||
|
||||
let lastQ;
|
||||
let year;
|
||||
if (q === 0) {
|
||||
// 当前为 Q1,则上一季度是上一年的 Q4
|
||||
lastQ = 3;
|
||||
year = now.year() - 1;
|
||||
} else {
|
||||
lastQ = q - 1;
|
||||
year = now.year();
|
||||
}
|
||||
|
||||
const quarterEnds = [
|
||||
{ month: 2, day: 31 }, // Q1 -> 03-31
|
||||
{ month: 5, day: 30 }, // Q2 -> 06-30
|
||||
{ month: 8, day: 30 }, // Q3 -> 09-30
|
||||
{ month: 11, day: 31 } // Q4 -> 12-31
|
||||
];
|
||||
|
||||
const { month: endMonth, day: endDay } = quarterEnds[lastQ];
|
||||
const lastQuarterEnd = dayjs(
|
||||
`${year}-${String(endMonth + 1).padStart(2, '0')}-${endDay}`,
|
||||
'YYYY-MM-DD'
|
||||
);
|
||||
|
||||
return report.isSame(lastQuarterEnd, 'day');
|
||||
};
|
||||
|
||||
export const fetchSmartFundNetValue = async (code, startDate) => {
|
||||
const today = nowInTz().startOf('day');
|
||||
let current = toTz(startDate).startOf('day');
|
||||
@@ -199,7 +248,9 @@ export const fetchFundDataFallback = async (c) => {
|
||||
gszzl: null,
|
||||
zzl: Number.isFinite(latest.growth) ? latest.growth : null,
|
||||
noValuation: true,
|
||||
holdings: []
|
||||
holdings: [],
|
||||
holdingsReportDate: null,
|
||||
holdingsIsLastQuarter: false
|
||||
});
|
||||
} else {
|
||||
reject(new Error('未能获取到基金数据'));
|
||||
@@ -255,9 +306,23 @@ export const fetchFundData = async (c) => {
|
||||
});
|
||||
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 (apidata) => {
|
||||
const holdingsCacheKey = `fund_holdings_archives_${c}`;
|
||||
cachedRequest(
|
||||
() => loadScript(holdingsUrl),
|
||||
holdingsCacheKey,
|
||||
{ cacheTime: 60 * 60 * 1000 }
|
||||
).then(async (apidata) => {
|
||||
let holdings = [];
|
||||
const html = apidata?.content || '';
|
||||
const holdingsReportDate = extractHoldingsReportDate(html);
|
||||
const holdingsIsLastQuarter = isLastQuarterReport(holdingsReportDate);
|
||||
|
||||
// 如果不是上一季度末的披露数据,则不展示重仓(并避免继续解析/请求行情)
|
||||
if (!holdingsIsLastQuarter) {
|
||||
resolveH({ holdings: [], holdingsReportDate, holdingsIsLastQuarter: false });
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -354,10 +419,15 @@ export const fetchFundData = async (c) => {
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
resolveH(holdings);
|
||||
}).catch(() => resolveH([]));
|
||||
resolveH({ holdings, holdingsReportDate, holdingsIsLastQuarter });
|
||||
}).catch(() => resolveH({ holdings: [], holdingsReportDate: null, holdingsIsLastQuarter: false }));
|
||||
});
|
||||
Promise.all([lsjzPromise, holdingsPromise]).then(([tData, holdings]) => {
|
||||
Promise.all([lsjzPromise, holdingsPromise]).then(([tData, holdingsResult]) => {
|
||||
const {
|
||||
holdings,
|
||||
holdingsReportDate,
|
||||
holdingsIsLastQuarter
|
||||
} = holdingsResult || {};
|
||||
if (tData) {
|
||||
if (tData.jzrq && (!gzData.jzrq || tData.jzrq >= gzData.jzrq)) {
|
||||
gzData.dwjz = tData.dwjz;
|
||||
@@ -365,7 +435,12 @@ export const fetchFundData = async (c) => {
|
||||
gzData.zzl = tData.zzl;
|
||||
}
|
||||
}
|
||||
resolve({ ...gzData, holdings });
|
||||
resolve({
|
||||
...gzData,
|
||||
holdings,
|
||||
holdingsReportDate,
|
||||
holdingsIsLastQuarter
|
||||
});
|
||||
});
|
||||
};
|
||||
scriptGz.onerror = () => {
|
||||
@@ -459,73 +534,140 @@ export const submitFeedback = async (formData) => {
|
||||
return response.json();
|
||||
};
|
||||
|
||||
// 使用智谱 GLM 从 OCR 文本中抽取基金名称
|
||||
export const extractFundNamesWithLLM = async (ocrText) => {
|
||||
const apiKey = '8df8ccf74a174722847c83b7e222f2af.4A39rJvUeBVDmef1';
|
||||
if (!apiKey || !ocrText) return [];
|
||||
const PINGZHONGDATA_GLOBAL_KEYS = [
|
||||
'ishb',
|
||||
'fS_name',
|
||||
'fS_code',
|
||||
'fund_sourceRate',
|
||||
'fund_Rate',
|
||||
'fund_minsg',
|
||||
'stockCodes',
|
||||
'zqCodes',
|
||||
'stockCodesNew',
|
||||
'zqCodesNew',
|
||||
'syl_1n',
|
||||
'syl_6y',
|
||||
'syl_3y',
|
||||
'syl_1y',
|
||||
'Data_fundSharesPositions',
|
||||
'Data_netWorthTrend',
|
||||
'Data_ACWorthTrend',
|
||||
'Data_grandTotal',
|
||||
'Data_rateInSimilarType',
|
||||
'Data_rateInSimilarPersent',
|
||||
'Data_fluctuationScale',
|
||||
'Data_holderStructure',
|
||||
'Data_assetAllocation',
|
||||
'Data_performanceEvaluation',
|
||||
'Data_currentFundManager',
|
||||
'Data_buySedemption',
|
||||
'swithSameType',
|
||||
];
|
||||
|
||||
try {
|
||||
const models = ['glm-4.5-flash', 'glm-4.7-flash'];
|
||||
const model = models[Math.floor(Math.random() * models.length)];
|
||||
let pingzhongdataQueue = Promise.resolve();
|
||||
|
||||
const resp = await fetch('https://open.bigmodel.cn/api/paas/v4/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
'你是一个基金 OCR 文本解析助手。' +
|
||||
'从下面的 OCR 文本中抽取其中出现的「基金名称列表」。' +
|
||||
'要求:1)基金名称一般为中文,中间不能有空字符串,可包含部分英文或括号' +
|
||||
'2)名称后面通常会跟着金额或持有金额(数字,可能带千分位逗号和小数);' +
|
||||
'3)忽略无关信息,只返回你判断为基金名称的字符串;' +
|
||||
'4)去重后输出。输出格式:严格返回 JSON,如 {"fund_names": ["基金名称1","基金名称2"]},不要输出任何多余说明',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: String(ocrText),
|
||||
},
|
||||
],
|
||||
temperature: 0.2,
|
||||
max_tokens: 1024,
|
||||
thinking: {
|
||||
type: 'disabled',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
let content = data?.choices?.[0]?.message?.content?.match(/\{[\s\S]*?\}/)?.[0];
|
||||
if (!isString(content)) return [];
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const names = parsed?.fund_names;
|
||||
if (!Array.isArray(names)) return [];
|
||||
return names
|
||||
.map((n) => (isString(n) ? n.trim().replaceAll(' ','') : ''))
|
||||
.filter(Boolean);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
const enqueuePingzhongdataLoad = (fn) => {
|
||||
const p = pingzhongdataQueue.then(fn, fn);
|
||||
// 避免队列被 reject 永久阻塞
|
||||
pingzhongdataQueue = p.catch(() => undefined);
|
||||
return p;
|
||||
};
|
||||
|
||||
let historyQueue = Promise.resolve();
|
||||
const snapshotPingzhongdataGlobals = (fundCode) => {
|
||||
const out = {};
|
||||
for (const k of PINGZHONGDATA_GLOBAL_KEYS) {
|
||||
if (typeof window?.[k] === 'undefined') continue;
|
||||
try {
|
||||
out[k] = JSON.parse(JSON.stringify(window[k]));
|
||||
} catch (e) {
|
||||
out[k] = window[k];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fundCode: out.fS_code || fundCode,
|
||||
fundName: out.fS_name || '',
|
||||
...out,
|
||||
};
|
||||
};
|
||||
|
||||
const jsonpLoadPingzhongdata = (fundCode, timeoutMs = 10000) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof document === 'undefined' || !document.body) {
|
||||
reject(new Error('无浏览器环境'));
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `https://fund.eastmoney.com/pingzhongdata/${fundCode}.js?v=${Date.now()}`;
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.async = true;
|
||||
|
||||
let done = false;
|
||||
let timer = null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
script.onload = null;
|
||||
script.onerror = null;
|
||||
if (document.body.contains(script)) document.body.removeChild(script);
|
||||
};
|
||||
|
||||
timer = setTimeout(() => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
cleanup();
|
||||
reject(new Error('pingzhongdata 请求超时'));
|
||||
}, timeoutMs);
|
||||
|
||||
script.onload = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
const data = snapshotPingzhongdataGlobals(fundCode);
|
||||
cleanup();
|
||||
resolve(data);
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
cleanup();
|
||||
reject(new Error('pingzhongdata 加载失败'));
|
||||
};
|
||||
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchAndParsePingzhongdata = async (fundCode) => {
|
||||
// 使用 JSONP(script 注入) 方式获取并解析 pingzhongdata
|
||||
return enqueuePingzhongdataLoad(() => jsonpLoadPingzhongdata(fundCode));
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取并解析「基金走势图/资产等」数据(pingzhongdata)
|
||||
* 来源:https://fund.eastmoney.com/pingzhongdata/${fundCode}.js
|
||||
*/
|
||||
export const fetchFundPingzhongdata = async (fundCode, { cacheTime = 60 * 60 * 1000 } = {}) => {
|
||||
if (!fundCode) throw new Error('fundCode 不能为空');
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
throw new Error('无浏览器环境');
|
||||
}
|
||||
|
||||
const cacheKey = `pingzhongdata_${fundCode}`;
|
||||
|
||||
try {
|
||||
return await cachedRequest(
|
||||
() => fetchAndParsePingzhongdata(fundCode),
|
||||
cacheKey,
|
||||
{ cacheTime }
|
||||
);
|
||||
} catch (e) {
|
||||
clearCachedRequest(cacheKey);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchFundHistory = async (code, range = '1m') => {
|
||||
if (typeof window === 'undefined') return [];
|
||||
@@ -539,73 +681,65 @@ export const fetchFundHistory = async (code, range = '1m') => {
|
||||
case '6m': start = start.subtract(6, 'month'); break;
|
||||
case '1y': start = start.subtract(1, 'year'); break;
|
||||
case '3y': start = start.subtract(3, 'year'); break;
|
||||
case 'all': start = dayjs(0).tz(TZ); break;
|
||||
default: start = start.subtract(1, 'month');
|
||||
}
|
||||
|
||||
const sdate = start.format('YYYY-MM-DD');
|
||||
const edate = end.format('YYYY-MM-DD');
|
||||
const per = 49;
|
||||
// 业绩走势统一走 pingzhongdata.Data_netWorthTrend
|
||||
try {
|
||||
const pz = await fetchFundPingzhongdata(code);
|
||||
const trend = pz?.Data_netWorthTrend;
|
||||
if (Array.isArray(trend) && trend.length) {
|
||||
const startMs = start.startOf('day').valueOf();
|
||||
// end 可能是当日任意时刻,这里用 end-of-day 包含最后一天
|
||||
const endMs = end.endOf('day').valueOf();
|
||||
const out = trend
|
||||
.filter((d) => d && typeof d.x === 'number' && d.x >= startMs && d.x <= endMs)
|
||||
.map((d) => {
|
||||
const value = Number(d.y);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const date = dayjs(d.x).tz(TZ).format('YYYY-MM-DD');
|
||||
return { date, value };
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
historyQueue = historyQueue.then(async () => {
|
||||
let allData = [];
|
||||
let page = 1;
|
||||
let totalPages = 1;
|
||||
if (out.length) return out;
|
||||
}
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const parseFundTextWithLLM = async (text) => {
|
||||
const apiKey = 'sk-a72c4e279bc62a03cc105be6263d464c';
|
||||
if (!apiKey || !text) return null;
|
||||
|
||||
try {
|
||||
const parseContent = (content) => {
|
||||
if (!content) return [];
|
||||
const rows = content.split('<tr>');
|
||||
const data = [];
|
||||
for (const row of rows) {
|
||||
const cells = row.match(/<td[^>]*>(.*?)<\/td>/g);
|
||||
if (cells && cells.length >= 2) {
|
||||
const dateStr = cells[0].replace(/<[^>]+>/g, '').trim();
|
||||
const valStr = cells[1].replace(/<[^>]+>/g, '').trim();
|
||||
const val = parseFloat(valStr);
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr) && !isNaN(val)) {
|
||||
data.push({ date: dateStr, value: val });
|
||||
}
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
const response = await fetch('https://apis.iflow.cn/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'qwen3-max',
|
||||
messages: [
|
||||
{ role: 'system', content: "你是一个基金文本解析助手。请从提供的OCR文本中执行以下任务:\n抽取所有基金信息,包括:基金名称:中文字符串(可含英文或括号),名称后常跟随金额数字。基金代码:6位数字(如果存在)。持有金额:数字格式(可能含千分位逗号或小数,如果存在)。持有收益:数字格式(可能含千分位逗号或小数,如果存在)。忽略无关文本。输出格式:以JSON数组形式返回结果,每个基金信息为一个对象,包含以下字段:基金名称(必填,字符串)基金代码(可选,字符串,不存在时为空字符串)持有金额(可选,字符串,不存在时为空字符串)持有收益(可选,字符串,不存在时为空字符串)示例输出:[{'fundName':'华夏成长混合','fundCode':'000001','holdAmounts':'50,000.00','holdGains':'2,500.00'},{'fundName':'易方达消费行业','fundCode':'','holdAmounts':'10,000.00','holdGains':'}]。除了示例输出的内容外,不要输出任何多余内容"},
|
||||
{ role: 'user', content: text }
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 2000
|
||||
})
|
||||
});
|
||||
|
||||
// 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 firstApidata = await loadScript(firstUrl);
|
||||
|
||||
if (!firstApidata || !firstApidata.content || firstApidata.content.includes('暂无数据')) {
|
||||
resolve([]);
|
||||
return;
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse total pages
|
||||
if (firstApidata.pages) {
|
||||
totalPages = parseInt(firstApidata.pages, 10) || 1;
|
||||
}
|
||||
|
||||
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}`;
|
||||
const nextApidata = await loadScript(nextUrl);
|
||||
if (nextApidata && nextApidata.content) {
|
||||
allData = allData.concat(parseContent(nextApidata.content));
|
||||
}
|
||||
}
|
||||
|
||||
// The data comes in reverse chronological order (newest first), so we need to reverse it for the chart (oldest first)
|
||||
resolve(allData.reverse());
|
||||
|
||||
const data = await response.json();
|
||||
return data?.choices?.[0]?.message?.content || null;
|
||||
} catch (e) {
|
||||
console.error('Fetch history error:', e);
|
||||
resolve([]);
|
||||
return null;
|
||||
}
|
||||
}).catch((e) => {
|
||||
console.error('Queue error:', e);
|
||||
resolve([]);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 61 KiB |
@@ -52,6 +52,8 @@ export default function Announcement() {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
maxHeight: 'calc(100dvh - 40px)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div className="title" style={{ display: 'flex', alignItems: 'center', gap: '12px', fontWeight: 700, fontSize: '18px', color: 'var(--accent)' }}>
|
||||
@@ -62,19 +64,14 @@ export default function Announcement() {
|
||||
</svg>
|
||||
<span>公告</span>
|
||||
</div>
|
||||
<div style={{ color: 'var(--text)', lineHeight: '1.6', fontSize: '15px' }}>
|
||||
为了增加更多用户方便访问, 新增国内加速地址:<a className="link-button"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--primary)', textDecoration: 'underline', padding: '0 4px', fontWeight: 600 }} href="https://fund.cc.cd/">https://fund.cc.cd/</a>
|
||||
<p>v0.1.9 版本更新内容如下:</p>
|
||||
<p>1. 新增亮色主题。</p>
|
||||
<p>2. PC、移动表格模式重构,支持自定义布局。</p>
|
||||
<p>3. PC端设置弹框支持修改页面容器宽度。</p>
|
||||
<p>4. 分组下自定义布局数据相互独立(旧数据需重新配置)。</p>
|
||||
<p>5. 更换随机头像风格。</p>
|
||||
感谢以下用户上月对项目赞助支持(排名不分顺序):
|
||||
<p>*业、M*.、S*o、b*g、*落、D*A、*山、匿名、*🍍、*啦、L*.、*洛、大大方块先生、带火星的小木条、F、無芯、广告制作装饰、**中、**礼</p>
|
||||
<div style={{ color: 'var(--text)', lineHeight: '1.6', fontSize: '15px', overflowY: 'auto', minHeight: 0, flex: 1, paddingRight: '4px' }}>
|
||||
<p>v0.2.1 版本更新内容如下:</p>
|
||||
<p>1. 改进拍照识别基金准确度。</p>
|
||||
<p>2. 拍照导入支持识别持仓金额、持仓收益。</p>
|
||||
以下功能将会在下一个版本上线:
|
||||
<p>1. 列表页查看基金详情。</p>
|
||||
<p>2. 大盘走势数据。</p>
|
||||
<p>3. 关联板块。</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}>
|
||||
|
||||
@@ -35,8 +35,21 @@ export default function ConfirmModal({ title, message, onConfirm, onCancel, conf
|
||||
{message}
|
||||
</p>
|
||||
<div className="row" style={{ gap: 12 }}>
|
||||
<button className="button secondary" onClick={onCancel} style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>取消</button>
|
||||
<button className="button danger" onClick={onConfirm} style={{ flex: 1 }}>{confirmText}</button>
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={onCancel}
|
||||
style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="button danger"
|
||||
onClick={onConfirm}
|
||||
style={{ flex: 1 }}
|
||||
autoFocus
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
@@ -97,7 +97,7 @@ export default function FundIntradayChart({ series = [], referenceNav, theme = '
|
||||
}, [series, referenceNav, chartColors.danger, chartColors.success]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const colors = getChartThemeColors();
|
||||
const colors = getChartThemeColors(theme);
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -260,11 +260,20 @@ export default function FundIntradayChart({ series = [], referenceNav, theme = '
|
||||
<span
|
||||
style={{
|
||||
fontSize: 9,
|
||||
padding: '1px 5px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 4,
|
||||
...(theme === 'light'
|
||||
? {
|
||||
border: '1px solid',
|
||||
borderColor: chartColors.primary,
|
||||
color: chartColors.primary,
|
||||
background: 'transparent',
|
||||
}
|
||||
: {
|
||||
background: 'var(--primary)',
|
||||
color: '#0f172a',
|
||||
fontWeight: 600
|
||||
}),
|
||||
fontWeight: 600,
|
||||
}}
|
||||
title="正在测试中的功能"
|
||||
>
|
||||
@@ -273,7 +282,7 @@ export default function FundIntradayChart({ series = [], referenceNav, theme = '
|
||||
</span>
|
||||
{displayDate && <span style={{ fontSize: 11 }}>估值日期 {displayDate}</span>}
|
||||
</div>
|
||||
<div style={{ position: 'relative', height: 100, width: '100%' }}>
|
||||
<div style={{ position: 'relative', height: 100, width: '100%', touchAction: 'pan-y' }}>
|
||||
<Line ref={chartRef} data={chartData} options={options} plugins={plugins} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -101,7 +101,8 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
{ label: '近3月', value: '3m' },
|
||||
{ label: '近6月', value: '6m' },
|
||||
{ label: '近1年', value: '1y' },
|
||||
{ label: '近3年', value: '3y'}
|
||||
{ label: '近3年', value: '3y' },
|
||||
{ label: '成立来', value: 'all' }
|
||||
];
|
||||
|
||||
const change = useMemo(() => {
|
||||
@@ -529,7 +530,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<div style={{ position: 'relative', height: 180, width: '100%' }}>
|
||||
<div style={{ position: 'relative', height: 180, width: '100%', touchAction: 'pan-y' }}>
|
||||
{loading && (
|
||||
<div className="chart-overlay" style={{ backdropFilter: 'blur(2px)' }}>
|
||||
<span className="muted" style={{ fontSize: '12px' }}>加载中...</span>
|
||||
|
||||
@@ -310,6 +310,7 @@ export default function MobileFundTable({
|
||||
const original = info.row.original || {};
|
||||
const code = original.code;
|
||||
const isUpdated = original.isUpdated;
|
||||
const hasDca = original.hasDca;
|
||||
const hasHoldingAmount = original.holdingAmountValue != null;
|
||||
const holdingAmountDisplay = hasHoldingAmount ? (original.holdingAmount ?? '—') : null;
|
||||
const isFavorites = favorites?.has?.(code);
|
||||
@@ -365,6 +366,7 @@ export default function MobileFundTable({
|
||||
}}
|
||||
>
|
||||
{holdingAmountDisplay}
|
||||
{hasDca && <span className="dca-indicator">定</span>}
|
||||
{isUpdated && <span className="updated-indicator">✓</span>}
|
||||
</span>
|
||||
) : code ? (
|
||||
@@ -386,6 +388,7 @@ export default function MobileFundTable({
|
||||
}}
|
||||
>
|
||||
#{code}
|
||||
{hasDca && <span className="dca-indicator">定</span>}
|
||||
{isUpdated && <span className="updated-indicator">✓</span>}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -480,13 +483,14 @@ export default function MobileFundTable({
|
||||
const value = original.estimateChangeValue;
|
||||
const isMuted = original.estimateChangeMuted;
|
||||
const time = original.estimateTime ?? '-';
|
||||
const displayTime = typeof time === 'string' && time.length > 5 ? time.slice(5) : time;
|
||||
const cls = isMuted ? 'muted' : value > 0 ? 'up' : value < 0 ? 'down' : '';
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||
<span className={cls} style={{ fontWeight: 700 }}>
|
||||
{info.getValue() ?? '—'}
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: '10px' }}>{time}</span>
|
||||
<span className="muted" style={{ fontSize: '10px' }}>{displayTime}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -348,6 +348,7 @@ export default function PcFundTable({
|
||||
const original = info.row.original || {};
|
||||
const code = original.code;
|
||||
const isUpdated = original.isUpdated;
|
||||
const hasDca = original.hasDca;
|
||||
const isFavorites = favorites?.has?.(code);
|
||||
const isGroupTab = currentTab && currentTab !== 'all' && currentTab !== 'fav';
|
||||
const rowContext = useContext(SortableRowContext);
|
||||
@@ -399,6 +400,7 @@ export default function PcFundTable({
|
||||
</span>
|
||||
{code ? <span className="muted code-text">
|
||||
#{code}
|
||||
{hasDca && <span className="dca-indicator">定</span>}
|
||||
{isUpdated && <span className="updated-indicator">✓</span>}
|
||||
</span> : null}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { CloseIcon } from './Icons';
|
||||
|
||||
@@ -9,8 +10,23 @@ export default function ScanImportConfirmModal({
|
||||
onClose,
|
||||
onToggle,
|
||||
onConfirm,
|
||||
refreshing
|
||||
refreshing,
|
||||
groups = [],
|
||||
isOcrScan = false
|
||||
}) {
|
||||
const [selectedGroupId, setSelectedGroupId] = useState('all');
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(selectedGroupId);
|
||||
};
|
||||
|
||||
const formatAmount = (val) => {
|
||||
if (!val) return null;
|
||||
const num = parseFloat(String(val).replace(/,/g, ''));
|
||||
if (isNaN(num)) return null;
|
||||
return num;
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
@@ -28,7 +44,7 @@ export default function ScanImportConfirmModal({
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="glass card modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ width: 460, maxWidth: '90vw' }}
|
||||
style={{ width: 480, maxWidth: '90vw' }}
|
||||
>
|
||||
<div className="title" style={{ marginBottom: 12, justifyContent: 'space-between' }}>
|
||||
<span>确认导入基金</span>
|
||||
@@ -36,18 +52,27 @@ export default function ScanImportConfirmModal({
|
||||
<CloseIcon width="20" height="20" />
|
||||
</button>
|
||||
</div>
|
||||
{isOcrScan && (
|
||||
<div className="ocr-warning" style={{ marginBottom: 12 }}>
|
||||
<span>拍照识别方案目前还在优化,请确认识别结果是否正确。</span>
|
||||
</div>
|
||||
)}
|
||||
{scannedFunds.length === 0 ? (
|
||||
<div className="muted" style={{ fontSize: 13, lineHeight: 1.6 }}>
|
||||
未识别到有效的基金代码,请尝试更清晰的截图或手动搜索。
|
||||
</div>
|
||||
) : (
|
||||
<div className="search-results pending-list" style={{ maxHeight: 320, overflowY: 'auto' }}>
|
||||
<>
|
||||
<div className="search-results pending-list" style={{ maxHeight: 360, overflowY: 'auto' }}>
|
||||
{scannedFunds.map((item) => {
|
||||
const isSelected = selectedScannedCodes.has(item.code);
|
||||
const isAlreadyAdded = item.status === 'added';
|
||||
const isInvalid = item.status === 'invalid';
|
||||
const isDisabled = isAlreadyAdded || isInvalid;
|
||||
const displayName = item.name || (isInvalid ? '未找到基金' : '未知基金');
|
||||
const holdAmounts = formatAmount(item.holdAmounts);
|
||||
const holdGains = formatAmount(item.holdGains);
|
||||
const hasHoldingData = holdAmounts !== null && holdGains !== null;
|
||||
return (
|
||||
<div
|
||||
key={item.code}
|
||||
@@ -57,8 +82,9 @@ export default function ScanImportConfirmModal({
|
||||
if (isDisabled) return;
|
||||
onToggle(item.code);
|
||||
}}
|
||||
style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
|
||||
style={{ cursor: isDisabled ? 'not-allowed' : 'pointer', flexDirection: 'column', alignItems: 'stretch' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div className="fund-info">
|
||||
<span className="fund-name">{displayName}</span>
|
||||
<span className="fund-code muted">#{item.code}</span>
|
||||
@@ -73,13 +99,46 @@ export default function ScanImportConfirmModal({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasHoldingData && !isDisabled && (
|
||||
<div style={{ display: 'flex', gap: 16, marginTop: 6, paddingLeft: 0 }}>
|
||||
{holdAmounts !== null && (
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
持有金额:<span style={{ color: 'var(--text)', fontWeight: 500 }}>¥{holdAmounts.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
|
||||
</span>
|
||||
)}
|
||||
{holdGains !== null && (
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
持有收益:<span style={{ color: holdGains >= 0 ? 'var(--danger)' : 'var(--success)', fontWeight: 500 }}>
|
||||
{holdGains >= 0 ? '+' : '-'}¥{Math.abs(holdGains).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ marginTop: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="muted" style={{ fontSize: 13, whiteSpace: 'nowrap' }}>添加到分组:</span>
|
||||
<select
|
||||
className="select"
|
||||
value={selectedGroupId}
|
||||
onChange={(e) => setSelectedGroupId(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
<option value="fav">自选</option>
|
||||
{groups.filter(g => g.id !== 'all' && g.id !== 'fav').map(g => (
|
||||
<option key={g.id} value={g.id}>{g.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 12 }}>
|
||||
<button className="button secondary" onClick={onClose}>取消</button>
|
||||
<button className="button" onClick={onConfirm} disabled={selectedScannedCodes.size === 0 || refreshing}>确认导入</button>
|
||||
<button className="button" onClick={handleConfirm} disabled={selectedScannedCodes.size === 0 || refreshing}>确认导入</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function TransactionHistoryModal({
|
||||
onClick={onAddHistory}
|
||||
style={{ fontSize: '12px', padding: '4px 12px', height: 'auto' }}
|
||||
>
|
||||
➕ 添加记录
|
||||
添加记录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
120
app/globals.css
120
app/globals.css
@@ -86,7 +86,7 @@ body::before {
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 90%;
|
||||
max-width: 100%;
|
||||
width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
@@ -1412,6 +1412,28 @@ input[type="number"] {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.dca-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(34, 211, 238, 0.15);
|
||||
color: #22d3ee;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
[data-theme="light"] .dca-indicator {
|
||||
background: rgba(6, 182, 212, 0.15);
|
||||
color: #0891b2;
|
||||
}
|
||||
|
||||
.code-text {
|
||||
font-size: 11px;
|
||||
}
|
||||
@@ -2628,6 +2650,28 @@ input[type="number"] {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ocr-warning {
|
||||
padding: 8px 12px;
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
|
||||
.ocr-warning span {
|
||||
font-size: 13px;
|
||||
color: #fbbf24;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
[data-theme="light"] .ocr-warning {
|
||||
background: rgba(180, 130, 30, 0.1);
|
||||
border-color: rgba(180, 130, 30, 0.25);
|
||||
}
|
||||
|
||||
[data-theme="light"] .ocr-warning span {
|
||||
color: #b4821e;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
@@ -3052,3 +3096,77 @@ input[type="number"] {
|
||||
margin: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 下拉选择框样式 ========== */
|
||||
.select {
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(11, 18, 32, 0.9);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color 200ms ease, box-shadow 200ms ease;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
.select:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
|
||||
.select option {
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.select option:hover,
|
||||
.select option:checked {
|
||||
background: rgba(34, 211, 238, 0.15);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* 亮色主题:下拉选择框 */
|
||||
[data-theme="light"] .select {
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23475569' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
[data-theme="light"] .select:hover {
|
||||
border-color: var(--accent);
|
||||
background: linear-gradient(180deg, #fff, #f8fafc);
|
||||
}
|
||||
|
||||
[data-theme="light"] .select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
|
||||
[data-theme="light"] .select option {
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
[data-theme="light"] .select option {
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
[data-theme="light"] .select option:hover,
|
||||
[data-theme="light"] .select option:checked {
|
||||
background: rgba(8, 145, 178, 0.12);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
204
app/hooks/useFundFuzzyMatcher.js
Normal file
204
app/hooks/useFundFuzzyMatcher.js
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { cachedRequest, clearCachedRequest } from '../lib/cacheRequest';
|
||||
|
||||
const FUND_CODE_SEARCH_URL = 'https://fund.eastmoney.com/js/fundcode_search.js';
|
||||
const FUND_LIST_CACHE_KEY = 'eastmoney_fundcode_search_list';
|
||||
const FUND_LIST_CACHE_TIME = 24 * 60 * 60 * 1000;
|
||||
|
||||
const formatEastMoneyFundList = (rawList) => {
|
||||
if (!Array.isArray(rawList)) return [];
|
||||
|
||||
return rawList
|
||||
.map((item) => {
|
||||
if (!Array.isArray(item)) return null;
|
||||
const code = String(item[0] ?? '').trim();
|
||||
const name = String(item[2] ?? '').trim();
|
||||
if (!code || !name) return null;
|
||||
return { code, name };
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
export const useFundFuzzyMatcher = () => {
|
||||
const allFundFuseRef = useRef(null);
|
||||
const allFundLoadPromiseRef = useRef(null);
|
||||
|
||||
const getAllFundFuse = useCallback(async () => {
|
||||
if (allFundFuseRef.current) return allFundFuseRef.current;
|
||||
if (allFundLoadPromiseRef.current) return allFundLoadPromiseRef.current;
|
||||
|
||||
allFundLoadPromiseRef.current = (async () => {
|
||||
const [fuseModule, allFundList] = await Promise.all([
|
||||
import('fuse.js'),
|
||||
cachedRequest(
|
||||
() =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined' || !document.body) {
|
||||
reject(new Error('NO_BROWSER_ENV'));
|
||||
return;
|
||||
}
|
||||
|
||||
const prevR = window.r;
|
||||
const script = document.createElement('script');
|
||||
script.src = `${FUND_CODE_SEARCH_URL}?_=${Date.now()}`;
|
||||
script.async = true;
|
||||
|
||||
const cleanup = () => {
|
||||
if (document.body.contains(script)) {
|
||||
document.body.removeChild(script);
|
||||
}
|
||||
if (prevR === undefined) {
|
||||
try {
|
||||
delete window.r;
|
||||
} catch (e) {
|
||||
window.r = undefined;
|
||||
}
|
||||
} else {
|
||||
window.r = prevR;
|
||||
}
|
||||
};
|
||||
|
||||
script.onload = () => {
|
||||
const snapshot = Array.isArray(window.r) ? JSON.parse(JSON.stringify(window.r)) : [];
|
||||
cleanup();
|
||||
const parsed = formatEastMoneyFundList(snapshot);
|
||||
if (!parsed.length) {
|
||||
reject(new Error('PARSE_ALL_FUND_FAILED'));
|
||||
return;
|
||||
}
|
||||
resolve(parsed);
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
cleanup();
|
||||
reject(new Error('LOAD_ALL_FUND_FAILED'));
|
||||
};
|
||||
|
||||
document.body.appendChild(script);
|
||||
}),
|
||||
FUND_LIST_CACHE_KEY,
|
||||
{ cacheTime: FUND_LIST_CACHE_TIME }
|
||||
),
|
||||
]);
|
||||
const Fuse = fuseModule.default;
|
||||
const fuse = new Fuse(Array.isArray(allFundList) ? allFundList : [], {
|
||||
keys: ['name', 'code'],
|
||||
includeScore: true,
|
||||
threshold: 0.5,
|
||||
ignoreLocation: true,
|
||||
minMatchCharLength: 2,
|
||||
});
|
||||
|
||||
allFundFuseRef.current = fuse;
|
||||
return fuse;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await allFundLoadPromiseRef.current;
|
||||
} catch (e) {
|
||||
allFundLoadPromiseRef.current = null;
|
||||
clearCachedRequest(FUND_LIST_CACHE_KEY);
|
||||
throw e;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const normalizeFundText = useCallback((value) => {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value
|
||||
.toUpperCase()
|
||||
.replace(/[((]/g, '(')
|
||||
.replace(/[))]/g, ')')
|
||||
.replace(/[·•]/g, '')
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[^\u4e00-\u9fa5A-Z0-9()]/g, '');
|
||||
}, []);
|
||||
|
||||
const parseFundQuerySignals = useCallback((rawName) => {
|
||||
const normalized = normalizeFundText(rawName);
|
||||
const hasETF = normalized.includes('ETF');
|
||||
const hasLOF = normalized.includes('LOF');
|
||||
const hasLink = normalized.includes('联接');
|
||||
const shareMatch = normalized.match(/([A-Z])(?:类)?$/i);
|
||||
const shareClass = shareMatch ? shareMatch[1].toUpperCase() : null;
|
||||
|
||||
const core = normalized
|
||||
.replace(/基金/g, '')
|
||||
.replace(/ETF联接/g, '')
|
||||
.replace(/联接[A-Z]?/g, '')
|
||||
.replace(/ETF/g, '')
|
||||
.replace(/LOF/g, '')
|
||||
.replace(/[A-Z](?:类)?$/g, '');
|
||||
|
||||
return {
|
||||
normalized,
|
||||
core,
|
||||
hasETF,
|
||||
hasLOF,
|
||||
hasLink,
|
||||
shareClass,
|
||||
};
|
||||
}, [normalizeFundText]);
|
||||
|
||||
const resolveFundCodeByFuzzy = useCallback(async (name) => {
|
||||
const querySignals = parseFundQuerySignals(name);
|
||||
if (!querySignals.normalized) return null;
|
||||
|
||||
const len = querySignals.normalized.length;
|
||||
const strictThreshold = len <= 4 ? 0.16 : len <= 8 ? 0.22 : 0.28;
|
||||
const relaxedThreshold = Math.min(0.45, strictThreshold + 0.16);
|
||||
const scoreGapThreshold = len <= 5 ? 0.08 : 0.06;
|
||||
|
||||
const fuse = await getAllFundFuse();
|
||||
const recalled = fuse.search(name, { limit: 50 });
|
||||
if (!recalled.length) return null;
|
||||
|
||||
const stage1 = recalled.filter((item) => (item.score ?? 1) <= relaxedThreshold);
|
||||
if (!stage1.length) return null;
|
||||
|
||||
const ranked = stage1
|
||||
.map((item) => {
|
||||
const candidateSignals = parseFundQuerySignals(item?.item?.name || '');
|
||||
let finalScore = item.score ?? 1;
|
||||
|
||||
if (querySignals.hasETF) {
|
||||
finalScore += candidateSignals.hasETF ? -0.04 : 0.2;
|
||||
}
|
||||
if (querySignals.hasLOF) {
|
||||
finalScore += candidateSignals.hasLOF ? -0.04 : 0.2;
|
||||
}
|
||||
if (querySignals.hasLink) {
|
||||
finalScore += candidateSignals.hasLink ? -0.03 : 0.18;
|
||||
}
|
||||
if (querySignals.shareClass) {
|
||||
finalScore += candidateSignals.shareClass === querySignals.shareClass ? -0.03 : 0.18;
|
||||
}
|
||||
|
||||
if (querySignals.core && candidateSignals.core) {
|
||||
if (candidateSignals.core.includes(querySignals.core)) {
|
||||
finalScore -= 0.06;
|
||||
} else if (!querySignals.core.includes(candidateSignals.core)) {
|
||||
finalScore += 0.06;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...item, finalScore };
|
||||
})
|
||||
.sort((a, b) => a.finalScore - b.finalScore);
|
||||
|
||||
const top1 = ranked[0];
|
||||
if (!top1 || top1.finalScore > strictThreshold) return null;
|
||||
|
||||
const top2 = ranked[1];
|
||||
if (top2 && (top2.finalScore - top1.finalScore) < scoreGapThreshold) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return top1?.item?.code || null;
|
||||
}, [getAllFundFuse, parseFundQuerySignals]);
|
||||
|
||||
return {
|
||||
resolveFundCodeByFuzzy,
|
||||
};
|
||||
};
|
||||
|
||||
export default useFundFuzzyMatcher;
|
||||
@@ -13,6 +13,11 @@ export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-title" content="基估宝" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default"/>
|
||||
<link rel="apple-touch-icon" href="/Icon-60@3x.png?v=1"/>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/Icon-60@3x.png?v=1"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
{/* 尽早设置 data-theme,减少首屏主题闪烁;与 suppressHydrationWarning 配合避免服务端/客户端 html 属性不一致报错 */}
|
||||
<script
|
||||
|
||||
56
app/lib/tradingCalendar.js
Normal file
56
app/lib/tradingCalendar.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* A股交易日历:基于 chinese-days 节假日数据,严格判断某日期是否为交易日
|
||||
* 交易日 = 周一至周五 且 不在法定节假日
|
||||
* 调休补班日(周末变工作日)A股仍休市,故不视为交易日
|
||||
*/
|
||||
|
||||
const CDN_BASE = 'https://cdn.jsdelivr.net/npm/chinese-days@1/dist/years';
|
||||
const yearCache = new Map(); // year -> Set<dateStr> (holidays)
|
||||
|
||||
/**
|
||||
* 加载某年的节假日数据
|
||||
* @param {number} year
|
||||
* @returns {Promise<Set<string>>} 节假日日期集合,格式 YYYY-MM-DD
|
||||
*/
|
||||
export async function loadHolidaysForYear(year) {
|
||||
if (yearCache.has(year)) {
|
||||
return yearCache.get(year);
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${CDN_BASE}/${year}.json`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const holidays = new Set(Object.keys(data?.holidays ?? {}));
|
||||
yearCache.set(year, holidays);
|
||||
return holidays;
|
||||
} catch (e) {
|
||||
console.warn(`[tradingCalendar] 加载 ${year} 年节假日失败:`, e);
|
||||
yearCache.set(year, new Set());
|
||||
return yearCache.get(year);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载多个年份的节假日数据
|
||||
* @param {number[]} years
|
||||
*/
|
||||
export async function loadHolidaysForYears(years) {
|
||||
await Promise.all([...new Set(years)].map(loadHolidaysForYear));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断某日期是否为 A股交易日
|
||||
* @param {dayjs.Dayjs} date - dayjs 对象
|
||||
* @param {Map<number, Set<string>>} [cache] - 可选,已加载的年份缓存,默认使用内部 yearCache
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isTradingDay(date, cache = yearCache) {
|
||||
const dayOfWeek = date.day(); // 0=周日, 6=周六
|
||||
if (dayOfWeek === 0 || dayOfWeek === 6) return false;
|
||||
|
||||
const dateStr = date.format('YYYY-MM-DD');
|
||||
const year = date.year();
|
||||
const holidays = cache.get(year);
|
||||
if (!holidays) return true; // 未加载该年数据时,仅排除周末
|
||||
return !holidays.has(dateStr);
|
||||
}
|
||||
335
app/page.jsx
335
app/page.jsx
@@ -11,6 +11,7 @@ import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
||||
import { isNumber, isString, isPlainObject } from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import Announcement from "./components/Announcement";
|
||||
import { Stat } from "./components/Common";
|
||||
import FundTrendChart from "./components/FundTrendChart";
|
||||
@@ -43,10 +44,12 @@ import DcaModal from "./components/DcaModal";
|
||||
import githubImg from "./assets/github.svg";
|
||||
import { supabase, isSupabaseConfigured } from './lib/supabase';
|
||||
import { recordValuation, getAllValuationSeries, clearFund } from './lib/valuationTimeseries';
|
||||
import { fetchFundData, fetchLatestRelease, fetchShanghaiIndexDate, fetchSmartFundNetValue, searchFunds, extractFundNamesWithLLM } from './api/fund';
|
||||
import { loadHolidaysForYears, isTradingDay as isDateTradingDay } from './lib/tradingCalendar';
|
||||
import { parseFundTextWithLLM, fetchFundData, fetchLatestRelease, fetchShanghaiIndexDate, fetchSmartFundNetValue, searchFunds } from './api/fund';
|
||||
import packageJson from '../package.json';
|
||||
import PcFundTable from './components/PcFundTable';
|
||||
import MobileFundTable from './components/MobileFundTable';
|
||||
import { useFundFuzzyMatcher } from './hooks/useFundFuzzyMatcher';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
@@ -342,6 +345,7 @@ export default function HomePage() {
|
||||
const [favorites, setFavorites] = useState(new Set());
|
||||
const [groups, setGroups] = useState([]); // [{ id, name, codes: [] }]
|
||||
const [currentTab, setCurrentTab] = useState('all');
|
||||
const hasLocalTabInitRef = useRef(false);
|
||||
const [groupModalOpen, setGroupModalOpen] = useState(false);
|
||||
const [groupManageOpen, setGroupManageOpen] = useState(false);
|
||||
const [addFundToGroupOpen, setAddFundToGroupOpen] = useState(false);
|
||||
@@ -349,6 +353,24 @@ export default function HomePage() {
|
||||
// 排序状态
|
||||
const [sortBy, setSortBy] = useState('default'); // default, name, yield, holding
|
||||
const [sortOrder, setSortOrder] = useState('desc'); // asc | desc
|
||||
const [isSortLoaded, setIsSortLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedSortBy = window.localStorage.getItem('localSortBy');
|
||||
const savedSortOrder = window.localStorage.getItem('localSortOrder');
|
||||
if (savedSortBy) setSortBy(savedSortBy);
|
||||
if (savedSortOrder) setSortOrder(savedSortOrder);
|
||||
setIsSortLoaded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && isSortLoaded) {
|
||||
window.localStorage.setItem('localSortBy', sortBy);
|
||||
window.localStorage.setItem('localSortOrder', sortOrder);
|
||||
}
|
||||
}, [sortBy, sortOrder, isSortLoaded]);
|
||||
|
||||
// 视图模式
|
||||
const [viewMode, setViewMode] = useState('card'); // card, list
|
||||
@@ -797,6 +819,7 @@ export default function HomePage() {
|
||||
code: f.code,
|
||||
fundName: f.name,
|
||||
isUpdated: f.jzrq === todayStr,
|
||||
hasDca: dcaPlans[f.code]?.enabled === true,
|
||||
latestNav,
|
||||
estimateNav,
|
||||
yesterdayChangePercent,
|
||||
@@ -816,7 +839,7 @@ export default function HomePage() {
|
||||
holdingProfitValue,
|
||||
};
|
||||
}),
|
||||
[displayFunds, holdings, isTradingDay, todayStr, getHoldingProfit],
|
||||
[displayFunds, holdings, isTradingDay, todayStr, getHoldingProfit, dcaPlans],
|
||||
);
|
||||
|
||||
// 自动滚动选中 Tab 到可视区域
|
||||
@@ -984,7 +1007,7 @@ export default function HomePage() {
|
||||
setTransactions(prev => {
|
||||
const current = prev[fundCode] || [];
|
||||
const record = {
|
||||
id: crypto.randomUUID(),
|
||||
id: uuidv4(),
|
||||
type: data.type,
|
||||
share: data.share,
|
||||
amount: data.amount,
|
||||
@@ -1009,7 +1032,7 @@ export default function HomePage() {
|
||||
// 如果没有价格(API失败),加入待处理队列
|
||||
if (!data.price || data.price === 0) {
|
||||
const pending = {
|
||||
id: crypto.randomUUID(),
|
||||
id: uuidv4(),
|
||||
fundCode: fund.code,
|
||||
fundName: fund.name,
|
||||
type: tradeModal.type,
|
||||
@@ -1060,7 +1083,7 @@ export default function HomePage() {
|
||||
setTransactions(prev => {
|
||||
const current = prev[fund.code] || [];
|
||||
const record = {
|
||||
id: crypto.randomUUID(),
|
||||
id: uuidv4(),
|
||||
type: tradeModal.type,
|
||||
share: data.share,
|
||||
amount: isBuy ? data.totalCost : (data.share * data.price),
|
||||
@@ -1148,9 +1171,11 @@ export default function HomePage() {
|
||||
const [isScanImporting, setIsScanImporting] = useState(false);
|
||||
const [scanImportProgress, setScanImportProgress] = useState({ current: 0, total: 0, success: 0, failed: 0 });
|
||||
const [scanProgress, setScanProgress] = useState({ stage: 'ocr', current: 0, total: 0 }); // stage: ocr | verify
|
||||
const [isOcrScan, setIsOcrScan] = useState(false); // 是否为拍照/图片识别触发的弹框
|
||||
const abortScanRef = useRef(false); // 终止扫描标记
|
||||
const fileInputRef = useRef(null);
|
||||
const ocrWorkerRef = useRef(null);
|
||||
const { resolveFundCodeByFuzzy } = useFundFuzzyMatcher();
|
||||
|
||||
const handleScanClick = () => {
|
||||
setScanModalOpen(true);
|
||||
@@ -1187,6 +1212,7 @@ export default function HomePage() {
|
||||
let worker = ocrWorkerRef.current;
|
||||
if (!worker) {
|
||||
const cdnBases = [
|
||||
'https://01kjzb6fhx9f8rjstc8c21qadx.esa.staticdn.net/npm',
|
||||
'https://fastly.jsdelivr.net/npm',
|
||||
'https://cdn.jsdelivr.net/npm',
|
||||
];
|
||||
@@ -1240,8 +1266,9 @@ export default function HomePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const allCodes = new Set();
|
||||
const allNames = new Set();
|
||||
const allFundsData = []; // 存储所有解析出的基金信息,格式为 [{fundCode, fundName, holdAmounts, holdGains}]
|
||||
const addedFundCodes = new Set(); // 用于去重
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
if (abortScanRef.current) break;
|
||||
|
||||
@@ -1265,45 +1292,71 @@ export default function HomePage() {
|
||||
}
|
||||
text = '';
|
||||
}
|
||||
const matches = text.match(/\b\d{6}\b/g) || [];
|
||||
matches.forEach(c => allCodes.add(c));
|
||||
|
||||
// 如果当前图片中没有识别出基金编码,尝试从文本中提取可能的中文基金名称(调用 GLM 接口)
|
||||
if (!matches.length && text) {
|
||||
let parsedNames = [];
|
||||
// 提取到 text 内容,调用大模型 api 进行解析,获取基金数据(fundCode 可能为空)
|
||||
const fundsResString = await parseFundTextWithLLM(text);
|
||||
let fundsRes = null; // 格式为 [{"fundCode": "000001", "fundName": "浙商债券","holdAmounts": "99.99", "holdGains": "99.99"}]
|
||||
try {
|
||||
parsedNames = await extractFundNamesWithLLM(text);
|
||||
fundsRes = JSON.parse(fundsResString);
|
||||
} catch (e) {
|
||||
parsedNames = [];
|
||||
console.error(e);
|
||||
}
|
||||
parsedNames.forEach((name) => {
|
||||
if (isString(name)) {
|
||||
allNames.add(name.trim());
|
||||
|
||||
// 处理大模型解析结果,根据 fundCode 去重
|
||||
if (Array.isArray(fundsRes) && fundsRes.length > 0) {
|
||||
fundsRes.forEach((fund) => {
|
||||
const code = fund.fundCode || '';
|
||||
const name = (fund.fundName || '').trim();
|
||||
if (code && !addedFundCodes.has(code)) {
|
||||
addedFundCodes.add(code);
|
||||
allFundsData.push({
|
||||
fundCode: code,
|
||||
fundName: name,
|
||||
holdAmounts: fund.holdAmounts || '',
|
||||
holdGains: fund.holdGains || ''
|
||||
});
|
||||
} else if (!code && name) {
|
||||
// fundCode 为空但有名称,后续需要通过名称搜索基金代码
|
||||
allFundsData.push({
|
||||
fundCode: '',
|
||||
fundName: name,
|
||||
holdAmounts: fund.holdAmounts || '',
|
||||
holdGains: fund.holdGains || ''
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (abortScanRef.current) {
|
||||
// 如果是手动终止,不显示结果弹窗
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果所有截图中都没有识别出基金编码,尝试使用识别到的中文名称去搜索基金
|
||||
if (allCodes.size === 0 && allNames.size > 0) {
|
||||
const names = Array.from(allNames);
|
||||
setScanProgress({ stage: 'verify', current: 0, total: names.length });
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
// 处理没有基金代码但有名称的情况,通过名称搜索基金代码
|
||||
const fundsWithoutCode = allFundsData.filter(f => !f.fundCode && f.fundName);
|
||||
if (fundsWithoutCode.length > 0) {
|
||||
setScanProgress({ stage: 'verify', current: 0, total: fundsWithoutCode.length });
|
||||
for (let i = 0; i < fundsWithoutCode.length; i++) {
|
||||
if (abortScanRef.current) break;
|
||||
const name = names[i];
|
||||
const fundItem = fundsWithoutCode[i];
|
||||
setScanProgress(prev => ({ ...prev, current: i + 1 }));
|
||||
try {
|
||||
const list = await searchFundsWithTimeout(name, 8000);
|
||||
const list = await searchFundsWithTimeout(fundItem.fundName, 8000);
|
||||
// 只有当搜索结果「有且仅有一条」时,才认为名称匹配是唯一且有效的
|
||||
if (Array.isArray(list) && list.length === 1) {
|
||||
const found = list[0];
|
||||
if (found && found.CODE) {
|
||||
allCodes.add(found.CODE);
|
||||
if (found && found.CODE && !addedFundCodes.has(found.CODE)) {
|
||||
addedFundCodes.add(found.CODE);
|
||||
fundItem.fundCode = found.CODE;
|
||||
}
|
||||
} else {
|
||||
// 使用 fuse.js 读取 Public 中的 allFunds 数据进行模糊匹配,补充搜索接口的不足
|
||||
try {
|
||||
const fuzzyCode = await resolveFundCodeByFuzzy(fundItem.fundName);
|
||||
if (fuzzyCode && !addedFundCodes.has(fuzzyCode)) {
|
||||
addedFundCodes.add(fuzzyCode);
|
||||
fundItem.fundCode = fuzzyCode;
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1311,7 +1364,9 @@ export default function HomePage() {
|
||||
}
|
||||
}
|
||||
|
||||
const codes = Array.from(allCodes).sort();
|
||||
// 过滤出有基金代码的记录
|
||||
const validFunds = allFundsData.filter(f => f.fundCode);
|
||||
const codes = validFunds.map(f => f.fundCode).sort();
|
||||
setScanProgress({ stage: 'verify', current: 0, total: codes.length });
|
||||
|
||||
const existingCodes = new Set(funds.map(f => f.code));
|
||||
@@ -1319,6 +1374,7 @@ export default function HomePage() {
|
||||
for (let i = 0; i < codes.length; i++) {
|
||||
if (abortScanRef.current) break;
|
||||
const code = codes[i];
|
||||
const fundInfo = validFunds.find(f => f.fundCode === code);
|
||||
setScanProgress(prev => ({ ...prev, current: i + 1 }));
|
||||
|
||||
let found = null;
|
||||
@@ -1333,8 +1389,10 @@ export default function HomePage() {
|
||||
const ok = !!found && !alreadyAdded;
|
||||
results.push({
|
||||
code,
|
||||
name: found ? (found.NAME || found.SHORTNAME || '') : '',
|
||||
status: alreadyAdded ? 'added' : (ok ? 'ok' : 'invalid')
|
||||
name: found ? (found.NAME || found.SHORTNAME || '') : (fundInfo?.fundName || ''),
|
||||
status: alreadyAdded ? 'added' : (ok ? 'ok' : 'invalid'),
|
||||
holdAmounts: fundInfo?.holdAmounts || '',
|
||||
holdGains: fundInfo?.holdGains || ''
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1344,6 +1402,7 @@ export default function HomePage() {
|
||||
|
||||
setScannedFunds(results);
|
||||
setSelectedScannedCodes(new Set(results.filter(r => r.status === 'ok').map(r => r.code)));
|
||||
setIsOcrScan(true);
|
||||
setScanConfirmModalOpen(true);
|
||||
} catch (err) {
|
||||
if (!abortScanRef.current) {
|
||||
@@ -1374,7 +1433,7 @@ export default function HomePage() {
|
||||
});
|
||||
};
|
||||
|
||||
const confirmScanImport = async () => {
|
||||
const confirmScanImport = async (targetGroupId = 'all') => {
|
||||
const codes = Array.from(selectedScannedCodes);
|
||||
if (codes.length === 0) {
|
||||
showToast('请至少选择一个基金代码', 'error');
|
||||
@@ -1384,8 +1443,15 @@ export default function HomePage() {
|
||||
setIsScanImporting(true);
|
||||
setScanImportProgress({ current: 0, total: codes.length, success: 0, failed: 0 });
|
||||
|
||||
const parseAmount = (val) => {
|
||||
if (!val) return null;
|
||||
const num = parseFloat(String(val).replace(/,/g, ''));
|
||||
return isNaN(num) ? null : num;
|
||||
};
|
||||
|
||||
try {
|
||||
const newFunds = [];
|
||||
const newHoldings = {};
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
@@ -1397,6 +1463,23 @@ export default function HomePage() {
|
||||
try {
|
||||
const data = await fetchFundData(code);
|
||||
newFunds.push(data);
|
||||
|
||||
const scannedFund = scannedFunds.find(f => f.code === code);
|
||||
const holdAmounts = parseAmount(scannedFund?.holdAmounts);
|
||||
const holdGains = parseAmount(scannedFund?.holdGains);
|
||||
const dwjz = data?.dwjz || data?.gsz || 0;
|
||||
|
||||
if (holdAmounts !== null && dwjz > 0) {
|
||||
const share = holdAmounts / dwjz;
|
||||
const profit = holdGains !== null ? holdGains : 0;
|
||||
const principal = holdAmounts - profit;
|
||||
const cost = share > 0 ? principal / share : 0;
|
||||
newHoldings[code] = {
|
||||
share: Number(share.toFixed(2)),
|
||||
cost: Number(cost.toFixed(4))
|
||||
};
|
||||
}
|
||||
|
||||
successCount++;
|
||||
setScanImportProgress(prev => ({ ...prev, success: prev.success + 1 }));
|
||||
} catch (e) {
|
||||
@@ -1411,6 +1494,15 @@ export default function HomePage() {
|
||||
storageHelper.setItem('funds', JSON.stringify(updated));
|
||||
return updated;
|
||||
});
|
||||
|
||||
if (Object.keys(newHoldings).length > 0) {
|
||||
setHoldings(prev => {
|
||||
const next = { ...prev, ...newHoldings };
|
||||
storageHelper.setItem('holdings', JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const nextSeries = {};
|
||||
newFunds.forEach(u => {
|
||||
if (u?.code != null && !u.noValuation && Number.isFinite(Number(u.gsz))) {
|
||||
@@ -1418,6 +1510,34 @@ export default function HomePage() {
|
||||
}
|
||||
});
|
||||
if (Object.keys(nextSeries).length > 0) setValuationSeries(prev => ({ ...prev, ...nextSeries }));
|
||||
|
||||
if (targetGroupId === 'fav') {
|
||||
setFavorites(prev => {
|
||||
const next = new Set(prev);
|
||||
codes.forEach(code => next.add(code));
|
||||
storageHelper.setItem('favorites', JSON.stringify(Array.from(next)));
|
||||
return next;
|
||||
});
|
||||
setCurrentTab('fav');
|
||||
} else if (targetGroupId && targetGroupId !== 'all') {
|
||||
setGroups(prev => {
|
||||
const updated = prev.map(g => {
|
||||
if (g.id === targetGroupId) {
|
||||
return {
|
||||
...g,
|
||||
codes: Array.from(new Set([...g.codes, ...codes]))
|
||||
};
|
||||
}
|
||||
return g;
|
||||
});
|
||||
storageHelper.setItem('groups', JSON.stringify(updated));
|
||||
return updated;
|
||||
});
|
||||
setCurrentTab(targetGroupId);
|
||||
} else {
|
||||
setCurrentTab('all');
|
||||
}
|
||||
|
||||
setSuccessModal({ open: true, message: `成功导入 ${successCount} 个基金` });
|
||||
} else {
|
||||
if (codes.length > 0 && successCount === 0 && failedCount === 0) {
|
||||
@@ -1457,14 +1577,19 @@ export default function HomePage() {
|
||||
userIdRef.current = user?.id || null;
|
||||
}, [user]);
|
||||
|
||||
const getFundCodesSignature = useCallback((value) => {
|
||||
const getFundCodesSignature = useCallback((value, extraFields = []) => {
|
||||
try {
|
||||
const list = JSON.parse(value || '[]');
|
||||
const list = Array.isArray(value) ? value : JSON.parse(value || '[]');
|
||||
if (!Array.isArray(list)) return '';
|
||||
const fields = Array.from(new Set([
|
||||
'jzrq',
|
||||
'dwjz',
|
||||
...(Array.isArray(extraFields) ? extraFields : [])
|
||||
]));
|
||||
const items = list.map((item) => {
|
||||
if (!item?.code) return null;
|
||||
// 加入 jzrq 和 dwjz 以检测净值更新
|
||||
return `${item.code}:${item.jzrq || ''}:${item.dwjz || ''}`;
|
||||
const extras = fields.map((field) => item?.[field] || '').join(':');
|
||||
return `${item.code}:${extras}`;
|
||||
}).filter(Boolean);
|
||||
return Array.from(new Set(items)).join('|');
|
||||
} catch (e) {
|
||||
@@ -1508,7 +1633,7 @@ export default function HomePage() {
|
||||
|
||||
const storageHelper = useMemo(() => {
|
||||
// 仅以下 key 参与云端同步;fundValuationTimeseries 不同步到云端(测试中功能,暂不同步)
|
||||
const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'collapsedTrends', 'refreshMs', 'holdings', 'pendingTrades', 'transactions', 'viewMode', 'dcaPlans', 'customSettings']);
|
||||
const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'collapsedTrends', 'refreshMs', 'holdings', 'pendingTrades', 'transactions', 'dcaPlans', 'customSettings']);
|
||||
const triggerSync = (key, prevValue, nextValue) => {
|
||||
if (keys.has(key)) {
|
||||
// 标记为脏数据
|
||||
@@ -1555,7 +1680,7 @@ export default function HomePage() {
|
||||
|
||||
useEffect(() => {
|
||||
// 仅以下 key 的变更会触发云端同步;fundValuationTimeseries 不在其中
|
||||
const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'collapsedTrends', 'refreshMs', 'holdings', 'pendingTrades', 'viewMode', 'dcaPlans', 'customSettings']);
|
||||
const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'collapsedTrends', 'refreshMs', 'holdings', 'pendingTrades', 'dcaPlans', 'customSettings']);
|
||||
const onStorage = (e) => {
|
||||
if (!e.key) return;
|
||||
if (e.key === 'localUpdatedAt') {
|
||||
@@ -1635,7 +1760,8 @@ export default function HomePage() {
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleDcaTrades = useCallback(() => {
|
||||
const scheduleDcaTrades = useCallback(async () => {
|
||||
if (!isTradingDay) return;
|
||||
if (!isPlainObject(dcaPlans)) return;
|
||||
const codesSet = new Set(funds.map((f) => f.code));
|
||||
if (codesSet.size === 0) return;
|
||||
@@ -1644,6 +1770,14 @@ export default function HomePage() {
|
||||
const nextPlans = { ...dcaPlans };
|
||||
const newPending = [];
|
||||
|
||||
// 预加载回溯区间内所有年份的节假日数据
|
||||
const years = new Set([today.year()]);
|
||||
Object.values(dcaPlans).forEach((plan) => {
|
||||
if (plan?.firstDate) years.add(toTz(plan.firstDate).year());
|
||||
if (plan?.lastDate) years.add(toTz(plan.lastDate).year());
|
||||
});
|
||||
await loadHolidaysForYears([...years]);
|
||||
|
||||
Object.entries(dcaPlans).forEach(([code, plan]) => {
|
||||
if (!plan || !plan.enabled) return;
|
||||
if (!codesSet.has(code)) return;
|
||||
@@ -1679,6 +1813,9 @@ export default function HomePage() {
|
||||
if (current.isAfter(today, 'day')) break;
|
||||
if (current.isBefore(first, 'day')) continue;
|
||||
|
||||
// 回溯补单:严格判断该日是否为 A股交易日(排除周末、法定节假日)
|
||||
if (!isDateTradingDay(current)) continue;
|
||||
|
||||
const dateStr = current.format('YYYY-MM-DD');
|
||||
|
||||
const pending = {
|
||||
@@ -1726,11 +1863,13 @@ export default function HomePage() {
|
||||
});
|
||||
|
||||
showToast(`已生成 ${newPending.length} 笔定投买入`, 'success');
|
||||
}, [dcaPlans, funds, todayStr, storageHelper]);
|
||||
}, [isTradingDay, dcaPlans, funds, todayStr, storageHelper]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTradingDay) return;
|
||||
scheduleDcaTrades();
|
||||
scheduleDcaTrades().catch((e) => {
|
||||
console.error('[scheduleDcaTrades]', e);
|
||||
});
|
||||
}, [isTradingDay, scheduleDcaTrades]);
|
||||
|
||||
const handleAddGroup = (name) => {
|
||||
@@ -1956,6 +2095,17 @@ export default function HomePage() {
|
||||
if (Array.isArray(savedGroups)) {
|
||||
setGroups(savedGroups);
|
||||
}
|
||||
// 读取用户上次选择的分组(仅本地存储,不同步云端)
|
||||
const savedTab = localStorage.getItem('currentTab');
|
||||
if (
|
||||
savedTab === 'all' ||
|
||||
savedTab === 'fav' ||
|
||||
(savedTab && Array.isArray(savedGroups) && savedGroups.some((g) => g?.id === savedTab))
|
||||
) {
|
||||
setCurrentTab(savedTab);
|
||||
} else if (savedTab) {
|
||||
setCurrentTab('all');
|
||||
}
|
||||
// 加载持仓数据
|
||||
const savedHoldings = JSON.parse(localStorage.getItem('holdings') || '{}');
|
||||
if (isPlainObject(savedHoldings)) {
|
||||
@@ -1978,11 +2128,22 @@ export default function HomePage() {
|
||||
setTheme(savedTheme);
|
||||
}
|
||||
} catch { }
|
||||
if (!cancelled) {
|
||||
hasLocalTabInitRef.current = true;
|
||||
}
|
||||
};
|
||||
init();
|
||||
return () => { cancelled = true; };
|
||||
}, [isSupabaseConfigured]);
|
||||
|
||||
// 记录用户当前选择的分组(仅本地存储,不同步云端)
|
||||
useEffect(() => {
|
||||
if (!hasLocalTabInitRef.current) return;
|
||||
try {
|
||||
localStorage.setItem('currentTab', currentTab);
|
||||
} catch { }
|
||||
}, [currentTab]);
|
||||
|
||||
// 主题同步到 document 并持久化
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
@@ -2460,49 +2621,27 @@ export default function HomePage() {
|
||||
setError('请输入或选择基金代码');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const newFunds = [];
|
||||
const failures = [];
|
||||
const nameMap = {};
|
||||
selectedFunds.forEach(f => { nameMap[f.CODE] = f.NAME; });
|
||||
for (const c of selectedCodes) {
|
||||
if (funds.some((f) => f.code === c)) continue;
|
||||
try {
|
||||
const data = await fetchFundData(c);
|
||||
newFunds.push(data);
|
||||
} catch (err) {
|
||||
failures.push({ code: c, name: nameMap[c] });
|
||||
}
|
||||
}
|
||||
if (newFunds.length === 0) {
|
||||
setError('未添加任何新基金');
|
||||
} else {
|
||||
const next = dedupeByCode([...newFunds, ...funds]);
|
||||
setFunds(next);
|
||||
storageHelper.setItem('funds', JSON.stringify(next));
|
||||
const nextSeries = {};
|
||||
newFunds.forEach(u => {
|
||||
if (u?.code != null && !u.noValuation && Number.isFinite(Number(u.gsz))) {
|
||||
nextSeries[u.code] = recordValuation(u.code, { gsz: u.gsz, gztime: u.gztime });
|
||||
}
|
||||
});
|
||||
if (Object.keys(nextSeries).length > 0) setValuationSeries(prev => ({ ...prev, ...nextSeries }));
|
||||
const fundsToConfirm = selectedCodes.map(code => ({
|
||||
code,
|
||||
name: nameMap[code] || '',
|
||||
status: funds.some(f => f.code === code) ? 'added' : 'pending'
|
||||
}));
|
||||
const pendingCodes = fundsToConfirm.filter(f => f.status === 'pending').map(f => f.code);
|
||||
if (pendingCodes.length === 0) {
|
||||
setError('所选基金已全部添加');
|
||||
return;
|
||||
}
|
||||
setScannedFunds(fundsToConfirm);
|
||||
setSelectedScannedCodes(new Set(pendingCodes));
|
||||
setIsOcrScan(false);
|
||||
setScanConfirmModalOpen(true);
|
||||
setSearchTerm('');
|
||||
setSelectedFunds([]);
|
||||
setShowDropdown(false);
|
||||
inputRef.current?.blur();
|
||||
setIsSearchFocused(false);
|
||||
if (failures.length > 0) {
|
||||
setAddFailures(failures);
|
||||
setAddResultOpen(true);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e.message || '添加失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeFund = (removeCode) => {
|
||||
@@ -2763,7 +2902,6 @@ export default function HomePage() {
|
||||
};
|
||||
});
|
||||
|
||||
const viewMode = payload.viewMode === 'list' ? 'list' : 'card';
|
||||
const customSettings = isPlainObject(payload.customSettings) ? payload.customSettings : {};
|
||||
|
||||
return JSON.stringify({
|
||||
@@ -2777,7 +2915,6 @@ export default function HomePage() {
|
||||
pendingTrades,
|
||||
transactions,
|
||||
dcaPlans,
|
||||
viewMode,
|
||||
customSettings
|
||||
});
|
||||
}
|
||||
@@ -2801,9 +2938,6 @@ export default function HomePage() {
|
||||
if (!keys || keys.has('collapsedTrends')) {
|
||||
all.collapsedTrends = JSON.parse(localStorage.getItem('collapsedTrends') || '[]');
|
||||
}
|
||||
if (!keys || keys.has('viewMode')) {
|
||||
all.viewMode = localStorage.getItem('viewMode') === 'list' ? 'list' : 'card';
|
||||
}
|
||||
if (!keys || keys.has('refreshMs')) {
|
||||
all.refreshMs = parseInt(localStorage.getItem('refreshMs') || '30000', 10);
|
||||
}
|
||||
@@ -2895,7 +3029,6 @@ export default function HomePage() {
|
||||
pendingTrades: all.pendingTrades,
|
||||
transactions: all.transactions,
|
||||
dcaPlans: cleanedDcaPlans,
|
||||
viewMode: all.viewMode,
|
||||
customSettings: isPlainObject(all.customSettings) ? all.customSettings : {}
|
||||
};
|
||||
}
|
||||
@@ -2916,7 +3049,6 @@ export default function HomePage() {
|
||||
pendingTrades: [],
|
||||
transactions: {},
|
||||
dcaPlans: {},
|
||||
viewMode: 'card',
|
||||
customSettings: {},
|
||||
exportedAt: nowInTz().toISOString()
|
||||
};
|
||||
@@ -2952,10 +3084,6 @@ export default function HomePage() {
|
||||
setTempSeconds(Math.round(nextRefreshMs / 1000));
|
||||
storageHelper.setItem('refreshMs', String(nextRefreshMs));
|
||||
|
||||
if (cloudData.viewMode === 'card' || cloudData.viewMode === 'list') {
|
||||
applyViewMode(cloudData.viewMode);
|
||||
}
|
||||
|
||||
const nextHoldings = isPlainObject(cloudData.holdings) ? cloudData.holdings : {};
|
||||
setHoldings(nextHoldings);
|
||||
storageHelper.setItem('holdings', JSON.stringify(nextHoldings));
|
||||
@@ -2989,6 +3117,25 @@ export default function HomePage() {
|
||||
if (nextFunds.length) {
|
||||
const codes = Array.from(new Set(nextFunds.map((f) => f.code)));
|
||||
if (codes.length) await refreshAll(codes);
|
||||
// 刷新完成后,强制同步本地localStorage 的 funds 数据到云端
|
||||
const currentUserId = userIdRef.current || user?.id;
|
||||
if (currentUserId) {
|
||||
try {
|
||||
const latestFunds = JSON.parse(localStorage.getItem('funds') || '[]');
|
||||
const localSig = getFundCodesSignature(latestFunds, ['gztime']);
|
||||
const cloudSig = getFundCodesSignature(Array.isArray(cloudData.funds) ? cloudData.funds : [], ['gztime']);
|
||||
if (localSig !== cloudSig) {
|
||||
await syncUserConfig(
|
||||
currentUserId,
|
||||
false,
|
||||
{ funds: Array.isArray(latestFunds) ? latestFunds : [] },
|
||||
true
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('刷新后强制同步 funds 到云端失败', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const payload = collectLocalPayload();
|
||||
@@ -4109,6 +4256,7 @@ export default function HomePage() {
|
||||
</span>
|
||||
<span className="muted">
|
||||
#{f.code}
|
||||
{dcaPlans[f.code]?.enabled === true && <span className="dca-indicator">定</span>}
|
||||
{f.jzrq === todayStr && <span className="updated-indicator">✓</span>}
|
||||
</span>
|
||||
</div>
|
||||
@@ -4162,7 +4310,8 @@ export default function HomePage() {
|
||||
|
||||
if (shouldHideChange) return null;
|
||||
|
||||
const changeLabel = hasTodayData ? '涨跌幅' : (isYesterdayChange ? '昨日涨跌幅' : (isPreviousTradingDay ? '上一交易日涨跌幅' : '涨跌幅'));
|
||||
// 不再区分“上一交易日涨跌幅”名称,统一使用“昨日涨跌幅”
|
||||
const changeLabel = hasTodayData ? '涨跌幅' : '昨日涨跌幅';
|
||||
return (
|
||||
<Stat
|
||||
label={changeLabel}
|
||||
@@ -4274,6 +4423,8 @@ export default function HomePage() {
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{f.holdingsIsLastQuarter && Array.isArray(f.holdings) && f.holdings.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }}
|
||||
className="title"
|
||||
@@ -4304,7 +4455,6 @@ export default function HomePage() {
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
{Array.isArray(f.holdings) && f.holdings.length ? (
|
||||
<div className="list">
|
||||
{f.holdings.map((h, idx) => (
|
||||
<div className="item" key={idx}>
|
||||
@@ -4320,12 +4470,11 @@ export default function HomePage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="muted" style={{ padding: '8px 0' }}>暂无重仓数据</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
<FundTrendChart
|
||||
key={`${f.code}-${theme}`}
|
||||
code={f.code}
|
||||
@@ -4647,6 +4796,8 @@ export default function HomePage() {
|
||||
onToggle={toggleScannedCode}
|
||||
onConfirm={confirmScanImport}
|
||||
refreshing={refreshing}
|
||||
groups={groups}
|
||||
isOcrScan={isOcrScan}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
30
package-lock.json
generated
30
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "real-time-fund",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "real-time-fund",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"dependencies": {
|
||||
"@dicebear/collection": "^9.3.1",
|
||||
"@dicebear/core": "^9.3.1",
|
||||
@@ -19,12 +19,14 @@
|
||||
"chart.js": "^4.5.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"framer-motion": "^12.29.2",
|
||||
"fuse.js": "^7.1.0",
|
||||
"lodash": "^4.17.23",
|
||||
"next": "^16.1.5",
|
||||
"react": "18.3.1",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"tesseract.js": "^5.1.1"
|
||||
"tesseract.js": "^5.1.1",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
@@ -4148,6 +4150,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/fuse.js": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/fuse.js/-/fuse.js-7.1.0.tgz",
|
||||
"integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/generator-function": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
|
||||
@@ -6959,6 +6970,19 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/wasm-feature-detect": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-time-fund",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -22,12 +22,14 @@
|
||||
"chart.js": "^4.5.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"framer-motion": "^12.29.2",
|
||||
"fuse.js": "^7.1.0",
|
||||
"lodash": "^4.17.23",
|
||||
"next": "^16.1.5",
|
||||
"react": "18.3.1",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"tesseract.js": "^5.1.1"
|
||||
"tesseract.js": "^5.1.1",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
|
||||
BIN
public/Icon-60@3x.png
Normal file
BIN
public/Icon-60@3x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
104742
public/allFund.json
Normal file
104742
public/allFund.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user