Open
Merge Request #4 · created by 朱子纯


feat(usr): 前端重写 — 原型 Tab 导航 + 全屏用户单据

Summary

  • 新增 tabsSlice — Redux 管理多 Tab 导航(openTab/closeTab/activateTab/updateTabPath)
  • 新增 AppShell — 深色顶栏 + Tab 栏 + NavOverlay 全屏导航
  • 重写 LoginPage — 蓝色渐变 hero + 右侧登录卡片,与原型一致
  • 重写 UserListPage — 深色工具栏 + filterbar + 表格,筛选条件持久化到 URL query string
  • 新增 UserDetailPage — 全屏单据页(替代 UserFormDrawer),支持新增/编辑模式
  • 删除 UserFormDrawer.tsx

Design Decisions

  • 导航结构:原型 Tab 顶栏,无侧边栏(与 docs/06 § 1.1 不符,已与用户确认)
  • 编辑模式数据通过 location.state 传入(后端无单条 GET by ID 接口)
  • 已知限制:编辑用户时权限组无法预加载(列表接口不返回 permGroupIds),UI 已显示警告

Test Plan

  • 36 个前端单元测试全部通过(pre-push hook 已验证)
  • 登录页面视觉与原型一致
  • Tab 切换保留各页面状态
  • 用户列表双击行进入编辑单据
  • 新增用户流程完整

From feature/frontend-rewrite into master
This request can be merged automatically. Only those with write access to this repository can merge merge requests.
1 participants
docs/superpowers/specs/2026-05-08-frontend-design.md 0 → 100644
  1 +# 前端重写设计文档
  2 +
  3 +**日期**: 2026-05-08
  4 +**范围**: USR 模块前端 — 登录、用户列表、用户单据
  5 +**参考原型**: `prototype/erp.html`
  6 +**技术栈**: 不变(React 18 + Ant Design 5 + Redux Toolkit + React Router v6 + Vite)
  7 +
  8 +---
  9 +
  10 +## 一、核心决策记录
  11 +
  12 +| 决策项 | 结论 | 说明 |
  13 +|---|---|---|
  14 +| 导航结构 | 原型 Tab 顶栏,无侧边栏 | 与 docs/06 § 1.1 不符,已与用户确认,本文档为准 |
  15 +| 用户表单形式 | 全屏单据页(独立 Tab) | 替代原 Drawer;与原型一致 |
  16 +| Tab 状态管理 | Redux `tabsSlice` | 切换 Tab 时 React Router navigate + 列表筛选存 URL query,保证状态可恢复 |
  17 +| 主页 | 占位页面 | KPI 看板后续模块实现 |
  18 +| 现有 api / store | 保留不动 | 逻辑正确,仅重写 UI 层 |
  19 +
  20 +> **docs/06 偏差声明**: docs/06 § 1.1 规定 Header + Sider + Content 经典 Admin 布局。本设计采用原型的 Tab 顶栏导航,无侧边栏。后续若文档更新,以本文档为 USR 模块实现基准。
  21 +
  22 +---
  23 +
  24 +## 二、架构
  25 +
  26 +```
  27 +React Router v6 (路由/渲染)
  28 + + tabsSlice (Redux) (Tab 列表 / activeId)
  29 + ↓
  30 +AppShell (深色顶栏 + Tab 栏 + NavOverlay)
  31 + ↓
  32 +页面组件 (LoginPage / MainPage / UserListPage / UserDetailPage)
  33 + ↓
  34 +api/ (Axios,保留) + store/slices/authSlice (保留)
  35 +```
  36 +
  37 +**Tab 切换机制**:
  38 +- Tab 切换 = `navigate(tab.path)` + Redux `activateTab(id)`
  39 +- 列表筛选条件持久化到 URL query string(如 `/usr/users?queryField=username&queryValue=管广飞`),切回时自动恢复
  40 +- 用户单据 Tab 固定 id `"userdetail"`,新增/编辑复用同一 Tab
  41 +
  42 +---
  43 +
  44 +## 三、路由结构
  45 +
  46 +| 路径 | 页面 | Tab 行为 |
  47 +|---|---|---|
  48 +| `/login` | LoginPage | 无 Shell,全屏 |
  49 +| `/` | MainPage | 固定 Tab "主页",不可关闭 |
  50 +| `/usr/users` | UserListPage | Tab "用户列表",可关闭 |
  51 +| `/usr/users/new` | UserDetailPage (新增模式) | Tab "用户信息单据",可关闭 |
  52 +| `/usr/users/:id` | UserDetailPage (编辑模式) | 复用 Tab "用户信息单据",可关闭 |
  53 +
  54 +---
  55 +
  56 +## 四、文件变更清单
  57 +
  58 +### 新建
  59 +
  60 +| 文件 | 职责 |
  61 +|---|---|
  62 +| `src/store/slices/tabsSlice.ts` | Tab 状态:`tabs: Tab[]`、`activeId: string \| null`;actions: `openTab` / `closeTab` / `activateTab` |
  63 +| `src/components/AppShell.tsx` | 深色顶栏 + Tab 栏 + NavOverlay 入口;包裹所有已登录页面 |
  64 +| `src/components/NavOverlay.tsx` | 全屏模块导航(按 prototype § nav-overlay 还原);当前阶段仅"用户列表"可点击,其余展示占位 |
  65 +| `src/pages/MainPage.tsx` | 主页占位 |
  66 +| `src/pages/usr/UserDetailPage.tsx` | 全屏用户单据页:深色工具栏(新增/修改/删除/保存/取消/作废/重置密码)+ 3 列表单网格 + 权限组 Tab 列表 |
  67 +
  68 +### 重写(逻辑不变,仅 UI 层)
  69 +
  70 +| 文件 | 改动说明 |
  71 +|---|---|
  72 +| `src/pages/usr/LoginPage.tsx` | 还原原型登录视觉:蓝色渐变 hero + 右侧白色登录卡片;API 调用逻辑保留 |
  73 +| `src/pages/usr/UserListPage.tsx` | 还原原型列表视觉:深色工具栏 + filterbar + 带单选圆点的表格;双击行导航到单据页 |
  74 +| `src/App.tsx` | 路由改用 AppShell 包裹;新增 `/usr/users/new` 和 `/usr/users/:id` 路由 |
  75 +| `src/styles/tokens.css` | 追加原型色值 token(见 § 五) |
  76 +
  77 +### 删除
  78 +
  79 +| 文件 | 原因 |
  80 +|---|---|
  81 +| `src/pages/usr/UserFormDrawer.tsx` | 由 UserDetailPage 全屏单据页替代 |
  82 +
  83 +### 保留不动
  84 +
  85 +- `src/api/` — auth.ts / usr.ts / request.ts
  86 +- `src/store/slices/authSlice.ts`
  87 +- `src/store/index.ts`、`src/store/hooks.ts`
  88 +- `src/components/PermButton.tsx`
  89 +
  90 +---
  91 +
  92 +## 五、新增 Design Tokens
  93 +
  94 +在 `src/styles/tokens.css` `:root` 块追加:
  95 +
  96 +| 变量名 | 值 | 用途 |
  97 +|---|---|---|
  98 +| `--color-topbar-bg` | `#1f1f23` | 顶栏背景 |
  99 +| `--color-toolbar-bg` | `#2c2f36` | 深色工具栏背景 |
  100 +| `--color-toolbar-text` | `#e6e7ea` | 深色工具栏文字 |
  101 +| `--color-tab-active` | `#1e84e6` | 活动 Tab 文字 / 下划线 |
  102 +| `--color-tab-text` | `#9aa0a8` | 非活动 Tab 文字 |
  103 +| `--color-field-bg-edit` | `#eaf3fe` | 可编辑表单字段背景 |
  104 +| `--color-field-label-req` | `#f04848` | 必填标签色(* 号) |
  105 +| `--color-table-border` | `#e3e6eb` | 表格边框 |
  106 +| `--color-table-row-alt` | `#f7f8fa` | 表格隔行背景色 |
  107 +| `--color-filterbar-bg` | `#ffffff` | 筛选栏背景 |
  108 +| `--color-nav-overlay-bg` | `#2b3137` | 导航 Overlay 背景 |
  109 +
  110 +---
  111 +
  112 +## 六、页面规格
  113 +
  114 +### 6.1 LoginPage
  115 +
  116 +- 全屏两分区:左侧蓝色渐变 hero(`radial-gradient` 深蓝)+ 网格背景 + 品牌文字;右侧白色登录卡片
  117 +- 卡片内:用户名输入框 / 密码输入框 / 公司版本下拉(从 `getBrands()` 加载)/ 登录按钮
  118 +- 登录成功后 → navigate('/') + 打开"主页" Tab
  119 +
  120 +### 6.2 AppShell
  121 +
  122 +- 高度 44px 深色顶栏,从左到右:Logo(鹿角 SVG)/ "全部导航"按钮 / Tab 列表 / 右侧用户信息
  123 +- Tab 列表:每个 Tab 含标题 + 关闭按钮(✕);活动 Tab 蓝色下划线;主页 Tab 无关闭按钮
  124 +- 点"全部导航"展开 NavOverlay;点 Tab 切换路由;点✕关闭 Tab 并跳转到前一个 Tab
  125 +
  126 +### 6.3 NavOverlay
  127 +
  128 +- 全屏覆盖层,背景 `#2b3137`
  129 +- 左侧:模块分类列表(系统管理高亮,其余占位)
  130 +- 右侧网格:按 prototype 还原 7 列模块链接;仅"用户列表"绑定 `openTab + navigate`
  131 +- 点击任意区域外(或再次点"全部导航")收起
  132 +
  133 +### 6.4 UserListPage
  134 +
  135 +- 深色工具栏:刷新 / 新增(`usr:create` 权限)/ 导出Excel
  136 +- Filterbar:查询字段 Select + 匹配方式 Select + 查询值 Input + 搜索按钮 + 清空按钮
  137 +- 表格:带单选圆点列 + 序号列 + 用户名/员工名/用户号/部门/用户类型/语言/作废/登录日期/制单人/制单日期
  138 +- 双击行 → navigate(`/usr/users/${row.sId}`)
  139 +- 点"新增" → navigate('/usr/users/new')
  140 +- 筛选条件同步到 URL query string
  141 +
  142 +### 6.5 UserDetailPage
  143 +
  144 +- 深色工具栏:新增 / 修改 / 删除 / 保存 / 取消 / 作废 / 重置密码
  145 +- 3 列表单网格(与原型一致):创建时间(只读)/ 制单人(只读)/ 员工名(下拉,可选)/ 用户名 / 类型 / 语言 / 用户号 / 单据修改权限(复选框)
  146 +- 可编辑字段背景 `--color-field-bg-edit`,只读字段背景 `--color-bg-input-readonly`
  147 +- 权限组 Tab:横排 Tab 栏(权限组 / 客户查看权限等占位)+ 权限列表(复选框 + 权限名)
  148 +- 新增模式:表单空白,保存调 `POST /api/usr/users`
  149 +- 编辑模式:行数据通过 React Router `location.state` 从列表页传入(后端无单条 GET by ID 接口);若直接访问 URL 但 state 为空,redirect 回 `/usr/users`
  150 +- 保存编辑调 `PUT /api/usr/users/:id`
  151 +- 删除、作废、重置密码:当前阶段按钮展示但不实现(REQ 范围外)
  152 +
  153 +---
  154 +
  155 +## 七、超出 REQ 范围的按钮处理
  156 +
  157 +工具栏中"删除"、"作废"、"取消作废"、"重置密码"在原型中存在,但当前 REQ-USR-001/002 未覆盖对应接口。这些按钮渲染但 `disabled`,tooltip 提示"功能待实现"。
