Files
LokiStack/frontend/src/pages/aiops/index.tsx
T
opsx 2efbed882a
CI / Backend (go vet + test) (push) Waiting to run
CI / Frontend (lint + build + test) (push) Waiting to run
fix(aiops): 保存配置后分析页状态即时同步;分析页布局重排
- enabled 状态在 saveConfig 成功后立即 setEnabled(此前只更新 config,
  导致'AI 分析未启用'横幅要等下次请求才消失)
- 布局:工具栏改 4 列网格(数据源/时间/应用/问题),执行按钮与提示移到
  分隔线下右对齐;未启用横幅加 ✦ 图标 + 「去配置」直达按钮;空态/加载态
  加图标居中;消耗行改统计卡风格(数值 tabular 高亮)
2026-11-01 00:26:22 +08:00

819 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { LLM_PROVIDERS, matchProvider } from './llmProviders'
import { Loader2, Play, Save, Sparkles, PlugZap, History, ShieldCheck } from 'lucide-react'
import { toast } from 'sonner'
import { ProGate } from '@/components/ProGate'
import { useLicense } from '@/hooks/useLicense'
import { PageContainer } from '@/components/PageContainer'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { aiApi, type AIAnalyzeResponse, type AIConfig, type AIConfigResponse, type AIRun } from '@/api/ai'
import { logsApi, type LogSource } from '@/api/logs'
const RANGE_PRESETS = [
{ key: '15m', minutes: 15 },
{ key: '1h', minutes: 60 },
{ key: '6h', minutes: 360 },
{ key: '24h', minutes: 1440 },
] as const
function fmtBytes(bytes: number): string {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = bytes
let index = 0
while (value >= 1024 && index < units.length - 1) {
value /= 1024
index += 1
}
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`
}
function fmtNum(value: number): string {
if (value >= 1e8) return `${(value / 1e8).toFixed(2)} 亿`
if (value >= 1e4) return `${(value / 1e4).toFixed(1)} 万`
return String(value)
}
export default function AiopsPage() {
const { t } = useTranslation('aiops')
const { pro } = useLicense()
const [tab, setTab] = useState('analyze')
// ── 分析 ──
const [sources, setSources] = useState<LogSource[]>([])
const [sourceId, setSourceId] = useState(0)
const [rangeKey, setRangeKey] = useState<(typeof RANGE_PRESETS)[number]['key']>('15m')
const [apps, setApps] = useState<string[]>([])
const [app, setApp] = useState('')
const [question, setQuestion] = useState('')
const [running, setRunning] = useState(false)
const [outcome, setOutcome] = useState<AIAnalyzeResponse | null>(null)
const [promptOpen, setPromptOpen] = useState(false)
const [enabled, setEnabled] = useState(false)
// app 候选:长窗口(>1h)必须指定应用收窄聚合范围
useEffect(() => {
setApp('')
setApps([])
if (!sourceId) return
let active = true
logsApi.labelValues(sourceId, 'app')
.then(list => { if (active) setApps(Array.isArray(list) ? list : []) })
.catch(() => { if (active) setApps([]) })
return () => { active = false }
}, [sourceId])
const rangeMinutes = RANGE_PRESETS.find(r => r.key === rangeKey)?.minutes ?? 15
const needApp = rangeMinutes > 60
// ── 配置 ──
const [config, setConfig] = useState<AIConfig | null>(null)
const [apiKeyInput, setApiKeyInput] = useState('')
const [redactText, setRedactText] = useState('')
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [usedToday, setUsedToday] = useState(0)
// ── 历史 ──
const [runs, setRuns] = useState<AIRun[]>([])
const [runTotal, setRunTotal] = useState(0)
const [runPage, setRunPage] = useState(1)
const [runsLoading, setRunsLoading] = useState(false)
const [detail, setDetail] = useState<AIRun | null>(null)
const PAGE_SIZE = 20
useEffect(() => {
logsApi
.sources()
.then((res: { list?: LogSource[] }) => {
const list = res?.list ?? []
setSources(list)
if (list.length > 0) setSourceId((prev: number) => prev || list[0].id)
})
.catch(() => toast.error('数据源列表加载失败'))
}, [])
const loadConfig = useCallback(() => {
aiApi
.getConfig()
.then((res: AIConfigResponse) => {
const cfg = res.config
// 首次配置默认 DeepSeek + 最新 flash:用户只需填 API Key
if (cfg && !cfg.baseUrl && !cfg.model) {
const deepseek = LLM_PROVIDERS.find(p => p.id === 'deepseek')!
cfg.baseUrl = deepseek.endpoint
cfg.model = 'deepseek-flash'
}
setConfig(cfg)
setEnabled(Boolean(res.config?.enabled))
setRedactText((res.config?.redactExtra ?? []).join('\n'))
setUsedToday(res.tokenUsedToday ?? 0)
})
.catch(() => {
/* 非管理员访问配置会 403,属预期 */
})
}, [])
useEffect(() => {
loadConfig()
}, [loadConfig])
const loadRuns = useCallback(
(page: number) => {
setRunsLoading(true)
aiApi
.runs(page, PAGE_SIZE)
.then(res => {
setRuns(res.list ?? [])
setRunTotal(res.total ?? 0)
})
.catch(err => toast.error(err instanceof Error ? err.message : '历史加载失败'))
.finally(() => setRunsLoading(false))
},
[],
)
useEffect(() => {
if (tab === 'history') loadRuns(runPage)
}, [tab, runPage, loadRuns])
const runAnalysis = async () => {
if (!sourceId) {
toast.error('请先选择数据源')
return
}
if (needApp && !app) {
toast.error('超过 1 小时的分析必须指定应用:全命名空间长窗口聚合过慢')
return
}
const end = Date.now()
const start = end - rangeMinutes * 60 * 1000
setRunning(true)
setOutcome(null)
try {
const res = await aiApi.analyze({
sourceId,
start: start * 1e6,
end: end * 1e6,
question: question.trim() || undefined,
app: app || undefined,
})
setOutcome(res)
loadConfig()
} catch (err) {
toast.error(err instanceof Error ? err.message : '分析失败')
} finally {
setRunning(false)
}
}
const saveConfig = async () => {
if (!config) return
setSaving(true)
try {
const res = await aiApi.saveConfig({
...config,
apiKey: apiKeyInput.trim(),
redactExtra: redactText
.split('\n')
.map(line => line.trim())
.filter(Boolean),
})
setConfig(res.config)
setEnabled(Boolean(res.config?.enabled))
setApiKeyInput('')
toast.success('配置已保存')
} catch (err) {
toast.error(err instanceof Error ? err.message : '保存失败')
} finally {
setSaving(false)
}
}
const testConnection = async () => {
setTesting(true)
try {
const res = await aiApi.testConfig({
baseUrl: config?.baseUrl,
model: config?.model,
apiKey: apiKeyInput.trim() || undefined,
})
toast.success(t('config.testOk', { model: res.model, latency: (res.latencyMs / 1000).toFixed(1), tokens: res.tokensUsed }))
} catch (err) {
toast.error(err instanceof Error ? err.message : '连接失败')
} finally {
setTesting(false)
}
}
const severityColor = (severity: string) => {
if (severity === 'high') return 'bg-destructive/15 text-destructive'
if (severity === 'medium') return 'bg-warning/15 text-warning'
return 'bg-muted text-muted-foreground'
}
const totalPages = Math.max(1, Math.ceil(runTotal / PAGE_SIZE))
if (!pro) return <ProGate featureKey="ai_ops" />
return (
<PageContainer title={t('title')} description={t('subtitle')}>
<Tabs value={tab} onValueChange={setTab} className="space-y-4">
<TabsList>
<TabsTrigger value="analyze">
<Sparkles className="mr-1.5 h-4 w-4" />
{t('tabs.analyze')}
</TabsTrigger>
<TabsTrigger value="config">
<PlugZap className="mr-1.5 h-4 w-4" />
{t('tabs.config')}
</TabsTrigger>
<TabsTrigger value="history">
<History className="mr-1.5 h-4 w-4" />
{t('tabs.history')}
</TabsTrigger>
</TabsList>
{/* ─────────── 分析 ─────────── */}
<TabsContent value="analyze" className="space-y-4">
{!enabled && (
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-warning/40 bg-warning/10 px-4 py-3 text-sm">
<Sparkles className="size-4 shrink-0 text-warning" />
<span className="flex-1">{t('analyze.notEnabled')}</span>
<Button size="sm" variant="outline" onClick={() => setTab('config')}>{t('analyze.goConfig')}</Button>
</div>
)}
<Card>
<CardContent className="pt-4">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="min-w-40 space-y-1">
<Label className="text-xs">{t('analyze.source')}</Label>
<select
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={sourceId}
onChange={e => setSourceId(Number(e.target.value))}
>
{sources.map(s => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label className="text-xs">{t('analyze.range')}</Label>
<div className="flex gap-1">
{RANGE_PRESETS.map(preset => (
<Button
key={preset.key}
type="button"
size="sm"
variant={rangeKey === preset.key ? 'default' : 'outline'}
className="h-9 px-3 text-xs"
onClick={() => setRangeKey(preset.key)}
>
{preset.key}
</Button>
))}
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">
{t('analyze.appScope')}
{needApp && <span className="ml-1 text-destructive">*</span>}
</Label>
<select
aria-label={t('analyze.appScope')}
className="h-9 min-w-40 rounded-md border bg-background px-3 text-sm"
value={app}
onChange={e => setApp(e.target.value)}
>
<option value="">{needApp ? t('analyze.appRequired') : t('analyze.allApps')}</option>
{apps.map(a => <option key={a} value={a}>{a}</option>)}
</select>
{needApp && <p className="text-[11px] text-warning">{t('analyze.appScopeHint')}</p>}
</div>
<div className="space-y-1 xl:col-span-2">
<Label className="text-xs">{t('analyze.question')}</Label>
<Input
value={question}
placeholder={t('analyze.questionPlaceholder')}
onChange={e => setQuestion(e.target.value)}
className="h-9"
/>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t pt-3">
<p className="text-xs text-muted-foreground">{t('analyze.runHint')}</p>
<Button onClick={runAnalysis} disabled={running || !enabled || (needApp && !app)} className="h-9">
{running ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : <Play className="mr-1.5 h-4 w-4" />}
{running ? t('analyze.running') : t('analyze.run')}
</Button>
</div>
</CardContent>
</Card>
{!outcome && !running && (
<Card>
<CardContent className="flex flex-col items-center justify-center gap-2 py-14 text-center">
<Sparkles className="size-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">{t('analyze.empty')}</p>
</CardContent>
</Card>
)}
{running && (
<Card>
<CardContent className="flex flex-col items-center justify-center gap-2 py-14 text-center">
<Loader2 className="size-7 animate-spin text-primary" />
<p className="text-sm font-medium">{t('analyze.running')}</p>
<p className="text-xs text-muted-foreground">{t('analyze.runHint')}</p>
</CardContent>
</Card>
)}
{outcome && (
<>
{/* 本次消耗:让成本和延迟是可见的 */}
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 rounded-lg border bg-muted/30 px-4 py-2.5 text-xs">
<span className="font-medium text-foreground">{t('analyze.lastRun')}</span>
<span className="text-muted-foreground">{t('analyze.promptTokens')} <span className="tabular-nums text-foreground">{fmtNum(outcome.run.promptTokens)}</span></span>
<span className="text-muted-foreground">{t('analyze.completionTokens')} <span className="tabular-nums text-foreground">{fmtNum(outcome.run.completionTokens)}</span></span>
<span className="text-muted-foreground">{t('analyze.duration')} <span className="tabular-nums text-foreground">{(outcome.run.durationMs / 1000).toFixed(1)}s</span></span>
<Button variant="link" size="sm" className="ml-auto h-auto p-0 text-xs" onClick={() => setPromptOpen(true)}>
{t('analyze.viewPrompt')}
</Button>
</div>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">{t('result.summary')}</CardTitle>
</CardHeader>
<CardContent className="whitespace-pre-wrap text-sm leading-relaxed">
{outcome.result.summary || t('commonEmpty')}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">{t('result.findings')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{(outcome.result.findings ?? []).length === 0 && (
<p className="text-sm text-muted-foreground">{t('result.noFindings')}</p>
)}
{(outcome.result.findings ?? []).map((finding, index) => (
<div key={index} className="rounded-md border p-3">
<div className="flex items-center gap-2">
<Badge className={`text-[10px] ${severityColor(finding.severity)}`}>
{finding.severity === 'high'
? t('result.severityHigh')
: finding.severity === 'medium'
? t('result.severityMedium')
: t('result.severityLow')}
</Badge>
<span className="text-sm font-medium">{finding.title}</span>
</div>
<p className="mt-1.5 text-xs text-muted-foreground">{finding.evidence}</p>
</div>
))}
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">{t('result.actions')}</CardTitle>
</CardHeader>
<CardContent>
{(outcome.result.actions ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">{t('result.noActions')}</p>
) : (
<ol className="list-decimal space-y-1 pl-5 text-sm">
{outcome.result.actions.map((action, index) => (
<li key={index}>{action}</li>
))}
</ol>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-1.5 text-base">
<ShieldCheck className="h-4 w-4" />
{t('result.unknowns')}
</CardTitle>
</CardHeader>
<CardContent>
{(outcome.result.unknowns ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">{t('result.noUnknowns')}</p>
) : (
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
{outcome.result.unknowns.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
{/* 信号明细:让用户知道结论是基于什么算出来的 */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">{t('signals.title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
<span>
{t('signals.volume')}:<span className="text-foreground">{fmtBytes(outcome.signals.totalBytes)}</span>
</span>
<span>
{t('signals.entries')}:
<span className="text-foreground">
{outcome.signals.entriesExact ? fmtNum(outcome.signals.totalEntries) : t('signals.estimated')}
</span>
</span>
{outcome.signals.baseline && (
<span>
{t('signals.baseline')}:
<span
className={
outcome.signals.baseline.deltaPercent > 20
? 'text-destructive'
: outcome.signals.baseline.deltaPercent < -20
? 'text-warning'
: 'text-foreground'
}
>
{outcome.signals.baseline.deltaPercent > 0 ? '+' : ''}
{outcome.signals.baseline.deltaPercent.toFixed(1)}%
</span>
</span>
)}
<span>
{t('signals.alerts')}:{outcome.signals.alerts.length}
</span>
</div>
{outcome.signals.apps.length > 0 && (
<div className="overflow-hidden rounded-md border">
<table className="w-full text-xs">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
<th className="px-2 py-1.5 text-left">#</th>
<th className="px-2 py-1.5 text-left">{t('signals.apps')}</th>
<th className="px-2 py-1.5 text-right">{t('signals.entries')}</th>
<th className="px-2 py-1.5 text-right">%</th>
</tr>
</thead>
<tbody>
{outcome.signals.apps.slice(0, 10).map((app, index) => (
<tr key={`${app.namespace}/${app.app}`} className="border-t">
<td className="px-2 py-1.5 text-muted-foreground">{index + 1}</td>
<td className="px-2 py-1.5 font-mono">
{app.app || '-'}
<span className="ml-1 text-muted-foreground">/{app.namespace}</span>
</td>
<td className="px-2 py-1.5 text-right tabular-nums">{fmtNum(app.entries)}</td>
<td className="px-2 py-1.5 text-right tabular-nums text-muted-foreground">{app.share}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{(outcome.warnings ?? []).length > 0 && (
<ul className="list-disc space-y-0.5 pl-5 text-xs text-muted-foreground">
{outcome.warnings.map((warning, index) => (
<li key={index}>{warning}</li>
))}
</ul>
)}
</CardContent>
</Card>
</>
)}
</TabsContent>
{/* ─────────── 模型配置 ─────────── */}
<TabsContent value="config" className="space-y-4">
{!config ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
仅管理员可修改模型配置
</CardContent>
</Card>
) : (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">模型接入(OpenAI 兼容)</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3">
<Switch
checked={Boolean(config.enabled)}
onCheckedChange={checked => setConfig({ ...config, enabled: checked })}
/>
<div>
<div className="text-sm">{t('config.enabled')}</div>
<div className="text-xs text-muted-foreground">{t('config.enabledHint')}</div>
</div>
<span className="ml-auto text-xs text-muted-foreground">
{t('config.usedToday', { used: fmtNum(usedToday) })}
</span>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">{t('config.provider')}</Label>
<Select
value={matchProvider(config.baseUrl)}
onValueChange={value => {
const p = LLM_PROVIDERS.find(x => x.id === value)
if (!p) return
setConfig({ ...config, baseUrl: p.endpoint, model: p.models[0] && value !== 'custom' ? p.models[0] : config.model })
}}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{LLM_PROVIDERS.map(p => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">{t('config.providerSelectHint')}</p>
</div>
<div className="space-y-1">
<Label className="text-xs">{t('config.model')}</Label>
{((LLM_PROVIDERS.find(p => p.id === matchProvider(config.baseUrl))?.models ?? ['deepseek-flash'])).includes(config.model) || !config.model ? (
<Select
value={config.model || ((LLM_PROVIDERS.find(p => p.id === matchProvider(config.baseUrl))?.models ?? ['deepseek-flash']))[0]}
onValueChange={value => {
if (value === '__custom__') { setConfig({ ...config, model: '' }); return }
setConfig({ ...config, model: value })
}}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{((LLM_PROVIDERS.find(p => p.id === matchProvider(config.baseUrl))?.models ?? ['deepseek-flash'])).map(m => (
<SelectItem key={m} value={m}>{m}</SelectItem>
))}
<SelectItem value="__custom__">{t('config.customModel')}</SelectItem>
</SelectContent>
</Select>
) : (
<Input value={config.model} placeholder="model-name" onChange={e => setConfig({ ...config, model: e.target.value })} />
)}
<p className="text-[11px] text-muted-foreground">{t('config.modelHint')}</p>
</div>
{matchProvider(config.baseUrl) === 'custom' && (
<div className="space-y-1 sm:col-span-2">
<Label className="text-xs">{t('config.baseUrl')}</Label>
<Input
value={config.baseUrl}
placeholder="https://api.deepseek.com/v1"
onChange={e => setConfig({ ...config, baseUrl: e.target.value })}
/>
<p className="text-[11px] text-muted-foreground">{t('config.baseUrlHint')}</p>
</div>
)}
<div className="space-y-1">
<Label className="text-xs">{t('config.apiKey')}</Label>
<Input
type="password"
value={apiKeyInput}
placeholder={config.apiKey || 'sk-...'}
onChange={e => setApiKeyInput(e.target.value)}
/>
<p className="text-[11px] text-muted-foreground">{t('config.apiKeyHint')}</p>
</div>
<details className="sm:col-span-2 rounded-lg border px-3 py-2">
<summary className="cursor-pointer text-xs font-medium text-muted-foreground">{t('config.advanced')}</summary>
<div className="mt-3 grid gap-4">
<div className="grid grid-cols-3 gap-2">
<div className="space-y-1">
<Label className="text-xs">{t('config.temperature')}</Label>
<Input
type="number"
step="0.1"
min="0"
max="1"
value={config.temperature}
onChange={e => setConfig({ ...config, temperature: Number(e.target.value) })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">{t('config.maxTokens')}</Label>
<Input
type="number"
value={config.maxTokens}
onChange={e => setConfig({ ...config, maxTokens: Number(e.target.value) })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">{t('config.timeoutSec')}</Label>
<Input
type="number"
value={config.timeoutSec}
onChange={e => setConfig({ ...config, timeoutSec: Number(e.target.value) })}
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">{t('config.dailyTokenCap')}</Label>
<Input
type="number"
value={config.dailyTokenCap}
onChange={e => setConfig({ ...config, dailyTokenCap: Number(e.target.value) })}
/>
<p className="text-[11px] text-muted-foreground">{t('config.dailyTokenCapHint')}</p>
</div>
<div className="space-y-1">
<Label className="text-xs">{t('config.redactExtra')}</Label>
<Textarea
rows={3}
value={redactText}
onChange={e => setRedactText(e.target.value)}
className="font-mono text-xs"
/>
<p className="text-[11px] text-muted-foreground">{t('config.redactExtraHint')}</p>
</div>
</div>
</details>
</div>
{/* 顺序按实际操作流:先「测试连接」确认能通,再「保存配置」 */}
<div className="flex gap-2">
<Button variant="outline" onClick={testConnection} disabled={testing}>
{testing ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<PlugZap className="mr-1.5 h-4 w-4" />
)}
{testing ? t('config.testing') : t('config.test')}
</Button>
<Button onClick={saveConfig} disabled={saving}>
{saving ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : <Save className="mr-1.5 h-4 w-4" />}
{t('config.save')}
</Button>
</div>
</CardContent>
</Card>
)}
</TabsContent>
{/* ─────────── 历史 ─────────── */}
<TabsContent value="history" className="space-y-3">
<Card>
<CardContent className="p-0">
<table className="w-full text-xs">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left">{t('history.time')}</th>
<th className="px-3 py-2 text-left">{t('history.user')}</th>
<th className="px-3 py-2 text-left">{t('history.source')}</th>
<th className="px-3 py-2 text-right">{t('history.range')}</th>
<th className="px-3 py-2 text-left">{t('history.status')}</th>
<th className="px-3 py-2 text-right">{t('history.tokens')}</th>
<th className="px-3 py-2 text-right">{t('history.duration')}</th>
</tr>
</thead>
<tbody>
{runsLoading && (
<tr>
<td colSpan={7} className="px-3 py-6 text-center text-muted-foreground">
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
</td>
</tr>
)}
{!runsLoading && runs.length === 0 && (
<tr>
<td colSpan={7} className="px-3 py-6 text-center text-muted-foreground">
{t('history.empty')}
</td>
</tr>
)}
{!runsLoading &&
runs.map(run => (
<tr
key={run.id}
className="cursor-pointer border-t hover:bg-muted/40"
onClick={() => setDetail(run)}
>
<td className="px-3 py-2 text-muted-foreground">
{new Date(run.createdAt).toLocaleString()}
</td>
<td className="px-3 py-2">{run.username || '-'}</td>
<td className="px-3 py-2">{run.sourceName}</td>
<td className="px-3 py-2 text-right tabular-nums">{run.rangeMinutes}m</td>
<td className="px-3 py-2">
<Badge
className={`text-[10px] ${
run.status === 'ok'
? 'bg-success/15 text-success'
: run.status === 'quota-exceeded'
? 'bg-warning/15 text-warning'
: 'bg-destructive/15 text-destructive'
}`}
>
{run.status === 'ok'
? t('history.statusOk')
: run.status === 'quota-exceeded'
? t('history.statusQuota')
: t('history.statusFailed')}
</Badge>
</td>
<td className="px-3 py-2 text-right tabular-nums">
{fmtNum(run.promptTokens + run.completionTokens)}
</td>
<td className="px-3 py-2 text-right tabular-nums">{(run.durationMs / 1000).toFixed(1)}s</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
共 {runTotal} 条 · 第 {runPage}/{totalPages} 页
</span>
<div className="flex gap-1">
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
disabled={runPage <= 1}
onClick={() => setRunPage(page => Math.max(1, page - 1))}
>
上一页
</Button>
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
disabled={runPage >= totalPages}
onClick={() => setRunPage(page => page + 1)}
>
下一页
</Button>
</div>
</div>
</TabsContent>
</Tabs>
{/* 发给模型的完整内容 */}
<Dialog open={promptOpen} onOpenChange={setPromptOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{t('analyze.promptTitle')}</DialogTitle>
</DialogHeader>
<p className="text-xs text-muted-foreground">{t('analyze.promptHint')}</p>
<pre className="max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-md bg-muted/40 p-3 text-xs">
{outcome?.prompt ?? ''}
</pre>
</DialogContent>
</Dialog>
{/* 历史详情 */}
<Dialog open={Boolean(detail)} onOpenChange={open => !open && setDetail(null)}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{t('history.detail')}</DialogTitle>
</DialogHeader>
{detail && (
<div className="space-y-3 text-sm">
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
<span>{new Date(detail.createdAt).toLocaleString()}</span>
<span>{detail.username}</span>
<span>{detail.sourceName}</span>
<span>{detail.model}</span>
<span>
{detail.promptTokens}+{detail.completionTokens} tokens
</span>
<span>{(detail.durationMs / 1000).toFixed(1)}s</span>
</div>
{detail.error && <p className="text-xs text-destructive">{detail.error}</p>}
<pre className="max-h-[55vh] overflow-auto whitespace-pre-wrap rounded-md bg-muted/40 p-3 text-xs">
{detail.promptPayload || detail.conclusion}
</pre>
</div>
)}
</DialogContent>
</Dialog>
</PageContainer>
)
}