refactor(ai): 配置页单列卡片重构 + 移除无用的 Token 预算

- AI 设置页改为单列分卡片布局(Card/Field/Toggle 辅助组件),自定义配置卡
  头部标注「Chat Completions」
- 移除从未实际生效的 ai_daily_token_budget(后端 config/api/settings +
  前端 Settings 接口与保存参数 + UI)
- 测试结果不再展示 token 消耗,仅显示「连通成功 · 模型: xxx」
This commit is contained in:
shy3130
2026-06-25 15:53:15 +08:00
parent 76ec644f35
commit 37ebe19f1e
5 changed files with 160 additions and 95 deletions
-4
View File
@@ -57,7 +57,6 @@ def get_settings() -> dict:
"ai_api_key_masked": secrets_store.mask(secrets_store.get_ai_key()),
"has_ai_key": bool(secrets_store.get_ai_key()),
"ai_model": secrets_store.get_ai_config("ai_model", settings.ai_model),
"ai_daily_token_budget": int(secrets_store.get_ai_config("ai_daily_token_budget", str(settings.ai_daily_token_budget)) or settings.ai_daily_token_budget),
"ai_user_agent": secrets_store.get_ai_config("ai_user_agent", settings.ai_user_agent),
}
@@ -212,7 +211,6 @@ class AiSettingsIn(BaseModel):
base_url: str = ""
api_key: str | None = None
model: str = ""
daily_token_budget: int = 500_000
user_agent: str = ""
@@ -238,8 +236,6 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
if req.model:
updates["ai_model"] = req.model
settings.ai_model = req.model
updates["ai_daily_token_budget"] = req.daily_token_budget
settings.ai_daily_token_budget = req.daily_token_budget
# user_agent 允许清空(回到默认浏览器 UA),故无条件持久化
updates["ai_user_agent"] = req.user_agent
settings.ai_user_agent = req.user_agent
-1
View File
@@ -70,7 +70,6 @@ class Settings(BaseSettings):
ai_base_url: str = "https://api.alysc.top"
ai_api_key: str = ""
ai_model: str = "gpt-5.5"
ai_daily_token_budget: int = 5_000_000
# 默认浏览器风格 UA,绕过 Cloudflare 等 CDN/WAF 的 Bot 拦截(Issue #8)。
# 用户可在 AI 设置页按需修改。
ai_user_agent: str = (
+1 -1
View File
@@ -2470,7 +2470,7 @@ all = [
[[package]]
name = "tickflow-stock-panel-backend"
version = "0.1.35"
version = "0.1.44"
source = { editable = "." }
dependencies = [
{ name = "apscheduler" },
+1 -2
View File
@@ -565,7 +565,6 @@ export interface SettingsState {
ai_api_key_masked: string
has_ai_key: boolean
ai_model: string
ai_daily_token_budget: number
ai_user_agent: string
}
@@ -637,7 +636,7 @@ export const api = {
),
/** 保存 AI 配置 */
saveAiSettings: (ai: { provider?: string; base_url?: string; api_key?: string; model?: string; daily_token_budget?: number; user_agent?: string }) =>
saveAiSettings: (ai: { provider?: string; base_url?: string; api_key?: string; model?: string; user_agent?: string }) =>
request<{ ok: boolean }>('/api/settings/ai', {
method: 'POST',
body: JSON.stringify(ai),
+158 -87
View File
@@ -1,13 +1,22 @@
import { useState, useEffect } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Save, Loader2, Check, Wifi, WifiOff, Eye, EyeOff, Shield, Shuffle } from 'lucide-react'
import {
Save, Loader2, Check, Wifi, WifiOff, Eye, EyeOff, Shield,
Shuffle, Plug, Zap, Settings2, ExternalLink,
} from 'lucide-react'
import { useSettings } from '@/lib/useSharedQueries'
import { api } 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 PRESETS: { label: string; url: string; model: string; website: string; websiteLabel: string; description: string; partner?: boolean; promo?: string }[] = [
{ label: 'DeepSeek', url: 'https://api.deepseek.com/v1', model: 'deepseek-chat', website: 'https://www.deepseek.com/', websiteLabel: 'deepseek.com', description: 'DeepSeek 官方 OpenAI 兼容接口。' },
{ label: '通义千问', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', website: 'https://tongyi.aliyun.com/', websiteLabel: 'tongyi.aliyun.com', description: '阿里云 DashScope 兼容模式接口。' },
{ 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: '炸鸡中转站', 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倍率' },
]
@@ -20,7 +29,6 @@ export function SettingsAIPanel() {
const [baseUrl, setBaseUrl] = useState('')
const [apiKey, setApiKey] = useState('')
const [model, setModel] = useState('')
const [tokenBudget, setTokenBudget] = useState(5_000_000)
// 自定义 User-Agent 开关:关闭 → 后端用内置默认浏览器 UA(开箱绕过 CDN 拦截);
// 开启 → 用下方文本框的 UA,留空时随机生成。
const [customUa, setCustomUa] = useState(false)
@@ -49,7 +57,6 @@ export function SettingsAIPanel() {
setProvider(s.ai_provider ?? 'openai_compat')
setBaseUrl(s.ai_base_url ?? '')
setModel(s.ai_model ?? '')
setTokenBudget(s.ai_daily_token_budget ?? 500_000)
// 有已保存的自定义 UA → 开关默认开启;否则关闭(用后端内置默认)
const ua = s.ai_user_agent ?? ''
setCustomUa(!!ua)
@@ -58,7 +65,7 @@ export function SettingsAIPanel() {
const save = useMutation({
mutationFn: () => api.saveAiSettings({
provider, base_url: baseUrl, api_key: apiKey || undefined, model, daily_token_budget: tokenBudget,
provider, base_url: baseUrl, api_key: apiKey || undefined, model,
// 关闭开关 → 提交空串,后端回退内置默认 UA
user_agent: customUa ? userAgent : '',
}),
@@ -72,9 +79,9 @@ export function SettingsAIPanel() {
setTesting(true); setTestResult(null)
try {
// 先保存当前配置(不保存 Key 仅用于测试时临时存)
if (apiKey) await api.saveAiSettings({ provider, base_url: baseUrl, api_key: apiKey, model, daily_token_budget: tokenBudget, user_agent: customUa ? userAgent : '' })
if (apiKey) await api.saveAiSettings({ provider, base_url: baseUrl, api_key: apiKey, model, user_agent: customUa ? userAgent : '' })
const r = await api.strategyAiTest()
setTestResult({ ok: r.ok, msg: r.ok ? `连通成功 · 模型: ${r.model}${r.usage ? ` · 消耗 ${r.usage.prompt + r.usage.completion} tokens` : ''}` : (r.error ?? '未知错误') })
setTestResult({ ok: r.ok, msg: r.ok ? `连通成功 · 模型: ${r.model}` : (r.error ?? '未知错误') })
} catch (e: any) {
setTestResult({ ok: false, msg: String(e?.message ?? '测试失败') })
} finally { setTesting(false) }
@@ -86,44 +93,49 @@ export function SettingsAIPanel() {
const configured = s?.has_ai_key
const selectedPreset = PRESETS.find(p => p.url === baseUrl)
const canSave = !!baseUrl && !!model
return (
<div className="max-w-2xl space-y-5">
{/* ===== 状态横幅 ===== */}
<div className={`rounded-2xl border px-5 py-4 flex items-center gap-4 ${configured ? 'border-emerald-400/20 bg-emerald-400/[0.04]' : 'border-amber-400/20 bg-amber-400/[0.04]'}`}>
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${configured ? 'bg-emerald-400/10 text-emerald-400' : 'bg-amber-400/10 text-amber-400'}`}>
{configured ? <Wifi className="h-5 w-5" /> : <WifiOff className="h-5 w-5" />}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-foreground">{configured ? 'AI 已连接' : 'AI 未配置'}</div>
<div className="text-xs text-muted mt-0.5">
{configured ? `${s?.ai_model} · ${s?.ai_api_key_masked}` : '配置 API Key 后即可使用 AI 策略定制'}
</div>
</div>
{configured && (
<div className="space-y-5 max-w-2xl">
{/* ===== ① 连接状态 ===== */}
<Card icon={Plug} title="连接状态" right={
configured && (
<button onClick={handleTest} disabled={testing}
className="h-8 px-3 rounded-lg border border-border/50 text-xs text-secondary hover:text-foreground hover:border-accent/30 transition-all flex items-center gap-1.5 shrink-0">
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>
{/* 测试结果 */}
{testResult && (
<div className={`rounded-xl border px-4 py-3 text-xs flex items-center gap-2.5 ${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 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 ? `${s?.ai_model} · ${s?.ai_api_key_masked}` : '配置 API Key 后即可使用 AI 策略定制'}
</div>
</div>
</div>
)}
{/* ===== 快速预设 ===== */}
<div className="space-y-2">
<div className="text-[10px] text-muted/50 uppercase tracking-wider"></div>
<div className="flex flex-wrap items-start gap-x-4 gap-y-3">
{/* 测试结果 */}
{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 ${baseUrl === p.url ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border bg-surface text-secondary hover:border-accent/30'}`}>
className={`rounded-lg border px-3 py-2 text-left transition-all ${baseUrl === p.url ? '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.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>}
@@ -132,89 +144,80 @@ export function SettingsAIPanel() {
))}
</div>
{selectedPreset && (
<div className="rounded-lg border border-border/30 bg-surface/30 px-3 py-2 text-[10px] leading-relaxed">
<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 text-muted hover:text-accent transition-colors">
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>
)}
</div>
</Card>
{/* ===== 自定义配置卡片 ===== */}
<div className="rounded-2xl border border-border/30 bg-surface/30 overflow-hidden">
<div className="px-5 py-3 border-b border-border/20">
<span className="text-xs font-medium text-foreground/70"></span>
</div>
<div className="px-5 py-4 space-y-4">
{/* ===== 自定义配置 ===== */}
<Card
icon={Settings2}
title="自定义配置"
right={
<span className="inline-flex items-center gap-1.5 text-[10px] text-muted/60" title="使用 OpenAI 兼容的 Chat Completions 接口 (/v1/chat/completions)">
<span className="rounded-full border border-border/40 bg-base/50 px-1.5 py-px font-mono">Chat Completions</span>
</span>
}
>
<div className="space-y-4">
{/* API 地址 + 模型 同行 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] text-muted/50 uppercase tracking-wider">API </label>
<Field label="API 地址">
<input type="text" value={baseUrl} onChange={e => setBaseUrl(e.target.value)}
placeholder="https://code.alysc.top"
className="w-full h-8 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" />
</div>
<div className="space-y-1.5">
<label className="text-[10px] text-muted/50 uppercase tracking-wider"></label>
className={INPUT_CLS} />
</Field>
<Field label="模型">
<input type="text" value={model} onChange={e => setModel(e.target.value)}
placeholder="gpt-5.5"
className="w-full h-8 px-2.5 rounded-lg bg-base border-0 ring-1 ring-border/30 text-xs text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow" />
</div>
className={INPUT_CLS} />
</Field>
</div>
{/* API Key */}
<div className="space-y-1.5">
<label className="text-[10px] text-muted/50 uppercase tracking-wider">API Key</label>
<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="w-full h-8 px-2.5 pr-8 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" />
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">
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-8 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">
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>
</div>
</Field>
{/* Token 预算 */}
<div className="space-y-1.5">
<label className="text-[10px] text-muted/50 uppercase tracking-wider"> Token </label>
<div className="flex items-center gap-3">
<input type="number" value={tokenBudget} onChange={e => setTokenBudget(Math.max(10000, Number(e.target.value) || 0))}
min={10000} step={100000}
className="w-44 h-8 px-2.5 rounded-lg bg-base border-0 ring-1 ring-border/30 text-xs font-mono text-foreground focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow" />
<span className="text-[10px] text-muted"> AI </span>
</div>
</div>
{/* 分隔线 */}
<div className="border-t border-border/20" />
{/* 请求头 User-Agent */}
{/* 自定义 User-Agent */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[10px] text-muted/50 uppercase tracking-wider"> User-Agent</label>
<button
type="button"
onClick={() => setCustomUa(v => !v)}
className={`relative inline-flex h-5 w-9 items-center rounded-full shrink-0 transition-colors duration-200 ${customUa ? 'bg-accent' : 'bg-elevated'}`}
aria-pressed={customUa}
>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${customUa ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
<Field label="自定义请求头 User-Agent" inline>
<Toggle checked={customUa} onChange={() => setCustomUa(v => !v)} />
</Field>
</div>
<div className="text-[10px] text-muted/70 leading-relaxed">
<div className="text-[11px] text-muted/70 leading-relaxed">
{customUa
? '当前使用下方自定义 UA 调用 AI API。'
: '默认已使用内置浏览器标识,可绕过 Cloudflare 等 CDN/WAF 拦截。仅在默认标识被拦截时才需开启自定义。'}
@@ -222,29 +225,29 @@ export function SettingsAIPanel() {
{customUa && (
<div className="flex gap-2">
<input type="text" value={userAgent} onChange={e => setUserAgent(e.target.value)}
placeholder="留空点击「随机生成」或直接粘贴浏览器 UA"
className="flex-1 h-8 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" />
placeholder="留空点击「随机」或直接粘贴浏览器 UA"
className={`${INPUT_CLS} flex-1`} />
<button type="button" onClick={genRandomUa}
title="随机生成一条浏览器 UA"
className="h-8 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">
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>
</div>
</Card>
{/* ===== 安全提示 ===== */}
<div className="rounded-2xl border border-amber-400/20 bg-amber-400/[0.04] px-5 py-3.5 flex items-start gap-3">
<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-[10px] text-amber-400/70 leading-relaxed">
<div className="text-[11px] text-amber-400/70 leading-relaxed">
API Key
</div>
</div>
{/* ===== 保存 ===== */}
<button onClick={() => save.mutate()} disabled={save.isPending || !baseUrl || !model}
<button onClick={() => save.mutate()} disabled={save.isPending || !canSave}
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 ? '已保存' : '保存配置'}
@@ -252,3 +255,71 @@ export function SettingsAIPanel() {
</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>
)
}