frontend/src/App.tsx
1 import { Navigate, Route, Routes } from 'react-router-dom' 1 import { Navigate, Route, Routes } from 'react-router-dom'
2 import { useAppSelector } from './store/hooks' 2 import { useAppSelector } from './store/hooks'
  3 +import AppShell from './components/AppShell'
3 import LoginPage from './pages/usr/LoginPage' 4 import LoginPage from './pages/usr/LoginPage'
  5 +import MainPage from './pages/MainPage'
4 import UserListPage from './pages/usr/UserListPage' 6 import UserListPage from './pages/usr/UserListPage'
  7 +import UserDetailPage from './pages/usr/UserDetailPage'
5 8
6 -function PrivateRoute({ children }: { children: React.ReactNode }) { 9 +function AuthenticatedShell() {
7 const accessToken = useAppSelector(s => s.auth.accessToken) 10 const accessToken = useAppSelector(s => s.auth.accessToken)
8 - return accessToken ? <>{children}</> : <Navigate to="/login" replace /> 11 + if (!accessToken) return <Navigate to="/login" replace />
  12 + return <AppShell />
9 } 13 }
10 14
11 export default function App() { 15 export default function App() {
12 return ( 16 return (
13 <Routes> 17 <Routes>
14 <Route path="/login" element={<LoginPage />} /> 18 <Route path="/login" element={<LoginPage />} />
15 - <Route path="/" element={<PrivateRoute><div>主页(待实现)</div></PrivateRoute>} />  
16 - <Route path="/usr/users" element={<PrivateRoute><UserListPage /></PrivateRoute>} /> 19 + <Route element={<AuthenticatedShell />}>
  20 + <Route path="/" element={<MainPage />} />
  21 + <Route path="/usr/users" element={<UserListPage />} />
  22 + <Route path="/usr/users/new" element={<UserDetailPage />} />
  23 + <Route path="/usr/users/:id" element={<UserDetailPage />} />
  24 + </Route>
17 <Route path="*" element={<Navigate to="/" replace />} /> 25 <Route path="*" element={<Navigate to="/" replace />} />
18 </Routes> 26 </Routes>
19 ) 27 )
frontend/src/components/AppShell.tsx 0 → 100644
  1 +import { useState } from 'react'
  2 +import { Outlet, useNavigate } from 'react-router-dom'
  3 +import { useAppSelector, useAppDispatch } from '../store/hooks'
  4 +import { closeTab, activateTab } from '../store/slices/tabsSlice'
  5 +import NavOverlay from './NavOverlay'
  6 +
  7 +const ANTLER_PATHS = [
  8 + 'M14 10c2 4 1 8-1 11 3-1 7 0 10 3 1-4 4-7 8-7-3 3-4 7-3 11l4 1c-1 3 0 6 3 8-3 0-6 1-8 4-1-3-4-5-8-5 2-3 2-7 0-10-3 1-7 0-10-3 3 0 5-2 6-5l-1-8z',
  9 + 'M48 14c-2 3-2 6-1 9-2-2-5-2-8-1 1 3 1 6-1 9 3 0 5 2 6 5 1-3 4-5 7-5-2-3-2-6 0-9 2 1 5 1 7-1-2 0-4-1-5-3-1-2-3-4-5-4z',
  10 + 'M28 38c2 3 5 5 9 5 1 4 4 7 8 8-3 2-5 5-5 9-3-2-7-3-11-2 1-3 1-7-1-10-3 0-6-1-8-4 3-1 6-3 8-6z',
  11 +]
  12 +
  13 +export default function AppShell() {
  14 + const dispatch = useAppDispatch()
  15 + const navigate = useNavigate()
  16 + const { tabs, activeId } = useAppSelector(s => s.tabs)
  17 + const userInfo = useAppSelector(s => s.auth.userInfo)
  18 + const [navOpen, setNavOpen] = useState(false)
  19 +
  20 + function handleTabClick(id: string, path: string) {
  21 + dispatch(activateTab(id))
  22 + navigate(path)
  23 + }
  24 +
  25 + function handleTabClose(e: React.MouseEvent, id: string) {
  26 + e.stopPropagation()
  27 + const idx = tabs.findIndex(t => t.id === id)
  28 + dispatch(closeTab(id))
  29 + const remaining = tabs.filter(t => t.id !== id)
  30 + const newIdx = Math.min(idx, remaining.length - 1)
  31 + if (remaining[newIdx]) navigate(remaining[newIdx].path)
  32 + }
  33 +
  34 + return (
  35 + <div style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}>
  36 + {/* Topbar */}
  37 + <div style={{ display: 'flex', alignItems: 'stretch', height: 44, background: 'var(--color-topbar-bg)', color: '#fff', position: 'relative', zIndex: 30, flexShrink: 0 }}>
  38 + {/* Logo */}
  39 + <div style={{ width: 54, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
  40 + <svg viewBox="0 0 64 64" width={30} height={30} fill="#0e1216">
  41 + {ANTLER_PATHS.map((d, i) => <path key={i} d={d} />)}
  42 + </svg>
  43 + </div>
  44 + {/* Nav toggle */}
  45 + <button
  46 + aria-label="全部导航"
  47 + onClick={() => setNavOpen(v => !v)}
  48 + style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '0 18px', color: '#fff', cursor: 'pointer', fontSize: 14, border: 'none', background: navOpen ? 'var(--color-primary)' : 'transparent', height: '100%', fontFamily: 'inherit' }}
  49 + >
  50 + <svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
  51 + <line x1={4} y1={7} x2={20} y2={7} /><line x1={4} y1={12} x2={20} y2={12} /><line x1={4} y1={17} x2={20} y2={17} />
  52 + </svg>
  53 + 全部导航
  54 + </button>
  55 + {/* Tab list */}
  56 + <div style={{ display: 'flex', alignItems: 'stretch', flex: 1 }}>
  57 + {tabs.map(tab => (
  58 + <div
  59 + key={tab.id}
  60 + role="tab"
  61 + aria-selected={tab.id === activeId}
  62 + onClick={() => handleTabClick(tab.id, tab.path)}
  63 + style={{
  64 + display: 'flex', alignItems: 'center', gap: 8, padding: '0 18px', cursor: 'pointer', fontSize: 14, height: '100%',
  65 + color: tab.id === activeId ? 'var(--color-tab-active)' : 'var(--color-tab-text)',
  66 + borderBottom: tab.id === activeId ? '2px solid var(--color-tab-active)' : 'none',
  67 + }}
  68 + >
  69 + {tab.title}
  70 + {tab.closable && (
  71 + <button
  72 + aria-label={`关闭 ${tab.title}`}
  73 + onClick={e => handleTabClose(e, tab.id)}
  74 + style={{ marginLeft: 6, width: 14, height: 14, borderRadius: '50%', border: 'none', background: 'transparent', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, color: 'var(--color-tab-text)', cursor: 'pointer', padding: 0 }}
  75 + >
  76 + ✕
  77 + </button>
  78 + )}
  79 + </div>
  80 + ))}
  81 + </div>
  82 + {/* User info */}
  83 + <div style={{ display: 'flex', alignItems: 'center', gap: 6, paddingRight: 14, fontSize: 14 }}>
  84 + {userInfo?.username}({userInfo?.userType})
  85 + </div>
  86 + </div>
  87 + {/* Stage */}
  88 + <div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
  89 + {navOpen && <NavOverlay onClose={() => setNavOpen(false)} />}
  90 + <div style={{ position: 'absolute', inset: 0, overflow: 'auto' }}>
  91 + <Outlet />
  92 + </div>
  93 + </div>
  94 + </div>
  95 + )
  96 +}
frontend/src/components/NavOverlay.tsx 0 → 100644
  1 +import { useNavigate } from 'react-router-dom'
  2 +import { useAppDispatch } from '../store/hooks'
  3 +import { openTab } from '../store/slices/tabsSlice'
  4 +
  5 +const LEFT_MODULES = [
  6 + '销售管理', 'DCS系统', '产品管理', '生产运营', '生产执行', '模具管理',
  7 + '采购管理', '材料库存', '成品库存', '外协管理', '物流管理', '质量管理',
  8 + '财务管理', '成本管理(专)', '成本管理', '设备管理', '人事行政', 'OA系统',
  9 + '基础设置', '系统设置',
  10 +]
  11 +
  12 +type NavItem = string | { label: string; go?: string; star?: boolean }
  13 +
  14 +const NAV_COLS: { title: string; items: NavItem[] }[] = [
  15 + { title: '期初设置', items: ['客户期初', '供应商期初', '材料期初', '产品期初', '数据导入', '离线导出下载'] },
  16 + { title: '用户管理', items: [{ label: '用户列表', go: 'userlist', star: true }, '系统权限', '系统权限稽查表', '权限组'] },
  17 + { title: '系统参数', items: ['系统参数', '财务结账', '系统常量配置'] },
  18 + { title: '计算方案', items: ['方案列表', '计算参数'] },
  19 + { title: '日志', items: ['个性化模块', '操作日志', '异常清除KPI任务表', 'MYSQL监听器'] },
  20 + { title: '开发平台', items: ['自定义开发范例', { label: '系统功能模块设置', star: true }, 'EBC流程清单', '功能模块界面设置', '增删改存业务处理'] },
  21 + { title: 'API对接管理', items: ['调用第三方接口(TOKEN配置)', '调用第三方接口(接口定义)', '被第三方调用(生成token)', '数据同步', '被第三方调用(API定义)'] },
  22 +]
  23 +
  24 +interface Props { onClose: () => void }
  25 +
  26 +export default function NavOverlay({ onClose }: Props) {
  27 + const navigate = useNavigate()
  28 + const dispatch = useAppDispatch()
  29 +
  30 + function handleUserList() {
  31 + dispatch(openTab({ id: 'userlist', title: '用户列表', path: '/usr/users', closable: true }))
  32 + navigate('/usr/users')
  33 + onClose()
  34 + }
  35 +
  36 + return (
  37 + <div
  38 + data-testid="nav-bg"
  39 + onClick={onClose}
  40 + style={{ position: 'absolute', inset: 0, background: 'var(--color-nav-overlay-bg)', zIndex: 20, color: '#cfd3da', display: 'flex' }}
  41 + >
  42 + <div onClick={e => e.stopPropagation()} style={{ display: 'flex', flex: 1 }}>
  43 + {/* Left sidebar */}
  44 + <div style={{ width: 200, background: 'var(--color-nav-overlay-bg)', borderRight: '1px solid var(--color-nav-sidebar-border)', padding: '8px 0', overflowY: 'auto' }}>
  45 + {LEFT_MODULES.map(mod => (
  46 + <div
  47 + key={mod}
  48 + style={{
  49 + display: 'flex', alignItems: 'center', gap: 10, padding: '11px 18px', fontSize: 14, cursor: 'pointer',
  50 + color: mod === '系统设置' ? 'var(--color-tab-active)' : 'var(--color-nav-item-text)',
  51 + background: mod === '系统设置' ? 'var(--color-nav-sidebar-active-bg)' : 'transparent',
  52 + }}
  53 + >
  54 + {mod}
  55 + </div>
  56 + ))}
  57 + </div>
  58 + {/* Right grid */}
  59 + <div style={{ flex: 1, padding: '30px 40px', display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '30px 40px', alignContent: 'start', overflowY: 'auto' }}>
  60 + {NAV_COLS.map(col => (
  61 + <div key={col.title}>
  62 + <h3 style={{ fontSize: 15, color: 'var(--color-nav-col-header)', fontWeight: 500, margin: '0 0 18px', borderBottom: '1px solid var(--color-nav-col-divider)', paddingBottom: 10 }}>
  63 + {col.title}
  64 + </h3>
  65 + {col.items.map(item => {
  66 + if (typeof item === 'string') {
  67 + return <div key={item} style={{ padding: '7px 0', color: 'var(--color-nav-item-text)', fontSize: 14 }}>{item}</div>
  68 + }
  69 + return (
  70 + <div
  71 + key={item.label}
  72 + onClick={item.go === 'userlist' ? handleUserList : undefined}
  73 + style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '7px 0', color: 'var(--color-nav-item-text)', fontSize: 14, cursor: item.go === 'userlist' ? 'pointer' : 'default' }}
  74 + >
  75 + {item.label}
  76 + {item.star && <span style={{ color: 'var(--color-nav-item-star)' }}>★</span>}
  77 + </div>
  78 + )
  79 + })}
  80 + </div>
  81 + ))}
  82 + </div>
  83 + </div>
  84 + </div>
  85 + )
  86 +}
