feat: 新增OCR识别导入基金
This commit is contained in:
BIN
app/assets/weChatGroup.jpg
Normal file
BIN
app/assets/weChatGroup.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 563 KiB |
@@ -227,3 +227,12 @@ export function MinusIcon(props) {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CameraIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
||||||
|
<circle cx="12" cy="13" r="4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
433
app/page.jsx
433
app/page.jsx
@@ -10,8 +10,8 @@ import timezone from 'dayjs/plugin/timezone';
|
|||||||
import Announcement from "./components/Announcement";
|
import Announcement from "./components/Announcement";
|
||||||
import { DatePicker, DonateTabs, NumericInput, Stat } from "./components/Common";
|
import { DatePicker, DonateTabs, NumericInput, Stat } from "./components/Common";
|
||||||
import FundTrendChart from "./components/FundTrendChart";
|
import FundTrendChart from "./components/FundTrendChart";
|
||||||
import { ChevronIcon, CloseIcon, CloudIcon, DragIcon, ExitIcon, EyeIcon, EyeOffIcon, GridIcon, ListIcon, LoginIcon, LogoutIcon, MailIcon, PinIcon, PinOffIcon, PlusIcon, RefreshIcon, SettingsIcon, SortIcon, StarIcon, TrashIcon, UpdateIcon, UserIcon } from "./components/Icons";
|
import { ChevronIcon, CloseIcon, CloudIcon, DragIcon, ExitIcon, EyeIcon, EyeOffIcon, GridIcon, ListIcon, LoginIcon, LogoutIcon, MailIcon, PinIcon, PinOffIcon, PlusIcon, RefreshIcon, SettingsIcon, SortIcon, StarIcon, TrashIcon, UpdateIcon, UserIcon, CameraIcon } from "./components/Icons";
|
||||||
import weChatGroupImg from "./assets/weChatGroup.png";
|
import weChatGroupImg from "./assets/weChatGroup.jpg";
|
||||||
import { supabase, isSupabaseConfigured } from './lib/supabase';
|
import { supabase, isSupabaseConfigured } from './lib/supabase';
|
||||||
import { fetchFundData, fetchLatestRelease, fetchShanghaiIndexDate, fetchSmartFundNetValue, searchFunds, submitFeedback } from './api/fund';
|
import { fetchFundData, fetchLatestRelease, fetchShanghaiIndexDate, fetchSmartFundNetValue, searchFunds, submitFeedback } from './api/fund';
|
||||||
import packageJson from '../package.json';
|
import packageJson from '../package.json';
|
||||||
@@ -279,6 +279,30 @@ function HoldingActionModal({ fund, onClose, onAction }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ScanButton({ onClick, disabled }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
title="拍照/上传图片识别基金代码"
|
||||||
|
style={{
|
||||||
|
opacity: disabled ? 0.5 : 1,
|
||||||
|
cursor: disabled ? 'wait' : 'pointer',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{disabled ? (
|
||||||
|
<div className="loading-spinner" style={{ width: 16, height: 16, border: '2px solid var(--muted)', borderTopColor: 'var(--primary)', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
|
||||||
|
) : (
|
||||||
|
<CameraIcon width="18" height="18" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TradeModal({ type, fund, holding, onClose, onConfirm, pendingTrades = [], onDeletePending }) {
|
function TradeModal({ type, fund, holding, onClose, onConfirm, pendingTrades = [], onDeletePending }) {
|
||||||
const isBuy = type === 'buy';
|
const isBuy = type === 'buy';
|
||||||
const [share, setShare] = useState('');
|
const [share, setShare] = useState('');
|
||||||
@@ -2446,6 +2470,182 @@ export default function HomePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [updateModalOpen, setUpdateModalOpen] = useState(false);
|
const [updateModalOpen, setUpdateModalOpen] = useState(false);
|
||||||
|
const [scanModalOpen, setScanModalOpen] = useState(false); // 扫描弹窗
|
||||||
|
const [scanConfirmModalOpen, setScanConfirmModalOpen] = useState(false); // 扫描确认弹窗
|
||||||
|
const [scannedFunds, setScannedFunds] = useState([]); // 扫描到的基金
|
||||||
|
const [selectedScannedCodes, setSelectedScannedCodes] = useState(new Set()); // 选中的扫描代码
|
||||||
|
const [isScanning, setIsScanning] = useState(false);
|
||||||
|
const [scanProgress, setScanProgress] = useState({ stage: 'ocr', current: 0, total: 0 }); // stage: ocr | verify
|
||||||
|
const abortScanRef = useRef(false); // 终止扫描标记
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
// 引入 Tesseract
|
||||||
|
const [Tesseract, setTesseract] = useState(null);
|
||||||
|
useEffect(() => {
|
||||||
|
import('tesseract.js').then(mod => setTesseract(mod.default));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleScanClick = () => {
|
||||||
|
setScanModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScanPick = () => {
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelScan = () => {
|
||||||
|
abortScanRef.current = true;
|
||||||
|
setIsScanning(false);
|
||||||
|
setScanProgress({ stage: 'ocr', current: 0, total: 0 });
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilesUpload = async (event) => {
|
||||||
|
const files = Array.from(event.target.files || []);
|
||||||
|
if (!files.length) return;
|
||||||
|
if (!Tesseract) {
|
||||||
|
alert('OCR 组件加载中,请稍后重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsScanning(true);
|
||||||
|
setScanModalOpen(false); // 关闭选择弹窗
|
||||||
|
abortScanRef.current = false;
|
||||||
|
setScanProgress({ stage: 'ocr', current: 0, total: files.length });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allCodes = new Set();
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
if (abortScanRef.current) break;
|
||||||
|
|
||||||
|
const f = files[i];
|
||||||
|
// 更新进度:正在处理第 i+1 张
|
||||||
|
setScanProgress(prev => ({ ...prev, current: i + 1 }));
|
||||||
|
|
||||||
|
const { data: { text } } = await Tesseract.recognize(f, 'eng'); // 这里使用英文解析能提升速度
|
||||||
|
const matches = text.match(/\b\d{6}\b/g) || [];
|
||||||
|
matches.forEach(c => allCodes.add(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abortScanRef.current) {
|
||||||
|
// 如果是手动终止,不显示结果弹窗
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const codes = Array.from(allCodes).sort();
|
||||||
|
setScanProgress({ stage: 'verify', current: 0, total: codes.length });
|
||||||
|
|
||||||
|
const existingCodes = new Set(funds.map(f => f.code));
|
||||||
|
const results = [];
|
||||||
|
for (let i = 0; i < codes.length; i++) {
|
||||||
|
if (abortScanRef.current) break;
|
||||||
|
const code = codes[i];
|
||||||
|
setScanProgress(prev => ({ ...prev, current: i + 1 }));
|
||||||
|
|
||||||
|
let found = null;
|
||||||
|
try {
|
||||||
|
const list = await searchFunds(code);
|
||||||
|
found = Array.isArray(list) ? list.find(d => d.CODE === code) : null;
|
||||||
|
} catch (e) {
|
||||||
|
found = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyAdded = existingCodes.has(code);
|
||||||
|
const ok = !!found && !alreadyAdded;
|
||||||
|
results.push({
|
||||||
|
code,
|
||||||
|
name: found ? (found.NAME || found.SHORTNAME || '') : '',
|
||||||
|
status: alreadyAdded ? 'added' : (ok ? 'ok' : 'invalid')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abortScanRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setScannedFunds(results);
|
||||||
|
setSelectedScannedCodes(new Set(results.filter(r => r.status === 'ok').map(r => r.code)));
|
||||||
|
setScanConfirmModalOpen(true);
|
||||||
|
} catch (err) {
|
||||||
|
if (!abortScanRef.current) {
|
||||||
|
console.error('OCR Error:', err);
|
||||||
|
showToast('图片识别失败,请重试或更换更清晰的截图', 'error');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsScanning(false);
|
||||||
|
setScanProgress({ stage: 'ocr', current: 0, total: 0 });
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleScannedCode = (code) => {
|
||||||
|
setSelectedScannedCodes(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(code)) next.delete(code);
|
||||||
|
else next.add(code);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmScanImport = () => {
|
||||||
|
const codes = Array.from(selectedScannedCodes);
|
||||||
|
if (codes.length === 0) {
|
||||||
|
showToast('请至少选择一个基金代码', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleScanResult(codes);
|
||||||
|
setScanConfirmModalOpen(false);
|
||||||
|
setScannedFunds([]);
|
||||||
|
setSelectedScannedCodes(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScanResult = (codes) => {
|
||||||
|
if (!codes || codes.length === 0) return;
|
||||||
|
// 如果只有一个代码,直接搜索
|
||||||
|
if (codes.length === 1) {
|
||||||
|
const code = codes[0];
|
||||||
|
setSearchTerm(code);
|
||||||
|
if (inputRef.current) inputRef.current.focus();
|
||||||
|
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
||||||
|
searchTimeoutRef.current = setTimeout(() => performSearch(code), 300);
|
||||||
|
} else {
|
||||||
|
// 多个代码,直接批量添加
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
let successCount = 0;
|
||||||
|
try {
|
||||||
|
const newFunds = [];
|
||||||
|
for (const code of codes) {
|
||||||
|
if (funds.some(existing => existing.code === code)) continue;
|
||||||
|
try {
|
||||||
|
const data = await fetchFundData(code);
|
||||||
|
newFunds.push(data);
|
||||||
|
successCount++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`添加基金 ${code} 失败`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newFunds.length > 0) {
|
||||||
|
const updated = dedupeByCode([...newFunds, ...funds]);
|
||||||
|
setFunds(updated);
|
||||||
|
storageHelper.setItem('funds', JSON.stringify(updated));
|
||||||
|
setSuccessModal({ open: true, message: `成功导入 ${successCount} 个基金` });
|
||||||
|
} else {
|
||||||
|
if (codes.length > 0 && successCount === 0) {
|
||||||
|
showToast('未找到有效基金或已存在', 'info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('批量导入失败', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const [cloudConfigModal, setCloudConfigModal] = useState({ open: false, userId: null });
|
const [cloudConfigModal, setCloudConfigModal] = useState({ open: false, userId: null });
|
||||||
const syncDebounceRef = useRef(null);
|
const syncDebounceRef = useRef(null);
|
||||||
const lastSyncedRef = useRef('');
|
const lastSyncedRef = useRef('');
|
||||||
@@ -3049,6 +3249,43 @@ export default function HomePage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleScanImportConfirm = async (codes) => {
|
||||||
|
if (!Array.isArray(codes) || codes.length === 0) return;
|
||||||
|
const uniqueCodes = Array.from(new Set(codes));
|
||||||
|
const toAdd = uniqueCodes.filter(c => !funds.some(f => f.code === c));
|
||||||
|
if (toAdd.length === 0) {
|
||||||
|
setSuccessModal({ open: true, message: '识别的基金已全部添加' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const added = [];
|
||||||
|
for (const code of toAdd) {
|
||||||
|
try {
|
||||||
|
const data = await fetchFundData(code);
|
||||||
|
if (data && data.code) {
|
||||||
|
added.push(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`通过识别导入基金 ${code} 失败`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (added.length > 0) {
|
||||||
|
setFunds(prev => {
|
||||||
|
const merged = [...prev, ...added];
|
||||||
|
const deduped = Array.from(new Map(merged.map(f => [f.code, f])).values());
|
||||||
|
storageHelper.setItem('funds', JSON.stringify(deduped));
|
||||||
|
return deduped;
|
||||||
|
});
|
||||||
|
setSuccessModal({ open: true, message: `已导入 ${added.length} 只基金` });
|
||||||
|
} else {
|
||||||
|
setSuccessModal({ open: true, message: '未能导入任何基金,请检查截图清晰度' });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const batchAddFunds = async () => {
|
const batchAddFunds = async () => {
|
||||||
if (selectedFunds.length === 0) return;
|
if (selectedFunds.length === 0) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -3848,7 +4085,9 @@ export default function HomePage() {
|
|||||||
donateOpen ||
|
donateOpen ||
|
||||||
!!fundDeleteConfirm ||
|
!!fundDeleteConfirm ||
|
||||||
updateModalOpen ||
|
updateModalOpen ||
|
||||||
weChatOpen;
|
weChatOpen ||
|
||||||
|
scanModalOpen ||
|
||||||
|
scanConfirmModalOpen;
|
||||||
|
|
||||||
if (isAnyModalOpen) {
|
if (isAnyModalOpen) {
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
@@ -3874,8 +4113,11 @@ export default function HomePage() {
|
|||||||
tradeModal.open,
|
tradeModal.open,
|
||||||
clearConfirm,
|
clearConfirm,
|
||||||
donateOpen,
|
donateOpen,
|
||||||
|
fundDeleteConfirm,
|
||||||
updateModalOpen,
|
updateModalOpen,
|
||||||
weChatOpen
|
weChatOpen,
|
||||||
|
scanModalOpen,
|
||||||
|
scanConfirmModalOpen
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -3956,7 +4198,7 @@ export default function HomePage() {
|
|||||||
<path d="M16.5 16.5L21 21" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
<path d="M16.5 16.5L21 21" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<div className="input navbar-input-shell">
|
<div className="input navbar-input-shell" style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className="navbar-input-field"
|
className="navbar-input-field"
|
||||||
@@ -3971,7 +4213,11 @@ export default function HomePage() {
|
|||||||
// 延迟关闭,以允许点击搜索结果
|
// 延迟关闭,以允许点击搜索结果
|
||||||
setTimeout(() => setIsSearchFocused(false), 200);
|
setTimeout(() => setIsSearchFocused(false), 200);
|
||||||
}}
|
}}
|
||||||
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
|
<div style={{ marginRight: 8, display: 'flex', alignItems: 'center' }}>
|
||||||
|
<ScanButton onClick={handleScanClick} disabled={isScanning} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isSearching && <div className="search-spinner" />}
|
{isSearching && <div className="search-spinner" />}
|
||||||
</div>
|
</div>
|
||||||
@@ -3985,7 +4231,9 @@ export default function HomePage() {
|
|||||||
opacity: refreshing ? 0.6 : 1,
|
opacity: refreshing ? 0.6 : 1,
|
||||||
display: (isSearchFocused || selectedFunds.length > 0) ? 'inline-flex' : undefined,
|
display: (isSearchFocused || selectedFunds.length > 0) ? 'inline-flex' : undefined,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center'
|
justifyContent: 'center',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
minWidth: 'fit-content'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{loading ? '添加中…' : '添加'}
|
{loading ? '添加中…' : '添加'}
|
||||||
@@ -5157,6 +5405,128 @@ export default function HomePage() {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{scanModalOpen && (
|
||||||
|
<motion.div
|
||||||
|
className="modal-overlay"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="选择持仓截图"
|
||||||
|
onClick={() => setScanModalOpen(false)}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
className="glass card modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ width: 420, maxWidth: '90vw' }}
|
||||||
|
>
|
||||||
|
<div className="title" style={{ marginBottom: 12 }}>
|
||||||
|
<span>选择持仓截图</span>
|
||||||
|
</div>
|
||||||
|
<div className="muted" style={{ fontSize: 13, lineHeight: 1.6, marginBottom: 12 }}>
|
||||||
|
从相册选择一张或多张持仓截图,系统将自动识别其中的基金代码(6位数字),并支持批量导入。
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
|
<button className="button secondary" onClick={() => setScanModalOpen(false)}>取消</button>
|
||||||
|
<button className="button" onClick={handleScanPick} disabled={isScanning}>
|
||||||
|
{isScanning ? '处理中…' : '选择图片'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{scanConfirmModalOpen && (
|
||||||
|
<motion.div
|
||||||
|
className="modal-overlay"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="确认导入基金"
|
||||||
|
onClick={() => setScanConfirmModalOpen(false)}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
className="glass card modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ width: 460, maxWidth: '90vw' }}
|
||||||
|
>
|
||||||
|
<div className="title" style={{ marginBottom: 12, justifyContent: 'space-between' }}>
|
||||||
|
<span>确认导入基金</span>
|
||||||
|
<button className="icon-button" onClick={() => setScanConfirmModalOpen(false)} style={{ border: 'none', background: 'transparent' }}>
|
||||||
|
<CloseIcon width="20" height="20" />
|
||||||
|
</button>
|
||||||
|
</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' }}>
|
||||||
|
{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 ? '未找到基金' : '未知基金');
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.code}
|
||||||
|
className={`search-item ${isSelected ? 'selected' : ''} ${isAlreadyAdded ? 'added' : ''}`}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
if (isDisabled) return;
|
||||||
|
toggleScannedCode(item.code);
|
||||||
|
}}
|
||||||
|
style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
|
||||||
|
>
|
||||||
|
<div className="fund-info">
|
||||||
|
<span className="fund-name">{displayName}</span>
|
||||||
|
<span className="fund-code muted">#{item.code}</span>
|
||||||
|
</div>
|
||||||
|
{isAlreadyAdded ? (
|
||||||
|
<span className="added-label">已添加</span>
|
||||||
|
) : isInvalid ? (
|
||||||
|
<span className="added-label">未找到</span>
|
||||||
|
) : (
|
||||||
|
<div className="checkbox">
|
||||||
|
{isSelected && <div className="checked-mark" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 12 }}>
|
||||||
|
<button className="button secondary" onClick={() => setScanConfirmModalOpen(false)}>取消</button>
|
||||||
|
<button className="button" onClick={confirmScanImport} disabled={selectedScannedCodes.size === 0}>确认导入</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleFilesUpload}
|
||||||
|
/>
|
||||||
|
|
||||||
{settingsOpen && (
|
{settingsOpen && (
|
||||||
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="设置" onClick={() => setSettingsOpen(false)}>
|
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="设置" onClick={() => setSettingsOpen(false)}>
|
||||||
<div className="glass card modal" onClick={(e) => e.stopPropagation()}>
|
<div className="glass card modal" onClick={(e) => e.stopPropagation()}>
|
||||||
@@ -5296,6 +5666,57 @@ export default function HomePage() {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isScanning && (
|
||||||
|
<motion.div
|
||||||
|
className="modal-overlay"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="识别进度"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
className="glass card modal"
|
||||||
|
style={{ width: 320, maxWidth: '90vw', textAlign: 'center', padding: '24px' }}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div className="loading-spinner" style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
border: '3px solid var(--muted)',
|
||||||
|
borderTopColor: 'var(--primary)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
margin: '0 auto',
|
||||||
|
animation: 'spin 1s linear infinite'
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<div className="title" style={{ justifyContent: 'center', marginBottom: 8 }}>
|
||||||
|
{scanProgress.stage === 'verify' ? '正在验证基金…' : '正在识别中…'}
|
||||||
|
</div>
|
||||||
|
{scanProgress.total > 0 && (
|
||||||
|
<div className="muted" style={{ marginBottom: 20 }}>
|
||||||
|
{scanProgress.stage === 'verify'
|
||||||
|
? `已验证 ${scanProgress.current} / ${scanProgress.total} 只基金`
|
||||||
|
: `已处理 ${scanProgress.current} / ${scanProgress.total} 张图片`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="button danger"
|
||||||
|
onClick={cancelScan}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
终止识别
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* 登录模态框 */}
|
{/* 登录模态框 */}
|
||||||
{loginModalOpen && (
|
{loginModalOpen && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
95
package-lock.json
generated
95
package-lock.json
generated
@@ -17,7 +17,8 @@
|
|||||||
"next": "^16.1.5",
|
"next": "^16.1.5",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "18.3.1"
|
"react-dom": "18.3.1",
|
||||||
|
"tesseract.js": "^7.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"babel-plugin-react-compiler": "^1.0.0"
|
"babel-plugin-react-compiler": "^1.0.0"
|
||||||
@@ -1242,6 +1243,12 @@
|
|||||||
"baseline-browser-mapping": "dist/cli.js"
|
"baseline-browser-mapping": "dist/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bmp-js": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001766",
|
"version": "1.0.30001766",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
|
||||||
@@ -1323,6 +1330,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/idb-keyval": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/is-url": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -1427,6 +1446,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/opencollective-postinstall": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"opencollective-postinstall": "index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -1496,6 +1544,12 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/regenerator-runtime": {
|
||||||
|
"version": "0.13.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||||
|
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
@@ -1595,6 +1649,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tesseract.js": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"bmp-js": "^0.1.0",
|
||||||
|
"idb-keyval": "^6.2.0",
|
||||||
|
"is-url": "^1.2.4",
|
||||||
|
"node-fetch": "^2.6.9",
|
||||||
|
"opencollective-postinstall": "^2.0.3",
|
||||||
|
"regenerator-runtime": "^0.13.3",
|
||||||
|
"tesseract.js-core": "^7.0.0",
|
||||||
|
"wasm-feature-detect": "^1.8.0",
|
||||||
|
"zlibjs": "^0.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tesseract.js-core": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/tr46": {
|
"node_modules/tr46": {
|
||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz",
|
||||||
@@ -1613,6 +1691,12 @@
|
|||||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/wasm-feature-detect": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
@@ -1649,6 +1733,15 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zlibjs": {
|
||||||
|
"version": "0.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
|
||||||
|
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
"next": "^16.1.5",
|
"next": "^16.1.5",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "18.3.1"
|
"react-dom": "18.3.1",
|
||||||
|
"tesseract.js": "^7.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user