feat(ui): 数据源能力卡片标签式路由 — 每个数据集单独选择提供方

- 每个数据源详情展示能力卡片, 卡片上以可点标签列出所有具备该能力的
  数据源(TickFlow 恒为候选), 点标签即刻切换该数据集路由, 跨源自由组合
- 右上角徽标显示当前提供方; 除权因子含「跟随日K」标签(默认态)
- TickFlow 详情含全部能力 + 恢复默认; 插件 Key 配置区对齐 TickFlow 布局
- 插件详情「整体切换为该源」= 一键套用其全部适配能力, 其余回退 TickFlow
This commit is contained in:
shy3130
2026-08-24 14:21:45 +08:00
parent ea746b97c7
commit 6c7c7088a9
+580 -122
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Check, Database, Plus, RefreshCw, Zap, FileWarning, Puzzle } from 'lucide-react'
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 { usePreferences } from '@/lib/useSharedQueries'
@@ -14,6 +14,316 @@ const DATASET_LABEL: Record<string, string> = {
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' },
}
/** 卡片内静态数据集标签: 只展示该源适配了哪些数据集 (路由选择在下方能力卡片) */
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, displayOf, 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
displayOf: (name?: string) => string
pending?: boolean
onSelect: (dataset: string, provider: string) => void
anyCustom?: boolean
onReset?: () => void
}) {
const isDefault = sourceName === 'tickflow'
const dsList = isDefault ? [...datasets, 'financial'] : datasets
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">{label}</div>
{desc && <div className="text-[10px] text-muted mt-0.5">{desc}</div>}
</div>
<span className="shrink-0 px-1.5 py-0.5 rounded text-[10px] text-muted/50 bg-elevated/60"></span>
</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>
{/* 右上角: 当前提供方 */}
<span
title={`${label}」当前由 ${displayOf(raw)} 提供`}
className={`shrink-0 px-1.5 py-0.5 rounded text-[10px] ${
raw === route.def ? 'text-muted/50 bg-elevated/60' : 'bg-accent/15 text-accent font-medium'
}`}
>
<span className="truncate max-w-[80px] inline-block align-middle">{displayOf(raw)}</span>
</span>
</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() {
@@ -48,8 +358,8 @@ export function SettingsDataSourcesPanel() {
})
const switchProvider = useMutation({
mutationFn: (name: string) => {
// tickflow: 5 个数据集全量重置为 tickflow
mutationFn: async (name: string) => {
// tickflow: 全量重置为默认路由
if (name === 'tickflow') {
return api.updateDataProviders({
daily_data_provider: 'tickflow',
@@ -59,19 +369,18 @@ export function SettingsDataSourcesPanel() {
financial_data_provider: 'tickflow',
})
}
// 非 tickflow: 按源声明的 datasets 动态切换。支持的数据集切到该源,
// 不支持的保持 tickflow, 使 preferences 与实际取数路由一致
// (避免 UI 显示某源、后台却走 tickflow 的假象)。
// 非 tickflow: 该源适配了哪些数据集就接管哪些, 其余回退默认
const supported = new Set(
allItems.find(s => s.name === name)?.datasets ?? []
)
const pick = (dataset: string) => (supported.has(dataset) ? name : 'tickflow')
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: 'same_as_daily', // 除权始终跟随日K
adj_factor_provider: pick('adj_factor'),
realtime_data_provider: pick('realtime'),
minute_data_provider: pick('minute'),
financial_data_provider: pick('financial'),
financial_data_provider: 'tickflow',
})
},
onSuccess: () => {
@@ -141,6 +450,97 @@ export function SettingsDataSourcesPanel() {
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">
{/* ===== 顶部: 当前数据源 + 数据源选择 (一个大卡片) ===== */}
@@ -179,19 +579,10 @@ export function SettingsDataSourcesPanel() {
</div>
</div>
{/* 当前数据源状态 */}
<div className="flex items-center gap-2 mb-4 px-3 py-2.5 rounded-lg bg-elevated/30">
<span className="text-[10px] uppercase tracking-widest text-muted"></span>
<span className="h-2 w-2 rounded-full bg-accent animate-pulse" />
<span className="text-sm font-medium text-foreground">
{activeName === 'tickflow' ? 'TickFlow' : customList.find(s => s.name === activeName)?.display_name || activeName}
</span>
</div>
{/* 数据源选择 - 横向卡片列表 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2.5">
{allItems.map(item => {
const isActive = activeName === item.name
const serving = servingLabels(item.name)
const isSelected = selected === item.name
const plugin = pluginMap.get(item.name)
const pluginUnavailable = plugin && !plugin.available
@@ -201,7 +592,8 @@ export function SettingsDataSourcesPanel() {
<div
key={item.name}
onClick={() => {
if (pluginUnavailable) return // 未安装的插件不可选
// 未就绪插件仅当支持界面配 Key 时可点开(进详情配置); 其余不可选
if (pluginUnavailable && !plugin?.api_key_env) return
setSelected(item.name)
// 只有用户自定义源 (YAML) 才进编辑器; tickflow 和插件不可编辑
if (customList.some(c => c.name === item.name)) {
@@ -209,7 +601,7 @@ export function SettingsDataSourcesPanel() {
}
}}
className={`relative text-left rounded-lg border px-3.5 py-3 transition-all ${
pluginUnavailable
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'
@@ -217,21 +609,38 @@ export function SettingsDataSourcesPanel() {
}`}
>
<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' : isActive ? 'bg-accent' : 'bg-transparent border border-muted/40'
}`} />
<span className={`text-sm truncate flex-1 ${isActive ? 'font-medium text-foreground' : 'text-secondary'}`}>
<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>
)}
{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 ? (
installing ? (
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>
@@ -244,10 +653,6 @@ export function SettingsDataSourcesPanel() {
<Zap className="h-2.5 w-2.5" />
</button>
)
) : isActive ? (
<span className="inline-flex items-center gap-0.5 text-[9px] text-accent shrink-0">
<Check className="h-2.5 w-2.5" /> 使
</span>
) : plugin ? (
/* 已安装插件: 使用 + 卸载 */
<div className="flex items-center gap-1 shrink-0">
@@ -258,7 +663,7 @@ export function SettingsDataSourcesPanel() {
>
使
</button>
{uninstalling ? (
{plugin?.runtime !== 'none' && (uninstalling ? (
<RefreshCw className="h-2.5 w-2.5 animate-spin text-muted" />
) : (
<button
@@ -269,7 +674,7 @@ export function SettingsDataSourcesPanel() {
>
</button>
)}
))}
</div>
) : (
<button
@@ -281,22 +686,8 @@ export function SettingsDataSourcesPanel() {
</button>
)}
</div>
{item.name !== 'tickflow' && item.datasets.length > 0 && (
<div className="flex flex-wrap items-center gap-1 ml-3.5">
<span className="text-[9px] font-medium text-accent bg-accent/10 px-1 py-0.5 rounded"></span>
{item.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>
)}
{item.name === 'tickflow' && (
<div className="flex flex-wrap items-center gap-1 ml-3.5">
<span className="text-[9px] font-medium text-accent bg-accent/10 px-1 py-0.5 rounded"></span>
<span className="text-[10px] text-muted/60">K · · · K</span>
</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>
@@ -334,11 +725,13 @@ export function SettingsDataSourcesPanel() {
)}
<div className="mt-3 flex items-center gap-3 text-[10px] text-muted/50">
<span></span>
<span></span>
<span className="text-muted/30">·</span>
<span>使</span>
<span>,</span>
<span className="text-muted/30">·</span>
<span>退 TickFlow</span>
<span>使</span>
<span className="text-muted/30">·</span>
<span> TickFlow </span>
</div>
</section>
@@ -353,39 +746,78 @@ export function SettingsDataSourcesPanel() {
>
{selected === 'tickflow' ? (
<TickFlowDetail
active={activeName === 'tickflow'}
active={servingDatasets('tickflow').length > 0}
onSwitch={() => switchProvider.mutate('tickflow')}
switching={switchProvider.isPending}
route={routeCtx}
/>
) : selected === '__new__' || customList.some(c => c.name === selected) ? (
<DataSourceEditor
key={selected}
initial={null}
existingName={selected === '__new__' ? undefined : 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 })
// 强制清除该源的详情缓存, 下次编辑重新拉取最新配置
if (selected !== '__new__') {
qc.removeQueries({ queryKey: ['data-source-detail', selected] })
}
if (selected === '__new__') setSelected(activeName === 'tickflow' ? 'tickflow' : activeName)
}}
activeName={activeName}
onActivate={(name) => switchProvider.mutate(name)}
onDelete={selected !== '__new__' && selectedCustom ? () => setConfirmDelete(selected) : undefined}
/>
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}
displayOf={routeCtx.displayOf}
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={activeName === selected}
isActive={servingDatasets(selected).length > 0}
onSwitch={() => switchProvider.mutate(selected)}
switching={switchProvider.isPending}
route={routeCtx}
/>
) : null}
</motion.div>
@@ -425,47 +857,73 @@ export function SettingsDataSourcesPanel() {
)
}
function PluginDetail({ plugin, isActive, onSwitch, switching }: {
function PluginDetail({ plugin, isActive, onSwitch, switching, route }: {
plugin: PluginDataSourceItem
isActive: boolean
onSwitch: () => void
switching: boolean
route: RouteCtx
}) {
return (
<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 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>
{plugin.description && <p className="text-xs text-secondary leading-relaxed">{plugin.description}</p>}
</div>
</div>
<div className="flex items-center gap-3">
{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>
) : (
<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>
)}
</div>
</section>
{/* 本源参与的能力: 标签选择提供方, 即刻生效 */}
<SourceCapabilityGrid
sourceName={plugin.name}
sourceDisplay={plugin.display_name}
datasets={plugin.datasets}
candidatesOf={route.candidatesOf}
providerOf={route.providerOf}
displayOf={route.displayOf}
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 }: { active: boolean; onSwitch: () => void; switching: boolean }) {
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">
@@ -479,35 +937,35 @@ function TickFlowDetail({ active, onSwitch, switching }: { active: boolean; onSw
<span className="rounded bg-warning/15 px-1.5 py-0.5 text-[10px] font-medium text-warning"></span>
{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" /> 使
<Check className="h-2.5 w-2.5" />
</span>
)}
</div>
<p className="text-xs text-secondary mt-1.5 leading-relaxed">
KK均由 TickFlow ,
,(K · · · · ),
</p>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5 mb-5">
{[
{ label: '日K', desc: '历史 + 实时覆写' },
{ label: '除权因子', desc: '复权计算' },
{ label: '实时行情', desc: '全市场快照' },
{ label: '分钟K', desc: '分时图 · 回测' },
].map(f => (
<div key={f.label} className="rounded-lg border border-border/50 bg-elevated/20 px-3 py-2.5">
<div className="text-xs font-medium text-foreground">{f.label}</div>
<div className="text-[10px] text-muted mt-0.5">{f.desc}</div>
</div>
))}
</div>
{/* 全部能力的提供方标签选择 + 恢复默认 */}
<SourceCapabilityGrid
sourceName="tickflow"
sourceDisplay="TickFlow"
datasets={['daily', 'adj_factor', 'realtime', 'minute']}
candidatesOf={route.candidatesOf}
providerOf={route.providerOf}
displayOf={route.displayOf}
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"
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" />