复制 local 代码到 serve
This commit is contained in:
@@ -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">
|
||||
这会清空已保存的 provider、API Key、API 地址、模型和 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>
|
||||
)
|
||||
}
|
||||
@@ -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 获取历史日K(当日数据需盘后1-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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user