Files
projectAIpopular/webapp/src/views/ChatView.vue
T
tzt 943eecc5ef feat(v3): 配色体系对齐 deepseek-harness Web(D7 视觉)
- 提取 dsh design-platform 官方色板为设计令牌:品牌蓝 #4176e6、bluish 灰阶、
  语义状态色、透明描边(rgba 0.04-0.1)
- 侧栏改近白(sidebar-fill),扁平化阴影,悬停/滚动条/缓动全部对齐 dsh
- 六视图旧硬编码蓝批量令牌化,视觉单一来源
2026-09-01 21:48:05 +08:00

339 lines
8.8 KiB
Vue
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.
<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: var(--c-bg);
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: var(--c-primary-tint); }
.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: var(--c-primary); }
.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: var(--c-primary-soft); }
.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: var(--c-primary-tint); color: var(--c-primary); }
.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: var(--c-primary); }
.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: var(--c-primary); }
.input-bar button {
padding: 10px 20px;
background: var(--c-primary);
color: #fff;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
.input-bar button:disabled { background: #9ca3af; cursor: not-allowed; }
</style>