frontend/src/pages/MainPage.tsx 0 → 100644
  1 +import { useEffect } from 'react'
  2 +import { useAppDispatch } from '../store/hooks'
  3 +import { activateTab } from '../store/slices/tabsSlice'
  4 +
  5 +export default function MainPage() {
  6 + const dispatch = useAppDispatch()
  7 + useEffect(() => {
  8 + dispatch(activateTab('main'))
  9 + }, [dispatch])
  10 +
  11 + return (
  12 + <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: '#888', fontSize: 16 }}>
  13 + 主页(功能待实现)
  14 + </div>
  15 + )
  16 +}
frontend/src/pages/usr/LoginPage.tsx
1 import { useEffect, useState } from 'react' 1 import { useEffect, useState } from 'react'
2 import { useNavigate } from 'react-router-dom' 2 import { useNavigate } from 'react-router-dom'
3 -import { Button, Form, Input, message, Select } from 'antd'  
4 -import { getBrands, login, BrandVO } from '../../api/auth' 3 +import { Button, Form, Input, Select, message } from 'antd'
  4 +import { getBrands, login, type BrandVO } from '../../api/auth'
5 import { setCredentials } from '../../store/slices/authSlice' 5 import { setCredentials } from '../../store/slices/authSlice'
  6 +import { activateTab } from '../../store/slices/tabsSlice'
6 import { useAppDispatch } from '../../store/hooks' 7 import { useAppDispatch } from '../../store/hooks'
7 8
  9 +const ANTLER_PATHS = [
  10 + 'M14 10c2 4 1 8-1 11 3-1 7 0 10 3 1-4 4-7 8-7-3 3-4 7-3 11l4 1c-1 3 0 6 3 8-3 0-6 1-8 4-1-3-4-5-8-5 2-3 2-7 0-10-3 1-7 0-10-3 3 0 5-2 6-5l-1-8z',
  11 + 'M48 14c-2 3-2 6-1 9-2-2-5-2-8-1 1 3 1 6-1 9 3 0 5 2 6 5 1-3 4-5 7-5-2-3-2-6 0-9 2 1 5 1 7-1-2 0-4-1-5-3-1-2-3-4-5-4z',
  12 + 'M28 38c2 3 5 5 9 5 1 4 4 7 8 8-3 2-5 5-5 9-3-2-7-3-11-2 1-3 1-7-1-10-3 0-6-1-8-4 3-1 6-3 8-6z',
  13 +]
  14 +
