From 6976b8200b3bc28d78655be6d5ef6369d3f33430 Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 8 May 2026 14:05:48 +0800 Subject: [PATCH 01/12] =?UTF-8?q?docs(usr):=20=E5=89=8D=E7=AB=AF=E9=87=8D?= =?UTF-8?q?=E5=86=99=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/2026-05-08-frontend-design.md | 157 +++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-08-frontend-design.md diff --git a/docs/superpowers/specs/2026-05-08-frontend-design.md b/docs/superpowers/specs/2026-05-08-frontend-design.md new file mode 100644 index 0000000..fc57f3f --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-frontend-design.md @@ -0,0 +1,157 @@ +# 前端重写设计文档 + +**日期**: 2026-05-08 +**范围**: USR 模块前端 — 登录、用户列表、用户单据 +**参考原型**: `prototype/erp.html` +**技术栈**: 不变(React 18 + Ant Design 5 + Redux Toolkit + React Router v6 + Vite) + +--- + +## 一、核心决策记录 + +| 决策项 | 结论 | 说明 | +|---|---|---| +| 导航结构 | 原型 Tab 顶栏,无侧边栏 | 与 docs/06 § 1.1 不符,已与用户确认,本文档为准 | +| 用户表单形式 | 全屏单据页(独立 Tab) | 替代原 Drawer;与原型一致 | +| Tab 状态管理 | Redux `tabsSlice` | 切换 Tab 时 React Router navigate + 列表筛选存 URL query,保证状态可恢复 | +| 主页 | 占位页面 | KPI 看板后续模块实现 | +| 现有 api / store | 保留不动 | 逻辑正确,仅重写 UI 层 | + +> **docs/06 偏差声明**: docs/06 § 1.1 规定 Header + Sider + Content 经典 Admin 布局。本设计采用原型的 Tab 顶栏导航,无侧边栏。后续若文档更新,以本文档为 USR 模块实现基准。 + +--- + +## 二、架构 + +``` +React Router v6 (路由/渲染) + + tabsSlice (Redux) (Tab 列表 / activeId) + ↓ +AppShell (深色顶栏 + Tab 栏 + NavOverlay) + ↓ +页面组件 (LoginPage / MainPage / UserListPage / UserDetailPage) + ↓ +api/ (Axios,保留) + store/slices/authSlice (保留) +``` + +**Tab 切换机制**: +- Tab 切换 = `navigate(tab.path)` + Redux `activateTab(id)` +- 列表筛选条件持久化到 URL query string(如 `/usr/users?queryField=username&queryValue=管广飞`),切回时自动恢复 +- 用户单据 Tab 固定 id `"userdetail"`,新增/编辑复用同一 Tab + +--- + +## 三、路由结构 + +| 路径 | 页面 | Tab 行为 | +|---|---|---| +| `/login` | LoginPage | 无 Shell,全屏 | +| `/` | MainPage | 固定 Tab "主页",不可关闭 | +| `/usr/users` | UserListPage | Tab "用户列表",可关闭 | +| `/usr/users/new` | UserDetailPage (新增模式) | Tab "用户信息单据",可关闭 | +| `/usr/users/:id` | UserDetailPage (编辑模式) | 复用 Tab "用户信息单据",可关闭 | + +--- + +## 四、文件变更清单 + +### 新建 + +| 文件 | 职责 | +|---|---| +| `src/store/slices/tabsSlice.ts` | Tab 状态:`tabs: Tab[]`、`activeId: string \| null`;actions: `openTab` / `closeTab` / `activateTab` | +| `src/components/AppShell.tsx` | 深色顶栏 + Tab 栏 + NavOverlay 入口;包裹所有已登录页面 | +| `src/components/NavOverlay.tsx` | 全屏模块导航(按 prototype § nav-overlay 还原);当前阶段仅"用户列表"可点击,其余展示占位 | +| `src/pages/MainPage.tsx` | 主页占位 | +| `src/pages/usr/UserDetailPage.tsx` | 全屏用户单据页:深色工具栏(新增/修改/删除/保存/取消/作废/重置密码)+ 3 列表单网格 + 权限组 Tab 列表 | + +### 重写(逻辑不变,仅 UI 层) + +| 文件 | 改动说明 | +|---|---| +| `src/pages/usr/LoginPage.tsx` | 还原原型登录视觉:蓝色渐变 hero + 右侧白色登录卡片;API 调用逻辑保留 | +| `src/pages/usr/UserListPage.tsx` | 还原原型列表视觉:深色工具栏 + filterbar + 带单选圆点的表格;双击行导航到单据页 | +| `src/App.tsx` | 路由改用 AppShell 包裹;新增 `/usr/users/new` 和 `/usr/users/:id` 路由 | +| `src/styles/tokens.css` | 追加原型色值 token(见 § 五) | + +### 删除 + +| 文件 | 原因 | +|---|---| +| `src/pages/usr/UserFormDrawer.tsx` | 由 UserDetailPage 全屏单据页替代 | + +### 保留不动 + +- `src/api/` — auth.ts / usr.ts / request.ts +- `src/store/slices/authSlice.ts` +- `src/store/index.ts`、`src/store/hooks.ts` +- `src/components/PermButton.tsx` + +--- + +## 五、新增 Design Tokens + +在 `src/styles/tokens.css` `:root` 块追加: + +| 变量名 | 值 | 用途 | +|---|---|---| +| `--color-topbar-bg` | `#1f1f23` | 顶栏背景 | +| `--color-toolbar-bg` | `#2c2f36` | 深色工具栏背景 | +| `--color-toolbar-text` | `#e6e7ea` | 深色工具栏文字 | +| `--color-tab-active` | `#1e84e6` | 活动 Tab 文字 / 下划线 | +| `--color-tab-text` | `#9aa0a8` | 非活动 Tab 文字 | +| `--color-field-bg-edit` | `#eaf3fe` | 可编辑表单字段背景 | +| `--color-field-label-req` | `#f04848` | 必填标签色(* 号) | +| `--color-table-border` | `#e3e6eb` | 表格边框 | +| `--color-table-row-alt` | `#f7f8fa` | 表格隔行背景色 | +| `--color-filterbar-bg` | `#ffffff` | 筛选栏背景 | +| `--color-nav-overlay-bg` | `#2b3137` | 导航 Overlay 背景 | + +--- + +## 六、页面规格 + +### 6.1 LoginPage + +- 全屏两分区:左侧蓝色渐变 hero(`radial-gradient` 深蓝)+ 网格背景 + 品牌文字;右侧白色登录卡片 +- 卡片内:用户名输入框 / 密码输入框 / 公司版本下拉(从 `getBrands()` 加载)/ 登录按钮 +- 登录成功后 → navigate('/') + 打开"主页" Tab + +### 6.2 AppShell + +- 高度 44px 深色顶栏,从左到右:Logo(鹿角 SVG)/ "全部导航"按钮 / Tab 列表 / 右侧用户信息 +- Tab 列表:每个 Tab 含标题 + 关闭按钮(✕);活动 Tab 蓝色下划线;主页 Tab 无关闭按钮 +- 点"全部导航"展开 NavOverlay;点 Tab 切换路由;点✕关闭 Tab 并跳转到前一个 Tab + +### 6.3 NavOverlay + +- 全屏覆盖层,背景 `#2b3137` +- 左侧:模块分类列表(系统管理高亮,其余占位) +- 右侧网格:按 prototype 还原 7 列模块链接;仅"用户列表"绑定 `openTab + navigate` +- 点击任意区域外(或再次点"全部导航")收起 + +### 6.4 UserListPage + +- 深色工具栏:刷新 / 新增(`usr:create` 权限)/ 导出Excel +- Filterbar:查询字段 Select + 匹配方式 Select + 查询值 Input + 搜索按钮 + 清空按钮 +- 表格:带单选圆点列 + 序号列 + 用户名/员工名/用户号/部门/用户类型/语言/作废/登录日期/制单人/制单日期 +- 双击行 → navigate(`/usr/users/${row.sId}`) +- 点"新增" → navigate('/usr/users/new') +- 筛选条件同步到 URL query string + +### 6.5 UserDetailPage + +- 深色工具栏:新增 / 修改 / 删除 / 保存 / 取消 / 作废 / 重置密码 +- 3 列表单网格(与原型一致):创建时间(只读)/ 制单人(只读)/ 员工名(下拉,可选)/ 用户名 / 类型 / 语言 / 用户号 / 单据修改权限(复选框) +- 可编辑字段背景 `--color-field-bg-edit`,只读字段背景 `--color-bg-input-readonly` +- 权限组 Tab:横排 Tab 栏(权限组 / 客户查看权限等占位)+ 权限列表(复选框 + 权限名) +- 新增模式:表单空白,保存调 `POST /api/usr/users` +- 编辑模式:行数据通过 React Router `location.state` 从列表页传入(后端无单条 GET by ID 接口);若直接访问 URL 但 state 为空,redirect 回 `/usr/users` +- 保存编辑调 `PUT /api/usr/users/:id` +- 删除、作废、重置密码:当前阶段按钮展示但不实现(REQ 范围外) + +--- + +## 七、超出 REQ 范围的按钮处理 + +工具栏中"删除"、"作废"、"取消作废"、"重置密码"在原型中存在,但当前 REQ-USR-001/002 未覆盖对应接口。这些按钮渲染但 `disabled`,tooltip 提示"功能待实现"。 -- 1.9.5 From e7e5dcee9b8cfa3a69ad892ea275a60884b36aef Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 8 May 2026 14:39:46 +0800 Subject: [PATCH 02/12] chore(usr): add prototype shell design tokens --- frontend/src/styles/tokens.css | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index bc8a542..cbd142b 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -40,4 +40,17 @@ --color-table-row-fg: #000000; --color-table-header-bg: #f5f5f5; --color-table-header-fg: rgba(0, 0, 0, 0.85); /* = #000000D9 */ + + /* === 3. Prototype shell tokens === */ + --color-topbar-bg: #1f1f23; + --color-toolbar-bg: #2c2f36; + --color-toolbar-text: #e6e7ea; + --color-tab-active: #1e84e6; + --color-tab-text: #9aa0a8; + --color-field-bg-edit: #eaf3fe; + --color-field-label-req: #f04848; + --color-table-border: #e3e6eb; + --color-table-row-alt: #f7f8fa; + --color-filterbar-bg: #ffffff; + --color-nav-overlay-bg: #2b3137; } -- 1.9.5 From ebe451fd53a53b882ce5d053d90aca4014e89c61 Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 8 May 2026 14:41:36 +0800 Subject: [PATCH 03/12] feat(usr): add tabsSlice for tab navigation state --- frontend/src/store/index.ts | 6 ++- frontend/src/store/slices/tabsSlice.ts | 53 +++++++++++++++++++++++++ frontend/src/test/tabsSlice.test.ts | 71 ++++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 frontend/src/store/slices/tabsSlice.ts create mode 100644 frontend/src/test/tabsSlice.test.ts diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts index 736b402..2145b38 100644 --- a/frontend/src/store/index.ts +++ b/frontend/src/store/index.ts @@ -1,10 +1,12 @@ import { configureStore } from '@reduxjs/toolkit' import authReducer from './slices/authSlice' +import tabsReducer from './slices/tabsSlice' export const store = configureStore({ reducer: { - auth: authReducer - } + auth: authReducer, + tabs: tabsReducer, + }, }) export type RootState = ReturnType diff --git a/frontend/src/store/slices/tabsSlice.ts b/frontend/src/store/slices/tabsSlice.ts new file mode 100644 index 0000000..2598b2c --- /dev/null +++ b/frontend/src/store/slices/tabsSlice.ts @@ -0,0 +1,53 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +export interface Tab { + id: string + title: string + path: string + closable: boolean +} + +interface TabsState { + tabs: Tab[] + activeId: string | null +} + +const initialState: TabsState = { + tabs: [{ id: 'main', title: '主页', path: '/', closable: false }], + activeId: 'main', +} + +const tabsSlice = createSlice({ + name: 'tabs', + initialState, + reducers: { + openTab(state, action: PayloadAction) { + const existing = state.tabs.find(t => t.id === action.payload.id) + if (existing) { + existing.path = action.payload.path + } else { + state.tabs.push(action.payload) + } + state.activeId = action.payload.id + }, + closeTab(state, action: PayloadAction) { + const idx = state.tabs.findIndex(t => t.id === action.payload) + if (idx === -1) return + state.tabs.splice(idx, 1) + if (state.activeId === action.payload) { + const newIdx = Math.min(idx, state.tabs.length - 1) + state.activeId = state.tabs[newIdx]?.id ?? null + } + }, + activateTab(state, action: PayloadAction) { + state.activeId = action.payload + }, + updateTabPath(state, action: PayloadAction<{ id: string; path: string }>) { + const tab = state.tabs.find(t => t.id === action.payload.id) + if (tab) tab.path = action.payload.path + }, + }, +}) + +export const { openTab, closeTab, activateTab, updateTabPath } = tabsSlice.actions +export default tabsSlice.reducer diff --git a/frontend/src/test/tabsSlice.test.ts b/frontend/src/test/tabsSlice.test.ts new file mode 100644 index 0000000..ce630fd --- /dev/null +++ b/frontend/src/test/tabsSlice.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest' +import tabsReducer, { + openTab, closeTab, activateTab, updateTabPath, + type Tab +} from '../store/slices/tabsSlice' + +const mainTab: Tab = { id: 'main', title: '主页', path: '/', closable: false } +const listTab: Tab = { id: 'userlist', title: '用户列表', path: '/usr/users', closable: true } +const detailTab: Tab = { id: 'userdetail', title: '用户信息单据', path: '/usr/users/new', closable: true } + +describe('tabsSlice', () => { + it('initialState_hasMainTab', () => { + const state = tabsReducer(undefined, { type: '' }) + expect(state.tabs).toHaveLength(1) + expect(state.tabs[0].id).toBe('main') + expect(state.activeId).toBe('main') + }) + + it('openTab_addsNewTabAndActivates', () => { + const state = tabsReducer(undefined, openTab(listTab)) + expect(state.tabs).toHaveLength(2) + expect(state.tabs[1].id).toBe('userlist') + expect(state.activeId).toBe('userlist') + }) + + it('openTab_existingId_updatesPathAndActivates', () => { + let state = tabsReducer(undefined, openTab(listTab)) + const updated: Tab = { ...listTab, path: '/usr/users?queryValue=alice' } + state = tabsReducer(state, openTab(updated)) + expect(state.tabs).toHaveLength(2) + expect(state.tabs[1].path).toBe('/usr/users?queryValue=alice') + expect(state.activeId).toBe('userlist') + }) + + it('closeTab_removesTabAndActivatesPrevious', () => { + let state = tabsReducer(undefined, openTab(listTab)) + state = tabsReducer(state, closeTab('userlist')) + expect(state.tabs).toHaveLength(1) + expect(state.activeId).toBe('main') + }) + + it('closeTab_middleTab_activatesTabToLeft', () => { + let state = tabsReducer(undefined, openTab(listTab)) + state = tabsReducer(state, openTab(detailTab)) + state = tabsReducer(state, closeTab('userdetail')) + expect(state.activeId).toBe('userlist') + }) + + it('closeTab_nonExistentId_doesNothing', () => { + const state = tabsReducer(undefined, closeTab('nonexistent')) + expect(state.tabs).toHaveLength(1) + expect(state.activeId).toBe('main') + }) + + it('activateTab_setsActiveId', () => { + let state = tabsReducer(undefined, openTab(listTab)) + state = tabsReducer(state, activateTab('main')) + expect(state.activeId).toBe('main') + }) + + it('updateTabPath_updatesPath', () => { + let state = tabsReducer(undefined, openTab(listTab)) + state = tabsReducer(state, updateTabPath({ id: 'userlist', path: '/usr/users?q=alice' })) + expect(state.tabs[1].path).toBe('/usr/users?q=alice') + }) + + it('updateTabPath_nonExistentId_doesNothing', () => { + const state = tabsReducer(undefined, updateTabPath({ id: 'nope', path: '/x' })) + expect(state.tabs[0].path).toBe('/') + }) +}) -- 1.9.5 From ae88da4c9f81b412162e998b348a3924cb290822 Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 8 May 2026 14:43:47 +0800 Subject: [PATCH 04/12] feat(usr): add NavOverlay component --- frontend/src/components/NavOverlay.tsx | 86 ++++++++++++++++++++++++++++++++++ frontend/src/test/NavOverlay.test.tsx | 56 ++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 frontend/src/components/NavOverlay.tsx create mode 100644 frontend/src/test/NavOverlay.test.tsx diff --git a/frontend/src/components/NavOverlay.tsx b/frontend/src/components/NavOverlay.tsx new file mode 100644 index 0000000..2a9d3b0 --- /dev/null +++ b/frontend/src/components/NavOverlay.tsx @@ -0,0 +1,86 @@ +import { useNavigate } from 'react-router-dom' +import { useAppDispatch } from '../store/hooks' +import { openTab } from '../store/slices/tabsSlice' + +const LEFT_MODULES = [ + '销售管理', 'DCS系统', '产品管理', '生产运营', '生产执行', '模具管理', + '采购管理', '材料库存', '成品库存', '外协管理', '物流管理', '质量管理', + '财务管理', '成本管理(专)', '成本管理', '设备管理', '人事行政', 'OA系统', + '基础设置', '系统设置', +] + +type NavItem = string | { label: string; go?: string; star?: boolean } + +const NAV_COLS: { title: string; items: NavItem[] }[] = [ + { title: '期初设置', items: ['客户期初', '供应商期初', '材料期初', '产品期初', '数据导入', '离线导出下载'] }, + { title: '用户管理', items: [{ label: '用户列表', go: 'userlist', star: true }, '系统权限', '系统权限稽查表', '权限组'] }, + { title: '系统参数', items: ['系统参数', '财务结账', '系统常量配置'] }, + { title: '计算方案', items: ['方案列表', '计算参数'] }, + { title: '日志', items: ['个性化模块', '操作日志', '异常清除KPI任务表', 'MYSQL监听器'] }, + { title: '开发平台', items: ['自定义开发范例', { label: '系统功能模块设置', star: true }, 'EBC流程清单', '功能模块界面设置', '增删改存业务处理'] }, + { title: 'API对接管理', items: ['调用第三方接口(TOKEN配置)', '调用第三方接口(接口定义)', '被第三方调用(生成token)', '数据同步', '被第三方调用(API定义)'] }, +] + +interface Props { onClose: () => void } + +export default function NavOverlay({ onClose }: Props) { + const navigate = useNavigate() + const dispatch = useAppDispatch() + + function handleUserList() { + dispatch(openTab({ id: 'userlist', title: '用户列表', path: '/usr/users', closable: true })) + navigate('/usr/users') + onClose() + } + + return ( +
+
e.stopPropagation()} style={{ display: 'flex', flex: 1 }}> + {/* Left sidebar */} +
+ {LEFT_MODULES.map(mod => ( +
+ {mod} +
+ ))} +
+ {/* Right grid */} +
+ {NAV_COLS.map(col => ( +
+

+ {col.title} +

+ {col.items.map(item => { + if (typeof item === 'string') { + return
{item}
+ } + return ( +
+ {item.label} + {item.star && } +
+ ) + })} +
+ ))} +
+
+
+ ) +} diff --git a/frontend/src/test/NavOverlay.test.tsx b/frontend/src/test/NavOverlay.test.tsx new file mode 100644 index 0000000..25e7093 --- /dev/null +++ b/frontend/src/test/NavOverlay.test.tsx @@ -0,0 +1,56 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Provider } from 'react-redux' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { configureStore } from '@reduxjs/toolkit' +import authReducer from '../store/slices/authSlice' +import tabsReducer from '../store/slices/tabsSlice' +import NavOverlay from '../components/NavOverlay' + +function makeStore() { + return configureStore({ reducer: { auth: authReducer, tabs: tabsReducer } }) +} + +function renderOverlay(onClose = vi.fn()) { + return render( + + + + } /> + UserList} /> + + + + ) +} + +describe('NavOverlay', () => { + it('renders_systemModules', () => { + renderOverlay() + expect(screen.getByText('系统设置')).toBeInTheDocument() + expect(screen.getByText('用户管理')).toBeInTheDocument() + }) + + it('clickUserList_navigatesAndClosesOverlay', async () => { + const onClose = vi.fn() + renderOverlay(onClose) + await userEvent.click(screen.getByText('用户列表')) + expect(screen.getByText('UserList')).toBeInTheDocument() + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('clickBackground_callsOnClose', async () => { + const onClose = vi.fn() + render( + + + + + + ) + // Click the outer overlay div (background) + await userEvent.click(document.querySelector('[data-testid="nav-bg"]')!) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) -- 1.9.5 From 2a548826b0f6648e27d8eea6cd5e9cca8d2bf43d Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 8 May 2026 14:45:48 +0800 Subject: [PATCH 05/12] feat(usr): add AppShell component with tab navigation --- frontend/src/components/AppShell.tsx | 96 ++++++++++++++++++++++++++++++++++++ frontend/src/test/AppShell.test.tsx | 85 +++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 frontend/src/components/AppShell.tsx create mode 100644 frontend/src/test/AppShell.test.tsx diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx new file mode 100644 index 0000000..6536b7d --- /dev/null +++ b/frontend/src/components/AppShell.tsx @@ -0,0 +1,96 @@ +import { useState } from 'react' +import { Outlet, useNavigate } from 'react-router-dom' +import { useAppSelector, useAppDispatch } from '../store/hooks' +import { closeTab, activateTab } from '../store/slices/tabsSlice' +import NavOverlay from './NavOverlay' + +const ANTLER_PATHS = [ + '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', + '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', + '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', +] + +export default function AppShell() { + const dispatch = useAppDispatch() + const navigate = useNavigate() + const { tabs, activeId } = useAppSelector(s => s.tabs) + const userInfo = useAppSelector(s => s.auth.userInfo) + const [navOpen, setNavOpen] = useState(false) + + function handleTabClick(id: string, path: string) { + dispatch(activateTab(id)) + navigate(path) + } + + function handleTabClose(e: React.MouseEvent, id: string) { + e.stopPropagation() + const idx = tabs.findIndex(t => t.id === id) + dispatch(closeTab(id)) + const remaining = tabs.filter(t => t.id !== id) + const newIdx = Math.min(idx, remaining.length - 1) + if (remaining[newIdx]) navigate(remaining[newIdx].path) + } + + return ( +
+ {/* Topbar */} +
+ {/* Logo */} +
+ + {ANTLER_PATHS.map((d, i) => )} + +
+ {/* Nav toggle */} + + {/* Tab list */} +
+ {tabs.map(tab => ( +
handleTabClick(tab.id, tab.path)} + style={{ + display: 'flex', alignItems: 'center', gap: 8, padding: '0 18px', cursor: 'pointer', fontSize: 14, height: '100%', + color: tab.id === activeId ? 'var(--color-tab-active)' : 'var(--color-tab-text)', + borderBottom: tab.id === activeId ? '2px solid var(--color-tab-active)' : 'none', + }} + > + {tab.title} + {tab.closable && ( + + )} +
+ ))} +
+ {/* User info */} +
+ {userInfo?.username}({userInfo?.userType}) +
+
+ {/* Stage */} +
+ {navOpen && setNavOpen(false)} />} +
+ +
+
+
+ ) +} diff --git a/frontend/src/test/AppShell.test.tsx b/frontend/src/test/AppShell.test.tsx new file mode 100644 index 0000000..4678a00 --- /dev/null +++ b/frontend/src/test/AppShell.test.tsx @@ -0,0 +1,85 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Provider } from 'react-redux' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { configureStore } from '@reduxjs/toolkit' +import authReducer from '../store/slices/authSlice' +import tabsReducer, { openTab } from '../store/slices/tabsSlice' +import AppShell from '../components/AppShell' + +vi.mock('../components/NavOverlay', () => ({ + default: ({ onClose }: { onClose: () => void }) => ( +
NavOverlay
+ ), +})) + +function makeStore(extraTabs = false) { + const store = configureStore({ reducer: { auth: authReducer, tabs: tabsReducer } }) + store.dispatch({ + type: 'auth/setCredentials', + payload: { accessToken: 'tok', refreshToken: 'ref', userInfo: { userId: 'u0', username: 'admin', userType: '超级管理员', language: '中文', brandId: 'b1' } }, + }) + if (extraTabs) { + store.dispatch(openTab({ id: 'userlist', title: '用户列表', path: '/usr/users', closable: true })) + } + return store +} + +function renderShell(path = '/', extraTabs = false) { + const store = makeStore(extraTabs) + return { + store, + ...render( + + + + }> + MainPage} /> + UserListPage} /> + + + + + ), + } +} + +describe('AppShell', () => { + it('renders_mainTabAndUserInfo', () => { + renderShell() + expect(screen.getByText('主页')).toBeInTheDocument() + expect(screen.getByText(/admin/)).toBeInTheDocument() + }) + + it('renders_openTabs', () => { + renderShell('/', true) + expect(screen.getByText('用户列表')).toBeInTheDocument() + }) + + it('clickTab_navigatesToTabPath', async () => { + renderShell('/', true) + await userEvent.click(screen.getByText('用户列表')) + await waitFor(() => expect(screen.getByText('UserListPage')).toBeInTheDocument()) + }) + + it('closeTab_removesTabAndNavigates', async () => { + renderShell('/usr/users', true) + await userEvent.click(screen.getByRole('button', { name: '关闭 用户列表' })) + await waitFor(() => expect(screen.queryByText('用户列表')).not.toBeInTheDocument()) + expect(screen.getByText('MainPage')).toBeInTheDocument() + }) + + it('navToggle_showsNavOverlay', async () => { + renderShell() + await userEvent.click(screen.getByRole('button', { name: /全部导航/ })) + expect(screen.getByTestId('nav-overlay')).toBeInTheDocument() + }) + + it('navToggle_secondClick_hidesOverlay', async () => { + renderShell() + await userEvent.click(screen.getByRole('button', { name: /全部导航/ })) + await userEvent.click(screen.getByRole('button', { name: /全部导航/ })) + expect(screen.queryByTestId('nav-overlay')).not.toBeInTheDocument() + }) +}) -- 1.9.5 From 24664be0d9ae586f79682f469cc0449bc16ea580 Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 8 May 2026 14:46:39 +0800 Subject: [PATCH 06/12] feat(usr): add MainPage and update App routing for tab shell --- frontend/src/App.tsx | 16 ++++++++++++---- frontend/src/pages/MainPage.tsx | 16 ++++++++++++++++ frontend/src/pages/usr/UserDetailPage.tsx | 3 +++ 3 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 frontend/src/pages/MainPage.tsx create mode 100644 frontend/src/pages/usr/UserDetailPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e91309e..cfb4ee9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,19 +1,27 @@ import { Navigate, Route, Routes } from 'react-router-dom' import { useAppSelector } from './store/hooks' +import AppShell from './components/AppShell' import LoginPage from './pages/usr/LoginPage' +import MainPage from './pages/MainPage' import UserListPage from './pages/usr/UserListPage' +import UserDetailPage from './pages/usr/UserDetailPage' -function PrivateRoute({ children }: { children: React.ReactNode }) { +function AuthenticatedShell() { const accessToken = useAppSelector(s => s.auth.accessToken) - return accessToken ? <>{children} : + if (!accessToken) return + return } export default function App() { return ( } /> -
主页(待实现)
} /> - } /> + }> + } /> + } /> + } /> + } /> + } />
) diff --git a/frontend/src/pages/MainPage.tsx b/frontend/src/pages/MainPage.tsx new file mode 100644 index 0000000..3329647 --- /dev/null +++ b/frontend/src/pages/MainPage.tsx @@ -0,0 +1,16 @@ +import { useEffect } from 'react' +import { useAppDispatch } from '../store/hooks' +import { activateTab } from '../store/slices/tabsSlice' + +export default function MainPage() { + const dispatch = useAppDispatch() + useEffect(() => { + dispatch(activateTab('main')) + }, [dispatch]) + + return ( +
+ 主页(功能待实现) +
+ ) +} diff --git a/frontend/src/pages/usr/UserDetailPage.tsx b/frontend/src/pages/usr/UserDetailPage.tsx new file mode 100644 index 0000000..63478f1 --- /dev/null +++ b/frontend/src/pages/usr/UserDetailPage.tsx @@ -0,0 +1,3 @@ +export default function UserDetailPage() { + return null +} -- 1.9.5 From 4a9044d7eda186b25b03743dad481e5384be46c5 Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 8 May 2026 14:48:18 +0800 Subject: [PATCH 07/12] feat(usr): rewrite LoginPage visual to match prototype REQ-USR-004 --- frontend/src/pages/usr/LoginPage.tsx | 85 +++++++++++++++++++++++------------- 1 file changed, 55 insertions(+), 30 deletions(-) diff --git a/frontend/src/pages/usr/LoginPage.tsx b/frontend/src/pages/usr/LoginPage.tsx index 6e4bab4..fb8551b 100644 --- a/frontend/src/pages/usr/LoginPage.tsx +++ b/frontend/src/pages/usr/LoginPage.tsx @@ -1,10 +1,17 @@ import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { Button, Form, Input, message, Select } from 'antd' -import { getBrands, login, BrandVO } from '../../api/auth' +import { Button, Form, Input, Select, message } from 'antd' +import { getBrands, login, type BrandVO } from '../../api/auth' import { setCredentials } from '../../store/slices/authSlice' +import { activateTab } from '../../store/slices/tabsSlice' import { useAppDispatch } from '../../store/hooks' +const ANTLER_PATHS = [ + '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', + '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', + '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', +] + export default function LoginPage() { const [brandOptions, setBrandOptions] = useState([]) const [loading, setLoading] = useState(false) @@ -19,17 +26,14 @@ export default function LoginPage() { const defaultNo = std ? std.sNo : brands[0]?.sNo if (defaultNo) form.setFieldValue('brandNo', defaultNo) }).catch(() => {}) - }, []) + }, [form]) const onFinish = async (values: { brandNo: string; username: string; password: string }) => { setLoading(true) try { const result = await login(values) - dispatch(setCredentials({ - accessToken: result.accessToken, - refreshToken: result.refreshToken, - userInfo: result.userInfo - })) + dispatch(setCredentials({ accessToken: result.accessToken, refreshToken: result.refreshToken, userInfo: result.userInfo })) + dispatch(activateTab('main')) navigate('/') } catch (e: unknown) { message.error(e instanceof Error ? e.message : '登录失败') @@ -39,28 +43,49 @@ export default function LoginPage() { } return ( -
-
-

小羚羊 ERP

-
- - - - - - - - - -
+
+ {/* Header */} +
+
+ + {ANTLER_PATHS.map((d, i) => )} + +
+ Antler ERP + 欢迎登录EBC平台 +
+ {/* Hero */} +
+ {/* Brand text */} +
+
Enterprise Business Capability
+
企业业务能力平台
+
ERP
+
+ {/* Login card */} +
+

用户登录

+
+ + + + + + + + + +
+
+
+ {/* Footer */} +
+ 🛠 ©Copyright Antler Software | 印刷ERP | 400-880-6237
) -- 1.9.5 From 11778b8c72a92be15810a3af9f79c1758771e137 Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 8 May 2026 14:51:43 +0800 Subject: [PATCH 08/12] feat(usr): rewrite UserListPage visual + URL filter sync REQ-USR-003 --- frontend/src/pages/usr/UserListPage.tsx | 220 ++++++++++++++++++-------------- frontend/src/test/UserListPage.test.tsx | 78 +++++------ 2 files changed, 158 insertions(+), 140 deletions(-) diff --git a/frontend/src/pages/usr/UserListPage.tsx b/frontend/src/pages/usr/UserListPage.tsx index 1444727..8e7a045 100644 --- a/frontend/src/pages/usr/UserListPage.tsx +++ b/frontend/src/pages/usr/UserListPage.tsx @@ -1,8 +1,8 @@ -import { useState, useEffect } from 'react' -import { Table, Select, Input, Button, Space } from 'antd' -import type { ColumnsType } from 'antd/es/table' +import { useEffect, useState } from 'react' +import { useNavigate, useSearchParams, useLocation } from 'react-router-dom' +import { useAppDispatch } from '../../store/hooks' +import { openTab, updateTabPath } from '../../store/slices/tabsSlice' import { PermButton } from '../../components/PermButton' -import UserFormDrawer from './UserFormDrawer' import { getUserList } from '../../api/usr' import type { PageVO, UserListItemVO } from '../../api/usr' @@ -24,110 +24,136 @@ const MATCH_TYPES = [ ] // REQ-USR-003: 查询用户 -// REQ-USR-002: 修改用户 +// REQ-USR-001: 新增入口 (navigate to /usr/users/new) +// REQ-USR-002: 修改入口 (double-click row → /usr/users/:id) export default function UserListPage() { - const [drawerOpen, setDrawerOpen] = useState(false) - const [editingUser, setEditingUser] = useState(null) + const navigate = useNavigate() + const dispatch = useAppDispatch() + const location = useLocation() + const [searchParams, setSearchParams] = useSearchParams() + + const [queryField, setQueryField] = useState(searchParams.get('queryField') ?? 'username') + const [matchType, setMatchType] = useState(searchParams.get('matchType') ?? 'contains') + const [queryValue, setQueryValue] = useState(searchParams.get('queryValue') ?? '') + const [currentPage, setCurrentPage] = useState(Number(searchParams.get('page') ?? '1')) const [data, setData] = useState | null>(null) - const [queryField, setQueryField] = useState('username') - const [matchType, setMatchType] = useState('contains') - const [queryValue, setQueryValue] = useState('') - const [currentPage, setCurrentPage] = useState(1) + const [selectedId, setSelectedId] = useState(null) + + useEffect(() => { + dispatch(openTab({ id: 'userlist', title: '用户列表', path: location.pathname + location.search, closable: true })) + doLoad(currentPage, queryField, matchType, queryValue) + }, []) - const load = (pg = 1) => { + function doLoad(pg: number, field: string, match: string, val: string) { setCurrentPage(pg) - getUserList({ queryField, matchType, queryValue, page: pg, pageSize: 20 }).then(setData) + getUserList({ queryField: field, matchType: match, queryValue: val, page: pg, pageSize: 20 }).then(setData) } - useEffect(() => { - load() - }, []) + function handleSearch() { + const newPath = `/usr/users?queryField=${queryField}&matchType=${matchType}&queryValue=${encodeURIComponent(queryValue)}` + setSearchParams({ queryField, matchType, queryValue, page: '1' }) + dispatch(updateTabPath({ id: 'userlist', path: newPath })) + doLoad(1, queryField, matchType, queryValue) + } - const columns: ColumnsType = [ - { title: '用户名', dataIndex: 'sUsername' }, - { title: '员工名', dataIndex: 'sStaffName' }, - { title: '用户号', dataIndex: 'sUserCode' }, - { title: '部门', dataIndex: 'sDepartment' }, - { title: '用户类型', dataIndex: 'sUserType' }, - { title: '语言', dataIndex: 'sLanguage' }, - { title: '作废', dataIndex: 'bIsDisabled', render: (v: number) => v ? '是' : '否' }, - { title: '登录日期', dataIndex: 'tLastLoginDate' }, - { title: '制单人', dataIndex: 'sCreatorUsername' }, - { title: '制单日期', dataIndex: 'tCreateDate' }, - { - title: '操作', - key: 'action', - render: (_, record) => ( - setEditingUser(record)} - > - 修改 - - ) - }, - ] + function handleClear() { + setQueryField('username'); setMatchType('contains'); setQueryValue('') + setSearchParams({}) + dispatch(updateTabPath({ id: 'userlist', path: '/usr/users' })) + doLoad(1, 'username', 'contains', '') + } + + const totalPages = data ? Math.ceil(data.total / 20) : 1 return ( -
- - - setQueryValue(e.target.value)} - placeholder="查询值" - style={{ width: 160 }} - /> - - setDrawerOpen(true)} - > +
+ {/* Toolbar */} +
+ + navigate('/usr/users/new')} style={tbBtn}> + 新增 - - - setDrawerOpen(false)} - onSuccess={() => { setDrawerOpen(false); load(1) }} - /> - setEditingUser(null)} - onSuccess={() => { setEditingUser(null); load(1) }} - /> + + + + + {/* Filter bar */} +
+ + + setQueryValue(e.target.value)} style={fIn} /> + + + +
+ {/* Table */} +
+
+ + + {['', '序号', '用户名', '员工名', '用户号', '部门', '用户类型', '语言', '作废', '登录日期', '制单人', '制单日期'].map((h, i) => ( + + ))} + + + + {(data?.list ?? []).map((row, idx) => ( + setSelectedId(row.sId)} + onDoubleClick={() => navigate(`/usr/users/${row.sId}`, { state: row })} + style={{ background: row.sId === selectedId ? 'var(--color-table-row-bg-selected)' : idx % 2 === 1 ? 'var(--color-table-row-alt)' : '#fff', cursor: 'pointer' }} + > + + + + + + + + + + + + + + ))} + +
+ {h}{i > 1 && ⇅ ⌕} +
{idx + 1}{row.sUsername}{row.sStaffName ?? ''}{row.sUserCode}{row.sDepartment ?? ''}{row.sUserType}{row.sLanguage}{row.bIsDisabled ? '是' : ''}{row.tLastLoginDate ?? ''}{row.sCreatorUsername ?? ''}{row.tCreateDate}
+
+ {/* Pager */} +
+ 当前显示 共{data?.total ?? 0}条记录 + + {currentPage} + + +
) } + +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' } +const fSel: React.CSSProperties = { height: 30, border: '1px solid #d5d8de', borderRadius: 2, padding: '0 28px 0 10px', background: '#fff', minWidth: 100 } +const fIn: React.CSSProperties = { height: 30, border: '1px solid #d5d8de', borderRadius: 2, padding: '0 10px', minWidth: 140, fontSize: 13, fontFamily: 'inherit' } +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' } +const td: React.CSSProperties = { padding: '7px 10px', textAlign: 'left', whiteSpace: 'nowrap', border: '1px solid var(--color-table-border)' } +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' } diff --git a/frontend/src/test/UserListPage.test.tsx b/frontend/src/test/UserListPage.test.tsx index 75b502d..ebb79e0 100644 --- a/frontend/src/test/UserListPage.test.tsx +++ b/frontend/src/test/UserListPage.test.tsx @@ -2,9 +2,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { Provider } from 'react-redux' -import { MemoryRouter } from 'react-router-dom' +import { MemoryRouter, Routes, Route } from 'react-router-dom' import { configureStore } from '@reduxjs/toolkit' import authReducer from '../store/slices/authSlice' +import tabsReducer from '../store/slices/tabsSlice' import UserListPage from '../pages/usr/UserListPage' vi.mock('../api/usr', () => ({ @@ -12,41 +13,50 @@ vi.mock('../api/usr', () => ({ getPermissionGroups: vi.fn().mockResolvedValue([]), createUser: vi.fn(), getUserList: vi.fn().mockResolvedValue({ total: 0, page: 1, pageSize: 20, list: [] }), - updateUser: vi.fn().mockResolvedValue({ userId: 'u1', username: 'alice', updatedAt: '2026-05-08T10:00:00' }) + updateUser: vi.fn().mockResolvedValue({ userId: 'u1', username: 'alice', updatedAt: '2026-05-08T10:00:00' }), })) vi.mock('../api/request', () => ({ - default: { get: vi.fn(), post: vi.fn() } + default: { get: vi.fn(), post: vi.fn(), put: vi.fn() }, })) function makeStore(userType: string) { - const store = configureStore({ reducer: { auth: authReducer } }) + const store = configureStore({ reducer: { auth: authReducer, tabs: tabsReducer } }) store.dispatch({ type: 'auth/setCredentials', - payload: { - accessToken: 'test-token', - refreshToken: 'test-refresh', - userInfo: { userId: 'u1', username: 'admin', userType, language: '中文', brandId: 'b1' } - } + payload: { accessToken: 'test-token', refreshToken: 'test-refresh', userInfo: { userId: 'u1', username: 'admin', userType, language: '中文', brandId: 'b1' } }, }) return store } -function renderPage(userType: string) { +const sampleRow = { + sId: 'u1', sUsername: 'alice', sUserCode: 'UC001', sUserType: '普通用户', + sLanguage: '中文', bCanEditDoc: 0, bIsDisabled: 0, tLastLoginDate: null, + sCreatorUsername: 'admin', tCreateDate: '2026-01-01T00:00:00', + sStaffName: '张三', sDepartment: '研发部', +} + +function renderPage(userType: string, extraRoutes = false) { const store = makeStore(userType) return render( - - + + {extraRoutes ? ( + + } /> + NewUserPage
} /> + DetailPage
} /> + + ) : ( + + )} ) } describe('UserListPage', () => { - beforeEach(() => { - vi.clearAllMocks() - }) + beforeEach(() => { vi.clearAllMocks() }) it('superAdmin_seesNewButton', () => { renderPage('超级管理员') @@ -58,23 +68,15 @@ describe('UserListPage', () => { expect(screen.queryByRole('button', { name: /新\s*增/ })).not.toBeInTheDocument() }) - it('clickNewButton_opensDrawer', async () => { - renderPage('超级管理员') + it('clickNewButton_navigatesToNewPage', async () => { + renderPage('超级管理员', true) await userEvent.click(screen.getByRole('button', { name: /新\s*增/ })) - await waitFor(() => expect(screen.getByText('新增用户')).toBeInTheDocument()) + await waitFor(() => expect(screen.getByText('NewUserPage')).toBeInTheDocument()) }) it('initialLoad_rendersTableRows', async () => { const { getUserList } = await import('../api/usr') - vi.mocked(getUserList).mockResolvedValueOnce({ - total: 1, page: 1, pageSize: 20, - list: [{ - sId: 'u1', sUsername: 'alice', sUserCode: 'UC001', sUserType: '普通用户', - sLanguage: '中文', bCanEditDoc: 0, bIsDisabled: 0, tLastLoginDate: null, - sCreatorUsername: 'admin', tCreateDate: '2026-01-01T00:00:00', - sStaffName: '张三', sDepartment: '研发部' - }] - }) + vi.mocked(getUserList).mockResolvedValueOnce({ total: 1, page: 1, pageSize: 20, list: [sampleRow] }) renderPage('超级管理员') await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()) expect(screen.getByText('张三')).toBeInTheDocument() @@ -87,22 +89,12 @@ describe('UserListPage', () => { await waitFor(() => expect(vi.mocked(getUserList)).toHaveBeenCalledTimes(2)) }) - it('editMode_submit_callsUpdateUser', async () => { - const { getUserList, updateUser } = await import('../api/usr') - vi.mocked(getUserList).mockResolvedValue({ - total: 1, page: 1, pageSize: 20, - list: [{ - sId: 'u1', sUsername: 'alice', sUserCode: 'UC001', sUserType: '普通用户', - sLanguage: '中文', bCanEditDoc: 0, bIsDisabled: 0, tLastLoginDate: null, - sCreatorUsername: 'admin', tCreateDate: '2026-01-01T00:00:00', - sStaffName: null, sDepartment: null - }] - }) - renderPage('超级管理员') + it('doubleClickRow_navigatesToDetailPage', async () => { + const { getUserList } = await import('../api/usr') + vi.mocked(getUserList).mockResolvedValueOnce({ total: 1, page: 1, pageSize: 20, list: [sampleRow] }) + renderPage('超级管理员', true) await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()) - await userEvent.click(screen.getByRole('button', { name: /修改/ })) - await waitFor(() => expect(screen.getByText('修改用户')).toBeInTheDocument()) - await userEvent.click(screen.getByRole('button', { name: /确\s*认/ })) - await waitFor(() => expect(vi.mocked(updateUser)).toHaveBeenCalledTimes(1)) + await userEvent.dblClick(screen.getByText('alice').closest('tr')!) + await waitFor(() => expect(screen.getByText('DetailPage')).toBeInTheDocument()) }) }) -- 1.9.5 From 777215f34d45c6a82688b25a49cdc20212b81fd8 Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 8 May 2026 14:55:11 +0800 Subject: [PATCH 09/12] feat(usr): add UserDetailPage full-screen form REQ-USR-001 REQ-USR-002 --- frontend/src/pages/usr/UserDetailPage.tsx | 200 +++++++++++++++++++++++++++++- frontend/src/test/UserDetailPage.test.tsx | 135 ++++++++++++++++++++ 2 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 frontend/src/test/UserDetailPage.test.tsx diff --git a/frontend/src/pages/usr/UserDetailPage.tsx b/frontend/src/pages/usr/UserDetailPage.tsx index 63478f1..f6b61eb 100644 --- a/frontend/src/pages/usr/UserDetailPage.tsx +++ b/frontend/src/pages/usr/UserDetailPage.tsx @@ -1,3 +1,201 @@ +import { useEffect, useState } from 'react' +import { useNavigate, useLocation, useParams } from 'react-router-dom' +import { message } from 'antd' +import { useAppDispatch } from '../../store/hooks' +import { openTab } from '../../store/slices/tabsSlice' +import { getStaffs, getPermissionGroups, createUser, updateUser } from '../../api/usr' +import type { StaffVO, PermissionGroupVO, UserListItemVO, UserCreateReq, UserUpdateReq } from '../../api/usr' + +// REQ-USR-001: 增加用户 +// REQ-USR-002: 修改用户 export default function UserDetailPage() { - return null + const { id } = useParams() + const navigate = useNavigate() + const location = useLocation() + const dispatch = useAppDispatch() + + const isNew = !id + const rowData = location.state as UserListItemVO | null + + const [staffs, setStaffs] = useState([]) + const [permGroups, setPermGroups] = useState([]) + const [selectedPermIds, setSelectedPermIds] = useState([]) + const [submitting, setSubmitting] = useState(false) + const [activePermTab, setActivePermTab] = useState(0) + + const [userCode, setUserCode] = useState('') + const [username, setUsername] = useState('') + const [userType, setUserType] = useState<'普通用户' | '超级管理员'>('普通用户') + const [language, setLanguage] = useState<'中文' | '英文' | '繁体'>('中文') + const [canEditDoc, setCanEditDoc] = useState(false) + const [isDisabled, setIsDisabled] = useState(false) + const [employeeId, setEmployeeId] = useState(null) + + useEffect(() => { + if (!isNew && !rowData) { + navigate('/usr/users', { replace: true }) + return + } + dispatch(openTab({ id: 'userdetail', title: '用户信息单据', path: location.pathname, closable: true })) + getStaffs().then(setStaffs).catch(() => {}) + getPermissionGroups().then(setPermGroups).catch(() => {}) + if (!isNew && rowData) { + setUserType(rowData.sUserType as '普通用户' | '超级管理员') + setLanguage(rowData.sLanguage as '中文' | '英文' | '繁体') + setCanEditDoc(rowData.bCanEditDoc === 1) + setIsDisabled(rowData.bIsDisabled === 1) + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + async function handleSave() { + if (isNew) { + if (!userCode.trim()) { message.error('请输入用户号'); return } + if (!username.trim()) { message.error('请输入用户名'); return } + } + setSubmitting(true) + try { + if (isNew) { + const req: UserCreateReq = { userCode, username, userType, language, canEditDoc, employeeId, permGroupIds: selectedPermIds } + await createUser(req) + message.success('新增用户成功') + } else { + const req: UserUpdateReq = { userType, language, canEditDoc, isDisabled, employeeId, permGroupIds: selectedPermIds } + await updateUser(id!, req) + message.success('修改用户成功') + } + navigate('/usr/users') + } catch (e: unknown) { + if (e instanceof Error) message.error(e.message) + } finally { + setSubmitting(false) + } + } + + const PERM_TABS = ['权限组', '客户查看权限', '供应商查看权限', '人员查看权限', '工序查看权限', '司机查看权限'] + + const fieldBg = 'var(--color-field-bg-edit)' + const readonlyBg = 'var(--color-form-bg-readonly)' + 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' } + 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' } + const selectStyle: React.CSSProperties = { ...inputStyle, padding: '0 8px' } + const cellStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px' } + const lblStyle: React.CSSProperties = { minWidth: 88, color: '#333', fontSize: 13, textAlign: 'right', flexShrink: 0 } + const reqLblStyle: React.CSSProperties = { ...lblStyle, color: 'var(--color-field-label-req)' } + + return ( +
+ {/* Dark toolbar */} +
+ {[ + { label: '⊕ 新增', onClick: () => navigate('/usr/users/new'), disabled: false }, + { label: '✎ 修改', onClick: undefined, disabled: true }, + { label: '🗑 删除', onClick: undefined, disabled: true, title: '功能待实现' }, + { label: '💾 保存', onClick: handleSave, disabled: submitting }, + { label: '✕ 取消', onClick: () => navigate('/usr/users'), disabled: false }, + { label: '作废', onClick: undefined, disabled: true, title: '功能待实现' }, + { label: '重置密码', onClick: undefined, disabled: true, title: '功能待实现' }, + ].map(btn => ( + + ))} + + +
+ + {/* 3-column form grid */} +
+ {/* Row 1: 创建时间 / 制单人 / 员工名 */} +
+ 创建时间: +
{isNew ? '' : rowData?.tCreateDate}
+
+
+ 制单人: +
{isNew ? '保存后自动生成' : rowData?.sCreatorUsername}
+
+
+ *员工名: + +
+ {/* Row 2: 用户名 / 类型 / 语言 */} +
+ {isNew ? '*用户名:' : '用户名:'} + {isNew + ? setUsername(e.target.value)} placeholder="请输入用户名" style={inputStyle} /> + :
{rowData?.sUsername}
} +
+
+ *类型: + +
+
+ *语言: + +
+ {/* Row 3: 用户号 / (empty) / 单据修改权限 */} +
+ {isNew ? '*用户号:' : '用户号:'} + {isNew + ? setUserCode(e.target.value)} placeholder="请输入用户号" style={inputStyle} /> + :
{rowData?.sUserCode}
} +
+
+
+ 单据修改权限: + setCanEditDoc(e.target.checked)} style={{ width: 14, height: 14 }} /> +
+
+ + {/* Permission tabs row */} +
+ {PERM_TABS.map((tab, i) => ( +
setActivePermTab(i)} + 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 }} + > + {tab} +
+ ))} +
+ + {/* Permission list */} +
+
+ + 权限分类 +
+ {activePermTab === 0 + ? permGroups.map(pg => ( +
+ setSelectedPermIds(prev => e.target.checked ? [...prev, pg.sId] : prev.filter(x => x !== pg.sId))} + style={{ width: 14, height: 14, flexShrink: 0 }} + /> + {pg.sGroupName} +
+ )) + :
功能待实现
} +
+
+ ) } diff --git a/frontend/src/test/UserDetailPage.test.tsx b/frontend/src/test/UserDetailPage.test.tsx new file mode 100644 index 0000000..c990bf4 --- /dev/null +++ b/frontend/src/test/UserDetailPage.test.tsx @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Provider } from 'react-redux' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { configureStore } from '@reduxjs/toolkit' +import authReducer from '../store/slices/authSlice' +import tabsReducer from '../store/slices/tabsSlice' +import UserDetailPage from '../pages/usr/UserDetailPage' + +vi.mock('../api/usr', () => ({ + getStaffs: vi.fn().mockResolvedValue([{ sId: 's1', sStaffName: '张三' }]), + getPermissionGroups: vi.fn().mockResolvedValue([{ sId: 'pg1', sGroupCode: 'usr:create', sGroupName: '新增用户', sCategory: '用户管理' }]), + createUser: vi.fn().mockResolvedValue({ userId: 'u2', userCode: 'UC002', username: 'bob' }), + updateUser: vi.fn().mockResolvedValue({ userId: 'u1', username: 'alice', updatedAt: '2026-05-08T10:00:00' }), + getUserList: vi.fn().mockResolvedValue({ total: 0, page: 1, pageSize: 20, list: [] }), +})) + +vi.mock('../api/request', () => ({ + default: { get: vi.fn(), post: vi.fn(), put: vi.fn() }, +})) + +function makeStore() { + const store = configureStore({ reducer: { auth: authReducer, tabs: tabsReducer } }) + store.dispatch({ + type: 'auth/setCredentials', + payload: { accessToken: 'tok', refreshToken: 'ref', userInfo: { userId: 'u0', username: 'admin', userType: '超级管理员', language: '中文', brandId: 'b1' } }, + }) + return store +} + +const rowData = { + sId: 'u1', sUsername: 'alice', sUserCode: 'UC001', sUserType: '普通用户', + sLanguage: '中文', bCanEditDoc: 0, bIsDisabled: 0, tLastLoginDate: null, + sCreatorUsername: 'admin', tCreateDate: '2026-01-01 00:00:00', + sStaffName: null, sDepartment: null, +} + +function renderNew() { + return render( + + + + } /> + UserList
} /> + + + + ) +} + +function renderEdit(state = rowData) { + return render( + + + + } /> + UserList
} /> + + + + ) +} + +describe('UserDetailPage', () => { + beforeEach(() => { vi.clearAllMocks() }) + + it('newMode_showsSaveButton', () => { + renderNew() + expect(screen.getByRole('button', { name: /保存/ })).toBeInTheDocument() + }) + + it('newMode_showsPermissionGroupTab', async () => { + renderNew() + await waitFor(() => expect(screen.getByText('新增用户')).toBeInTheDocument()) + }) + + it('editMode_showsUsernameReadonly', async () => { + renderEdit() + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()) + }) + + it('editMode_noState_redirectsToList', async () => { + render( + + + + } /> + UserList} /> + + + + ) + await waitFor(() => expect(screen.getByText('UserList')).toBeInTheDocument()) + }) + + it('newMode_save_callsCreateUser', async () => { + const { createUser } = await import('../api/usr') + renderNew() + await userEvent.type(screen.getByPlaceholderText('请输入用户号'), 'UC002') + await userEvent.type(screen.getByPlaceholderText('请输入用户名'), 'bob') + await userEvent.click(screen.getByRole('button', { name: /保存/ })) + await waitFor(() => expect(vi.mocked(createUser)).toHaveBeenCalledWith( + expect.objectContaining({ userCode: 'UC002', username: 'bob' }) + )) + }) + + it('editMode_save_callsUpdateUser', async () => { + const { updateUser } = await import('../api/usr') + renderEdit() + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()) + await userEvent.click(screen.getByRole('button', { name: /保存/ })) + await waitFor(() => expect(vi.mocked(updateUser)).toHaveBeenCalledWith( + 'u1', + expect.objectContaining({ userType: '普通用户' }) + )) + }) + + it('permCheckbox_togglesSelection', async () => { + renderNew() + await waitFor(() => expect(screen.getByText('新增用户')).toBeInTheDocument()) + const cb = screen.getByRole('checkbox') + expect(cb).not.toBeChecked() + await userEvent.click(cb) + expect(cb).toBeChecked() + await userEvent.click(cb) + expect(cb).not.toBeChecked() + }) + + it('cancelButton_navigatesToList', async () => { + renderNew() + await userEvent.click(screen.getByRole('button', { name: /取消/ })) + await waitFor(() => expect(screen.getByText('UserList')).toBeInTheDocument()) + }) +}) -- 1.9.5 From e792984dbd0363b8a121f8a32dccf4a7726d210b Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 8 May 2026 14:55:59 +0800 Subject: [PATCH 10/12] chore(usr): delete UserFormDrawer replaced by UserDetailPage --- frontend/src/pages/usr/UserFormDrawer.tsx | 171 ------------------------------ 1 file changed, 171 deletions(-) delete mode 100644 frontend/src/pages/usr/UserFormDrawer.tsx diff --git a/frontend/src/pages/usr/UserFormDrawer.tsx b/frontend/src/pages/usr/UserFormDrawer.tsx deleted file mode 100644 index 872eb0c..0000000 --- a/frontend/src/pages/usr/UserFormDrawer.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { useEffect, useState } from 'react' -import { - Drawer, Form, Input, Select, Checkbox, Button, message, Table -} from 'antd' -import type { ColumnsType } from 'antd/es/table' -import { getStaffs, getPermissionGroups, createUser, updateUser, StaffVO, PermissionGroupVO, UserCreateReq, UserUpdateReq } from '../../api/usr' - -interface InitialData { - userType: string - language: string - canEditDoc: boolean - isDisabled: boolean - employeeId?: string | null - permGroupIds?: string[] -} - -interface Props { - open: boolean - onClose: () => void - onSuccess: () => void - userId?: string - initialData?: InitialData -} - -export default function UserFormDrawer({ open, onClose, onSuccess, userId, initialData }: Props) { - const [form] = Form.useForm() - const [staffs, setStaffs] = useState([]) - const [permGroups, setPermGroups] = useState([]) - const [selectedPermIds, setSelectedPermIds] = useState([]) - const [submitting, setSubmitting] = useState(false) - - const isEditMode = !!userId - - useEffect(() => { - if (open) { - getStaffs().then(setStaffs).catch(() => {}) - getPermissionGroups().then(setPermGroups).catch(() => {}) - if (isEditMode && initialData) { - form.setFieldsValue({ - userType: initialData.userType, - language: initialData.language, - canEditDoc: initialData.canEditDoc, - isDisabled: initialData.isDisabled, - employeeId: initialData.employeeId ?? null, - }) - setSelectedPermIds(initialData.permGroupIds ?? []) - } else { - form.resetFields() - setSelectedPermIds([]) - } - } - }, [open]) - - const permColumns: ColumnsType = [ - { - title: '', - key: 'select', - width: 40, - render: (_, record) => ( - { - setSelectedPermIds(prev => - e.target.checked ? [...prev, record.sId] : prev.filter(id => id !== record.sId) - ) - }} - /> - ) - }, - { title: '权限组', dataIndex: 'sGroupName' }, - { title: '分类', dataIndex: 'sCategory' } - ] - - async function handleSubmit() { - try { - const values = await form.validateFields() - setSubmitting(true) - if (isEditMode) { - const req: UserUpdateReq = { - userType: values.userType, - language: values.language, - canEditDoc: values.canEditDoc ?? false, - isDisabled: values.isDisabled ?? false, - employeeId: values.employeeId ?? null, - permGroupIds: selectedPermIds - } - await updateUser(userId, req) - message.success('修改用户成功') - } else { - const req: UserCreateReq = { - userCode: values.userCode, - username: values.username, - userType: values.userType, - language: values.language, - canEditDoc: values.canEditDoc ?? false, - employeeId: values.employeeId ?? null, - permGroupIds: selectedPermIds - } - await createUser(req) - message.success('新增用户成功') - } - form.resetFields() - setSelectedPermIds([]) - onSuccess() - } catch (e: unknown) { - if (e instanceof Error) { - message.error(e.message) - } - } finally { - setSubmitting(false) - } - } - - return ( - - - - - } - > -
- {!isEditMode && ( - <> - - - - - - - - )} - - - - - 可编辑文档 - - {isEditMode && ( - - 作废 - - )} - -