diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts index 736b402..2145b38 100644 --- a/frontend/src/store/index.ts +++ b/frontend/src/store/index.ts @@ -1,10 +1,12 @@ import { configureStore } from '@reduxjs/toolkit' import authReducer from './slices/authSlice' +import tabsReducer from './slices/tabsSlice' export const store = configureStore({ reducer: { - auth: authReducer - } + auth: authReducer, + tabs: tabsReducer, + }, }) export type RootState = ReturnType diff --git a/frontend/src/store/slices/tabsSlice.ts b/frontend/src/store/slices/tabsSlice.ts new file mode 100644 index 0000000..2598b2c --- /dev/null +++ b/frontend/src/store/slices/tabsSlice.ts @@ -0,0 +1,53 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +export interface Tab { + id: string + title: string + path: string + closable: boolean +} + +interface TabsState { + tabs: Tab[] + activeId: string | null +} + +const initialState: TabsState = { + tabs: [{ id: 'main', title: '主页', path: '/', closable: false }], + activeId: 'main', +} + +const tabsSlice = createSlice({ + name: 'tabs', + initialState, + reducers: { + openTab(state, action: PayloadAction) { + const existing = state.tabs.find(t => t.id === action.payload.id) + if (existing) { + existing.path = action.payload.path + } else { + state.tabs.push(action.payload) + } + state.activeId = action.payload.id + }, + closeTab(state, action: PayloadAction) { + const idx = state.tabs.findIndex(t => t.id === action.payload) + if (idx === -1) return + state.tabs.splice(idx, 1) + if (state.activeId === action.payload) { + const newIdx = Math.min(idx, state.tabs.length - 1) + state.activeId = state.tabs[newIdx]?.id ?? null + } + }, + activateTab(state, action: PayloadAction) { + state.activeId = action.payload + }, + updateTabPath(state, action: PayloadAction<{ id: string; path: string }>) { + const tab = state.tabs.find(t => t.id === action.payload.id) + if (tab) tab.path = action.payload.path + }, + }, +}) + +export const { openTab, closeTab, activateTab, updateTabPath } = tabsSlice.actions +export default tabsSlice.reducer diff --git a/frontend/src/test/tabsSlice.test.ts b/frontend/src/test/tabsSlice.test.ts new file mode 100644 index 0000000..ce630fd --- /dev/null +++ b/frontend/src/test/tabsSlice.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest' +import tabsReducer, { + openTab, closeTab, activateTab, updateTabPath, + type Tab +} from '../store/slices/tabsSlice' + +const mainTab: Tab = { id: 'main', title: '主页', path: '/', closable: false } +const listTab: Tab = { id: 'userlist', title: '用户列表', path: '/usr/users', closable: true } +const detailTab: Tab = { id: 'userdetail', title: '用户信息单据', path: '/usr/users/new', closable: true } + +describe('tabsSlice', () => { + it('initialState_hasMainTab', () => { + const state = tabsReducer(undefined, { type: '' }) + expect(state.tabs).toHaveLength(1) + expect(state.tabs[0].id).toBe('main') + expect(state.activeId).toBe('main') + }) + + it('openTab_addsNewTabAndActivates', () => { + const state = tabsReducer(undefined, openTab(listTab)) + expect(state.tabs).toHaveLength(2) + expect(state.tabs[1].id).toBe('userlist') + expect(state.activeId).toBe('userlist') + }) + + it('openTab_existingId_updatesPathAndActivates', () => { + let state = tabsReducer(undefined, openTab(listTab)) + const updated: Tab = { ...listTab, path: '/usr/users?queryValue=alice' } + state = tabsReducer(state, openTab(updated)) + expect(state.tabs).toHaveLength(2) + expect(state.tabs[1].path).toBe('/usr/users?queryValue=alice') + expect(state.activeId).toBe('userlist') + }) + + it('closeTab_removesTabAndActivatesPrevious', () => { + let state = tabsReducer(undefined, openTab(listTab)) + state = tabsReducer(state, closeTab('userlist')) + expect(state.tabs).toHaveLength(1) + expect(state.activeId).toBe('main') + }) + + it('closeTab_middleTab_activatesTabToLeft', () => { + let state = tabsReducer(undefined, openTab(listTab)) + state = tabsReducer(state, openTab(detailTab)) + state = tabsReducer(state, closeTab('userdetail')) + expect(state.activeId).toBe('userlist') + }) + + it('closeTab_nonExistentId_doesNothing', () => { + const state = tabsReducer(undefined, closeTab('nonexistent')) + expect(state.tabs).toHaveLength(1) + expect(state.activeId).toBe('main') + }) + + it('activateTab_setsActiveId', () => { + let state = tabsReducer(undefined, openTab(listTab)) + state = tabsReducer(state, activateTab('main')) + expect(state.activeId).toBe('main') + }) + + it('updateTabPath_updatesPath', () => { + let state = tabsReducer(undefined, openTab(listTab)) + state = tabsReducer(state, updateTabPath({ id: 'userlist', path: '/usr/users?q=alice' })) + expect(state.tabs[1].path).toBe('/usr/users?q=alice') + }) + + it('updateTabPath_nonExistentId_doesNothing', () => { + const state = tabsReducer(undefined, updateTabPath({ id: 'nope', path: '/x' })) + expect(state.tabs[0].path).toBe('/') + }) +})