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 风格控制台
+
+
+
+
+
+
-
-
-
-
-
-
@@ -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 @@
+
+
+
+
+
+
+
会话详情
+
{{ summary.title || '未命名会话' }}
+
+
+ {{ summary.status || 'UNKNOWN' }}
+
+ 最近更新 · {{ summary.updatedAt || '--' }}
+ last_seq · {{ summary.lastSeq ?? '--' }}
+
+
+
+ {{ tag }}
+
+
+
+
+
+
历史消息
+
{{ messages.length }}
+
+
+
草稿字数
+
{{ inputText.length }}
+
+
+
+
+
+
+
+
+ {{ msg.author }}
+ {{ msg.time }}
+
+
{{ msg.text }}
+
+
+
+
+ {{ file.name }}
+ {{ file.size || '图像' }}
+
+
+
+
+
+
+
+
+
+
+
+
+ 发送
+
+
+
+
+
待发送附件
+
+
+
+
+ {{ file.name }}
+ {{ file.size || '文件' }}
+
+
+
+
+
+
+
+
+
+
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')
})
})