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 切换保留各页面状态
- 用户列表双击行进入编辑单据
- 新增用户流程完整
Showing
17 changed files
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 | 1 | import { Navigate, Route, Routes } from 'react-router-dom' |
| 2 | 2 | import { useAppSelector } from './store/hooks' |
| 3 | +import AppShell from './components/AppShell' | |
| 3 | 4 | import LoginPage from './pages/usr/LoginPage' |
| 5 | +import MainPage from './pages/MainPage' | |
| 4 | 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 | 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 | 15 | export default function App() { |
| 12 | 16 | return ( |
| 13 | 17 | <Routes> |
| 14 | 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 | 25 | <Route path="*" element={<Navigate to="/" replace />} /> |
| 18 | 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 | 1 | import { useEffect, useState } from 'react' |
| 2 | 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 | 5 | import { setCredentials } from '../../store/slices/authSlice' |
| 6 | +import { activateTab } from '../../store/slices/tabsSlice' | |
| 6 | 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 | 15 | export default function LoginPage() { |
| 9 | 16 | const [brandOptions, setBrandOptions] = useState<BrandVO[]>([]) |
| 10 | 17 | const [loading, setLoading] = useState(false) |
| ... | ... | @@ -19,17 +26,14 @@ export default function LoginPage() { |
| 19 | 26 | const defaultNo = std ? std.sNo : brands[0]?.sNo |
| 20 | 27 | if (defaultNo) form.setFieldValue('brandNo', defaultNo) |
| 21 | 28 | }).catch(() => {}) |
| 22 | - }, []) | |
| 29 | + }, [form]) | |
| 23 | 30 | |
| 24 | 31 | const onFinish = async (values: { brandNo: string; username: string; password: string }) => { |
| 25 | 32 | setLoading(true) |
| 26 | 33 | try { |
| 27 | 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 | 37 | navigate('/') |
| 34 | 38 | } catch (e: unknown) { |
| 35 | 39 | message.error(e instanceof Error ? e.message : '登录失败') |
| ... | ... | @@ -39,28 +43,49 @@ export default function LoginPage() { |
| 39 | 43 | } |
| 40 | 44 | |
| 41 | 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 | 89 | </div> |
| 65 | 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 | 5 | import { PermButton } from '../../components/PermButton' |
| 5 | -import UserFormDrawer from './UserFormDrawer' | |
| 6 | 6 | import { getUserList } from '../../api/usr' |
| 7 | 7 | import type { PageVO, UserListItemVO } from '../../api/usr' |
| 8 | 8 | |
| ... | ... | @@ -24,110 +24,136 @@ const MATCH_TYPES = [ |
| 24 | 24 | ] |
| 25 | 25 | |
| 26 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 1 | import { configureStore } from '@reduxjs/toolkit' |
| 2 | 2 | import authReducer from './slices/authSlice' |
| 3 | +import tabsReducer from './slices/tabsSlice' | |
| 3 | 4 | |
| 4 | 5 | export const store = configureStore({ |
| 5 | 6 | reducer: { |
| 6 | - auth: authReducer | |
| 7 | - } | |
| 7 | + auth: authReducer, | |
| 8 | + tabs: tabsReducer, | |
| 9 | + }, | |
| 8 | 10 | }) |
| 9 | 11 | |
| 10 | 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 | 40 | --color-table-row-fg: #000000; |
| 41 | 41 | --color-table-header-bg: #f5f5f5; |
| 42 | 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 'vitest' |
| 2 | 2 | import { render, screen, waitFor } from '@testing-library/react' |
| 3 | 3 | import userEvent from '@testing-library/user-event' |
| 4 | 4 | import { Provider } from 'react-redux' |
| 5 | -import { MemoryRouter } from 'react-router-dom' | |
| 5 | +import { MemoryRouter, Routes, Route } from 'react-router-dom' | |
| 6 | 6 | import { configureStore } from '@reduxjs/toolkit' |
| 7 | 7 | import authReducer from '../store/slices/authSlice' |
| 8 | +import tabsReducer from '../store/slices/tabsSlice' | |
| 8 | 9 | import UserListPage from '../pages/usr/UserListPage' |
| 9 | 10 | |
| 10 | 11 | vi.mock('../api/usr', () => ({ |
| ... | ... | @@ -12,41 +13,50 @@ vi.mock('../api/usr', () => ({ |
| 12 | 13 | getPermissionGroups: vi.fn().mockResolvedValue([]), |
| 13 | 14 | createUser: vi.fn(), |
| 14 | 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 | 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 | 23 | function makeStore(userType: string) { |
| 23 | - const store = configureStore({ reducer: { auth: authReducer } }) | |
| 24 | + const store = configureStore({ reducer: { auth: authReducer, tabs: tabsReducer } }) | |
| 24 | 25 | store.dispatch({ |
| 25 | 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 | 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 | 40 | const store = makeStore(userType) |
| 37 | 41 | return render( |
| 38 | 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 | 53 | </MemoryRouter> |
| 42 | 54 | </Provider> |
| 43 | 55 | ) |
| 44 | 56 | } |
| 45 | 57 | |
| 46 | 58 | describe('UserListPage', () => { |
| 47 | - beforeEach(() => { | |
| 48 | - vi.clearAllMocks() | |
| 49 | - }) | |
| 59 | + beforeEach(() => { vi.clearAllMocks() }) | |
| 50 | 60 | |
| 51 | 61 | it('superAdmin_seesNewButton', () => { |
| 52 | 62 | renderPage('超级管理员') |
| ... | ... | @@ -58,23 +68,15 @@ describe('UserListPage', () => { |
| 58 | 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 | 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 | 77 | it('initialLoad_rendersTableRows', async () => { |
| 68 | 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 | 80 | renderPage('超级管理员') |
| 79 | 81 | await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()) |
| 80 | 82 | expect(screen.getByText('张三')).toBeInTheDocument() |
| ... | ... | @@ -87,22 +89,12 @@ describe('UserListPage', () => { |
| 87 | 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 | 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 | +}) | ... | ... |