feat: 拍照识别增加持有收益
This commit is contained in:
@@ -669,72 +669,6 @@ export const fetchFundPingzhongdata = async (fundCode, { cacheTime = 60 * 60 * 1
|
||||
}
|
||||
};
|
||||
|
||||
// 使用智谱 GLM 从 OCR 文本中抽取基金名称
|
||||
export const extractFundNamesWithLLM = async (ocrText) => {
|
||||
const apiKey = '8df8ccf74a174722847c83b7e222f2af.4A39rJvUeBVDmef1';
|
||||
if (!apiKey || !ocrText) return [];
|
||||
|
||||
try {
|
||||
const models = ['glm-4.5-flash', 'glm-4.7-flash'];
|
||||
const model = models[Math.floor(Math.random() * models.length)];
|
||||
|
||||
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 [];
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchFundHistory = async (code, range = '1m') => {
|
||||
if (typeof window === 'undefined') return [];
|
||||
|
||||
@@ -776,3 +710,36 @@ export const fetchFundHistory = async (code, range = '1m') => {
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const parseFundTextWithLLM = async (text) => {
|
||||
const apiKey = 'sk-a72c4e279bc62a03cc105be6263d464c';
|
||||
if (!apiKey || !text) return null;
|
||||
|
||||
try {
|
||||
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
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data?.choices?.[0]?.message?.content || null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -11,7 +11,8 @@ export default function ScanImportConfirmModal({
|
||||
onToggle,
|
||||
onConfirm,
|
||||
refreshing,
|
||||
groups = []
|
||||
groups = [],
|
||||
isOcrScan = false
|
||||
}) {
|
||||
const [selectedGroupId, setSelectedGroupId] = useState('all');
|
||||
|
||||
@@ -19,6 +20,13 @@ export default function ScanImportConfirmModal({
|
||||
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"
|
||||
@@ -36,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>
|
||||
@@ -44,19 +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}
|
||||
@@ -66,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>
|
||||
@@ -82,6 +99,23 @@ 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>
|
||||
|
||||
@@ -2650,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;
|
||||
|
||||
121
app/page.jsx
121
app/page.jsx
@@ -45,7 +45,7 @@ import githubImg from "./assets/github.svg";
|
||||
import { supabase, isSupabaseConfigured } from './lib/supabase';
|
||||
import { recordValuation, getAllValuationSeries, clearFund } from './lib/valuationTimeseries';
|
||||
import { loadHolidaysForYears, isTradingDay as isDateTradingDay } from './lib/tradingCalendar';
|
||||
import { fetchFundData, fetchLatestRelease, fetchShanghaiIndexDate, fetchSmartFundNetValue, searchFunds, extractFundNamesWithLLM } from './api/fund';
|
||||
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';
|
||||
@@ -1171,6 +1171,7 @@ 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);
|
||||
@@ -1211,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',
|
||||
];
|
||||
@@ -1264,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;
|
||||
|
||||
@@ -1289,52 +1292,69 @@ 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(name);
|
||||
if (fuzzyCode) {
|
||||
allCodes.add(fuzzyCode);
|
||||
const fuzzyCode = await resolveFundCodeByFuzzy(fundItem.fundName);
|
||||
if (fuzzyCode && !addedFundCodes.has(fuzzyCode)) {
|
||||
addedFundCodes.add(fuzzyCode);
|
||||
fundItem.fundCode = fuzzyCode;
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
@@ -1344,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));
|
||||
@@ -1352,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;
|
||||
@@ -1366,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 || ''
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1377,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) {
|
||||
@@ -1417,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;
|
||||
|
||||
@@ -1430,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) {
|
||||
@@ -1444,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))) {
|
||||
@@ -2576,6 +2635,7 @@ export default function HomePage() {
|
||||
}
|
||||
setScannedFunds(fundsToConfirm);
|
||||
setSelectedScannedCodes(new Set(pendingCodes));
|
||||
setIsOcrScan(false);
|
||||
setScanConfirmModalOpen(true);
|
||||
setSearchTerm('');
|
||||
setSelectedFunds([]);
|
||||
@@ -4737,6 +4797,7 @@ export default function HomePage() {
|
||||
onConfirm={confirmScanImport}
|
||||
refreshing={refreshing}
|
||||
groups={groups}
|
||||
isOcrScan={isOcrScan}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
Reference in New Issue
Block a user