master: 添加会话模块及界面改进
- 新增 ChatView 页面及其逻辑设计 - 修改 App.vue 以支持动态会话标题 - 调整 Sidebar 为固定高度及改进滚动样式 - 优化会话分页,添加 lastPage 逻辑 - 在 SessionSidebar 中实现刷新操作和路由同步 - 更新路由配置,添加 chat 相关路径 - 添加单元测试以覆盖新逻辑
This commit is contained in:
29
src/App.vue
29
src/App.vue
@@ -11,9 +11,10 @@ const menus = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const isAuthRoute = computed(() => route.name === 'login')
|
const isAuthRoute = computed(() => route.name === 'login')
|
||||||
const activeMenuLabel = computed(
|
const activeMenuLabel = computed(() => {
|
||||||
() => menus.find((m) => m.name === route.name)?.label || '控制台'
|
if (route.name === 'chat') return '会话'
|
||||||
)
|
return menus.find((m) => m.name === route.name)?.label || '控制台'
|
||||||
|
})
|
||||||
const miniMenuOpen = ref(false)
|
const miniMenuOpen = ref(false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -30,15 +31,9 @@ const miniMenuOpen = ref(false)
|
|||||||
<section class="main">
|
<section class="main">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="top-title">{{ activeMenuLabel }}</div>
|
<div class="top-title">{{ activeMenuLabel }}</div>
|
||||||
<div class="top-meta">会话列表常驻左侧,Apple 风格控制台</div>
|
|
||||||
</header>
|
|
||||||
<div class="page">
|
|
||||||
<router-view />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<el-popover
|
<el-popover
|
||||||
v-model:visible="miniMenuOpen"
|
v-model:visible="miniMenuOpen"
|
||||||
placement="top-start"
|
placement="bottom-end"
|
||||||
trigger="click"
|
trigger="click"
|
||||||
popper-class="mini-popper"
|
popper-class="mini-popper"
|
||||||
>
|
>
|
||||||
@@ -61,6 +56,11 @@ const miniMenuOpen = ref(false)
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</el-popover>
|
</el-popover>
|
||||||
|
</header>
|
||||||
|
<div class="page">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -122,19 +122,11 @@ const miniMenuOpen = ref(false)
|
|||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-meta {
|
|
||||||
color: #6b7280;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
min-height: calc(100vh - 140px);
|
min-height: calc(100vh - 140px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-btn {
|
.mini-btn {
|
||||||
position: fixed;
|
|
||||||
left: 18px;
|
|
||||||
bottom: 18px;
|
|
||||||
height: 46px;
|
height: 46px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -147,6 +139,7 @@ const miniMenuOpen = ref(false)
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||||
color: #111827;
|
color: #111827;
|
||||||
|
min-width: 104px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-btn:hover {
|
.mini-btn:hover {
|
||||||
|
|||||||
@@ -11,17 +11,16 @@ const router = useRouter()
|
|||||||
const sessions = ref([])
|
const sessions = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const canLoadMore = computed(
|
const canLoadMore = computed(() => pagination.page < pagination.lastPage)
|
||||||
() => pagination.page * pagination.perPage < pagination.total
|
|
||||||
)
|
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
q: '',
|
q: '',
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
})
|
})
|
||||||
const pagination = reactive({
|
const pagination = reactive({
|
||||||
page: 1,
|
page: 1,
|
||||||
perPage: 20,
|
perPage: 10,
|
||||||
total: 0,
|
total: 0,
|
||||||
|
lastPage: 1,
|
||||||
})
|
})
|
||||||
const currentSessionId = ref(localStorage.getItem('ars-current-session') || '')
|
const currentSessionId = ref(localStorage.getItem('ars-current-session') || '')
|
||||||
|
|
||||||
@@ -53,9 +52,19 @@ const fetchSessions = async ({ append = false } = {}) => {
|
|||||||
})
|
})
|
||||||
const list = data.data || []
|
const list = data.data || []
|
||||||
sessions.value = append ? [...sessions.value, ...list] : list
|
sessions.value = append ? [...sessions.value, ...list] : list
|
||||||
pagination.total = data.total || 0
|
const meta = data.meta || {}
|
||||||
pagination.page = data.current_page || pagination.page
|
const total = meta.total ?? data.total ?? list.length
|
||||||
pagination.perPage = data.per_page || pagination.perPage
|
const currentPage = meta.current_page ?? data.current_page ?? pagination.page
|
||||||
|
const perPage =
|
||||||
|
meta.per_page ?? data.per_page ?? pagination.perPage ?? 10
|
||||||
|
const lastPage =
|
||||||
|
meta.last_page ??
|
||||||
|
data.last_page ??
|
||||||
|
(perPage ? Math.ceil(total / perPage) || 1 : 1)
|
||||||
|
pagination.total = total
|
||||||
|
pagination.page = currentPage
|
||||||
|
pagination.perPage = perPage
|
||||||
|
pagination.lastPage = lastPage
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const status = err.response?.status
|
const status = err.response?.status
|
||||||
const message = err.response?.data?.message || '获取会话列表失败'
|
const message = err.response?.data?.message || '获取会话列表失败'
|
||||||
@@ -71,6 +80,9 @@ const selectSession = (session) => {
|
|||||||
currentSessionId.value = session.session_id
|
currentSessionId.value = session.session_id
|
||||||
localStorage.setItem('ars-current-session', session.session_id)
|
localStorage.setItem('ars-current-session', session.session_id)
|
||||||
ElMessage.success(`已切换到会话:${session.session_name || '未命名'}`)
|
ElMessage.success(`已切换到会话:${session.session_name || '未命名'}`)
|
||||||
|
if (router.currentRoute.value.params.id !== session.session_id) {
|
||||||
|
router.push({ name: 'chat', params: { id: session.session_id } })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreated = (session) => {
|
const handleCreated = (session) => {
|
||||||
@@ -83,12 +95,20 @@ const handleCreated = (session) => {
|
|||||||
}
|
}
|
||||||
currentSessionId.value = session.session_id
|
currentSessionId.value = session.session_id
|
||||||
localStorage.setItem('ars-current-session', session.session_id)
|
localStorage.setItem('ars-current-session', session.session_id)
|
||||||
|
router.push({ name: 'chat', params: { id: session.session_id } })
|
||||||
}
|
}
|
||||||
fetchSessions()
|
fetchSessions()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
pagination.page = 1
|
pagination.page = 1
|
||||||
|
pagination.lastPage = 1
|
||||||
|
fetchSessions()
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshList = () => {
|
||||||
|
pagination.page = 1
|
||||||
|
pagination.lastPage = 1
|
||||||
fetchSessions()
|
fetchSessions()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +152,8 @@ onMounted(() => {
|
|||||||
size="small"
|
size="small"
|
||||||
color="#1d4ed8"
|
color="#1d4ed8"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
@click="fetchSessions"
|
data-testid="refresh-btn"
|
||||||
|
@click="refreshList"
|
||||||
>
|
>
|
||||||
刷新
|
刷新
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -169,12 +190,13 @@ onMounted(() => {
|
|||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
<span>{{ pagination.total }} 个会话</span>
|
<span>{{ pagination.total }} 个会话</span>
|
||||||
<el-button
|
<el-button
|
||||||
size="small"
|
class="load-more"
|
||||||
text
|
:loading="loading"
|
||||||
:disabled="!canLoadMore || loading"
|
:disabled="!canLoadMore || loading"
|
||||||
@click="loadMore"
|
@click="loadMore"
|
||||||
>
|
>
|
||||||
加载更多
|
<span class="arrow">↓</span>
|
||||||
|
<span class="load-label">加载更多</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -193,6 +215,9 @@ onMounted(() => {
|
|||||||
.sidebar {
|
.sidebar {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
height: 100vh;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
background: rgba(255, 255, 255, 0.8);
|
background: rgba(255, 255, 255, 0.8);
|
||||||
border-right: 1px solid rgba(0, 0, 0, 0.06);
|
border-right: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
@@ -201,6 +226,28 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(37, 99, 235, 0.3) rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar::-webkit-scrollbar-track {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(236, 242, 255, 0.9));
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar::-webkit-scrollbar-thumb {
|
||||||
|
background: linear-gradient(180deg, rgba(37, 99, 235, 0.6), rgba(59, 130, 246, 0.5));
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: linear-gradient(180deg, rgba(37, 99, 235, 0.8), rgba(37, 99, 235, 0.6));
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-head {
|
.sidebar-head {
|
||||||
@@ -286,10 +333,10 @@ onMounted(() => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination {
|
.pagination {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
flex-direction: column;
|
||||||
align-items: center;
|
gap: 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
@@ -298,10 +345,42 @@ onMounted(() => {
|
|||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
width: 100%;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px dashed rgba(0, 0, 0, 0.1);
|
||||||
|
color: #1f2937;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more:hover {
|
||||||
|
border-color: rgba(37, 99, 235, 0.35);
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
|
height: auto;
|
||||||
|
position: static;
|
||||||
|
overflow: visible;
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
box-shadow: 0 8px 24px rgba(17, 24, 39, 0.08);
|
box-shadow: 0 8px 24px rgba(17, 24, 39, 0.08);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
import LoginView from '../views/LoginView.vue'
|
import LoginView from '../views/LoginView.vue'
|
||||||
import WelcomeView from '../views/WelcomeView.vue'
|
import WelcomeView from '../views/WelcomeView.vue'
|
||||||
import UsersView from '../views/UsersView.vue'
|
import UsersView from '../views/UsersView.vue'
|
||||||
|
import ChatView from '../views/ChatView.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
@@ -9,6 +10,7 @@ const router = createRouter({
|
|||||||
{ path: '/', name: 'login', component: LoginView },
|
{ path: '/', name: 'login', component: LoginView },
|
||||||
{ path: '/welcome', name: 'welcome', component: WelcomeView },
|
{ path: '/welcome', name: 'welcome', component: WelcomeView },
|
||||||
{ path: '/users', name: 'users', component: UsersView },
|
{ path: '/users', name: 'users', component: UsersView },
|
||||||
|
{ path: '/chat/:id?', name: 'chat', component: ChatView },
|
||||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
605
src/views/ChatView.vue
Normal file
605
src/views/ChatView.vue
Normal 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>
|
||||||
@@ -20,6 +20,7 @@ const makeRouter = () =>
|
|||||||
routes: [
|
routes: [
|
||||||
{ path: '/', name: 'login', component: LoginView },
|
{ path: '/', name: 'login', component: LoginView },
|
||||||
{ path: '/welcome', name: 'welcome', component: LoginView },
|
{ path: '/welcome', name: 'welcome', component: LoginView },
|
||||||
|
{ path: '/chat/:id', name: 'chat', component: LoginView },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ describe('SessionSidebar', () => {
|
|||||||
total: 1,
|
total: 1,
|
||||||
current_page: 1,
|
current_page: 1,
|
||||||
per_page: 20,
|
per_page: 20,
|
||||||
|
last_page: 1,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -81,6 +83,55 @@ describe('SessionSidebar', () => {
|
|||||||
)
|
)
|
||||||
expect(wrapper.text()).toContain('Demo')
|
expect(wrapper.text()).toContain('Demo')
|
||||||
await wrapper.find('.session-item').trigger('click')
|
await wrapper.find('.session-item').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
expect(localStorage.getItem('ars-current-session')).toBe('s1')
|
expect(localStorage.getItem('ars-current-session')).toBe('s1')
|
||||||
|
expect(router.currentRoute.value.name).toBe('chat')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets page when refreshing after loading more', async () => {
|
||||||
|
localStorage.setItem('ars-token', 'jwt-token')
|
||||||
|
mockedAxios.get
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
data: [{ session_id: 's1', session_name: 'P1', status: 'OPEN' }],
|
||||||
|
meta: { total: 2, current_page: 1, per_page: 10, last_page: 2 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
data: [{ session_id: 's2', session_name: 'P2', status: 'OPEN' }],
|
||||||
|
meta: { total: 2, current_page: 2, per_page: 10, last_page: 2 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
data: [{ session_id: 's1', session_name: 'Refreshed', status: 'OPEN' }],
|
||||||
|
meta: { total: 1, current_page: 1, per_page: 10, last_page: 1 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = makeRouter()
|
||||||
|
router.push('/welcome')
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
const wrapper = mount(SessionSidebar, {
|
||||||
|
global: {
|
||||||
|
plugins: [router, ElementPlus],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.findAll('.session-item')).toHaveLength(1)
|
||||||
|
|
||||||
|
await wrapper.find('.load-more').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(mockedAxios.get.mock.calls[1][1].params.page).toBe(2)
|
||||||
|
expect(wrapper.findAll('.session-item')).toHaveLength(2)
|
||||||
|
|
||||||
|
await wrapper.find('[data-testid="refresh-btn"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(mockedAxios.get.mock.calls[2][1].params.page).toBe(1)
|
||||||
|
expect(wrapper.findAll('.session-item')).toHaveLength(1)
|
||||||
|
expect(wrapper.text()).toContain('Refreshed')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user