main: 改进 ChatView 页面功能并优化用户体验

- 重构会话详情加载逻辑,移除 mock 数据,改为动态获取
- 新增归档操作及其后端接口接入
- 增加 SSE 支持,实现消息实时更新
- 优化消息发送流程,添加错误提示和状态管理
- 改善界面样式,调整消息气泡布局与滚动交互
- 增加骨架屏和空状态提示,提升加载体验
This commit is contained in:
2025-12-14 21:58:32 +08:00
parent f435e6d571
commit 98fbc46021

View File

@@ -1,112 +1,112 @@
<script setup> <script setup>
import { computed, reactive, ref, watch } from 'vue' import {
import { useRoute } from 'vue-router' computed,
reactive,
ref,
watch,
nextTick,
onMounted,
onBeforeUnmount,
} from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import axios from 'axios'
const route = useRoute() const route = useRoute()
const inputText = ref('我们继续讨论:请总结当前进展,并生成可执行的下一步。') const router = useRouter()
const apiBase = import.meta.env.VITE_API_BASE || 'http://localhost:8000/api'
const inputText = ref('')
const uploadFiles = ref([]) const uploadFiles = ref([])
const fileList = ref([]) const fileList = ref([])
const sending = ref(false)
const loadingMessages = ref(false)
const errorMessage = ref('')
const showJumpDown = ref(false)
const lastSeq = ref(0)
const eventSource = ref(null)
const sseRetryTimer = ref(null)
const archiveLoading = ref(false)
const sampleImage = const sampleImage =
'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="220" height="140" viewBox="0 0 220 140"><defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%"><stop stop-color="%23e0e9ff" offset="0%"/><stop stop-color="%23c7d8ff" offset="50%"/><stop stop-color="%23e8f1ff" offset="100%"/></linearGradient></defs><rect width="220" height="140" rx="12" fill="url(%23g)"/><text x="50%" y="52%" dominant-baseline="middle" text-anchor="middle" fill="%231d4ed8" font-family="Arial" font-size="16">mock image</text></svg>' 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="220" height="140" viewBox="0 0 220 140"><defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%"><stop stop-color="%23e0e9ff" offset="0%"/><stop stop-color="%23c7d8ff" offset="50%"/><stop stop-color="%23e8f1ff" offset="100%"/></linearGradient></defs><rect width="220" height="140" rx="12" fill="url(%23g)"/><text x="50%" y="52%" dominant-baseline="middle" text-anchor="middle" fill="%231d4ed8" font-family="Arial" font-size="16">mock image</text></svg>'
const mockSessions = reactive({ const sessionId = ref(
s1: { route.params.id
summary: { ? String(route.params.id)
title: '智能客服体验调优', : localStorage.getItem('ars-current-session') || ''
status: 'OPEN',
updatedAt: '2025-02-14 10:26',
lastSeq: 18,
tags: ['产品设计', 'LLM', '多模态'],
},
messages: [
{
id: 'm1',
role: 'user',
author: '你',
time: '10:05',
text: '我们需要让欢迎页更有 CRT 科幻感,同时保持 Apple 风格的简洁。',
attachments: [
{ name: 'welcome-draft.pdf', type: 'file', size: '320KB' },
],
},
{
id: 'm2',
role: 'agent',
author: 'ARS',
time: '10:08',
text: '已收到。建议1) 使用玻璃拟态背景2) 增加呼吸动画3) 采用柔和蓝绿渐变。',
attachments: [],
},
{
id: 'm3',
role: 'user',
author: '你',
time: '10:12',
text: '这里有一张示意图,请参考。并补充一个简短的按钮文案。',
attachments: [
{
name: 'wireframe.png',
type: 'image',
size: '640KB',
url: sampleImage,
},
],
},
{
id: 'm4',
role: 'agent',
author: 'ARS',
time: '10:15',
text: '好的,按钮文案可以用 “开始体验”。并将插画置于右侧,保留留白。',
attachments: [],
},
],
},
s2: {
summary: {
title: '用户反馈整理',
status: 'LOCKED',
updatedAt: '2025-02-13 17:20',
lastSeq: 9,
tags: ['反馈', '数据整理'],
},
messages: [
{
id: 'm1',
role: 'user',
author: '你',
time: '17:00',
text: '请汇总最近 30 条用户反馈,并标记优先级。',
attachments: [{ name: 'feedback.csv', type: 'file', size: '80KB' }],
},
{
id: 'm2',
role: 'agent',
author: 'ARS',
time: '17:12',
text: '已完成:共 30 条,高优先级 6 条,中 12 条,低 12 条,已按主题聚合。',
attachments: [],
},
],
},
})
const sessionId = computed(() => (route.params.id ? String(route.params.id) : 's1'))
const sessionData = ref(mockSessions[sessionId.value] || mockSessions.s1)
const applySession = () => {
sessionData.value = mockSessions[sessionId.value] || mockSessions.s1
}
watch(
() => route.params.id,
() => applySession(),
{ immediate: true }
) )
const summary = computed(() => sessionData.value?.summary || {}) const summary = reactive({
const messages = computed(() => sessionData.value?.messages || []) title: '未命名会话',
status: 'OPEN',
updatedAt: '--',
lastSeq: '--',
tags: [],
})
const messagesState = ref([])
const normalizeTime = (val) =>
val
? new Date(val).toLocaleTimeString('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
})
: '--:--'
const normalizeMessage = (msg = {}) => {
const seq = msg.seq ?? lastSeq.value ?? 0
return {
id: msg.message_id || `msg-${seq || Math.random()}`,
seq,
role: msg.role?.toLowerCase() === 'user' ? 'user' : 'agent',
author: msg.role?.toUpperCase() === 'USER' ? '你' : 'ARS',
time: normalizeTime(msg.created_at),
text: msg.content || '(空内容)',
attachments:
msg.payload?.attachments?.map((file) => ({
name: file.name || '附件',
type: file.type || 'file',
size: file.size || '',
url: file.url || sampleImage,
})) || [],
}
}
const upsertMessage = (msg) => {
const list = messagesState.value
const idx = list.findIndex(
(m) => m.id === msg.id || (msg.seq && m.seq === msg.seq)
)
if (idx >= 0) {
const next = [...list]
next[idx] = msg
messagesState.value = next
} else {
messagesState.value = [...list, msg]
}
lastSeq.value = Math.max(lastSeq.value || 0, msg.seq || 0)
summary.lastSeq = lastSeq.value
summary.updatedAt =
msg.time ||
summary.updatedAt ||
new Date().toLocaleString('zh-CN', {
hour12: false,
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
const normalizedSummary = (data = {}) => ({
title: data.session_name || '未命名会话',
status: data.status || 'OPEN',
updatedAt: data.updated_at?.slice(0, 16) || '--',
lastSeq: data.last_seq ?? '--',
tags: data.last_message_type ? [data.last_message_type] : [],
})
const messages = computed(() => messagesState.value || [])
const handleFileChange = (uploadFile, uploadFilesList) => { const handleFileChange = (uploadFile, uploadFilesList) => {
fileList.value = uploadFilesList fileList.value = uploadFilesList
@@ -143,8 +143,303 @@ const handleFileRemove = (file, list) => {
} }
const handleSend = () => { const handleSend = () => {
ElMessage.info('仅做 UI 演示,尚未接入发送接口') if (sending.value) return
if (summary.status === 'CLOSED') {
ElMessage.warning('会话已归档,无法发送')
return
} }
if (!sessionId.value) {
ElMessage.warning('请先在左侧选择或新建会话')
return
}
const token = localStorage.getItem('ars-token') || ''
if (!token) {
ElMessage.warning('请先登录')
router.replace({ name: 'login' })
return
}
if (!inputText.value.trim() && !uploadFiles.value.length) {
ElMessage.warning('请输入内容或选择附件')
return
}
const payload =
uploadFiles.value.length > 0
? {
attachments: uploadFiles.value.map((f) => ({
name: f.name,
type: f.type,
size: f.size,
url: f.url,
})),
}
: null
const body = {
role: 'USER',
type: 'user.prompt',
content: inputText.value.trim(),
payload,
dedupe_key: `ui-${Date.now()}`,
}
sending.value = true
axios
.post(`${apiBase}/sessions/${sessionId.value}/messages`, body, {
headers: { Authorization: `Bearer ${token}` },
})
.then((resp) => {
const data = resp.data?.data || resp.data || {}
const now = new Date()
const nextMsg = normalizeMessage({
...data,
role: 'USER',
content: inputText.value.trim(),
payload:
uploadFiles.value.length > 0
? { attachments: uploadFiles.value }
: null,
created_at:
data.created_at ||
now.toISOString().slice(0, 19).replace('T', ' '),
})
upsertMessage(nextMsg)
inputText.value = ''
uploadFiles.value = []
fileList.value = []
nextTick(() => scrollToBottom())
ElMessage.success('已发送')
})
.catch((err) => {
const message =
err.response?.data?.message ||
err.message ||
'发送失败,请稍后再试'
ElMessage.error(message)
if (err.response?.status === 401) router.replace({ name: 'login' })
})
.finally(() => {
sending.value = false
})
}
const handleArchive = async () => {
if (!sessionId.value) return
const token = localStorage.getItem('ars-token') || ''
if (!token) {
router.replace({ name: 'login' })
return
}
archiveLoading.value = true
try {
const { data } = await axios.post(
`${apiBase}/sessions/${sessionId.value}/archive`,
{},
{ headers: { Authorization: `Bearer ${token}` } }
)
const info = data.data || data
Object.assign(summary, normalizedSummary(info))
lastSeq.value = info.last_seq || lastSeq.value
ElMessage.success('会话已归档')
} catch (err) {
const message =
err.response?.data?.message || '归档失败,请稍后再试'
ElMessage.error(message)
if (err.response?.status === 401) router.replace({ name: 'login' })
} finally {
archiveLoading.value = false
}
}
const fetchSummary = async () => {
const token = localStorage.getItem('ars-token') || ''
if (!token) {
router.replace({ name: 'login' })
return
}
if (!sessionId.value) return
try {
const { data } = await axios.get(`${apiBase}/sessions/${sessionId.value}`, {
headers: { Authorization: `Bearer ${token}` },
})
const info = data.data || data
Object.assign(summary, normalizedSummary(info))
if (info.last_seq) {
lastSeq.value = info.last_seq
}
} catch (err) {
// 后端可能不支持单条查询,容忍 404
if (err.response?.status === 401) router.replace({ name: 'login' })
}
}
const fetchMessages = async () => {
const token = localStorage.getItem('ars-token') || ''
if (!token) {
router.replace({ name: 'login' })
return
}
if (!sessionId.value) return
loadingMessages.value = true
errorMessage.value = ''
try {
const { data } = await axios.get(
`${apiBase}/sessions/${sessionId.value}/messages`,
{
params: { after_seq: 0, limit: 200 },
headers: { Authorization: `Bearer ${token}` },
}
)
const list = data.data || []
messagesState.value = list.map((msg) => normalizeMessage(msg))
if (list.length) {
const last = list[list.length - 1]
lastSeq.value = last.seq || 0
Object.assign(summary, {
title: summary.title || '未命名会话',
status: last.status || summary.status,
lastSeq: last.seq ?? summary.lastSeq,
updatedAt:
last.created_at?.slice(0, 16) ||
summary.updatedAt ||
new Date().toLocaleString('zh-CN', {
hour12: false,
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}),
})
}
} catch (err) {
const message =
err.response?.data?.message || '获取消息失败,请稍后再试'
errorMessage.value = message
ElMessage.error(message)
if (err.response?.status === 401) router.replace({ name: 'login' })
} finally {
loadingMessages.value = false
}
}
const fetchSingleMessage = async (messageId) => {
const token = localStorage.getItem('ars-token') || ''
if (!token || !sessionId.value || !messageId) return
try {
const { data } = await axios.get(
`${apiBase}/sessions/${sessionId.value}/messages/${messageId}`,
{ headers: { Authorization: `Bearer ${token}` } }
)
const detail = data.data || data
const normalized = normalizeMessage(detail)
upsertMessage(normalized)
} catch (err) {
if (err.response?.status === 401) router.replace({ name: 'login' })
}
}
const scrollToBottom = () => {
window.scrollTo({
top:
document.documentElement.scrollHeight ||
document.body.scrollHeight ||
0,
behavior: 'smooth',
})
}
const handleWindowScroll = () => {
const doc = document.documentElement
const top = window.scrollY || doc.scrollTop || 0
const distance = doc.scrollHeight - top - window.innerHeight
showJumpDown.value = distance > 160
}
const stopSse = () => {
if (sseRetryTimer.value) {
clearTimeout(sseRetryTimer.value)
sseRetryTimer.value = null
}
if (eventSource.value) {
eventSource.value.close()
eventSource.value = null
}
}
const startSse = () => {
if (typeof window === 'undefined' || typeof EventSource === 'undefined')
return
if (!sessionId.value) return
const token = localStorage.getItem('ars-token') || ''
if (!token) {
router.replace({ name: 'login' })
return
}
stopSse()
const url = new URL(`${apiBase}/sessions/${sessionId.value}/sse`)
url.searchParams.set('after_seq', lastSeq.value || 0)
url.searchParams.set('limit', 200)
url.searchParams.set('token', token)
const es = new EventSource(url.toString())
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data || '{}')
if (!data) return
if (!data.content && data.message_id) {
fetchSingleMessage(data.message_id)
return
}
const normalized = normalizeMessage(data)
upsertMessage(normalized)
nextTick(() => scrollToBottom())
} catch (e) {
// ignore parse error
}
}
es.onerror = () => {
stopSse()
sseRetryTimer.value = window.setTimeout(() => startSse(), 3000)
}
eventSource.value = es
}
const applySession = () => {
if (!sessionId.value) return
stopSse()
lastSeq.value = 0
localStorage.setItem('ars-current-session', sessionId.value)
fetchSummary()
fetchMessages().then(() => {
nextTick(() => {
scrollToBottom()
startSse()
})
})
}
watch(
() => route.params.id,
(val) => {
sessionId.value =
val !== undefined
? String(val)
: localStorage.getItem('ars-current-session') || ''
applySession()
},
{ immediate: true }
)
onMounted(() => {
window.addEventListener('scroll', handleWindowScroll, { passive: true })
nextTick(() => handleWindowScroll())
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', handleWindowScroll)
stopSse()
})
</script> </script>
<template> <template>
@@ -179,11 +474,34 @@ const handleSend = () => {
<p class="stat-label">草稿字数</p> <p class="stat-label">草稿字数</p>
<p class="stat-value">{{ inputText.length }}</p> <p class="stat-value">{{ inputText.length }}</p>
</div> </div>
<el-button
size="small"
:loading="archiveLoading"
:disabled="summary.status === 'CLOSED'"
@click="handleArchive"
>
{{ summary.status === 'CLOSED' ? '已归档' : '归档会话' }}
</el-button>
</div> </div>
</section> </section>
<section class="chat-window"> <section class="chat-window">
<div class="history"> <div class="history">
<el-skeleton v-if="loadingMessages" animated :rows="4" class="skeleton" />
<el-alert
v-else-if="errorMessage"
:title="errorMessage"
type="error"
show-icon
:closable="false"
class="history-error"
/>
<el-empty
v-else-if="!messages.length"
description="暂无消息"
:image-size="100"
/>
<template v-else>
<div <div
v-for="msg in messages" v-for="msg in messages"
:key="msg.id" :key="msg.id"
@@ -193,6 +511,22 @@ const handleSend = () => {
<div class="bubble-head"> <div class="bubble-head">
<span class="author">{{ msg.author }}</span> <span class="author">{{ msg.author }}</span>
<span class="time">{{ msg.time }}</span> <span class="time">{{ msg.time }}</span>
<el-tag
v-if="msg.role === 'agent'"
size="small"
type="info"
effect="plain"
>
ARS 回复
</el-tag>
<el-tag
v-else
size="small"
type="primary"
effect="plain"
>
提示词
</el-tag>
</div> </div>
<p class="bubble-text">{{ msg.text }}</p> <p class="bubble-text">{{ msg.text }}</p>
<div v-if="msg.attachments?.length" class="attachment-list"> <div v-if="msg.attachments?.length" class="attachment-list">
@@ -214,8 +548,18 @@ const handleSend = () => {
</div> </div>
</div> </div>
</div> </div>
</template>
</div> </div>
<div class="composer-dock">
<button
v-if="showJumpDown"
type="button"
class="jump-down"
@click="scrollToBottom"
>
回到底部
</button>
<div class="composer"> <div class="composer">
<el-upload <el-upload
class="upload" class="upload"
@@ -234,14 +578,22 @@ const handleSend = () => {
<el-input <el-input
v-model="inputText" v-model="inputText"
type="textarea" type="textarea"
:autosize="{ minRows: 2, maxRows: 6 }" :autosize="{ minRows: 3, maxRows: 8 }"
class="composer-input" class="composer-input"
placeholder="输入消息,支持粘贴大段文本与附件..." :placeholder="summary.status === 'CLOSED' ? '会话已归档,无法继续发送' : '输入消息,支持粘贴大段文本与附件...'"
:disabled="summary.status === 'CLOSED'"
/> />
<el-button color="#1d4ed8" class="send-btn" @click="handleSend"> <el-button
color="#1d4ed8"
class="send-btn"
:loading="sending"
:disabled="summary.status === 'CLOSED'"
@click="handleSend"
>
发送 发送
</el-button> </el-button>
</div> </div>
</div>
<div v-if="uploadFiles.length" class="pending-attachments"> <div v-if="uploadFiles.length" class="pending-attachments">
<p class="pending-title">待发送附件</p> <p class="pending-title">待发送附件</p>
@@ -373,52 +725,82 @@ const handleSend = () => {
background: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(0, 0, 0, 0.04); border: 1px solid rgba(0, 0, 0, 0.04);
box-shadow: 0 18px 32px rgba(17, 24, 39, 0.08); box-shadow: 0 18px 32px rgba(17, 24, 39, 0.08);
align-items: center;
} }
.history { .history {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
max-height: 48vh; width: min(980px, 100%);
overflow: auto; padding: 0 12px;
padding-right: 8px; margin: 0 auto;
scrollbar-width: thin;
scrollbar-color: rgba(37, 99, 235, 0.25) rgba(255, 255, 255, 0.8);
} }
.history::-webkit-scrollbar { .skeleton {
width: 8px; padding: 12px 8px;
} }
.history::-webkit-scrollbar-thumb { .history-error {
background: rgba(37, 99, 235, 0.3); margin-bottom: 8px;
border-radius: 10px; }
.composer-dock {
position: sticky;
bottom: 0;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 8px 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.8));
}
.jump-down {
border: 1px solid rgba(37, 99, 235, 0.2);
background: rgba(255, 255, 255, 0.92);
color: #1d4ed8;
border-radius: 999px;
padding: 6px 12px;
box-shadow: 0 10px 20px rgba(17, 24, 39, 0.08);
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.jump-down:hover {
transform: translateY(-1px);
box-shadow: 0 14px 28px rgba(17, 24, 39, 0.12);
} }
.bubble { .bubble {
padding: 12px 14px; padding: 14px 16px;
border-radius: 14px; border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.05); border: 1px solid rgba(0, 0, 0, 0.05);
max-width: 760px; max-width: 820px;
box-shadow: 0 10px 20px rgba(17, 24, 39, 0.06); box-shadow: 0 10px 20px rgba(17, 24, 39, 0.06);
width: calc(100% - 48px);
align-self: center;
background: #ffffff;
word-break: break-word;
white-space: pre-wrap;
position: relative;
} }
.bubble-user { .bubble-user {
align-self: flex-end;
background: linear-gradient(135deg, #e7f0ff, #dfe9ff);
border-color: rgba(37, 99, 235, 0.18); border-color: rgba(37, 99, 235, 0.18);
background: linear-gradient(135deg, #edf2ff, #e5edff);
} }
.bubble-agent { .bubble-agent {
align-self: flex-start; border-color: rgba(0, 0, 0, 0.04);
background: #ffffff;
} }
.bubble-head { .bubble-head {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
margin-bottom: 4px; margin-bottom: 6px;
} }
.author { .author {
@@ -432,9 +814,10 @@ const handleSend = () => {
} }
.bubble-text { .bubble-text {
margin: 0 0 6px; margin: 0 0 8px;
color: #111827; color: #111827;
line-height: 1.6; line-height: 1.7;
font-size: 15px;
} }
.attachment-list { .attachment-list {
@@ -451,6 +834,7 @@ const handleSend = () => {
padding: 8px 10px; padding: 8px 10px;
border-radius: 10px; border-radius: 10px;
background: rgba(15, 23, 42, 0.03); background: rgba(15, 23, 42, 0.03);
word-break: break-word;
} }
.attachment.file { .attachment.file {
@@ -484,10 +868,12 @@ const handleSend = () => {
grid-template-columns: auto 1fr auto; grid-template-columns: auto 1fr auto;
gap: 10px; gap: 10px;
align-items: flex-start; align-items: flex-start;
padding: 12px; padding: 14px;
border-radius: 14px; border-radius: 14px;
background: #f7f9fc; background: #f7f9fc;
border: 1px solid rgba(0, 0, 0, 0.04); border: 1px solid rgba(0, 0, 0, 0.04);
width: min(980px, 100%);
box-shadow: 0 12px 30px rgba(17, 24, 39, 0.12);
} }
.add-btn { .add-btn {
@@ -513,6 +899,7 @@ const handleSend = () => {
border-radius: 12px; border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.06); border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
min-height: 110px;
} }
.send-btn { .send-btn {
@@ -592,6 +979,7 @@ const handleSend = () => {
.composer { .composer {
grid-template-columns: 1fr; grid-template-columns: 1fr;
width: 100%;
} }
.add-btn { .add-btn {