mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
fix: 端点套餐门控/探测重试/分钟K月单位 + 财务页新功能 + localStorage 残留自愈
## Bug 修复 - 端点测速:Expert+ 用户现可选用专线端点(premium),非 Expert 仍锁定(EndpointTestDialog) - 能力探测:try_call 对瞬时错误(429/5xx/超时)重试,避免抖动误丢 capability(导致"需 Free"误导徽章) - 数据画像 CapBadge:tierReq='Free' 时不显示无意义的"需 Free"徽章 - 财务同步:用服务端 is_syncing 标志驱动 UI,刷新后仍正确显示"同步中",防重复点击 + 进度提示 ## 新功能 - 财务页重设计:中间模糊搜索框(代码/名称),选股后展示 4 标签页详情 (核心指标/利润表/资负表/现金流),字段格式化(百分点/金额转亿/每股) - 分钟K向前扩展:Expert+ 新增「月」单位(1~6 月,180 天),Pro 仍只「天」(15 天) ## localStorage 残留自愈(拉新代码后不再显示本地开发残留) - 策略池:加载后自动清除失效的自定义策略 ID - 回测页:校验 selectedStrategy 是否存在,失效则重置;result 不跨会话恢复 ## 重构 - 提取共享 tier 工具到 capability-labels.ts(TIER_RANK/isExpertOrAbove)
This commit is contained in:
@@ -41,7 +41,14 @@ def financial_status(request: Request):
|
||||
fs = getattr(request.app.state, "financial_scheduler", None)
|
||||
last_sync = fs.last_sync if fs else {}
|
||||
|
||||
return {"available": True, "tables": tables, "last_sync": last_sync}
|
||||
return {
|
||||
"available": True,
|
||||
"tables": tables,
|
||||
"last_sync": last_sync,
|
||||
# 服务端是否正在同步(手动触发)——前端据此显示"同步中"并防重复点击,
|
||||
# 且刷新页面后仍能正确反映服务端状态。
|
||||
"syncing": bool(fs and fs.is_syncing),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/metrics")
|
||||
|
||||
@@ -550,7 +550,9 @@ async def extend_minute_history(request: Request):
|
||||
"""向前扩展分钟K历史数据 — 仅拉数据,不做任何后续处理。
|
||||
|
||||
body: { "value": int, "unit": "day"|"month" }
|
||||
最大 15 天。返回 job_id,可轮询 /api/pipeline/jobs 查看进度。
|
||||
- day 单位:1~15 天(所有有分钟K权限的套餐可用)
|
||||
- month 单位:1~6 月(每月按 30 天计,即最多 180 天)—— 仅 Expert+ 可用
|
||||
返回 job_id,可轮询 /api/pipeline/jobs 查看进度。
|
||||
"""
|
||||
import asyncio
|
||||
import traceback as _tb
|
||||
@@ -570,10 +572,20 @@ async def extend_minute_history(request: Request):
|
||||
if not capset.has(Cap.KLINE_MINUTE_BATCH):
|
||||
raise HTTPException(status_code=403, detail="需要 Pro+ 权限 (batch minute K-line)")
|
||||
|
||||
# 计算天数,上限 15
|
||||
# month 单位(按月扩展更长的分钟K历史)仅 Expert+ 开放;Pro 仅可用 day
|
||||
if unit == "month":
|
||||
from app.tickflow.policy import tier_label
|
||||
base_tier = tier_label().split()[0].split("+")[0].strip().lower()
|
||||
if base_tier != "expert":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="按月扩展分钟K历史需要 Expert 及以上套餐",
|
||||
)
|
||||
|
||||
# 计算天数上限:day 最多 15 天;month 最多 6 月(180 天)
|
||||
from datetime import timedelta
|
||||
if unit == "month":
|
||||
total_days = min(value * 30, 15)
|
||||
total_days = min(value * 30, 180)
|
||||
else:
|
||||
total_days = min(value, 15)
|
||||
|
||||
|
||||
@@ -197,6 +197,8 @@ class FinancialScheduler:
|
||||
self._capset: CapabilitySet | None = None
|
||||
self._lock = threading.Lock()
|
||||
self._last_sync: dict[str, str] = {} # {table: iso_timestamp}
|
||||
# 手动同步(run_now)是否正在进行。前端据此显示"同步中"并防重复点击。
|
||||
self._is_syncing = False
|
||||
|
||||
def start(self, data_dir: Path, capset: CapabilitySet) -> None:
|
||||
if not capset.has(Cap.FINANCIAL):
|
||||
@@ -242,27 +244,46 @@ class FinancialScheduler:
|
||||
pass
|
||||
|
||||
def run_now(self, table: str | None = None) -> dict[str, int]:
|
||||
"""手动触发同步。table=None 同步全部。"""
|
||||
"""手动触发同步。table=None 同步全部。
|
||||
|
||||
用 _is_syncing 标志防并发:若已有同步在进行,本次直接跳过,
|
||||
避免重复请求拖慢服务端 / 触发上游限流。
|
||||
"""
|
||||
if not self._capset or not self._capset.has(Cap.FINANCIAL):
|
||||
return {}
|
||||
if table:
|
||||
fn = {
|
||||
"metrics": sync_metrics,
|
||||
"income": sync_income,
|
||||
"balance_sheet": sync_balance_sheet,
|
||||
"cash_flow": sync_cash_flow,
|
||||
}.get(table)
|
||||
if not fn:
|
||||
return {}
|
||||
rows = fn(self._data_dir, self._capset)
|
||||
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
|
||||
return result
|
||||
with self._lock:
|
||||
if self._is_syncing:
|
||||
logger.info("financial sync skipped: already running")
|
||||
return {"_skipped": 1}
|
||||
self._is_syncing = True
|
||||
try:
|
||||
if table:
|
||||
fn = {
|
||||
"metrics": sync_metrics,
|
||||
"income": sync_income,
|
||||
"balance_sheet": sync_balance_sheet,
|
||||
"cash_flow": sync_cash_flow,
|
||||
}.get(table)
|
||||
if not fn:
|
||||
return {}
|
||||
rows = fn(self._data_dir, self._capset)
|
||||
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
|
||||
return result
|
||||
finally:
|
||||
with self._lock:
|
||||
self._is_syncing = False
|
||||
|
||||
@property
|
||||
def is_syncing(self) -> bool:
|
||||
"""手动同步是否正在进行(供 /status 返回,前端据此显示"同步中")。"""
|
||||
with self._lock:
|
||||
return self._is_syncing
|
||||
|
||||
@property
|
||||
def last_sync(self) -> dict[str, str]:
|
||||
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -52,6 +53,50 @@ def _tier_to_capset(tier_def: dict[str, dict[str, Any]]) -> CapabilitySet:
|
||||
return CapabilitySet(caps)
|
||||
|
||||
|
||||
def _is_transient(e: Exception) -> bool:
|
||||
"""是否为"可重试的瞬时错误"——网络抖动 / 限流 / 服务端 5xx。
|
||||
|
||||
与权限/参数错误(403/401/400/404)区分:后者重试也无用,不重试。
|
||||
用类名匹配而非 import SDK 异常,避免探测期对 SDK 内部耦合。
|
||||
"""
|
||||
cls = e.__class__.__name__
|
||||
if cls in {
|
||||
"RateLimitError", "InternalServerError", "APIError",
|
||||
"ConnectionError", "TimeoutError", "ConnectError",
|
||||
"ConnectTimeout", "ReadTimeout", "RemoteProtocolError",
|
||||
"httpx.ConnectError", "httpx.TimeoutException",
|
||||
}:
|
||||
return True
|
||||
# APIError 体系下,status_code 5xx/429 视为瞬时
|
||||
status = getattr(e, "status_code", None)
|
||||
if isinstance(status, int) and (status == 429 or status >= 500):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _call_with_retry(fn, attempts: int = 3, backoff: float = 0.6) -> None:
|
||||
"""调用 fn();对瞬时错误退避重试,权限/参数错误立即抛出。
|
||||
|
||||
attempts=总尝试次数(含首次)。返回 None,异常由调用方分类。
|
||||
"""
|
||||
last_exc: Exception | None = None
|
||||
for i in range(attempts):
|
||||
try:
|
||||
fn()
|
||||
return
|
||||
except Exception as e: # noqa: BLE001
|
||||
last_exc = e
|
||||
# 权限/参数类错误:重试无意义,立即抛出交给 try_call 归类
|
||||
if not _is_transient(e):
|
||||
raise
|
||||
# 瞬时错误:最后一轮不再 sleep
|
||||
if i < attempts - 1:
|
||||
time.sleep(backoff * (i + 1))
|
||||
# 重试耗尽,抛出最后一次异常
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
|
||||
def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str]]:
|
||||
"""逐 capability 试探。需要 API key。
|
||||
|
||||
@@ -65,7 +110,7 @@ def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str]]:
|
||||
|
||||
def try_call(cap: Cap, fn, default_limits: dict[str, Any]) -> None:
|
||||
try:
|
||||
fn()
|
||||
_call_with_retry(fn)
|
||||
available[cap] = CapabilityLimits(
|
||||
rpm=default_limits.get("rpm"),
|
||||
batch=default_limits.get("batch"),
|
||||
@@ -85,6 +130,7 @@ def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str]]:
|
||||
if is_perm_denied:
|
||||
log.append(f"✗ {cap}(无权限)")
|
||||
else:
|
||||
# 重试耗尽仍失败的瞬时错误 — 标记为疑似,而非直接判定"无此能力"
|
||||
log.append(f"? {cap} ({cls}: {e})")
|
||||
|
||||
# 用各档默认上限作为占位(无 X-RateLimit-* 头时)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Wifi, Play, Loader2, X, Check, Crown } from 'lucide-react'
|
||||
import { api, type EndpointItem } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { EXPERT_RANK, tierRank } from '@/lib/capability-labels'
|
||||
|
||||
interface EpResult {
|
||||
ok: boolean
|
||||
@@ -15,7 +16,7 @@ interface EpResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function EndpointTestDialog({ hasKey, currentEndpoint, onClose }: { hasKey: boolean; currentEndpoint: string; onClose: () => void }) {
|
||||
export function EndpointTestDialog({ hasKey, tierLabel, currentEndpoint, onClose }: { hasKey: boolean; tierLabel: string; currentEndpoint: string; onClose: () => void }) {
|
||||
const qc = useQueryClient()
|
||||
const [results, setResults] = useState<Record<string, EpResult | null>>({})
|
||||
const [testing, setTesting] = useState<Record<string, boolean>>({})
|
||||
@@ -52,6 +53,8 @@ export function EndpointTestDialog({ hasKey, currentEndpoint, onClose }: { hasKe
|
||||
|
||||
const anyTesting = Object.values(testing).some(Boolean)
|
||||
const isFree = !hasKey
|
||||
// 专线端点需 Expert 及以上套餐;Free 模式必然不可用
|
||||
const canUsePremium = !isFree && tierRank(tierLabel) >= EXPERT_RANK
|
||||
const currentLabel = endpoints.find(ep => ep.url === currentEndpoint)?.label ?? currentEndpoint
|
||||
|
||||
async function applyEndpoint(url: string) {
|
||||
@@ -137,7 +140,7 @@ export function EndpointTestDialog({ hasKey, currentEndpoint, onClose }: { hasKe
|
||||
<div className="py-10 text-center text-xs text-muted">未能加载端点列表</div>
|
||||
) : (
|
||||
endpoints.map(ep => (
|
||||
<EpRow key={ep.url} ep={ep} result={results[ep.url]} testing={testing[ep.url]} isCurrent={ep.url === currentEndpoint} isFree={isFree} switching={switching} onApply={applyEndpoint} />
|
||||
<EpRow key={ep.url} ep={ep} result={results[ep.url]} testing={testing[ep.url]} isCurrent={ep.url === currentEndpoint} isFree={isFree} canUsePremium={canUsePremium} switching={switching} onApply={applyEndpoint} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -151,12 +154,13 @@ export function EndpointTestDialog({ hasKey, currentEndpoint, onClose }: { hasKe
|
||||
)
|
||||
}
|
||||
|
||||
function EpRow({ ep, result, testing, isCurrent, isFree, switching, onApply }: {
|
||||
function EpRow({ ep, result, testing, isCurrent, isFree, canUsePremium, switching, onApply }: {
|
||||
ep: EndpointItem
|
||||
result: EpResult | null
|
||||
testing?: boolean
|
||||
isCurrent?: boolean
|
||||
isFree?: boolean
|
||||
canUsePremium?: boolean
|
||||
switching: string | null
|
||||
onApply: (url: string) => void
|
||||
}) {
|
||||
@@ -234,9 +238,9 @@ function EpRow({ ep, result, testing, isCurrent, isFree, switching, onApply }: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 应用按钮区域 —— Free 模式不可用任何付费端点 */}
|
||||
{isFree ? null : isPremium ? (
|
||||
// 专线端点:需 Expert 及以上套餐权限,不可应用
|
||||
{/* 应用按钮区域 —— Free 模式不可用任何付费端点;专线端点需 Expert+ */}
|
||||
{isFree ? null : (isPremium && !canUsePremium) ? (
|
||||
// 专线端点:需 Expert 及以上套餐权限,当前套餐不足,不可应用
|
||||
<span
|
||||
className="shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-btn text-[11px] font-medium bg-warning/10 text-warning/70 cursor-not-allowed select-none mt-0.5"
|
||||
title="需要 Expert 及以上套餐的专线加速权限"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { isExpertOrAbove } from '@/lib/capability-labels'
|
||||
|
||||
export function MinuteSyncConfig({ caps, isRunning, onStart }: { caps: { label: string; capabilities: Record<string, { rpm: number | null; batch: number | null; subscribe: number | null }> } | undefined; isRunning: boolean; onStart: () => void }) {
|
||||
const qc = useQueryClient()
|
||||
@@ -88,7 +89,7 @@ export function MinuteSyncConfig({ caps, isRunning, onStart }: { caps: { label:
|
||||
|
||||
<div className="pt-2 border-t border-border space-y-2.5">
|
||||
<div className="text-[10px] text-secondary">向前扩展历史数据</div>
|
||||
<MinuteExtendControls hasMinuteCap={hasMinuteCap} isRunning={isRunning} onStart={onStart} />
|
||||
<MinuteExtendControls hasMinuteCap={hasMinuteCap} tierLabel={caps?.label ?? ''} isRunning={isRunning} onStart={onStart} />
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] text-muted">
|
||||
@@ -98,8 +99,11 @@ export function MinuteSyncConfig({ caps, isRunning, onStart }: { caps: { label:
|
||||
)
|
||||
}
|
||||
|
||||
function MinuteExtendControls({ hasMinuteCap, isRunning, onStart }: { hasMinuteCap: boolean; isRunning: boolean; onStart: () => void }) {
|
||||
function MinuteExtendControls({ hasMinuteCap, tierLabel, isRunning, onStart }: { hasMinuteCap: boolean; tierLabel: string; isRunning: boolean; onStart: () => void }) {
|
||||
const qc = useQueryClient()
|
||||
// 月单位(按月扩展更长的分钟K历史)仅 Expert+ 开放;Pro 仅可用"天"(1~15 天)
|
||||
const canUseMonth = isExpertOrAbove(tierLabel)
|
||||
const [unit, setUnit] = useState<'day' | 'month'>('day')
|
||||
const [value, setValue] = useState(5)
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
|
||||
@@ -107,10 +111,12 @@ function MinuteExtendControls({ hasMinuteCap, isRunning, onStart }: { hasMinuteC
|
||||
queryKey: QK.dataStatus,
|
||||
queryFn: api.dataStatus,
|
||||
})
|
||||
const hasMinuteData = !!(dataStatus.data?.minute?.rows)
|
||||
// 判断本地是否已有分钟K数据:后端 _safe_aggregate_minute 为避免全表扫描,
|
||||
// rows 恒为 0,改用 trading_days(分区目录数,真实统计)判断。
|
||||
const hasMinuteData = !!(dataStatus.data?.minute?.trading_days)
|
||||
|
||||
const extend = useMutation({
|
||||
mutationFn: () => api.extendMinuteHistory(value, 'day'),
|
||||
mutationFn: () => api.extendMinuteHistory(value, unit),
|
||||
onSuccess: () => {
|
||||
onStart()
|
||||
qc.invalidateQueries({ queryKey: QK.pipelineJobs })
|
||||
@@ -118,7 +124,8 @@ function MinuteExtendControls({ hasMinuteCap, isRunning, onStart }: { hasMinuteC
|
||||
},
|
||||
})
|
||||
|
||||
const totalDays = Math.min(value, 15)
|
||||
// 各单位上限:day 15 天,month 6 月(180 天)
|
||||
const maxValue = unit === 'month' ? 6 : 15
|
||||
|
||||
const handleFetch = () => {
|
||||
if (!hasMinuteData) {
|
||||
@@ -128,6 +135,14 @@ function MinuteExtendControls({ hasMinuteCap, isRunning, onStart }: { hasMinuteC
|
||||
}
|
||||
}
|
||||
|
||||
// 切换单位时把 value clamp 到新单位的上限
|
||||
const switchUnit = (u: 'day' | 'month') => {
|
||||
if (u === unit) return
|
||||
setUnit(u)
|
||||
const max = u === 'month' ? 6 : 15
|
||||
setValue(v => Math.min(v, max))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -138,15 +153,30 @@ function MinuteExtendControls({ hasMinuteCap, isRunning, onStart }: { hasMinuteC
|
||||
className="h-6 w-6 flex items-center justify-center rounded-l-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
|
||||
>−</button>
|
||||
<div className="h-6 w-8 flex items-center justify-center border-y border-border text-[11px] font-mono tabular-nums text-foreground bg-base">
|
||||
{totalDays}
|
||||
{value}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setValue(Math.min(15, value + 1))}
|
||||
disabled={!hasMinuteCap || isRunning || extend.isPending || value >= 15}
|
||||
onClick={() => setValue(Math.min(maxValue, value + 1))}
|
||||
disabled={!hasMinuteCap || isRunning || extend.isPending || value >= maxValue}
|
||||
className="h-6 w-6 flex items-center justify-center rounded-r-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
|
||||
>+</button>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted">天</span>
|
||||
|
||||
{canUseMonth ? (
|
||||
<div className="flex rounded-btn border border-border overflow-hidden">
|
||||
{(['day', 'month'] as const).map(u => (
|
||||
<button
|
||||
key={u}
|
||||
onClick={() => switchUnit(u)}
|
||||
className={`px-2 py-0.5 text-[10px] font-medium transition-colors ${
|
||||
unit === u ? 'bg-accent/15 text-accent' : 'text-secondary hover:bg-elevated'
|
||||
}`}
|
||||
>{u === 'day' ? '天' : '月'}</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted">天</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -165,7 +195,7 @@ function MinuteExtendControls({ hasMinuteCap, isRunning, onStart }: { hasMinuteC
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setConfirmOpen(false)} />
|
||||
<div className="relative rounded-card border border-border bg-surface shadow-2xl mx-4 px-6 py-5 max-w-sm w-full space-y-4">
|
||||
<div className="text-sm text-foreground text-center">本地暂无分钟K数据,是否立即获取最近 {totalDays} 日分钟K?</div>
|
||||
<div className="text-sm text-foreground text-center">本地暂无分钟K数据,是否立即获取最近 {value} {unit === 'month' ? '月' : '天'}的分钟K?</div>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => { setConfirmOpen(false); extend.mutate() }}
|
||||
|
||||
@@ -52,7 +52,9 @@ function CapBadge({ hasCap, isLocal, tierLabel, tierReq, capInfo, localSuffix }:
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasCap && tierReq) {
|
||||
if (!hasCap && tierReq && tierReq !== 'Free') {
|
||||
// 缺权限且非 Free 档(付费档位才提示升级);Free 档人人可用,
|
||||
// 若显示"需 Free"会造成 Expert 等用户困惑(通常是探测瞬时失败丢能力)
|
||||
return (
|
||||
<span className="text-[10px] text-warning/90 bg-warning/8 rounded px-1.5 py-px font-medium">
|
||||
需 {tierReq}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useState } from 'react'
|
||||
import { CalendarDays, TrendingUp, FileText, Wallet, Activity } from 'lucide-react'
|
||||
import {
|
||||
useFinancialMetrics,
|
||||
useFinancialIncome,
|
||||
useFinancialBalanceSheet,
|
||||
useFinancialCashFlow,
|
||||
} from '@/lib/useFinancials'
|
||||
import { fmtPrice, fmtBigNum, fmtDate } from '@/lib/format'
|
||||
import { Skeleton } from '@/components/data/Skeleton'
|
||||
|
||||
interface Props {
|
||||
symbol: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type TabKey = 'metrics' | 'income' | 'balance_sheet' | 'cash_flow'
|
||||
|
||||
const TABS: { key: TabKey; label: string; icon: typeof TrendingUp }[] = [
|
||||
{ key: 'metrics', label: '核心指标', icon: TrendingUp },
|
||||
{ key: 'income', label: '利润表', icon: FileText },
|
||||
{ key: 'balance_sheet', label: '资产负债表', icon: Wallet },
|
||||
{ key: 'cash_flow', label: '现金流量表', icon: Activity },
|
||||
]
|
||||
|
||||
// 字段定义:键 → (中文名, 格式化类型)
|
||||
// pct=百分点(存的是 12.3 表示 12.3%); amount=金额(元,转亿/万亿); perShare=每股; num=普通数值(保留2位)
|
||||
type FmtType = 'pct' | 'amount' | 'perShare' | 'num'
|
||||
type FieldDef = { label: string; fmt: FmtType; group?: string }
|
||||
|
||||
const FIELD_DEFS: Record<TabKey, FieldDef[]> = {
|
||||
metrics: [
|
||||
{ label: '基本每股收益 EPS', fmt: 'perShare', key: 'eps_basic' } as any,
|
||||
{ label: '稀释每股收益 EPS', fmt: 'perShare', key: 'eps_diluted' } as any,
|
||||
{ label: '每股净资产 BPS', fmt: 'perShare', key: 'bps' } as any,
|
||||
{ label: '每股经营现金流', fmt: 'perShare', key: 'ocfps' } as any,
|
||||
{ label: '净资产收益率 ROE', fmt: 'pct', key: 'roe' } as any,
|
||||
{ label: '稀释 ROE', fmt: 'pct', key: 'roe_diluted' } as any,
|
||||
{ label: '总资产收益率 ROA', fmt: 'pct', key: 'roa' } as any,
|
||||
{ label: '销售毛利率', fmt: 'pct', key: 'gross_margin' } as any,
|
||||
{ label: '销售净利率', fmt: 'pct', key: 'net_margin' } as any,
|
||||
{ label: '资产负债率', fmt: 'pct', key: 'debt_to_asset_ratio' } as any,
|
||||
{ label: '营业收入同比增长', fmt: 'pct', key: 'revenue_yoy' } as any,
|
||||
{ label: '净利润同比增长', fmt: 'pct', key: 'net_income_yoy' } as any,
|
||||
{ label: '经营现金/营收', fmt: 'pct', key: 'operating_cash_to_revenue' } as any,
|
||||
{ label: '存货周转率', fmt: 'num', key: 'inventory_turnover' } as any,
|
||||
],
|
||||
income: [
|
||||
{ label: '营业收入', fmt: 'amount', key: 'revenue' } as any,
|
||||
{ label: '营业成本', fmt: 'amount', key: 'operating_cost' } as any,
|
||||
{ label: '营业利润', fmt: 'amount', key: 'operating_profit' } as any,
|
||||
{ label: '销售费用', fmt: 'amount', key: 'selling_expense' } as any,
|
||||
{ label: '管理费用', fmt: 'amount', key: 'admin_expense' } as any,
|
||||
{ label: '研发费用', fmt: 'amount', key: 'rd_expense' } as any,
|
||||
{ label: '财务费用', fmt: 'amount', key: 'financial_expense' } as any,
|
||||
{ label: '营业外收入', fmt: 'amount', key: 'non_operating_income' } as any,
|
||||
{ label: '营业外支出', fmt: 'amount', key: 'non_operating_expense' } as any,
|
||||
{ label: '利润总额', fmt: 'amount', key: 'total_profit' } as any,
|
||||
{ label: '所得税', fmt: 'amount', key: 'income_tax' } as any,
|
||||
{ label: '净利润', fmt: 'amount', key: 'net_income' } as any,
|
||||
{ label: '归母净利润', fmt: 'amount', key: 'net_income_attributable' } as any,
|
||||
{ label: '扣非净利润', fmt: 'amount', key: 'net_income_deducted' } as any,
|
||||
{ label: '基本每股收益', fmt: 'perShare', key: 'basic_eps' } as any,
|
||||
{ label: '稀释每股收益', fmt: 'perShare', key: 'diluted_eps' } as any,
|
||||
],
|
||||
balance_sheet: [
|
||||
{ label: '资产总计', fmt: 'amount', key: 'total_assets' } as any,
|
||||
{ label: '流动资产合计', fmt: 'amount', key: 'total_current_assets' } as any,
|
||||
{ label: '非流动资产合计', fmt: 'amount', key: 'total_non_current_assets' } as any,
|
||||
{ label: '货币资金', fmt: 'amount', key: 'cash_and_equivalents' } as any,
|
||||
{ label: '应收账款', fmt: 'amount', key: 'accounts_receivable' } as any,
|
||||
{ label: '存货', fmt: 'amount', key: 'inventory' } as any,
|
||||
{ label: '固定资产', fmt: 'amount', key: 'fixed_assets' } as any,
|
||||
{ label: '无形资产', fmt: 'amount', key: 'intangible_assets' } as any,
|
||||
{ label: '商誉', fmt: 'amount', key: 'goodwill' } as any,
|
||||
{ label: '负债合计', fmt: 'amount', key: 'total_liabilities' } as any,
|
||||
{ label: '流动负债合计', fmt: 'amount', key: 'total_current_liabilities' } as any,
|
||||
{ label: '非流动负债合计', fmt: 'amount', key: 'total_non_current_liabilities' } as any,
|
||||
{ label: '短期借款', fmt: 'amount', key: 'short_term_borrowing' } as any,
|
||||
{ label: '长期借款', fmt: 'amount', key: 'long_term_borrowing' } as any,
|
||||
{ label: '应付账款', fmt: 'amount', key: 'accounts_payable' } as any,
|
||||
{ label: '所有者权益合计', fmt: 'amount', key: 'total_equity' } as any,
|
||||
{ label: '归母所有者权益', fmt: 'amount', key: 'equity_attributable' } as any,
|
||||
{ label: '未分配利润', fmt: 'amount', key: 'retained_earnings' } as any,
|
||||
{ label: '少数股东权益', fmt: 'amount', key: 'minority_interest' } as any,
|
||||
],
|
||||
cash_flow: [
|
||||
{ label: '经营活动现金流净额', fmt: 'amount', key: 'net_operating_cash_flow' } as any,
|
||||
{ label: '投资活动现金流净额', fmt: 'amount', key: 'net_investing_cash_flow' } as any,
|
||||
{ label: '筹资活动现金流净额', fmt: 'amount', key: 'net_financing_cash_flow' } as any,
|
||||
{ label: '固定资产/无形资产投资', fmt: 'amount', key: 'capex' } as any,
|
||||
{ label: '现金及等价物净增加额', fmt: 'amount', key: 'net_cash_change' } as any,
|
||||
],
|
||||
}
|
||||
|
||||
function formatValue(v: number | null | undefined, fmt: FmtType): string {
|
||||
if (v == null || Number.isNaN(v)) return '—'
|
||||
switch (fmt) {
|
||||
case 'pct':
|
||||
// 存储的是百分点(12.3 表示 12.3%),直接保留2位 + %
|
||||
return `${v.toFixed(2)}%`
|
||||
case 'amount':
|
||||
// 金额(元)→ 亿/万亿;保留负号
|
||||
return fmtBigNum(v)
|
||||
case 'perShare':
|
||||
return fmtPrice(v, 2)
|
||||
case 'num':
|
||||
default:
|
||||
return v.toFixed(2)
|
||||
}
|
||||
}
|
||||
|
||||
export function StockFinancialDetail({ symbol, name }: Props) {
|
||||
const [tab, setTab] = useState<TabKey>('metrics')
|
||||
|
||||
const metrics = useFinancialMetrics(symbol)
|
||||
const income = useFinancialIncome(symbol)
|
||||
const balance = useFinancialBalanceSheet(symbol)
|
||||
const cashFlow = useFinancialCashFlow(symbol)
|
||||
|
||||
const queryMap = {
|
||||
metrics: metrics,
|
||||
income: income,
|
||||
balance_sheet: balance,
|
||||
cash_flow: cashFlow,
|
||||
} as const
|
||||
|
||||
const current = queryMap[tab]
|
||||
// 按 period_end 降序(最新在前);同步默认 latest_only,通常只有1期
|
||||
const rows = (current.data?.data ?? []).slice().sort((a, b) =>
|
||||
(b.period_end ?? '').localeCompare(a.period_end ?? '')
|
||||
)
|
||||
const fieldDefs = FIELD_DEFS[tab]
|
||||
|
||||
// 头部报告期信息取最新一期(优先用当前 tab,兜底用 metrics)
|
||||
const latestPeriod = rows[0]?.period_end ?? metrics.data?.data?.[0]?.period_end ?? null
|
||||
const latestAnnounce = rows[0]?.announce_date ?? metrics.data?.data?.[0]?.announce_date ?? null
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-border bg-surface overflow-hidden">
|
||||
{/* 头部:标的 + 报告期 */}
|
||||
<div className="px-5 py-4 border-b border-border flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-baseline gap-2 min-w-0">
|
||||
<span className="text-lg font-semibold text-foreground">{name}</span>
|
||||
<span className="text-xs font-mono text-muted">{symbol}</span>
|
||||
</div>
|
||||
{latestPeriod && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-secondary ml-auto">
|
||||
<CalendarDays className="h-3.5 w-3.5" />
|
||||
<span>报告期 <span className="font-mono">{latestPeriod}</span></span>
|
||||
{latestAnnounce && (
|
||||
<span className="text-muted">· 披露 {fmtDate(latestAnnounce)}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 标签页 */}
|
||||
<div className="flex items-center gap-1 px-3 pt-2 border-b border-border/60">
|
||||
{TABS.map(t => {
|
||||
const Icon = t.icon
|
||||
const isActive = tab === t.key
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium border-b-2 -mb-px transition-colors ${
|
||||
isActive
|
||||
? 'border-accent text-accent'
|
||||
: 'border-transparent text-muted hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{t.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 表格内容 */}
|
||||
<div className="p-4">
|
||||
{current.isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="flex justify-between">
|
||||
<Skeleton w="w-32" h="h-4" />
|
||||
<Skeleton w="w-20" h="h-4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="py-10 text-center text-xs text-muted">
|
||||
暂无{TABS.find(t => t.key === tab)?.label}数据 — 可点击顶部「全部同步」拉取
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{/* 多期时为每期渲染一组;单期时只有一组 */}
|
||||
{rows.map((row, ri) => (
|
||||
<div key={row.period_end ?? ri}>
|
||||
{rows.length > 1 && (
|
||||
<div className="text-[11px] text-muted mb-2 flex items-center gap-1.5">
|
||||
<CalendarDays className="h-3 w-3" />
|
||||
报告期 <span className="font-mono text-secondary">{row.period_end}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-0">
|
||||
{fieldDefs.map((def: any) => {
|
||||
const val = row[def.key]
|
||||
return (
|
||||
<div
|
||||
key={def.key}
|
||||
className="flex items-baseline justify-between gap-3 py-2 border-b border-border/40"
|
||||
>
|
||||
<span className="text-xs text-secondary shrink-0">{def.label}</span>
|
||||
<span className="text-sm font-mono tabular-nums text-foreground text-right">
|
||||
{formatValue(val, def.fmt)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Search, Loader2 } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
|
||||
interface Props {
|
||||
onSelect: (symbol: string, name: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 个股模糊搜索框 —— 财务页主入口。
|
||||
* 复用 instrumentSearch 后端(代码 / 名称模糊匹配),单选即跳转该股财务详情。
|
||||
* 模式对齐 Watchlist.StockSearchBox:useQuery + 外部点击关闭 + 键盘导航。
|
||||
*/
|
||||
export function StockFinancialSearch({ onSelect }: Props) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [activeIdx, setActiveIdx] = useState(-1)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const search = useQuery({
|
||||
queryKey: QK.instrumentSearch(query),
|
||||
queryFn: () => api.instrumentSearch(query),
|
||||
enabled: query.trim().length > 0,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const results = search.data?.results ?? []
|
||||
|
||||
// 外部点击关闭下拉
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [])
|
||||
|
||||
function handleSelect(r: { symbol: string; name: string }) {
|
||||
onSelect(r.symbol, r.name)
|
||||
setQuery('')
|
||||
setOpen(false)
|
||||
setActiveIdx(-1)
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Escape') { setOpen(false); return }
|
||||
if (!open || results.length === 0) return
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setActiveIdx(i => Math.min(i + 1, results.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setActiveIdx(i => Math.max(i - 1, -1))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (activeIdx >= 0) handleSelect(results[activeIdx])
|
||||
else if (results.length > 0) handleSelect(results[0])
|
||||
}
|
||||
}
|
||||
|
||||
const trimmed = query.trim()
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full max-w-xl mx-auto">
|
||||
<div className="relative flex items-center">
|
||||
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted pointer-events-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="输入股票代码或名称,如 600000 / 浦发"
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); setActiveIdx(-1) }}
|
||||
onFocus={() => { if (trimmed) setOpen(true) }}
|
||||
onKeyDown={handleKeyDown}
|
||||
// 较宽、更醒目 —— 作为财务页主入口
|
||||
className="w-full h-11 pl-11 pr-10 rounded-card bg-surface border border-border text-sm text-foreground placeholder:text-muted focus:outline-none focus:border-accent/50 focus:bg-base transition-colors"
|
||||
/>
|
||||
{search.isFetching && (
|
||||
<Loader2 className="absolute right-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && trimmed && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.12, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="absolute left-0 right-0 top-full mt-1.5 z-50 max-h-[360px] overflow-y-auto rounded-card border border-border bg-base shadow-xl"
|
||||
>
|
||||
{search.isLoading ? (
|
||||
<div className="px-4 py-6 flex items-center justify-center gap-2 text-xs text-muted">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
搜索中…
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-xs text-muted">
|
||||
未找到匹配的股票
|
||||
</div>
|
||||
) : (
|
||||
results.map((r, i) => (
|
||||
<button
|
||||
key={r.symbol}
|
||||
type="button"
|
||||
onClick={() => handleSelect(r)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors duration-100 ${
|
||||
i === activeIdx ? 'bg-accent/10 text-accent' : 'hover:bg-elevated text-foreground'
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono shrink-0 text-xs w-[88px]">{r.symbol}</span>
|
||||
<span className="truncate text-sm flex-1">{r.name}</span>
|
||||
{r.code && <span className="text-[10px] text-muted font-mono shrink-0">{r.code}</span>}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -39,6 +39,8 @@ export interface FinancialStatus {
|
||||
available: boolean
|
||||
tables: Record<string, { rows: number; symbols: number }>
|
||||
last_sync: Record<string, string>
|
||||
/** 服务端是否正在同步(手动触发)——驱动"同步中"UI 并防重复点击 */
|
||||
syncing?: boolean
|
||||
}
|
||||
|
||||
export interface FinancialMetricRecord {
|
||||
|
||||
@@ -13,3 +13,18 @@ export const CAP_LABELS: Record<string, { name: string; hint: string }> = {
|
||||
'financial': { name: '财务数据', hint: '利润表 / 资负表 / 现金流 / 关键指标' },
|
||||
'adj_factor': { name: '复权因子', hint: '让 MA/MACD 等指标在分红送转日不失真' },
|
||||
}
|
||||
|
||||
// 套餐等级 —— 用于按档位门控功能(如专线端点 / 按月扩展分钟K)。
|
||||
// 基础档提取与后端 quote_service.py 一致:取 label 第一个词("Pro +" → "pro")。
|
||||
export const TIER_RANK: Record<string, number> = { free: 0, starter: 1, pro: 2, expert: 3 }
|
||||
export const EXPERT_RANK = TIER_RANK.expert
|
||||
|
||||
export function tierRank(label: string): number {
|
||||
const base = (label.split(' ')[0] ?? '').split('+')[0].trim().toLowerCase()
|
||||
return TIER_RANK[base] ?? -1
|
||||
}
|
||||
|
||||
export function isExpertOrAbove(label: string): boolean {
|
||||
return tierRank(label) >= EXPERT_RANK
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ export function useFinancialStatus() {
|
||||
queryKey: FINANCIAL_QK.status,
|
||||
queryFn: () => api.financialStatus(),
|
||||
staleTime: 60_000,
|
||||
// 同步进行中时每 3s 轮询,及时反映表数变化与同步完成;空闲时不轮询。
|
||||
refetchInterval: (query) => (query.state.data?.syncing ? 3_000 : false),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,6 +28,33 @@ export function useFinancialMetrics(symbol?: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function useFinancialIncome(symbol?: string) {
|
||||
return useQuery({
|
||||
queryKey: FINANCIAL_QK.income(symbol),
|
||||
queryFn: () => api.financialIncome(symbol),
|
||||
enabled: !!symbol,
|
||||
staleTime: 300_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useFinancialBalanceSheet(symbol?: string) {
|
||||
return useQuery({
|
||||
queryKey: FINANCIAL_QK.balanceSheet(symbol),
|
||||
queryFn: () => api.financialBalanceSheet(symbol),
|
||||
enabled: !!symbol,
|
||||
staleTime: 300_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useFinancialCashFlow(symbol?: string) {
|
||||
return useQuery({
|
||||
queryKey: FINANCIAL_QK.cashFlow(symbol),
|
||||
queryFn: () => api.financialCashFlow(symbol),
|
||||
enabled: !!symbol,
|
||||
staleTime: 300_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useFinancialSync() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
|
||||
@@ -19,7 +19,18 @@ export function useStrategyPool() {
|
||||
setPool(newOrder)
|
||||
}, [])
|
||||
|
||||
// 清除池中不存在于 validIds 的失效策略(如本地开发残留的自定义策略)。
|
||||
// 仅当确实有失效项时才更新,避免无谓重渲染。
|
||||
const prune = useCallback((validIds: Iterable<string>) => {
|
||||
const validSet = validIds instanceof Set ? validIds : new Set(validIds)
|
||||
setPool(prev => {
|
||||
if (prev.length === 0) return prev
|
||||
const next = prev.filter(id => validSet.has(id))
|
||||
return next.length === prev.length ? prev : next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const isInPool = useCallback((id: string) => pool.includes(id), [pool])
|
||||
|
||||
return { pool, addToPool, removeFromPool, reorderPool, isInPool }
|
||||
return { pool, addToPool, removeFromPool, reorderPool, prune, isInPool }
|
||||
}
|
||||
|
||||
@@ -733,6 +733,7 @@ export function Data() {
|
||||
{showEndpointTest && (
|
||||
<EndpointTestDialog
|
||||
hasKey={settings.data?.has_tickflow_key ?? false}
|
||||
tierLabel={settings.data?.tier_label ?? ''}
|
||||
currentEndpoint={settings.data?.current_endpoint ?? ''}
|
||||
onClose={() => setShowEndpointTest(false)}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { RefreshCw, Lock } from 'lucide-react'
|
||||
import { RefreshCw, Lock, Loader2, X, Search } 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'
|
||||
|
||||
const TABLE_LABELS: Record<string, string> = {
|
||||
metrics: '核心指标',
|
||||
@@ -12,29 +14,15 @@ const TABLE_LABELS: Record<string, string> = {
|
||||
cash_flow: '现金流量表',
|
||||
}
|
||||
|
||||
const METRIC_LABELS: Record<string, string> = {
|
||||
eps_basic: '基本EPS',
|
||||
eps_diluted: '稀释EPS',
|
||||
bps: '每股净资产',
|
||||
ocfps: '每股经营现金流',
|
||||
roe: 'ROE',
|
||||
roe_diluted: '稀释ROE',
|
||||
roa: 'ROA',
|
||||
gross_margin: '毛利率',
|
||||
net_margin: '净利率',
|
||||
debt_to_asset_ratio: '负债率',
|
||||
revenue_yoy: '营收增速',
|
||||
net_income_yoy: '净利增速',
|
||||
operating_cash_to_revenue: '经营现金/营收',
|
||||
inventory_turnover: '存货周转率',
|
||||
}
|
||||
|
||||
export function Financials() {
|
||||
const { data: caps } = useCapabilities()
|
||||
const hasFinancial = caps?.capabilities?.['financial'] != null
|
||||
const { data: status, isLoading } = useFinancialStatus()
|
||||
const syncMut = useFinancialSync()
|
||||
const [syncing, setSyncing] = useState<string | null>(null)
|
||||
// 用服务端 syncing 真值驱动 UI(而非本地状态),刷新后仍正确,且天然防重复点击
|
||||
const syncing = status?.syncing ?? false
|
||||
// 选中的个股(模糊搜索结果);null 时显示搜索引导
|
||||
const [selected, setSelected] = useState<{ symbol: string; name: string } | null>(null)
|
||||
|
||||
if (!hasFinancial) {
|
||||
return (
|
||||
@@ -49,17 +37,16 @@ export function Financials() {
|
||||
)
|
||||
}
|
||||
|
||||
const handleSync = async (table: string) => {
|
||||
setSyncing(table)
|
||||
try {
|
||||
await syncMut.mutateAsync(table)
|
||||
} finally {
|
||||
setSyncing(null)
|
||||
}
|
||||
const handleSync = (table: string) => {
|
||||
// 防重复点击:syncing 中不再触发(后端 run_now 也有 is_syncing 兜底)
|
||||
if (syncing) return
|
||||
syncMut.mutate(table)
|
||||
}
|
||||
|
||||
const tables = status?.tables ?? {}
|
||||
const available = status?.available ?? false
|
||||
// 进度:已同步(rows>0)的表数 / 总表数,供同步中提示
|
||||
const syncedCount = Object.values(tables).filter(t => (t?.rows ?? 0) > 0).length
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -69,24 +56,30 @@ export function Financials() {
|
||||
right={
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="px-3 py-1.5 text-xs bg-card border border-border rounded-md hover:bg-accent transition-colors disabled:opacity-50"
|
||||
className="px-3 py-1.5 text-xs bg-card border border-border rounded-md hover:bg-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => handleSync('all')}
|
||||
disabled={!!syncing}
|
||||
disabled={syncing}
|
||||
title={syncing ? '正在同步,请稍候…' : '同步全部财务表'}
|
||||
>
|
||||
<RefreshCw className={`inline w-3 h-3 mr-1 ${syncing ? 'animate-spin' : ''}`} />
|
||||
{syncing
|
||||
? <Loader2 className="inline w-3 h-3 mr-1 animate-spin" />
|
||||
: <RefreshCw className="inline w-3 h-3 mr-1" />}
|
||||
{syncing ? '同步中…' : '全部同步'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{!available || isLoading ? (
|
||||
<div className="p-5 text-sm text-muted">
|
||||
{isLoading ? '加载中…' : '暂无数据,点击"全部同步"从 TickFlow 拉取财务数据'}
|
||||
{syncing && (
|
||||
<div className="px-5 -mt-2 pb-1 text-xs text-accent/80 flex items-center gap-1.5">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
正在从 TickFlow 拉取财务数据,已同步 {syncedCount}/4 张表…
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-5 space-y-6">
|
||||
{/* 各表状态卡片 */}
|
||||
)}
|
||||
|
||||
{/* 同步状态卡片 —— 始终显示,反映本地财务数据概况 */}
|
||||
{!isLoading && available && (
|
||||
<div className="px-5 pt-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{Object.entries(TABLE_LABELS).map(([key, label]) => {
|
||||
const info = tables[key]
|
||||
@@ -95,12 +88,14 @@ export function Financials() {
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<button
|
||||
className="text-muted hover:text-foreground transition-colors disabled:opacity-50"
|
||||
className="text-muted hover:text-foreground transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => handleSync(key)}
|
||||
disabled={!!syncing}
|
||||
title={`同步${label}`}
|
||||
disabled={syncing}
|
||||
title={syncing ? '正在同步…' : `同步${label}`}
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${syncing === key ? 'animate-spin' : ''}`} />
|
||||
{syncing
|
||||
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
: <RefreshCw className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold tabular-nums">
|
||||
@@ -114,23 +109,8 @@ export function Financials() {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 指标说明 */}
|
||||
<div className="bg-card border border-border rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium mb-3">核心指标字段说明</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-1.5 text-xs">
|
||||
{Object.entries(METRIC_LABELS).map(([key, label]) => (
|
||||
<div key={key} className="flex gap-2">
|
||||
<code className="text-primary/80 font-mono">{key}</code>
|
||||
<span className="text-muted">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 最后同步时间 */}
|
||||
{status?.last_sync && Object.keys(status.last_sync).length > 0 && (
|
||||
<div className="text-xs text-muted">
|
||||
<div className="text-xs text-muted mt-3">
|
||||
最后同步: {Object.entries(status.last_sync).map(([k, v]) =>
|
||||
`${TABLE_LABELS[k] || k}: ${new Date(v).toLocaleString()}`
|
||||
).join(' / ')}
|
||||
@@ -138,6 +118,57 @@ export function Financials() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-5 text-sm text-muted">加载中…</div>
|
||||
) : !available ? (
|
||||
<div className="p-5 text-sm text-muted">暂无数据,点击"全部同步"从 TickFlow 拉取财务数据</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 个股搜索区 */}
|
||||
<div className="px-5 pt-6 pb-2">
|
||||
{selected ? (
|
||||
// 已选股:紧凑搜索条 + 清除按钮(便于换股)
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 max-w-xl">
|
||||
<StockFinancialSearch onSelect={(symbol, name) => setSelected({ symbol, name })} />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelected(null)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs text-secondary hover:text-foreground rounded-btn border border-border hover:bg-elevated transition-colors shrink-0"
|
||||
title="清除选择"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// 未选股:醒目居中引导
|
||||
<div className="flex flex-col items-center gap-3 py-6">
|
||||
<div className="flex items-center gap-2 text-sm text-secondary">
|
||||
<Search className="h-4 w-4" />
|
||||
<span>搜索个股查看详细财务数据</span>
|
||||
</div>
|
||||
<StockFinancialSearch onSelect={(symbol, name) => setSelected({ symbol, name })} />
|
||||
<div className="text-[11px] text-muted">支持股票代码或名称模糊匹配,如 600000 / 浦发</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 个股详情 / 空引导 */}
|
||||
<div className="px-5 pb-8">
|
||||
{selected ? (
|
||||
<StockFinancialDetail symbol={selected.symbol} name={selected.name} />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
title="未选择股票"
|
||||
hint="在上方搜索框输入股票代码或名称,选择后即可查看该股的核心指标、利润表、资产负债表与现金流量表。"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function Screener() {
|
||||
const [showBuilder, setShowBuilder] = useState(false)
|
||||
const [builderMode, setBuilderMode] = useState<'create' | 'modify'>('create')
|
||||
const [showStore, setShowStore] = useState(false)
|
||||
const { pool, addToPool, removeFromPool, reorderPool } = useStrategyPool()
|
||||
const { pool, addToPool, removeFromPool, reorderPool, prune } = useStrategyPool()
|
||||
const [cardSize, setCardSize] = useState<CardSize>(loadCardSize)
|
||||
// 日k蜡烛图显示开关(仅当 candle 列可见时才有意义;持久化)
|
||||
const [dailyKChartVisible, setDailyKChartVisible] = useState<boolean>(() => storage.screenerCandle.get(true))
|
||||
@@ -144,6 +144,14 @@ export function Screener() {
|
||||
const availableStrategyIds = useMemo(() => new Set((strategies.data?.presets ?? []).map(s => s.id)), [strategies.data])
|
||||
const visiblePool = useMemo(() => pool.filter(id => availableStrategyIds.has(id)), [pool, availableStrategyIds])
|
||||
|
||||
// 策略列表加载后,自动清除池中失效的自定义策略(如本地开发残留的、
|
||||
// 当前后端已不存在的策略 ID),避免"策略池"对话框持续显示失效项。
|
||||
// availableStrategyIds 初始为空集合时跳过,防止首次渲染误清整个池。
|
||||
useEffect(() => {
|
||||
if (availableStrategyIds.size === 0) return
|
||||
prune(availableStrategyIds)
|
||||
}, [availableStrategyIds, prune])
|
||||
|
||||
// 进入页面自动跑策略池中的策略,获取命中数
|
||||
const runAll = useMutation({
|
||||
mutationFn: ({ date, strategyIds }: { date?: string; strategyIds?: string[] } = {}) =>
|
||||
|
||||
@@ -638,7 +638,10 @@ export function StrategyBacktest() {
|
||||
const [scoringDraft, setScoringDraft] = useState<Record<string, number>>({})
|
||||
const [strategyParams, setStrategyParams] = useState<Record<string, any>>(saved?.params ?? {})
|
||||
const [overrides, setOverrides] = useState<Record<string, any>>(saved?.overrides ?? {})
|
||||
const [result, setResult] = useState<StrategyBacktestResult | null>(saved?.result ?? null)
|
||||
// result 不从 localStorage 恢复:它是运行产物(净值/交易),大且易过时,
|
||||
// 跨会话/拉新代码后自动渲染一个可能对应已失效策略的旧结果会造成困惑
|
||||
// (切页不卸载组件,内存中的 result 仍保留,无需靠 localStorage 恢复)。
|
||||
const [result, setResult] = useState<StrategyBacktestResult | null>(null)
|
||||
const [resultTab, setResultTab] = useState<'daily' | 'trades' | 'picks'>('daily')
|
||||
const [dailyPage, setDailyPage] = useState(0)
|
||||
const [tradePage, setTradePage] = useState(0)
|
||||
@@ -656,6 +659,20 @@ export function StrategyBacktest() {
|
||||
strategyGroup === 'all' ? strategyList : strategyList.filter(st => st.source === strategyGroup)
|
||||
), [strategyGroup, strategyList])
|
||||
|
||||
// 校验 localStorage 里保存的上次选中策略是否仍存在(本地开发残留的自定义策略
|
||||
// 拉新代码后会失效,导致 strategyGet 一直 404/加载中)。列表就绪后若失效,
|
||||
// 连带清除其专属的 params/overrides/result(这些是该策略的运行配置/产物,
|
||||
// 策略失效后留着会造成"孤儿"状态:界面显示旧回测结果却无对应策略)。
|
||||
useEffect(() => {
|
||||
if (strategies.isLoading || strategyList.length === 0) return
|
||||
if (selectedStrategy && !strategyList.some(st => st.id === selectedStrategy)) {
|
||||
setSelectedStrategy(null)
|
||||
setStrategyParams({})
|
||||
setOverrides({})
|
||||
setResult(null)
|
||||
}
|
||||
}, [strategies.isLoading, strategyList, selectedStrategy])
|
||||
|
||||
const strategyDetail = useQuery({
|
||||
queryKey: ['strategy-detail', selectedStrategy],
|
||||
queryFn: () => api.strategyGet(selectedStrategy!),
|
||||
|
||||
Reference in New Issue
Block a user