diff --git a/frontend/src/pages/__tests__/login.spec.ts b/frontend/src/pages/__tests__/login.spec.ts
new file mode 100644
index 0000000..8a8e080
--- /dev/null
+++ b/frontend/src/pages/__tests__/login.spec.ts
@@ -0,0 +1,247 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { setActivePinia, createPinia } from 'pinia'
+import { mount, flushPromises } from '@vue/test-utils'
+import { nextTick } from 'vue'
+import { message } from 'ant-design-vue'
+import { ApiError } from '@/types/api'
+import { useUserStore } from '@/stores/user'
+
+// jsdom 不支持 matchMedia,Ant Design Vue 响应式布局需要
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ }),
+})
+
+vi.mock('@/utils/request', () => ({
+ api: {
+ get: vi.fn(),
+ post: vi.fn(),
+ },
+}))
+
+const mockPush = vi.fn()
+const mockRoute = { query: {} }
+
+vi.mock('vue-router', () => ({
+ useRouter: () => ({ push: mockPush }),
+ useRoute: () => mockRoute,
+}))
+
+// Stub Brand component to avoid asset loading
+vi.mock('@/components/Brand.vue', () => ({
+ default: { template: '
' },
+}))
+
+describe('Login Page', () => {
+ let wrapper: ReturnType
+
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ localStorage.clear()
+ vi.restoreAllMocks()
+ mockPush.mockReset()
+ mockRoute.query = {}
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ wrapper?.unmount()
+ vi.useRealTimers()
+ })
+
+ async function mountPage() {
+ const { default: LoginPage } = await import('../login.vue')
+
+ wrapper = mount(LoginPage, {
+ attachTo: document.body,
+ global: {
+ stubs: {
+ UserOutlined: { template: '' },
+ LockOutlined: { template: '' },
+ },
+ },
+ })
+ await flushPromises()
+ await nextTick()
+ return wrapper
+ }
+
+ it('renders login form with username, password inputs and submit button', async () => {
+ await mountPage()
+ const html = wrapper.html()
+ // 验证输入框存在
+ expect(html).toContain('请输入用户名')
+ expect(html).toContain('请输入密码')
+ // 验证提交按钮存在
+ expect(html).toContain('登录')
+ })
+
+ it('calls /api/v1/login, sets token/user and redirects to /', async () => {
+ const { api } = await import('@/utils/request')
+ const mockResponse = {
+ access_token: 'a.b.c',
+ refresh_token: 'r.r.r',
+ user: { id: 1, username: 'admin', email: 'a@b.com', role: 'admin', status: 1 },
+ }
+ vi.mocked(api.post).mockResolvedValueOnce(mockResponse)
+
+ await mountPage()
+ const store = useUserStore()
+
+ // 直接调用 handleSubmit 来绕过表单验证
+ // 通过组件 vm 访问 setup 暴露的方法
+ const vm = wrapper.getCurrentComponent().setupState
+ vm.formState.username = 'admin'
+ vm.formState.password = 'password'
+ vm.formRef = { value: { validate: vi.fn().mockResolvedValue(true) } }
+ // mock formRef.validate
+ vm.formRef = { validate: vi.fn().mockResolvedValue(true) }
+ await vm.handleSubmit()
+ await flushPromises()
+
+ expect(api.post).toHaveBeenCalledWith('/api/v1/login', {
+ username: 'admin',
+ password: 'password',
+ })
+ expect(store.token).toBe('a.b.c')
+ expect(store.user).toEqual(mockResponse.user)
+
+ vi.advanceTimersByTime(500)
+ expect(mockPush).toHaveBeenCalledWith('/')
+ })
+
+ it('passes remember=false to setToken when unchecked', async () => {
+ const { api } = await import('@/utils/request')
+ vi.mocked(api.post).mockResolvedValueOnce({
+ access_token: 'a.b.c',
+ refresh_token: 'r.r.r',
+ user: { id: 1, username: 'u', email: 'u@u.com', role: 'user', status: 1 },
+ })
+
+ await mountPage()
+ const vm = wrapper.getCurrentComponent().setupState
+ vm.formState.username = 'user'
+ vm.formState.password = 'pass'
+ vm.formState.remember = false
+ vm.formRef = { validate: vi.fn().mockResolvedValue(true) }
+ await vm.handleSubmit()
+ await flushPromises()
+
+ expect(localStorage.getItem('access_token')).toBeNull()
+ expect(localStorage.getItem('user')).toBeNull()
+ })
+
+ it('redirects to safe path from query param', async () => {
+ const { api } = await import('@/utils/request')
+ vi.mocked(api.post).mockResolvedValueOnce({
+ access_token: 'a.b.c',
+ refresh_token: 'r.r.r',
+ user: { id: 1, username: 'u', email: 'u@u.com', role: 'user', status: 1 },
+ })
+ mockRoute.query = { redirect: '/dashboard' }
+
+ await mountPage()
+ const vm = wrapper.getCurrentComponent().setupState
+ vm.formState.username = 'user'
+ vm.formState.password = 'pass'
+ vm.formRef = { validate: vi.fn().mockResolvedValue(true) }
+ await vm.handleSubmit()
+ await flushPromises()
+
+ vi.advanceTimersByTime(500)
+ expect(mockPush).toHaveBeenCalledWith('/dashboard')
+ })
+
+ it('blocks //evil redirect and falls back to /', async () => {
+ const { api } = await import('@/utils/request')
+ vi.mocked(api.post).mockResolvedValueOnce({
+ access_token: 'a.b.c',
+ refresh_token: 'r.r.r',
+ user: { id: 1, username: 'u', email: 'u@u.com', role: 'user', status: 1 },
+ })
+ mockRoute.query = { redirect: '//evil.com' }
+
+ await mountPage()
+ const vm = wrapper.getCurrentComponent().setupState
+ vm.formState.username = 'user'
+ vm.formState.password = 'pass'
+ vm.formRef = { validate: vi.fn().mockResolvedValue(true) }
+ await vm.handleSubmit()
+ await flushPromises()
+
+ vi.advanceTimersByTime(500)
+ expect(mockPush).toHaveBeenCalledWith('/')
+ })
+
+ it('blocks http:// redirect and falls back to /', async () => {
+ const { api } = await import('@/utils/request')
+ vi.mocked(api.post).mockResolvedValueOnce({
+ access_token: 'a.b.c',
+ refresh_token: 'r.r.r',
+ user: { id: 1, username: 'u', email: 'u@u.com', role: 'user', status: 1 },
+ })
+ mockRoute.query = { redirect: 'http://evil.com' }
+
+ await mountPage()
+ const vm = wrapper.getCurrentComponent().setupState
+ vm.formState.username = 'user'
+ vm.formState.password = 'pass'
+ vm.formRef = { validate: vi.fn().mockResolvedValue(true) }
+ await vm.handleSubmit()
+ await flushPromises()
+
+ vi.advanceTimersByTime(500)
+ expect(mockPush).toHaveBeenCalledWith('/')
+ })
+
+ it('shows error message on ApiError', async () => {
+ const { api } = await import('@/utils/request')
+ vi.mocked(api.post).mockRejectedValueOnce(new ApiError('用户名或密码错误', 401))
+ const errorSpy = vi.spyOn(message, 'error').mockImplementation(() => ({}) as any)
+
+ await mountPage()
+ const vm = wrapper.getCurrentComponent().setupState
+ vm.formState.username = 'user'
+ vm.formState.password = 'wrong'
+ vm.formRef = { validate: vi.fn().mockResolvedValue(true) }
+ await vm.handleSubmit()
+ await flushPromises()
+
+ expect(errorSpy).toHaveBeenCalledWith('用户名或密码错误')
+ errorSpy.mockRestore()
+ })
+
+ it('shows network error message on generic error', async () => {
+ const { api } = await import('@/utils/request')
+ vi.mocked(api.post).mockRejectedValueOnce(new Error('Network failure'))
+ const errorSpy = vi.spyOn(message, 'error').mockImplementation(() => ({}) as any)
+
+ await mountPage()
+ const vm = wrapper.getCurrentComponent().setupState
+ vm.formState.username = 'user'
+ vm.formState.password = 'pass'
+ vm.formRef = { validate: vi.fn().mockResolvedValue(true) }
+ await vm.handleSubmit()
+ await flushPromises()
+
+ expect(errorSpy).toHaveBeenCalledWith('登录失败,请检查网络连接')
+ errorSpy.mockRestore()
+ })
+
+ it('navigates to register page', async () => {
+ await mountPage()
+ const vm = wrapper.getCurrentComponent().setupState
+ vm.goToRegister()
+
+ expect(mockPush).toHaveBeenCalledWith('/register')
+ })
+})
diff --git a/frontend/src/pages/__tests__/register.spec.ts b/frontend/src/pages/__tests__/register.spec.ts
new file mode 100644
index 0000000..6cafc1c
--- /dev/null
+++ b/frontend/src/pages/__tests__/register.spec.ts
@@ -0,0 +1,190 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { setActivePinia, createPinia } from 'pinia'
+import { mount, flushPromises } from '@vue/test-utils'
+import { nextTick } from 'vue'
+import { message } from 'ant-design-vue'
+import { ApiError } from '@/types/api'
+import { useUserStore } from '@/stores/user'
+
+// jsdom 不支持 matchMedia,Ant Design Vue 响应式布局需要
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ }),
+})
+
+vi.mock('@/utils/request', () => ({
+ api: {
+ get: vi.fn(),
+ post: vi.fn(),
+ },
+}))
+
+const mockPush = vi.fn()
+
+vi.mock('vue-router', () => ({
+ useRouter: () => ({ push: mockPush }),
+ useRoute: () => ({ query: {} }),
+}))
+
+vi.mock('@/components/Brand.vue', () => ({
+ default: { template: '' },
+}))
+
+describe('Register Page', () => {
+ let wrapper: ReturnType
+
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ localStorage.clear()
+ vi.restoreAllMocks()
+ mockPush.mockReset()
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ wrapper?.unmount()
+ vi.useRealTimers()
+ })
+
+ async function mountPage() {
+ const { default: RegisterPage } = await import('../register.vue')
+
+ wrapper = mount(RegisterPage, {
+ attachTo: document.body,
+ global: {
+ stubs: {
+ UserOutlined: { template: '' },
+ LockOutlined: { template: '' },
+ MailOutlined: { template: '' },
+ },
+ },
+ })
+ await flushPromises()
+ await nextTick()
+ return wrapper
+ }
+
+ it('renders register form with all fields and submit button', async () => {
+ await mountPage()
+ const html = wrapper.html()
+ // 验证所有输入框的 placeholder 存在
+ expect(html).toContain('请输入用户名')
+ expect(html).toContain('请输入邮箱地址')
+ expect(html).toContain('请输入密码')
+ expect(html).toContain('请再次输入密码')
+ // 验证注册按钮存在
+ expect(html).toContain('注册')
+ })
+
+ it('calls /api/v1/register and redirects to /login on success without token', async () => {
+ const { api } = await import('@/utils/request')
+ vi.mocked(api.post).mockResolvedValueOnce({})
+ const successSpy = vi.spyOn(message, 'success').mockImplementation(() => ({}) as any)
+
+ await mountPage()
+ const store = useUserStore()
+ const vm = wrapper.getCurrentComponent().setupState
+ vm.formState.username = 'newuser'
+ vm.formState.email = 'new@test.com'
+ vm.formState.password = 'password123'
+ vm.formState.confirmPassword = 'password123'
+ vm.formRef = { validate: vi.fn().mockResolvedValue(true) }
+ await vm.handleSubmit()
+ await flushPromises()
+
+ expect(api.post).toHaveBeenCalledWith('/api/v1/register', {
+ username: 'newuser',
+ email: 'new@test.com',
+ password: 'password123',
+ })
+ expect(store.token).toBeNull()
+ expect(successSpy).toHaveBeenCalled()
+
+ vi.advanceTimersByTime(1500)
+ expect(mockPush).toHaveBeenCalledWith('/login')
+ successSpy.mockRestore()
+ })
+
+ it('sets token and user when backend returns them on register', async () => {
+ const { api } = await import('@/utils/request')
+ const mockResponse = {
+ access_token: 'a.b.c',
+ refresh_token: 'r.r.r',
+ user: { id: 2, username: 'newuser', email: 'new@test.com', role: 'user', status: 1 },
+ }
+ vi.mocked(api.post).mockResolvedValueOnce(mockResponse)
+ vi.spyOn(message, 'success').mockImplementation(() => ({}) as any)
+
+ await mountPage()
+ const store = useUserStore()
+ const vm = wrapper.getCurrentComponent().setupState
+ vm.formState.username = 'newuser'
+ vm.formState.email = 'new@test.com'
+ vm.formState.password = 'password123'
+ vm.formState.confirmPassword = 'password123'
+ vm.formRef = { validate: vi.fn().mockResolvedValue(true) }
+ await vm.handleSubmit()
+ await flushPromises()
+
+ expect(store.token).toBe('a.b.c')
+ expect(store.user).toEqual(mockResponse.user)
+
+ vi.advanceTimersByTime(1500)
+ expect(mockPush).toHaveBeenCalledWith('/login')
+ })
+
+ it('shows error message on ApiError', async () => {
+ const { api } = await import('@/utils/request')
+ vi.mocked(api.post).mockRejectedValueOnce(new ApiError('用户名已存在', 409))
+ const errorSpy = vi.spyOn(message, 'error').mockImplementation(() => ({}) as any)
+
+ await mountPage()
+ const vm = wrapper.getCurrentComponent().setupState
+ vm.formState.username = 'existing'
+ vm.formState.email = 'e@test.com'
+ vm.formState.password = 'password123'
+ vm.formState.confirmPassword = 'password123'
+ vm.formRef = { validate: vi.fn().mockResolvedValue(true) }
+ await vm.handleSubmit()
+ await flushPromises()
+
+ expect(errorSpy).toHaveBeenCalledWith('用户名已存在')
+ errorSpy.mockRestore()
+ })
+
+ it('shows network error message on generic error', async () => {
+ const { api } = await import('@/utils/request')
+ vi.mocked(api.post).mockRejectedValueOnce(new Error('Network failure'))
+ const errorSpy = vi.spyOn(message, 'error').mockImplementation(() => ({}) as any)
+
+ await mountPage()
+ const vm = wrapper.getCurrentComponent().setupState
+ vm.formState.username = 'user'
+ vm.formState.email = 'u@test.com'
+ vm.formState.password = 'password123'
+ vm.formState.confirmPassword = 'password123'
+ vm.formRef = { validate: vi.fn().mockResolvedValue(true) }
+ await vm.handleSubmit()
+ await flushPromises()
+
+ expect(errorSpy).toHaveBeenCalledWith('注册失败,请检查网络连接')
+ errorSpy.mockRestore()
+ })
+
+ it('navigates to login page', async () => {
+ await mountPage()
+ const vm = wrapper.getCurrentComponent().setupState
+ vm.goToLogin()
+
+ expect(mockPush).toHaveBeenCalledWith('/login')
+ })
+})
diff --git a/frontend/src/pages/login.vue b/frontend/src/pages/login.vue
index 0526927..faa7442 100644
--- a/frontend/src/pages/login.vue
+++ b/frontend/src/pages/login.vue
@@ -36,7 +36,7 @@ const handleSubmit = async () => {
access_token: string
refresh_token: string
user: UserInfo
- }>('/api/auth/login', {
+ }>('/api/v1/login', {
username: formState.username,
password: formState.password,
})
diff --git a/frontend/src/pages/register.vue b/frontend/src/pages/register.vue
index 8a3ac99..ac3af2e 100644
--- a/frontend/src/pages/register.vue
+++ b/frontend/src/pages/register.vue
@@ -54,7 +54,7 @@ const handleSubmit = async () => {
access_token?: string
refresh_token?: string
user?: UserInfo
- }>('/api/auth/register', {
+ }>('/api/v1/register', {
username: formState.username,
email: formState.email,
password: formState.password,
diff --git a/frontend/src/stores/__tests__/user.spec.ts b/frontend/src/stores/__tests__/user.spec.ts
index c6ac748..dd85a5f 100644
--- a/frontend/src/stores/__tests__/user.spec.ts
+++ b/frontend/src/stores/__tests__/user.spec.ts
@@ -73,6 +73,28 @@ describe('useUserStore', () => {
})
})
+ describe('setToken with remember=false', () => {
+ it('does not persist tokens to localStorage', () => {
+ const store = useUserStore()
+
+ store.setToken('h.p.s', 'r.p.s', false)
+
+ expect(store.token).toBe('h.p.s')
+ expect(store.refreshToken).toBe('r.p.s')
+ expect(localStorage.getItem('access_token')).toBeNull()
+ expect(localStorage.getItem('refresh_token')).toBeNull()
+ })
+
+ it('clears existing user from localStorage', () => {
+ localStorage.setItem('user', JSON.stringify({ id: 1, username: 'old' }))
+
+ const store = useUserStore()
+ store.setToken('h.p.s', 'r.p.s', false)
+
+ expect(localStorage.getItem('user')).toBeNull()
+ })
+ })
+
describe('setUser', () => {
it('sets user info in store and localStorage', () => {
const store = useUserStore()
@@ -85,6 +107,17 @@ describe('useUserStore', () => {
expect(store.isAdmin).toBe(false)
expect(JSON.parse(localStorage.getItem('user')!)).toEqual(userInfo)
})
+
+ it('does not write to localStorage when remember=false', () => {
+ const store = useUserStore()
+ store.setToken('h.p.s', 'r.p.s', false)
+
+ const userInfo = { id: 1, username: 'test', email: 't@t.com', role: 'user', status: 1 }
+ store.setUser(userInfo)
+
+ expect(store.user).toEqual(userInfo)
+ expect(localStorage.getItem('user')).toBeNull()
+ })
})
describe('logout', () => {
@@ -114,7 +147,7 @@ describe('useUserStore', () => {
const store = useUserStore()
const result = await store.fetchCurrentUser()
- expect(api.get).toHaveBeenCalledWith('/api/v1/auth/me')
+ expect(api.get).toHaveBeenCalledWith('/api/v1/me')
expect(result).toEqual(mockUser)
expect(store.user).toEqual(mockUser)
expect(store.isAdmin).toBe(true)
diff --git a/frontend/src/stores/user.ts b/frontend/src/stores/user.ts
index 8b37d42..1b9660b 100644
--- a/frontend/src/stores/user.ts
+++ b/frontend/src/stores/user.ts
@@ -12,6 +12,7 @@ export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('access_token'))
const refreshToken = ref(localStorage.getItem('refresh_token'))
const user = ref(null)
+ const _remember = ref(true)
// 基本 JWT 格式校验(三段式),防止垃圾值绕过路由守卫
const isLoggedIn = computed(() => {
@@ -24,6 +25,7 @@ export const useUserStore = defineStore('user', () => {
function setToken(accessToken: string, newRefreshToken: string, remember = true) {
token.value = accessToken
refreshToken.value = newRefreshToken
+ _remember.value = remember
if (remember) {
localStorage.setItem('access_token', accessToken)
localStorage.setItem('refresh_token', newRefreshToken)
@@ -31,16 +33,19 @@ export const useUserStore = defineStore('user', () => {
// 不记住:清除持久化,token 仅存于内存,关闭标签页即失效
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
+ localStorage.removeItem('user')
}
}
function setUser(info: UserInfo) {
user.value = info
- localStorage.setItem('user', JSON.stringify(info))
+ if (_remember.value) {
+ localStorage.setItem('user', JSON.stringify(info))
+ }
}
async function fetchCurrentUser() {
- const data = await api.get('/api/v1/auth/me')
+ const data = await api.get('/api/v1/me')
setUser(data)
return data
}