Commit d0a605693806a5a059ee756fe16e55bd3d32969e

Authored by zichun
1 parent 1bdbabbf

feat(fe-shell): 顶栏标签栈联动逻辑 useTabStack REQ-USR-003

frontend/src/layouts/AppLayout/useTabStack.ts 0 → 100644
  1 +// REQ-USR-003: 顶栏标签栈逻辑(复刻原型 tabsOpen/openTab/.close,BR4/BR5/BR6)。
  2 +// 纯逻辑 hook:只管标签集合 + activeKey;路由跳转由调用方据 routePath 执行。
  3 +import { useCallback, useMemo, useState } from 'react';
  4 +
  5 +/** 业务标签 key(跨组件一致的合同级常量) */
  6 +export type BizTabKey = 'userlist' | 'userdetail';
  7 +export type TabKey = 'home' | BizTabKey;
  8 +
  9 +export interface TabItem {
  10 + key: string;
  11 + title: string;
  12 + closable: boolean;
  13 + routePath: string;
  14 +}
  15 +
  16 +/** 固定主页标签:不可关闭,恒在最左 */
  17 +export const HOME_TAB: TabItem = {
  18 + key: 'home',
  19 + title: '主页',
  20 + closable: false,
  21 + routePath: '/',
  22 +};
  23 +
  24 +/** 业务标签定义(标题 / 路由 / 可关闭) */
  25 +export const BIZ_TABS: Record<BizTabKey, TabItem> = {
  26 + userlist: { key: 'userlist', title: '用户列表', closable: true, routePath: '/usr/users' },
  27 + userdetail: { key: 'userdetail', title: '用户信息单据', closable: true, routePath: '/usr/users/new' },
  28 +};
  29 +
  30 +export interface TabStack {
  31 + tabs: TabItem[];
  32 + activeKey: string;
  33 + openTab: (key: BizTabKey) => void;
  34 + closeTab: (key: string) => void;
  35 + setActive: (key: string) => void;
  36 +}
  37 +
  38 +export function useTabStack(): TabStack {
  39 + // 已打开的业务标签集合(home 恒在,不入此集合)
  40 + const [userlistOpen, setUserlistOpen] = useState(false);
  41 + const [userdetailOpen, setUserdetailOpen] = useState(false);
  42 + const [activeKey, setActiveKey] = useState<string>('home');
  43 +
  44 + const tabs = useMemo<TabItem[]>(() => {
  45 + const list: TabItem[] = [HOME_TAB];
  46 + if (userlistOpen) list.push(BIZ_TABS.userlist);
  47 + if (userdetailOpen) list.push(BIZ_TABS.userdetail);
  48 + return list;
  49 + }, [userlistOpen, userdetailOpen]);
  50 +
  51 + const openTab = useCallback((key: BizTabKey) => {
  52 + if (key === 'userlist') {
  53 + setUserlistOpen(true);
  54 + setActiveKey('userlist');
  55 + } else if (key === 'userdetail') {
  56 + // 打开单据前先确保用户列表存在(BR6)
  57 + setUserlistOpen(true);
  58 + setUserdetailOpen(true);
  59 + setActiveKey('userdetail');
  60 + }
  61 + }, []);
  62 +
  63 + const closeTab = useCallback((key: string) => {
  64 + if (key === 'userlist') {
  65 + // 关用户列表联动关用户信息单据并回主页(BR5)
  66 + setUserlistOpen(false);
  67 + setUserdetailOpen(false);
  68 + setActiveKey('home');
  69 + } else if (key === 'userdetail') {
  70 + // 关用户信息单据回用户列表
  71 + setUserdetailOpen(false);
  72 + setActiveKey('userlist');
  73 + }
  74 + }, []);
  75 +
  76 + const setActive = useCallback((key: string) => {
  77 + setActiveKey(key);
  78 + }, []);
  79 +
  80 + return { tabs, activeKey, openTab, closeTab, setActive };
  81 +}
frontend/tests/unit/useTabStack.test.tsx 0 → 100644
  1 +// REQ-USR-003: useTabStack 标签栈逻辑(BR4/BR5/BR6)
  2 +import { describe, it, expect } from 'vitest';
  3 +import { act, renderHook } from '@testing-library/react';
  4 +import { useTabStack } from '../../src/layouts/AppLayout/useTabStack';
  5 +
  6 +describe('useTabStack', () => {
  7 + it('starts with fixed home tab only (closable false, leftmost)', () => {
  8 + const { result } = renderHook(() => useTabStack());
  9 + expect(result.current.tabs.map((t) => t.key)).toEqual(['home']);
  10 + expect(result.current.activeKey).toBe('home');
  11 + expect(result.current.tabs[0].closable).toBe(false);
  12 + expect(result.current.tabs[0].title).toBe('主页');
  13 + expect(result.current.tabs[0].routePath).toBe('/');
  14 + });
  15 +
  16 + it('openTab userlist appends userlist and activates it', () => {
  17 + const { result } = renderHook(() => useTabStack());
  18 + act(() => result.current.openTab('userlist'));
  19 + expect(result.current.tabs.map((t) => t.key)).toEqual(['home', 'userlist']);
  20 + expect(result.current.activeKey).toBe('userlist');
  21 + const ul = result.current.tabs.find((t) => t.key === 'userlist')!;
  22 + expect(ul.closable).toBe(true);
  23 + expect(ul.routePath).toBe('/usr/users');
  24 + expect(ul.title).toBe('用户列表');
  25 + });
  26 +
  27 + it('openTab userdetail ensures userlist exists then appends userdetail', () => {
  28 + const { result } = renderHook(() => useTabStack());
  29 + act(() => result.current.openTab('userdetail'));
  30 + expect(result.current.tabs.map((t) => t.key)).toEqual(['home', 'userlist', 'userdetail']);
  31 + expect(result.current.activeKey).toBe('userdetail');
  32 + const ud = result.current.tabs.find((t) => t.key === 'userdetail')!;
  33 + expect(ud.title).toBe('用户信息单据');
  34 + expect(ud.closable).toBe(true);
  35 + });
  36 +
  37 + it('closeTab userlist also removes userdetail and activates home', () => {
  38 + const { result } = renderHook(() => useTabStack());
  39 + act(() => result.current.openTab('userdetail'));
  40 + act(() => result.current.closeTab('userlist'));
  41 + expect(result.current.tabs.map((t) => t.key)).toEqual(['home']);
  42 + expect(result.current.activeKey).toBe('home');
  43 + });
  44 +
  45 + it('closeTab userdetail activates userlist', () => {
  46 + const { result } = renderHook(() => useTabStack());
  47 + act(() => result.current.openTab('userdetail'));
  48 + act(() => result.current.closeTab('userdetail'));
  49 + expect(result.current.tabs.map((t) => t.key)).toEqual(['home', 'userlist']);
  50 + expect(result.current.activeKey).toBe('userlist');
  51 + });
  52 +
  53 + it('open existing tab does not duplicate', () => {
  54 + const { result } = renderHook(() => useTabStack());
  55 + act(() => result.current.openTab('userlist'));
  56 + act(() => result.current.openTab('userlist'));
  57 + expect(result.current.tabs.map((t) => t.key)).toEqual(['home', 'userlist']);
  58 + });
  59 +});