8 export default function LoginPage() { 15 export default function LoginPage() {
9 const [brandOptions, setBrandOptions] = useState<BrandVO[]>([]) 16 const [brandOptions, setBrandOptions] = useState<BrandVO[]>([])
10 const [loading, setLoading] = useState(false) 17 const [loading, setLoading] = useState(false)
@@ -19,17 +26,14 @@ export default function LoginPage() { @@ -19,17 +26,14 @@ export default function LoginPage() {
19 const defaultNo = std ? std.sNo : brands[0]?.sNo 26 const defaultNo = std ? std.sNo : brands[0]?.sNo
20 if (defaultNo) form.setFieldValue('brandNo', defaultNo) 27 if (defaultNo) form.setFieldValue('brandNo', defaultNo)
21 }).catch(() => {}) 28 }).catch(() => {})
22 - }, []) 29 + }, [form])
23 30
24 const onFinish = async (values: { brandNo: string; username: string; password: string }) => { 31 const onFinish = async (values: { brandNo: string; username: string; password: string }) => {
25 setLoading(true) 32 setLoading(true)
26 try { 33 try {
27 const result = await login(values) 34 const result = await login(values)
28 - dispatch(setCredentials({  
29 - accessToken: result.accessToken,  
30 - refreshToken: result.refreshToken,  
31 - userInfo: result.userInfo  
32 - })) 35 + dispatch(setCredentials({ accessToken: result.accessToken, refreshToken: result.refreshToken, userInfo: result.userInfo }))
  36 + dispatch(activateTab('main'))
33 navigate('/') 37 navigate('/')
34 } catch (e: unknown) { 38 } catch (e: unknown) {
35 message.error(e instanceof Error ? e.message : '登录失败') 39 message.error(e instanceof Error ? e.message : '登录失败')
@@ -39,28 +43,49 @@ export default function LoginPage() { @@ -39,28 +43,49 @@ export default function LoginPage() {
39 } 43 }
40 44
41 return ( 45 return (
42 - <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>  
43 - <div style={{ width: 360 }}>  
44 - <h2 style={{ textAlign: 'center', marginBottom: 24 }}>小羚羊 ERP</h2>  
45 - <Form form={form} layout="vertical" onFinish={onFinish}>  
46 - <Form.Item label="公司/版本" name="brandNo" rules={[{ required: true, message: '请选择公司/版本' }]}>  
47 - <Select  
48 - options={brandOptions.map(b => ({ value: b.sNo, label: b.sName }))}  
49 - placeholder="请选择公司/版本"  
50 - />  
51 - </Form.Item>  
52 - <Form.Item label="用户名" name="username" rules={[{ required: true, message: '请输入用户名' }]}>  
53 - <Input placeholder="请输入用户名" />  
54 - </Form.Item>  
55 - <Form.Item label="密码" name="password" rules={[{ required: true, message: '请输入密码' }]}>  
56 - <Input.Password placeholder="请输入密码" />  
57 - </Form.Item>  
58 - <Form.Item>  
59 - <Button type="primary" htmlType="submit" loading={loading} block>  
60 - 登录  
61 - </Button>  
62 - </Form.Item>  
63 - </Form> 46 + <div style={{ position: 'absolute', inset: 0, background: 'var(--color-login-bg)', display: 'flex', flexDirection: 'column' }}>
  47 + {/* Header */}
  48 + <div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '18px 36px' }}>
  49 + <div style={{ width: 42, height: 42, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
  50 + <svg viewBox="0 0 64 64" width={42} height={42} fill="#0e1216">
  51 + {ANTLER_PATHS.map((d, i) => <path key={i} d={d} />)}
  52 + </svg>
  53 + </div>
  54 + <span style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-login-brand)', letterSpacing: 2 }}>Antler ERP</span>
  55 + <span style={{ color: '#444', fontSize: 14, marginLeft: 6 }}>欢迎登录EBC平台</span>
  56 + </div>
  57 + {/* Hero */}
  58 + <div style={{ flex: 1, position: 'relative', background: 'radial-gradient(ellipse at center, var(--color-login-hero-start) 0%, var(--color-login-hero-mid) 60%, var(--color-login-hero-end) 100%)', overflow: 'hidden' }}>
  59 + {/* Brand text */}
  60 + <div style={{ position: 'absolute', left: '8%', top: '35%', color: '#fff', zIndex: 2 }}>
  61 + <div style={{ fontSize: 30, fontWeight: 300, color: '#cfe1ff', marginBottom: 6 }}>Enterprise Business Capability</div>
  62 + <div style={{ fontSize: 54, fontWeight: 700, letterSpacing: 4, marginBottom: 4 }}>企业业务能力平台</div>
  63 + <div style={{ fontSize: 90, fontWeight: 800, letterSpacing: 8, lineHeight: 0.9 }}>ERP</div>
  64 + </div>
  65 + {/* Login card */}
  66 + <div style={{ position: 'absolute', right: '8%', top: '50%', transform: 'translateY(-50%)', background: '#fff', width: 380, padding: '36px 32px', borderRadius: 2, boxShadow: '0 12px 40px rgba(0,0,0,.3)', zIndex: 3 }}>
  67 + <h3 style={{ margin: '0 0 22px', fontSize: 18, color: '#333', fontWeight: 500 }}>用户登录</h3>
  68 + <Form form={form} layout="vertical" onFinish={onFinish}>
  69 + <Form.Item label="公司/版本" name="brandNo" rules={[{ required: true, message: '请选择公司/版本' }]}>
  70 + <Select options={brandOptions.map(b => ({ value: b.sNo, label: b.sName }))} placeholder="请选择公司/版本" />
  71 + </Form.Item>
  72 + <Form.Item label="用户名" name="username" rules={[{ required: true, message: '请输入用户名' }]}>
  73 + <Input placeholder="请输入用户名" />
  74 + </Form.Item>
  75 + <Form.Item label="密码" name="password" rules={[{ required: true, message: '请输入密码' }]}>
  76 + <Input.Password placeholder="请输入密码" />
  77 + </Form.Item>
  78 + <Form.Item>
  79 + <Button type="primary" htmlType="submit" loading={loading} block style={{ height: 42, fontSize: 15, letterSpacing: 8, borderRadius: 2 }}>
  80 + 登 录
  81 + </Button>
  82 + </Form.Item>
  83 + </Form>
  84 + </div>
  85 + </div>
  86 + {/* Footer */}
  87 + <div style={{ background: 'var(--color-login-bg)', textAlign: 'center', padding: '14px 8px', color: 'var(--color-login-text-muted)', fontSize: 12, borderTop: '1px solid var(--color-login-border)' }}>
  88 + 🛠 ©Copyright Antler Software | 印刷ERP | 400-880-6237
64 </div> 89 </div>
65 </div> 90 </div>
66 ) 91 )
frontend/src/pages/usr/UserDetailPage.tsx 0 → 100644
  1 +import { useEffect, useState } from 'react'
  2 +import { useNavigate, useLocation, useParams } from 'react-router-dom'
  3 +import { message } from 'antd'
  4 +import { useAppDispatch } from '../../store/hooks'
  5 +import { openTab } from '../../store/slices/tabsSlice'
  6 +import { getStaffs, getPermissionGroups, createUser, updateUser } from '../../api/usr'
  7 +import type { StaffVO, PermissionGroupVO, UserListItemVO, UserCreateReq, UserUpdateReq } from '../../api/usr'
  8 +
  9 +// REQ-USR-001: 增加用户
  10 +// REQ-USR-002: 修改用户
  11 +export default function UserDetailPage() {
  12 + const { id } = useParams()
  13 + const navigate = useNavigate()
  14 + const location = useLocation()
  15 + const dispatch = useAppDispatch()
  16 +
  17 + const isNew = !id
  18 + const rowData = location.state as UserListItemVO | null
  19 +
  20 + const [staffs, setStaffs] = useState<StaffVO[]>([])
  21 + const [permGroups, setPermGroups] = useState<PermissionGroupVO[]>([])
  22 + const [selectedPermIds, setSelectedPermIds] = useState<string[]>([])
  23 + const [submitting, setSubmitting] = useState(false)
  24 + const [activePermTab, setActivePermTab] = useState(0)
  25 +
  26 + const [userCode, setUserCode] = useState('')
  27 + const [username, setUsername] = useState('')
  28 + const [userType, setUserType] = useState<'普通用户' | '超级管理员'>('普通用户')
  29 + const [language, setLanguage] = useState<'中文' | '英文' | '繁体'>('中文')
  30 + const [canEditDoc, setCanEditDoc] = useState(false)
  31 + const [isDisabled, setIsDisabled] = useState(false)
  32 + const [employeeId, setEmployeeId] = useState<string | null>(null)
  33 +
  34 + useEffect(() => {
  35 + if (!isNew && !rowData) {
  36 + navigate('/usr/users', { replace: true })
  37 + return
  38 + }
  39 + dispatch(openTab({ id: 'userdetail', title: '用户信息单据', path: location.pathname, closable: true }))
  40 + getStaffs().then(setStaffs).catch(() => {})
  41 + getPermissionGroups().then(setPermGroups).catch(() => {})
  42 + if (!isNew && rowData) {
  43 + setUserType(rowData.sUserType as '普通用户' | '超级管理员')
  44 + setLanguage(rowData.sLanguage as '中文' | '英文' | '繁体')
  45 + setCanEditDoc(rowData.bCanEditDoc === 1)
  46 + setIsDisabled(rowData.bIsDisabled === 1)
  47 + }
  48 + }, []) // eslint-disable-line react-hooks/exhaustive-deps
  49 +
  50 + async function handleSave() {
  51 + if (isNew) {
  52 + if (!userCode.trim()) { message.error('请输入用户号'); return }
  53 + if (!username.trim()) { message.error('请输入用户名'); return }
  54 + }
  55 + setSubmitting(true)
  56 + try {
  57 + if (isNew) {
  58 + const req: UserCreateReq = { userCode, username, userType, language, canEditDoc, employeeId, permGroupIds: selectedPermIds }
  59 + await createUser(req)
  60 + message.success('新增用户成功')
  61 + } else {
  62 + const req: UserUpdateReq = { userType, language, canEditDoc, isDisabled, employeeId, permGroupIds: selectedPermIds }
  63 + await updateUser(id!, req)
  64 + message.success('修改用户成功')
  65 + }
  66 + navigate('/usr/users')
  67 + } catch (e: unknown) {
  68 + if (e instanceof Error) message.error(e.message)
  69 + } finally {
  70 + setSubmitting(false)
  71 + }
  72 + }
  73 +
  74 + const PERM_TABS = ['权限组', '客户查看权限', '供应商查看权限', '人员查看权限', '工序查看权限', '司机查看权限']
  75 +
  76 + const fieldBg = 'var(--color-field-bg-edit)'
  77 + const readonlyBg = 'var(--color-form-bg-readonly)'
  78 + const inputStyle: React.CSSProperties = { flex: 1, height: 28, border: '1px solid #d5d8de', borderRadius: 2, padding: '0 10px', background: fieldBg, minWidth: 0, fontSize: 13, fontFamily: 'inherit' }
  79 + const roStyle: React.CSSProperties = { flex: 1, height: 28, border: '1px solid #d5d8de', borderRadius: 2, padding: '0 10px', background: readonlyBg, color: '#444', display: 'flex', alignItems: 'center', fontSize: 13, minWidth: 0, overflow: 'hidden' }
  80 + const selectStyle: React.CSSProperties = { ...inputStyle, padding: '0 8px' }
  81 + const cellStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px' }
  82 + const lblStyle: React.CSSProperties = { minWidth: 88, color: '#333', fontSize: 13, textAlign: 'right', flexShrink: 0 }
  83 + const reqLblStyle: React.CSSProperties = { ...lblStyle, color: 'var(--color-field-label-req)' }
  84 +
  85 + return (
  86 + <div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
  87 + {/* Dark toolbar */}
  88 + <div style={{ background: 'var(--color-toolbar-bg)', color: 'var(--color-toolbar-text)', display: 'flex', alignItems: 'center', gap: 6, padding: '0 8px', height: 38, flexShrink: 0 }}>
  89 + {[
  90 + { label: '⊕ 新增', onClick: () => navigate('/usr/users/new'), disabled: false },
  91 + { label: '✎ 修改', onClick: undefined, disabled: true },
  92 + { label: '🗑 删除', onClick: undefined, disabled: true, title: '功能待实现' },
  93 + { label: '💾 保存', onClick: handleSave, disabled: submitting },
  94 + { label: '✕ 取消', onClick: () => navigate('/usr/users'), disabled: false },
  95 + { label: '作废', onClick: undefined, disabled: true, title: '功能待实现' },
  96 + { label: '重置密码', onClick: undefined, disabled: true, title: '功能待实现' },
  97 + ].map(btn => (
  98 + <button
  99 + key={btn.label}
  100 + onClick={btn.onClick}
  101 + disabled={btn.disabled}
  102 + title={btn.title}
  103 + style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '6px 12px', color: btn.disabled ? '#666' : 'var(--color-toolbar-text)', cursor: btn.disabled ? 'not-allowed' : 'pointer', fontSize: 13, borderRadius: 2, border: 'none', background: 'transparent', fontFamily: 'inherit', opacity: btn.disabled ? 0.5 : 1 }}
  104 + >
  105 + {btn.label}
  106 + </button>
  107 + ))}
  108 + <span style={{ flex: 1 }} />
  109 + <span style={{ padding: '6px 8px', color: '#cfd2d8', cursor: 'pointer' }}>⚙</span>
  110 + </div>
  111 +
  112 + {/* 3-column form grid */}
  113 + <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', background: '#fff', padding: '10px 14px', borderBottom: '1px solid var(--color-table-border)', flexShrink: 0 }}>
  114 + {/* Row 1: 创建时间 / 制单人 / 员工名 */}
  115 + <div style={cellStyle}>
  116 + <span style={lblStyle}>创建时间:</span>
  117 + <div style={roStyle}>{isNew ? '' : rowData?.tCreateDate}</div>
  118 + </div>
  119 + <div style={cellStyle}>
  120 + <span style={lblStyle}>制单人:</span>
  121 + <div style={roStyle}>{isNew ? '保存后自动生成' : rowData?.sCreatorUsername}</div>
  122 + </div>
  123 + <div style={cellStyle}>
  124 + <span style={reqLblStyle}>*员工名:</span>
  125 + <select value={employeeId ?? ''} onChange={e => setEmployeeId(e.target.value || null)} style={{ ...selectStyle, backgroundImage: 'url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'10\' height=\'10\' viewBox=\'0 0 10 10\'><path d=\'M2 3l3 4 3-4z\' fill=\'%23888\'/></svg>")', backgroundRepeat: 'no-repeat', backgroundPosition: 'right 8px center', paddingRight: 24 }}>
  126 + <option value="">— 不关联 —</option>
  127 + {staffs.map(s => <option key={s.sId} value={s.sId}>{s.sStaffName}</option>)}
  128 + </select>
  129 + </div>
  130 + {/* Row 2: 用户名 / 类型 / 语言 */}
  131 + <div style={cellStyle}>
  132 + <span style={isNew ? reqLblStyle : lblStyle}>{isNew ? '*用户名:' : '用户名:'}</span>
  133 + {isNew
  134 + ? <input type="text" value={username} onChange={e => setUsername(e.target.value)} placeholder="请输入用户名" style={inputStyle} />
  135 + : <div style={roStyle}>{rowData?.sUsername}</div>}
  136 + </div>
  137 + <div style={cellStyle}>
  138 + <span style={reqLblStyle}>*类型:</span>
  139 + <select value={userType} onChange={e => setUserType(e.target.value as typeof userType)} style={selectStyle}>
  140 + <option value="普通用户">普通用户</option>
  141 + <option value="超级管理员">超级管理员</option>
  142 + </select>
  143 + </div>
  144 + <div style={cellStyle}>
  145 + <span style={reqLblStyle}>*语言:</span>
  146 + <select value={language} onChange={e => setLanguage(e.target.value as typeof language)} style={selectStyle}>
  147 + <option value="中文">中文</option>
  148 + <option value="英文">English</option>
  149 + <option value="繁体">繁體</option>
  150 + </select>
  151 + </div>
  152 + {/* Row 3: 用户号 / (empty) / 单据修改权限 */}
  153 + <div style={cellStyle}>
  154 + <span style={isNew ? reqLblStyle : lblStyle}>{isNew ? '*用户号:' : '用户号:'}</span>
  155 + {isNew
  156 + ? <input type="text" value={userCode} onChange={e => setUserCode(e.target.value)} placeholder="请输入用户号" style={inputStyle} />
  157 + : <div style={roStyle}>{rowData?.sUserCode}</div>}
  158 + </div>
  159 + <div style={cellStyle} />
  160 + <div style={cellStyle}>
  161 + <span style={lblStyle}>单据修改权限:</span>
  162 + <input type="checkbox" aria-hidden="true" checked={canEditDoc} onChange={e => setCanEditDoc(e.target.checked)} style={{ width: 14, height: 14 }} />
  163 + </div>
  164 + </div>
  165 +
  166 + {/* Permission tabs row */}
  167 + <div style={{ display: 'flex', background: '#fff', borderBottom: '1px solid var(--color-table-border)', padding: '0 6px', flexShrink: 0 }}>
  168 + {PERM_TABS.map((tab, i) => (
  169 + <div
  170 + key={tab}
  171 + onClick={() => setActivePermTab(i)}
  172 + style={{ padding: '11px 18px', fontSize: 14, color: i === activePermTab ? 'var(--color-tab-active)' : '#444', cursor: 'pointer', borderBottom: i === activePermTab ? `2px solid var(--color-tab-active)` : 'none', marginRight: 4 }}
  173 + >
  174 + {tab}
  175 + </div>
  176 + ))}
  177 + </div>
  178 +
  179 + {/* Permission list
  180 + KNOWN LIMITATION (edit mode): UserListItemVO does not include permGroupIds (backend list API
  181 + does not return them). In edit mode, selectedPermIds starts empty — saving will overwrite
  182 + existing permissions with whatever the user selects here. A future backend GET /usr/users/:id
  183 + endpoint should return permGroupIds so they can be pre-populated. */}
  184 + {!isNew && activePermTab === 0 && (
  185 + <div style={{ background: '#fffbe6', borderBottom: '1px solid #ffe58f', padding: '8px 14px', fontSize: 12, color: '#ad6800' }}>
  186 + ⚠ 编辑模式下权限组未预加载(后端列表接口不返回 permGroupIds)。保存将以当前勾选为准,请重新勾选所需权限组。
  187 + </div>
  188 + )}
  189 + <div style={{ flex: 1, background: '#fff', overflow: 'auto' }}>
  190 + <div style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '10px 14px', borderBottom: '1px solid var(--color-table-border)', background: 'var(--color-table-header-bg)', fontWeight: 500, color: '#222', fontSize: 13 }}>
  191 + <span style={{ width: 14, height: 14, border: '1px solid #b8bcc3', display: 'inline-block', flexShrink: 0 }} />
  192 + <span>权限分类</span>
  193 + </div>
  194 + {activePermTab === 0
  195 + ? permGroups.map(pg => (
  196 + <div key={pg.sId} style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '10px 14px', borderBottom: '1px solid var(--color-table-border)', fontSize: 13, color: '#333' }}>
  197 + <input
  198 + type="checkbox"
  199 + checked={selectedPermIds.includes(pg.sId)}
  200 + onChange={e => setSelectedPermIds(prev => e.target.checked ? [...prev, pg.sId] : prev.filter(x => x !== pg.sId))}
  201 + style={{ width: 14, height: 14, flexShrink: 0 }}
  202 + />
  203 + <span>{pg.sGroupName}</span>
  204 + </div>
  205 + ))
  206 + : <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 100, color: '#aaa' }}>功能待实现</div>}
  207 + </div>
  208 + </div>
  209 + )
  210 +}
