From b107a5e6802533d91a9a9e9f55643224047611d2 Mon Sep 17 00:00:00 2001 From: ROOG Date: Sun, 14 Dec 2025 20:22:06 +0800 Subject: [PATCH] =?UTF-8?q?master:=20=E6=B7=BB=E5=8A=A0=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=8F=8A=E7=95=8C=E9=9D=A2=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ChatView 页面及其逻辑设计 - 修改 App.vue 以支持动态会话标题 - 调整 Sidebar 为固定高度及改进滚动样式 - 优化会话分页,添加 lastPage 逻辑 - 在 SessionSidebar 中实现刷新操作和路由同步 - 更新路由配置,添加 chat 相关路径 - 添加单元测试以覆盖新逻辑 --- src/App.vue | 67 ++-- src/components/SessionSidebar.vue | 107 +++++- src/router/index.js | 2 + src/views/ChatView.vue | 605 ++++++++++++++++++++++++++++++ tests/session-sidebar.spec.js | 51 +++ 5 files changed, 781 insertions(+), 51 deletions(-) create mode 100644 src/views/ChatView.vue diff --git a/src/App.vue b/src/App.vue index ab80534..f766cfb 100644 --- a/src/App.vue +++ b/src/App.vue @@ -11,9 +11,10 @@ const menus = [ ] const isAuthRoute = computed(() => route.name === 'login') -const activeMenuLabel = computed( - () => menus.find((m) => m.name === route.name)?.label || '控制台' -) +const activeMenuLabel = computed(() => { + if (route.name === 'chat') return '会话' + return menus.find((m) => m.name === route.name)?.label || '控制台' +}) const miniMenuOpen = ref(false) @@ -30,37 +31,36 @@ const miniMenuOpen = ref(false)
{{ activeMenuLabel }}
-
会话列表常驻左侧,Apple 风格控制台
+ +
+

快捷导航

+ + {{ item.label }} + +
+ +
- -
-

快捷导航

- - {{ item.label }} - -
- -
@@ -122,19 +122,11 @@ const miniMenuOpen = ref(false) border-bottom: 1px solid rgba(0, 0, 0, 0.04); } -.top-meta { - color: #6b7280; - font-size: 14px; -} - .page { min-height: calc(100vh - 140px); } .mini-btn { - position: fixed; - left: 18px; - bottom: 18px; height: 46px; padding: 0 12px; border-radius: 12px; @@ -147,6 +139,7 @@ const miniMenuOpen = ref(false) cursor: pointer; transition: transform 0.18s ease, box-shadow 0.18s ease; color: #111827; + min-width: 104px; } .mini-btn:hover { diff --git a/src/components/SessionSidebar.vue b/src/components/SessionSidebar.vue index bc713f0..a555f68 100644 --- a/src/components/SessionSidebar.vue +++ b/src/components/SessionSidebar.vue @@ -11,17 +11,16 @@ const router = useRouter() const sessions = ref([]) const loading = ref(false) const errorMessage = ref('') -const canLoadMore = computed( - () => pagination.page * pagination.perPage < pagination.total -) +const canLoadMore = computed(() => pagination.page < pagination.lastPage) const filters = reactive({ q: '', status: 'OPEN', }) const pagination = reactive({ page: 1, - perPage: 20, + perPage: 10, total: 0, + lastPage: 1, }) const currentSessionId = ref(localStorage.getItem('ars-current-session') || '') @@ -53,9 +52,19 @@ const fetchSessions = async ({ append = false } = {}) => { }) const list = data.data || [] sessions.value = append ? [...sessions.value, ...list] : list - pagination.total = data.total || 0 - pagination.page = data.current_page || pagination.page - pagination.perPage = data.per_page || pagination.perPage + const meta = data.meta || {} + const total = meta.total ?? data.total ?? list.length + 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) { const status = err.response?.status const message = err.response?.data?.message || '获取会话列表失败' @@ -71,6 +80,9 @@ const selectSession = (session) => { currentSessionId.value = session.session_id localStorage.setItem('ars-current-session', session.session_id) 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) => { @@ -83,12 +95,20 @@ const handleCreated = (session) => { } currentSessionId.value = session.session_id localStorage.setItem('ars-current-session', session.session_id) + router.push({ name: 'chat', params: { id: session.session_id } }) } fetchSessions() } const handleSearch = () => { pagination.page = 1 + pagination.lastPage = 1 + fetchSessions() +} + +const refreshList = () => { + pagination.page = 1 + pagination.lastPage = 1 fetchSessions() } @@ -132,7 +152,8 @@ onMounted(() => { size="small" color="#1d4ed8" :loading="loading" - @click="fetchSessions" + data-testid="refresh-btn" + @click="refreshList" > 刷新 @@ -169,12 +190,13 @@ onMounted(() => { @@ -193,6 +215,9 @@ onMounted(() => { .sidebar { width: 300px; min-height: 100vh; + height: 100vh; + position: sticky; + top: 0; padding: 18px; background: rgba(255, 255, 255, 0.8); border-right: 1px solid rgba(0, 0, 0, 0.06); @@ -201,6 +226,28 @@ onMounted(() => { display: flex; flex-direction: column; 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 { @@ -286,10 +333,10 @@ onMounted(() => { font-size: 12px; } - .pagination { +.pagination { display: flex; - justify-content: flex-start; - align-items: center; + flex-direction: column; + gap: 6px; font-size: 12px; color: #6b7280; } @@ -298,10 +345,42 @@ onMounted(() => { 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) { .sidebar { width: 100%; min-height: auto; + height: auto; + position: static; + overflow: visible; border-right: none; border-bottom: 1px solid rgba(0, 0, 0, 0.06); box-shadow: 0 8px 24px rgba(17, 24, 39, 0.08); diff --git a/src/router/index.js b/src/router/index.js index 62e5696..ea6a888 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router' import LoginView from '../views/LoginView.vue' import WelcomeView from '../views/WelcomeView.vue' import UsersView from '../views/UsersView.vue' +import ChatView from '../views/ChatView.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -9,6 +10,7 @@ const router = createRouter({ { path: '/', name: 'login', component: LoginView }, { path: '/welcome', name: 'welcome', component: WelcomeView }, { path: '/users', name: 'users', component: UsersView }, + { path: '/chat/:id?', name: 'chat', component: ChatView }, { path: '/:pathMatch(.*)*', redirect: '/' }, ], }) diff --git a/src/views/ChatView.vue b/src/views/ChatView.vue new file mode 100644 index 0000000..d4c162a --- /dev/null +++ b/src/views/ChatView.vue @@ -0,0 +1,605 @@ + + + + + diff --git a/tests/session-sidebar.spec.js b/tests/session-sidebar.spec.js index 98d0e6b..182f31c 100644 --- a/tests/session-sidebar.spec.js +++ b/tests/session-sidebar.spec.js @@ -20,6 +20,7 @@ const makeRouter = () => routes: [ { path: '/', name: 'login', component: LoginView }, { path: '/welcome', name: 'welcome', component: LoginView }, + { path: '/chat/:id', name: 'chat', component: LoginView }, ], }) @@ -61,6 +62,7 @@ describe('SessionSidebar', () => { total: 1, current_page: 1, per_page: 20, + last_page: 1, }, }) @@ -81,6 +83,55 @@ describe('SessionSidebar', () => { ) expect(wrapper.text()).toContain('Demo') await wrapper.find('.session-item').trigger('click') + await flushPromises() 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') }) })