Commit ebe451fd53a53b882ce5d053d90aca4014e89c61

Authored by zichun
1 parent e7e5dcee

feat(usr): add tabsSlice for tab navigation state

frontend/src/store/index.ts
1 import { configureStore } from '@reduxjs/toolkit' 1 import { configureStore } from '@reduxjs/toolkit'
2 import authReducer from './slices/authSlice' 2 import authReducer from './slices/authSlice'
  3 +import tabsReducer from './slices/tabsSlice'
3 4
4 export const store = configureStore({ 5 export const store = configureStore({
5 reducer: { 6 reducer: {
6 - auth: authReducer  
7 - } 7 + auth: authReducer,
  8 + tabs: tabsReducer,
  9 + },
8 }) 10 })
9 11
10 export type RootState = ReturnType<typeof store.getState> 12 export type RootState = ReturnType<typeof store.getState>
frontend/src/store/slices/tabsSlice.ts 0 → 100644
  1 +import { createSlice, PayloadAction } from '@reduxjs/toolkit'
  2 +
  3 +export interface Tab {
  4 + id: string
  5 + title: string
  6 + path: string
  7 + closable: boolean
  8 +}
  9 +
  10 +interface TabsState {
  11 + tabs: Tab[]
  12 + activeId: string | null
  13 +}
  14 +
  15 +const initialState: TabsState = {
  16 + tabs: [{ id: 'main', title: '主页', path: '/', closable: false }],
  17 + activeId: 'main',
  18 +}
  19 +
  20 +const tabsSlice = createSlice({
  21 + name: 'tabs',
  22 + initialState,
  23 + reducers: {
  24 + openTab(state, action: PayloadAction<Tab>) {
  25 + const existing = state.tabs.find(t => t.id === action.payload.id)
  26 + if (existing) {
  27 + existing.path = action.payload.path
  28 + } else {
  29 + state.tabs.push(action.payload)
  30 + }
  31 + state.activeId = action.payload.id
  32 + },
  33 + closeTab(state, action: PayloadAction<string>) {
  34 + const idx = state.tabs.findIndex(t => t.id === action.payload)
  35 + if (idx === -1) return
  36 + state.tabs.splice(idx, 1)
  37 + if (state.activeId === action.payload) {
  38 + const newIdx = Math.min(idx, state.tabs.length - 1)
  39 + state.activeId = state.tabs[newIdx]?.id ?? null
  40 + }
  41 + },
  42 + activateTab(state, action: PayloadAction<string>) {
  43 + state.activeId = action.payload
  44 + },
  45 + updateTabPath(state, action: PayloadAction<{ id: string; path: string }>) {
  46 + const tab = state.tabs.find(t => t.id === action.payload.id)
  47 + if (tab) tab.path = action.payload.path
  48 + },
  49 + },
  50 +})
  51 +
  52 +export const { openTab, closeTab, activateTab, updateTabPath } = tabsSlice.actions
  53 +export default tabsSlice.reducer
frontend/src/test/tabsSlice.test.ts 0 → 100644
  1 +import { describe, it, expect } from 'vitest'
  2 +import tabsReducer, {
  3 + openTab, closeTab, activateTab, updateTabPath,
  4 + type Tab
  5 +} from '../store/slices/tabsSlice'
  6 +
  7 +const mainTab: Tab = { id: 'main', title: '主页', path: '/', closable: false }
  8 +const listTab: Tab = { id: 'userlist', title: '用户列表', path: '/usr/users', closable: true }
  9 +const detailTab: Tab = { id: 'userdetail', title: '用户信息单据', path: '/usr/users/new', closable: true }
  10 +
  11 +describe('tabsSlice', () => {
  12 + it('initialState_hasMainTab', () => {
  13 + const state = tabsReducer(undefined, { type: '' })
  14 + expect(state.tabs).toHaveLength(1)
  15 + expect(state.tabs[0].id).toBe('main')
  16 + expect(state.activeId).toBe('main')
  17 + })
  18 +
  19 + it('openTab_addsNewTabAndActivates', () => {
  20 + const state = tabsReducer(undefined, openTab(listTab))
  21 + expect(state.tabs).toHaveLength(2)
  22 + expect(state.tabs[1].id).toBe('userlist')
  23 + expect(state.activeId).toBe('userlist')
  24 + })
  25 +
  26 + it('openTab_existingId_updatesPathAndActivates', () => {
  27 + let state = tabsReducer(undefined, openTab(listTab))
  28 + const updated: Tab = { ...listTab, path: '/usr/users?queryValue=alice' }
  29 + state = tabsReducer(state, openTab(updated))
  30 + expect(state.tabs).toHaveLength(2)
  31 + expect(state.tabs[1].path).toBe('/usr/users?queryValue=alice')
  32 + expect(state.activeId).toBe('userlist')
  33 + })
  34 +
  35 + it('closeTab_removesTabAndActivatesPrevious', () => {
  36 + let state = tabsReducer(undefined, openTab(listTab))
  37 + state = tabsReducer(state, closeTab('userlist'))
  38 + expect(state.tabs).toHaveLength(1)
  39 + expect(state.activeId).toBe('main')
  40 + })
  41 +
  42 + it('closeTab_middleTab_activatesTabToLeft', () => {
  43 + let state = tabsReducer(undefined, openTab(listTab))
  44 + state = tabsReducer(state, openTab(detailTab))
  45 + state = tabsReducer(state, closeTab('userdetail'))
  46 + expect(state.activeId).toBe('userlist')
  47 + })
  48 +
  49 + it('closeTab_nonExistentId_doesNothing', () => {
  50 + const state = tabsReducer(undefined, closeTab('nonexistent'))
  51 + expect(state.tabs).toHaveLength(1)
  52 + expect(state.activeId).toBe('main')
  53 + })
  54 +
  55 + it('activateTab_setsActiveId', () => {
  56 + let state = tabsReducer(undefined, openTab(listTab))
  57 + state = tabsReducer(state, activateTab('main'))
  58 + expect(state.activeId).toBe('main')
  59 + })
  60 +
  61 + it('updateTabPath_updatesPath', () => {
  62 + let state = tabsReducer(undefined, openTab(listTab))
  63 + state = tabsReducer(state, updateTabPath({ id: 'userlist', path: '/usr/users?q=alice' }))
  64 + expect(state.tabs[1].path).toBe('/usr/users?q=alice')
  65 + })
  66 +
  67 + it('updateTabPath_nonExistentId_doesNothing', () => {
  68 + const state = tabsReducer(undefined, updateTabPath({ id: 'nope', path: '/x' }))
  69 + expect(state.tabs[0].path).toBe('/')
  70 + })
  71 +})