frontend/src/pages/usr/UserListPage.tsx
1 -import { useState, useEffect } from 'react'  
2 -import { Table, Select, Input, Button, Space } from 'antd'  
3 -import type { ColumnsType } from 'antd/es/table' 1 +import { useEffect, useState } from 'react'
  2 +import { useNavigate, useSearchParams, useLocation } from 'react-router-dom'
  3 +import { useAppDispatch } from '../../store/hooks'
  4 +import { openTab, updateTabPath } from '../../store/slices/tabsSlice'
4 import { PermButton } from '../../components/PermButton' 5 import { PermButton } from '../../components/PermButton'
5 -import UserFormDrawer from './UserFormDrawer'  
6 import { getUserList } from '../../api/usr' 6 import { getUserList } from '../../api/usr'
7 import type { PageVO, UserListItemVO } from '../../api/usr' 7 import type { PageVO, UserListItemVO } from '../../api/usr'
8 8
@@ -24,110 +24,136 @@ const MATCH_TYPES = [ @@ -24,110 +24,136 @@ const MATCH_TYPES = [
24 ] 24 ]
25 25
26 // REQ-USR-003: 查询用户 26 // REQ-USR-003: 查询用户
27 -// REQ-USR-002: 修改用户 27 +// REQ-USR-001: 新增入口 (navigate to /usr/users/new)
  28 +// REQ-USR-002: 修改入口 (double-click row → /usr/users/:id)
