mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 19:04:15 +08:00
628 lines
29 KiB
TypeScript
628 lines
29 KiB
TypeScript
import { useState } from 'react'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
import { Check, Clock3, Database, Plus, RefreshCw, Zap, FileWarning } from 'lucide-react'
|
|
import { api, type DataSourceItem, type PluginDataSourceItem, type Preferences } from '@/lib/api'
|
|
import { QK } from '@/lib/queryKeys'
|
|
import { usePreferences } from '@/lib/useSharedQueries'
|
|
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: '分钟',
|
|
}
|
|
|
|
type TimeoutUnit = 'second' | 'minute' | 'hour'
|
|
|
|
const TIMEOUT_UNIT_SECONDS: Record<TimeoutUnit, number> = {
|
|
second: 1,
|
|
minute: 60,
|
|
hour: 3600,
|
|
}
|
|
|
|
function preferredTimeoutUnit(seconds: number): TimeoutUnit {
|
|
if (seconds >= 3600 && seconds % 1800 === 0) return 'hour'
|
|
if (seconds % 60 === 0) return 'minute'
|
|
return 'second'
|
|
}
|
|
|
|
function formatTimeoutValue(seconds: number, unit: TimeoutUnit): string {
|
|
if (!Number.isFinite(seconds)) return ''
|
|
const value = seconds / TIMEOUT_UNIT_SECONDS[unit]
|
|
return String(Number(value.toFixed(4)))
|
|
}
|
|
|
|
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 [timeoutDraft, setTimeoutDraft] = useState<{ regular: string; long: string } | null>(null)
|
|
const [regularUnitOverride, setRegularUnitOverride] = useState<TimeoutUnit | null>(null)
|
|
const [longUnitOverride, setLongUnitOverride] = useState<TimeoutUnit | null>(null)
|
|
|
|
const currentRegularTimeout = prefs.data?.data_source_job_timeout_s ?? 1200
|
|
const currentLongTimeout = prefs.data?.data_source_long_job_timeout_s ?? 1800
|
|
const regularTimeoutUnit = regularUnitOverride ?? preferredTimeoutUnit(currentRegularTimeout)
|
|
const longTimeoutUnit = longUnitOverride ?? preferredTimeoutUnit(currentLongTimeout)
|
|
const regularTimeoutInput = timeoutDraft?.regular
|
|
?? formatTimeoutValue(currentRegularTimeout, regularTimeoutUnit)
|
|
const longTimeoutInput = timeoutDraft?.long
|
|
?? formatTimeoutValue(currentLongTimeout, longTimeoutUnit)
|
|
const regularInputNumber = Number(regularTimeoutInput)
|
|
const longInputNumber = Number(longTimeoutInput)
|
|
const regularTimeout = Math.round(regularInputNumber * TIMEOUT_UNIT_SECONDS[regularTimeoutUnit])
|
|
const longTimeout = Math.round(longInputNumber * TIMEOUT_UNIT_SECONDS[longTimeoutUnit])
|
|
const timeoutValuesValid = Number.isFinite(regularInputNumber) && regularInputNumber > 0
|
|
&& Number.isFinite(longInputNumber) && longInputNumber > 0
|
|
&& regularTimeout >= 60 && longTimeout >= 60
|
|
const timeoutValuesChanged = regularTimeout !== currentRegularTimeout
|
|
|| longTimeout !== currentLongTimeout
|
|
|
|
const saveJobTimeouts = useMutation({
|
|
mutationFn: () => api.updateDataSourceJobTimeouts(regularTimeout, longTimeout),
|
|
onSuccess: (saved) => {
|
|
qc.setQueryData<Preferences>(QK.preferences, current => (
|
|
current ? { ...current, ...saved } : current
|
|
))
|
|
setTimeoutDraft(null)
|
|
toast('任务超时配置已保存', 'success')
|
|
},
|
|
onError: (e: Error) => toast(`保存失败: ${e.message}`, 'error'),
|
|
})
|
|
|
|
const reload = useMutation({
|
|
mutationFn: api.reloadDataSources,
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: QK.dataSources })
|
|
toast('配置已重新加载', 'success')
|
|
},
|
|
})
|
|
|
|
const remove = useMutation({
|
|
mutationFn: (name: string) => api.deleteDataSource(name),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: QK.dataSources })
|
|
qc.invalidateQueries({ queryKey: QK.preferences })
|
|
setSelected('tickflow')
|
|
setConfirmDelete(null)
|
|
toast('数据源已删除', 'success')
|
|
},
|
|
})
|
|
|
|
const switchProvider = useMutation({
|
|
mutationFn: (name: string) => {
|
|
// tickflow: 5 个数据集全量重置为 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: 按源声明的 datasets 动态切换。支持的数据集切到该源,
|
|
// 不支持的保持 tickflow, 使 preferences 与实际取数路由一致
|
|
// (避免 UI 显示某源、后台却走 tickflow 的假象)。
|
|
const supported = new Set(
|
|
allItems.find(s => s.name === name)?.datasets ?? []
|
|
)
|
|
const pick = (dataset: string) => (supported.has(dataset) ? name : 'tickflow')
|
|
return api.updateDataProviders({
|
|
daily_data_provider: pick('daily'),
|
|
adj_factor_provider: 'same_as_daily', // 除权始终跟随日K
|
|
realtime_data_provider: pick('realtime'),
|
|
minute_data_provider: pick('minute'),
|
|
financial_data_provider: pick('financial'),
|
|
})
|
|
},
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: QK.preferences })
|
|
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 })
|
|
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 })
|
|
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)
|
|
|
|
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>
|
|
|
|
{/* 当前数据源状态 */}
|
|
<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 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={() => {
|
|
if (pluginUnavailable) 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
|
|
? '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' : isActive ? 'bg-accent' : 'bg-transparent border border-muted/40'
|
|
}`} />
|
|
<span className={`text-sm truncate flex-1 ${isActive ? 'font-medium text-foreground' : 'text-secondary'}`}>
|
|
{item.display_name}
|
|
</span>
|
|
{item.name === 'tickflow' && (
|
|
<span className="text-[9px] text-muted/50 uppercase tracking-wider shrink-0">内置</span>
|
|
)}
|
|
{pluginNames.has(item.name) && (
|
|
<span className="text-[9px] text-muted/50 uppercase tracking-wider shrink-0">插件</span>
|
|
)}
|
|
{/* 右侧操作区: 插件未安装→安装按钮; 已激活→使用中; 否则→使用/卸载 */}
|
|
{pluginUnavailable ? (
|
|
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>
|
|
)
|
|
) : 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">
|
|
<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>
|
|
{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>
|
|
{item.name !== 'tickflow' && item.datasets.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 ml-3.5">
|
|
{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="text-[10px] text-muted/60 ml-3.5">日K · 除权 · 实时 · 分钟K</div>
|
|
)}
|
|
{/* 未安装插件显示安装命令提示 */}
|
|
{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>未启用的数据集自动回退 TickFlow</span>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="rounded-card border border-border bg-surface p-5">
|
|
<div className="flex items-start justify-between gap-4 mb-4">
|
|
<div className="flex items-start gap-2.5">
|
|
<Clock3 className="h-4 w-4 text-secondary mt-0.5" />
|
|
<div>
|
|
<h2 className="text-sm font-medium text-foreground">数据任务超时</h2>
|
|
<p className="text-[11px] text-muted mt-1 leading-relaxed">
|
|
后台任务运行超过对应时间后将判定为疑似卡死。保存时自动换算为秒,修改后对新建任务生效。
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => saveJobTimeouts.mutate()}
|
|
disabled={!timeoutValuesValid || !timeoutValuesChanged || saveJobTimeouts.isPending}
|
|
className="shrink-0 px-3 py-1.5 rounded-btn bg-accent text-white text-xs font-medium hover:bg-accent/90 disabled:opacity-40 transition-colors"
|
|
>
|
|
{saveJobTimeouts.isPending ? '保存中...' : '保存'}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<label className="rounded-lg border border-border/60 bg-elevated/20 px-3.5 py-3">
|
|
<span className="block text-xs font-medium text-foreground mb-1">普通任务超时</span>
|
|
<span className="block text-[10px] text-muted mb-2">日 K 管道、扩展、修正与重算任务</span>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="number"
|
|
min={regularTimeoutUnit === 'second' ? 60 : regularTimeoutUnit === 'minute' ? 1 : 1 / 60}
|
|
step={regularTimeoutUnit === 'second' ? 60 : regularTimeoutUnit === 'minute' ? 1 : 0.5}
|
|
value={regularTimeoutInput}
|
|
onChange={e => setTimeoutDraft({ regular: e.target.value, long: longTimeoutInput })}
|
|
className="w-full rounded-btn border border-border bg-base px-2.5 py-1.5 text-sm text-foreground font-mono outline-none focus:border-accent"
|
|
/>
|
|
<select
|
|
value={regularTimeoutUnit}
|
|
onChange={e => {
|
|
const nextUnit = e.target.value as TimeoutUnit
|
|
setTimeoutDraft({
|
|
regular: formatTimeoutValue(regularTimeout, nextUnit),
|
|
long: longTimeoutInput,
|
|
})
|
|
setRegularUnitOverride(nextUnit)
|
|
}}
|
|
className="w-20 shrink-0 rounded-btn border border-border bg-base px-2 py-1.5 text-xs text-foreground outline-none focus:border-accent"
|
|
>
|
|
<option value="second">秒</option>
|
|
<option value="minute">分钟</option>
|
|
<option value="hour">小时</option>
|
|
</select>
|
|
</div>
|
|
<span className="block text-[10px] text-muted/60 mt-1.5">默认 20 分钟,最小 1 分钟</span>
|
|
</label>
|
|
|
|
<label className="rounded-lg border border-border/60 bg-elevated/20 px-3.5 py-3">
|
|
<span className="block text-xs font-medium text-foreground mb-1">长任务超时</span>
|
|
<span className="block text-[10px] text-muted mb-2">分钟 K 全市场同步任务</span>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="number"
|
|
min={longTimeoutUnit === 'second' ? 60 : longTimeoutUnit === 'minute' ? 1 : 1 / 60}
|
|
step={longTimeoutUnit === 'second' ? 60 : longTimeoutUnit === 'minute' ? 1 : 0.5}
|
|
value={longTimeoutInput}
|
|
onChange={e => setTimeoutDraft({ regular: regularTimeoutInput, long: e.target.value })}
|
|
className="w-full rounded-btn border border-border bg-base px-2.5 py-1.5 text-sm text-foreground font-mono outline-none focus:border-accent"
|
|
/>
|
|
<select
|
|
value={longTimeoutUnit}
|
|
onChange={e => {
|
|
const nextUnit = e.target.value as TimeoutUnit
|
|
setTimeoutDraft({
|
|
regular: regularTimeoutInput,
|
|
long: formatTimeoutValue(longTimeout, nextUnit),
|
|
})
|
|
setLongUnitOverride(nextUnit)
|
|
}}
|
|
className="w-20 shrink-0 rounded-btn border border-border bg-base px-2 py-1.5 text-xs text-foreground outline-none focus:border-accent"
|
|
>
|
|
<option value="second">秒</option>
|
|
<option value="minute">分钟</option>
|
|
<option value="hour">小时</option>
|
|
</select>
|
|
</div>
|
|
<span className="block text-[10px] text-muted/60 mt-1.5">默认 30 分钟,最小 1 分钟</span>
|
|
</label>
|
|
</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={activeName === 'tickflow'}
|
|
onSwitch={() => switchProvider.mutate('tickflow')}
|
|
switching={switchProvider.isPending}
|
|
/>
|
|
) : 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 })
|
|
// 强制清除该源的详情缓存, 下次编辑重新拉取最新配置
|
|
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}
|
|
/>
|
|
) : pluginList.find(x => x.name === selected) ? (
|
|
/* 选中插件: 显示只读详情, 不进编辑器 */
|
|
<PluginDetail
|
|
plugin={pluginList.find(x => x.name === selected)!}
|
|
isActive={activeName === selected}
|
|
onSwitch={() => switchProvider.mutate(selected)}
|
|
switching={switchProvider.isPending}
|
|
/>
|
|
) : 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 }: {
|
|
plugin: PluginDataSourceItem
|
|
isActive: boolean
|
|
onSwitch: () => void
|
|
switching: boolean
|
|
}) {
|
|
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>
|
|
{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.5 text-xs text-accent">
|
|
<Check className="h-3.5 w-3.5" /> 当前使用中
|
|
</span>
|
|
) : (
|
|
<button
|
|
onClick={onSwitch}
|
|
disabled={switching}
|
|
className="px-3 py-1.5 rounded-btn bg-accent/10 text-accent hover:bg-accent/20 text-xs font-medium transition-colors disabled:opacity-50"
|
|
>
|
|
切换为当前数据源
|
|
</button>
|
|
)}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function TickFlowDetail({ active, onSwitch, switching }: { active: boolean; onSwitch: () => void; switching: boolean }) {
|
|
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="text-[10px] text-muted/60 uppercase tracking-wider border border-border rounded px-1.5 py-0.5">内置默认</span>
|
|
{active && (
|
|
<span className="inline-flex items-center gap-1 text-[10px] text-accent bg-accent/10 px-1.5 py-0.5 rounded">
|
|
<Check className="h-2.5 w-2.5" /> 当前使用
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-secondary mt-1.5 leading-relaxed">
|
|
项目默认数据源。日K、除权因子、实时行情、分钟K均由 TickFlow 提供,无需额外配置。
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5 mb-5">
|
|
{[
|
|
{ label: '日K', desc: '历史 + 实时覆写' },
|
|
{ label: '除权因子', desc: 'Starter+ 能力' },
|
|
{ label: '实时行情', desc: '全市场快照' },
|
|
{ label: '分钟K', desc: 'Pro+ 能力' },
|
|
].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>
|
|
|
|
{!active && (
|
|
<button
|
|
onClick={onSwitch}
|
|
disabled={switching}
|
|
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-btn bg-accent text-white text-sm font-medium hover:bg-accent/90 disabled:opacity-50 transition-colors"
|
|
>
|
|
<Zap className="h-3.5 w-3.5" />
|
|
切换为当前数据源
|
|
</button>
|
|
)}
|
|
</section>
|
|
|
|
{/* TickFlow API Key 配置 + 订阅档位 + 可用功能 (原 account tab 内容) */}
|
|
<TickFlowKeyConfig />
|
|
</div>
|
|
)
|
|
}
|