master: 添加会话模块及界面改进

- 新增 ChatView 页面及其逻辑设计
- 修改 App.vue 以支持动态会话标题
- 调整 Sidebar 为固定高度及改进滚动样式
- 优化会话分页,添加 lastPage 逻辑
- 在 SessionSidebar 中实现刷新操作和路由同步
- 更新路由配置,添加 chat 相关路径
- 添加单元测试以覆盖新逻辑
This commit is contained in:
2025-12-14 20:22:06 +08:00
parent 0959754d1e
commit b107a5e680
5 changed files with 781 additions and 51 deletions

605
src/views/ChatView.vue Normal file
View File

@@ -0,0 +1,605 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
const route = useRoute()
const inputText = ref('我们继续讨论:请总结当前进展,并生成可执行的下一步。')
const uploadFiles = ref([])
const fileList = ref([])
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>'
const mockSessions = reactive({
s1: {
summary: {
title: '智能客服体验调优',
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 messages = computed(() => sessionData.value?.messages || [])
const handleFileChange = (uploadFile, uploadFilesList) => {
fileList.value = uploadFilesList
uploadFiles.value = uploadFilesList.map((file) => ({
uid: file.uid,
name: file.name,
type: file.raw?.type?.startsWith('image/') ? 'image' : 'file',
size: file.size ? `${Math.ceil(file.size / 1024)}KB` : '',
url: file.url || (file.raw ? URL.createObjectURL(file.raw) : sampleImage),
}))
}
const handleFileRemove = (file, list) => {
const toAttachment = (item) => ({
uid: item.uid,
name: item.name,
type:
item.raw?.type?.startsWith('image/') || item.type?.startsWith('image/')
? 'image'
: 'file',
size: item.size ? `${Math.ceil(item.size / 1024)}KB` : '',
url: item.url || (item.raw ? URL.createObjectURL(item.raw) : sampleImage),
})
if (Array.isArray(list)) {
fileList.value = list
uploadFiles.value = list.map(toAttachment)
return
}
const filtered = fileList.value.filter((item) => item.uid !== file.uid)
fileList.value = filtered
uploadFiles.value = filtered.map(toAttachment)
}
const handleSend = () => {
ElMessage.info('仅做 UI 演示,尚未接入发送接口')
}
</script>
<template>
<div class="chat-shell">
<section class="session-hero">
<div class="hero-left">
<p class="eyebrow">会话详情</p>
<h2>{{ summary.title || '未命名会话' }}</h2>
<div class="hero-meta">
<el-tag
:type="summary.status === 'OPEN' ? 'success' : summary.status === 'LOCKED' ? 'warning' : 'info'"
effect="plain"
size="small"
>
{{ summary.status || 'UNKNOWN' }}
</el-tag>
<span class="muted">最近更新 · {{ summary.updatedAt || '--' }}</span>
<span class="muted">last_seq · {{ summary.lastSeq ?? '--' }}</span>
</div>
<div class="chips">
<span v-for="tag in summary.tags || []" :key="tag" class="chip">
{{ tag }}
</span>
</div>
</div>
<div class="hero-right">
<div class="stat">
<p class="stat-label">历史消息</p>
<p class="stat-value">{{ messages.length }}</p>
</div>
<div class="stat">
<p class="stat-label">草稿字数</p>
<p class="stat-value">{{ inputText.length }}</p>
</div>
</div>
</section>
<section class="chat-window">
<div class="history">
<div
v-for="msg in messages"
:key="msg.id"
class="bubble"
:class="msg.role === 'user' ? 'bubble-user' : 'bubble-agent'"
>
<div class="bubble-head">
<span class="author">{{ msg.author }}</span>
<span class="time">{{ msg.time }}</span>
</div>
<p class="bubble-text">{{ msg.text }}</p>
<div v-if="msg.attachments?.length" class="attachment-list">
<div
v-for="file in msg.attachments"
:key="file.name"
class="attachment"
:class="file.type"
>
<div
v-if="file.type === 'image'"
class="attachment-thumb"
:style="{ backgroundImage: file.url ? `url(${file.url})` : undefined }"
/>
<div class="attachment-meta">
<span class="name">{{ file.name }}</span>
<span class="size">{{ file.size || '图像' }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="composer">
<el-upload
class="upload"
multiple
accept="image/*,.pdf,.txt,.doc,.docx,.md"
:auto-upload="false"
:show-file-list="false"
:file-list="fileList"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
>
<button type="button" class="add-btn" aria-label="上传文件">
+
</button>
</el-upload>
<el-input
v-model="inputText"
type="textarea"
:autosize="{ minRows: 2, maxRows: 6 }"
class="composer-input"
placeholder="输入消息,支持粘贴大段文本与附件..."
/>
<el-button color="#1d4ed8" class="send-btn" @click="handleSend">
发送
</el-button>
</div>
<div v-if="uploadFiles.length" class="pending-attachments">
<p class="pending-title">待发送附件</p>
<div class="pending-list">
<div
v-for="file in uploadFiles"
:key="file.uid"
class="pending-chip"
>
<div
v-if="file.type === 'image'"
class="pending-thumb"
:style="{ backgroundImage: file.url ? `url(${file.url})` : undefined }"
/>
<div class="pending-meta">
<span class="name">{{ file.name }}</span>
<span class="size">{{ file.size || '文件' }}</span>
</div>
<button
type="button"
class="remove"
aria-label="移除附件"
@click="handleFileRemove(file)"
>
×
</button>
</div>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.chat-shell {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 80vh;
}
.session-hero {
display: flex;
justify-content: space-between;
gap: 18px;
padding: 18px 20px;
border-radius: 16px;
background: linear-gradient(135deg, #f5f8ff, #edf2ff);
border: 1px solid rgba(0, 0, 0, 0.04);
box-shadow: 0 18px 38px rgba(17, 24, 39, 0.08);
}
.hero-left h2 {
margin: 6px 0;
color: #0f172a;
font-size: 24px;
}
.eyebrow {
margin: 0;
font-size: 13px;
letter-spacing: 0.08em;
color: #6b7280;
text-transform: uppercase;
}
.hero-meta {
display: flex;
align-items: center;
gap: 10px;
color: #6b7280;
font-size: 13px;
}
.muted {
color: #6b7280;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.chip {
padding: 6px 10px;
border-radius: 10px;
background: rgba(37, 99, 235, 0.08);
color: #1d4ed8;
font-size: 12px;
letter-spacing: 0.01em;
}
.hero-right {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
min-width: 260px;
}
.stat {
padding: 12px 14px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(0, 0, 0, 0.04);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.stat-label {
margin: 0 0 4px;
color: #6b7280;
font-size: 13px;
}
.stat-value {
margin: 0;
font-size: 20px;
font-weight: 700;
color: #111827;
}
.chat-window {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(0, 0, 0, 0.04);
box-shadow: 0 18px 32px rgba(17, 24, 39, 0.08);
}
.history {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 48vh;
overflow: auto;
padding-right: 8px;
scrollbar-width: thin;
scrollbar-color: rgba(37, 99, 235, 0.25) rgba(255, 255, 255, 0.8);
}
.history::-webkit-scrollbar {
width: 8px;
}
.history::-webkit-scrollbar-thumb {
background: rgba(37, 99, 235, 0.3);
border-radius: 10px;
}
.bubble {
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(0, 0, 0, 0.05);
max-width: 760px;
box-shadow: 0 10px 20px rgba(17, 24, 39, 0.06);
}
.bubble-user {
align-self: flex-end;
background: linear-gradient(135deg, #e7f0ff, #dfe9ff);
border-color: rgba(37, 99, 235, 0.18);
}
.bubble-agent {
align-self: flex-start;
background: #ffffff;
}
.bubble-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.author {
font-weight: 700;
color: #0f172a;
}
.time {
font-size: 12px;
color: #9ca3af;
}
.bubble-text {
margin: 0 0 6px;
color: #111827;
line-height: 1.6;
}
.attachment-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.attachment {
display: grid;
grid-template-columns: 52px 1fr;
gap: 10px;
align-items: center;
padding: 8px 10px;
border-radius: 10px;
background: rgba(15, 23, 42, 0.03);
}
.attachment.file {
grid-template-columns: 1fr;
}
.attachment-thumb {
width: 52px;
height: 52px;
border-radius: 12px;
background-size: cover;
background-position: center;
background-image: linear-gradient(135deg, #e5edff, #dbeafe);
border: 1px solid rgba(0, 0, 0, 0.04);
}
.attachment-meta .name {
display: block;
font-weight: 600;
color: #0f172a;
}
.attachment-meta .size {
display: block;
color: #6b7280;
font-size: 12px;
}
.composer {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 10px;
align-items: flex-start;
padding: 12px;
border-radius: 14px;
background: #f7f9fc;
border: 1px solid rgba(0, 0, 0, 0.04);
}
.add-btn {
width: 44px;
height: 44px;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.08);
background: #ffffff;
box-shadow: 0 8px 18px rgba(17, 24, 39, 0.08);
font-size: 24px;
color: #1d4ed8;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.add-btn:hover {
transform: translateY(-2px);
box-shadow: 0 12px 24px rgba(17, 24, 39, 0.12);
}
.composer-input :deep(.el-textarea__inner) {
background: #ffffff;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.send-btn {
height: 44px;
border-radius: 12px;
font-weight: 700;
padding: 0 18px;
}
.pending-attachments {
padding: 10px 0 0;
border-top: 1px dashed rgba(0, 0, 0, 0.08);
margin-top: 4px;
}
.pending-title {
margin: 0 0 8px;
font-size: 13px;
color: #6b7280;
}
.pending-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.pending-chip {
display: grid;
grid-template-columns: 48px 1fr 16px;
gap: 8px;
align-items: center;
padding: 8px 10px;
border-radius: 12px;
background: rgba(37, 99, 235, 0.06);
border: 1px solid rgba(37, 99, 235, 0.15);
}
.pending-thumb {
width: 48px;
height: 48px;
border-radius: 10px;
background-size: cover;
background-position: center;
background-image: linear-gradient(135deg, #e0e7ff, #c7d2fe);
}
.pending-meta .name {
display: block;
font-weight: 600;
color: #0f172a;
}
.pending-meta .size {
display: block;
font-size: 12px;
color: #4b5563;
}
.remove {
cursor: pointer;
color: #111827;
font-size: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
}
@media (max-width: 900px) {
.session-hero {
flex-direction: column;
}
.chat-window {
padding: 12px;
}
.composer {
grid-template-columns: 1fr;
}
.add-btn {
width: 100%;
}
.send-btn {
width: 100%;
}
}
</style>