28 export default function UserListPage() { 29 export default function UserListPage() {
29 - const [drawerOpen, setDrawerOpen] = useState(false)  
30 - const [editingUser, setEditingUser] = useState<UserListItemVO | null>(null) 30 + const navigate = useNavigate()
  31 + const dispatch = useAppDispatch()
  32 + const location = useLocation()
  33 + const [searchParams, setSearchParams] = useSearchParams()
  34 +
  35 + const [queryField, setQueryField] = useState(searchParams.get('queryField') ?? 'username')
  36 + const [matchType, setMatchType] = useState(searchParams.get('matchType') ?? 'contains')
  37 + const [queryValue, setQueryValue] = useState(searchParams.get('queryValue') ?? '')
  38 + const [currentPage, setCurrentPage] = useState(Number(searchParams.get('page') ?? '1'))
31 const [data, setData] = useState<PageVO<UserListItemVO> | null>(null) 39 const [data, setData] = useState<PageVO<UserListItemVO> | null>(null)
32 - const [queryField, setQueryField] = useState('username')  
33 - const [matchType, setMatchType] = useState('contains')  
34 - const [queryValue, setQueryValue] = useState('')  
35 - const [currentPage, setCurrentPage] = useState(1) 40 + const [selectedId, setSelectedId] = useState<string | null>(null)
  41 +
  42 + useEffect(() => {
  43 + dispatch(openTab({ id: 'userlist', title: '用户列表', path: location.pathname + location.search, closable: true }))
  44 + doLoad(currentPage, queryField, matchType, queryValue)
  45 + }, [])
36 46
37 - const load = (pg = 1) => { 47 + function doLoad(pg: number, field: string, match: string, val: string) {
38 setCurrentPage(pg) 48 setCurrentPage(pg)
39 - getUserList({ queryField, matchType, queryValue, page: pg, pageSize: 20 }).then(setData) 49 + getUserList({ queryField: field, matchType: match, queryValue: val, page: pg, pageSize: 20 }).then(setData)
40 } 50 }
41 51
42 - useEffect(() => {  
43 - load()  
44 - }, []) 52 + function handleSearch() {
  53 + const newPath = `/usr/users?queryField=${queryField}&matchType=${matchType}&queryValue=${encodeURIComponent(queryValue)}`
  54 + setSearchParams({ queryField, matchType, queryValue, page: '1' })
  55 + dispatch(updateTabPath({ id: 'userlist', path: newPath }))
  56 + doLoad(1, queryField, matchType, queryValue)
  57 + }
45 58
46 - const columns: ColumnsType<UserListItemVO> = [  
47 - { title: '用户名', dataIndex: 'sUsername' },  
48 - { title: '员工名', dataIndex: 'sStaffName' },  
49 - { title: '用户号', dataIndex: 'sUserCode' },  
50 - { title: '部门', dataIndex: 'sDepartment' },  
51 - { title: '用户类型', dataIndex: 'sUserType' },  
52 - { title: '语言', dataIndex: 'sLanguage' },  
53 - { title: '作废', dataIndex: 'bIsDisabled', render: (v: number) => v ? '是' : '否' },  
54 - { title: '登录日期', dataIndex: 'tLastLoginDate' },  
55 - { title: '制单人', dataIndex: 'sCreatorUsername' },  
56 - { title: '制单日期', dataIndex: 'tCreateDate' },  
57 - {  
58 - title: '操作',  
59 - key: 'action',  
60 - render: (_, record) => (  
61 - <PermButton  
62 - permission="usr:edit"  
63 - type="link"  
64 - onClick={() => setEditingUser(record)}  
65 - >  
66 - 修改  
67 - </PermButton>  
68 - )  
69 - },  
70 - ] 59 + function handleClear() {
  60 + setQueryField('username'); setMatchType('contains'); setQueryValue('')
  61 + setSearchParams({})
  62 + dispatch(updateTabPath({ id: 'userlist', path: '/usr/users' }))
  63 + doLoad(1, 'username', 'contains', '')
  64 + }
  65 +
  66 + const totalPages = data ? Math.ceil(data.total / 20) : 1
71 67
72 return ( 68 return (
73 - <div>  
74 - <Space style={{ marginBottom: 16 }} wrap>  
75 - <Select  
76 - value={queryField}  
77 - onChange={setQueryField}  
78 - options={QUERY_FIELDS}  
79 - style={{ width: 120 }}  
80 - />  
81 - <Select  
82 - value={matchType}  
83 - onChange={setMatchType}  
84 - options={MATCH_TYPES}  
85 - style={{ width: 100 }}  
86 - />  
87 - <Input  
88 - value={queryValue}  
89 - onChange={e => setQueryValue(e.target.value)}  
90 - placeholder="查询值"  
91 - style={{ width: 160 }}  
92 - />  
93 - <Button type="primary" onClick={() => load(1)}>搜索</Button>  
94 - <PermButton  
95 - permission="usr:create"  
96 - type="primary"  
97 - onClick={() => setDrawerOpen(true)}  
98 - > 69 + <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
  70 + {/* Toolbar */}
  71 + <div style={{ background: 'var(--color-toolbar-bg)', color: 'var(--color-toolbar-text)', display: 'flex', alignItems: 'center', gap: 6, padding: '0 8px', height: 38, flexShrink: 0 }}>
  72 + <button onClick={() => doLoad(currentPage, queryField, matchType, queryValue)} style={tbBtn}>
  73 + <svg width={14} height={14} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 4v5h-5"/></svg>
  74 + 刷新
  75 + </button>
  76 + <PermButton permission="usr:create" onClick={() => navigate('/usr/users/new')} style={tbBtn}>
  77 + <svg width={14} height={14} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><circle cx={12} cy={12} r={9}/><path d="M12 8v8M8 12h8"/></svg>
99 新增 78 新增
100 </PermButton> 79 </PermButton>
101 - </Space>  
102 - <Table  
103 - dataSource={data?.list ?? []}  
104 - columns={columns}  
105 - rowKey="sId"  
106 - pagination={{  
107 - total: data?.total ?? 0,  
108 - pageSize: 20,  
109 - current: currentPage,  
110 - onChange: load,  
111 - }}  
112 - />  
113 - <UserFormDrawer  
114 - open={drawerOpen}  
115 - onClose={() => setDrawerOpen(false)}  
116 - onSuccess={() => { setDrawerOpen(false); load(1) }}  
117 - />  
118 - <UserFormDrawer  
119 - open={editingUser !== null}  
120 - userId={editingUser?.sId}  
121 - initialData={editingUser ? {  
122 - userType: editingUser.sUserType,  
123 - language: editingUser.sLanguage,  
124 - canEditDoc: editingUser.bCanEditDoc === 1,  
125 - isDisabled: editingUser.bIsDisabled === 1,  
126 - employeeId: null,  
127 - } : undefined}  
128 - onClose={() => setEditingUser(null)}  
129 - onSuccess={() => { setEditingUser(null); load(1) }}  
130 - /> 80 + <button style={tbBtn}>
  81 + <svg width={14} height={14} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path d="M4 4h12l4 4v12H4z"/><path d="M16 4v4h4M8 12h8M8 16h8"/></svg>
  82 + 导出Excel
  83 + </button>
  84 + <span style={{ flex: 1 }} />
  85 + <span style={{ padding: '6px 8px', cursor: 'pointer', color: '#cfd2d8' }}>⚙</span>
  86 + </div>
  87 + {/* Filter bar */}
  88 + <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px', background: 'var(--color-filterbar-bg)', borderBottom: '1px solid var(--color-table-border)', flexShrink: 0 }}>
  89 + <select value={queryField} onChange={e => setQueryField(e.target.value)} style={fSel}>
  90 + {QUERY_FIELDS.map(f => <option key={f.value} value={f.value}>{f.label}</option>)}
  91 + </select>
  92 + <select value={matchType} onChange={e => setMatchType(e.target.value)} style={fSel}>
  93 + {MATCH_TYPES.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
  94 + </select>
  95 + <input value={queryValue} onChange={e => setQueryValue(e.target.value)} style={fIn} />
  96 + <span style={{ width: 34, height: 30, background: '#dfe5ee', border: '1px solid #d5d8de', display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 2, cursor: 'pointer', color: '#3776c8' }}>▾</span>
  97 + <button onClick={handleSearch} style={fBtn}>
  98 + <svg width={13} height={13} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><circle cx={11} cy={11} r={7}/><line x1={21} y1={21} x2={16.5} y2={16.5}/></svg>
  99 + 搜索
  100 + </button>
  101 + <button onClick={handleClear} style={{ ...fBtn, background: '#fff', color: '#444', borderColor: '#cfd3da' }}>⊗ 清空</button>
  102 + </div>
  103 + {/* Table */}
  104 + <div style={{ flex: 1, overflow: 'auto', background: '#fff', border: '1px solid var(--color-table-border)', borderTop: 'none' }}>
  105 + <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
  106 + <thead>
  107 + <tr>
  108 + {['', '序号', '用户名', '员工名', '用户号', '部门', '用户类型', '语言', '作废', '登录日期', '制单人', '制单日期'].map((h, i) => (
  109 + <th key={i} style={{ background: 'var(--color-table-header-bg)', fontWeight: 500, color: '#333', padding: '7px 10px', textAlign: 'left', whiteSpace: 'nowrap', border: '1px solid var(--color-table-border)', position: 'sticky', top: 0, zIndex: 1, width: i === 0 ? 32 : i === 1 ? 60 : undefined }}>
  110 + {h}{i > 1 && <span style={{ color: '#aaa', marginLeft: 4 }}>⇅ ⌕</span>}
  111 + </th>
  112 + ))}
  113 + </tr>
  114 + </thead>
  115 + <tbody>
  116 + {(data?.list ?? []).map((row, idx) => (
  117 + <tr
  118 + key={row.sId}
  119 + onClick={() => setSelectedId(row.sId)}
  120 + onDoubleClick={() => navigate(`/usr/users/${row.sId}`, { state: row })}
  121 + style={{ background: row.sId === selectedId ? 'var(--color-table-row-bg-selected)' : idx % 2 === 1 ? 'var(--color-table-row-alt)' : '#fff', cursor: 'pointer' }}
  122 + >
  123 + <td style={td}><span style={{ width: 14, height: 14, border: '1px solid #b8bcc3', borderRadius: '50%', display: 'inline-block', background: row.sId === selectedId ? 'var(--color-primary)' : '#fff' }} /></td>
  124 + <td style={td}>{idx + 1}</td>
  125 + <td style={td}>{row.sUsername}</td>
  126 + <td style={td}>{row.sStaffName ?? ''}</td>
  127 + <td style={td}>{row.sUserCode}</td>
  128 + <td style={td}>{row.sDepartment ?? ''}</td>
  129 + <td style={td}>{row.sUserType}</td>
  130 + <td style={td}>{row.sLanguage}</td>
  131 + <td style={td}>{row.bIsDisabled ? '是' : ''}</td>
  132 + <td style={td}>{row.tLastLoginDate ?? ''}</td>
  133 + <td style={td}>{row.sCreatorUsername ?? ''}</td>
  134 + <td style={td}>{row.tCreateDate}</td>
  135 + </tr>
  136 + ))}
  137 + </tbody>
  138 + </table>
  139 + </div>
  140 + {/* Pager */}
  141 + <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 14px', background: '#fff', borderTop: '1px solid var(--color-table-border)', justifyContent: 'flex-end', fontSize: 13, color: '#555', flexShrink: 0 }}>
  142 + <span>当前显示 共{data?.total ?? 0}条记录</span>
  143 + <button onClick={() => currentPage > 1 && doLoad(currentPage - 1, queryField, matchType, queryValue)} style={pgBtn}>‹</button>
  144 + <span style={{ width: 28, height: 28, border: '1px solid var(--color-primary)', color: 'var(--color-primary)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', borderRadius: 2 }}>{currentPage}</span>
  145 + <button onClick={() => currentPage < totalPages && doLoad(currentPage + 1, queryField, matchType, queryValue)} style={pgBtn}>›</button>
  146 + <select defaultValue="10000" style={{ height: 28, border: '1px solid #d5d8de', borderRadius: 2, padding: '0 8px' }}>
  147 + <option value="10000">10000 条/页</option>
  148 + </select>
  149 + </div>
131 </div> 150 </div>
132 ) 151 )
133 } 152 }
  153 +
  154 +const tbBtn: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', gap: 6, padding: '6px 12px', color: 'var(--color-toolbar-text)', cursor: 'pointer', fontSize: 13, borderRadius: 2, border: 'none', background: 'transparent', fontFamily: 'inherit' }
  155 +const fSel: React.CSSProperties = { height: 30, border: '1px solid #d5d8de', borderRadius: 2, padding: '0 28px 0 10px', background: '#fff', minWidth: 100 }
  156 +const fIn: React.CSSProperties = { height: 30, border: '1px solid #d5d8de', borderRadius: 2, padding: '0 10px', minWidth: 140, fontSize: 13, fontFamily: 'inherit' }
  157 +const fBtn: React.CSSProperties = { height: 30, padding: '0 14px', borderRadius: 2, border: '1px solid var(--color-primary)', background: 'var(--color-primary)', color: '#fff', display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }
  158 +const td: React.CSSProperties = { padding: '7px 10px', textAlign: 'left', whiteSpace: 'nowrap', border: '1px solid var(--color-table-border)' }
  159 +const pgBtn: React.CSSProperties = { width: 28, height: 28, border: '1px solid #d5d8de', background: '#fff', borderRadius: 2, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: '#666', fontFamily: 'inherit' }
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/styles/tokens.css
@@ -40,4 +40,34 @@ @@ -40,4 +40,34 @@
40 --color-table-row-fg: #000000; 40 --color-table-row-fg: #000000;
41 --color-table-header-bg: #f5f5f5; 41 --color-table-header-bg: #f5f5f5;
42 --color-table-header-fg: rgba(0, 0, 0, 0.85); /* = #000000D9 */ 42 --color-table-header-fg: rgba(0, 0, 0, 0.85); /* = #000000D9 */
  43 +
  44 + /* === 3. Prototype shell tokens === */
  45 + --color-topbar-bg: #1f1f23;
  46 + --color-toolbar-bg: #2c2f36;
  47 + --color-toolbar-text: #e6e7ea;
  48 + --color-tab-active: #1e84e6;
  49 + --color-tab-text: #9aa0a8;
  50 + --color-field-bg-edit: #eaf3fe;
  51 + --color-field-label-req: #f04848;
  52 + --color-table-border: #e3e6eb;
  53 + --color-table-row-alt: #f7f8fa;
  54 + --color-filterbar-bg: #ffffff;
  55 + --color-nav-overlay-bg: #2b3137;
  56 +
  57 + /* === 4. NavOverlay colors === */
  58 + --color-nav-sidebar-border: #1e2226;
  59 + --color-nav-sidebar-active-bg: #34393f;
  60 + --color-nav-col-header: #e8eaee;
  61 + --color-nav-col-divider: #4a4f57;
  62 + --color-nav-item-text: #cfd3da;
  63 + --color-nav-item-star: #f3b526;
  64 +
  65 + /* === 5. Login hero === */
  66 + --color-login-hero-start: #1a4ea0;
  67 + --color-login-hero-mid: #0a1d44;
  68 + --color-login-hero-end: #050d20;
  69 + --color-login-bg: #eaedf2;
  70 + --color-login-border: #d8dce2;
  71 + --color-login-brand: #e0a020;
  72 + --color-login-text-muted: #666666;
43 } 73 }
frontend/src/test/AppShell.test.tsx 0 → 100644
  1 +import { describe, it, expect, vi } from 'vitest'
  2 +import { render, screen, waitFor } from '@testing-library/react'
  3 +import userEvent from '@testing-library/user-event'
  4 +import { Provider } from 'react-redux'
  5 +import { MemoryRouter, Routes, Route } from 'react-router-dom'
  6 +import { configureStore } from '@reduxjs/toolkit'
  7 +import authReducer from '../store/slices/authSlice'
  8 +import tabsReducer, { openTab } from '../store/slices/tabsSlice'
  9 +import AppShell from '../components/AppShell'
  10 +
  11 +vi.mock('../components/NavOverlay', () => ({
  12 + default: ({ onClose }: { onClose: () => void }) => (
  13 + <div data-testid="nav-overlay" onClick={onClose}>NavOverlay</div>
  14 + ),
  15 +}))
  16 +
  17 +function makeStore(extraTabs = false) {
  18 + const store = configureStore({ reducer: { auth: authReducer, tabs: tabsReducer } })
  19 + store.dispatch({
  20 + type: 'auth/setCredentials',
  21 + payload: { accessToken: 'tok', refreshToken: 'ref', userInfo: { userId: 'u0', username: 'admin', userType: '超级管理员', language: '中文', brandId: 'b1' } },
  22 + })
  23 + if (extraTabs) {
  24 + store.dispatch(openTab({ id: 'userlist', title: '用户列表', path: '/usr/users', closable: true }))
  25 + }
  26 + return store
  27 +}
  28 +
  29 +function renderShell(path = '/', extraTabs = false) {
  30 + const store = makeStore(extraTabs)
  31 + return {
  32 + store,
  33 + ...render(
  34 + <Provider store={store}>
  35 + <MemoryRouter initialEntries={[path]}>
  36 + <Routes>
  37 + <Route element={<AppShell />}>
  38 + <Route path="/" element={<div>MainPage</div>} />
  39 + <Route path="/usr/users" element={<div>UserListPage</div>} />
  40 + </Route>
  41 + </Routes>
  42 + </MemoryRouter>
  43 + </Provider>
  44 + ),
  45 + }
  46 +}
  47 +
  48 +describe('AppShell', () => {
  49 + it('renders_mainTabAndUserInfo', () => {
  50 + renderShell()
  51 + expect(screen.getByText('主页')).toBeInTheDocument()
  52 + expect(screen.getByText(/admin/)).toBeInTheDocument()
  53 + })
  54 +
  55 + it('renders_openTabs', () => {
  56 + renderShell('/', true)
  57 + expect(screen.getByText('用户列表')).toBeInTheDocument()
  58 + })
  59 +
  60 + it('clickTab_navigatesToTabPath', async () => {
  61 + renderShell('/', true)
  62 + await userEvent.click(screen.getByText('用户列表'))
  63 + await waitFor(() => expect(screen.getByText('UserListPage')).toBeInTheDocument())
  64 + })
  65 +
  66 + it('closeTab_removesTabAndNavigates', async () => {
  67 + renderShell('/usr/users', true)
  68 + await userEvent.click(screen.getByRole('button', { name: '关闭 用户列表' }))
  69 + await waitFor(() => expect(screen.queryByText('用户列表')).not.toBeInTheDocument())
  70 + expect(screen.getByText('MainPage')).toBeInTheDocument()
  71 + })
  72 +
  73 + it('navToggle_showsNavOverlay', async () => {
  74 + renderShell()
  75 + await userEvent.click(screen.getByRole('button', { name: /全部导航/ }))
  76 + expect(screen.getByTestId('nav-overlay')).toBeInTheDocument()
  77 + })
  78 +
  79 + it('navToggle_secondClick_hidesOverlay', async () => {
  80 + renderShell()
  81 + await userEvent.click(screen.getByRole('button', { name: /全部导航/ }))
  82 + await userEvent.click(screen.getByRole('button', { name: /全部导航/ }))
  83 + expect(screen.queryByTestId('nav-overlay')).not.toBeInTheDocument()
  84 + })
  85 +})
