Files
LokiStack/frontend/src/pages/aiops/index.tsx
T
opsx 07aa41bc33
CI / Backend (go vet + test) (push) Waiting to run
CI / Frontend (lint + build + test) (push) Waiting to run
fix(aiops): 菜单移至日志告警下系统设置上;模型配置精简
- 菜单顺序:日志查询 → 日志分析 → 日志告警 → 智能运维 → 系统设置
- 模型配置去掉快捷预设按钮组;Base URL 仅选「自定义」供应商时显示;
  温度/输出上限/超时/每日token上限/额外脱敏收进「高级设置」折叠(默认收起),
  常规配置只剩:供应商下拉 + 模型名 + API Key + 测试/保存
2026-10-31 23:36:16 +08:00

794 lines
34 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) => {
setConfig(res.config)
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)
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', { reply: res.reply || 'OK' }))
} 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="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-sm">
{t('analyze.notEnabled')}
</div>
)}
<Card>
<CardContent className="flex flex-wrap items-end gap-3 pt-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="min-w-64 flex-1 space-y-1">
<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>
<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>
</CardContent>
<div className="px-4 pb-3 text-xs text-muted-foreground">{t('analyze.runHint')}</div>
</Card>
{!outcome && !running && (
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">{t('analyze.empty')}</CardContent>
</Card>
)}
{running && (
<Card>
<CardContent className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('analyze.running')}
</CardContent>
</Card>
)}
{outcome && (
<>
{/* 本次消耗:让成本和延迟是可见的 */}
<div className="flex flex-wrap items-center gap-4 rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<span>
{t('analyze.lastRun')}:{t('analyze.promptTokens')} {fmtNum(outcome.run.promptTokens)} ·{' '}
{t('analyze.completionTokens')} {fmtNum(outcome.run.completionTokens)} · {t('analyze.duration')}{' '}
{(outcome.run.durationMs / 1000).toFixed(1)}s
</span>
<Button variant="link" size="sm" className="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>
<Input
value={config.model}
placeholder="deepseek-flash"
list="llm-model-options"
onChange={e => setConfig({ ...config, model: e.target.value })}
/>
<datalist id="llm-model-options">
{(LLM_PROVIDERS.find(p => p.id === matchProvider(config.baseUrl))?.models ?? []).map(m => (
<option key={m} value={m} />
))}
</datalist>
<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>
)
}