feat: 拍照识别增加持有收益

This commit is contained in:
hzm
2026-03-06 00:09:06 +08:00
parent c08c97d706
commit fe3c2b64f6
4 changed files with 196 additions and 112 deletions

View File

@@ -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;
}
};

View File

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

View File

@@ -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;

View File

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