frontend/src/test/NavOverlay.test.tsx 0 → 100644
  1 +import { describe, it, expect, vi } from 'vitest'
  2 +import { render, screen } from '@testing-library/react'
  3 +import userEvent from '@testing-library/user-event'
  4 +import { Provider } from 'react-redux'
  5 +import { MemoryRouter, Routes, Route } from 'react-router-dom'
  6 +import { configureStore } from '@reduxjs/toolkit'
  7 +import authReducer from '../store/slices/authSlice'
  8 +import tabsReducer from '../store/slices/tabsSlice'
  9 +import NavOverlay from '../components/NavOverlay'
  10 +
  11 +function makeStore() {
  12 + return configureStore({ reducer: { auth: authReducer, tabs: tabsReducer } })
  13 +}
  14 +
  15 +function renderOverlay(onClose = vi.fn()) {
  16 + return render(
  17 + <Provider store={makeStore()}>
  18 + <MemoryRouter initialEntries={['/']}>
  19 + <Routes>
  20 + <Route path="/" element={<NavOverlay onClose={onClose} />} />
  21 + <Route path="/usr/users" element={<div>UserList</div>} />
  22 + </Routes>
  23 + </MemoryRouter>
  24 + </Provider>
  25 + )
  26 +}
  27 +
  28 +describe('NavOverlay', () => {
  29 + it('renders_systemModules', () => {
  30 + renderOverlay()
  31 + expect(screen.getByText('系统设置')).toBeInTheDocument()
  32 + expect(screen.getByText('用户管理')).toBeInTheDocument()
  33 + })
  34 +
  35 + it('clickUserList_navigatesAndClosesOverlay', async () => {
  36 + const onClose = vi.fn()
  37 + renderOverlay(onClose)
  38 + await userEvent.click(screen.getByText('用户列表'))
  39 + expect(screen.getByText('UserList')).toBeInTheDocument()
  40 + expect(onClose).toHaveBeenCalledTimes(1)
  41 + })
  42 +
  43 + it('clickBackground_callsOnClose', async () => {
  44 + const onClose = vi.fn()
  45 + render(
  46 + <Provider store={makeStore()}>
  47 + <MemoryRouter>
  48 + <NavOverlay onClose={onClose} />
  49 + </MemoryRouter>
  50 + </Provider>
  51 + )
  52 + // Click the outer overlay div (background)
  53 + await userEvent.click(document.querySelector('[data-testid="nav-bg"]')!)
  54 + expect(onClose).toHaveBeenCalledTimes(1)
  55 + })
  56 +})
frontend/src/test/UserDetailPage.test.tsx 0 → 100644
  1 +import { describe, it, expect, vi, beforeEach } from 'vitest'
  2 +import { render, screen, waitFor } from '@testing-library/react'
  3 +import userEvent from '@testing-library/user-event'
  4 +import { Provider } from 'react-redux'
  5 +import { MemoryRouter, Routes, Route } from 'react-router-dom'
  6 +import { configureStore } from '@reduxjs/toolkit'
  7 +import authReducer from '../store/slices/authSlice'
  8 +import tabsReducer from '../store/slices/tabsSlice'
  9 +import UserDetailPage from '../pages/usr/UserDetailPage'
  10 +
  11 +vi.mock('../api/usr', () => ({
  12 + getStaffs: vi.fn().mockResolvedValue([{ sId: 's1', sStaffName: '张三' }]),
  13 + getPermissionGroups: vi.fn().mockResolvedValue([{ sId: 'pg1', sGroupCode: 'usr:create', sGroupName: '新增用户', sCategory: '用户管理' }]),
  14 + createUser: vi.fn().mockResolvedValue({ userId: 'u2', userCode: 'UC002', username: 'bob' }),
  15 + updateUser: vi.fn().mockResolvedValue({ userId: 'u1', username: 'alice', updatedAt: '2026-05-08T10:00:00' }),
  16 + getUserList: vi.fn().mockResolvedValue({ total: 0, page: 1, pageSize: 20, list: [] }),
  17 +}))
  18 +
  19 +vi.mock('../api/request', () => ({
  20 + default: { get: vi.fn(), post: vi.fn(), put: vi.fn() },
  21 +}))
  22 +
  23 +function makeStore() {
  24 + const store = configureStore({ reducer: { auth: authReducer, tabs: tabsReducer } })
  25 + store.dispatch({
  26 + type: 'auth/setCredentials',
  27 + payload: { accessToken: 'tok', refreshToken: 'ref', userInfo: { userId: 'u0', username: 'admin', userType: '超级管理员', language: '中文', brandId: 'b1' } },
  28 + })
  29 + return store
  30 +}
  31 +
  32 +const rowData = {
  33 + sId: 'u1', sUsername: 'alice', sUserCode: 'UC001', sUserType: '普通用户',
  34 + sLanguage: '中文', bCanEditDoc: 0, bIsDisabled: 0, tLastLoginDate: null,
  35 + sCreatorUsername: 'admin', tCreateDate: '2026-01-01 00:00:00',
  36 + sStaffName: null, sDepartment: null,
  37 +}
  38 +
  39 +function renderNew() {
  40 + return render(
  41 + <Provider store={makeStore()}>
  42 + <MemoryRouter initialEntries={['/usr/users/new']}>
  43 + <Routes>
  44 + <Route path="/usr/users/new" element={<UserDetailPage />} />
  45 + <Route path="/usr/users" element={<div>UserList</div>} />
  46 + </Routes>
  47 + </MemoryRouter>
  48 + </Provider>
  49 + )
  50 +}
  51 +
  52 +function renderEdit(state = rowData) {
  53 + return render(
  54 + <Provider store={makeStore()}>
  55 + <MemoryRouter initialEntries={[{ pathname: '/usr/users/u1', state }]}>
  56 + <Routes>
  57 + <Route path="/usr/users/:id" element={<UserDetailPage />} />
  58 + <Route path="/usr/users" element={<div>UserList</div>} />
  59 + </Routes>
  60 + </MemoryRouter>
  61 + </Provider>
  62 + )
  63 +}
  64 +
  65 +describe('UserDetailPage', () => {
  66 + beforeEach(() => { vi.clearAllMocks() })
  67 +
  68 + it('newMode_showsSaveButton', () => {
  69 + renderNew()
  70 + expect(screen.getByRole('button', { name: /保存/ })).toBeInTheDocument()
  71 + })
  72 +
  73 + it('newMode_showsPermissionGroupTab', async () => {
  74 + renderNew()
  75 + await waitFor(() => expect(screen.getByText('新增用户')).toBeInTheDocument())
  76 + })
  77 +
  78 + it('editMode_showsUsernameReadonly', async () => {
  79 + renderEdit()
  80 + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument())
  81 + })
  82 +
  83 + it('editMode_noState_redirectsToList', async () => {
  84 + render(
  85 + <Provider store={makeStore()}>
  86 + <MemoryRouter initialEntries={['/usr/users/u1']}>
  87 + <Routes>
  88 + <Route path="/usr/users/:id" element={<UserDetailPage />} />
  89 + <Route path="/usr/users" element={<div>UserList</div>} />
  90 + </Routes>
  91 + </MemoryRouter>
  92 + </Provider>
  93 + )
  94 + await waitFor(() => expect(screen.getByText('UserList')).toBeInTheDocument())
  95 + })
  96 +
  97 + it('newMode_save_callsCreateUser', async () => {
  98 + const { createUser } = await import('../api/usr')
  99 + renderNew()
  100 + await userEvent.type(screen.getByPlaceholderText('请输入用户号'), 'UC002')
  101 + await userEvent.type(screen.getByPlaceholderText('请输入用户名'), 'bob')
  102 + await userEvent.click(screen.getByRole('button', { name: /保存/ }))
  103 + await waitFor(() => expect(vi.mocked(createUser)).toHaveBeenCalledWith(
  104 + expect.objectContaining({ userCode: 'UC002', username: 'bob' })
  105 + ))
  106 + })
  107 +
  108 + it('editMode_save_callsUpdateUser', async () => {
  109 + const { updateUser } = await import('../api/usr')
  110 + renderEdit()
  111 + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument())
  112 + await userEvent.click(screen.getByRole('button', { name: /保存/ }))
  113 + await waitFor(() => expect(vi.mocked(updateUser)).toHaveBeenCalledWith(
  114 + 'u1',
  115 + expect.objectContaining({ userType: '普通用户' })
  116 + ))
  117 + })
  118 +
  119 + it('permCheckbox_togglesSelection', async () => {
  120 + renderNew()
  121 + await waitFor(() => expect(screen.getByText('新增用户')).toBeInTheDocument())
  122 + const cb = screen.getByRole('checkbox')
  123 + expect(cb).not.toBeChecked()
  124 + await userEvent.click(cb)
  125 + expect(cb).toBeChecked()
  126 + await userEvent.click(cb)
  127 + expect(cb).not.toBeChecked()
  128 + })
  129 +
  130 + it('cancelButton_navigatesToList', async () => {
  131 + renderNew()
  132 + await userEvent.click(screen.getByRole('button', { name: /取消/ }))
  133 + await waitFor(() => expect(screen.getByText('UserList')).toBeInTheDocument())
  134 + })
  135 +})
