Files
tick-stock-panel/frontend/src/pages/settings/DataSources.tsx
T
shy3130 b14ffda1bd feat(ui): 数据源能力卡片显示 TickFlow 订阅档位要求与全档位标识
每张能力卡右上角显示该能力所需最低档位(全档位/Starter+/Pro+/Expert+,
配色与左侧菜单档位体系一致, 当前档位不足时琥珀描边提示), 替代原
"当前提供方"徽标(信息已由下方高亮标签承载)。TickFlow 详情头部与
列表卡片在"第三方"后增加 Expert 三色渐变的"已适配全档位"标识。
2026-08-24 17:14:48 +08:00

1023 lines
45 KiB
TypeScript

import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Check, Database, Eye, EyeOff, KeyRound, Plus, RefreshCw, Zap, FileWarning, Puzzle, AlertCircle, CheckCircle2, Loader2, Save, Trash2 } from 'lucide-react'
import { api, type DataSourceItem, type PluginDataSourceItem } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { useCapabilities, usePreferences } from '@/lib/useSharedQueries'
import { TIER_RANK, tierRank, tierStyle } from '@/lib/capability-labels'
import { toast } from '@/components/Toast'
import { DataSourceEditor } from './DataSourceEditor'
import { TickFlowKeyConfig } from './Keys'
const DATASET_LABEL: Record<string, string> = {
daily: '日K',
adj_factor: '除权',
realtime: '实时',
minute: '分钟',
financial: '财务',
}
/** 数据集 → 路由偏好字段 + 默认值 + 展示标签 (financial 无后端路由消费方, 仅展示不参与切换) */
/** 能力卡片定义: 数据集 + 说明 (路由选择嵌入每张卡片) */
const CAPABILITY_CARDS = [
{ dataset: 'daily', label: '日K', desc: '历史 + 实时覆写' },
{ dataset: 'adj_factor', label: '除权因子', desc: '复权计算' },
{ dataset: 'realtime', label: '实时行情', desc: '全市场快照' },
{ dataset: 'minute', label: '分钟K', desc: '分时图 · 回测' },
] as const
/** 数据集 → 路由偏好字段 + 默认值 (financial 无后端路由消费方, 仅展示) */
const DATASET_ROUTE: Record<string, {
field: 'daily_data_provider' | 'adj_factor_provider' | 'minute_data_provider' | 'realtime_data_provider'
def: string
}> = {
daily: { field: 'daily_data_provider', def: 'tickflow' },
adj_factor: { field: 'adj_factor_provider', def: 'same_as_daily' },
minute: { field: 'minute_data_provider', def: 'tickflow' },
realtime: { field: 'realtime_data_provider', def: 'tickflow' },
}
/** 各能力在 TickFlow 需要的最低订阅档位 (对照 tiers.yaml: 日K 全档位可用,
* 全市场实时/除权因子需 Starter+, 分钟K需 Pro+, 财务需 Expert+) */
const TICKFLOW_TIER_REQ: Record<string, string> = {
daily: 'none',
adj_factor: 'starter',
realtime: 'starter',
minute: 'pro',
financial: 'expert',
}
/** TickFlow 档位要求徽标: 按所需档位配色(与左侧菜单/Key 页一致), 当前档位不足时琥珀描边提示 */
function TierReqChip({ tier, currentLabel }: { tier: string; currentLabel?: string }) {
const text = tier === 'none' ? '全档位' : `${tier}+`
const req = TIER_RANK[tier] ?? -1
const unmet = currentLabel != null && tierRank(currentLabel) < req
const t = tierStyle(tier)
return (
<span
title={unmet
? `TickFlow 该能力需 ${text} — 当前档位 ${currentLabel}`
: `TickFlow 该能力需 ${text}`}
className={`inline-flex h-[15px] shrink-0 items-center gap-1 rounded px-1.5 text-[9px] font-bold font-mono leading-none ${unmet ? 'ring-1 ring-warning/60' : ''}`}
style={t.tagBg}
>
<span className="h-1 w-1 rounded-full shrink-0" style={t.dotStyle} />
<span className="capitalize" style={t.labelTextStyle}>{text}</span>
</span>
)
}
/** TickFlow「已适配全档位」标识: Expert 三色渐变(全档位体系里最醒目的身份色) */
function AllTiersBadge({ size = 'text-[10px]' }: { size?: string }) {
const t = tierStyle('expert')
return (
<span
className="inline-flex shrink-0 items-center rounded px-1.5 py-0.5 font-medium leading-none"
style={t.tagBg}
title="无 Key 到 Expert 各订阅档位均有可用能力 — 日K全档位可用, 除权/分钟/财务等高级能力按订阅档位解锁"
>
<span className={size} style={t.labelTextStyle}> 已适配全档位</span>
</span>
)
}
/** 卡片内静态数据集标签: 只展示该源适配了哪些数据集 (路由选择在下方能力卡片) */
function DatasetChipRow({ datasets }: { datasets: string[] }) {
if (datasets.length === 0) return null
return (
<div className="flex flex-wrap items-center gap-1 ml-3.5 mt-1">
<span className="text-[9px] font-medium text-accent bg-accent/10 px-1 py-0.5 rounded">已适配</span>
{datasets.map(ds => (
<span key={ds} className="text-[9px] text-muted/60 bg-elevated/60 px-1 py-0.5 rounded">
{DATASET_LABEL[ds] || ds}
</span>
))}
</div>
)
}
/** 按能力路由网格: 每个数据源详情下展示一张能力卡,卡片上以可点标签列出
* 所有具备该能力的数据源 — 点谁,该数据集就立刻由谁提供,可跨源自由组合。
* TickFlow 详情含全部能力; 其他源只列自己参与的能力。 */
function SourceCapabilityGrid({ sourceName, sourceDisplay, datasets, candidatesOf, providerOf, pending, onSelect, anyCustom, onReset }: {
sourceName: string
sourceDisplay: string
datasets: string[]
/** 该数据集的所有候选提供方 (含 TickFlow), 按推荐顺序 */
candidatesOf: (dataset: string) => { name: string; display: string }[]
/** 该数据集当前的原始路由偏好值 (adj_factor 可能是 same_as_daily) */
providerOf: (dataset: string) => string
pending?: boolean
onSelect: (dataset: string, provider: string) => void
anyCustom?: boolean
onReset?: () => void
}) {
const isDefault = sourceName === 'tickflow'
const dsList = isDefault ? [...datasets, 'financial'] : datasets
const caps = useCapabilities()
if (dsList.length === 0) return null
return (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] text-muted">
{isDefault
? '每个能力可单独选择提供方 — 点标签即刻切换,未单独设置的由 TickFlow 提供'
: `${sourceDisplay} 参与的能力 — 每个能力都可单独选择由哪个数据源提供`}
</span>
{isDefault && anyCustom && (
<button
onClick={onReset}
disabled={pending}
className="text-[11px] text-muted/60 hover:text-accent transition-colors disabled:opacity-50"
>
恢复默认
</button>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2.5 mb-2">
{dsList.map(ds => {
const route = DATASET_ROUTE[ds]
const meta = CAPABILITY_CARDS.find(c => c.dataset === ds)
const label = meta?.label || DATASET_LABEL[ds] || ds
const desc = meta?.desc || ''
if (!route) {
return (
<div key={ds} className="rounded-lg border border-border/50 bg-elevated/20 px-3 py-2.5" title="该数据集暂不支持切换数据源">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-xs font-medium text-foreground truncate">{label}</div>
{desc && <div className="text-[10px] text-muted mt-0.5">{desc}</div>}
</div>
<div className="flex items-center gap-1 shrink-0">
{isDefault && <TierReqChip tier={TICKFLOW_TIER_REQ[ds]} currentLabel={caps.data?.label} />}
<span className="shrink-0 px-1.5 py-0.5 rounded text-[10px] text-muted/50 bg-elevated/60">固定</span>
</div>
</div>
</div>
)
}
const raw = providerOf(ds)
const candidates = candidatesOf(ds)
return (
<div key={ds} className="rounded-lg border border-border/50 bg-elevated/20 px-3 py-2.5">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-xs font-medium text-foreground">{label}</div>
{desc && <div className="text-[10px] text-muted mt-0.5">{desc}</div>}
</div>
{/* 右上角: TickFlow 所需档位 (当前提供方由下方高亮标签指示) */}
{isDefault && <TierReqChip tier={TICKFLOW_TIER_REQ[ds]} currentLabel={caps.data?.label} />}
</div>
{/* 提供方标签: 点谁该数据集就由谁提供,当前项高亮 */}
<div className="flex flex-wrap gap-1 mt-2 pt-2 border-t border-border/50">
{ds === 'adj_factor' && (
<button
type="button"
disabled={pending}
onClick={() => onSelect(ds, 'same_as_daily')}
title="除权因子跟随日K数据源"
className={tagCls(raw === 'same_as_daily')}
>
跟随日K
</button>
)}
{candidates.map(c => (
<button
key={c.name}
type="button"
disabled={pending}
onClick={() => onSelect(ds, c.name)}
title={`「${label}」由 ${c.display} 提供`}
className={tagCls(raw === c.name)}
>
{c.display}
</button>
))}
</div>
</div>
)
})}
</div>
</div>
)
}
/** 提供方标签样式: 当前项高亮(accent), 其余弱化可点 */
function tagCls(active: boolean) {
return `px-1.5 py-0.5 rounded text-[10px] transition-colors select-none disabled:opacity-50 cursor-pointer ${
active
? 'bg-accent/15 text-accent font-medium'
: 'bg-elevated/60 text-muted/70 hover:bg-accent/15 hover:text-accent'
}`
}
/** 详情组件所需的路由上下文(由面板构造) */
interface RouteCtx {
/** 数据集 → 候选提供方列表 (含 TickFlow) */
candidatesOf: (dataset: string) => { name: string; display: string }[]
/** 数据集 → 当前原始路由偏好值 (adj_factor 可能是 same_as_daily) */
providerOf: (dataset: string) => string
displayOf: (name?: string) => string
pending: boolean
onSelect: (dataset: string, provider: string) => void
anyCustom: boolean
onReset: () => void
}
/** 插件 API Key 配置区 (布局对齐 TickFlowKeyConfig: 状态 + 输入 + 保存并检测)。
* 先探后存: 后端用候选 Key 实探一次, 无效不落盘; secrets.json 优先于 .env。 */
function PluginKeyConfig({ plugin }: { plugin: PluginDataSourceItem }) {
const qc = useQueryClient()
const [keyInput, setKeyInput] = useState('')
const [revealing, setRevealing] = useState(false)
const [saved, setSaved] = useState(false)
const invalidate = () => {
qc.invalidateQueries({ queryKey: QK.dataSources })
qc.invalidateQueries({ queryKey: QK.capabilities })
qc.invalidateQueries({ queryKey: QK.quoteStatus })
}
const save = useMutation({
mutationFn: () => api.savePluginKey(plugin.name, keyInput.trim()),
onSuccess: (data) => {
invalidate()
if (data.ok) {
setKeyInput('')
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
},
onError: (e: Error) => toast(`保存失败: ${e.message}`, 'error'),
})
const clear = useMutation({
mutationFn: () => api.clearPluginKey(plugin.name),
onSuccess: (data) => {
invalidate()
if (data.ok) {
toast(
data.plugin_available
? '已清除界面配置的 Key(.env 中的同名变量仍然生效)'
: 'Key 已清除,插件不再可用',
'success',
)
}
},
onError: (e: Error) => toast(`清除失败: ${e.message}`, 'error'),
})
return (
<section className="rounded-card border border-border bg-surface p-6">
<div className="flex items-center gap-2.5 mb-3">
<KeyRound className="h-4 w-4 text-secondary" />
<h3 className="text-sm font-medium text-foreground">API Key</h3>
<span className="text-[10px] text-muted/50 uppercase tracking-wider">{plugin.api_key_env}</span>
</div>
<p className="text-xs text-secondary leading-relaxed mb-4">
Key 保存为本地文件(secrets.json, 优先级高于 .env),不会上传任何第三方。保存前会先用该 Key
实探一次数据接口,无效则不落盘。
</p>
{/* 当前状态 */}
<div className="flex items-center justify-between mb-4">
<div className="min-w-0">
<div className="text-[10px] uppercase tracking-widest text-muted">状态</div>
<div className="mt-1 flex items-center gap-2 min-w-0">
{plugin.available ? (
<>
<CheckCircle2 className="h-4 w-4 text-bear shrink-0" />
<span className="text-sm font-medium shrink-0">已配置</span>
{save.data?.ok && save.data.api_key_masked && (
<span className="font-mono text-xs text-secondary truncate">{save.data.api_key_masked}</span>
)}
</>
) : (
<>
<AlertCircle className="h-4 w-4 text-muted shrink-0" />
<span className="text-sm font-medium text-muted shrink-0">未配置</span>
<span className="text-xs text-muted/70 truncate" title={plugin.status}>{plugin.status}</span>
</>
)}
</div>
</div>
{plugin.available && (
<button
onClick={() => clear.mutate()}
disabled={clear.isPending}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn bg-elevated text-secondary hover:text-danger text-xs transition-colors duration-150 ease-smooth disabled:opacity-50 shrink-0"
>
<Trash2 className="h-3 w-3" />
清除
</button>
)}
</div>
{/* 输入 */}
<form
onSubmit={(e) => { e.preventDefault(); if (keyInput.trim()) save.mutate() }}
className="space-y-2"
>
<div className="relative">
<input
type={revealing ? 'text' : 'password'}
placeholder={plugin.available ? '粘贴新 Key 替换当前' : `粘贴 ${plugin.display_name} API Key`}
value={keyInput}
onChange={(e) => { setKeyInput(e.target.value); if (saved) setSaved(false) }}
autoComplete="off"
className="w-full px-3 py-2 pr-9 rounded-input bg-base border border-border text-sm font-mono focus:outline-none focus:border-accent transition-colors duration-150 ease-smooth"
/>
<button
type="button"
onClick={() => setRevealing((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted hover:text-foreground transition-colors duration-150 ease-smooth"
tabIndex={-1}
aria-label={revealing ? '隐藏' : '显示'}
>
{revealing ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<button
type="submit"
disabled={save.isPending || (!keyInput.trim() && !saved)}
className="w-full h-10 rounded-xl bg-accent text-white text-sm font-semibold flex items-center justify-center gap-2 hover:bg-accent/90 disabled:opacity-40 transition-all"
>
{save.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : saved ? <Check className="h-4 w-4" /> : <Save className="h-4 w-4" />}
{save.isPending ? '验证中...' : saved ? '已保存' : '保存并检测'}
</button>
</form>
{/* 无效 Key —— 先探后存: 探测失败时不存储 */}
{save.data && !save.data.ok && (
<div className="mt-3 text-xs text-danger flex items-center gap-1.5">
<AlertCircle className="h-3 w-3 shrink-0" />
{save.data.error || 'Key 无效,未保存'}
</div>
)}
{save.isError && (
<div className="mt-3 text-xs text-danger">
保存失败:{String((save.error as Error).message)}
</div>
)}
</section>
)
}
export function SettingsDataSourcesPanel() {
const qc = useQueryClient()
const prefs = usePreferences()
const sources = useQuery({ queryKey: QK.dataSources, queryFn: api.dataSources })
const [selected, setSelected] = useState<string>('tickflow') // 当前在右侧编辑的源 name
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
const reload = useMutation({
mutationFn: api.reloadDataSources,
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.dataSources })
// 重载可能改变数据集声明 → 能力与实时模式随之变化
qc.invalidateQueries({ queryKey: QK.capabilities })
qc.invalidateQueries({ queryKey: QK.quoteStatus })
toast('配置已重新加载', 'success')
},
})
const remove = useMutation({
mutationFn: (name: string) => api.deleteDataSource(name),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.dataSources })
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.capabilities })
qc.invalidateQueries({ queryKey: QK.quoteStatus })
setSelected('tickflow')
setConfirmDelete(null)
toast('数据源已删除', 'success')
},
})
const switchProvider = useMutation({
mutationFn: async (name: string) => {
// tickflow: 全量重置为默认路由
if (name === 'tickflow') {
return api.updateDataProviders({
daily_data_provider: 'tickflow',
adj_factor_provider: 'same_as_daily',
realtime_data_provider: 'tickflow',
minute_data_provider: 'tickflow',
financial_data_provider: 'tickflow',
})
}
// 非 tickflow: 该源适配了哪些数据集就接管哪些, 其余回退默认
const supported = new Set(
allItems.find(s => s.name === name)?.datasets ?? []
)
const pick = (dataset: string) =>
supported.has(dataset) ? name : (dataset === 'adj_factor' ? 'same_as_daily' : 'tickflow')
return api.updateDataProviders({
daily_data_provider: pick('daily'),
adj_factor_provider: pick('adj_factor'),
realtime_data_provider: pick('realtime'),
minute_data_provider: pick('minute'),
financial_data_provider: 'tickflow',
})
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.capabilities })
// 切换会改变实时行情 provider → 模式(none/watchlist/full_market)立即刷新
qc.invalidateQueries({ queryKey: QK.quoteStatus })
toast('数据源已切换', 'success')
},
})
const editExisting = useMutation({
mutationFn: (name: string) => api.dataSource(name),
onSuccess: (_data, name) => setSelected(name),
})
const installMut = useMutation({
mutationFn: (name: string) => api.installPlugin(name),
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: QK.dataSources })
qc.invalidateQueries({ queryKey: QK.capabilities })
qc.invalidateQueries({ queryKey: QK.quoteStatus })
if (data.install_ok) {
toast('插件依赖安装成功', 'success')
} else {
toast(data.install_message || '安装失败', 'error')
}
},
onError: (e: Error) => toast(`安装失败: ${e.message}`, 'error'),
})
const uninstallMut = useMutation({
mutationFn: (name: string) => api.uninstallPlugin(name),
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: QK.dataSources })
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.capabilities })
qc.invalidateQueries({ queryKey: QK.quoteStatus })
if (data.uninstall_ok) {
toast(data.uninstall_message || '已卸载', 'success')
} else {
toast(data.uninstall_message || '卸载失败', 'error')
}
},
onError: (e: Error) => toast(`卸载失败: ${e.message}`, 'error'),
})
const builtin: DataSourceItem[] = sources.data?.builtin ?? []
const pluginList: PluginDataSourceItem[] = sources.data?.plugins ?? []
const customList: DataSourceItem[] = sources.data?.custom ?? []
const errors = sources.data?.errors ?? []
const activeName = prefs.data?.daily_data_provider || 'tickflow'
// 插件 name → 状态 (供卡片渲染时判断 available/installing 等)
const pluginMap = new Map(pluginList.map(p => [p.name, p]))
const pluginNames = new Set(pluginList.map(p => p.name))
// 顶部数据源选择列表 (内置 + 所有插件 + 自定义 + 新增)
const pluginItems: DataSourceItem[] = pluginList.map(p => ({
name: p.name, display_name: p.display_name, datasets: p.datasets,
}))
const allItems = [
...builtin,
...pluginItems,
...customList,
]
const selectedCustom = customList.find(s => s.name === selected)
// ===== 各数据集当前的有效提供方 (除权 same_as_daily = 跟随日K) =====
// 用于"服务中"徽标: 改单个能力路由只影响对应数据集, 不再产生"当前数据源被切换"的表现
const dailyPref = prefs.data?.daily_data_provider || 'tickflow'
const adjPref = prefs.data?.adj_factor_provider || 'same_as_daily'
const effProvider: Record<string, string> = {
daily: dailyPref,
adj_factor: adjPref === 'same_as_daily' ? dailyPref : adjPref,
minute: prefs.data?.minute_data_provider || 'tickflow',
realtime: prefs.data?.realtime_data_provider || 'tickflow',
}
const servingDatasets = (name: string) =>
Object.entries(effProvider).filter(([, v]) => v === name).map(([k]) => k)
const servingLabels = (name: string) =>
servingDatasets(name).map(k => DATASET_LABEL[k] || k)
const displayOf = (name?: string) =>
name === 'tickflow' ? 'TickFlow'
: name === 'same_as_daily' ? '跟随日K'
: allItems.find(s => s.name === name)?.display_name || name || ''
const invalidateRouting = () => {
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.capabilities })
qc.invalidateQueries({ queryKey: QK.quoteStatus })
}
// 单个能力的提供方切换: 点标签即刻生效 (same_as_daily 仅除权有效 = 跟随日K)
const routeMut = useMutation({
mutationFn: ({ dataset, provider }: { dataset: string; provider: string }) => {
const route = DATASET_ROUTE[dataset]
if (!route) throw new Error(`数据集 ${dataset} 不支持切换数据源`)
return api.updateDataProviders({ [route.field]: provider })
},
onSuccess: (_d, v) => {
invalidateRouting()
const label = DATASET_LABEL[v.dataset] || v.dataset
toast(`「${label}」已切换为 ${displayOf(v.provider)}`, 'success')
},
onError: (e: Error) => toast(`路由切换失败: ${e.message}`, 'error'),
})
// 恢复默认: 路由全部回 TickFlow
const resetRouteMut = useMutation({
mutationFn: () => api.updateDataProviders({
daily_data_provider: 'tickflow',
adj_factor_provider: 'same_as_daily',
minute_data_provider: 'tickflow',
realtime_data_provider: 'tickflow',
}),
onSuccess: () => {
invalidateRouting()
toast('数据集路由已恢复默认(TickFlow)', 'success')
},
onError: (e: Error) => toast(`恢复失败: ${e.message}`, 'error'),
})
const anyCustomRouting = Object.values(effProvider).some(v => v !== 'tickflow') || adjPref !== 'same_as_daily'
// 某数据集的全部候选提供方 (TickFlow 恒在首位, 其余按声明该数据集的数据源列出)
const candidatesOf = (dataset: string) => {
const list = [{ name: 'tickflow', display: 'TickFlow' }]
for (const item of allItems) {
if (item.name !== 'tickflow' && item.datasets.includes(dataset)) {
list.push({ name: item.name, display: item.display_name || item.name })
}
}
return list
}
// 数据集 → 当前原始路由偏好值 (adj_factor 保留 same_as_daily 以驱动"跟随日K"标签态)
const providerOf = (dataset: string) => {
switch (dataset) {
case 'daily': return dailyPref
case 'adj_factor': return adjPref
case 'minute': return prefs.data?.minute_data_provider || 'tickflow'
case 'realtime': return prefs.data?.realtime_data_provider || 'tickflow'
default: return 'tickflow'
}
}
// 传给各数据源详情的路由上下文(能力卡标签选择 + 当前提供方展示)
const routeCtx: RouteCtx = {
candidatesOf,
providerOf,
displayOf,
pending: routeMut.isPending,
onSelect: (dataset, provider) => routeMut.mutate({ dataset, provider }),
anyCustom: anyCustomRouting,
onReset: () => resetRouteMut.mutate(),
}
return (
<div className="space-y-5 max-w-5xl">
{/* ===== 顶部: 当前数据源 + 数据源选择 (一个大卡片) ===== */}
<section className="rounded-card border border-border bg-surface p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2.5">
<Database className="h-4 w-4 text-secondary" />
<h2 className="text-sm font-medium text-foreground">数据源</h2>
<span
className="text-[10px] text-muted/40 font-mono truncate hidden lg:inline max-w-[480px]"
title={sources.data?.config_dir}
>
{sources.data?.config_dir}
</span>
</div>
<button
onClick={() => reload.mutate()}
disabled={reload.isPending}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs text-muted hover:text-foreground hover:bg-elevated transition-colors disabled:opacity-50"
>
<RefreshCw className={`h-3 w-3 ${reload.isPending ? 'animate-spin' : ''}`} />
重新加载
</button>
</div>
{/* 插件化说明 (置顶黄色提示条): 接入自有行情 → 把文档交给 AI */}
<div className="mb-4 flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2.5">
<Puzzle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
<div className="text-[11px] leading-relaxed text-muted">
<span className="text-secondary">数据源已插件化</span>
,接入自有行情?把文档发给 AI 即可自动接入:
<span className="mx-0.5 rounded bg-elevated/70 px-1 py-px font-mono text-[10px] text-secondary">docs/custom-data-source.md</span>
(自有 HTTP 接口) ·
<span className="mx-0.5 rounded bg-elevated/70 px-1 py-px font-mono text-[10px] text-secondary">docs/plugin-development.md</span>
(插件开发)
</div>
</div>
{/* 数据源选择 - 横向卡片列表 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2.5">
{allItems.map(item => {
const serving = servingLabels(item.name)
const isSelected = selected === item.name
const plugin = pluginMap.get(item.name)
const pluginUnavailable = plugin && !plugin.available
const installing = installMut.isPending && installMut.variables === item.name
const uninstalling = uninstallMut.isPending && uninstallMut.variables === item.name
return (
<div
key={item.name}
onClick={() => {
// 未就绪插件仅当支持界面配 Key 时可点开(进详情配置); 其余不可选
if (pluginUnavailable && !plugin?.api_key_env) return
setSelected(item.name)
// 只有用户自定义源 (YAML) 才进编辑器; tickflow 和插件不可编辑
if (customList.some(c => c.name === item.name)) {
editExisting.mutate(item.name)
}
}}
className={`relative text-left rounded-lg border px-3.5 py-3 transition-all ${
pluginUnavailable && !plugin?.api_key_env
? 'border-border/40 bg-elevated/10 opacity-70'
: isSelected
? 'border-accent/50 bg-accent/5 ring-1 ring-accent/20 cursor-pointer'
: 'border-border/60 bg-elevated/20 hover:bg-elevated/40 cursor-pointer'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span
className={`h-1.5 w-1.5 rounded-full shrink-0 ${
pluginUnavailable ? 'bg-muted/30' : serving.length > 0 ? 'bg-accent' : 'bg-transparent border border-muted/40'
}`}
/>
<span className={`text-sm truncate flex-1 ${serving.length > 0 ? 'font-medium text-foreground' : 'text-secondary'}`}>
{item.display_name}
</span>
{serving.length > 0 && (
<span
className="shrink-0 inline-flex items-center gap-0.5 text-[9px] text-accent"
title={`正在提供: ${serving.join(' · ')}`}
>
<Check className="h-2.5 w-2.5" /> 服务中
</span>
)}
{item.name === 'tickflow' && (
<>
<span className="shrink-0 rounded bg-warning/15 px-1 py-0.5 text-[9px] font-medium leading-none text-warning">第三方</span>
<AllTiersBadge size="text-[9px]" />
</>
)}
{pluginNames.has(item.name) && (
<span className="shrink-0 rounded bg-warning/15 px-1 py-0.5 text-[9px] font-medium leading-none text-warning">第三方</span>
)}
{/* 右侧操作区: 插件未安装→安装按钮(runtime=none 无依赖可装,显示配置提示); 否则→使用/卸载 */}
{pluginUnavailable ? (
plugin?.runtime === 'none' ? (
<span
className="text-[10px] text-muted/50 shrink-0 cursor-help"
title={plugin?.status || '未配置凭据'}
>
点击配置 Key
</span>
) : installing ? (
<span className="inline-flex items-center gap-1 text-[9px] text-accent shrink-0">
<RefreshCw className="h-2.5 w-2.5 animate-spin" /> 安装中...
</span>
) : (
<button
onClick={(e) => { e.stopPropagation(); installMut.mutate(item.name) }}
disabled={installMut.isPending}
className="shrink-0 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors disabled:opacity-50"
>
<Zap className="h-2.5 w-2.5" /> 安装
</button>
)
) : plugin ? (
/* 已安装插件: 使用 + 卸载 */
<div className="flex items-center gap-1 shrink-0">
<button
onClick={(e) => { e.stopPropagation(); switchProvider.mutate(item.name) }}
disabled={switchProvider.isPending}
className="rounded px-1.5 py-0.5 text-[10px] font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors disabled:opacity-50"
>
使用
</button>
{plugin?.runtime !== 'none' && (uninstalling ? (
<RefreshCw className="h-2.5 w-2.5 animate-spin text-muted" />
) : (
<button
onClick={(e) => { e.stopPropagation(); uninstallMut.mutate(item.name) }}
disabled={uninstallMut.isPending}
className="text-[10px] text-muted/50 hover:text-danger transition-colors disabled:opacity-40"
title="卸载依赖"
>
卸载
</button>
))}
</div>
) : (
<button
onClick={(e) => { e.stopPropagation(); switchProvider.mutate(item.name) }}
disabled={switchProvider.isPending}
className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors disabled:opacity-50"
>
使用
</button>
)}
</div>
{/* 数据集标签(静态): 该源适配的数据集, 路由选择在下方能力卡片 */}
<DatasetChipRow datasets={item.name === 'tickflow' ? [...item.datasets, 'financial'] : item.datasets} />
{/* 未安装插件显示安装命令提示 */}
{pluginUnavailable && plugin?.install_hint && (
<div className="ml-3.5 mt-1 text-[10px] text-muted/40 font-mono truncate">{plugin.install_hint}</div>
)}
</div>
)
})}
{/* 新增数据源卡片 */}
<button
onClick={() => setSelected('__new__')}
className={`rounded-lg border border-dashed px-3.5 py-3 transition-all flex items-center justify-center gap-1.5 text-sm ${
selected === '__new__'
? 'border-accent/50 bg-accent/5 text-accent'
: 'border-border/50 text-muted hover:text-foreground hover:border-border hover:bg-elevated/30'
}`}
>
<Plus className="h-3.5 w-3.5" />
新增数据源
</button>
</div>
{/* 错误提示 */}
{errors.length > 0 && (
<div className="mt-3 flex items-start gap-1.5 px-3 py-2 rounded-lg bg-danger/5 border border-danger/20">
<FileWarning className="h-3.5 w-3.5 text-danger shrink-0 mt-0.5" />
<div className="text-[11px] text-danger/80 leading-relaxed space-y-0.5">
{errors.map((err, idx) => (
<div key={idx}>
<span className="font-mono">{err.name || err.path}</span>: {err.errors.join('; ')}
</div>
))}
</div>
</div>
)}
<div className="mt-3 flex items-center gap-3 text-[10px] text-muted/50">
<span>单击查看各源能力</span>
<span className="text-muted/30">·</span>
<span>能力卡片上点标签,单独选择每个数据集的提供方</span>
<span className="text-muted/30">·</span>
<span>点「使用」一键套用该源全部能力</span>
<span className="text-muted/30">·</span>
<span>未单独设置的由 TickFlow 提供</span>
</div>
</section>
{/* ===== 下方: 编辑区 ===== */}
<AnimatePresence mode="wait">
<motion.div
key={selected}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.15 }}
>
{selected === 'tickflow' ? (
<TickFlowDetail
active={servingDatasets('tickflow').length > 0}
onSwitch={() => switchProvider.mutate('tickflow')}
switching={switchProvider.isPending}
route={routeCtx}
/>
) : selected === '__new__' || customList.some(c => c.name === selected) ? (
selected === '__new__' ? (
<DataSourceEditor
key={selected}
initial={null}
existingName={undefined}
onCancel={() => setSelected('tickflow')}
onSaved={() => {
qc.invalidateQueries({ queryKey: QK.dataSources })
// 数据集声明变化 → 能力增广与实时模式立即刷新
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.capabilities })
qc.invalidateQueries({ queryKey: QK.quoteStatus })
setSelected('tickflow')
}}
activeName={activeName}
onActivate={(name) => switchProvider.mutate(name)}
/>
) : (
/* 自定义源详情: 能力卡片(路由) + 编辑器 */
<div className="space-y-5">
<section className="rounded-card border border-border bg-surface p-6">
<div className="flex items-center gap-2.5 mb-4">
<Database className="h-4 w-4 text-secondary" />
<h3 className="text-sm font-medium text-foreground">
{selectedCustom?.display_name || selected} · 数据集能力与路由
</h3>
</div>
<SourceCapabilityGrid
sourceName={selected}
sourceDisplay={selectedCustom?.display_name || selected}
datasets={selectedCustom?.datasets || []}
candidatesOf={routeCtx.candidatesOf}
providerOf={routeCtx.providerOf}
pending={routeCtx.pending}
onSelect={routeCtx.onSelect}
/>
</section>
<DataSourceEditor
key={selected}
initial={null}
existingName={selected}
onCancel={() => setSelected('tickflow')}
onSaved={() => {
qc.invalidateQueries({ queryKey: QK.dataSources })
// 数据集声明变化 → 能力增广与实时模式立即刷新
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.capabilities })
qc.invalidateQueries({ queryKey: QK.quoteStatus })
// 强制清除该源的详情缓存, 下次编辑重新拉取最新配置
qc.removeQueries({ queryKey: ['data-source-detail', selected] })
}}
activeName={activeName}
onActivate={(name) => switchProvider.mutate(name)}
onDelete={selectedCustom ? () => setConfirmDelete(selected) : undefined}
/>
</div>
)
) : pluginList.find(x => x.name === selected) ? (
/* 选中插件: 信息 + 能力卡片(路由) + Key 配置 */
<PluginDetail
plugin={pluginList.find(x => x.name === selected)!}
isActive={servingDatasets(selected).length > 0}
onSwitch={() => switchProvider.mutate(selected)}
switching={switchProvider.isPending}
route={routeCtx}
/>
) : null}
</motion.div>
</AnimatePresence>
{/* 删除确认弹窗 */}
{confirmDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setConfirmDelete(null)}
/>
<div className="relative w-[90vw] max-w-[380px] rounded-card border border-border bg-base shadow-2xl p-6">
<h3 className="text-sm font-medium text-foreground mb-2">删除数据源</h3>
<p className="text-xs text-secondary mb-5">
确认删除「{customList.find(s => s.name === confirmDelete)?.display_name || confirmDelete}? 该数据源的配置文件将被移除,此操作不可撤销。
</p>
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setConfirmDelete(null)}
className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-sm transition-colors"
>
取消
</button>
<button
onClick={() => remove.mutate(confirmDelete)}
disabled={remove.isPending}
className="px-3 py-1.5 rounded-btn bg-danger/15 text-danger hover:bg-danger/25 text-sm font-medium transition-colors disabled:opacity-50"
>
{remove.isPending ? '删除中...' : '确认删除'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
function PluginDetail({ plugin, isActive, onSwitch, switching, route }: {
plugin: PluginDataSourceItem
isActive: boolean
onSwitch: () => void
switching: boolean
route: RouteCtx
}) {
return (
<div className="space-y-5">
<section className="rounded-card border border-border bg-surface p-6">
<div className="flex items-start gap-4 mb-5">
<div className="h-11 w-11 rounded-xl bg-accent/10 flex items-center justify-center shrink-0">
<Zap className="h-5 w-5 text-accent" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-base font-semibold text-foreground">{plugin.display_name}</h3>
<span className="text-[10px] text-muted/50 uppercase tracking-wider">插件 · {plugin.runtime}</span>
</div>
{plugin.description && <p className="text-xs text-secondary leading-relaxed">{plugin.description}</p>}
</div>
</div>
{/* 本源参与的能力: 标签选择提供方, 即刻生效 */}
<SourceCapabilityGrid
sourceName={plugin.name}
sourceDisplay={plugin.display_name}
datasets={plugin.datasets}
candidatesOf={route.candidatesOf}
providerOf={route.providerOf}
pending={route.pending}
onSelect={route.onSelect}
/>
<div className="flex items-center gap-3 mt-4">
{isActive ? (
<span className="inline-flex items-center gap-1 text-[10px] text-accent bg-accent/10 px-2 py-1 rounded">
<Check className="h-2.5 w-2.5" /> 服务中
</span>
) : plugin.available ? (
<button
onClick={onSwitch}
disabled={switching}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent/10 text-accent text-xs font-medium hover:bg-accent/20 transition-colors disabled:opacity-50"
>
<Zap className="h-3.5 w-3.5" />
整体切换为该源
</button>
) : (
<span className="text-xs text-muted">{plugin.status}</span>
)}
</div>
</section>
{/* API Key 配置 (声明了 api_key_env 的插件) */}
{plugin.api_key_env && <PluginKeyConfig plugin={plugin} />}
</div>
)
}
function TickFlowDetail({ active, onSwitch, switching, route }: {
active: boolean
onSwitch: () => void
switching: boolean
route: RouteCtx
}) {
return (
<div className="space-y-5">
<section className="rounded-card border border-border bg-surface p-6">
<div className="flex items-start gap-4 mb-5">
<div className="h-11 w-11 rounded-xl bg-accent/10 flex items-center justify-center shrink-0">
<Database className="h-5 w-5 text-accent" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h2 className="text-base font-semibold text-foreground">TickFlow</h2>
<span className="rounded bg-warning/15 px-1.5 py-0.5 text-[10px] font-medium text-warning">第三方</span>
<AllTiersBadge />
{active && (
<span className="inline-flex items-center gap-1 text-[10px] text-accent bg-accent/10 px-2 py-1 rounded">
<Check className="h-2.5 w-2.5" /> 服务中
</span>
)}
</div>
<p className="text-xs text-secondary mt-1.5 leading-relaxed">
默认数据源,具备全部能力(K · 除权 · 分钟 · 实时 · 财务)。在下方每个能力卡片上点标签,可单独选择该数据集由哪个数据源提供。
</p>
</div>
</div>
{/* 全部能力的提供方标签选择 + 恢复默认 */}
<SourceCapabilityGrid
sourceName="tickflow"
sourceDisplay="TickFlow"
datasets={['daily', 'adj_factor', 'realtime', 'minute']}
candidatesOf={route.candidatesOf}
providerOf={route.providerOf}
pending={route.pending}
onSelect={route.onSelect}
anyCustom={route.anyCustom}
onReset={route.onReset}
/>
{!active && (
<button
onClick={onSwitch}
disabled={switching}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent/10 text-accent text-xs font-medium hover:bg-accent/20 transition-colors disabled:opacity-50 mt-3"
>
<Zap className="h-3.5 w-3.5" />
切换为当前数据源
</button>
)}
</section>
{/* TickFlow API Key 配置 + 订阅档位 + 可用功能 (原 account tab 内容) */}
<TickFlowKeyConfig />
</div>
)
}