diff --git a/backend/app/services/financial_sync.py b/backend/app/services/financial_sync.py index 25e1cc2..d8f900b 100644 --- a/backend/app/services/financial_sync.py +++ b/backend/app/services/financial_sync.py @@ -270,10 +270,14 @@ class FinancialScheduler: self._last_sync[table] = datetime.now(timezone.utc).isoformat() return {table: rows} else: - result = sync_all(self._data_dir, self._capset) - now = datetime.now(timezone.utc).isoformat() - for t in result: - self._last_sync[t] = now + # 全部同步: 逐表执行, 每张完成立即更新 last_sync, + # 让前端轮询 /status 能看到进度递增 (而非等全部完成才一次性更新)。 + symbols = _get_symbols(self._data_dir) + result: dict[str, int] = {} + for t in FINANCIAL_TABLES: + result[t] = _sync_table(t, symbols, self._data_dir, self._capset, latest_only=True) + self._last_sync[t] = datetime.now(timezone.utc).isoformat() + _refresh_financials_views(self._data_dir) return result finally: with self._lock: diff --git a/frontend/src/components/financials/StockFinancialDetail.tsx b/frontend/src/components/financials/StockFinancialDetail.tsx index b1c6c6d..4a15b55 100644 --- a/frontend/src/components/financials/StockFinancialDetail.tsx +++ b/frontend/src/components/financials/StockFinancialDetail.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { CalendarDays, TrendingUp, FileText, Wallet, Activity } from 'lucide-react' +import { CalendarDays, TrendingUp, FileText, Wallet, Activity, Sparkles } from 'lucide-react' import { useFinancialMetrics, useFinancialIncome, @@ -112,6 +112,12 @@ function formatValue(v: number | null | undefined, fmt: FmtType): string { export function StockFinancialDetail({ symbol, name }: Props) { const [tab, setTab] = useState('metrics') + // AI 财务分析占位: 功能开发中, 点击提示 + const [showDevToast, setShowDevToast] = useState(false) + const handleAiAnalysis = () => { + setShowDevToast(true) + setTimeout(() => setShowDevToast(false), 2500) + } const metrics = useFinancialMetrics(symbol) const income = useFinancialIncome(symbol) @@ -137,22 +143,32 @@ export function StockFinancialDetail({ symbol, name }: Props) { const latestAnnounce = rows[0]?.announce_date ?? metrics.data?.data?.[0]?.announce_date ?? null return ( -
+
{/* 头部:标的 + 报告期 */}
{name} {symbol}
- {latestPeriod && ( -
- - 报告期 {latestPeriod} - {latestAnnounce && ( - · 披露 {fmtDate(latestAnnounce)} - )} -
- )} +
+ {latestPeriod && ( +
+ + 报告期 {latestPeriod} + {latestAnnounce && ( + · 披露 {fmtDate(latestAnnounce)} + )} +
+ )} + +
{/* 标签页 */} @@ -224,6 +240,13 @@ export function StockFinancialDetail({ symbol, name }: Props) {
)}
+ + {/* AI 分析开发中提示 */} + {showDevToast && ( +
+ ✨ AI 财务分析功能开发中,敬请期待 +
+ )} ) } diff --git a/frontend/src/lib/useFinancials.ts b/frontend/src/lib/useFinancials.ts index 955694c..5758a6b 100644 --- a/frontend/src/lib/useFinancials.ts +++ b/frontend/src/lib/useFinancials.ts @@ -59,6 +59,11 @@ export function useFinancialSync() { const qc = useQueryClient() return useMutation({ mutationFn: (table: string) => api.financialSync(table), + // 点击瞬间立即刷新 status: 让后端 is_syncing=True 马上反映到 UI, + // 避免 mutation 阻塞(全量同步需数分钟)期间界面无变化。 + onMutate: () => { + qc.invalidateQueries({ queryKey: FINANCIAL_QK.status }) + }, onSuccess: () => { qc.invalidateQueries({ queryKey: FINANCIAL_QK.status }) qc.invalidateQueries({ queryKey: ['financials'] }) diff --git a/frontend/src/pages/Financials.tsx b/frontend/src/pages/Financials.tsx index 5bba4e1..9f655e7 100644 --- a/frontend/src/pages/Financials.tsx +++ b/frontend/src/pages/Financials.tsx @@ -1,11 +1,12 @@ -import { useState } from 'react' -import { RefreshCw, Lock, Loader2, X, Search } from 'lucide-react' +import { useState, useRef } from 'react' +import { RefreshCw, Lock, Loader2, X, Search, FileText, Database, Clock, CheckCircle2, Hourglass } from 'lucide-react' import { PageHeader } from '@/components/PageHeader' import { EmptyState } from '@/components/EmptyState' import { useCapabilities } from '@/lib/useSharedQueries' import { useFinancialStatus, useFinancialSync } from '@/lib/useFinancials' import { StockFinancialSearch } from '@/components/financials/StockFinancialSearch' import { StockFinancialDetail } from '@/components/financials/StockFinancialDetail' +import { fmtBigNum } from '@/lib/format' const TABLE_LABELS: Record = { metrics: '核心指标', @@ -14,25 +15,43 @@ const TABLE_LABELS: Record = { cash_flow: '现金流量表', } +const TABLE_ICON: Record = { + metrics: Database, + income: FileText, + balance_sheet: FileText, + cash_flow: FileText, +} + export function Financials() { const { data: caps } = useCapabilities() const hasFinancial = caps?.capabilities?.['financial'] != null const { data: status, isLoading } = useFinancialStatus() const syncMut = useFinancialSync() - // 用服务端 syncing 真值驱动 UI(而非本地状态),刷新后仍正确,且天然防重复点击 - const syncing = status?.syncing ?? false + // 同步状态: 服务端 syncing 真值优先, 兜底本地 mutation pending + const syncing = (status?.syncing ?? false) || syncMut.isPending + // 本次同步开始时间戳(ms): 用于判断每张表的 last_sync 是否属于本次同步 + // (后端每张表完成即更新 last_sync, 前端轮询时对比时间戳得到精确进度) + const syncStartedAtRef = useRef(null) + // 单表同步时记录表名 (null = 全量同步), 用于区分卡片状态 + const syncSingleTableRef = useRef(null) // 选中的个股(模糊搜索结果);null 时显示搜索引导 const [selected, setSelected] = useState<{ symbol: string; name: string } | null>(null) if (!hasFinancial) { return ( <> - - + +
+
+
+ +
+

需要 Expert 套餐

+

+ 财务数据接口仅 Expert 套餐可用。升级后此页自动显示财务数据面板。 +

+
+
) } @@ -40,13 +59,45 @@ export function Financials() { const handleSync = (table: string) => { // 防重复点击:syncing 中不再触发(后端 run_now 也有 is_syncing 兜底) if (syncing) return - syncMut.mutate(table) + // 记录开始时间: 全量同步判断所有 4 张表, 单表同步只判断这一张 + syncStartedAtRef.current = Date.now() + syncSingleTableRef.current = table === 'all' ? null : table + syncMut.mutate(table, { + onSettled: () => { + syncStartedAtRef.current = null + syncSingleTableRef.current = null + }, + }) } const tables = status?.tables ?? {} const available = status?.available ?? false - // 进度:已同步(rows>0)的表数 / 总表数,供同步中提示 - const syncedCount = Object.values(tables).filter(t => (t?.rows ?? 0) > 0).length + const lastSync = status?.last_sync ?? {} + // 本次同步进度: 仅当 syncStartedAt 存在且 syncing 时, 按 last_sync 时间戳判断 + const syncStartedAt = syncStartedAtRef.current + const syncSingleTable = syncSingleTableRef.current + const isFullSync = syncing && syncStartedAt && !syncSingleTable // 全量同步 + const isSingleSync = syncing && syncStartedAt && !!syncSingleTable // 单表同步 + const TABLE_ORDER = ['metrics', 'income', 'balance_sheet', 'cash_flow'] as const + const tableDoneThisRound = (key: string): boolean => { + if (!syncStartedAt || !syncing) return false + // 单表同步: 只判断这一张表是否完成 + if (syncSingleTable && key !== syncSingleTable) return false + const ls = lastSync[key] + if (!ls) return false + return new Date(ls).getTime() >= syncStartedAt + } + // 当前正在同步的表: + // 全量同步 → 第一个未完成的; 单表同步 → 那张表(未完成时) + const currentSyncingTable = syncing && syncStartedAt + ? (syncSingleTable + ? (tableDoneThisRound(syncSingleTable) ? null : syncSingleTable) + : TABLE_ORDER.find(t => !tableDoneThisRound(t)) ?? null) + : null + const syncedCount = TABLE_ORDER.filter(t => tableDoneThisRound(t)).length + // 卡片三态: 仅全量同步时未轮到的表显示"等待"; 单表同步时其他表保持原样 + const isWaitingTable = (key: string): boolean => + !!isFullSync && !tableDoneThisRound(key) && currentSyncingTable !== key return ( <> @@ -54,121 +105,169 @@ export function Financials() { title="财务" subtitle="利润表 / 资负表 / 现金流 / 关键指标 · Expert" right={ -
+
+ {syncing && ( + + + {isFullSync + ? `已同步 ${syncedCount}/4 张表…` + : isSingleSync + ? `同步${TABLE_LABELS[syncSingleTable!] ?? syncSingleTable}…` + : '同步中…'} + + )}
} /> - {syncing && ( -
- - 正在从 TickFlow 拉取财务数据,已同步 {syncedCount}/4 张表… -
- )} - - {/* 同步状态卡片 —— 始终显示,反映本地财务数据概况 */} - {!isLoading && available && ( -
-
- {Object.entries(TABLE_LABELS).map(([key, label]) => { - const info = tables[key] - return ( -
-
- {label} - -
-
- {info?.rows ?? 0} - -
-
- {info?.symbols ?? 0} 只标的 -
-
- ) - })} +
+ {syncing && ( +
+ + 正在从 TickFlow 拉取财务数据,请稍候…
- {status?.last_sync && Object.keys(status.last_sync).length > 0 && ( -
- 最后同步: {Object.entries(status.last_sync).map(([k, v]) => - `${TABLE_LABELS[k] || k}: ${new Date(v).toLocaleString()}` - ).join(' / ')} + )} + + {/* 同步状态卡片 —— 始终显示,反映本地财务数据概况 */} + {!isLoading && available && ( +
+
+ {Object.entries(TABLE_LABELS).map(([key, label]) => { + const info = tables[key] + const TIcon = TABLE_ICON[key] ?? Database + const hasData = (info?.rows ?? 0) > 0 + // 本次同步三态: 完成 / 同步中 / 等待 (仅全量同步时未轮到的表才"等待") + const doneThisRound = tableDoneThisRound(key) + const isThisSyncing = currentSyncingTable === key + const isWaiting = isWaitingTable(key) + const lsTime = lastSync[key] + return ( +
+
+
+ {doneThisRound ? ( + + ) : isThisSyncing ? ( + + ) : isWaiting ? ( + + ) : ( + + )} + {label} +
+ +
+
+ {fmtBigNum(info?.rows ?? 0)} + +
+
+ {fmtBigNum(info?.symbols ?? 0)} 只标的 +
+
+ + {lsTime + ? new Date(lsTime).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) + : '尚未同步'} +
+
+ ) + })}
- )} -
- )} - - {isLoading ? ( -
加载中…
- ) : !available ? ( -
暂无数据,点击"全部同步"从 TickFlow 拉取财务数据
- ) : ( - <> - {/* 个股搜索区 */} -
- {selected ? ( - // 已选股:紧凑搜索条 + 清除按钮(便于换股) -
-
- setSelected({ symbol, name })} /> -
- -
- ) : ( - // 未选股:醒目居中引导 -
-
- - 搜索个股查看详细财务数据 -
- setSelected({ symbol, name })} /> -
支持股票代码或名称模糊匹配,如 600000 / 浦发
-
- )}
+ )} - {/* 个股详情 / 空引导 */} -
- {selected ? ( - - ) : ( - - )} + {isLoading ? ( +
+
- - )} + ) : !available ? ( +
+ +
暂无财务数据
+
点击右上角"全部同步"从 TickFlow 拉取
+
+ ) : ( + <> + {/* 个股搜索区 */} +
+ {selected ? ( + // 已选股:紧凑搜索条 + 清除按钮(便于换股) +
+
+ setSelected({ symbol, name })} /> +
+ +
+ ) : ( + // 未选股:醒目居中引导 +
+
+ + 搜索个股查看详细财务数据 +
+
+ setSelected({ symbol, name })} /> +
+
支持股票代码或名称模糊匹配,如 600000 / 浦发
+
+ )} +
+ + {/* 个股详情 / 空引导 */} +
+ {selected ? ( + + ) : ( + + )} +
+ + )} +
) }