feat(v0.2): 功能门槛统一为数据源通用的能力标准

- 后端: 数据集→能力映射增广 (daily/adj_factor/minute/financial),
  非 tickflow provider 按声明数据集动态 grant; 数据源偏好更新与
  删除数据源后刷新 app.state.capabilities 快照; 新增 8 项测试
- 前端: 通用界面去档位词, 缺能力统一「{能力名} · 不可用」徽章
  (capability-labels 新增 MissingCapChip, 点击跳设置→数据源);
  StatCard/深度配置/分钟同步/历史扩展/财务页等 20+ 文件对齐;
  实时模式判定改用 quoteStatus.mode 与 realtime_allowed,
  替代前端 tierRank 推断; 监控设置页放开整页拦截
- 档位词仅保留 TickFlow 专属界面 (Key 配置/端点测速/引导页),
  docs/configuration.md 档位表加注解说明
This commit is contained in:
shy3130
2026-08-23 12:34:40 +08:00
parent 0f2c7ce9b0
commit da3ada28e1
25 changed files with 327 additions and 172 deletions
+19 -17
View File
@@ -3,7 +3,7 @@ import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useQuoteStream, useQuoteStreamStatus } from '@/lib/useQuoteStream'
import { ToastContainer } from '@/components/Toast'
import { ToastContainer, toast } from '@/components/Toast'
import { AlertToastContainer } from '@/components/AlertToast'
import { AiAnalysisHost } from '@/components/financials/AiAnalysisHost'
import { AiReportBubble } from '@/components/financials/AiReportBubble'
@@ -20,7 +20,6 @@ import {
useToggleRealtimeQuotes,
} from '@/lib/useSharedMutations'
import { QK } from '@/lib/queryKeys'
import { tierRank } from '@/lib/capability-labels'
import {
Siren,
Star,
@@ -364,7 +363,7 @@ export function Layout() {
const navigate = useNavigate()
const version = versionData?.version
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
// Free 档监控限制提示: 可手动关闭, 不持久化 (刷新后恢复显示)
// 自选实时模式限制提示: 可手动关闭, 不持久化 (刷新后恢复显示)
const [dismissFreeHint, setDismissFreeHint] = useState(false)
useEffect(() => {
const compact = window.matchMedia('(max-width: 767px)')
@@ -408,9 +407,10 @@ export function Layout() {
const isTrading = quoteStatus?.is_trading_hours ?? false
// 管道/数据修正运行期间实时行情被临时暂停 — 此时禁止开启
const isPaused = quoteStatus?.paused ?? false
const tier = tierRank(caps?.label ?? '')
const isNoneTier = tier < 0
const isWatchlistMode = tier === 0
// 实时模式以 quote_status 为准 (数据源无关): none=不可用 / watchlist=自选实时 / full_market=全市场
const quoteMode = quoteStatus?.mode ?? 'none'
const realtimeUnavailable = quoteMode === 'none'
const isWatchlistMode = quoteMode === 'watchlist'
const realtimeModeLabel = isWatchlistMode ? '自选股' : '全市场'
// 当前实时行情数据源名称 (custom 时显示源名, tickflow 时不显示)
const realtimeProvider = prefs?.realtime_data_provider
@@ -512,15 +512,17 @@ export function Layout() {
const visibleNavItems = navItems.filter(n => !hiddenIds.has(n.to) && !hiddenIds.has(n.to.replace(/^\/analysis\//, '')))
const handleToggle = async (enabled: boolean) => {
// 开启时重新校验档位
// 开启时重新校验实时权限 (以 quote_status 的数据源无关判定为准)
if (enabled) {
const fresh = await qc.fetchQuery({
queryKey: QK.capabilities,
queryFn: api.capabilities,
queryKey: QK.quoteStatus,
queryFn: api.quoteStatus,
})
const freshTier = tierRank(fresh.label ?? '')
if (freshTier < 0) return
if (freshTier === 0 && (prefs?.realtime_watchlist_symbols?.length ?? 0) === 0) {
if (!fresh.realtime_allowed) {
toast('当前数据源无实时行情能力, 请先配置数据源', 'error')
return
}
if (fresh.mode === 'watchlist' && (prefs?.realtime_watchlist_symbols?.length ?? 0) === 0) {
navigate('/watchlist')
return
}
@@ -740,7 +742,7 @@ export function Layout() {
</div>
) : (
<div className="border-t border-border px-3 py-2.5 shrink-0">
{isNoneTier && !realtimeProviderName ? (
{realtimeUnavailable && !realtimeProviderName ? (
<div>
<div className="flex items-center justify-between">
<span className="text-xs text-secondary truncate"></span>
@@ -760,7 +762,7 @@ export function Layout() {
</div>
</div>
) : (
/* Starter+ — 开关 + 跳转设置 */
/* 实时可用 — 开关 + 跳转设置 */
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className={`inline-block h-2 w-2 shrink-0 rounded-full ${realtimeIndicatorClass}`} />
@@ -810,13 +812,13 @@ export function Layout() {
{/* 状态提示 */}
{realtimeEnabled
&& (!isNoneTier || realtimeProviderName)
&& (!realtimeUnavailable || realtimeProviderName)
&& (isPaused || (isWatchlistMode && !dismissFreeHint && !realtimeProviderName))
&& (
<div className="mt-1.5 text-[10px] leading-snug space-y-0.5">
{isWatchlistMode && !dismissFreeHint && !realtimeProviderName && (
<div className="flex items-start gap-1 text-amber-400/80">
<span className="flex-1"> 5 Starter+</span>
<span className="flex-1"> 5 </span>
<button
onClick={() => setDismissFreeHint(true)}
className="text-amber-400/50 hover:text-amber-400 shrink-0 transition-colors"
@@ -831,7 +833,7 @@ export function Layout() {
)}
</div>
)}
{showSidebarQuotes && !isWatchlistMode && (!isNoneTier || !!realtimeProviderName) && (
{showSidebarQuotes && !isWatchlistMode && (!realtimeUnavailable || !!realtimeProviderName) && (
<SidebarIndexQuotes rows={sidebarIndexQuotes?.rows} items={sidebarIndexes} />
)}
</div>
+2 -2
View File
@@ -70,7 +70,7 @@ export function SealedBadge({ degraded, hasDepth, isHistorical, sealedReady, sea
// 组装原因文案(仅降级时用)
const reasons: string[] = []
if (!hasDepth) reasons.push('当前套餐无五档盘口能力(需 Pro+),涨停判定基于收盘价,可能含假涨停')
if (!hasDepth) reasons.push('五档盘口数据不可用,涨停判定基于收盘价,可能含假涨停')
if (isHistorical) reasons.push('历史日期的盘口快照不可获取,无法判定真假板')
if (hasDepth && !isHistorical && !sealedReady) reasons.push('盘中 sealed 数据尚未就绪,收盘后自动恢复')
@@ -126,7 +126,7 @@ export function SealedBadge({ degraded, hasDepth, isHistorical, sealedReady, sea
</div>
))}
<div className="mt-1.5 pt-1.5 border-t border-border text-muted">
(/)Pro+
(/),
</div>
</>
) : (
@@ -73,7 +73,7 @@ export function StockMultiDayIntradayChart({
onError: (e: Error) => {
const msg = e.message || ''
if (msg.includes('403') || msg.includes('Pro')) {
toast('分钟K数据需要 Pro+ 权限', 'error')
toast('分钟K(批量)数据不可用', 'error')
} else {
toast(`补齐数据失败: ${msg}`, 'error')
}
+1 -1
View File
@@ -78,7 +78,7 @@ export function StockPanel({
saveInfoFields(next)
}, [])
// 财务指标:仅当信息条配置含可见的财务字段且用户具备 FINANCIAL 能力 (Expert) 时才请求
// 财务指标:仅当信息条配置含可见的财务字段且用户具备财务数据能力 (financial) 时才请求
// 无能力时跳过请求, 避免后端抛 CapabilityDenied (403) 导致 free/starter 档弹错误提示
const { data: caps } = useCapabilities()
const hasFinancialCap = !!caps?.capabilities?.['financial']
@@ -3,12 +3,12 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { usePreferences, useCapabilities } from '@/lib/useSharedQueries'
import { isExpertOrAbove } from '@/lib/capability-labels'
import { MissingCapChip } from '@/lib/capability-labels'
/**
* 五档盘口 sealed(真假涨停) 配置内容(纯内容, 无外框, 由父级 Card 包裹)。
*
* - 轮询间隔: Pro 10~120s / Expert 3~120s
* - 轮询间隔: 常规 10~120s · 数据源具备实时推送能力时 3~120s
* - 盘后定版时间: 15:01~18:00, 默认 15:02
* - disabled 时(监控关闭)输入框禁用
*/
@@ -19,8 +19,9 @@ export function DepthConfigContent({ disabled }: { disabled?: boolean }) {
const caps = useCapabilities()
const hasDepth = !!caps.data?.capabilities?.['depth5.batch']
const tierLabel = caps.data?.label ?? ''
const range = isExpertOrAbove(tierLabel) ? { lo: 3, hi: 120 } : { lo: 10, hi: 120 }
// 能力判定(非档位): 具备实时推送能力的数据源允许更快的盘口轮询
const fastPolling = !!caps.data?.capabilities?.['websocket']
const range = fastPolling ? { lo: 3, hi: 120 } : { lo: 10, hi: 120 }
const interval = prefs.data?.depth_polling_interval ?? 10
const finalizeTime = prefs.data?.depth_finalize_time ?? { hour: 15, minute: 2 }
@@ -45,13 +46,16 @@ export function DepthConfigContent({ disabled }: { disabled?: boolean }) {
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
})
// 无能力: 显示升级提示
// 无能力: 显示能力缺失说明 + 去数据源配置入口
if (!hasDepth) {
return (
<p className="text-xs text-muted leading-relaxed">
, <span className="text-accent">Pro </span>
()()
</p>
<div className="space-y-2">
<p className="text-xs text-muted leading-relaxed">
,
,()()
</p>
<MissingCapChip capKey="depth5.batch" />
</div>
)
}
@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { MissingCapChip } from '@/lib/capability-labels'
export function ExtendHistoryPanel({ caps, isRunning, earliestDate, onStart }: {
caps: { label: string; capabilities: Record<string, { rpm: number | null; batch: number | null; subscribe: number | null }> } | undefined
@@ -89,9 +90,7 @@ export function ExtendHistoryPanel({ caps, isRunning, earliestDate, onStart }: {
</button>
{!hasBatchCap && (
<span className="text-[10px] text-warning/80 bg-warning/8 rounded px-1.5 py-px font-medium">
Pro+
</span>
<MissingCapChip capKey="kline.daily.batch" />
)}
</div>
)
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2, Trash2, Download, Calendar } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { MissingCapChip } from '@/lib/capability-labels'
export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string; capabilities: Record<string, { rpm: number | null; batch: number | null; subscribe: number | null }> } | undefined; onJobStart?: (jobId: string) => void }) {
const qc = useQueryClient()
@@ -110,7 +111,7 @@ export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string;
</div>
<span className="text-[10px] text-muted"></span>
{!hasMinuteCap && (
<span className="text-[10px] text-warning/80 bg-warning/8 rounded px-1.5 py-px font-medium"> Pro+</span>
<MissingCapChip capKey="kline.minute.batch" />
)}
</div>
</div>
@@ -41,8 +41,8 @@ export const DATA_CARD_DEFS: CardDef[] = [
{ key: 'enriched', label: 'Enriched', desc: '技术指标计算结果', defaultHiddenIfNoCap: false },
{ key: 'index', label: '指数', desc: '主要市场指数日K', defaultHiddenIfNoCap: false },
{ key: 'etf', label: 'ETF', desc: '场内交易基金日K', defaultHiddenIfNoCap: false, defaultHidden: true },
{ key: 'minute', label: '分钟 K', desc: '分钟级K线(需 Pro+)', defaultHiddenIfNoCap: true },
{ key: 'financials', label: '财务数据', desc: '财报数据(需 Expert)', defaultHiddenIfNoCap: true },
{ key: 'minute', label: '分钟 K', desc: '分钟级K线(依赖分钟K批量数据)', defaultHiddenIfNoCap: true },
{ key: 'financials', label: '财务数据', desc: '财报数据(依赖财务数据)', defaultHiddenIfNoCap: true },
{ key: 'regime', label: '市场环境', desc: '每日环境状态(本地计算)', defaultHiddenIfNoCap: false },
]
@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { MissingCapChip } from '@/lib/capability-labels'
import { DatePicker } from '@/components/DatePicker'
function pad(n: number) { return String(n).padStart(2, '0') }
@@ -98,8 +99,8 @@ export function RepairDailyPanel({ caps, isRunning, latestDate, onStart }: {
</button>
{!hasBatchCap && (
<span className="block text-[10px] text-warning/80 bg-warning/8 rounded px-1.5 py-px font-medium text-center">
Pro+
<span className="block text-center">
<MissingCapChip capKey="kline.daily.batch" />
</span>
)}
</div>
+26 -33
View File
@@ -2,24 +2,25 @@ import { motion } from 'framer-motion'
import { Loader2, CheckCircle2, Settings, Table2 } from 'lucide-react'
import { formatNumber } from '@/lib/format'
import { fmtDate } from '@/lib/format'
import { MissingCapChip } from '@/lib/capability-labels'
import { Skeleton } from './Skeleton'
// 卡片能力定义:capKey → 查 capability limitstierReq → 无权限时示的档位要求
// capKey 为空串表示该数据在 free-api 服务器(None/Free 档)即可获取,无需付费能力门控。
// 卡片能力定义:capKey → 查 capability limitsmissingCapName → 无权限时示的能力名
// capKey 为空串表示该数据在免费服务器(None/Free)或本地即可获取,无需能力门控。
export const CARD_META: Record<string, {
capKey: string // 对应的 capability key,空串表示本地计算 / free 服务器可用
tierReq: string // 最低档位要求(无权限时显示)
capKey: string // 对应的 capability key,空串表示本地计算 / 免费服务器可用
missingCapName: string // 缺能力时提示的能力名(空串表示缺能力也不显示徽章)
}> = {
// 标的维表走 exchanges 端点,free-api 服务器即可获取,无需付费能力
instruments: { capKey: '', tierReq: '' },
daily: { capKey: 'kline.daily.batch', tierReq: 'Starter+' },
adj_factor: { capKey: 'adj_factor', tierReq: 'Starter+' },
enriched: { capKey: '', tierReq: '' },
// ETF 复用日K批量能力(免费档 kline.daily.batch 即可),不显示档位徽章
etf: { capKey: 'kline.daily.batch', tierReq: '' },
minute: { capKey: 'kline.minute.batch', tierReq: 'Pro+' },
financials: { capKey: 'financial', tierReq: 'Expert' },
regime: { capKey: '', tierReq: '' },
// 标的维表走 exchanges 端点,免费服务器即可获取,无需付费能力
instruments: { capKey: '', missingCapName: '' },
daily: { capKey: 'kline.daily.batch', missingCapName: '日 K(批量)' },
adj_factor: { capKey: 'adj_factor', missingCapName: '复权因子' },
enriched: { capKey: '', missingCapName: '' },
// ETF 复用日K批量能力(免费档即可),缺能力时随日K卡提示,不单独显示徽章
etf: { capKey: 'kline.daily.batch', missingCapName: '' },
minute: { capKey: 'kline.minute.batch', missingCapName: '分钟 K(批量)' },
financials: { capKey: 'financial', missingCapName: '财务数据' },
regime: { capKey: '', missingCapName: '' },
}
export function Pill({ label, value }: { label: string; value: number | string }) {
@@ -31,16 +32,15 @@ export function Pill({ label, value }: { label: string; value: number | string }
)
}
function CapBadge({ hasCap, isLocal, tierLabel, tierReq, capInfo, localSuffix, customProvider }: {
function CapBadge({ hasCap, isLocal, missingCapName, capInfo, localSuffix, customProvider }: {
hasCap: boolean
isLocal: boolean
tierLabel?: string
tierReq?: string
missingCapName?: string
capInfo?: { rpm: number | null; batch: number | null; subscribe: number | null } | undefined
localSuffix?: string
customProvider?: string | null
}) {
// 走自定义数据源时, 显示数据源名而非 TickFlow 档位
// 走自定义数据源时, 显示数据源名 (能力来源对所有数据源统一表达)
if (customProvider) {
return (
<span className="text-[10px] text-accent/80 bg-accent/8 rounded px-1.5 py-px font-medium">
@@ -57,8 +57,8 @@ function CapBadge({ hasCap, isLocal, tierLabel, tierReq, capInfo, localSuffix, c
)
}
if (hasCap && capInfo && tierLabel) {
const parts = [tierLabel, `${capInfo.rpm}/min`]
if (hasCap && capInfo) {
const parts = ['可用', `${capInfo.rpm}/min`]
if (capInfo.batch != null && capInfo.batch > 1) parts.push(`${capInfo.batch}股/批`)
return (
<span className="text-[10px] text-accent/80 bg-accent/8 rounded px-1.5 py-px font-mono font-medium">
@@ -67,20 +67,15 @@ function CapBadge({ hasCap, isLocal, tierLabel, tierReq, capInfo, localSuffix, c
)
}
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}
</span>
)
if (!hasCap && missingCapName) {
// 能力标准对所有数据源一致: 缺能力提示能力名而非档位, 点击跳数据源设置
return <MissingCapChip label={missingCapName} />
}
if (hasCap) {
return (
<span className="text-[10px] text-accent/80 bg-accent/8 rounded px-1.5 py-px font-medium">
{tierLabel ?? '已授权'}
</span>
)
}
@@ -93,7 +88,7 @@ export type FieldTab = { label: string; table: string }
export function StatCard({
title, hint, stats, isInstrument = false, loading = false,
active = false, done = false, skipped = false, stagePct = 0,
tierKey, capLimits, tierLabel, customProvider,
tierKey, capLimits, customProvider,
auto, onSettings, onShowFields, settingsOpen, subLabel, localBadgeSuffix, fieldTabs,
}: {
title: string
@@ -107,7 +102,6 @@ export function StatCard({
stagePct?: number
tierKey?: string
capLimits?: Record<string, { rpm: number | null; batch: number | null; subscribe: number | null }>
tierLabel?: string
customProvider?: string | null
onSettings?: () => void
onShowFields?: (table?: string) => void
@@ -248,8 +242,7 @@ export function StatCard({
<CapBadge
hasCap={hasCap}
isLocal={isLocal}
tierLabel={tierLabel}
tierReq={meta?.tierReq}
missingCapName={meta?.missingCapName}
capInfo={capInfo}
localSuffix={localBadgeSuffix}
customProvider={customProvider}
+46 -1
View File
@@ -1,4 +1,6 @@
// capability 内部名 → 用户能理解的中文标签
import { useNavigate } from 'react-router-dom'
export const CAP_LABELS: Record<string, { name: string; hint: string }> = {
'quote.by_symbol': { name: '自选股实时监控', hint: 'Free 可按标的查询实时行情,用于少量自选股监控' },
'quote.batch': { name: '实时行情(批量)', hint: '一次拿多只股票的价' },
@@ -9,12 +11,55 @@ export const CAP_LABELS: Record<string, { name: string; hint: string }> = {
'kline.minute.batch': { name: '分钟 K(批量)', hint: '多股分钟 K' },
'depth5': { name: '五档盘口', hint: '买卖五档报价' },
'depth5.batch': { name: '五档盘口(批量)', hint: '批量买卖五档快照' },
'websocket': { name: '实时推送(WS)', hint: '免轮询的实时行情订阅' },
'financial': { name: '财务数据', hint: '利润表 / 资负表 / 现金流 / 关键指标' },
'adj_factor': { name: '复权因子', hint: '让 MA/MACD 等指标在分红送转日不失真' },
}
// 套餐等级 —— 用于按档位门控功能(如专线端点 / 按月扩展分钟K)。
// ===== 数据源无关的能力提示 (所有数据源共用一套标准) =====
// 功能门槛一律以能力键表达, 不再出现 TickFlow 档位词 (档位仅出现在 TickFlow 专属界面)。
/** 能力键 → 用户可读能力名 */
export function capName(capKey: string): string {
return CAP_LABELS[capKey]?.name ?? capKey
}
/** 数据不可用标准徽章: 「分钟 K(批量) · 不可用」, 通用状态陈述, 默认点击跳转 设置→数据源 (to=null 关闭跳转) */
export function MissingCapChip({ capKey, label, to = '/settings?tab=data-sources', className = '' }: {
capKey?: string
label?: string
to?: string | null
className?: string
}) {
const navigate = useNavigate()
const text = label ?? (capKey != null ? capName(capKey) : '')
const content = (
<>
{text ? `${text} · 不可用` : '不可用'}
</>
)
if (to == null) {
return (
<span className={`text-[10px] text-warning/90 bg-warning/8 rounded px-1.5 py-px font-medium ${className}`} title="该数据当前不可用">
{content}
</span>
)
}
return (
<button
type="button"
onClick={(e) => { e.stopPropagation(); navigate(to) }}
className={`text-[10px] text-warning/90 bg-warning/8 rounded px-1.5 py-px font-medium hover:bg-warning/15 transition-colors ${className}`}
title="前往 设置 → 数据源"
>
{content}
</button>
)
}
// 套餐等级 —— 仅用于 TickFlow 专属界面 (Key 配置 / 端点测速 / 引导页 tickflow 分支)。
// 通用功能门槛一律用能力键 (capName/needCapText/MissingCapChip), 不用档位词。
// 基础档提取与后端 quote_service.py 一致:取 label 第一个词("Pro +" → "pro")。
// none = None 档(无 key / 无效 key),低于 free,仅历史日K无实时行情。
export const TIER_RANK: Record<string, number> = { none: -1, free: 0, starter: 1, pro: 2, expert: 3 }
+1 -1
View File
@@ -72,7 +72,7 @@ export const BUILTIN_COLUMNS: ColumnConfig[] = [
{ id: 'builtin:signals', source: { type: 'builtin', key: 'signals' }, label: '信号', visible: true, align: 'center' },
{ id: 'builtin:candle', source: { type: 'builtin', key: 'candle' }, label: '日k', visible: false, align: 'center' },
{ id: 'builtin:intraday', source: { type: 'builtin', key: 'intraday' }, label: '分时', visible: false, align: 'center' },
// 财务指标 (需 Expert 套餐 financial capability, 列默认隐藏)
// 财务指标 (需财务数据能力 financial, 列默认隐藏)
{ id: 'builtin:eps', source: { type: 'builtin', key: 'eps' }, label: 'EPS', visible: false, align: 'center' },
{ id: 'builtin:bps', source: { type: 'builtin', key: 'bps' }, label: 'BPS', visible: false, align: 'center' },
{ id: 'builtin:roe', source: { type: 'builtin', key: 'roe' }, label: 'ROE', visible: false, align: 'center' },
+3 -3
View File
@@ -668,7 +668,7 @@ export function Dashboard() {
const currentDate = selectedDate ?? data.as_of ?? ''
const quoteRunning = (!selectedDate || selectedDate === latestDate) && data.quote_status?.running
// 实时模式: none / watchlist / full_market。
// watchlist (Free 档) 仅自选 ≤5 只实时, 看板呈现的大盘数据实为盘后快照, 需提示避免误读。
// watchlist 模式仅自选 ≤5 只实时, 看板呈现的大盘数据实为盘后快照, 需提示避免误读。
const quoteMode = data.quote_status?.mode as ('none' | 'watchlist' | 'full_market') | undefined
return (
@@ -740,14 +740,14 @@ export function Dashboard() {
</div>
</div>
{/* Free 档提示: 大盘看板为盘后数据, 仅自选股实时。避免用户误读为全市场实时。 */}
{/* 自选实时模式提示: 大盘看板为盘后数据, 仅自选股实时。避免用户误读为全市场实时。 */}
{quoteMode === 'watchlist' && (
<div className="mb-1.5 flex items-start gap-2 rounded-card border border-amber-500/30 bg-amber-500/8 px-3 py-1.5 text-[11px] leading-relaxed">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
<div className="min-w-0 flex-1 text-secondary">
,<strong className="text-foreground"></strong>(),;
({data.quote_status?.watchlist_symbol_count ?? 0} )
<span className="ml-1 text-accent"> Starter+</span>
<span className="ml-1 text-accent"></span>
</div>
</div>
)}
+7 -17
View File
@@ -29,6 +29,7 @@ import {
useDataStatus,
} from '@/lib/useSharedQueries'
import { useToggleRealtimeQuotes, useUpdateQuoteInterval } from '@/lib/useSharedMutations'
import { MissingCapChip } from '@/lib/capability-labels'
import { QK } from '@/lib/queryKeys'
import { PageHeader } from '@/components/PageHeader'
import { formatScheduleDatePart, formatScheduleTimePart, isToday } from '@/lib/format'
@@ -400,7 +401,6 @@ export function Data() {
stagePct={activeCard === 'instruments' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="instruments"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
onShowFields={() => setSchemaTable('instruments')}
/>
@@ -418,7 +418,6 @@ export function Data() {
stagePct={activeCard === 'daily' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="daily"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
customProvider={getCustomProviderName('daily')}
auto
onShowFields={() => setSchemaTable('daily')}
@@ -439,7 +438,6 @@ export function Data() {
stagePct={activeCard === 'adj_factor' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="adj_factor"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
customProvider={getCustomProviderName('adj_factor')}
auto
onShowFields={() => setSchemaTable('adj_factor')}
@@ -458,7 +456,6 @@ export function Data() {
stagePct={activeCard === 'enriched' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="enriched"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
subLabel={status.data?.indicators_ready === false ? '字段 · 指标计算中…' : '字段 · 指标 · 信号'}
localBadgeSuffix={`${prefs.data?.enriched_batch_size ?? 1000}只/批`}
@@ -480,7 +477,6 @@ export function Data() {
stagePct={activeCard === 'index_daily' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="daily"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto={indexAuto}
subLabel={indexOverviewLabel}
fieldTabs={[
@@ -502,7 +498,6 @@ export function Data() {
loading={isLoading}
tierKey="etf"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
customProvider={getCustomProviderName('etf')}
auto={etfAuto}
subLabel="维表 · 日K · 指标"
@@ -527,7 +522,6 @@ export function Data() {
stagePct={activeCard === 'minute' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="minute"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
customProvider={getCustomProviderName('minute')}
auto={minuteAuto}
onShowFields={() => setSchemaTable('minute')}
@@ -545,7 +539,6 @@ export function Data() {
loading={isLoading}
tierKey="financials"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
customProvider={getCustomProviderName('financials')}
subLabel={`历史股本 · ${historicalShareRows.toLocaleString()}`}
onSettings={hasData ? () => setOpenSettings(v => v === 'financials' ? null : 'financials') : undefined}
@@ -566,7 +559,6 @@ export function Data() {
stagePct={activeCard === 'regime' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="regime"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto={prefs.data?.pipeline_regime_enabled === true}
subLabel="状态 · 综合分 · 指标"
onSettings={hasData ? () => setOpenSettings(v => v === 'regime' ? null : 'regime') : undefined}
@@ -662,17 +654,17 @@ export function Data() {
/>
<div className="px-8 py-6 space-y-6 max-w-6xl">
{/* None 档提示 —— 非阻断: 无需 Key 也可获取历史日K, 实时行情等扩展能力受限 */}
{/* 无 Key 提示 —— 非阻断: 历史日K走免费通道, 实时等能力取决于所选数据源 */}
{isNoKey && (
<div className="flex items-center gap-2 rounded-card border border-border bg-elevated/40 px-3 py-2 text-xs">
<Info className="h-4 w-4 shrink-0 text-muted" />
<span className="text-secondary leading-relaxed">
None ,使K()
API Key ,
API Key,K将使用免费通道获取
K等能力取决于所选数据源,
<Link to="/settings?tab=data-sources" className="mx-0.5 font-medium text-accent hover:underline">
</Link>
</span>
</div>
)}
@@ -1144,9 +1136,7 @@ export function Data() {
)}
</button>
{!hasDailyBatchCap && (
<span className="text-[10px] text-warning/80 bg-warning/8 rounded px-1.5 py-px font-medium">
Starter+ / Pro K
</span>
<MissingCapChip capKey="kline.daily.batch" />
)}
</div>
</div>
+4 -4
View File
@@ -62,15 +62,15 @@ export function Financials() {
if (!hasFinancial) {
return (
<>
<PageHeader title="财务分析" subtitle="利润表 / 资负表 / 现金流 / 关键指标 / 股本 / AI分析 · Expert" />
<PageHeader title="财务分析" subtitle="利润表 / 资负表 / 现金流 / 关键指标 / 股本 / AI分析" />
<div className="px-8 py-10">
<div className="mx-auto max-w-md rounded-card border border-warning/30 bg-warning/[0.04] p-8 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-warning/10">
<Lock className="h-6 w-6 text-warning" />
</div>
<h3 className="mt-4 text-base font-semibold text-foreground"> Expert </h3>
<h3 className="mt-4 text-base font-semibold text-foreground"></h3>
<p className="mt-2 text-xs leading-relaxed text-secondary">
Expert
,
</p>
{/* 当前财务数据源(TickFlow)需付费,后续将接入免费数据源;期间欢迎在 issues 推荐免费源 */}
<div className="mt-5 rounded-btn border border-accent/25 bg-accent/[0.05] px-3.5 py-3 text-left">
@@ -159,7 +159,7 @@ export function Financials() {
<>
<PageHeader
title="财务分析"
subtitle="利润表 / 资负表 / 现金流 / 关键指标 / 股本 / AI分析 · Expert"
subtitle="利润表 / 资负表 / 现金流 / 关键指标 / 股本 / AI分析"
right={
<div className="flex items-center gap-2">
<LastStockChip stock={lastStock} onSelect={pick} />
+3 -3
View File
@@ -75,7 +75,7 @@ export function Indices() {
const [selectedDate, setSelectedDate] = useState<string | null>(null)
const [linkedPrice, setLinkedPrice] = useState<number | null>(null)
// 分时数据需 Pro+ (kline.minute.batch) 能力
// 分时数据依赖分钟K批量数据 (kline.minute.batch)
const caps = useCapabilities()
const hasMinuteCap = !!caps.data?.capabilities?.['kline.minute.batch']
@@ -315,8 +315,8 @@ export function Indices() {
{!hasMinuteCap ? (
<div className="flex h-full flex-col items-center justify-center gap-2 text-center">
<Lock className="h-5 w-5 text-muted" />
<div className="text-xs text-secondary"> Pro+</div>
<div className="text-[10px] text-muted"></div>
<div className="text-xs text-secondary"></div>
<div className="text-[10px] text-muted">K()</div>
</div>
) : (
<>
+3 -3
View File
@@ -259,7 +259,7 @@ const StockCard = React.memo(function StockCard({ stock, extFields, direction, s
const hasTags = conceptTags.length > 0 || industryTags.length > 0
// 齿轮始终可见: 让免费用户也能看到功能入口, 点开后在菜单内提示权限不足。
// Pro+ 用户正常设置; 免费用户保存按钮禁用 + 显示升级提示。
// 有五档盘口能力的用户正常设置; 无能力时保存按钮禁用 + 显示能力提示。
return (
<div className="relative group w-full">
{/* 监控设置按钮 (右上角): 不能嵌在卡片 button 内 */}
@@ -615,10 +615,10 @@ function MonitorMenu({ stock, direction, sealMode, monitorRule, anchorRect, hasD
<button
onClick={handleSave}
disabled={saving || !threshold || !hasDepth}
title={!hasDepth ? '需 Pro+ 套餐 (批量五档能力)' : ''}
title={!hasDepth ? '五档盘口(批量)数据不可用' : ''}
className="flex-1 h-7 rounded text-[11px] font-medium transition-all cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed bg-accent text-white hover:bg-accent/90 active:scale-[0.98] disabled:active:scale-100"
>
{saving ? '保存中…' : !hasDepth ? '需 Pro+ 套餐' : existing ? '更新监控' : '开启监控'}
{saving ? '保存中…' : !hasDepth ? '五档盘口不可用' : existing ? '更新监控' : '开启监控'}
</button>
</div>
</div>
+5 -9
View File
@@ -7,7 +7,6 @@ import { DEFAULT_STRATEGY_NOTIFY_EVENTS } from '@/lib/strategyMonitorEvents'
import { toast } from '@/components/Toast'
import { useDataStatus, usePreferences, useCapabilities, useQuoteStatus } from '@/lib/useSharedQueries'
import { useWatchlistBatchAdd } from '@/lib/useSharedMutations'
import { isExpertOrAbove } from '@/lib/capability-labels'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
import { PageHeader } from '@/components/PageHeader'
@@ -399,13 +398,13 @@ export function Screener() {
columns.find(c => c.source.type === 'builtin' && c.source.key === 'intraday' && c.visible),
[columns],
)
// 分时图需 Pro+ (kline.minute.batch), 低档用户开了列也不拉数据
// 分时图依赖分钟K批量数据 (kline.minute.batch), 无数据时开了列也不拉
const caps = useCapabilities()
const hasMinuteBatch = !!caps.data?.capabilities?.['kline.minute.batch']
const intradayVisible = !!intradayColumn && hasMinuteBatch && intradayChartVisible
// 分时数据加载策略 (与自选页一致, 简洁优先):
// - 全量加载当前列表 symbol, 但按套餐 batch 上限截断 (Pro=100 / Expert=200),
// - 全量加载当前列表 symbol, 但按数据源 batch 上限截断,
// 超出时只取前 batch 只并提示用户, 避免一次性发太多请求打爆 rpm 配额
// - 刷新: minute_intraday_refresh 偏好开启时按用户设定间隔轮询; 否则仅首次加载,
// 用户可点表头刷新按钮手动更新
@@ -420,9 +419,7 @@ export function Screener() {
[displayRows],
)
const intradayTruncated = intradayVisible && allIntradaySymbols.length > minuteBatchCap
// 是否已是最高档 (Expert+): 最高档时截断提示不再建议"升级套餐"
const isMaxTier = isExpertOrAbove(caps.data?.label ?? '')
// 截断到 batch 上限 (Pro=100 / Expert=200), 一次请求 = 一次 TickFlow 调用
// 截断到 batch 上限, 一次请求 = 一次数据源调用
const intradaySymbols = useMemo(
() => intradayTruncated ? allIntradaySymbols.slice(0, minuteBatchCap) : allIntradaySymbols,
[allIntradaySymbols, intradayTruncated, minuteBatchCap],
@@ -866,11 +863,10 @@ export function Screener() {
<span className="num">{result.elapsed_ms.toFixed(1)} ms</span>
</div>
)}
{/* 分时截断提示: 超套餐上限时在工具栏内联显示, 可关闭 */}
{/* 分时截断提示: 超数据源批量上限时在工具栏内联显示, 可关闭 */}
{intradayTruncated && !intradayCapDismissed && (
<span className="inline-flex items-center gap-1 text-xs text-warning/90">
{minuteBatchCap}/{allIntradaySymbols.length}
{!isMaxTier && ', 可升级'}
{minuteBatchCap}/{allIntradaySymbols.length} ·
<button
type="button"
onClick={() => setIntradayCapDismissed(true)}
+5 -5
View File
@@ -412,7 +412,7 @@ function StockSearchBox({
// 视觉: 内圈实心点 + 外圈 animate-ping 扩散晕, 语义=「在线/活动」。
// 配色用 accent (电光蓝) 而非绿/红: 项目设计规范规定红绿仅用于价格/K线,
// UI 状态用 accent, 避免与 A 股涨跌色混淆。
// 全市场模式 (Starter+) 不显示 —— 全部都在监控, 标记无信息量。
// 全市场模式不显示 —— 全部都在监控, 标记无信息量。
function RealtimeDot({ title = '实时监控中' }: { title?: string }) {
return (
<span
@@ -731,7 +731,7 @@ export function Watchlist() {
)
// 分时列渲染配置(宽高, 来自列定制, 已钳制边界)
const intradayResolved = useMemo(() => resolveIntradayConfig(intradayColumn?.intradayConfig), [intradayColumn])
// 分时图需 Pro+ (kline.minute.batch), 低档用户开了列也不拉数据
// 分时图依赖分钟K批量数据 (kline.minute.batch), 无数据时开了列也不拉
const caps = useCapabilities()
const hasMinuteBatch = !!caps.data?.capabilities?.['kline.minute.batch']
const intradayVisible = !!intradayColumn && hasMinuteBatch && intradayChartVisible
@@ -872,7 +872,7 @@ export function Watchlist() {
return patched
}, [dailyKVisible, klineBatch.data, enriched.data])
// 批量分时数据 (Pro+ 用户, 列可见才拉)
// 批量分时数据 (有分钟K批量能力时, 列可见才拉)
// 刷新策略: 仅当实时行情运行 且 用户在实时监控设置里开启 minute_intraday_refresh 时
// 按用户设定的间隔轮询 (不接 SSE 高频, 避免每秒拉 TickFlow 触限流); 与 Screener / 设置卡片描述一致。
const { data: prefsData } = usePreferences()
@@ -1053,8 +1053,8 @@ export function Watchlist() {
const watchlistContentLoading = list.isLoading || (allSymbols.length > 0 && enriched.isLoading)
// 实时监控圆点: 仅 Free/低档 "按自选股实时监控" 模式 (mode === 'watchlist') 下显示;
// Starter+ 全市场模式 (mode === 'full_market') 全部标的都在监控, 标圆点无意义, 故不显示。
// 后端 Free 档实际只监控自选页前 N 个 (N = watchlist_symbol_count), 顺序与 allSymbols 一致。
// 全市场模式 (mode === 'full_market') 全部标的都在监控, 标圆点无意义, 故不显示。
// 后端自选实时模式实际只监控自选页前 N 个 (N = watchlist_symbol_count), 顺序与 allSymbols 一致。
const realtimeMode = quoteStatus.data?.mode
const watchlistMonitoredCount = quoteStatus.data?.watchlist_symbol_count ?? 0
const showRealtimeDot = realtimeRunning && realtimeMode === 'watchlist'
@@ -927,7 +927,7 @@ export function StrategyBacktest() {
const [regimeStates, setRegimeStates] = useState<string[]>(saved?.regimeStates ?? [])
const [regimeMinScore, setRegimeMinScore] = useState<number | ''>(saved?.regimeMinScore ?? '')
const [settingsOpen, setSettingsOpen] = useState(false)
// 分钟K成交价细化: 不改变信号日或成交日, 需 Pro+ 分钟K能力
// 分钟K成交价细化: 不改变信号日或成交日, 依赖分钟K批量数据
const { data: caps } = useCapabilities()
const hasMinuteBatch = !!caps?.capabilities?.['kline.minute.batch']
const toggleMinuteFill = () => {
@@ -1402,7 +1402,7 @@ export function StrategyBacktest() {
onClick={toggleMinuteFill}
disabled={!hasMinuteBatch}
title={!hasMinuteBatch
? '分钟K成交价:需 Pro+ 权限 (分钟K批量)'
? '分钟K成交价:分钟K(批量)数据不可用'
: '分钟K成交:细化成交价,并为兼容的卖出信号提供下一分钟成交。'
}
className={`group relative inline-flex h-3.5 w-6 items-center rounded-full shrink-0 transition-colors duration-200 ${
@@ -1417,7 +1417,7 @@ export function StrategyBacktest() {
</button>
<span className={`text-[9px] font-medium ${highGranularity ? 'text-amber-400' : 'text-muted/50'}`}></span>
{!hasMinuteBatch && (
<span className="text-[8px] text-accent/70 font-medium bg-accent/10 px-1 py-px rounded">Pro+</span>
<span className="text-[8px] text-accent/70 font-medium bg-accent/10 px-1 py-px rounded">K</span>
)}
</div>
</div>
+12 -39
View File
@@ -19,7 +19,6 @@ import {
import { useUpdateQuoteInterval, useToggleRealtimeQuotes } from '@/lib/useSharedMutations'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { tierRank } from '@/lib/capability-labels'
import { toast } from '@/components/Toast'
import { DepthConfigContent } from '@/components/data/DepthConfigCard'
@@ -47,12 +46,9 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
const { data: intervalData } = useQuoteInterval()
const updateInterval = useUpdateQuoteInterval()
const toggleQuote = useToggleRealtimeQuotes()
const tier = tierRank(caps?.label ?? '')
const isNoneTier = tier < 0
// None 档但配了自定义实时源时, 后端 is_realtime_allowed 仍返回 True (realtime_mode=full_market)
// 此时不应拦截实时监控页 — 用 quoteStatus.realtime_allowed 作为最终判据
const realtimeAllowed = quoteStatus?.realtime_allowed ?? !isNoneTier
const isFreeTier = tier === 0
// 实时模式以 quote_status 为准 (数据源无关): watchlist=自选实时 / full_market=全市场 / none=不可用
const quoteMode = quoteStatus?.mode ?? 'none'
const isWatchlistMode = quoteMode === 'watchlist'
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
// 分时图实时刷新间隔 (秒), 与后端 [3,60] clamp 对齐; 默认 6
const intradayInterval = prefs?.minute_intraday_refresh_interval ?? 6
@@ -111,7 +107,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
const watchlist = useQuery({
queryKey: QK.watchlist,
queryFn: () => api.watchlistList(),
enabled: isFreeTier && watchlistSymbols.length > 0,
enabled: isWatchlistMode && watchlistSymbols.length > 0,
})
const watchlistNameBySymbol = new Map(
(watchlist.data?.symbols ?? []).map(row => [row.symbol, row.name] as const),
@@ -281,29 +277,6 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
}
}, [highlight])
if (isNoneTier && !realtimeAllowed) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl
bg-gradient-to-br from-purple-500/20 to-blue-500/20 mb-5">
<Activity className="h-7 w-7 text-purple-400" />
</div>
<h2 className="text-lg font-medium text-foreground mb-2"></h2>
<p className="text-sm text-secondary max-w-md mb-6">
Free None 使 free-api K1-2
</p>
<a
href="/settings?tab=data-sources"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-btn
bg-accent text-white text-sm font-medium
hover:bg-accent/90 transition-colors"
>
API Key
</a>
</div>
)
}
return (
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-6 max-w-5xl">
{/* ========== 左列 ========== */}
@@ -328,7 +301,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
<div className="min-w-0">
<div className="text-sm text-foreground"></div>
<div className="text-[11px] text-muted">
{isFreeTier ? '每轮拉取自选股实时行情的时间间隔' : '每轮拉取全市场行情的时间间隔'}
{isWatchlistMode ? '每轮拉取自选股实时行情的时间间隔' : '每轮拉取全市场行情的时间间隔'}
</div>
</div>
<span className="text-[11px] font-mono text-foreground shrink-0 tabular-nums">
@@ -352,10 +325,10 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
</div>
</Card>
{isFreeTier && (
{isWatchlistMode && (
<Card icon={Activity} title="自选股实时">
<div className="mb-3 rounded-btn border border-accent/25 bg-accent/10 px-3 py-2 text-xs font-medium leading-snug text-accent">
Free 5 6
5 6
</div>
{watchlistSymbols.length > 0 ? (
<div className="space-y-1.5">
@@ -374,7 +347,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
</div>
) : (
<div className="rounded-btn border border-border bg-base/40 px-3 py-3 text-xs text-muted">
Free
</div>
)}
<div className="mt-2 flex items-center justify-between gap-3">
@@ -388,7 +361,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
</div>
</Card>
)}
{!isFreeTier && (
{!isWatchlistMode && (
<Card icon={Wifi} title="页面实时刷新">
<p className="text-xs text-secondary mb-4">
SSE
@@ -412,7 +385,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
<Card icon={Activity} title="分时图刷新">
<ToggleRow
label="自选/策略分时图实时刷新"
desc={`开启后自选与策略列表的分时图盘中每 ${intradayInterval} 秒自动刷新(需 Pro+ 权限 + 实时行情运行)。关闭时仅打开页面时拉取一次, 可点表头刷新按钮手动更新。`}
desc={`开启后自选与策略列表的分时图盘中每 ${intradayInterval} 秒自动刷新(依赖分钟K批量数据 + 实时行情运行)。关闭时仅打开页面时拉取一次, 可点表头刷新按钮手动更新。`}
checked={prefs?.minute_intraday_refresh ?? false}
onChange={(v) => save({ minute_intraday_refresh: v })}
/>
@@ -445,7 +418,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
</div>
</Card>
{!isFreeTier && (
{!isWatchlistMode && (
<Card icon={BarChart3} title="左侧菜单指数">
<p className="text-xs text-secondary mb-4">
@@ -483,7 +456,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
<Card
icon={Flame}
title="连板梯队降级修正"
badge={!hasDepth ? '需 Pro+' : undefined}
badge={!hasDepth ? '五档盘口不可用' : undefined}
right={hasDepth ? (
<button
onClick={() => runFix.mutate()}