main: 改进 ChatView 页面功能并优化用户体验
- 重构会话详情加载逻辑,移除 mock 数据,改为动态获取 - 新增归档操作及其后端接口接入 - 增加 SSE 支持,实现消息实时更新 - 优化消息发送流程,添加错误提示和状态管理 - 改善界面样式,调整消息气泡布局与滚动交互 - 增加骨架屏和空状态提示,提升加载体验
This commit is contained in:
@@ -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,68 +474,125 @@ 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">
|
||||||
<div
|
<el-skeleton v-if="loadingMessages" animated :rows="4" class="skeleton" />
|
||||||
v-for="msg in messages"
|
<el-alert
|
||||||
:key="msg.id"
|
v-else-if="errorMessage"
|
||||||
class="bubble"
|
:title="errorMessage"
|
||||||
:class="msg.role === 'user' ? 'bubble-user' : 'bubble-agent'"
|
type="error"
|
||||||
>
|
show-icon
|
||||||
<div class="bubble-head">
|
:closable="false"
|
||||||
<span class="author">{{ msg.author }}</span>
|
class="history-error"
|
||||||
<span class="time">{{ msg.time }}</span>
|
/>
|
||||||
</div>
|
<el-empty
|
||||||
<p class="bubble-text">{{ msg.text }}</p>
|
v-else-if="!messages.length"
|
||||||
<div v-if="msg.attachments?.length" class="attachment-list">
|
description="暂无消息"
|
||||||
<div
|
:image-size="100"
|
||||||
v-for="file in msg.attachments"
|
/>
|
||||||
:key="file.name"
|
<template v-else>
|
||||||
class="attachment"
|
<div
|
||||||
:class="file.type"
|
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>
|
||||||
|
<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>
|
||||||
|
<p class="bubble-text">{{ msg.text }}</p>
|
||||||
|
<div v-if="msg.attachments?.length" class="attachment-list">
|
||||||
<div
|
<div
|
||||||
v-if="file.type === 'image'"
|
v-for="file in msg.attachments"
|
||||||
class="attachment-thumb"
|
:key="file.name"
|
||||||
:style="{ backgroundImage: file.url ? `url(${file.url})` : undefined }"
|
class="attachment"
|
||||||
/>
|
:class="file.type"
|
||||||
<div class="attachment-meta">
|
>
|
||||||
<span class="name">{{ file.name }}</span>
|
<div
|
||||||
<span class="size">{{ file.size || '图像' }}</span>
|
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>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="composer">
|
<div class="composer-dock">
|
||||||
<el-upload
|
<button
|
||||||
class="upload"
|
v-if="showJumpDown"
|
||||||
multiple
|
type="button"
|
||||||
accept="image/*,.pdf,.txt,.doc,.docx,.md"
|
class="jump-down"
|
||||||
:auto-upload="false"
|
@click="scrollToBottom"
|
||||||
:show-file-list="false"
|
|
||||||
:file-list="fileList"
|
|
||||||
:on-change="handleFileChange"
|
|
||||||
:on-remove="handleFileRemove"
|
|
||||||
>
|
>
|
||||||
<button type="button" class="add-btn" aria-label="上传文件">
|
↓ 回到底部
|
||||||
+
|
</button>
|
||||||
</button>
|
<div class="composer">
|
||||||
</el-upload>
|
<el-upload
|
||||||
<el-input
|
class="upload"
|
||||||
v-model="inputText"
|
multiple
|
||||||
type="textarea"
|
accept="image/*,.pdf,.txt,.doc,.docx,.md"
|
||||||
:autosize="{ minRows: 2, maxRows: 6 }"
|
:auto-upload="false"
|
||||||
class="composer-input"
|
:show-file-list="false"
|
||||||
placeholder="输入消息,支持粘贴大段文本与附件..."
|
:file-list="fileList"
|
||||||
/>
|
:on-change="handleFileChange"
|
||||||
<el-button color="#1d4ed8" class="send-btn" @click="handleSend">
|
:on-remove="handleFileRemove"
|
||||||
发送
|
>
|
||||||
</el-button>
|
<button type="button" class="add-btn" aria-label="上传文件">
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</el-upload>
|
||||||
|
<el-input
|
||||||
|
v-model="inputText"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 8 }"
|
||||||
|
class="composer-input"
|
||||||
|
:placeholder="summary.status === 'CLOSED' ? '会话已归档,无法继续发送' : '输入消息,支持粘贴大段文本与附件...'"
|
||||||
|
:disabled="summary.status === 'CLOSED'"
|
||||||
|
/>
|
||||||
|
<el-button
|
||||||
|
color="#1d4ed8"
|
||||||
|
class="send-btn"
|
||||||
|
:loading="sending"
|
||||||
|
:disabled="summary.status === 'CLOSED'"
|
||||||
|
@click="handleSend"
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="uploadFiles.length" class="pending-attachments">
|
<div v-if="uploadFiles.length" class="pending-attachments">
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user