frontend/src/test/UserListPage.test.tsx
@@ -2,9 +2,10 @@ import { describe, it, expect, vi, beforeEach } from &#39;vitest&#39; @@ -2,9 +2,10 @@ import { describe, it, expect, vi, beforeEach } from &#39;vitest&#39;
2 import { render, screen, waitFor } from '@testing-library/react' 2 import { render, screen, waitFor } from '@testing-library/react'
3 import userEvent from '@testing-library/user-event' 3 import userEvent from '@testing-library/user-event'
4 import { Provider } from 'react-redux' 4 import { Provider } from 'react-redux'
5 -import { MemoryRouter } from 'react-router-dom' 5 +import { MemoryRouter, Routes, Route } from 'react-router-dom'
6 import { configureStore } from '@reduxjs/toolkit' 6 import { configureStore } from '@reduxjs/toolkit'
7 import authReducer from '../store/slices/authSlice' 7 import authReducer from '../store/slices/authSlice'
  8 +import tabsReducer from '../store/slices/tabsSlice'
8 import UserListPage from '../pages/usr/UserListPage' 9 import UserListPage from '../pages/usr/UserListPage'
9 10
10 vi.mock('../api/usr', () => ({ 11 vi.mock('../api/usr', () => ({
@@ -12,41 +13,50 @@ vi.mock(&#39;../api/usr&#39;, () =&gt; ({ @@ -12,41 +13,50 @@ vi.mock(&#39;../api/usr&#39;, () =&gt; ({
12 getPermissionGroups: vi.fn().mockResolvedValue([]), 13 getPermissionGroups: vi.fn().mockResolvedValue([]),
13 createUser: vi.fn(), 14 createUser: vi.fn(),
14 getUserList: vi.fn().mockResolvedValue({ total: 0, page: 1, pageSize: 20, list: [] }), 15 getUserList: vi.fn().mockResolvedValue({ total: 0, page: 1, pageSize: 20, list: [] }),
15 - updateUser: vi.fn().mockResolvedValue({ userId: 'u1', username: 'alice', updatedAt: '2026-05-08T10:00:00' }) 16 + updateUser: vi.fn().mockResolvedValue({ userId: 'u1', username: 'alice', updatedAt: '2026-05-08T10:00:00' }),
16 })) 17 }))
17 18
18 vi.mock('../api/request', () => ({ 19 vi.mock('../api/request', () => ({
19 - default: { get: vi.fn(), post: vi.fn() } 20 + default: { get: vi.fn(), post: vi.fn(), put: vi.fn() },
20 })) 21 }))
21 22
22 function makeStore(userType: string) { 23 function makeStore(userType: string) {
23 - const store = configureStore({ reducer: { auth: authReducer } }) 24 + const store = configureStore({ reducer: { auth: authReducer, tabs: tabsReducer } })
24 store.dispatch({ 25 store.dispatch({
25 type: 'auth/setCredentials', 26 type: 'auth/setCredentials',
26 - payload: {  
27 - accessToken: 'test-token',  
28 - refreshToken: 'test-refresh',  
29 - userInfo: { userId: 'u1', username: 'admin', userType, language: '中文', brandId: 'b1' }  
30 - } 27 + payload: { accessToken: 'test-token', refreshToken: 'test-refresh', userInfo: { userId: 'u1', username: 'admin', userType, language: '中文', brandId: 'b1' } },
31 }) 28 })
32 return store 29 return store
33 } 30 }
34 31
35 -function renderPage(userType: string) { 32 +const sampleRow = {
  33 + sId: 'u1', sUsername: 'alice', sUserCode: 'UC001', sUserType: '普通用户',
  34 + sLanguage: '中文', bCanEditDoc: 0, bIsDisabled: 0, tLastLoginDate: null,
  35 + sCreatorUsername: 'admin', tCreateDate: '2026-01-01T00:00:00',
  36 + sStaffName: '张三', sDepartment: '研发部',
  37 +}
  38 +
  39 +function renderPage(userType: string, extraRoutes = false) {
36 const store = makeStore(userType) 40 const store = makeStore(userType)
37 return render( 41 return render(
38 <Provider store={store}> 42 <Provider store={store}>
39 - <MemoryRouter>  
40 - <UserListPage /> 43 + <MemoryRouter initialEntries={['/usr/users']}>
  44 + {extraRoutes ? (
  45 + <Routes>
  46 + <Route path="/usr/users" element={<UserListPage />} />
  47 + <Route path="/usr/users/new" element={<div>NewUserPage</div>} />
  48 + <Route path="/usr/users/:id" element={<div>DetailPage</div>} />
  49 + </Routes>
  50 + ) : (
  51 + <UserListPage />
  52 + )}
41 </MemoryRouter> 53 </MemoryRouter>
42 </Provider> 54 </Provider>
43 ) 55 )
44 } 56 }
45 57
46 describe('UserListPage', () => { 58 describe('UserListPage', () => {
47 - beforeEach(() => {  
48 - vi.clearAllMocks()  
49 - }) 59 + beforeEach(() => { vi.clearAllMocks() })
50 60
51 it('superAdmin_seesNewButton', () => { 61 it('superAdmin_seesNewButton', () => {
52 renderPage('超级管理员') 62 renderPage('超级管理员')
@@ -58,23 +68,15 @@ describe(&#39;UserListPage&#39;, () =&gt; { @@ -58,23 +68,15 @@ describe(&#39;UserListPage&#39;, () =&gt; {
58 expect(screen.queryByRole('button', { name: /新\s*增/ })).not.toBeInTheDocument() 68 expect(screen.queryByRole('button', { name: /新\s*增/ })).not.toBeInTheDocument()
59 }) 69 })
60 70
61 - it('clickNewButton_opensDrawer', async () => {  
62 - renderPage('超级管理员') 71 + it('clickNewButton_navigatesToNewPage', async () => {
  72 + renderPage('超级管理员', true)
63 await userEvent.click(screen.getByRole('button', { name: /新\s*增/ })) 73 await userEvent.click(screen.getByRole('button', { name: /新\s*增/ }))
64 - await waitFor(() => expect(screen.getByText('新增用户')).toBeInTheDocument()) 74 + await waitFor(() => expect(screen.getByText('NewUserPage')).toBeInTheDocument())
65 }) 75 })
66 76
67 it('initialLoad_rendersTableRows', async () => { 77 it('initialLoad_rendersTableRows', async () => {
68 const { getUserList } = await import('../api/usr') 78 const { getUserList } = await import('../api/usr')
69 - vi.mocked(getUserList).mockResolvedValueOnce({  
70 - total: 1, page: 1, pageSize: 20,  
71 - list: [{  
72 - sId: 'u1', sUsername: 'alice', sUserCode: 'UC001', sUserType: '普通用户',  
73 - sLanguage: '中文', bCanEditDoc: 0, bIsDisabled: 0, tLastLoginDate: null,  
74 - sCreatorUsername: 'admin', tCreateDate: '2026-01-01T00:00:00',  
75 - sStaffName: '张三', sDepartment: '研发部'  
76 - }]  
77 - }) 79 + vi.mocked(getUserList).mockResolvedValueOnce({ total: 1, page: 1, pageSize: 20, list: [sampleRow] })
78 renderPage('超级管理员') 80 renderPage('超级管理员')
79 await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()) 81 await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument())
80 expect(screen.getByText('张三')).toBeInTheDocument() 82 expect(screen.getByText('张三')).toBeInTheDocument()
@@ -87,22 +89,12 @@ describe(&#39;UserListPage&#39;, () =&gt; { @@ -87,22 +89,12 @@ describe(&#39;UserListPage&#39;, () =&gt; {
87 await waitFor(() => expect(vi.mocked(getUserList)).toHaveBeenCalledTimes(2)) 89 await waitFor(() => expect(vi.mocked(getUserList)).toHaveBeenCalledTimes(2))
88 }) 90 })
89 91
90 - it('editMode_submit_callsUpdateUser', async () => {  
91 - const { getUserList, updateUser } = await import('../api/usr')  
92 - vi.mocked(getUserList).mockResolvedValue({  
93 - total: 1, page: 1, pageSize: 20,  
94 - list: [{  
95 - sId: 'u1', sUsername: 'alice', sUserCode: 'UC001', sUserType: '普通用户',  
96 - sLanguage: '中文', bCanEditDoc: 0, bIsDisabled: 0, tLastLoginDate: null,  
97 - sCreatorUsername: 'admin', tCreateDate: '2026-01-01T00:00:00',  
98 - sStaffName: null, sDepartment: null  
99 - }]  
100 - })  
101 - renderPage('超级管理员') 92 + it('doubleClickRow_navigatesToDetailPage', async () => {
  93 + const { getUserList } = await import('../api/usr')
  94 + vi.mocked(getUserList).mockResolvedValueOnce({ total: 1, page: 1, pageSize: 20, list: [sampleRow] })
  95 + renderPage('超级管理员', true)
102 await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()) 96 await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument())
103 - await userEvent.click(screen.getByRole('button', { name: /修改/ }))  
104 - await waitFor(() => expect(screen.getByText('修改用户')).toBeInTheDocument())  
105 - await userEvent.click(screen.getByRole('button', { name: /确\s*认/ }))  
106 - await waitFor(() => expect(vi.mocked(updateUser)).toHaveBeenCalledTimes(1)) 97 + await userEvent.dblClick(screen.getByText('alice').closest('tr')!)
  98 + await waitFor(() => expect(screen.getByText('DetailPage')).toBeInTheDocument())
107 }) 99 })
108 }) 100 })
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 +})