diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 0000000..5cac8dc
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,5 @@
+{
+ "extends": [
+ "next/core-web-vitals"
+ ]
+}
\ No newline at end of file
diff --git a/app/components/AddFundToGroupModal.jsx b/app/components/AddFundToGroupModal.jsx
new file mode 100644
index 0000000..87dfb8f
--- /dev/null
+++ b/app/components/AddFundToGroupModal.jsx
@@ -0,0 +1,90 @@
+'use client';
+
+import { useState } from 'react';
+import { motion } from 'framer-motion';
+import { CloseIcon, PlusIcon } from './Icons';
+
+export default function AddFundToGroupModal({ allFunds, currentGroupCodes, onClose, onAdd }) {
+ const [selected, setSelected] = useState(new Set());
+
+ const availableFunds = (allFunds || []).filter(f => !(currentGroupCodes || []).includes(f.code));
+
+ const toggleSelect = (code) => {
+ setSelected(prev => {
+ const next = new Set(prev);
+ if (next.has(code)) next.delete(code);
+ else next.add(code);
+ return next;
+ });
+ };
+
+ return (
+
+ e.stopPropagation()}
+ >
+
+
+
+ {availableFunds.length === 0 ? (
+
+ ) : (
+
+ {availableFunds.map((fund) => (
+
toggleSelect(fund.code)}
+ style={{ cursor: 'pointer' }}
+ >
+
+ {selected.has(fund.code) &&
}
+
+
+
{fund.name}
+
#{fund.code}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/AddResultModal.jsx b/app/components/AddResultModal.jsx
new file mode 100644
index 0000000..491baa2
--- /dev/null
+++ b/app/components/AddResultModal.jsx
@@ -0,0 +1,53 @@
+'use client';
+
+import { motion } from 'framer-motion';
+import { CloseIcon, SettingsIcon } from './Icons';
+
+export default function AddResultModal({ failures, onClose }) {
+ return (
+
+ e.stopPropagation()}
+ >
+
+
+ 未获取到估值数据的基金如下:
+
+
+ {failures.map((it, idx) => (
+
+
{it.name || '未知名称'}
+
+ #{it.code}
+
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/CloudConfigModal.jsx b/app/components/CloudConfigModal.jsx
new file mode 100644
index 0000000..d62781a
--- /dev/null
+++ b/app/components/CloudConfigModal.jsx
@@ -0,0 +1,54 @@
+'use client';
+
+import { motion } from 'framer-motion';
+import { CloseIcon, CloudIcon } from './Icons';
+
+export default function CloudConfigModal({ onConfirm, onCancel, type = 'empty' }) {
+ const isConflict = type === 'conflict';
+ return (
+
+ e.stopPropagation()}
+ >
+
+
+
+ {isConflict ? '发现配置冲突' : '云端暂无配置'}
+
+ {!isConflict && (
+
+ )}
+
+
+ {isConflict
+ ? '检测到本地配置与云端不一致,请选择操作:'
+ : '是否将本地配置同步到云端?'}
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/ConfirmModal.jsx b/app/components/ConfirmModal.jsx
new file mode 100644
index 0000000..ea32584
--- /dev/null
+++ b/app/components/ConfirmModal.jsx
@@ -0,0 +1,43 @@
+'use client';
+
+import { motion } from 'framer-motion';
+import { TrashIcon } from './Icons';
+
+export default function ConfirmModal({ title, message, onConfirm, onCancel, confirmText = "确定删除" }) {
+ return (
+ {
+ e.stopPropagation();
+ onCancel();
+ }}
+ initial={{ opacity: 0 }}
+ animate={{ opacity: 1 }}
+ exit={{ opacity: 0 }}
+ style={{ zIndex: 10002 }}
+ >
+ e.stopPropagation()}
+ >
+
+
+ {title}
+
+
+ {message}
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/DonateModal.jsx b/app/components/DonateModal.jsx
new file mode 100644
index 0000000..1a41e40
--- /dev/null
+++ b/app/components/DonateModal.jsx
@@ -0,0 +1,37 @@
+'use client';
+
+import { motion } from 'framer-motion';
+import { CloseIcon } from './Icons';
+import { DonateTabs } from './Common';
+
+export default function DonateModal({ onClose }) {
+ return (
+
+
e.stopPropagation()}
+ >
+
+
+
+
+
+
+
+ 感谢您的支持!您的鼓励是我持续维护和更新的动力。
+
+
+
+ );
+}
diff --git a/app/components/FeedbackModal.jsx b/app/components/FeedbackModal.jsx
new file mode 100644
index 0000000..2290d20
--- /dev/null
+++ b/app/components/FeedbackModal.jsx
@@ -0,0 +1,148 @@
+'use client';
+
+import { useState } from 'react';
+import { motion } from 'framer-motion';
+import { CloseIcon, SettingsIcon } from './Icons';
+import { submitFeedback } from '../api/fund';
+
+export default function FeedbackModal({ onClose, user, onOpenWeChat }) {
+ const [submitting, setSubmitting] = useState(false);
+ const [succeeded, setSucceeded] = useState(false);
+ const [error, setError] = useState("");
+
+ const onSubmit = async (e) => {
+ e.preventDefault();
+ setSubmitting(true);
+ setError("");
+
+ const formData = new FormData(e.target);
+ const nickname = formData.get("nickname")?.trim();
+ if (!nickname) {
+ formData.set("nickname", "匿名");
+ }
+
+ // Web3Forms Access Key
+ formData.append("access_key", process.env.NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY || '');
+ formData.append("subject", "基估宝 - 用户反馈");
+
+ try {
+ const data = await submitFeedback(formData);
+ if (data.success) {
+ setSucceeded(true);
+ } else {
+ setError(data.message || "提交失败,请稍后再试");
+ }
+ } catch (err) {
+ setError("网络错误,请检查您的连接");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+ e.stopPropagation()}
+ >
+
+
+ {succeeded ? (
+
+
🎉
+
感谢您的反馈!
+
我们已收到您的建议,会尽快查看。
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/app/components/GroupManageModal.jsx b/app/components/GroupManageModal.jsx
new file mode 100644
index 0000000..048d60e
--- /dev/null
+++ b/app/components/GroupManageModal.jsx
@@ -0,0 +1,195 @@
+'use client';
+
+import { useState } from 'react';
+import { AnimatePresence, motion, Reorder } from 'framer-motion';
+import ConfirmModal from './ConfirmModal';
+import { CloseIcon, DragIcon, PlusIcon, SettingsIcon, TrashIcon } from './Icons';
+
+export default function GroupManageModal({ groups, onClose, onSave }) {
+ const [items, setItems] = useState(groups);
+ const [deleteConfirm, setDeleteConfirm] = useState(null); // { id, name }
+
+ const handleReorder = (newOrder) => {
+ setItems(newOrder);
+ };
+
+ const handleRename = (id, newName) => {
+ const truncatedName = (newName || '').slice(0, 8);
+ setItems(prev => prev.map(item => item.id === id ? { ...item, name: truncatedName } : item));
+ };
+
+ const handleDeleteClick = (id, name) => {
+ const itemToDelete = items.find(it => it.id === id);
+ const isNew = !groups.find(g => g.id === id);
+ const isEmpty = itemToDelete && (!itemToDelete.codes || itemToDelete.codes.length === 0);
+
+ if (isNew || isEmpty) {
+ setItems(prev => prev.filter(item => item.id !== id));
+ } else {
+ setDeleteConfirm({ id, name });
+ }
+ };
+
+ const handleConfirmDelete = () => {
+ if (deleteConfirm) {
+ setItems(prev => prev.filter(item => item.id !== deleteConfirm.id));
+ setDeleteConfirm(null);
+ }
+ };
+
+ const handleAddRow = () => {
+ const newGroup = {
+ id: `group_${Date.now()}`,
+ name: '',
+ codes: []
+ };
+ setItems(prev => [...prev, newGroup]);
+ };
+
+ const handleConfirm = () => {
+ const hasEmpty = items.some(it => !it.name.trim());
+ if (hasEmpty) return;
+ onSave(items);
+ onClose();
+ };
+
+ const isAllValid = items.every(it => it.name.trim() !== '');
+
+ return (
+
+ e.stopPropagation()}
+ >
+
+
+
+ {items.length === 0 ? (
+
+ ) : (
+
+
+ {items.map((item) => (
+
+
+
+
+ handleRename(item.id, e.target.value)}
+ placeholder="请输入分组名称..."
+ style={{
+ flex: 1,
+ height: '36px',
+ background: 'rgba(0,0,0,0.2)',
+ border: !item.name.trim() ? '1px solid var(--danger)' : 'none'
+ }}
+ />
+
+
+ ))}
+
+
+ )}
+
+
+
+
+ {!isAllValid && (
+
+ 所有分组名称均不能为空
+
+ )}
+
+
+
+
+
+ {deleteConfirm && (
+ setDeleteConfirm(null)}
+ />
+ )}
+
+
+ );
+}
diff --git a/app/components/GroupModal.jsx b/app/components/GroupModal.jsx
new file mode 100644
index 0000000..6fa9f9b
--- /dev/null
+++ b/app/components/GroupModal.jsx
@@ -0,0 +1,61 @@
+'use client';
+
+import { useState } from 'react';
+import { motion } from 'framer-motion';
+import { CloseIcon, PlusIcon } from './Icons';
+
+export default function GroupModal({ onClose, onConfirm }) {
+ const [name, setName] = useState('');
+ return (
+
+ e.stopPropagation()}
+ >
+
+
+
+ {
+ const v = e.target.value || '';
+ // 限制最多 8 个字符(兼容中英文),超出部分自动截断
+ setName(v.slice(0, 8));
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && name.trim()) onConfirm(name.trim());
+ }}
+ />
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/HoldingActionModal.jsx b/app/components/HoldingActionModal.jsx
new file mode 100644
index 0000000..68086f1
--- /dev/null
+++ b/app/components/HoldingActionModal.jsx
@@ -0,0 +1,68 @@
+'use client';
+
+import { motion } from 'framer-motion';
+import { CloseIcon, SettingsIcon } from './Icons';
+
+export default function HoldingActionModal({ fund, onClose, onAction }) {
+ return (
+
+ e.stopPropagation()}
+ style={{ maxWidth: '320px' }}
+ >
+
+
+
+
{fund?.name}
+
#{fund?.code}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/HoldingEditModal.jsx b/app/components/HoldingEditModal.jsx
new file mode 100644
index 0000000..45d6af0
--- /dev/null
+++ b/app/components/HoldingEditModal.jsx
@@ -0,0 +1,241 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { motion } from 'framer-motion';
+import { CloseIcon, SettingsIcon } from './Icons';
+
+export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
+ const [mode, setMode] = useState('amount'); // 'amount' | 'share'
+
+ const dwjz = fund?.dwjz || fund?.gsz || 0;
+
+ const [share, setShare] = useState('');
+ const [cost, setCost] = useState('');
+ const [amount, setAmount] = useState('');
+ const [profit, setProfit] = useState('');
+
+ useEffect(() => {
+ if (holding) {
+ const s = holding.share || 0;
+ const c = holding.cost || 0;
+ setShare(String(s));
+ setCost(String(c));
+
+ if (dwjz > 0) {
+ const a = s * dwjz;
+ const p = (dwjz - c) * s;
+ setAmount(a.toFixed(2));
+ setProfit(p.toFixed(2));
+ }
+ }
+ }, [holding, fund]);
+
+ const handleModeChange = (newMode) => {
+ if (newMode === mode) return;
+ setMode(newMode);
+
+ if (newMode === 'share') {
+ if (amount && dwjz > 0) {
+ const a = parseFloat(amount);
+ const p = parseFloat(profit || 0);
+ const s = a / dwjz;
+ const principal = a - p;
+ const c = s > 0 ? principal / s : 0;
+
+ setShare(s.toFixed(2));
+ setCost(c.toFixed(4));
+ }
+ } else {
+ if (share && dwjz > 0) {
+ const s = parseFloat(share);
+ const c = parseFloat(cost || 0);
+ const a = s * dwjz;
+ const p = (dwjz - c) * s;
+
+ setAmount(a.toFixed(2));
+ setProfit(p.toFixed(2));
+ }
+ }
+ };
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+
+ let finalShare = 0;
+ let finalCost = 0;
+
+ if (mode === 'share') {
+ if (!share || !cost) return;
+ finalShare = Number(Number(share).toFixed(2));
+ finalCost = Number(cost);
+ } else {
+ if (!amount || !dwjz) return;
+ const a = Number(amount);
+ const p = Number(profit || 0);
+ const rawShare = a / dwjz;
+ finalShare = Number(rawShare.toFixed(2));
+ const principal = a - p;
+ finalCost = finalShare > 0 ? principal / finalShare : 0;
+ }
+
+ onSave({
+ share: finalShare,
+ cost: finalCost
+ });
+ onClose();
+ };
+
+ const isValid = mode === 'share'
+ ? (share && cost && !isNaN(share) && !isNaN(cost))
+ : (amount && !isNaN(amount) && (!profit || !isNaN(profit)) && dwjz > 0);
+
+ return (
+
+ e.stopPropagation()}
+ style={{ maxWidth: '400px' }}
+ >
+
+
+
+
{fund?.name}
+
+
#{fund?.code}
+
+ 最新净值:{dwjz}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/LoginModal.jsx b/app/components/LoginModal.jsx
new file mode 100644
index 0000000..dbf9e3c
--- /dev/null
+++ b/app/components/LoginModal.jsx
@@ -0,0 +1,97 @@
+'use client';
+
+import { MailIcon } from './Icons';
+
+export default function LoginModal({
+ onClose,
+ loginEmail,
+ setLoginEmail,
+ loginOtp,
+ setLoginOtp,
+ loginLoading,
+ loginError,
+ loginSuccess,
+ handleSendOtp,
+ handleVerifyEmailOtp
+}) {
+ return (
+
+
e.stopPropagation()}>
+
+
+ 邮箱登录
+ 使用邮箱验证登录
+
+
+
+
+
+ );
+}
diff --git a/app/components/ScanImportConfirmModal.jsx b/app/components/ScanImportConfirmModal.jsx
new file mode 100644
index 0000000..2f027e5
--- /dev/null
+++ b/app/components/ScanImportConfirmModal.jsx
@@ -0,0 +1,86 @@
+'use client';
+
+import { motion } from 'framer-motion';
+import { CloseIcon } from './Icons';
+
+export default function ScanImportConfirmModal({
+ scannedFunds,
+ selectedScannedCodes,
+ onClose,
+ onToggle,
+ onConfirm
+}) {
+ return (
+
+ e.stopPropagation()}
+ style={{ width: 460, maxWidth: '90vw' }}
+ >
+
+ 确认导入基金
+
+
+ {scannedFunds.length === 0 ? (
+
+ 未识别到有效的基金代码,请尝试更清晰的截图或手动搜索。
+
+ ) : (
+
+ {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 (
+
e.preventDefault()}
+ onClick={() => {
+ if (isDisabled) return;
+ onToggle(item.code);
+ }}
+ style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
+ >
+
+ {displayName}
+ #{item.code}
+
+ {isAlreadyAdded ? (
+
已添加
+ ) : isInvalid ? (
+
未找到
+ ) : (
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/ScanImportProgressModal.jsx b/app/components/ScanImportProgressModal.jsx
new file mode 100644
index 0000000..67aceea
--- /dev/null
+++ b/app/components/ScanImportProgressModal.jsx
@@ -0,0 +1,48 @@
+'use client';
+
+import { motion } from 'framer-motion';
+
+export default function ScanImportProgressModal({ scanImportProgress }) {
+ return (
+
+
+
+
+ 正在导入基金…
+
+ {scanImportProgress.total > 0 && (
+
+ 进度 {scanImportProgress.current} / {scanImportProgress.total}
+
+ )}
+
+ 成功 {scanImportProgress.success},失败 {scanImportProgress.failed}
+
+
+
+ );
+}
diff --git a/app/components/ScanPickModal.jsx b/app/components/ScanPickModal.jsx
new file mode 100644
index 0000000..0dda62a
--- /dev/null
+++ b/app/components/ScanPickModal.jsx
@@ -0,0 +1,40 @@
+'use client';
+
+import { motion } from 'framer-motion';
+
+export default function ScanPickModal({ onClose, onPick, isScanning }) {
+ return (
+
+ e.stopPropagation()}
+ style={{ width: 420, maxWidth: '90vw' }}
+ >
+
+ 选择持仓截图
+
+
+ 从相册选择一张或多张持仓截图,系统将自动识别其中的基金代码(6位数字),并支持批量导入。
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/ScanProgressModal.jsx b/app/components/ScanProgressModal.jsx
new file mode 100644
index 0000000..037f4e9
--- /dev/null
+++ b/app/components/ScanProgressModal.jsx
@@ -0,0 +1,54 @@
+'use client';
+
+import { motion } from 'framer-motion';
+
+export default function ScanProgressModal({ scanProgress, onCancel }) {
+ return (
+
+
+
+
+ {scanProgress.stage === 'verify' ? '正在验证基金…' : '正在识别中…'}
+
+ {scanProgress.total > 0 && (
+
+ {scanProgress.stage === 'verify'
+ ? `已验证 ${scanProgress.current} / ${scanProgress.total} 只基金`
+ : `已处理 ${scanProgress.current} / ${scanProgress.total} 张图片`}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/components/SettingsModal.jsx b/app/components/SettingsModal.jsx
new file mode 100644
index 0000000..a149e91
--- /dev/null
+++ b/app/components/SettingsModal.jsx
@@ -0,0 +1,84 @@
+'use client';
+
+import { SettingsIcon } from './Icons';
+
+export default function SettingsModal({
+ onClose,
+ tempSeconds,
+ setTempSeconds,
+ saveSettings,
+ exportLocalData,
+ importFileRef,
+ handleImportFileChange,
+ importMsg
+}) {
+ return (
+
+
e.stopPropagation()}>
+
+
+ 设置
+ 配置刷新频率
+
+
+
+
刷新频率
+
+ {[10, 30, 60, 120, 300].map((s) => (
+
+ ))}
+
+
setTempSeconds(Number(e.target.value))}
+ placeholder="自定义秒数"
+ />
+ {tempSeconds < 10 && (
+
+ 最小 10 秒
+
+ )}
+
+
+
+
数据导出
+
+
+
+
数据导入
+
+
+
+
+ {importMsg && (
+
+ {importMsg}
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/SuccessModal.jsx b/app/components/SuccessModal.jsx
new file mode 100644
index 0000000..4de9eb7
--- /dev/null
+++ b/app/components/SuccessModal.jsx
@@ -0,0 +1,35 @@
+'use client';
+
+import { motion } from 'framer-motion';
+
+export default function SuccessModal({ message, onClose }) {
+ return (
+
+ e.stopPropagation()}
+ >
+
+
🎉
+
{message}
+
操作已完成,您可以继续使用。
+
+
+
+
+ );
+}
diff --git a/app/components/TradeModal.jsx b/app/components/TradeModal.jsx
new file mode 100644
index 0000000..78c5c56
--- /dev/null
+++ b/app/components/TradeModal.jsx
@@ -0,0 +1,697 @@
+'use client';
+
+import { useEffect, useMemo, useState } from 'react';
+import { AnimatePresence, motion } from 'framer-motion';
+import dayjs from 'dayjs';
+import utc from 'dayjs/plugin/utc';
+import timezone from 'dayjs/plugin/timezone';
+import { fetchSmartFundNetValue } from '../api/fund';
+import { DatePicker, NumericInput } from './Common';
+import ConfirmModal from './ConfirmModal';
+import { CloseIcon } from './Icons';
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+
+const DEFAULT_TZ = 'Asia/Shanghai';
+const getBrowserTimeZone = () => {
+ if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ return tz || DEFAULT_TZ;
+ }
+ return DEFAULT_TZ;
+};
+const TZ = getBrowserTimeZone();
+dayjs.tz.setDefault(TZ);
+const nowInTz = () => dayjs().tz(TZ);
+const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
+const formatDate = (input) => toTz(input).format('YYYY-MM-DD');
+
+export default function TradeModal({ type, fund, holding, onClose, onConfirm, pendingTrades = [], onDeletePending }) {
+ const isBuy = type === 'buy';
+ const [share, setShare] = useState('');
+ const [amount, setAmount] = useState('');
+ const [feeRate, setFeeRate] = useState('0');
+ const [date, setDate] = useState(() => {
+ return formatDate();
+ });
+ const [isAfter3pm, setIsAfter3pm] = useState(nowInTz().hour() >= 15);
+ const [calcShare, setCalcShare] = useState(null);
+
+ const currentPendingTrades = useMemo(() => {
+ return pendingTrades.filter(t => t.fundCode === fund?.code);
+ }, [pendingTrades, fund]);
+
+ const pendingSellShare = useMemo(() => {
+ return currentPendingTrades
+ .filter(t => t.type === 'sell')
+ .reduce((acc, curr) => acc + (Number(curr.share) || 0), 0);
+ }, [currentPendingTrades]);
+
+ const availableShare = holding ? Math.max(0, holding.share - pendingSellShare) : 0;
+
+ const [showPendingList, setShowPendingList] = useState(false);
+
+ useEffect(() => {
+ if (showPendingList && currentPendingTrades.length === 0) {
+ setShowPendingList(false);
+ }
+ }, [showPendingList, currentPendingTrades]);
+
+ const getEstimatePrice = () => fund?.estPricedCoverage > 0.05 ? fund?.estGsz : (typeof fund?.gsz === 'number' ? fund?.gsz : Number(fund?.dwjz));
+ const [price, setPrice] = useState(getEstimatePrice());
+ const [loadingPrice, setLoadingPrice] = useState(false);
+ const [actualDate, setActualDate] = useState(null);
+
+ useEffect(() => {
+ if (date && fund?.code) {
+ setLoadingPrice(true);
+ setActualDate(null);
+
+ let queryDate = date;
+ if (isAfter3pm) {
+ queryDate = toTz(date).add(1, 'day').format('YYYY-MM-DD');
+ }
+
+ fetchSmartFundNetValue(fund.code, queryDate).then(result => {
+ if (result) {
+ setPrice(result.value);
+ setActualDate(result.date);
+ } else {
+ setPrice(0);
+ setActualDate(null);
+ }
+ }).finally(() => setLoadingPrice(false));
+ }
+ }, [date, isAfter3pm, isBuy, fund]);
+
+ const [feeMode, setFeeMode] = useState('rate');
+ const [feeValue, setFeeValue] = useState('0');
+ const [showConfirm, setShowConfirm] = useState(false);
+
+ const sellShare = parseFloat(share) || 0;
+ const sellPrice = parseFloat(price) || 0;
+ const sellAmount = sellShare * sellPrice;
+
+ let sellFee = 0;
+ if (feeMode === 'rate') {
+ const rate = parseFloat(feeValue) || 0;
+ sellFee = sellAmount * (rate / 100);
+ } else {
+ sellFee = parseFloat(feeValue) || 0;
+ }
+
+ const estimatedReturn = sellAmount - sellFee;
+
+ useEffect(() => {
+ if (!isBuy) return;
+ const a = parseFloat(amount);
+ const f = parseFloat(feeRate);
+ const p = parseFloat(price);
+ if (a > 0 && !isNaN(f)) {
+ if (p > 0) {
+ const netAmount = a / (1 + f / 100);
+ const s = netAmount / p;
+ setCalcShare(s.toFixed(2));
+ } else {
+ setCalcShare('待确认');
+ }
+ } else {
+ setCalcShare(null);
+ }
+ }, [isBuy, amount, feeRate, price]);
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ if (isBuy) {
+ if (!amount || !feeRate || !date || calcShare === null) return;
+ setShowConfirm(true);
+ } else {
+ if (!share || !date) return;
+ setShowConfirm(true);
+ }
+ };
+
+ const handleFinalConfirm = () => {
+ if (isBuy) {
+ onConfirm({ share: calcShare === '待确认' ? null : Number(calcShare), price: Number(price), totalCost: Number(amount), date, isAfter3pm, feeRate: Number(feeRate) });
+ return;
+ }
+ onConfirm({ share: Number(share), price: Number(price), date: actualDate || date, isAfter3pm, feeMode, feeValue });
+ };
+
+ const isValid = isBuy
+ ? (!!amount && !!feeRate && !!date && calcShare !== null)
+ : (!!share && !!date);
+
+ const handleSetShareFraction = (fraction) => {
+ if (availableShare > 0) {
+ setShare((availableShare * fraction).toFixed(2));
+ }
+ };
+
+ const [revokeTrade, setRevokeTrade] = useState(null);
+
+ return (
+
+ e.stopPropagation()}
+ style={{ maxWidth: '420px' }}
+ >
+
+
+ {isBuy ? '📥' : '📤'}
+ {showPendingList ? '待交易队列' : (showConfirm ? (isBuy ? '买入确认' : '卖出确认') : (isBuy ? '加仓' : '减仓'))}
+
+
+
+
+ {!showPendingList && !showConfirm && currentPendingTrades.length > 0 && (
+ setShowPendingList(true)}
+ >
+ ⚠️ 当前有 {currentPendingTrades.length} 笔待处理交易
+ 查看详情 >
+
+ )}
+
+ {showPendingList ? (
+
+
+
+
+
+ {currentPendingTrades.map((trade, idx) => (
+
+
+
+ {trade.type === 'buy' ? '买入' : '卖出'}
+
+ {trade.date} {trade.isAfter3pm ? '(15:00后)' : ''}
+
+
+ 份额/金额
+ {trade.share ? `${trade.share} 份` : `¥${trade.amount}`}
+
+
+
状态
+
+ 等待净值更新...
+
+
+
+
+ ))}
+
+
+ ) : (
+ <>
+ {!showConfirm && (
+
+
{fund?.name}
+
#{fund?.code}
+
+ )}
+
+ {showConfirm ? (
+ isBuy ? (
+
+
+
+ 基金名称
+ {fund?.name}
+
+
+ 买入金额
+ ¥{Number(amount).toFixed(2)}
+
+
+ 买入费率
+ {Number(feeRate).toFixed(2)}%
+
+
+ 参考净值
+ {loadingPrice ? '查询中...' : (price ? `¥${Number(price).toFixed(4)}` : '待查询 (加入队列)')}
+
+
+ 预估份额
+ {calcShare === '待确认' ? '待确认' : `${Number(calcShare).toFixed(2)} 份`}
+
+
+ 买入日期
+ {date}
+
+
+ 交易时段
+ {isAfter3pm ? '15:00后' : '15:00前'}
+
+
+ {loadingPrice ? '正在获取该日净值...' : `*基于${price === getEstimatePrice() ? '当前净值/估值' : '当日净值'}测算`}
+
+
+
+ {holding && calcShare !== '待确认' && (
+
+
持仓变化预览
+
+
+
持有份额
+
+ {holding.share.toFixed(2)}
+ →
+ {(holding.share + Number(calcShare)).toFixed(2)}
+
+
+ {price ? (
+
+
持有市值 (估)
+
+ ¥{(holding.share * Number(price)).toFixed(2)}
+ →
+ ¥{((holding.share + Number(calcShare)) * Number(price)).toFixed(2)}
+
+
+ ) : null}
+
+
+ )}
+
+
+
+
+
+
+ ) : (
+
+
+
+ 基金名称
+ {fund?.name}
+
+
+ 卖出份额
+ {sellShare.toFixed(2)} 份
+
+
+ 预估卖出单价
+ {loadingPrice ? '查询中...' : (price ? `¥${sellPrice.toFixed(4)}` : '待查询 (加入队列)')}
+
+
+ 卖出费率/费用
+ {feeMode === 'rate' ? `${feeValue}%` : `¥${feeValue}`}
+
+
+ 预估手续费
+ {price ? `¥${sellFee.toFixed(2)}` : '待计算'}
+
+
+ 卖出日期
+ {date}
+
+
+ 预计回款
+ {loadingPrice ? '计算中...' : (price ? `¥${estimatedReturn.toFixed(2)}` : '待计算')}
+
+
+ {loadingPrice ? '正在获取该日净值...' : `*基于${price === getEstimatePrice() ? '当前净值/估值' : '当日净值'}测算`}
+
+
+
+ {holding && (
+
+
持仓变化预览
+
+
+
持有份额
+
+ {holding.share.toFixed(2)}
+ →
+ {(holding.share - sellShare).toFixed(2)}
+
+
+ {price ? (
+
+
持有市值 (估)
+
+ ¥{(holding.share * sellPrice).toFixed(2)}
+ →
+ ¥{((holding.share - sellShare) * sellPrice).toFixed(2)}
+
+
+ ) : null}
+
+
+ )}
+
+
+
+
+
+
+ )
+ ) : (
+
+ )}
+ >
+ )}
+
+
+ {revokeTrade && (
+ {
+ onDeletePending?.(revokeTrade.id);
+ setRevokeTrade(null);
+ }}
+ onCancel={() => setRevokeTrade(null)}
+ confirmText="确认撤销"
+ />
+ )}
+
+
+ );
+}
diff --git a/app/components/UpdatePromptModal.jsx b/app/components/UpdatePromptModal.jsx
new file mode 100644
index 0000000..5865367
--- /dev/null
+++ b/app/components/UpdatePromptModal.jsx
@@ -0,0 +1,72 @@
+'use client';
+
+import { motion } from 'framer-motion';
+import { UpdateIcon } from './Icons';
+
+export default function UpdatePromptModal({ updateContent, onClose, onRefresh }) {
+ return (
+
+ e.stopPropagation()}
+ >
+
+
+ 更新提示
+
+
+
+ 检测到新版本,是否刷新浏览器以更新?
+
+ 更新内容如下:
+
+ {updateContent && (
+
+ {updateContent}
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/WeChatModal.jsx b/app/components/WeChatModal.jsx
new file mode 100644
index 0000000..86f7184
--- /dev/null
+++ b/app/components/WeChatModal.jsx
@@ -0,0 +1,45 @@
+'use client';
+
+import { motion } from 'framer-motion';
+import { CloseIcon } from './Icons';
+import weChatGroupImg from '../assets/weChatGroup.jpg';
+
+export default function WeChatModal({ onClose }) {
+ return (
+
+ e.stopPropagation()}
+ style={{ maxWidth: '360px', padding: '24px' }}
+ >
+
+
+

+
+
+ 扫码加入群聊,获取最新更新与交流
+
+
+
+ );
+}
diff --git a/app/page.jsx b/app/page.jsx
index 0e5cd3a..ff78066 100644
--- a/app/page.jsx
+++ b/app/page.jsx
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useRef, useState, useMemo, useLayoutEffect, useCallback } from 'react';
-import { motion, AnimatePresence, Reorder } from 'framer-motion';
+import { motion, AnimatePresence } from 'framer-motion';
import { createWorker } from 'tesseract.js';
import { createAvatar } from '@dicebear/core';
import { glass } from '@dicebear/collection';
@@ -9,12 +9,31 @@ import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import Announcement from "./components/Announcement";
-import { DatePicker, DonateTabs, NumericInput, Stat } from "./components/Common";
+import { Stat } from "./components/Common";
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, CameraIcon } from "./components/Icons";
-import weChatGroupImg from "./assets/weChatGroup.jpg";
+import { ChevronIcon, CloseIcon, ExitIcon, EyeIcon, EyeOffIcon, GridIcon, ListIcon, LoginIcon, LogoutIcon, PinIcon, PinOffIcon, PlusIcon, RefreshIcon, SettingsIcon, SortIcon, StarIcon, TrashIcon, UpdateIcon, UserIcon, CameraIcon } from "./components/Icons";
+import AddFundToGroupModal from "./components/AddFundToGroupModal";
+import AddResultModal from "./components/AddResultModal";
+import CloudConfigModal from "./components/CloudConfigModal";
+import ConfirmModal from "./components/ConfirmModal";
+import DonateModal from "./components/DonateModal";
+import FeedbackModal from "./components/FeedbackModal";
+import GroupManageModal from "./components/GroupManageModal";
+import GroupModal from "./components/GroupModal";
+import HoldingEditModal from "./components/HoldingEditModal";
+import HoldingActionModal from "./components/HoldingActionModal";
+import LoginModal from "./components/LoginModal";
+import ScanImportConfirmModal from "./components/ScanImportConfirmModal";
+import ScanImportProgressModal from "./components/ScanImportProgressModal";
+import ScanPickModal from "./components/ScanPickModal";
+import ScanProgressModal from "./components/ScanProgressModal";
+import SettingsModal from "./components/SettingsModal";
+import SuccessModal from "./components/SuccessModal";
+import TradeModal from "./components/TradeModal";
+import UpdatePromptModal from "./components/UpdatePromptModal";
+import WeChatModal from "./components/WeChatModal";
import { supabase, isSupabaseConfigured } from './lib/supabase';
-import { fetchFundData, fetchLatestRelease, fetchShanghaiIndexDate, fetchSmartFundNetValue, searchFunds, submitFeedback } from './api/fund';
+import { fetchFundData, fetchLatestRelease, fetchShanghaiIndexDate, fetchSmartFundNetValue, searchFunds } from './api/fund';
import packageJson from '../package.json';
dayjs.extend(utc);
@@ -34,252 +53,6 @@ const nowInTz = () => dayjs().tz(TZ);
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
const formatDate = (input) => toTz(input).format('YYYY-MM-DD');
-function FeedbackModal({ onClose, user, onOpenWeChat }) {
- const [submitting, setSubmitting] = useState(false);
- const [succeeded, setSucceeded] = useState(false);
- const [error, setError] = useState("");
-
- const onSubmit = async (e) => {
- e.preventDefault();
- setSubmitting(true);
- setError("");
-
- const formData = new FormData(e.target);
- const nickname = formData.get("nickname")?.trim();
- if (!nickname) {
- formData.set("nickname", "匿名");
- }
-
- // Web3Forms Access Key
- formData.append("access_key", process.env.NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY || '');
- formData.append("subject", "基估宝 - 用户反馈");
-
- try {
- const data = await submitFeedback(formData);
- if (data.success) {
- setSucceeded(true);
- } else {
- setError(data.message || "提交失败,请稍后再试");
- }
- } catch (err) {
- setError("网络错误,请检查您的连接");
- } finally {
- setSubmitting(false);
- }
- };
-
- return (
-
- e.stopPropagation()}
- >
-
-
- {succeeded ? (
-
-
🎉
-
感谢您的反馈!
-
我们已收到您的建议,会尽快查看。
-
-
- ) : (
-
- )}
-
-
- );
-}
-
-function WeChatModal({ onClose }) {
- return (
-
- e.stopPropagation()}
- style={{ maxWidth: '360px', padding: '24px' }}
- >
-
-
-

-
-
- 扫码加入群聊,获取最新更新与交流
-
-
-
- );
-}
-
-function HoldingActionModal({ fund, onClose, onAction }) {
- return (
-
- e.stopPropagation()}
- style={{ maxWidth: '320px' }}
- >
-
-
-
-
{fund?.name}
-
#{fund?.code}
-
-
-
-
-
-
-
-
-
-
- );
-}
-
function ScanButton({ onClick, disabled }) {
return (