feat(v3): Web 应用化基线(异步任务/SSE/llama-server 管理/Vue SPA 四页 + 设置页整页滚动修复)

This commit is contained in:
tzt
2026-09-01 08:31:47 +08:00
parent 8d36eeec59
commit 3bbdcb7cc7
60 changed files with 7236 additions and 1160 deletions
+338
View File
@@ -0,0 +1,338 @@
<template>
<div class="chat-view">
<!-- 历史会话侧边栏 -->
<aside class="sidebar">
<h3>会话记录</h3>
<ul class="session-list">
<li
v-for="s in chatStore.sessions"
:key="s.requestId"
:class="['session-item', { active: s.requestId === chatStore.currentId }]"
@click="chatStore.setCurrent(s.requestId)"
>
<span class="s-query">{{ s.query.slice(0, 28) }}{{ s.query.length > 28 ? '…' : '' }}</span>
<span class="s-status" :class="s.status?.status ?? 'pending'">
{{ s.status?.status ?? 'pending' }}
</span>
</li>
</ul>
</aside>
<!-- 主聊天区 -->
<main class="main">
<div v-if="!chatStore.current()" class="empty">
<p>输入问题开启端云协同协作之旅</p>
<p class="hint">结果通过 SSE 实时推送可切换协作页面查看交流文本可视化</p>
</div>
<template v-else>
<!-- 用户问题 -->
<div class="user-msg">
<span class="role-label"></span>
<p>{{ chatStore.current()!.query }}</p>
</div>
<!-- 状态指示器 -->
<div class="status-bar">
<span v-if="runStatus === 'pending'" class="badge pending"> 排队中</span>
<span v-else-if="runStatus === 'running'" class="badge running">
🔄 协作中 ({{ ws?.meta.round ?? 0 }}/{{ ws?.meta.rounds_cap ?? 6 }})
</span>
<span v-else-if="runStatus === 'done'" class="badge done"> 完成</span>
<span v-else-if="runStatus === 'failed'" class="badge failed"> 失败</span>
<span v-if="ws" class="route-path">
路由{{ chatStore.current()!.status?.route?.join(' ') ?? '' }}
</span>
</div>
<!-- 协作元信息 -->
<div v-if="ws" class="ws-meta">
<span>tokens: {{ ws.meta.api_input_tokens }} in / {{ ws.meta.api_output_tokens }} out</span>
<span>延迟: {{ chatStore.current()!.status?.latency_ms?.toFixed(0) }} ms</span>
<span>模型: {{ chatStore.current()!.status?.model_used ?? '—' }}</span>
</div>
<!-- 响应正文 -->
<div v-if="response" class="assistant-msg">
<span class="role-label">系统</span>
<pre>{{ response }}</pre>
</div>
<!-- 交流文本预览紧凑折叠 -->
<details v-if="ws" class="ws-preview">
<summary>📄 交流文本预览</summary>
<div v-if="ws.brief" class="brief-block">
<strong>Brief:</strong> {{ ws.brief.goal }}
<span class="tags">{{ ws.brief.tags.join(', ') }}</span>
</div>
<ul v-if="ws.plan?.length" class="plan-list">
<li v-for="p in ws.plan" :key="p.id" :class="p.status">
<span class="step-id">{{ p.id }}</span>
<span>{{ p.task }}</span>
<span class="deps" v-if="p.deps.length">{{ p.deps.join(',') }}</span>
</li>
</ul>
<ul v-if="ws.progress?.length" class="progress-list">
<li v-for="pg in ws.progress" :key="pg.step" :class="pg.status">
{{ pg.step }}: {{ pg.status }}
</li>
</ul>
</details>
<!-- 错误 -->
<div v-if="chatStore.current()!.error" class="error-msg">
{{ chatStore.current()!.error }}
</div>
</template>
<!-- 输入框 -->
<form class="input-bar" @submit.prevent="handleSend">
<input
v-model="input"
placeholder="输入你的问题…"
:disabled="sending"
autofocus
/>
<button type="submit" :disabled="sending || !input.trim()">
{{ sending ? '发送中…' : '发送' }}
</button>
</form>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useChatStore } from '@/stores/chat'
import { watchRun } from '@/api'
import type { Workspace } from '@/types'
const chatStore = useChatStore()
const input = ref('')
const sending = ref(false)
const current = computed(() => chatStore.current())
const runStatus = computed(() => current.value?.status?.status ?? 'pending')
const ws = computed(() => current.value?.workspace)
const response = computed(() => current.value?.status?.response ?? null)
// SSE 订阅
let sseHandle: ReturnType<typeof watchRun> | null = null
function subscribeSSE(requestId: string) {
sseHandle?.close()
sseHandle = watchRun(requestId)
sseHandle.subscribe({
onWorkspace(workspace: Workspace) {
chatStore.updateWorkspace(requestId, workspace)
},
onStatus(_state: string) {
chatStore.pollStatus(requestId)
},
onError(detail: string) {
const s = chatStore.sessions.find((x) => x.requestId === requestId)
if (s) s.error = detail
},
onDone() {
chatStore.pollStatus(requestId)
},
})
}
watch(
() => chatStore.currentId,
(id) => {
if (id) {
subscribeSSE(id)
// 若已完成立即拉一次状态
if (current.value?.status?.status === 'done') {
chatStore.pollStatus(id)
}
}
},
{ immediate: true },
)
async function handleSend() {
if (!input.value.trim() || sending.value) return
const query = input.value.trim()
input.value = ''
sending.value = true
try {
const session = await chatStore.sendQuery(query)
subscribeSSE(session.requestId)
} finally {
sending.value = false
}
}
</script>
<style scoped>
.chat-view {
display: flex;
height: 100%;
gap: 0;
}
.sidebar {
width: 240px;
border-right: 1px solid #e5e7eb;
background: #f9fafb;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar h3 {
padding: 12px 16px;
margin: 0;
font-size: 14px;
color: #6b7280;
border-bottom: 1px solid #e5e7eb;
}
.session-list {
list-style: none;
margin: 0;
padding: 8px;
overflow-y: auto;
flex: 1;
}
.session-item {
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 4px;
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13px;
}
.session-item:hover { background: #e5e7eb; }
.session-item.active { background: #dbeafe; }
.s-query { color: #111; }
.s-status { font-size: 11px; color: #9ca3af; }
.s-status.done { color: #16a34a; }
.s-status.failed { color: #dc2626; }
.s-status.running { color: #2563eb; }
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 20px 24px;
gap: 12px;
}
.empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #9ca3af;
gap: 8px;
}
.hint { font-size: 13px; }
.user-msg, .assistant-msg {
background: #f3f4f6;
border-radius: 8px;
padding: 12px 16px;
display: flex;
gap: 12px;
font-size: 14px;
line-height: 1.6;
}
.assistant-msg { background: #eff6ff; }
.role-label {
font-weight: 700;
font-size: 12px;
color: #6b7280;
min-width: 32px;
}
.user-msg pre, .assistant-msg pre {
margin: 0;
white-space: pre-wrap;
font-family: inherit;
}
.status-bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.badge {
padding: 3px 10px;
border-radius: 99px;
font-size: 12px;
font-weight: 600;
}
.badge.pending { background: #f3f4f6; color: #6b7280; }
.badge.running { background: #dbeafe; color: #2563eb; }
.badge.done { background: #dcfce7; color: #16a34a; }
.badge.failed { background: #fee2e2; color: #dc2626; }
.route-path { font-size: 12px; color: #9ca3af; }
.ws-meta {
display: flex;
gap: 16px;
font-size: 12px;
color: #9ca3af;
}
.ws-preview {
background: #fafafa;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px;
font-size: 13px;
}
.brief-block { margin-bottom: 8px; }
.tags { margin-left: 8px; color: #6b7280; font-size: 12px; }
.plan-list, .progress-list {
list-style: none;
padding: 0;
margin: 4px 0 0;
}
.plan-list li, .progress-list li {
padding: 2px 0;
display: flex;
gap: 6px;
}
.step-id { font-family: monospace; color: #6b7280; min-width: 48px; }
.deps { color: #9ca3af; font-size: 12px; }
.done { color: #16a34a; }
.pending { color: #9ca3af; }
.running { color: #2563eb; }
.error-msg { color: #dc2626; font-size: 13px; background: #fee2e2; padding: 8px 12px; border-radius: 6px; }
.input-bar {
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #e5e7eb;
margin-top: auto;
}
.input-bar input {
flex: 1;
padding: 10px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
outline: none;
}
.input-bar input:focus { border-color: #2563eb; }
.input-bar button {
padding: 10px 20px;
background: #2563eb;
color: #fff;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
.input-bar button:disabled { background: #9ca3af; cursor: not-allowed; }
</style>
+450
View File
@@ -0,0 +1,450 @@
<template>
<div class="collab-view">
<!-- 左侧会话选择 + 协作流程图 -->
<aside class="collab-sidebar">
<h3>协作会话</h3>
<ul class="session-list">
<li
v-for="s in chatStore.sessions"
:key="s.requestId"
:class="['session-item', { active: s.requestId === activeId }]"
@click="selectSession(s.requestId)"
>
<span>{{ s.query.slice(0, 24) }}{{ s.query.length > 24 ? '…' : '' }}</span>
<span class="meta">
{{ s.status?.rounds_used ?? '?' }}
· {{ s.status?.model_used ?? '—' }}
</span>
</li>
</ul>
<!-- 当前会话统计 -->
<div v-if="currentSession" class="stats">
<h4>运行统计</h4>
<div class="stat-grid">
<span>输入 Token</span><b>{{ currentSession.status?.api_input_tokens ?? 0 }}</b>
<span>输出 Token</span><b>{{ currentSession.status?.api_output_tokens ?? 0 }}</b>
<span>延迟</span><b>{{ currentSession.status?.latency_ms?.toFixed(0) ?? '—' }} ms</b>
<span>快路径</span><b>{{ currentSession.status?.fast_path ? '是' : '否' }}</b>
</div>
<h4>路由路径</h4>
<div class="route-flow">
<span
v-for="(r, i) in currentSession.status?.route ?? []"
:key="i"
class="route-node"
>{{ r }}</span>
</div>
</div>
</aside>
<!-- 右侧交流文本实时可视化 -->
<main class="collab-main">
<div v-if="!ws" class="empty">
<p>从左侧选择一个会话或在对话页发起新提问</p>
<p>协作过程通过 SSE 实时推送无需刷新</p>
</div>
<template v-else>
<!-- Brief 阶段 -->
<section class="section brief-section">
<h2 class="section-title">📋 Brief任务简报</h2>
<div class="brief-card">
<div class="goal">{{ ws.brief?.goal ?? '(未生成)' }}</div>
<div class="tags">
<span v-for="t in ws.brief?.tags ?? []" :key="t" class="tag">{{ t }}</span>
<span v-if="ws.brief?.domain" class="domain">{{ ws.brief.domain }}</span>
</div>
<ul v-if="ws.brief?.constraints?.length" class="constraints">
<li v-for="(c, i) in ws.brief.constraints" :key="i">{{ c }}</li>
</ul>
</div>
</section>
<!-- Plan 阶段 -->
<section class="section plan-section">
<h2 class="section-title">📌 Plan执行计划</h2>
<div class="plan-timeline">
<div
v-for="step in ws.plan ?? []"
:key="step.id"
:class="['plan-step', getStepStatus(step.id)]"
>
<div class="step-dot" />
<div class="step-content">
<div class="step-header">
<span class="step-id">{{ step.id }}</span>
<span class="step-status">{{ getStepStatus(step.id) }}</span>
</div>
<p class="step-task">{{ step.task }}</p>
<div v-if="step.deps.length" class="step-deps">
依赖<span v-for="d in step.deps" :key="d" class="dep">{{ d }}</span>
</div>
<!-- 步骤产出工件 -->
<div v-if="getStepArtifact(step.id)" class="step-artifact">
<details>
<summary>📄 工件内容</summary>
<pre>{{ getStepArtifact(step.id) }}</pre>
</details>
</div>
</div>
</div>
</div>
</section>
<!-- Progress 实时滚动 -->
<section class="section progress-section">
<h2 class="section-title">🔄 进度实时</h2>
<div class="progress-bar-wrap">
<div class="progress-label">
{{ ws.meta.round }} / {{ ws.meta.rounds_cap }}
</div>
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: `${(ws.meta.round / ws.meta.rounds_cap) * 100}%` }"
/>
</div>
</div>
<div class="progress-steps">
<div
v-for="pg in ws.progress ?? []"
:key="pg.step"
:class="['pg-step', pg.status]"
>
<span class="pg-icon">
{{ pg.status === 'done' ? '✅' : pg.status === 'running' ? '⏳' : '⭕' }}
</span>
<span>{{ pg.step }}</span>
<span v-if="pg.note" class="pg-note">{{ pg.note }}</span>
</div>
</div>
</section>
<!-- Issues + Decisions -->
<section v-if="ws.issues?.length" class="section issues-section">
<h2 class="section-title"> Issues &amp; 裁决</h2>
<div v-for="issue in ws.issues" :key="issue.id" class="issue-card">
<div class="issue-header">
<span class="issue-id">{{ issue.id }}</span>
<span class="issue-step">Step: {{ issue.step }}</span>
</div>
<p>{{ issue.description }}</p>
<div v-if="getDecision(issue.id)" class="decision">
<strong>Architect 裁决</strong>{{ getDecision(issue.id)?.reply }}
</div>
</div>
</section>
<!-- Archive -->
<section v-if="ws.archive?.length" class="section archive-section">
<h2 class="section-title">📦 Archive摘要归档</h2>
<ul class="archive-list">
<li v-for="(item, i) in ws.archive" :key="i">{{ item }}</li>
</ul>
</section>
<!-- 最终交付 -->
<section v-if="ws.meta.state === 'done'" class="section deliver-section">
<h2 class="section-title">🎉 交付</h2>
<div class="response-block">
<pre>{{ currentSession?.status?.response ?? '(无内容)' }}</pre>
</div>
</section>
</template>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useChatStore } from '@/stores/chat'
import { watchRun } from '@/api'
import type { Workspace } from '@/types'
const chatStore = useChatStore()
const activeId = ref<string | null>(chatStore.currentId)
const liveWs = ref<Workspace | null>(null)
const currentSession = computed(() =>
chatStore.sessions.find((s) => s.requestId === activeId.value) ?? null,
)
const ws = computed(() => liveWs.value ?? currentSession.value?.workspace ?? null)
function selectSession(id: string) {
activeId.value = id
liveWs.value = null
}
// SSE 实时推送
let sse: ReturnType<typeof watchRun> | null = null
function startSSE(requestId: string) {
sse?.close()
sse = watchRun(requestId)
sse.subscribe({
onWorkspace(workspace: Workspace) {
if (workspace.request_id === activeId.value) {
liveWs.value = workspace
chatStore.updateWorkspace(requestId, workspace)
}
},
onStatus() {
chatStore.pollStatus(requestId)
},
onDone() {
chatStore.pollStatus(requestId)
},
onError() {},
})
}
watch(
() => chatStore.sessions,
(sessions) => {
if (activeId.value) {
const found = sessions.find((s) => s.requestId === activeId.value)
if (!found) activeId.value = sessions[0]?.requestId ?? null
}
},
{ deep: true },
)
watch(
activeId,
(id) => {
if (!id) return
const s = chatStore.sessions.find((x) => x.requestId === id)
if (s?.workspace) liveWs.value = s.workspace
startSSE(id)
},
{ immediate: true },
)
function getStepStatus(stepId: string) {
return ws.value?.progress?.find((p: import('@/types').ProgressStep) => p.step === stepId)?.status ?? 'pending'
}
function getStepArtifact(stepId: string) {
return ws.value?.progress?.find((p: import('@/types').ProgressStep) => p.step === stepId)?.artifact ?? null
}
function getDecision(issueId: string) {
return ws.value?.decisions?.find((d: import('@/types').Decision) => d.ref === issueId) ?? null
}
</script>
<style scoped>
.collab-view {
display: flex;
height: 100%;
}
.collab-sidebar {
width: 260px;
border-right: 1px solid #e5e7eb;
background: #f9fafb;
display: flex;
flex-direction: column;
overflow: hidden;
}
.collab-sidebar h3 {
padding: 12px 16px;
margin: 0;
font-size: 13px;
color: #6b7280;
border-bottom: 1px solid #e5e7eb;
font-weight: 600;
}
.session-list {
list-style: none;
padding: 8px;
overflow-y: auto;
flex: 0 0 auto;
max-height: 35%;
}
.session-item {
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 4px;
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13px;
}
.session-item:hover { background: #e5e7eb; }
.session-item.active { background: #dbeafe; }
.meta { font-size: 11px; color: #9ca3af; }
.stats {
padding: 12px 16px;
border-top: 1px solid #e5e7eb;
font-size: 12px;
overflow-y: auto;
}
.stats h4 { margin: 0 0 6px; color: #374151; font-size: 12px; }
.stat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 8px;
margin-bottom: 12px;
}
.stat-grid span { color: #6b7280; }
.stat-grid b { color: #111; text-align: right; }
.route-flow {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.route-node {
background: #e0e7ff;
color: #3730a3;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-family: monospace;
}
.collab-main {
flex: 1;
overflow-y: auto;
padding: 20px 28px;
}
.empty {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #9ca3af;
gap: 8px;
}
.section { margin-bottom: 28px; }
.section-title {
font-size: 16px;
color: #111;
margin: 0 0 12px;
padding-bottom: 6px;
border-bottom: 2px solid #2563eb;
}
.brief-card {
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
padding: 14px 16px;
}
.goal { font-size: 14px; margin-bottom: 8px; color: #1e40af; font-weight: 600; }
.tags { display: flex; gap: 6px; flex-wrap: wrap; }
.tag { background: #dbeafe; color: #1d4ed8; padding: 2px 8px; border-radius: 99px; font-size: 12px; }
.domain { background: #fce7f3; color: #9d174d; padding: 2px 8px; border-radius: 99px; font-size: 12px; }
.constraints { list-style: disc inside; font-size: 13px; color: #374151; margin-top: 8px; }
/* Plan timeline */
.plan-timeline { display: flex; flex-direction: column; gap: 0; }
.plan-step {
display: flex;
gap: 12px;
padding-bottom: 16px;
position: relative;
}
.plan-step::before {
content: '';
position: absolute;
left: 6px;
top: 16px;
bottom: 0;
width: 2px;
background: #e5e7eb;
}
.plan-step:last-child::before { display: none; }
.step-dot {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid #d1d5db;
background: #fff;
flex-shrink: 0;
margin-top: 2px;
z-index: 1;
}
.plan-step.done .step-dot { background: #16a34a; border-color: #16a34a; }
.plan-step.running .step-dot { background: #2563eb; border-color: #2563eb; }
.plan-step.pending .step-dot { background: #fff; }
.step-content { flex: 1; }
.step-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.step-id { font-family: monospace; font-size: 12px; color: #6b7280; font-weight: 700; }
.step-status { font-size: 11px; padding: 1px 6px; border-radius: 99px; }
.plan-step.done .step-status { background: #dcfce7; color: #16a34a; }
.plan-step.running .step-status { background: #dbeafe; color: #2563eb; }
.plan-step.pending .step-status { background: #f3f4f6; color: #9ca3af; }
.step-task { margin: 0; font-size: 13px; color: #374151; }
.step-deps { font-size: 11px; color: #9ca3af; margin-top: 2px; }
.dep { background: #f3f4f6; padding: 0 4px; border-radius: 3px; font-family: monospace; margin-right: 4px; }
.step-artifact { margin-top: 6px; }
.step-artifact details { background: #fafafa; border: 1px solid #e5e7eb; border-radius: 4px; }
.step-artifact summary { padding: 4px 8px; cursor: pointer; font-size: 12px; color: #6b7280; }
.step-artifact pre { padding: 6px 10px; font-size: 12px; margin: 0; white-space: pre-wrap; max-height: 120px; overflow-y: auto; }
/* Progress */
.progress-bar-wrap { margin-bottom: 10px; }
.progress-label { font-size: 12px; color: #6b7280; margin-bottom: 4px; }
.progress-bar { height: 6px; background: #e5e7eb; border-radius: 99px; overflow: hidden; }
.progress-fill { height: 100%; background: #2563eb; border-radius: 99px; transition: width 0.4s ease; }
.progress-steps { display: flex; flex-wrap: wrap; gap: 8px; }
.pg-step {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
border: 1px solid #e5e7eb;
}
.pg-step.done { background: #dcfce7; border-color: #bbf7d0; }
.pg-step.running { background: #dbeafe; border-color: #bfdbfe; }
.pg-note { color: #9ca3af; font-size: 11px; }
/* Issues */
.issue-card {
border: 1px solid #fca5a5;
background: #fff5f5;
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 10px;
}
.issue-header { display: flex; gap: 8px; margin-bottom: 6px; }
.issue-id { font-family: monospace; font-size: 12px; color: #dc2626; font-weight: 700; }
.issue-step { font-size: 11px; color: #6b7280; }
.decision {
margin-top: 8px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 4px;
padding: 6px 10px;
font-size: 13px;
}
/* Archive */
.archive-list { list-style: disc inside; font-size: 13px; color: #374151; }
/* Deliver */
.response-block {
background: #f0fdf4;
border: 1px solid #86efac;
border-radius: 8px;
padding: 14px 16px;
}
.response-block pre {
margin: 0;
white-space: pre-wrap;
font-size: 14px;
line-height: 1.7;
}
</style>
+171
View File
@@ -0,0 +1,171 @@
<template>
<div class="metrics-view">
<header class="metrics-header">
<h2>系统指标</h2>
<button class="refresh" @click="load">🔄 刷新</button>
</header>
<div v-if="loading" class="loading">加载中</div>
<div v-else-if="error" class="error">{{ error }}</div>
<template v-else-if="data">
<!-- 卡片网格 -->
<div class="card-grid">
<div class="metric-card">
<h3>路由器v1</h3>
<div class="kv-list">
<template v-for="(v, k) in data.router" :key="k">
<span>{{ k }}</span><b>{{ v }}</b>
</template>
</div>
</div>
<div class="metric-card">
<h3>缓存</h3>
<div class="kv-list">
<template v-for="(v, k) in data.cache" :key="k">
<span>{{ k }}</span><b>{{ v }}</b>
</template>
</div>
</div>
<div v-if="data.v2" class="metric-card highlight">
<h3>协作管线v2</h3>
<div class="kv-list">
<template v-for="(v, k) in data.v2" :key="k">
<span>{{ k }}</span><b>{{ v }}</b>
</template>
</div>
</div>
<div v-if="data.review" class="metric-card review-card">
<h3>人工检验</h3>
<div class="review-stats">
<div class="stat-item">
<span class="stat-num">{{ data.review.pending }}</span>
<span class="stat-label">待审核</span>
</div>
<div class="stat-item">
<span class="stat-num">{{ data.review.total }}</span>
<span class="stat-label">总提交</span>
</div>
</div>
<div v-if="data.review.total > 0" class="progress-wrap">
<div
class="reviewed-bar"
:style="{
width: `${((data.review.total - data.review.pending) / data.review.total) * 100}%`,
}"
/>
</div>
<p class="review-rate">
通过率
{{ (((data.review.total - data.review.pending) / data.review.total) * 100).toFixed(1) }}%
</p>
</div>
</div>
<!-- 原始 JSON -->
<details class="raw-json">
<summary>原始 JSON</summary>
<pre>{{ JSON.stringify(data, null, 2) }}</pre>
</details>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getMetrics } from '@/api'
import type { Metrics } from '@/types'
const data = ref<Metrics | null>(null)
const loading = ref(false)
const error = ref('')
async function load() {
loading.value = true
error.value = ''
try {
data.value = await getMetrics()
} catch (e: unknown) {
// 网络超时或服务端错误:显示友好错误而不是无限 loading
error.value = e instanceof Error ? e.message : '指标加载失败,请检查后端服务'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.metrics-view { padding: 20px 24px; height: 100%; overflow-y: auto; }
.metrics-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.metrics-header h2 { margin: 0; font-size: 20px; }
.refresh { padding: 6px 14px; border: 1px solid #d1d5db; border-radius: 6px; cursor: pointer; background: #fff; }
.loading, .error { text-align: center; padding: 40px; color: #9ca3af; }
.error { color: #dc2626; }
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.metric-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 16px;
}
.metric-card.highlight { border-color: #2563eb; background: #eff6ff; }
.metric-card h3 { margin: 0 0 12px; font-size: 14px; color: #374151; }
.kv-list {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 12px;
font-size: 13px;
}
.kv-list span { color: #6b7280; }
.kv-list b { color: #111; text-align: right; }
.review-card { grid-column: span 2; }
.review-stats { display: flex; gap: 24px; margin-bottom: 12px; }
.stat-item { display: flex; flex-direction: column; align-items: center; }
.stat-num { font-size: 28px; font-weight: 700; color: #2563eb; }
.stat-label { font-size: 12px; color: #6b7280; }
.progress-wrap {
height: 8px;
background: #e5e7eb;
border-radius: 99px;
overflow: hidden;
margin-bottom: 6px;
}
.reviewed-bar { height: 100%; background: #16a34a; transition: width 0.5s ease; }
.review-rate { font-size: 13px; color: #6b7280; margin: 0; }
.raw-json {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.raw-json summary { padding: 10px 14px; cursor: pointer; font-size: 13px; color: #6b7280; }
.raw-json pre {
padding: 10px 14px;
margin: 0;
font-size: 12px;
white-space: pre-wrap;
border-top: 1px solid #e5e7eb;
}
</style>
+196
View File
@@ -0,0 +1,196 @@
<template>
<div class="review-view">
<header class="review-header">
<h2>人工检验队列</h2>
<div class="controls">
<button :class="{ active: filter === 'all' }" @click="filter = 'all'">全部</button>
<button :class="{ active: filter === 'pending' }" @click="filter = 'pending'">待审核</button>
<button :class="{ active: filter === 'approved' }" @click="filter = 'approved'">已通过</button>
<button :class="{ active: filter === 'rejected' }" @click="filter = 'rejected'">已拒绝</button>
<button class="refresh-btn" @click="load">🔄 刷新</button>
</div>
</header>
<div v-if="loading" class="loading">加载中</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else class="queue-list">
<div v-if="!filtered.length" class="empty">队列为空</div>
<div v-for="item in filtered" :key="item.id" class="review-card">
<div class="card-header">
<span class="card-id">#{{ item.id }}</span>
<span class="verdict-badge" :class="item.verdict">{{ item.verdict }}</span>
<span class="tags">
<span v-for="t in item.tags" :key="t" class="tag">{{ t }}</span>
</span>
<span class="date">{{ item.created_at }}</span>
</div>
<div class="query-block">
<strong>Query</strong>{{ item.query }}
</div>
<div class="response-block">
<strong>Response</strong>
<pre>{{ item.response }}</pre>
</div>
<div v-if="item.verdict === 'pending'" class="actions">
<textarea
v-model="correctionInputs[item.id]"
placeholder="修正意见(可选)"
rows="2"
/>
<div class="btn-row">
<button class="approve" @click="submit(item.id, 'approved')"> 通过</button>
<button class="reject" @click="submit(item.id, 'rejected')"> 拒绝</button>
</div>
</div>
<div v-else-if="item.correction" class="correction">
<strong>修正</strong>{{ item.correction }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { listReviews, submitReview } from '@/api'
import type { ReviewItem } from '@/types'
const items = ref<ReviewItem[]>([])
const loading = ref(false)
const error = ref('')
const filter = ref<'all' | 'pending' | 'approved' | 'rejected'>('pending')
const correctionInputs = ref<Record<number, string>>({})
const filtered = computed(() =>
filter.value === 'all'
? items.value
: items.value.filter((i) => i.verdict === filter.value),
)
async function load() {
loading.value = true
error.value = ''
try {
items.value = await listReviews()
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : String(e)
} finally {
loading.value = false
}
}
async function submit(id: number, verdict: 'approved' | 'rejected') {
try {
await submitReview(id, verdict, correctionInputs.value[id] || undefined)
await load()
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : String(e)
}
}
onMounted(load)
</script>
<style scoped>
.review-view { padding: 20px 24px; height: 100%; overflow-y: auto; }
.review-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.review-header h2 { margin: 0; font-size: 20px; }
.controls { display: flex; gap: 8px; }
button {
padding: 6px 14px;
border: 1px solid #d1d5db;
background: #fff;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
button.active { background: #2563eb; color: #fff; border-color: #2563eb; }
.refresh-btn { margin-left: auto; }
.loading, .error, .empty {
text-align: center;
padding: 40px;
color: #9ca3af;
}
.error { color: #dc2626; }
.queue-list { display: flex; flex-direction: column; gap: 16px; }
.review-card {
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 16px;
background: #fff;
}
.card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.card-id { font-family: monospace; font-size: 12px; color: #6b7280; }
.verdict-badge {
padding: 2px 8px;
border-radius: 99px;
font-size: 12px;
font-weight: 600;
}
.verdict-badge.pending { background: #fef3c7; color: #92400e; }
.verdict-badge.approved { background: #dcfce7; color: #16a34a; }
.verdict-badge.rejected { background: #fee2e2; color: #dc2626; }
.tags { display: flex; gap: 4px; }
.tag { background: #e0e7ff; color: #3730a3; padding: 1px 6px; border-radius: 4px; font-size: 11px; }
.date { margin-left: auto; font-size: 11px; color: #9ca3af; }
.query-block, .response-block {
font-size: 13px;
margin-bottom: 8px;
line-height: 1.6;
}
.query-block pre, .response-block pre {
margin: 4px 0 0;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 4px;
padding: 6px 10px;
white-space: pre-wrap;
font-size: 13px;
}
.actions { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 13px;
resize: vertical;
box-sizing: border-box;
font-family: inherit;
}
.btn-row { display: flex; gap: 8px; }
.approve { background: #dcfce7; border-color: #86efac; color: #16a34a; }
.reject { background: #fee2e2; border-color: #fca5a5; color: #dc2626; }
.correction {
margin-top: 8px;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 4px;
padding: 6px 10px;
font-size: 13px;
}
</style>
File diff suppressed because it is too large Load Diff