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
+86
View File
@@ -0,0 +1,86 @@
<template>
<div class="app">
<!-- 顶部导航栏 -->
<nav class="topbar">
<span class="brand">🤖 端云协同 LLM 系统</span>
<div class="nav-links">
<router-link to="/chat">💬 对话</router-link>
<router-link to="/collaboration">🔄 协作</router-link>
<router-link to="/review">🔍 检验</router-link>
<router-link to="/metrics">📊 指标</router-link>
<router-link to="/settings"> 设置</router-link>
</div>
</nav>
<!-- 路由视图 -->
<router-view class="content" />
</div>
</template>
<script setup lang="ts">
// App.vue — 根布局,导航栏 + 路由出口
</script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #app {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
color: #111;
background: #fff;
}
.app {
display: flex;
flex-direction: column;
height: 100%;
}
.topbar {
display: flex;
align-items: center;
gap: 24px;
padding: 0 24px;
height: 52px;
border-bottom: 1px solid #e5e7eb;
background: #fff;
flex-shrink: 0;
}
.brand {
font-size: 16px;
font-weight: 700;
color: #1e40af;
white-space: nowrap;
}
.nav-links {
display: flex;
gap: 4px;
}
.nav-links a {
padding: 6px 14px;
border-radius: 6px;
text-decoration: none;
color: #374151;
font-size: 14px;
font-weight: 500;
transition: background 0.15s;
}
.nav-links a:hover { background: #f3f4f6; }
.nav-links a.router-link-active {
background: #eff6ff;
color: #2563eb;
}
.content {
flex: 1;
overflow: hidden; /* 限制自己高度,不被子内容撑开 */
display: flex;
flex-direction: column;
min-height: 0;
}
</style>
+262
View File
@@ -0,0 +1,262 @@
import axios from 'axios'
import type {
ChatInitResponse,
RunStatus,
SSEEvent,
ReviewItem,
Metrics,
} from '@/types'
const http = axios.create({ timeout: 10_000 })
// ── Chat ─────────────────────────────────────────────────────────────────────
/** POST /chat:异步提交,立即返回 request_id */
export async function chat(query: string, domain_group?: string) {
const { data } = await http.post<ChatInitResponse>('/chat', { query, domain_group })
return data
}
/** GET /runs/{id}/status:查询任务状态 */
export async function getRunStatus(requestId: string) {
const { data } = await http.get<RunStatus>(`/runs/${requestId}/status`)
return data
}
/** GET /runs/{id}/workspace:获取交流文本全文 */
export async function getWorkspace(requestId: string) {
const { data } = await http.get(`/runs/${requestId}/workspace`)
return data
}
/** SSE /runs/{id}/stream:订阅实时协作事件 */
export function watchRun(requestId: string) {
const es = new EventSource(`/runs/${requestId}/stream`)
return {
/** 监听器会在组件卸载时自动断开,调用者只需提供 onXxx 回调 */
subscribe(opts: {
onWorkspace: (ws: import('@/types').Workspace) => void
onStatus: (s: string) => void
onError: (detail: string) => void
onDone: () => void
}) {
es.addEventListener('message', (ev) => {
const d: SSEEvent = JSON.parse(ev.data)
if (d.type === 'workspace') opts.onWorkspace(d.workspace)
else if (d.type === 'status') {
opts.onStatus(d.value)
if (d.value === 'done' || d.value === 'failed') opts.onDone()
}
else if (d.type === 'error') opts.onError(d.detail)
})
es.addEventListener('error', () => {
// EventSource 自己会重连,这里只记录
})
},
close() { es.close() },
}
}
// ── 人工检验 ─────────────────────────────────────────────────────────────────
export async function listReviews(status?: string) {
const params = status ? { status } : {}
const { data } = await http.get<ReviewItem[]>('/review/queue', { params })
return data
}
export async function submitReview(reviewId: number, verdict: string, correction?: string) {
const { data } = await http.post(`/review/${reviewId}`, null, {
params: { verdict, correction },
})
return data
}
// ── 指标 ─────────────────────────────────────────────────────────────────────
export async function getMetrics() {
const { data } = await http.get<Metrics>('/api/metrics')
return data
}
// ── 模型设置 ─────────────────────────────────────────────────────────────────
export interface ModelSettings {
worker: {
backend: string
model: string
base_url: string
port: number
temperature: number
max_fix_attempts: number
code_timeout_s: number
}
architect: {
model: string
base_url: string
api_key: string
}
pipeline: {
fast_path: boolean
rounds_cap: number
api_token_cap: number
breach_policy: string
}
}
/** GET /config:读取当前模型设置 */
export async function getConfig() {
const { data } = await http.get<ModelSettings>('/config')
return data
}
/** PUT /config:更新模型设置(部分更新) */
export async function updateConfig(patch: Partial<ModelSettings>) {
const { data } = await http.put<ModelSettings>('/config', patch)
return data
}
/** POST /config/reset:恢复默认设置 */
export async function resetConfig() {
const { data } = await http.post<ModelSettings>('/config/reset')
return data
}
// ── 模型发现 & 连接验证 ───────────────────────────────────────────────────────
export interface ModelInfo {
id: string
name: string
}
/**
* GET /config/models?backend=llama_server&base_url=...&api_key=...
* 返回 { models: ModelInfo[] } 或 { error: string }
*/
export async function listModels(
backend: string,
base_url: string,
api_key: string,
) {
const params: Record<string, string> = { backend }
if (base_url) params.base_url = base_url
if (api_key) params.api_key = api_key
const { data } = await http.get<{ models?: ModelInfo[]; error?: string }>(
'/config/models', { params },
)
return data
}
/**
* GET /config/ping?backend=...&base_url=...&api_key=...
* 返回 { ok: boolean, detail?: string }
*/
export async function pingBackend(
backend: string,
base_url: string,
api_key: string,
) {
const params: Record<string, string> = { backend }
if (base_url) params.base_url = base_url
if (api_key) params.api_key = api_key
const { data } = await http.get<{ ok: boolean; detail?: string; status_code?: number }>(
'/config/ping', { params },
)
return data
}
// ── llama-server 内置管理 ─────────────────────────────────────────────────────
export interface LlamaStatus {
running: boolean
pid?: number | null
model?: string | null
port?: number | null
base_url?: string | null
started_at?: number | null
error?: string | null
}
export interface LocalModel {
id: string
name: string
size_mb: number
path: string
}
export interface DownloadProgress {
url: string
dest: string
downloaded_bytes: number
total_bytes?: number | null
progress_pct: number
speed: string
eta: string
done: boolean
error?: string | null
}
/** GET /llama/status */
export async function getLlamaStatus() {
const { data } = await http.get<LlamaStatus>('/llama/status')
return data
}
/** GET /llama/models */
export async function listLocalModels() {
const { data } = await http.get<{ models: LocalModel[] }>('/llama/models')
return data.models
}
/** POST /llama/start */
export async function startLlama(model: string, port = 8901, ngl = 99, ctx = 4096) {
const { data } = await http.post<LlamaStatus>('/llama/start', null, {
params: { model, port, ngl, ctx },
})
return data
}
/** POST /llama/stop */
export async function stopLlama() {
const { data } = await http.post<{ running: boolean }>('/llama/stop')
return data
}
/** POST /llama/download */
export async function downloadModel(url: string, dest?: string) {
const params: Record<string, string> = { url }
if (dest) params.dest = dest
const { data } = await http.post<DownloadProgress>('/llama/download', null, { params })
return data
}
/** SSE /llama/download/stream?url=... */
export function watchDownload(url: string) {
const es = new EventSource(`/llama/download/stream?url=${encodeURIComponent(url)}`)
return {
subscribe(opts: { onProgress: (p: DownloadProgress) => void; onDone: (p: DownloadProgress) => void }) {
es.addEventListener('message', (ev) => {
const p: DownloadProgress = JSON.parse(ev.data)
opts.onProgress(p)
if (p.done) opts.onDone(p)
})
es.addEventListener('error', () => { es.close() })
},
close() { es.close() },
}
}
// ── 辅助:轮询直到完成(用于不需要 SSE 的场景)──────────────────────────────────
export async function pollUntilDone(
requestId: string,
{ timeout = 60_000, interval = 500 }: { timeout?: number; interval?: number } = {},
) {
const t0 = Date.now()
while (Date.now() - t0 < timeout) {
const s = await getRunStatus(requestId)
if (s.status === 'done' || s.status === 'failed') return s
await new Promise((r) => setTimeout(r, interval))
}
throw new Error('poll timeout')
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { ref } from 'vue'
import heroImg from '../assets/hero.png'
import viteLogo from '../assets/vite.svg'
import vueLogo from '../assets/vue.svg'
const count = ref(0)
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button type="button" class="counter" @click="count++">
Count is {{ count }}
</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>
+9
View File
@@ -0,0 +1,9 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+41
View File
@@ -0,0 +1,41 @@
import { createRouter, createWebHistory } from 'vue-router'
import ChatView from '@/views/ChatView.vue'
// Vite 构建时 base=/static/,但 FastAPI 在 /metrics 等路径提供 SPA(不在 /static/ 下),
// 所以 history 固定用 '/',避免 Vue Router 把 /metrics 当作 /static/metrics 解析导致路由不匹配。
const router = createRouter({
history: createWebHistory('/'),
routes: [
{
path: '/',
redirect: '/chat',
},
{
path: '/chat',
name: 'chat',
component: ChatView,
},
{
path: '/collaboration',
name: 'collaboration',
component: () => import('@/views/CollaborationView.vue'),
},
{
path: '/review',
name: 'review',
component: () => import('@/views/ReviewView.vue'),
},
{
path: '/metrics',
name: 'metrics',
component: () => import('@/views/MetricsView.vue'),
},
{
path: '/settings',
name: 'settings',
component: () => import('@/views/SettingsView.vue'),
},
],
})
export default router
+61
View File
@@ -0,0 +1,61 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { chat, getRunStatus, pollUntilDone } from '@/api'
import type { ChatInitResponse, RunStatus, Workspace } from '@/types'
// 单次会话记录
export interface ChatSession {
requestId: string
query: string
init: ChatInitResponse
status: RunStatus | null
workspace: Workspace | null
error: string | null
}
export const useChatStore = defineStore('chat', () => {
const sessions = ref<ChatSession[]>([])
const currentId = ref<string | null>(null)
const current = () => sessions.value.find((s) => s.requestId === currentId.value) ?? null
async function sendQuery(query: string) {
const init = await chat(query)
const session: ChatSession = {
requestId: init.request_id,
query,
init,
status: null,
workspace: null,
error: null,
}
sessions.value.unshift(session)
currentId.value = init.request_id
return session
}
async function pollStatus(requestId: string) {
const s = await getRunStatus(requestId)
const session = sessions.value.find((x) => x.requestId === requestId)
if (session) session.status = s
return s
}
async function waitForDone(requestId: string) {
const s = await pollUntilDone(requestId)
const session = sessions.value.find((x) => x.requestId === requestId)
if (session) session.status = s
return s
}
function updateWorkspace(requestId: string, ws: Workspace) {
const session = sessions.value.find((x) => x.requestId === requestId)
if (session) session.workspace = ws
}
function setCurrent(id: string | null) {
currentId.value = id
}
return { sessions, currentId, current, sendQuery, pollStatus, waitForDone, updateWorkspace, setCurrent }
})
+296
View File
@@ -0,0 +1,296 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#app {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+117
View File
@@ -0,0 +1,117 @@
// ── 协作任务状态 ──────────────────────────────────────────────────────────────
/** POST /chat 立即返回 */
export interface ChatInitResponse {
request_id: string
status: 'pending'
}
/** GET /runs/{id}/status 返回 */
export interface RunStatus {
request_id: string
status: 'pending' | 'running' | 'done' | 'failed' // JobStore 任务状态
ws_state: string | null // workspace.json 内 state
started_at: number
finished_at: number
error: string | null
// PipelineResult 等效字段(扁平时 TaskInfo
response: string | null
pipeline_status: string | null // done | fast_path | escalated | failed
fast_path: boolean
rounds_used: number
api_input_tokens: number
api_output_tokens: number
cost_est: number
model_used: string | null
latency_ms: number | null
route: string[]
workspace_path: string | null
}
// ── SSE 事件 ─────────────────────────────────────────────────────────────────
export type SSEEvent =
| { type: 'workspace'; version: number; state: string; request_id: string; workspace: Workspace }
| { type: 'status'; value: string; request_id: string }
| { type: 'error'; detail: string }
// ── 交流文本(workspace.json)────────────────────────────────────────────────
export interface Workspace {
request_id: string
query: string
brief: Brief | null
plan: PlanStep[]
progress: ProgressStep[]
issues: Issue[]
decisions: Decision[]
archive: string[]
meta: WsMeta
status?: string
error?: string
}
export interface Brief {
goal: string
tags: string[]
domain: string
constraints: string[]
}
export interface PlanStep {
id: string
task: string
deps: string[]
status?: string
}
export interface ProgressStep {
step: string
status: 'pending' | 'running' | 'done'
artifact?: string
note?: string
}
export interface Issue {
id: string
step: string
description: string
suggestion?: string
}
export interface Decision {
ref: string
reply: string
patch_plan?: PlanStep[]
}
export interface WsMeta {
state: 'draft' | 'in_progress' | 'reviewing' | 'done' | 'failed'
round: number
rounds_cap: number
version?: number
api_input_tokens: number
api_output_tokens: number
}
// ── 人工检验 ─────────────────────────────────────────────────────────────────
export interface ReviewItem {
id: number
request_id: string
query: string
response: string
verdict: 'pending' | 'approved' | 'rejected'
tags: string[]
created_at: string
correction?: string
}
// ── 指标 ─────────────────────────────────────────────────────────────────────
export interface Metrics {
router: Record<string, unknown>
cache: Record<string, unknown>
v2?: Record<string, unknown>
review?: { pending: number; total: number }
}
+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