复制 local 代码到 serve

This commit is contained in:
2026-07-04 16:59:25 +08:00
parent cee04c1b46
commit 0474e5fb46
302 changed files with 78290 additions and 0 deletions
+437
View File
@@ -0,0 +1,437 @@
import { useState, useEffect } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import {
Save, Loader2, Check, Wifi, WifiOff, Eye, EyeOff, Shield,
Shuffle, Plug, Zap, Settings2, ExternalLink, Trash2,
Terminal,
} from 'lucide-react'
import { useSettings } from '@/lib/useSharedQueries'
import { api, type SettingsState } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
// 统一的输入框样式(与项目其他设置页一致)
const INPUT_CLS =
'w-full h-9 px-2.5 rounded-lg bg-base border-0 ring-1 ring-border/30 text-xs font-mono text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow'
const CODEX_PROVIDER = 'codex_cli'
const OPENAI_PROVIDER = 'openai_compat'
const CUSTOM_CODEX_MODEL = '__custom__'
const CODEX_COMMAND = 'codex'
const CODEX_MODEL_OPTIONS = [
{ label: 'Codex 默认(推荐)', value: '', hint: '使用当前 Codex CLI 支持的默认模型' },
{ label: 'gpt-5.5', value: 'gpt-5.5', hint: '高能力模型' },
{ label: 'gpt-5', value: 'gpt-5', hint: '通用模型' },
]
const PRESETS: { label: string; provider?: string; url: string; model: string; codexCommand?: string; website: string; websiteLabel: string; description: string; partner?: boolean; promo?: string }[] = [
{ label: 'DeepSeek', url: 'https://api.deepseek.com', model: 'deepseek-v4-pro', website: 'https://www.deepseek.com/', websiteLabel: 'deepseek.com', description: 'DeepSeek 官方 OpenAI 兼容接口。' },
{ label: '通义千问', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-3.6plus', website: 'https://tongyi.aliyun.com/', websiteLabel: 'tongyi.aliyun.com', description: '阿里云 DashScope 兼容模式接口。' },
{ label: '智谱 GLM', url: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-5.2', website: 'https://open.bigmodel.cn/', websiteLabel: 'open.bigmodel.cn', description: '智谱 AI 官方 OpenAI 兼容接口。' },
{ label: 'Kimi', url: 'https://api.moonshot.cn/v1', model: 'kimi-k2.6', website: 'https://platform.moonshot.cn/', websiteLabel: 'platform.moonshot.cn', description: '月之暗面 Moonshot 官方 OpenAI 兼容接口,支持超长上下文。' },
{ label: 'Codex CLI', provider: CODEX_PROVIDER, url: '', model: '', codexCommand: CODEX_COMMAND, website: 'https://developers.openai.com/codex/noninteractive', websiteLabel: 'codex exec', description: '调用本机 Codex CLI 的 codex exec, 适合已登录 ChatGPT/Codex 的本地环境。' },
{ label: '炸鸡中转站', url: 'https://code.alysc.top/v1', model: 'gpt-5.5', website: 'https://code.alysc.top/sign-up?aff=1afk', websiteLabel: 'code.alysc.top', description: 'OpenAI 兼容中转服务,适合直接使用国际模型。', partner: true, promo: '通过链接邀请注册赠送免费额度 · 国际模型最低0.01倍率' },
]
export function SettingsAIPanel() {
const qc = useQueryClient()
const settings = useSettings()
const s = settings.data
const [provider, setProvider] = useState(OPENAI_PROVIDER)
const [baseUrl, setBaseUrl] = useState('')
const [apiKey, setApiKey] = useState('')
const [model, setModel] = useState('')
const [codexCustomModel, setCodexCustomModel] = useState(false)
const [codexCommand, setCodexCommand] = useState(CODEX_COMMAND)
const [customUa, setCustomUa] = useState(false)
const [userAgent, setUserAgent] = useState('')
const [showKey, setShowKey] = useState(false)
const [saved, setSaved] = useState(false)
const [confirmClear, setConfirmClear] = useState(false)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ ok: boolean; msg: string } | null>(null)
const isCodexProvider = provider === CODEX_PROVIDER
const savedCodexProvider = s?.ai_provider === CODEX_PROVIDER
const configured = s?.ai_configured ?? (savedCodexProvider ? !!(s?.ai_codex_command ?? CODEX_COMMAND) : s?.has_ai_key)
const selectedPreset = PRESETS.find(p => (p.provider ?? OPENAI_PROVIDER) === provider && (isCodexProvider ? p.codexCommand === codexCommand : p.url === baseUrl))
const codexModelSelectValue = codexCustomModel ? CUSTOM_CODEX_MODEL : model
const canSave = isCodexProvider ? true : !!baseUrl.trim() && !!model.trim()
useEffect(() => {
if (!s) return
setProvider(s.ai_provider ?? OPENAI_PROVIDER)
setBaseUrl(s.ai_base_url ?? '')
setModel(s.ai_model ?? '')
setCodexCustomModel(!!s.ai_model && !CODEX_MODEL_OPTIONS.some(o => o.value === s.ai_model))
setCodexCommand(s.ai_codex_command ?? CODEX_COMMAND)
const ua = s.ai_user_agent ?? ''
setCustomUa(!!ua)
setUserAgent(ua)
}, [s])
const payload = () => ({
provider,
base_url: baseUrl,
api_key: apiKey || undefined,
model,
codex_command: isCodexProvider ? CODEX_COMMAND : codexCommand,
user_agent: customUa ? userAgent : '',
})
const save = useMutation({
mutationFn: () => api.saveAiSettings(payload()),
onSuccess: (result) => {
setSaved(true)
setApiKey('')
qc.setQueryData<SettingsState>(QK.settings, prev => prev ? {
...prev,
ai_provider: result.ai_provider ?? provider,
ai_base_url: baseUrl,
ai_model: result.ai_model ?? model,
ai_codex_command: result.ai_codex_command ?? (isCodexProvider ? CODEX_COMMAND : codexCommand),
ai_configured: result.ai_configured ?? (isCodexProvider ? true : (apiKey ? true : prev.ai_configured)),
...(apiKey ? {
has_ai_key: true,
ai_api_key_masked: `${apiKey.slice(0, 4)}......${apiKey.slice(-4)}`,
} : {}),
} : prev)
qc.invalidateQueries({ queryKey: QK.settings })
setTimeout(() => setSaved(false), 2000)
},
})
const clear = useMutation({
mutationFn: () => api.clearAiSettings(),
onSuccess: () => {
setConfirmClear(false)
setProvider(OPENAI_PROVIDER)
setBaseUrl('')
setApiKey('')
setModel('')
setCodexCustomModel(false)
setCodexCommand(CODEX_COMMAND)
setTestResult(null)
qc.setQueryData<SettingsState>(QK.settings, prev => prev ? {
...prev,
ai_provider: OPENAI_PROVIDER,
ai_base_url: '',
ai_model: '',
ai_codex_command: CODEX_COMMAND,
has_ai_key: false,
ai_configured: false,
ai_api_key_masked: '',
} : prev)
qc.invalidateQueries({ queryKey: QK.settings })
},
})
const genRandomUa = () => {
const major = 128 + Math.floor(Math.random() * 8)
const platforms = [
'Windows NT 10.0; Win64; x64',
'Macintosh; Intel Mac OS X 10_15_7',
'X11; Linux x86_64',
]
const pf = platforms[Math.floor(Math.random() * platforms.length)]
setUserAgent(`Mozilla/5.0 (${pf}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36`)
}
const handlePreset = (p: typeof PRESETS[number]) => {
setProvider(p.provider ?? OPENAI_PROVIDER)
setBaseUrl(p.url)
setModel(p.model)
setCodexCustomModel(false)
if (p.codexCommand) setCodexCommand(CODEX_COMMAND)
}
const handleTest = async () => {
setTesting(true)
setTestResult(null)
try {
if (canSave) await api.saveAiSettings(payload())
const r = await api.strategyAiTest()
setTestResult({ ok: r.ok, msg: r.ok ? `连通成功 · ${r.model ?? provider}` : (r.error ?? '未知错误') })
} catch (e: any) {
setTestResult({ ok: false, msg: String(e?.message ?? '测试失败') })
} finally {
setTesting(false)
}
}
return (
<div className="space-y-5 max-w-2xl">
<Card icon={Plug} title="连接状态" right={
configured && (
<button onClick={handleTest} disabled={testing}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn bg-elevated hover:bg-elevated/80 text-xs text-secondary transition-colors duration-150 ease-smooth disabled:opacity-50">
{testing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wifi className="h-3 w-3" />}
{testing ? '测试中' : '测试'}
</button>
)
}>
<div className="flex items-center gap-3">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 ${configured ? 'bg-emerald-400/10 text-emerald-400' : 'bg-amber-400/10 text-amber-400'}`}>
{configured ? <Wifi className="h-4.5 w-4.5" /> : <WifiOff className="h-4.5 w-4.5" />}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">{configured ? 'AI 已连接' : 'AI 未配置'}</div>
<div className="text-xs text-muted mt-0.5 truncate">
{configured
? (savedCodexProvider
? `${s?.ai_codex_command ?? CODEX_COMMAND} · ${s?.ai_model || '默认模型'}`
: `${s?.ai_model} · ${s?.ai_api_key_masked}`)
: (isCodexProvider ? '使用本机 codex exec, 此处无需填写 API Key。' : '配置 API Key 后即可使用 AI 功能。')}
</div>
</div>
</div>
{testResult && (
<div className={`mt-3 rounded-btn border px-3 py-2 text-xs flex items-center gap-2 ${testResult.ok ? 'border-emerald-400/20 bg-emerald-400/[0.04] text-emerald-400' : 'border-danger/20 bg-danger/[0.04] text-danger'}`}>
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${testResult.ok ? 'bg-emerald-400' : 'bg-danger'}`} />
{testResult.msg}
</div>
)}
</Card>
<Card icon={Zap} title="快速预设">
<div className="flex flex-wrap items-start gap-2">
{PRESETS.map(p => (
<button key={p.label} onClick={() => handlePreset(p)}
className={`rounded-lg border px-3 py-2 text-left transition-all ${selectedPreset?.label === p.label ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border bg-base text-secondary hover:border-accent/30'}`}>
<div className="flex items-center gap-1.5 text-xs font-medium">
<span>{p.label}</span>
{p.provider === CODEX_PROVIDER && <Terminal className="h-3 w-3" />}
{p.partner && <span className="rounded-full border border-orange-400/30 bg-orange-400/10 px-1.5 py-px text-[9px] text-orange-400"></span>}
</div>
</button>
))}
</div>
{selectedPreset && (
<div className="mt-3 rounded-btn border border-border/30 bg-base/30 px-3 py-2 text-[11px] leading-relaxed">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-secondary">{selectedPreset.description}</span>
{selectedPreset.promo && <span className="text-amber-400">{selectedPreset.promo}</span>}
</div>
<a href={selectedPreset.website} target="_blank" rel="noreferrer"
className="mt-1 inline-flex items-center gap-1 text-muted hover:text-accent transition-colors">
{selectedPreset.websiteLabel}
<ExternalLink className="h-3 w-3" />
</a>
</div>
)}
</Card>
<Card
icon={Settings2}
title="自定义配置"
right={
<span className="inline-flex items-center gap-1.5 text-[10px] text-muted/60" title={isCodexProvider ? 'Use local Codex CLI via codex exec' : 'Use OpenAI-compatible Chat Completions API'}>
<span className="rounded-full border border-border/40 bg-base/50 px-1.5 py-px font-mono">{isCodexProvider ? 'codex exec' : 'Chat Completions'}</span>
{isCodexProvider ? 'CLI' : '接口'}
</span>
}
>
<div className="space-y-4">
{isCodexProvider ? (
<div className="grid grid-cols-2 gap-4">
<Field label="CLI 命令" hint="固定使用默认 codex 命令, 由后端自动解析本机 Codex Desktop/CLI, 不支持自定义可执行路径。">
<div className={`${INPUT_CLS} flex items-center text-muted/80 select-none`} aria-label="Codex CLI command">
{CODEX_COMMAND}
</div>
</Field>
<Field
label="模型(可选)"
hint={codexCustomModel
? '留空则使用 Codex 默认模型'
: CODEX_MODEL_OPTIONS.find(o => o.value === model)?.hint}
>
<select
value={codexModelSelectValue}
onChange={e => {
const value = e.target.value
if (value === CUSTOM_CODEX_MODEL) {
setCodexCustomModel(true)
if (CODEX_MODEL_OPTIONS.some(o => o.value === model)) setModel('')
} else {
setCodexCustomModel(false)
setModel(value)
}
}}
className={INPUT_CLS}
>
{CODEX_MODEL_OPTIONS.map(option => (
<option key={option.label} value={option.value}>{option.label}</option>
))}
<option value={CUSTOM_CODEX_MODEL}></option>
</select>
{codexCustomModel && (
<input
type="text"
value={model}
onChange={e => setModel(e.target.value)}
placeholder="例如 gpt-5.5"
className={`${INPUT_CLS} mt-2`}
/>
)}
</Field>
</div>
) : (
<>
<div className="grid grid-cols-2 gap-4">
<Field label="API 地址">
<input type="text" value={baseUrl} onChange={e => setBaseUrl(e.target.value)} placeholder="https://code.alysc.top" className={INPUT_CLS} />
</Field>
<Field label="模型">
<input type="text" value={model} onChange={e => setModel(e.target.value)} placeholder="gpt-5.5" className={INPUT_CLS} />
</Field>
</div>
<Field label="API Key">
<div className="flex gap-2">
<div className="flex-1 relative">
<input type={showKey ? 'text' : 'password'} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={configured ? `${s?.ai_api_key_masked} · 留空不修改` : 'sk-...'} className={`${INPUT_CLS} pr-9`} />
<button onClick={() => setShowKey(v => !v)} className="absolute right-2 top-1/2 -translate-y-1/2 text-muted/40 hover:text-muted" tabIndex={-1} aria-label={showKey ? '隐藏' : '显示'}>
{showKey ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
</div>
<button onClick={handleTest} disabled={testing || !apiKey} className="h-9 px-3 rounded-lg border border-border/50 text-xs text-secondary hover:text-accent hover:border-accent/30 disabled:opacity-40 transition-all flex items-center gap-1.5 shrink-0">
{testing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wifi className="h-3 w-3" />}
</button>
</div>
</Field>
<div className="border-t border-border/20" />
<div className="space-y-2">
<div className="flex items-center justify-between">
<Field label="自定义 User-Agent" inline>
<Toggle checked={customUa} onChange={() => setCustomUa(v => !v)} />
</Field>
</div>
{customUa && (
<div className="flex gap-2">
<input type="text" value={userAgent} onChange={e => setUserAgent(e.target.value)} placeholder="粘贴浏览器 User-Agent" className={`${INPUT_CLS} flex-1`} />
<button type="button" onClick={genRandomUa} title="随机生成浏览器 User-Agent" className="h-9 px-2.5 rounded-lg border border-border/50 text-xs text-secondary hover:text-accent hover:border-accent/30 transition-all flex items-center gap-1.5 shrink-0">
<Shuffle className="h-3 w-3" />
</button>
</div>
)}
</div>
</>
)}
</div>
</Card>
<div className="rounded-card border border-amber-400/20 bg-amber-400/[0.04] px-4 py-3 flex items-start gap-3">
<Shield className="h-4 w-4 text-amber-400/70 mt-0.5 shrink-0" />
<div className="text-[11px] text-amber-400/70 leading-relaxed">
{isCodexProvider
? 'Codex CLI 模式会复用本机已登录的 Codex 账户, 个股、财务、复盘等分析上下文会发送给 OpenAI/Codex。保存即表示确认仅在本机或可信内网使用。'
: 'API Key 仅保存在本机项目文件中, 不会上传到任何服务器。请妥善保管。'}
</div>
</div>
<div className="flex gap-2">
<button onClick={() => save.mutate()} disabled={save.isPending || !canSave} className="flex-1 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>
{configured && (
<button onClick={() => setConfirmClear(true)} disabled={clear.isPending} className="h-10 px-4 rounded-xl bg-elevated text-secondary hover:text-danger text-sm flex items-center justify-center gap-1.5 hover:bg-elevated/80 disabled:opacity-50 transition-all shrink-0" title="Clear AI provider configuration">
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
{confirmClear && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setConfirmClear(false)} />
<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"> AI </h3>
<p className="text-xs text-secondary mb-5 leading-relaxed">
providerAPI KeyAPI Codex CLI
</p>
<div className="flex items-center justify-end gap-2">
<button onClick={() => setConfirmClear(false)} className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-sm transition-colors">
</button>
<button onClick={() => clear.mutate()} disabled={clear.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">
{clear.isPending ? '清空中...' : '确认'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
// ===== 通用卡片(与 Keys 页风格统一) =====
interface CardProps {
icon: React.ComponentType<{ className?: string }>
title: string
right?: React.ReactNode
children: React.ReactNode
}
function Card({ icon: Icon, title, right, children }: CardProps) {
return (
<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">
<Icon className="h-4 w-4 text-secondary" />
<h2 className="text-sm font-medium text-foreground">{title}</h2>
</div>
{right}
</div>
{children}
</section>
)
}
// ===== 表单字段(统一 label + 输入框样式) =====
function Field({ label, hint, inline, children }: {
label: string
hint?: string
inline?: boolean
children: React.ReactNode
}) {
if (inline) {
return (
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[10px] text-muted/50 uppercase tracking-wider">{label}</div>
{hint && <div className="text-[10px] text-muted mt-0.5">{hint}</div>}
</div>
{children}
</div>
)
}
return (
<div className="space-y-1.5">
<div className="text-[10px] text-muted/50 uppercase tracking-wider">{label}</div>
{children}
{hint && <div className="text-[10px] text-muted">{hint}</div>}
</div>
)
}
// ===== 开关 =====
function Toggle({ checked, onChange }: { checked: boolean; onChange: () => void }) {
return (
<button
type="button"
onClick={onChange}
className={`relative inline-flex h-5 w-9 items-center rounded-full shrink-0 transition-colors duration-200 ${checked ? 'bg-accent' : 'bg-elevated'}`}
aria-pressed={checked}
>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${checked ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
)
}
@@ -0,0 +1,276 @@
import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Plus, Trash2, Zap, Settings2, Lock } from 'lucide-react'
import { api, type CustomSignal } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { BUILTIN_SIGNAL_DEFINITIONS, type SignalKind } from '@/lib/signals'
import { CustomSignalDialog } from '@/components/signals/CustomSignalDialog'
type SignalSection = 'builtin' | 'custom'
const KIND_LABEL: Record<SignalKind, string> = { entry: '买入', exit: '卖出', both: '买卖通用' }
const KIND_CLASS: Record<SignalKind, string> = {
entry: 'bg-accent/10 text-accent',
exit: 'bg-warning/10 text-warning',
both: 'bg-muted/10 text-muted',
}
export function SettingsCustomSignalsPanel() {
const qc = useQueryClient()
const list = useQuery({ queryKey: QK.customSignals, queryFn: api.customSignalsList })
const options = useQuery({ queryKey: QK.customSignalsOptions, queryFn: api.customSignalsOptions })
const [activeSection, setActiveSection] = useState<SignalSection>('builtin')
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<CustomSignal | null>(null)
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null)
const resetDeleteTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const fields = options.data?.fields ?? []
const signals = list.data?.signals ?? []
const enabledCustomSignals = signals.filter(sig => sig.enabled).length
const tabs = [
{ key: 'builtin' as const, label: '内置信号', count: BUILTIN_SIGNAL_DEFINITIONS.length, hint: '系统提供,只读' },
{ key: 'custom' as const, label: '自定义信号', count: signals.length, hint: `${enabledCustomSignals} 个已启用` },
]
useEffect(() => () => {
if (resetDeleteTimer.current) clearTimeout(resetDeleteTimer.current)
}, [])
const clearDeleteConfirm = () => {
if (resetDeleteTimer.current) clearTimeout(resetDeleteTimer.current)
resetDeleteTimer.current = null
setConfirmingDeleteId(null)
}
const openNew = () => {
setEditing(null)
clearDeleteConfirm()
setActiveSection('custom')
setShowForm(true)
}
const openEdit = (sig: CustomSignal) => {
setEditing(sig)
clearDeleteConfirm()
setActiveSection('custom')
setShowForm(true)
}
const closeForm = () => {
setShowForm(false)
setEditing(null)
}
const del = useMutation({
mutationFn: api.customSignalDelete,
onSuccess: () => {
clearDeleteConfirm()
qc.invalidateQueries({ queryKey: QK.customSignals })
},
})
const toggleEnabled = (sig: CustomSignal) => {
api.customSignalSave({ ...sig, enabled: !sig.enabled }).then(() => qc.invalidateQueries({ queryKey: QK.customSignals }))
}
const handleDeleteClick = (sig: CustomSignal) => {
if (confirmingDeleteId === sig.id) {
clearDeleteConfirm()
del.mutate(sig.id)
return
}
setConfirmingDeleteId(sig.id)
if (resetDeleteTimer.current) clearTimeout(resetDeleteTimer.current)
resetDeleteTimer.current = setTimeout(() => setConfirmingDeleteId(null), 3000)
}
return (
<div className="max-w-6xl space-y-6">
<section className="rounded-2xl border border-border bg-surface p-6 bg-[radial-gradient(circle_at_top_right,rgba(234,179,8,0.12),transparent_38%)]">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-[11px] uppercase tracking-[0.2em] text-amber-400/80"></div>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-foreground"></h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-secondary">
+ + 使
</p>
</div>
<button
onClick={openNew}
className="inline-flex items-center justify-center gap-1.5 rounded-btn bg-amber-500/90 px-3 py-1.5 text-xs font-medium text-base hover:bg-amber-500 transition-colors"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<div className="mt-5 grid grid-cols-1 gap-3 md:grid-cols-3">
<StatCard label="内置信号" value={BUILTIN_SIGNAL_DEFINITIONS.length} hint="系统提供,只读" />
<StatCard label="自定义信号" value={signals.length} hint="用户创建,可编辑" />
<StatCard label="已启用自定义" value={enabledCustomSignals} hint="会注入 csg_* 列" />
</div>
<div className="mt-5 rounded-card border border-border bg-base/60 p-1.5">
<div className="grid grid-cols-1 gap-1.5 md:grid-cols-2">
{tabs.map(tab => {
const active = activeSection === tab.key
return (
<button
key={tab.key}
type="button"
onClick={() => setActiveSection(tab.key)}
className={`rounded-btn px-4 py-3 text-left transition-colors ${active ? 'bg-amber-500/15 text-amber-300 shadow-sm' : 'text-secondary hover:bg-elevated hover:text-foreground'}`}
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium">{tab.label}</span>
<span className={`rounded px-2 py-0.5 text-[11px] ${active ? 'bg-amber-400/15 text-amber-300' : 'bg-elevated text-muted'}`}>{tab.count}</span>
</div>
<div className="mt-1 text-[11px] text-muted">{tab.hint}</div>
</button>
)
})}
</div>
</div>
</section>
{activeSection === 'builtin' && (
<section className="rounded-card border border-border bg-surface p-5 space-y-4">
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<div className="flex items-center gap-2">
<Lock className="h-3.5 w-3.5 text-muted" />
<h3 className="text-sm font-medium text-foreground"></h3>
<span className="rounded bg-elevated px-1.5 py-0.5 text-[10px] text-muted"></span>
</div>
<p className="mt-1 text-xs text-muted"> enriched </p>
</div>
<div className="text-[11px] text-muted">ID <span className="font-mono text-foreground/70">signal_</span></div>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{BUILTIN_SIGNAL_DEFINITIONS.map(sig => (
<div key={sig.id} className="rounded-card border border-border bg-base p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-foreground truncate">{sig.name}</h4>
<span className={`rounded px-1.5 py-0.5 text-[10px] ${KIND_CLASS[sig.kind]}`}>
{KIND_LABEL[sig.kind]}
</span>
</div>
<p className="mt-1 text-[11px] text-muted font-mono truncate">{sig.id}</p>
</div>
<span className="shrink-0 rounded border border-border bg-elevated px-1.5 py-0.5 text-[10px] text-muted">{sig.category}</span>
</div>
<p className="mt-3 text-xs leading-5 text-secondary">{sig.description}</p>
</div>
))}
</div>
</section>
)}
{activeSection === 'custom' && (
<section className="rounded-card border border-border bg-surface p-5 space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="flex items-center gap-2">
<Zap className="h-3.5 w-3.5 text-amber-400" />
<h3 className="text-sm font-medium text-foreground"></h3>
<span className="rounded bg-amber-400/10 px-1.5 py-0.5 text-[10px] text-amber-400"></span>
</div>
<p className="mt-1 text-xs text-muted">/ csg_* 使</p>
</div>
<button
onClick={openNew}
className="inline-flex items-center justify-center gap-1.5 rounded-btn border border-amber-400/30 bg-amber-400/5 px-3 py-1.5 text-xs font-medium text-amber-400 hover:bg-amber-400/10 transition-colors"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{signals.map(sig => (
<div key={sig.id} className="rounded-card border border-border bg-base p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-foreground truncate">{sig.name}</h3>
<span className={`rounded px-1.5 py-0.5 text-[10px] ${KIND_CLASS[sig.kind]}`}>
{KIND_LABEL[sig.kind]}
</span>
{!sig.enabled && <span className="rounded bg-muted/10 px-1.5 py-0.5 text-[10px] text-muted"></span>}
</div>
<p className="mt-1 text-[11px] text-muted font-mono truncate">csg_{sig.id}</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => toggleEnabled(sig)} title={sig.enabled ? '停用' : '启用'} className={`p-1 rounded cursor-pointer ${sig.enabled ? 'text-emerald-400 hover:bg-emerald-400/10' : 'text-muted hover:bg-elevated'}`}>
<Zap className="h-3.5 w-3.5" />
</button>
<button onClick={() => openEdit(sig)} className="p-1 rounded text-muted hover:text-accent hover:bg-accent/10 cursor-pointer" title="编辑">
<Settings2 className="h-3.5 w-3.5" />
</button>
{confirmingDeleteId === sig.id ? (
<button
onClick={() => handleDeleteClick(sig)}
disabled={del.isPending}
title="再次点击确认删除"
className="inline-flex items-center gap-1 rounded-md bg-danger/15 px-1.5 py-0.5 text-[10px] font-medium text-danger border border-danger/30 animate-pulse cursor-pointer disabled:opacity-50"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
) : (
<button
onClick={() => handleDeleteClick(sig)}
disabled={del.isPending}
className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10 cursor-pointer disabled:opacity-50"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
<div className="mt-3 space-y-1">
{sig.conditions.map((c, i) => (
<div key={i} className="flex items-center gap-1.5 text-[11px] text-secondary">
<span className="text-muted/50 w-6 text-right">{i === 0 ? '当' : '且'}</span>
<span className="font-mono text-foreground/80">{fieldLabel(c.left, fields)}</span>
<span className="font-mono text-muted">{c.op}</span>
<span className="font-mono text-foreground/80">{rightDisplay(c.right, fields)}</span>
</div>
))}
</div>
</div>
))}
{signals.length === 0 && (
<div className="rounded-card border border-border bg-base px-5 py-10 text-center text-sm text-muted md:col-span-2">
</div>
)}
</div>
</section>
)}
<CustomSignalDialog open={showForm} signal={editing} onClose={closeForm} />
</div>
)
}
function StatCard({ label, value, hint }: { label: string; value: number; hint: string }) {
return (
<div className="rounded-card border border-border/80 bg-base/70 px-4 py-3">
<div className="text-[11px] text-muted">{label}</div>
<div className="mt-1 text-2xl font-semibold text-foreground">{value}</div>
<div className="mt-0.5 text-[11px] text-muted">{hint}</div>
</div>
)
}
function fieldLabel(key: string, fields: { key: string; label: string }[]): string {
return fields.find(f => f.key === key)?.label ?? key
}
function rightDisplay(right: string, fields: { key: string; label: string }[]): string {
if (right.startsWith('field:')) return fieldLabel(right.slice(6), fields)
return right
}
@@ -0,0 +1,291 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ExternalLink, Pencil, Plus, Save, Trash2, X } from 'lucide-react'
import { api, type AnalysisColumn, type AnalysisMenu, type ExtDataConfig, type ExtDataField } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
function dtypeToColumnType(dtype: string): AnalysisColumn['type'] {
return dtype === 'int' || dtype === 'float' ? 'number' : 'string'
}
function buildColumn(field: ExtDataField): AnalysisColumn {
return {
field: field.name,
label: field.label || field.name,
type: dtypeToColumnType(field.dtype),
precision: field.dtype === 'float' ? 2 : null,
sortable: field.dtype === 'int' || field.dtype === 'float',
visible: true,
}
}
function firstMatchingField(config: ExtDataConfig | undefined, keywords: string[]) {
if (!config) return ''
for (const keyword of keywords) {
const lower = keyword.toLowerCase()
const matched = config.fields.find(f => f.name.toLowerCase().includes(lower) || f.label.toLowerCase().includes(lower))
if (matched) return matched.name
}
return config.fields.find(f => !['symbol', 'code'].includes(f.name) && f.dtype === 'string')?.name ?? ''
}
export function SettingsExtPagesPanel() {
const qc = useQueryClient()
const menus = useQuery({ queryKey: QK.analysisMenus, queryFn: api.analysisMenus })
const extData = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
const configs = extData.data?.items ?? []
const menuItems = menus.data?.items ?? []
const [showForm, setShowForm] = useState(false)
const [editingMenu, setEditingMenu] = useState<AnalysisMenu | null>(null)
const [id, setId] = useState('')
const [label, setLabel] = useState('')
const [dataSource, setDataSource] = useState('')
const [template, setTemplate] = useState<'dimension_rank' | 'ranking' | 'table'>('dimension_rank')
const [dimensionField, setDimensionField] = useState('')
const [rankField, setRankField] = useState('')
const [selectedColumns, setSelectedColumns] = useState<string[]>([])
const [error, setError] = useState('')
const activeConfig = configs.find(c => c.id === dataSource) ?? configs[0]
const fields = activeConfig?.fields ?? []
const numericFields = useMemo(() => fields.filter(f => f.dtype === 'int' || f.dtype === 'float'), [fields])
const resetForm = () => {
const cfg = configs[0]
setEditingMenu(null)
setId('')
setLabel('')
setDataSource(cfg?.id ?? '')
setTemplate('dimension_rank')
setDimensionField(firstMatchingField(cfg, ['概念', 'industry', '行业', 'sector']))
setRankField('')
setSelectedColumns(cfg?.fields.filter(f => !['symbol', 'code'].includes(f.name)).slice(0, 6).map(f => f.name) ?? [])
setError('')
}
const editMenu = (menu: AnalysisMenu) => {
const cfg = configs.find(c => c.id === menu.data_source)
setEditingMenu(menu)
setId(menu.id)
setLabel(menu.label)
setDataSource(menu.data_source)
setTemplate(menu.template)
setDimensionField(menu.dimension_field ?? firstMatchingField(cfg, ['概念', 'industry', '行业', 'sector']))
setRankField(menu.rank_field ?? '')
setSelectedColumns(menu.detail_columns.map(c => c.field))
setError('')
setShowForm(true)
}
const save = useMutation({
mutationFn: () => {
const cfg = activeConfig
if (!cfg) throw new Error('请选择扩展数据源')
if (!id.trim()) throw new Error('请输入菜单标识')
if (!label.trim()) throw new Error('请输入菜单名称')
if (template === 'dimension_rank' && !dimensionField) throw new Error('请选择分组字段')
if (template === 'ranking' && !rankField) throw new Error('请选择排名字段')
const detailColumns = selectedColumns
.map(name => cfg.fields.find(f => f.name === name))
.filter(Boolean)
.map(f => buildColumn(f as ExtDataField))
const groupColumns: AnalysisColumn[] = template === 'dimension_rank'
? [
{ field: '__dimension', label: cfg.fields.find(f => f.name === dimensionField)?.label || '分组', type: 'string', visible: true },
{ field: '__count', label: '股票数', type: 'number', sortable: true, visible: true },
...detailColumns.filter(c => c.type === 'number').slice(0, 2).map(c => ({ ...c, label: `平均${c.label || c.field}`, aggregate: 'avg' as const })),
]
: []
return api.analysisMenuSave(id.trim(), {
label: label.trim(),
icon: template === 'dimension_rank' ? 'tags' : 'chart',
data_source: cfg.id,
template,
dimension_field: template === 'dimension_rank' ? dimensionField : null,
rank_field: template === 'ranking' ? rankField : null,
group_columns: groupColumns,
detail_columns: detailColumns,
default_sort: template === 'ranking' && rankField ? { field: rankField, order: 'desc' } : null,
visible: editingMenu?.visible ?? true,
order: editingMenu?.order ?? menuItems.length + 100,
})
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.analysisMenus })
setShowForm(false)
resetForm()
},
onError: (err) => setError(String((err as any)?.message ?? err)),
})
const del = useMutation({
mutationFn: api.analysisMenuDelete,
onSuccess: () => qc.invalidateQueries({ queryKey: QK.analysisMenus }),
})
return (
<div className="max-w-6xl space-y-6">
<section className="rounded-2xl border border-border bg-surface p-6 bg-[radial-gradient(circle_at_top_right,rgba(139,92,246,0.14),transparent_38%)]">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-[11px] uppercase tracking-[0.2em] text-accent/80"></div>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-foreground"></h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-secondary">
访
</p>
</div>
<button
onClick={() => { resetForm(); setShowForm(true) }}
className="inline-flex items-center justify-center gap-1.5 rounded-btn bg-accent/90 px-3 py-1.5 text-xs font-medium text-base hover:bg-accent transition-colors"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
</section>
{showForm && (
<section className="rounded-card border border-border bg-surface p-5 space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-medium text-foreground">{editingMenu ? '编辑扩展页面' : '新建扩展页面'}</h3>
<p className="mt-1 text-[11px] text-muted"></p>
</div>
<button onClick={() => { setShowForm(false); setError('') }} className="rounded p-1 text-muted hover:bg-elevated hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<input
value={id}
disabled={!!editingMenu}
onChange={e => setId(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))}
placeholder="如 concept_hot"
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground disabled:opacity-60"
/>
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<input value={label} onChange={e => setLabel(e.target.value)} placeholder="如 概念热度" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<select
value={dataSource || activeConfig?.id || ''}
onChange={e => {
const cfg = configs.find(c => c.id === e.target.value)
setDataSource(e.target.value)
setDimensionField(firstMatchingField(cfg, ['概念', 'industry', '行业', 'sector']))
setRankField('')
setSelectedColumns(cfg?.fields.filter(f => !['symbol', 'code'].includes(f.name)).slice(0, 6).map(f => f.name) ?? [])
}}
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground"
>
{configs.map(cfg => <option key={cfg.id} value={cfg.id}>{cfg.label}</option>)}
</select>
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<select value={template} onChange={e => setTemplate(e.target.value as any)} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground">
<option value="dimension_rank"></option>
<option value="ranking"></option>
<option value="table"></option>
</select>
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<select value={dimensionField} onChange={e => setDimensionField(e.target.value)} disabled={template !== 'dimension_rank'} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground disabled:opacity-50">
<option value=""></option>
{fields.map(f => <option key={f.name} value={f.name}>{f.label || f.name}</option>)}
</select>
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<select value={rankField} onChange={e => setRankField(e.target.value)} disabled={template !== 'ranking'} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground disabled:opacity-50">
<option value=""></option>
{numericFields.map(f => <option key={f.name} value={f.name}>{f.label || f.name}</option>)}
</select>
</label>
</div>
<div>
<div className="text-[11px] text-muted mb-2"></div>
<div className="flex flex-wrap gap-2">
{fields.filter(f => !['symbol', 'code'].includes(f.name)).map(f => {
const active = selectedColumns.includes(f.name)
return (
<button
key={f.name}
onClick={() => setSelectedColumns(cols => active ? cols.filter(c => c !== f.name) : [...cols, f.name])}
className={`rounded-full border px-3 py-1 text-[11px] transition-colors ${active ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border bg-elevated/40 text-secondary hover:bg-elevated'}`}
>
{f.label || f.name}
</button>
)
})}
</div>
</div>
{error && <div className="rounded-btn border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">{error}</div>}
<div className="flex justify-end gap-2">
<button onClick={() => { setShowForm(false); setError('') }} className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs"></button>
<button onClick={() => save.mutate()} disabled={save.isPending} className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium disabled:opacity-50">
<Save className="h-3.5 w-3.5" />
</button>
</div>
</section>
)}
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{menuItems.map(menu => (
<div key={menu.id} className="rounded-card border border-border bg-surface p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-foreground">{menu.label}</h3>
{menu.builtin && <span className="rounded bg-accent/10 px-1.5 py-0.5 text-[10px] text-accent"></span>}
{!menu.visible && <span className="rounded bg-muted/10 px-1.5 py-0.5 text-[10px] text-muted"></span>}
</div>
<p className="mt-1 text-[11px] text-muted font-mono">{menu.id}</p>
</div>
<div className="flex items-center gap-1">
<button onClick={() => editMenu(menu)} className="p-1 rounded text-muted hover:text-accent hover:bg-accent/10" title="编辑">
<Pencil className="h-3.5 w-3.5" />
</button>
{!menu.builtin && (
<button onClick={() => del.mutate(menu.id)} disabled={del.isPending} className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10" title="删除">
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
<div className="mt-3 space-y-1 text-[11px] text-secondary">
<div><span className="font-mono text-muted">{menu.data_source}</span></div>
<div>{menu.template}</div>
{menu.dimension_field && <div>{menu.dimension_field}</div>}
<div>{menu.detail_columns.length} </div>
</div>
<Link to={`/analysis/${menu.id}`} className="mt-4 inline-flex w-full items-center justify-center gap-1.5 rounded-btn border border-border bg-elevated px-3 py-1.5 text-xs text-foreground hover:bg-border/30 transition-colors">
<ExternalLink className="h-3.5 w-3.5" />
</Link>
</div>
))}
{menuItems.length === 0 && (
<div className="rounded-card border border-border bg-surface px-5 py-10 text-center text-sm text-muted md:col-span-2 xl:col-span-3"></div>
)}
</section>
</div>
)
}
+439
View File
@@ -0,0 +1,439 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import {
Key,
Eye,
EyeOff,
Trash2,
CheckCircle2,
AlertCircle,
RefreshCw,
Activity,
ExternalLink,
Loader2,
Save,
Check,
HelpCircle,
} from 'lucide-react'
import { api } from '@/lib/api'
import { useCapabilities, useSettings } from '@/lib/useSharedQueries'
import { QK } from '@/lib/queryKeys'
import { CAP_LABELS, tierTextStyle, tierStyle, tierBaseName, ALL_TIERS, TierTag } from '@/lib/capability-labels'
// ===== 导出为 Panel 组件 (由 Settings.tsx 嵌入) =====
export function SettingsKeysPanel() {
const qc = useQueryClient()
const settings = useSettings()
const caps = useCapabilities()
const [keyInput, setKeyInput] = useState('')
const [revealing, setRevealing] = useState(false)
const [confirmClear, setConfirmClear] = useState(false)
const [saved, setSaved] = useState(false)
const save = useMutation({
mutationFn: () => api.saveTickflowKey(keyInput.trim()),
onSuccess: (data) => {
setKeyInput('')
qc.invalidateQueries({ queryKey: QK.settings })
qc.invalidateQueries({ queryKey: QK.capabilities })
if (data.ok) {
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
// ok=false 由 save.data 在下方渲染提示(reason=invalid),无需额外处理
},
})
const clear = useMutation({
mutationFn: () => api.clearTickflowKey(),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.settings })
qc.invalidateQueries({ queryKey: QK.capabilities })
},
})
const redetect = useMutation({
mutationFn: api.redetectCapabilities,
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.settings })
qc.invalidateQueries({ queryKey: QK.capabilities })
},
})
const mode = settings.data?.mode
const masked = settings.data?.tickflow_api_key_masked
const capCount = caps.data ? Object.keys(caps.data.capabilities).length : 0
return (
<>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_1.3fr] gap-6 max-w-5xl">
{/* ========== 左列: Key 配置 ========== */}
<div className="space-y-6">
<Card icon={Key} title="TickFlow API Key">
<p className="text-sm text-secondary leading-relaxed mb-4">
{' '}
<a
href="https://tickflow.org/auth/register?ref=V3KDKGXPEA"
target="_blank"
rel="noreferrer"
className="text-accent hover:underline inline-flex items-baseline gap-0.5"
>
tickflow.org
<ExternalLink className="h-3 w-3 self-center" />
</a>{' '}
API 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">
{mode === 'api_key' ? (
<>
<CheckCircle2 className="h-4 w-4 text-bear shrink-0" />
<span className="text-sm font-medium shrink-0"></span>
<span className="font-mono text-xs text-secondary truncate">{masked}</span>
</>
) : mode === 'free' ? (
<>
<CheckCircle2 className="h-4 w-4 text-bear shrink-0" />
<span className="text-sm font-medium shrink-0"> Key</span>
<span className="font-mono text-xs text-secondary truncate">{masked}</span>
</>
) : (
<>
<AlertCircle className="h-4 w-4 text-muted shrink-0" />
<span className="text-sm font-medium text-muted"></span>
</>
)}
</div>
</div>
{(mode === 'api_key' || mode === 'free') && (
<button
onClick={() => setConfirmClear(true)}
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={mode === 'none' ? '粘贴 TickFlow API Key' : '粘贴新 Key 替换当前'}
value={keyInput}
onChange={(e) => { setKeyInput(e.target.value); if (saved) setSaved(false) }}
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>
{/* 检测中提示 —— 成功/失败后自动消失 */}
{save.isPending && (
<div className="flex items-start gap-1.5 rounded-btn border border-warning/30 bg-warning/10 px-3 py-2 text-[11px] leading-snug text-warning">
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
<span>
·
<button
type="button"
onClick={() => { save.reset(); redetect.mutate() }}
disabled={redetect.isPending}
className="font-semibold underline underline-offset-2 hover:text-warning/80 disabled:opacity-50"
>
{redetect.isPending ? '重新检测中…' : '重新检测'}
</button>
</span>
</div>
)}
</form>
{save.isError && (
<div className="mt-3 text-xs text-danger">
:{String((save.error as any).message)}
</div>
)}
{/* 无效 key —— 先探后存:探测失败(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.reason === 'invalid'
? 'Key 无效或已过期,请检查后重试(未保存该 Key)'
: save.data.error ?? '保存失败'}
</div>
)}
{save.data?.ok && (
<div className="mt-3 text-xs text-bear flex items-center gap-1.5">
<CheckCircle2 className="h-3 w-3" />
{save.data.tier_label}
{save.data.mode === 'free' && '(免费档 · 历史日K + 自选实时监控)'}
</div>
)}
</Card>
</div>
{/* ========== 右列: 档位 + 能力 ========== */}
<div className="space-y-6">
<Card
icon={Activity}
title="订阅档位"
right={
<button
onClick={() => redetect.mutate()}
disabled={redetect.isPending}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn bg-elevated hover:bg-elevated/80 text-xs text-secondary transition-colors duration-150 ease-smooth disabled:opacity-50"
>
<RefreshCw className={`h-3 w-3 ${redetect.isPending ? 'animate-spin' : ''}`} />
</button>
}
>
{caps.data ? (
<>
<div className="flex items-center gap-1.5">
<div className="font-mono text-3xl font-bold tracking-tight" style={tierTextStyle(caps.data.label)}>
{caps.data.label}
</div>
<TierHelpPopover currentLabel={caps.data.label} />
</div>
<div className="mt-1 text-xs text-muted">
API Key · "代表性 capability"
</div>
{settings.data?.missing_caps && settings.data.missing_caps.length > 0 && (
<div className="mt-3 rounded-btn border border-warning/40 bg-warning/5 px-3 py-2 text-xs">
<div className="font-medium text-warning mb-1">
({settings.data.missing_caps.length} )
</div>
<div className="text-secondary space-y-0.5">
{settings.data.missing_caps.map((c) => (
<div key={c} className="font-mono">
{CAP_LABELS[c]?.name ?? c}
</div>
))}
</div>
</div>
)}
</>
) : (
<div className="text-sm text-muted"></div>
)}
</Card>
<Card icon={CheckCircle2} title="可用功能" badge={`${capCount}`}>
{caps.data && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
className="-mx-5 -mb-5"
>
<div className="border-t border-border">
{Object.entries(caps.data.capabilities).map(([cap, lim]) => {
const meta = CAP_LABELS[cap]
return (
<div
key={cap}
className="px-5 py-3 border-b border-border last:border-b-0 flex items-baseline justify-between gap-4"
>
<div className="min-w-0">
<div className="text-sm text-foreground truncate">
{meta?.name ?? cap}
</div>
{meta?.hint && (
<div className="mt-0.5 text-[11px] text-muted truncate">
{meta.hint}
</div>
)}
</div>
<div className="text-right shrink-0 text-xs">
<div className="font-mono text-foreground">
{lim.rpm ? `${lim.rpm}/min` : lim.subscribe ? `${lim.subscribe} 订阅` : '—'}
</div>
{lim.batch && (
<div className="font-mono text-muted">{lim.batch} /</div>
)}
</div>
</div>
)
})}
</div>
</motion.div>
)}
{settings.data?.probe_log && settings.data.probe_log.length > 0 && (
<details className="mt-4 -mx-5 -mb-5 border-t border-border">
<summary className="cursor-pointer px-5 py-3 text-xs text-muted hover:text-secondary transition-colors duration-150 ease-smooth select-none">
</summary>
<div className="px-5 pb-4 font-mono text-[11px] space-y-0.5 text-secondary">
{settings.data.probe_log.map((line, i) => (
<div key={i}>{line}</div>
))}
</div>
</details>
)}
</Card>
</div>
</div>
{/* 确认清除 Key 弹窗 */}
{confirmClear && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setConfirmClear(false)}
/>
<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"> API Key</h3>
<p className="text-xs text-secondary mb-5">
退 None (K), Key
</p>
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setConfirmClear(false)}
className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-sm transition-colors"
>
</button>
<button
onClick={() => { setConfirmClear(false); clear.mutate() }}
disabled={clear.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"
>
{clear.isPending ? '清除中...' : '确认清除'}
</button>
</div>
</div>
</div>
)}
</>
)
}
// ===== 通用卡片 =====
// ===== 档位说明弹窗 =====
function TierHelpPopover({ currentLabel }: { currentLabel: string }) {
const [open, setOpen] = useState(false)
const currentBase = tierBaseName(currentLabel)
return (
<div className="relative inline-flex items-center">
<HelpCircle
className="h-4 w-4 text-muted/60 cursor-help hover:text-muted transition-colors"
onClick={() => setOpen(v => !v)}
/>
<AnimatePresence>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.95 }}
transition={{ duration: 0.15 }}
className="absolute top-full left-0 mt-1 z-50 w-72 bg-surface border border-border rounded-lg shadow-xl p-3.5 text-[11px] leading-relaxed"
onClick={e => e.stopPropagation()}
>
{/* 档位 tag 横排 */}
<div className="flex items-center gap-1.5 mb-3">
{ALL_TIERS.map(t => (
<div key={t} className={`flex flex-col items-center gap-1 ${t === currentBase ? '' : 'opacity-60'}`}>
<TierTag label={t} />
</div>
))}
</div>
{/* 每档说明 */}
<div className="space-y-1 mb-3 pb-3 border-b border-border">
{ALL_TIERS.map(t => {
const s = tierStyle(t)
return (
<div key={t} className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full shrink-0" style={s.dotStyle} />
<span className="font-mono font-bold w-12 shrink-0" style={s.labelTextStyle}>{t === 'none' ? 'None' : t}</span>
<span className="text-secondary">{s.desc}</span>
</div>
)
})}
</div>
<div className="mb-3 rounded-btn border border-warning/30 bg-warning/10 px-2.5 py-1.5 text-[11px] font-medium text-warning">
</div>
{/* 检测说明 */}
<div className="text-secondary space-y-1.5">
<div className="font-medium text-foreground"></div>
<p> Key 后系统会在付费端点逐一试探数据能力:连单只日K都拿不到则判为None( Key);K但无复权因子则判为Free; Starter/Pro/Expert</p>
<p className="text-muted">None Free (K), Key,</p>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
)
}
interface CardProps {
icon: React.ComponentType<{ className?: string }>
title: string
badge?: string
right?: React.ReactNode
children: React.ReactNode
}
function Card({ icon: Icon, title, badge, right, children }: CardProps) {
return (
<section className="rounded-card border border-border bg-surface p-5">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<Icon className="h-4 w-4 text-secondary" />
<h2 className="text-sm font-medium text-foreground">{title}</h2>
{badge && (
<span className="px-1.5 py-0.5 text-[10px] font-mono rounded bg-elevated text-muted">
{badge}
</span>
)}
</div>
{right}
</div>
{children}
</section>
)
}
@@ -0,0 +1,311 @@
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Eye, EyeOff, ExternalLink, GripVertical, Settings, Bell } from 'lucide-react'
import { Link } from 'react-router-dom'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { usePreferences } from '@/lib/useSharedQueries'
interface NavEntry {
id: string
label: string
type: 'builtin' | 'analysis'
visible: boolean
}
const BUILTIN_PAGES: NavEntry[] = [
{ id: '/', label: '看板', type: 'builtin', visible: true },
{ id: '/watchlist', label: '自选', type: 'builtin', visible: true },
{ id: '/screener', label: '策略', type: 'builtin', visible: true },
{ id: '/backtest', label: '回测', type: 'builtin', visible: true },
{ id: '/limit-ladder', label: '连板梯队', type: 'builtin', visible: true },
{ id: '/concept-analysis', label: '概念分析', type: 'builtin', visible: true },
{ id: '/industry-analysis', label: '行业分析', type: 'builtin', visible: true },
{ id: '/stock-analysis', label: '个股分析', type: 'builtin', visible: true },
{ id: '/review', label: '复盘', type: 'builtin', visible: true },
{ id: '/financials', label: '财务分析', type: 'builtin', visible: true },
{ id: '/indices', label: '指数', type: 'builtin', visible: true },
{ id: '/trading', label: '交易', type: 'builtin', visible: true },
{ id: '/monitor', label: '监控中心', type: 'builtin', visible: true },
{ id: '/data', label: '数据', type: 'builtin', visible: true },
]
// ── Sortable row ──
function SortableItem({ entry, hidden, onToggleHidden, badgeEnabled, onToggleBadge }: {
entry: NavEntry
hidden: boolean
onToggleHidden: (id: string) => void
badgeEnabled?: boolean
onToggleBadge?: (id: string) => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: entry.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
zIndex: isDragging ? 10 : undefined,
}
return (
<div
ref={setNodeRef}
style={style}
className={`grid grid-cols-[2.5rem_1fr_4.5rem_3rem_3rem_3rem] items-center border-b border-border/70 px-4 py-3 last:border-b-0 ${
isDragging ? 'bg-elevated rounded-lg shadow-lg' : ''
} ${hidden ? 'opacity-50' : ''}`}
>
<div
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-muted hover:text-foreground transition-colors"
>
<GripVertical className="h-4 w-4" />
</div>
<div className="min-w-0 flex items-center gap-2">
<span className={`truncate text-sm font-medium ${!hidden ? 'text-foreground' : 'text-muted line-through'}`}>
{entry.label}
</span>
{hidden && (
<span className="rounded bg-elevated px-1.5 py-0.5 text-[10px] text-muted shrink-0"></span>
)}
<span className="truncate text-[11px] text-muted font-mono">{entry.id}</span>
</div>
<div>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] ${
entry.type === 'analysis' ? 'bg-accent/10 text-accent' : 'bg-elevated text-muted'
}`}>
{entry.type === 'builtin' ? '内置' : '扩展'}
</span>
</div>
<div className="flex justify-center">
<button
onClick={() => onToggleHidden(entry.id)}
className={`rounded p-1 transition-colors ${
hidden
? 'text-muted hover:text-accent hover:bg-accent/10'
: 'text-accent hover:bg-accent/10'
}`}
title={hidden ? '显示' : '隐藏'}
>
{hidden ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<div className="flex justify-center">
{entry.type === 'builtin' ? (
<Link
to={entry.id}
className="rounded p-1 text-muted hover:text-accent hover:bg-accent/10 transition-colors"
title="打开页面"
>
<ExternalLink className="h-3.5 w-3.5" />
</Link>
) : (
<Link
to={`/settings?tab=ext-pages`}
className="rounded p-1 text-muted hover:text-accent hover:bg-accent/10 transition-colors"
title="编辑扩展页面"
>
<Settings className="h-3.5 w-3.5" />
</Link>
)}
</div>
{/* 第 6 列: 徽标开关 (仅监控中心) */}
<div className="flex justify-center">
{onToggleBadge && (
<button
onClick={() => onToggleBadge(entry.id)}
className={`rounded p-1 transition-colors ${
badgeEnabled
? 'text-accent hover:bg-accent/10'
: 'text-muted hover:text-accent hover:bg-accent/10'
}`}
title={badgeEnabled ? '关闭数字提示' : '开启数字提示'}
>
<Bell className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
)
}
// ── Main panel ──
export function SettingsMenuSettingsPanel() {
const qc = useQueryClient()
const { data: prefs } = usePreferences()
const menus = useQuery({ queryKey: QK.analysisMenus, queryFn: api.analysisMenus })
const analysisEntries: NavEntry[] = (menus.data?.items ?? []).map(m => ({
id: m.id,
label: m.label,
type: 'analysis' as const,
visible: m.visible,
}))
const allEntries = useMemo(() => {
const saved = prefs?.nav_order ?? []
const entryMap = new Map<string, NavEntry>()
for (const e of BUILTIN_PAGES) entryMap.set(e.id, e)
for (const e of analysisEntries) entryMap.set(e.id, e)
if (saved.length === 0) return [...BUILTIN_PAGES, ...analysisEntries]
const ordered: NavEntry[] = []
const seen = new Set<string>()
for (const id of saved) {
const entry = entryMap.get(id)
if (entry) {
ordered.push(entry)
seen.add(id)
}
}
for (const e of [...BUILTIN_PAGES, ...analysisEntries]) {
if (!seen.has(e.id)) ordered.push(e)
}
return ordered
}, [prefs?.nav_order, analysisEntries])
const hiddenSet = useMemo(() => new Set(prefs?.nav_hidden ?? []), [prefs?.nav_hidden])
// Local order state for optimistic drag updates
const [localOrder, setLocalOrder] = useState<string[] | null>(null)
const orderedEntries = useMemo(() => {
const order = localOrder ?? prefs?.nav_order ?? []
if (!order.length) return allEntries
const byId = new Map(allEntries.map(e => [e.id, e]))
const result: NavEntry[] = []
const seen = new Set<string>()
for (const id of order) {
const e = byId.get(id)
if (e) { result.push(e); seen.add(id) }
}
for (const e of allEntries) {
if (!seen.has(e.id)) result.push(e)
}
return result
}, [localOrder, prefs?.nav_order, allEntries])
const saveNavOrder = useMutation({
mutationFn: (order: string[]) => api.saveNavOrder(order),
onSuccess: () => {
setLocalOrder(null)
qc.invalidateQueries({ queryKey: QK.preferences })
},
})
const saveNavHidden = useMutation({
mutationFn: (hidden: string[]) => api.saveNavHidden(hidden),
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
})
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const ids = orderedEntries.map(e => e.id)
const oldIdx = ids.indexOf(active.id as string)
const newIdx = ids.indexOf(over.id as string)
const reordered = arrayMove(ids, oldIdx, newIdx)
setLocalOrder(reordered)
saveNavOrder.mutate(reordered)
}
const toggleHidden = (id: string) => {
const next = new Set(hiddenSet)
if (next.has(id)) next.delete(id)
else next.add(id)
saveNavHidden.mutate([...next])
}
// 监控中心徽标开关 (localStorage)
const [badgeEnabled, setBadgeEnabled] = useState(() => {
try { return localStorage.getItem('monitor_badge_enabled') !== '0' } catch { return true }
})
const toggleBadge = (id: string) => {
if (id !== '/monitor') return
const next = !badgeEnabled
setBadgeEnabled(next)
try { localStorage.setItem('monitor_badge_enabled', next ? '1' : '0') } catch { /* ignore */ }
}
return (
<div className="max-w-5xl space-y-6">
<section className="rounded-2xl border border-border bg-surface p-6 bg-[radial-gradient(circle_at_top_right,rgba(59,130,246,0.12),transparent_38%)]">
<div className="text-[11px] uppercase tracking-[0.2em] text-accent/80"></div>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-foreground"></h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-secondary">
</p>
</section>
<section className="rounded-card border border-border bg-surface overflow-hidden">
<div className="grid grid-cols-[2.5rem_1fr_4.5rem_3rem_3rem_3rem] items-center border-b border-border px-4 py-2 text-[11px] text-muted">
<div />
<div></div>
<div></div>
<div className="text-center"></div>
<div className="text-center"></div>
<div className="text-center"></div>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={orderedEntries.map(e => e.id)}
strategy={verticalListSortingStrategy}
>
{orderedEntries.map((entry) => (
<SortableItem
key={entry.id}
entry={entry}
hidden={hiddenSet.has(entry.id)}
onToggleHidden={toggleHidden}
badgeEnabled={entry.id === '/monitor' ? badgeEnabled : undefined}
onToggleBadge={entry.id === '/monitor' ? toggleBadge : undefined}
/>
))}
</SortableContext>
</DndContext>
{menus.isLoading && (
<div className="px-5 py-10 text-center text-sm text-muted">...</div>
)}
</section>
</div>
)
}
@@ -0,0 +1,572 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query'
import {
Activity,
Wifi,
BarChart3,
Flame,
Zap,
Webhook,
ChevronDown,
} from 'lucide-react'
import {
usePreferences,
useQuoteStatus,
useQuoteInterval,
useCapabilities,
} from '@/lib/useSharedQueries'
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'
// 页面 → 显示名
const PAGE_LABELS: Record<string, string> = {
'overview-market': '看板',
watchlist: '自选页',
'limit-ladder': '连板梯队',
}
const SIDEBAR_INDEX_OPTIONS = [
{ symbol: '000001.SH', name: '上证指数' },
{ symbol: '399001.SZ', name: '深证成指' },
{ symbol: '399006.SZ', name: '创业板指' },
{ symbol: '000680.SH', name: '科创综指' },
]
// ===== 导出为 Panel 组件 (由 Settings.tsx 嵌入) =====
export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = {}) {
const qc = useQueryClient()
const { data: prefs } = usePreferences()
const { data: caps } = useCapabilities()
const { data: quoteStatus } = useQuoteStatus()
const { data: intervalData } = useQuoteInterval()
const updateInterval = useUpdateQuoteInterval()
const toggleQuote = useToggleRealtimeQuotes()
const tier = tierRank(caps?.label ?? '')
const isNoneTier = tier < 0
const isFreeTier = tier === 0
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
const refreshPages = prefs?.sse_refresh_pages ?? {}
const limitLadderMonitor = prefs?.limit_ladder_monitor_enabled ?? false
const hasDepth = !!caps?.capabilities?.['depth5.batch']
// 新建监控规则时是否默认勾选飞书推送 (全局默认值, 单条规则可独立修改)
const webhookDefault = prefs?.webhook_enabled_default ?? false
const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? SIDEBAR_INDEX_OPTIONS.map(i => i.symbol)
const indicesPinned = prefs?.indices_nav_pinned ?? true
const isRunning = quoteStatus?.running ?? false
const isTrading = quoteStatus?.is_trading_hours ?? false
const interval = intervalData?.interval ?? 10
const minInterval = intervalData?.min_interval ?? 5
const maxInterval = intervalData?.max_interval ?? 60
const [intervalDraft, setIntervalDraft] = useState(interval)
const feishuWebhookUrl = prefs?.feishu_webhook_url ?? ''
const feishuWebhookSecret = prefs?.feishu_webhook_secret ?? ''
const [feishuDraft, setFeishuDraft] = useState(feishuWebhookUrl)
const [feishuSecretDraft, setFeishuSecretDraft] = useState(feishuWebhookSecret)
const [feishuError, setFeishuError] = useState('')
// 飞书渠道配置区展开态 (推送通知卡片内)
const [channelOpen, setChannelOpen] = useState(false)
useEffect(() => {
setFeishuDraft(feishuWebhookUrl)
setFeishuSecretDraft(feishuWebhookSecret)
}, [feishuWebhookUrl, feishuWebhookSecret])
const watchlistSymbols = prefs?.realtime_watchlist_symbols ?? []
const watchlist = useQuery({
queryKey: QK.watchlist,
queryFn: () => api.watchlistList(),
enabled: isFreeTier && watchlistSymbols.length > 0,
})
const watchlistNameBySymbol = new Map(
(watchlist.data?.symbols ?? []).map(row => [row.symbol, row.name] as const),
)
const save = useCallback(async (cfg: Record<string, unknown>) => {
try {
await api.updateRealtimeMonitorConfig(cfg)
qc.invalidateQueries({ queryKey: QK.preferences })
} catch (e) {
// 忽略 — Toast 已在 request 层处理
}
}, [qc])
const handleToggleQuote = useCallback(async (enabled: boolean) => {
await toggleQuote.mutateAsync(enabled)
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.quoteStatus })
}, [toggleQuote, qc])
const toggleSidebarIndex = useCallback((symbol: string, visible: boolean) => {
const selected = new Set(sidebarIndexSymbols)
if (visible) selected.add(symbol)
else selected.delete(symbol)
const next = SIDEBAR_INDEX_OPTIONS
.map(item => item.symbol)
.filter(s => selected.has(s))
save({ sidebar_index_symbols: next })
}, [save, sidebarIndexSymbols])
const toggleIndicesPin = useCallback((pinned: boolean) => {
api.updateIndicesNavPinned(pinned).then(() => qc.invalidateQueries({ queryKey: QK.preferences }))
}, [qc])
const toggleLimitLadderMonitor = useCallback(async (enabled: boolean) => {
await api.updateLimitLadderMonitor(enabled)
qc.invalidateQueries({ queryKey: QK.preferences })
}, [qc])
const toggleWebhookDefault = useCallback(async (enabled: boolean) => {
await api.updateWebhookDefault(enabled)
qc.invalidateQueries({ queryKey: QK.preferences })
}, [qc])
const saveFeishuWebhook = useMutation({
mutationFn: ({ url, secret }: { url: string; secret: string }) => api.updateFeishuWebhook(url, secret),
onSuccess: () => {
setFeishuError('')
toast('飞书 Webhook 已保存', 'success')
qc.invalidateQueries({ queryKey: QK.preferences })
},
onError: (err: any) => setFeishuError(String(err?.message ?? '保存失败')),
})
const FEISHU_PREFIX = 'https://open.feishu.cn/open-apis/bot/v2/hook/'
const submitFeishu = useCallback(() => {
const url = feishuDraft.trim()
const secret = feishuSecretDraft.trim()
if (url && !url.startsWith(FEISHU_PREFIX)) {
setFeishuError('地址需以 ' + FEISHU_PREFIX + ' 开头')
return
}
saveFeishuWebhook.mutate({ url, secret })
}, [feishuDraft, feishuSecretDraft, saveFeishuWebhook])
const runFix = useMutation({
mutationFn: () => api.runLimitLadderFix(),
onSuccess: (data) => {
toast(data.msg, data.ok ? 'success' : 'error')
// 修正后连板梯队数据变了, 刷新相关缓存
qc.invalidateQueries({ queryKey: ['limit-ladder'] })
},
onError: () => toast('修正请求失败', 'error'),
})
useEffect(() => {
setIntervalDraft(interval)
}, [interval])
useEffect(() => {
if (intervalDraft === interval) return
const t = window.setTimeout(() => {
updateInterval.mutate(intervalDraft)
}, 2000)
return () => window.clearTimeout(t)
}, [intervalDraft, interval, updateInterval])
// highlight=depth-fix 时闪烁高亮连板梯队修正卡片
const [flash, setFlash] = useState(false)
const flashedRef = useRef(false)
useEffect(() => {
if (highlight === 'depth-fix' && !flashedRef.current) {
flashedRef.current = true
// 延迟一帧确保 DOM 已渲染, 再触发闪烁
requestAnimationFrame(() => {
setFlash(true)
const t = setTimeout(() => setFlash(false), 2000)
return () => clearTimeout(t)
})
}
}, [highlight])
if (isNoneTier) {
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=account"
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-[1fr_1fr] gap-6 max-w-5xl">
{/* ========== 左列 ========== */}
<div className="space-y-6">
{/* 行情状态 — 开关 + 间隔 */}
<Card icon={Activity} title="行情轮询">
<ToggleRow
label="实时行情"
desc={isRunning && isTrading ? '运行中' : isRunning ? '运行中 (非交易时段)' : '已关闭'}
checked={realtimeEnabled}
onChange={handleToggleQuote}
/>
<div className="mt-3 pt-3 border-t border-border">
<div className="flex items-center justify-between gap-4 py-1">
<div className="min-w-0">
<div className="text-sm text-foreground"></div>
<div className="text-[11px] text-muted">
{isFreeTier ? '每轮拉取自选股实时行情的时间间隔' : '每轮拉取全市场行情的时间间隔'}
</div>
</div>
<span className="text-[11px] font-mono text-foreground shrink-0 tabular-nums">
{intervalDraft < 1 ? intervalDraft.toFixed(1) : intervalDraft.toFixed(0)}s
</span>
</div>
<div className="flex items-center gap-3 mt-2">
<input
type="range"
min={minInterval}
max={maxInterval}
step={minInterval < 1 ? 0.1 : minInterval < 3 ? 0.5 : 1}
value={intervalDraft}
onChange={(e) => setIntervalDraft(parseFloat(e.target.value))}
className="flex-1 h-1 accent-accent cursor-pointer"
/>
<span className="text-[10px] text-muted shrink-0">
{intervalDraft !== interval ? '2秒后保存' : `${minInterval}s — ${maxInterval}s`}
</span>
</div>
</div>
</Card>
{isFreeTier && (
<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
</div>
{watchlistSymbols.length > 0 ? (
<div className="space-y-1.5">
{watchlistSymbols.map(symbol => {
const name = watchlistNameBySymbol.get(symbol)
return (
<div key={symbol} className="flex items-center justify-between rounded-btn bg-base/50 border border-border px-2 py-1.5">
<div className="min-w-0 flex items-baseline gap-1.5">
<span className="text-xs font-mono text-foreground">{symbol}</span>
{name && <span className="truncate text-[11px] text-secondary">{name}</span>}
</div>
<span className="text-[10px] text-muted shrink-0"></span>
</div>
)
})}
</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">
<span className="text-[10px] text-muted"> {watchlistSymbols.length}/5 </span>
<Link
to="/watchlist"
className="px-3 py-1 rounded-btn bg-elevated text-secondary text-xs font-medium hover:text-foreground transition-colors"
>
</Link>
</div>
</Card>
)}
{!isFreeTier && (
<Card icon={Wifi} title="页面实时刷新">
<p className="text-xs text-secondary mb-4">
SSE
</p>
<div className="space-y-2">
{Object.entries(PAGE_LABELS).map(([key, label]) => (
<ToggleRow
key={key}
label={label}
desc={`SSE 推送时刷新 ${label} 数据`}
checked={refreshPages[key] !== false}
onChange={(v) => save({ sse_refresh_pages: { ...refreshPages, [key]: v } })}
/>
))}
</div>
</Card>
)}
{!isFreeTier && (
<Card icon={BarChart3} title="左侧菜单指数">
<p className="text-xs text-secondary mb-4">
</p>
<div className="space-y-2">
{SIDEBAR_INDEX_OPTIONS.map(item => (
<ToggleRow
key={item.symbol}
label={item.name}
desc={item.symbol}
checked={sidebarIndexSymbols.includes(item.symbol)}
onChange={(v) => toggleSidebarIndex(item.symbol, v)}
/>
))}
</div>
<div className="mt-3 pt-3 border-t border-border">
<ToggleRow
label="固定显示"
desc={indicesPinned ? '指数卡片常驻显示(即使实时行情关闭)' : '跟随实时行情开关(仅实时开时显示)'}
checked={indicesPinned}
onChange={toggleIndicesPin}
/>
</div>
</Card>
)}
</div>
{/* ========== 右列 ========== */}
<div className="space-y-6">
{/* 连板梯队降级修正 (移至右列顶部) */}
<div
id="depth-fix"
className={`rounded-card transition-all duration-500 ${flash ? 'ring-2 ring-accent/60 ring-offset-2 ring-offset-base scale-[1.01]' : 'ring-0 ring-transparent'}`}
>
<Card
icon={Flame}
title="连板梯队降级修正"
badge={!hasDepth ? '需 Pro+' : undefined}
right={hasDepth ? (
<button
onClick={() => runFix.mutate()}
disabled={runFix.isPending}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px]
bg-accent/15 text-accent hover:bg-accent/25 transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
>
<Zap className="h-3 w-3" />
{runFix.isPending ? '修正中…' : '立即修正'}
</button>
) : undefined}
>
{hasDepth ? (
<>
<p className="text-xs text-secondary mb-4">
/,(=)
,
</p>
<ToggleRow
label="启用真假板修正"
desc="开启后盘中自动拉取五档盘口修正真假板"
checked={limitLadderMonitor}
onChange={toggleLimitLadderMonitor}
/>
<div className="mt-4 pt-3 border-t border-border">
<div className="text-[10px] uppercase tracking-widest text-muted mb-3">
</div>
<DepthConfigContent disabled={!limitLadderMonitor} />
</div>
</>
) : (
<DepthConfigContent disabled />
)}
</Card>
</div>
{/* 推送通知 — 监控告警的外部推送渠道 (全局配置)。
飞书已实现; 微信开发中, QMT/ptrade 待定。
每个渠道合并成一行: 勾选=新建规则默认推送, 点行展开地址配置。 */}
<Card icon={Webhook} title="推送通知">
<p className="text-xs text-secondary mb-3">
,<b className="text-foreground/80"></b>,
</p>
{/* 渠道列表 — 每行一个渠道, 勾选默认 + 点行展开地址配置 */}
<div className="space-y-2">
{/* 飞书 (可用): 勾选默认 + 展开地址配置 */}
<div className="rounded-btn border border-border/60 bg-base/40 overflow-hidden">
<div
onClick={() => setChannelOpen(o => !o)}
className="flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors hover:bg-base/60"
>
<input
type="checkbox"
checked={webhookDefault}
onChange={e => { e.stopPropagation(); toggleWebhookDefault(e.target.checked) }}
onClick={e => e.stopPropagation()}
title="作为新建规则的默认推送渠道"
className="h-3 w-3 accent-accent cursor-pointer"
/>
<span className="text-[11px] font-medium text-foreground"></span>
<span className="text-[9px] text-muted"></span>
{webhookDefault && (
<span className="rounded bg-accent/15 px-1 py-px text-[9px] text-accent"></span>
)}
<span className={`ml-auto text-[9px] ${feishuWebhookUrl ? 'text-emerald-500' : 'text-warning'}`}>
{feishuWebhookUrl ? '已配置' : '未配置'}
</span>
<ChevronDown className={`h-3 w-3 text-muted transition-transform ${channelOpen ? 'rotate-180' : ''}`} />
</div>
{/* 飞书地址配置 — 行内展开 */}
{channelOpen && (
<div className="border-t border-border/60 bg-base/30 p-3">
<label className="block space-y-1.5">
<span className="text-[11px] text-muted">Webhook </span>
<input
value={feishuDraft}
onChange={e => setFeishuDraft(e.target.value)}
placeholder={FEISHU_PREFIX + 'xxxxxxxx'}
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
/>
</label>
<label className="block mt-2 space-y-1.5">
<span className="text-[11px] text-muted"> ( · )</span>
<input
type="password"
value={feishuSecretDraft}
onChange={e => setFeishuSecretDraft(e.target.value)}
placeholder="机器人未启用签名校验则留空"
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
/>
</label>
{feishuError && (
<div className="mt-2 text-[11px] text-danger">{feishuError}</div>
)}
<div className="mt-2 flex items-center gap-2">
<button
onClick={submitFeishu}
disabled={saveFeishuWebhook.isPending || (feishuDraft.trim() === feishuWebhookUrl && feishuSecretDraft.trim() === feishuWebhookSecret)}
className="px-3 py-1.5 rounded-btn bg-accent text-base text-xs font-medium disabled:opacity-50 cursor-pointer hover:bg-accent/90 transition-colors"
>
{saveFeishuWebhook.isPending ? '保存中…' : '保存'}
</button>
{feishuWebhookUrl && (
<span className="text-[10px] text-emerald-500"> </span>
)}
</div>
<details className="mt-3 text-[10px] text-muted">
<summary className="cursor-pointer hover:text-secondary"> Webhook ?</summary>
<ol className="mt-1.5 space-y-1 pl-4 list-decimal leading-relaxed">
<li>, <b></b></li>
<li> <b></b></li>
<li>, Webhook </li>
<li><b></b>,</li>
<li></li>
</ol>
<p className="mt-1.5 pl-4 text-muted/70">
📖 :
<a href="https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot?lang=zh-CN" target="_blank" rel="noreferrer" className="text-accent hover:text-accent/80">
使
</a>
</p>
</details>
</div>
)}
</div>
{/* 占位渠道 — 不可点 */}
{[
{ name: '微信', hint: '公众号/企业微信', status: '开发中' },
{ name: 'QMT', hint: '量化交易终端', status: '待定' },
{ name: 'ptrade', hint: '量化交易终端', status: '待定' },
].map(ch => (
<div
key={ch.name}
className="flex items-center gap-2 rounded-btn border border-border/40 bg-base/20 px-2.5 py-2 opacity-60"
>
<input type="checkbox" disabled className="h-3 w-3 accent-accent" />
<span className="text-[11px] text-secondary">{ch.name}</span>
<span className="text-[9px] text-muted">{ch.hint}</span>
<span className="ml-auto rounded bg-muted/10 px-1 py-px text-[9px] text-muted">{ch.status}</span>
</div>
))}
</div>
</Card>
</div>
</div>
)
}
// ===== ToggleRow =====
function ToggleRow({
label,
desc,
checked,
onChange,
icon: Icon,
}: {
label: string
desc: string
checked: boolean
onChange: (v: boolean) => void
icon?: React.ComponentType<{ className?: string }>
}) {
return (
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0 flex items-start gap-2">
{Icon && <Icon className="h-3.5 w-3.5 text-secondary shrink-0 mt-0.5" />}
<div className="min-w-0">
<div className="text-sm text-foreground">{label}</div>
<div className="text-[11px] text-muted truncate">{desc}</div>
</div>
</div>
<button
onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 items-center rounded-full shrink-0 transition-colors duration-200 ${
checked ? 'bg-accent' : 'bg-elevated'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${
checked ? 'translate-x-[18px]' : 'translate-x-[3px]'
}`}
/>
</button>
</div>
)
}
// ===== 通用卡片 =====
interface CardProps {
icon: React.ComponentType<{ className?: string }>
title: string
badge?: string
right?: React.ReactNode
children: React.ReactNode
}
function Card({ icon: Icon, title, badge, right, children }: CardProps) {
return (
<section className="rounded-card border border-border bg-surface p-5">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<Icon className="h-4 w-4 text-secondary" />
<h2 className="text-sm font-medium text-foreground">{title}</h2>
{badge && (
<span className="px-1.5 py-0.5 text-[10px] font-mono rounded bg-elevated text-muted">
{badge}
</span>
)}
</div>
{right}
</div>
{children}
</section>
)
}
@@ -0,0 +1,270 @@
/**
* 系统设置面板 — 全局行为开关。
*
* 独立于实时监控, 放置影响整体应用行为的开关项。
*/
import { useState, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Settings2, Trash2, RefreshCw, Bell, Volume2, Info } from 'lucide-react'
import { usePreferences, useVersion } from '@/lib/useSharedQueries'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { PageHeader } from '@/components/PageHeader'
import { refreshAlertToastConfig } from '@/components/AlertToast'
import { SOUND_OPTIONS, previewSound } from '@/lib/notificationSound'
export function SettingsSystemPanel() {
const qc = useQueryClient()
const { data: prefs } = usePreferences()
const { data: versionData } = useVersion()
const [saving, setSaving] = useState(false)
const screenerAutoRun = prefs?.screener_auto_run ?? true
const [clearing, setClearing] = useState(false)
const [toastEnabled, setToastEnabled] = useState(() => {
try { return localStorage.getItem('alert_toast_enabled') !== '0' } catch { return true }
})
const [toastMax, setToastMax] = useState(() => {
try {
const v = parseInt(localStorage.getItem('alert_toast_max') || '', 10)
return v >= 1 && v <= 5 ? v : 3
} catch { return 3 }
})
const [soundEnabled, setSoundEnabled] = useState(() => {
try { return localStorage.getItem('alert_sound_enabled') !== '0' } catch { return true }
})
const [soundType, setSoundType] = useState(() => {
try { return localStorage.getItem('alert_sound') || 'ding' } catch { return 'ding' }
})
const save = useCallback(async (cfg: Record<string, unknown>) => {
setSaving(true)
try {
await api.updateRealtimeMonitorConfig(cfg)
qc.invalidateQueries({ queryKey: QK.preferences })
} finally {
setSaving(false)
}
}, [qc])
// 刷新前端缓存: 清除 react-query 缓存 + 强制重载 (绕过浏览器缓存)
// 不动 localStorage (用户列配置/策略池等偏好保留), 也不影响后端的本地股票数据
const handleClearCache = useCallback(() => {
setClearing(true)
qc.clear()
// 加时间戳参数强制浏览器重新下载所有静态资源
setTimeout(() => {
window.location.href = window.location.pathname + '?_t=' + Date.now()
}, 300)
}, [qc])
return (
<>
<PageHeader
title="系统设置"
subtitle="全局行为开关"
/>
<section className="rounded-card border border-border bg-surface p-5">
<div className="flex items-center gap-2 mb-4">
<Settings2 className="h-4 w-4 text-accent" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
<ToggleRow
label="进入策略页自动运行策略"
desc="开启后进入策略页自动跑所有策略获取命中数; 关闭则需手动点击"
checked={screenerAutoRun}
disabled={saving}
onChange={(v) => save({ screener_auto_run: v })}
/>
</section>
<section className="rounded-card border border-border bg-surface p-5 mt-6">
<div className="flex items-center gap-2 mb-4">
<Bell className="h-4 w-4 text-accent" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
<ToggleRow
label="开启监控通知弹窗"
desc="收到监控告警时在右下角弹出通知卡片"
checked={toastEnabled}
disabled={saving}
onChange={(v) => {
localStorage.setItem('alert_toast_enabled', v ? '1' : '0')
setToastEnabled(v)
refreshAlertToastConfig()
}}
/>
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm text-foreground"></div>
<div className="text-[11px] text-muted truncate"> (1-5), </div>
</div>
<select
value={toastMax}
disabled={!toastEnabled}
onChange={(e) => {
const v = Number(e.target.value)
localStorage.setItem('alert_toast_max', String(v))
setToastMax(v)
refreshAlertToastConfig()
}}
className="w-16 h-8 px-1.5 rounded-btn border border-border bg-base text-xs text-foreground disabled:opacity-50"
>
{[1, 2, 3, 4, 5].map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
<ToggleRow
label="通知声效"
desc="收到监控告警时播放提示音"
checked={soundEnabled}
disabled={!toastEnabled}
onChange={(v) => {
localStorage.setItem('alert_sound_enabled', v ? '1' : '0')
setSoundEnabled(v)
if (v) previewSound(soundType)
}}
/>
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0 flex items-center gap-1.5">
<Volume2 className="h-3.5 w-3.5 text-muted" />
<div>
<div className="text-sm text-foreground"></div>
<div className="text-[11px] text-muted truncate"></div>
</div>
</div>
<div className="flex items-center gap-1.5">
<select
value={soundType}
disabled={!toastEnabled || !soundEnabled}
onChange={(e) => {
const v = e.target.value
localStorage.setItem('alert_sound', v)
setSoundType(v)
if (v !== 'none') previewSound(v)
}}
className="w-20 h-8 px-1.5 rounded-btn border border-border bg-base text-xs text-foreground disabled:opacity-50"
>
{SOUND_OPTIONS.map(s => <option key={s.key} value={s.key}>{s.label}</option>)}
</select>
<button
onClick={() => previewSound(soundType)}
disabled={!toastEnabled || !soundEnabled || soundType === 'none'}
className="px-2 h-8 rounded-btn border border-border bg-base text-xs text-secondary hover:text-foreground hover:border-accent/30 disabled:opacity-50 transition-colors cursor-pointer"
>
</button>
</div>
</div>
</section>
<section className="rounded-card border border-border bg-surface p-5 mt-6">
<div className="flex items-center gap-2 mb-4">
<Trash2 className="h-4 w-4 text-accent" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm text-foreground"></div>
<div className="text-[11px] text-muted truncate">
()
</div>
</div>
<button
onClick={handleClearCache}
disabled={clearing}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn text-xs
bg-elevated text-secondary hover:text-foreground transition-colors
disabled:opacity-50 shrink-0"
>
{clearing ? (
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{clearing ? '清理中…' : '清理并刷新'}
</button>
</div>
</section>
<section className="rounded-card border border-border bg-surface p-5 mt-6">
<div className="flex items-center gap-2 mb-4">
<Info className="h-4 w-4 text-accent" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm text-foreground"></div>
<div className="text-[11px] text-muted truncate"></div>
</div>
<span className="font-mono text-xs text-secondary shrink-0">
{versionData?.version ?? '—'}
</span>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm text-foreground"></div>
<div className="text-[11px] text-muted truncate"> GitHub Releases </div>
</div>
<a
href="https://github.com/shy3130/tickflow-stock-panel/releases/latest"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn text-xs
bg-elevated text-secondary hover:text-foreground transition-colors shrink-0"
>
<RefreshCw className="h-3.5 w-3.5" />
</a>
</div>
</section>
</>
)
}
// ===== ToggleRow =====
function ToggleRow({
label,
desc,
checked,
disabled,
onChange,
}: {
label: string
desc: string
checked: boolean
disabled?: boolean
onChange: (v: boolean) => void
}) {
return (
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm text-foreground">{label}</div>
<div className="text-[11px] text-muted truncate">{desc}</div>
</div>
<button
onClick={() => onChange(!checked)}
disabled={disabled}
className={`relative inline-flex h-5 w-9 items-center rounded-full shrink-0 transition-colors duration-200 disabled:opacity-50 ${
checked ? 'bg-accent' : 'bg-elevated'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${
checked ? 'translate-x-[18px]' : 'translate-x-[3px]'
}`}
/>
</button>
</div>
)
}