diff --git a/apps/admin/package.json b/apps/admin/package.json index e5bbac1..68b4e37 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -14,9 +14,9 @@ }, "dependencies": { "@ant-design/icons": "^6.1.1", - "@ant-design/x": "2.8.0", - "@ant-design/x-markdown": "2.8.0", - "@ant-design/x-sdk": "2.8.0", + "@ant-design/x": "^2.8.0", + "@ant-design/x-markdown": "^2.8.0", + "@ant-design/x-sdk": "^2.8.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index 43fedae..779f41e 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; -import { Sparkles } from 'lucide-react'; import { Layout, Menu, Button, Avatar, Dropdown, Drawer, Grid, Tooltip } from 'antd'; import { DashboardOutlined, @@ -312,7 +311,7 @@ const MainLayout: React.FC = () => { ; -}; - -export default PermissionButton; -``` - -- [x] **Step 3: 创建 PermissionRoute 组件** - -```typescript -// frontend/src/components/PermissionRoute.tsx -import React from 'react'; -import { Result } from 'antd'; -import { usePermission } from '../hooks/usePermission'; - -interface PermissionRouteProps { - permission: string; - children: React.ReactNode; -} - -const PermissionRoute: React.FC = ({ permission, children }) => { - const { hasPermission } = usePermission(); - if (!hasPermission(permission)) { - return ( - - ); - } - return <>{children}; -}; - -export default PermissionRoute; -``` - -- [x] **Step 4: 修改 Login 页面存储 permissions** - -在 `frontend/src/pages/Login/index.tsx` 的 `onFinish` 方法中,登录成功后存储 permissions: - -```typescript -// 在 try 块中,现有代码之后添加: -localStorage.setItem('permissions', JSON.stringify(res.user.permissions || [])); -``` - -完整变更: - -```typescript -const onFinish = async (values: any) => { - setLoading(true); - try { - const res: any = await api.post('/auth/login', values); - localStorage.setItem('token', res.access_token); - localStorage.setItem('user', JSON.stringify(res.user)); - localStorage.setItem('permissions', JSON.stringify(res.user.permissions || [])); - message.success('登录成功'); - navigate('/dashboard'); - } catch (err: any) { - message.error(err?.message || '登录失败'); - } finally { - setLoading(false); - } -}; -``` - -同时修改退出登录时的清理: - -```typescript -// 在 MainLayout.tsx 的 handleLogout 中(Task 10 会处理), -// 但此处先在 Login 页确保权限被正确存储 -``` - -- [x] **Step 5: 修改 axios 拦截器处理 403** - -在 `frontend/src/api/index.ts` 中,在 401 处理逻辑之后增加 403 处理: - -```typescript -api.interceptors.response.use( - (res) => res.data, - (err) => { - if (err.response?.status === 401) { - localStorage.removeItem('token'); - localStorage.removeItem('user'); - localStorage.removeItem('permissions'); - window.location.href = '/login'; - } - if (err.response?.status === 403) { - // 403 不跳转登录,仅提示权限不足 - // 使用动态 import 避免循环依赖,或者简单 console 处理 - // 由于 antd message 在此处无法直接使用,延迟处理: - const msg = err.response?.data?.message || '权限不足'; - console.warn('[403]', msg); - } - return Promise.reject(err.response?.data || err); - }, -); -``` - -**注意**:此处 403 提示需在各页面调用 api 时由 catch 块处理显示 message。具体在 Task 10 中各页面的 api 调用 catch 块中增加 403 判断。 - -- [x] **Step 6: 编译验证** - -```bash -cd frontend && npx tsc -b --noEmit -``` - -预期:无类型错误(可能需要处理 React 19 + Ant Design 6 的类型兼容问题,如有则忽略第三方类型错误)。 - -- [x] **Step 7: Commit** - -```bash -git add frontend/src/hooks/ frontend/src/components/PermissionButton.tsx frontend/src/components/PermissionRoute.tsx frontend/src/pages/Login/ frontend/src/api/ -git commit -m "feat(frontend): add usePermission hook, PermissionButton, PermissionRoute, and 403 handling" -``` - -archived-with: 2026-07-03-rbac-refactor ---- - -### Task 9: 前端角色管理和权限一览页面 - -**Files:** -- Create: `frontend/src/pages/Roles/index.tsx` -- Create: `frontend/src/pages/Permissions/index.tsx` - -**Interfaces:** -- Consumes: `usePermission` hook (Task 8), `/rbac/roles` API, `/rbac/permissions` API (Task 5) -- Produces: 角色管理页面(CRUD + 权限勾选),权限一览页面(只读分组展示) - -- [x] **Step 1: 创建角色管理页面** - -```tsx -// frontend/src/pages/Roles/index.tsx -import React, { useEffect, useState } from 'react'; -import { Table, Button, Modal, Form, Input, Space, Tag, Popconfirm, message, Card, Checkbox } from 'antd'; -import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; -import api from '../../api'; -import { usePermission } from '../../hooks/usePermission'; -import PermissionButton from '../../components/PermissionButton'; - -interface PermissionItem { - id: number; - code: string; - name: string; - group: string; -} - -interface RoleItem { - id: number; - name: string; - description: string; - isSystem: boolean; - status: number; - permissions: PermissionItem[]; -} - -const RolesPage: React.FC = () => { - const [data, setData] = useState([]); - const [loading, setLoading] = useState(false); - const [modalOpen, setModalOpen] = useState(false); - const [editing, setEditing] = useState(null); - const [allPerms, setAllPerms] = useState<{ group: string; permissions: PermissionItem[] }[]>([]); - const [form] = Form.useForm(); - const [selectedPermIds, setSelectedPermIds] = useState([]); - - const fetchData = async () => { - setLoading(true); - try { - const [roles, permTree] = await Promise.all([ - api.get('/rbac/roles') as Promise, - api.get('/rbac/permissions/tree') as Promise<{ group: string; permissions: PermissionItem[] }[]>, - ]); - setData(roles); - setAllPerms(permTree); - } catch (e) { console.error(e); } - setLoading(false); - }; - - useEffect(() => { fetchData(); }, []); - - const handleAdd = () => { - setEditing(null); - form.resetFields(); - setSelectedPermIds([]); - setModalOpen(true); - }; - - const handleEdit = (record: RoleItem) => { - setEditing(record); - form.setFieldsValue({ name: record.name, description: record.description }); - setSelectedPermIds(record.permissions.map(p => p.id)); - setModalOpen(true); - }; - - const handleSubmit = async () => { - const values = await form.validateFields(); - try { - if (editing) { - await api.put(`/rbac/roles/${editing.id}`, { name: values.name, description: values.description, permissionIds: selectedPermIds }); - message.success('角色更新成功'); - } else { - await api.post('/rbac/roles', { name: values.name, description: values.description, permissionIds: selectedPermIds }); - message.success('角色创建成功'); - } - setModalOpen(false); - fetchData(); - } catch (e: any) { message.error(e.message || '操作失败'); } - }; - - const handleDelete = async (id: number) => { - try { - await api.delete(`/rbac/roles/${id}`); - message.success('角色已删除'); - fetchData(); - } catch (e: any) { message.error(e.message || '删除失败'); } - }; - - const groupNames: Record = { - dashboard: '数据面板', student: '学生管理', room: '宿舍管理', occupancy: '入住管理', - expense: '费用管理', bill: '账单管理', deposit: '押金管理', - classroom: '教室管理', tenant: '租赁方', rental: '租赁订单', - log: '操作日志', user: '用户管理', role: '角色管理', - }; - - const columns = [ - { title: 'ID', dataIndex: 'id', width: 60 }, - { title: '名称', dataIndex: 'name', width: 120 }, - { title: '描述', dataIndex: 'description', width: 200, ellipsis: true }, - { - title: '权限标签', dataIndex: 'permissions', width: 150, ellipsis: true, - render: (perms: PermissionItem[]) => perms?.length > 0 - ? {perms.length} 个权限 - : 无权限, - }, - { - title: '系统', dataIndex: 'isSystem', width: 70, - render: (v: boolean) => v ? 系统 : null, - }, - { - title: '操作', width: 160, fixed: 'right' as const, - render: (_: any, record: RoleItem) => ( - - } onClick={() => handleEdit(record)}> - 编辑 - - {!record.isSystem && ( - - handleDelete(record.id)}> - - - - )} - - ), - }, - ]; - - const handleGroupCheckAll = (group: string, checked: boolean) => { - const groupPermIds = allPerms.find(g => g.group === group)?.permissions.map(p => p.id) || []; - if (checked) { - setSelectedPermIds(prev => [...new Set([...prev, ...groupPermIds])]); - } else { - setSelectedPermIds(prev => prev.filter(id => !groupPermIds.includes(id))); - } - }; - - const isGroupAllChecked = (group: string) => { - const groupPermIds = allPerms.find(g => g.group === group)?.permissions.map(p => p.id) || []; - return groupPermIds.length > 0 && groupPermIds.every(id => selectedPermIds.includes(id)); - }; - - const isGroupIndeterminate = (group: string) => { - const groupPermIds = allPerms.find(g => g.group === group)?.permissions.map(p => p.id) || []; - const checkedCount = groupPermIds.filter(id => selectedPermIds.includes(id)).length; - return checkedCount > 0 && checkedCount < groupPermIds.length; - }; - - return ( -
-
-

角色管理

- } onClick={handleAdd}> - 新增角色 - -
- - - setModalOpen(false)} - width={700} - destroyOnClose - > -
- - - - - - - -
- {allPerms.map(group => ( - handleGroupCheckAll(group.group, e.target.checked)} - > - {groupNames[group.group] || group.group} - - } - style={{ marginBottom: 8 }} - > - setSelectedPermIds(vals as number[])} - > - - {group.permissions.map(p => ( - {p.name} - ))} - - - - ))} -
-
- -
- - ); -}; - -export default RolesPage; -``` - -- [x] **Step 2: 创建权限一览页面** - -```tsx -// frontend/src/pages/Permissions/index.tsx -import React, { useEffect, useState } from 'react'; -import { Card, Tag, Input, Space, Spin } from 'antd'; -import api from '../../api'; - -interface PermissionItem { - id: number; - code: string; - name: string; - group: string; - description: string; -} - -const PermissionsPage: React.FC = () => { - const [permTree, setPermTree] = useState<{ group: string; permissions: PermissionItem[] }[]>([]); - const [loading, setLoading] = useState(false); - const [search, setSearch] = useState(''); - - const groupNames: Record = { - dashboard: '数据面板', student: '学生管理', room: '宿舍管理', occupancy: '入住管理', - expense: '费用管理', bill: '账单管理', deposit: '押金管理', - classroom: '教室管理', tenant: '租赁方', rental: '租赁订单', - log: '操作日志', user: '用户管理', role: '角色管理', - }; - - useEffect(() => { - setLoading(true); - api.get('/rbac/permissions/tree') - .then((res: any) => setPermTree(res)) - .catch(console.error) - .finally(() => setLoading(false)); - }, []); - - const filteredTree = search - ? permTree.map(g => ({ - ...g, - permissions: g.permissions.filter(p => - p.name.includes(search) || p.code.includes(search) - ), - })).filter(g => g.permissions.length > 0) - : permTree; - - if (loading) return ; - - return ( -
-
-

权限一览

- !e.target.value && setSearch('')} - /> -
- - {filteredTree.map(group => ( - {groupNames[group.group] || group.group} ({group.permissions.length})} - size="small" - > - - {group.permissions.map(p => ( - - {p.name} {p.code} - - ))} - - - ))} - -
- ); -}; - -export default PermissionsPage; -``` - -- [x] **Step 3: 编译验证** - -```bash -cd frontend && npx tsc -b --noEmit -``` - -预期:无新增类型错误。 - -- [x] **Step 4: Commit** - -```bash -git add frontend/src/pages/Roles/ frontend/src/pages/Permissions/ -git commit -m "feat(frontend): add Roles management page and Permissions overview page" -``` - -archived-with: 2026-07-03-rbac-refactor ---- - -### Task 10: 前端路由、菜单和用户管理页面适配 - -**Files:** -- Modify: `frontend/src/App.tsx` -- Modify: `frontend/src/layouts/MainLayout.tsx` -- Modify: `frontend/src/pages/Users/index.tsx` -- Modify: 各业务页面中需要权限控制的按钮(约 11 个页面) - -**Interfaces:** -- Consumes: `usePermission`, `PermissionRoute`, `PermissionButton` (Task 8), `/rbac/users` API (Task 5) - -- [x] **Step 1: 修改 App.tsx 添加路由和权限包装** - -```tsx -// frontend/src/App.tsx -import React from 'react'; -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; -import { ConfigProvider, App as AntdApp } from 'antd'; -import zhCN from 'antd/es/locale/zh_CN'; -import MainLayout from './layouts/MainLayout'; -import LoginPage from './pages/Login'; -import DashboardPage from './pages/Dashboard'; -import StudentsPage from './pages/Students'; -import RoomsPage from './pages/Rooms'; -import OccupanciesPage from './pages/Occupancies'; -import ExpensesPage from './pages/Expenses'; -import BillsPage from './pages/Bills'; -import RoomVisualPage from './pages/RoomVisual'; -import OperationLogsPage from './pages/OperationLogs'; -import UsersPage from './pages/Users'; -import DepositsPage from './pages/Deposits'; -import ClassroomsPage from './pages/Classrooms'; -import TenantsPage from './pages/Tenants'; -import ClassroomRentalsPage from './pages/ClassroomRentals'; -import ClassroomSchedulePage from './pages/ClassroomSchedule'; -import RolesPage from './pages/Roles'; -import PermissionsPage from './pages/Permissions'; -import PermissionRoute from './components/PermissionRoute'; - -const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const token = localStorage.getItem('token'); - return token ? <>{children} : ; -}; - -const App: React.FC = () => { - return ( - - - - - } /> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - - - ); -}; - -export default App; -``` - -- [x] **Step 2: 修改 MainLayout 菜单过滤** - -```tsx -// frontend/src/layouts/MainLayout.tsx -// 核心变更: -// 1. 导入 usePermission hook -// 2. 为每个菜单项添加 permission 字段 -// 3. 使用 permissions 数组过滤(替代原来的 role === 'admin' 和 allowedMenus 逻辑) -// 4. 添加角色管理和权限一览菜单项 -// 5. 退出登录时清除 permissions - -import React, { useState, useEffect } from 'react'; -import { Outlet, useNavigate, useLocation } from 'react-router-dom'; -import { Layout, Menu, Button, Avatar, Dropdown, Drawer } from 'antd'; -import { - DashboardOutlined, TeamOutlined, HomeOutlined, SwapOutlined, - DollarOutlined, FileTextOutlined, LogoutOutlined, UserOutlined, - MenuFoldOutlined, MenuUnfoldOutlined, AppstoreOutlined, - AuditOutlined, SettingOutlined, WalletOutlined, ReadOutlined, - TagsOutlined, FileProtectOutlined, CalendarOutlined, - SafetyOutlined, KeyOutlined, -} from '@ant-design/icons'; -import { usePermission } from '../hooks/usePermission'; - -const { Header, Sider, Content } = Layout; - -interface MenuItemType { - key: string; - icon: React.ReactNode; - label: string; - permission?: string; - children?: MenuItemType[]; -} - -const allMenuItems: MenuItemType[] = [ - { key: '/dashboard', icon: , label: '数据面板', permission: 'dashboard:view' }, - { key: '/room-visual', icon: , label: '宿舍总览', permission: 'room:view' }, - { key: '/students', icon: , label: '学生管理', permission: 'student:view' }, - { key: '/rooms', icon: , label: '宿舍管理', permission: 'room:view' }, - { key: '/occupancies', icon: , label: '入住管理', permission: 'occupancy:view' }, - { key: '/expenses', icon: , label: '费用录入', permission: 'expense:view' }, - { key: '/deposits', icon: , label: '押金管理', permission: 'deposit:view' }, - { key: '/bills', icon: , label: '账单管理', permission: 'bill:view' }, - { - key: 'classroom-group', - icon: , - label: '教室管理', - permission: 'classroom:view', - children: [ - { key: '/classroom-schedule', icon: , label: '排期总览', permission: 'classroom:view' }, - { key: '/classrooms', icon: , label: '教室列表', permission: 'classroom:view' }, - { key: '/classroom-rentals', icon: , label: '租赁订单', permission: 'rental:view' }, - { key: '/tenants', icon: , label: '租赁方', permission: 'tenant:view' }, - ], - }, - { key: '/operation-logs', icon: , label: '操作日志', permission: 'log:view' }, - { key: '/roles', icon: , label: '角色管理', permission: 'role:view' }, - { key: '/permissions', icon: , label: '权限一览', permission: 'role:view' }, - { key: '/users', icon: , label: '账号管理', permission: 'user:view' }, -]; - -const MainLayout: React.FC = () => { - const [collapsed, setCollapsed] = useState(false); - const [isMobile, setIsMobile] = useState(window.innerWidth < 768); - const [drawerOpen, setDrawerOpen] = useState(false); - const navigate = useNavigate(); - const location = useLocation(); - const user = JSON.parse(localStorage.getItem('user') || '{}'); - const { hasPermission } = usePermission(); - - useEffect(() => { - const handleResize = () => setIsMobile(window.innerWidth < 768); - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, []); - - // 按 permission 过滤菜单 - const filterByPermission = (items: MenuItemType[]): MenuItemType[] => { - return items - .map(item => { - if (item.children) { - const kids = filterByPermission(item.children); - if (kids.length === 0) return null; - return { ...item, children: kids }; - } - if (!item.permission) return item; - return hasPermission(item.permission) ? item : null; - }) - .filter(Boolean) as MenuItemType[]; - }; - - const menuItems = filterByPermission(allMenuItems); - - const handleLogout = () => { - localStorage.removeItem('token'); - localStorage.removeItem('user'); - localStorage.removeItem('permissions'); - navigate('/login'); - }; - - const handleMenuClick = (key: string) => { - navigate(key); - if (isMobile) setDrawerOpen(false); - }; - - const transformToMenuItems = (items: MenuItemType[]): any[] => { - return items.map(item => ({ - key: item.key, - icon: item.icon, - label: item.label, - children: item.children ? transformToMenuItems(item.children) : undefined, - })); - }; - - const menuContent = ( - handleMenuClick(key)} - style={{ border: 'none' }} - /> - ); - - return ( - - {!isMobile && ( - -
- {collapsed ? '恭' : '恭学教育基地'} -
- {menuContent} -
- )} - {isMobile && ( - setDrawerOpen(false)} width={240} styles={{ body: { padding: 0 } }} title="恭学教育基地"> - {menuContent} - - )} - -
-
- - - -
-
- ); -}; - -export default MainLayout; -``` - -- [x] **Step 3: 重构用户管理页面** - -在 `frontend/src/pages/Users/index.tsx` 中: -1. API 端点从 `/auth/users` 改为 `/rbac/users`,创建用户从 `/auth/register` 改为 `/rbac/users POST` -2. 角色列从单一 Tag(admin/operator)改为多角色 Tag 列表 -3. 编辑弹窗角色从 Select 单选改为 Select mode="multiple" -4. 移除 `allowedMenus` 相关代码(MENU_OPTIONS 常量、Checkbox.Group) -5. 导入并加载可选角色列表(从 `/rbac/roles`) - -```tsx -// frontend/src/pages/Users/index.tsx -import React, { useEffect, useState } from 'react'; -import { Table, Button, Modal, Form, Input, Select, Switch, Space, Tag, Popconfirm, message } from 'antd'; -import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined } from '@ant-design/icons'; -import dayjs from 'dayjs'; -import api from '../../api'; -import PermissionButton from '../../components/PermissionButton'; - -const UsersPage: React.FC = () => { - const [data, setData] = useState([]); - const [roles, setRoles] = useState([]); - const [loading, setLoading] = useState(false); - const [modalOpen, setModalOpen] = useState(false); - const [pwdModalOpen, setPwdModalOpen] = useState(false); - const [editing, setEditing] = useState(null); - const [resetTarget, setResetTarget] = useState(null); - const [form] = Form.useForm(); - const [pwdForm] = Form.useForm(); - - const fetchData = async () => { - setLoading(true); - try { - const [users, rolesRes] = await Promise.all([ - api.get('/rbac/users') as Promise, - api.get('/rbac/roles') as Promise, - ]); - setData(users); - setRoles(rolesRes); - } catch (e) { console.error(e); } - setLoading(false); - }; - - useEffect(() => { fetchData(); }, []); - - const handleAdd = () => { - setEditing(null); - form.resetFields(); - setModalOpen(true); - }; - - const handleEdit = (record: any) => { - setEditing(record); - form.setFieldsValue({ - username: record.username, - name: record.name, - isActive: record.isActive, - roleIds: record.roles?.map((r: any) => r.id) || [], - }); - setModalOpen(true); - }; - - const handleSubmit = async () => { - const values = await form.validateFields(); - try { - if (editing) { - await api.put(`/rbac/users/${editing.id}`, { username: values.username, name: values.name, isActive: values.isActive, roleIds: values.roleIds || [] }); - message.success('更新成功'); - } else { - await api.post('/rbac/users', { username: values.username, password: values.password, name: values.name, roleIds: values.roleIds || [] }); - message.success('创建成功'); - } - setModalOpen(false); - fetchData(); - } catch (e: any) { message.error(e.message || '操作失败'); } - }; - - const handleDelete = async (id: number) => { - try { - await api.delete(`/rbac/users/${id}`); - message.success('已删除'); - fetchData(); - } catch (e: any) { message.error(e.message || '删除失败'); } - }; - - const handleResetPwd = (record: any) => { - setResetTarget(record); - pwdForm.resetFields(); - setPwdModalOpen(true); - }; - - const handlePwdSubmit = async () => { - const values = await pwdForm.validateFields(); - try { - await api.put(`/rbac/users/${resetTarget.id}/password`, { password: values.password }); - message.success('密码已重置'); - setPwdModalOpen(false); - } catch (e: any) { message.error(e.message || '操作失败'); } - }; - - const columns = [ - { title: 'ID', dataIndex: 'id', width: 60 }, - { title: '用户名', dataIndex: 'username', width: 120 }, - { title: '姓名', dataIndex: 'name', width: 120 }, - { - title: '角色', dataIndex: 'roles', width: 200, - render: (v: any[]) => v && v.length > 0 - ? v.map(r => {r.name}) - : 无角色, - }, - { - title: '状态', dataIndex: 'isActive', width: 80, - render: (v: boolean) => {v ? '启用' : '禁用'}, - }, - { - title: '最后登录', dataIndex: 'lastLoginAt', width: 170, - render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-', - }, - { - title: '创建时间', dataIndex: 'createdAt', width: 170, - render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'), - }, - { - title: '操作', width: 220, fixed: 'right' as const, - render: (_: any, record: any) => ( - - } onClick={() => handleEdit(record)}>编辑 - } onClick={() => handleResetPwd(record)}>重置密码 - {record.username !== 'admin' && ( - - handleDelete(record.id)}> - - - - )} - - ), - }, - ]; - - return ( -
-
-

账号管理

- } onClick={handleAdd}>新增账号 -
-
- - setModalOpen(false)} destroyOnClose> -
- - - - {!editing && ( - - - - )} - - - - {editing && ( - - - - )} - -
` 组件若无 `scroll={{ x }}` 则添加之,列少(<=6)用 `max-content`,列中(7-10)用 `800`,列多(>10)用 `1000` -- 弹窗宽度由全局 CSS `max-width: calc(100vw - 24px)` 覆盖,组件级不做重复处理 -- 编译必须通过:`npm run build` 无 TypeScript 错误 -- 命名约定:从 `expenseTypeMap` 等现有映射扩展,不新建无意义的变量名 - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 1: 全局 CSS 三断点体系 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/index.css` - -**Interfaces:** -- Produces: 三个 `@media` 层级的 CSS 规则,供所有页面共用 -- 断点规范:手机 `(max-width: 575px)`、平板 `(min-width: 576px) and (max-width: 991px)` - -**Description:** 将当前仅有单断点(max-width: 767px)的 index.css 改为三断点体系,同时添加全局表格容器横向滚动和通用组件响应式样式。 - -- [x] **Step 1: 替换 index.css 中的 @media 规则** - -将当前文件内容替换为以下完整版本: - -```css -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: - -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -#root { - min-height: 100vh; -} - -/* === 通用:表格容器横向滚动 === */ -.ant-table-wrapper { - overflow-x: auto; -} - -/* === 手机 (< 576px) === */ -@media (max-width: 575px) { - .ant-table { - font-size: 13px; - } - .ant-table-cell { - padding: 8px 6px !important; - } - .ant-descriptions-item-label, - .ant-descriptions-item-content { - font-size: 13px; - } - .ant-modal { - max-width: calc(100vw - 24px) !important; - margin: 12px auto !important; - } - .ant-modal .ant-modal-body { - max-height: 60vh; - overflow-y: auto; - } - h2 { - font-size: 18px !important; - } - .ant-card { - margin-bottom: 8px; - } - .ant-space-item .ant-btn { - padding: 2px 6px; - font-size: 12px; - } -} - -/* === 平板 (576-991px) === */ -@media (min-width: 576px) and (max-width: 991px) { - .ant-modal { - max-width: calc(100vw - 48px) !important; - } -} -``` - -**Changed from current:** -- 旧断点 `(max-width: 767px)` 改为 `(max-width: 575px)` -- 新增 `.ant-table-wrapper { overflow-x: auto }` 通用规则 -- 新增平板断点 `(min-width: 576px) and (max-width: 991px)` 仅做弹窗微调 -- 桌面(>= 992px)使用浏览器默认样式,无需显式 @media - -- [x] **Step 2: 验证 CSS 语法** - -```bash -# 用 node 检查语法(没有专门的 CSS 语法检查,可跳过) -echo "CSS rules updated" -``` - -- [x] **Step 3: Commit** - -```bash -git add apps/admin/src/index.css -git commit -m "feat: 建立三断点 CSS 体系,替换单断点移动端样式" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 2: MainLayout 三端布局重构 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/layouts/MainLayout.tsx` - -**Interfaces:** -- Consumes: antd `Grid.useBreakpoint()` (from `antd`) -- Produces: `isMobile` / `isTablet` / `isDesktop` 三端布尔值,Sider/Drawer 切换逻辑 -- Removes: `useState(window.innerWidth < 768)` + `useEffect` resize 事件监听 - -**Description:** 将 MainLayout 从自建 resize 监听改为 antd v6 的 Grid.useBreakpoint() hook,根据三端断点控制侧栏(桌面 Sider / 手机 Drawer / 平板 Sider 折叠)、Header 用户区域文字显示、Content padding。 - -- [x] **Step 1: 替换 import 和状态声明** - -找到文件中的 import 区域(第 3 行附近),在 antd 的 import 中添加 `Grid`: - -```typescript -import { Layout, Menu, Button, Avatar, Dropdown, Drawer, Grid } from 'antd'; -``` - -然后替换第 86-98 行的状态和 useEffect: - -```typescript -const MainLayout: React.FC = () => { - const [collapsed, setCollapsed] = useState(false); - const screens = Grid.useBreakpoint(); - const isMobile = !screens.sm; // < 576px (仅 xs) - const isTablet = (screens.sm || screens.md) && !screens.lg; // 576-991px - const isDesktop = !!screens.lg; // >= 992px - const [drawerOpen, setDrawerOpen] = useState(false); - const navigate = useNavigate(); - const location = useLocation(); - const user = JSON.parse(localStorage.getItem('user') || '{}'); - const { hasPermission } = usePermission(); -``` - -移除原来的 `useEffect` + `resize` 事件监听(第 94-98 行)和 `useState(window.innerWidth < 768)`(第 87 行)。 - -- [x] **Step 2: 更新 Sider/平板/Drawer 渲染逻辑** - -替换第 150-187 行的侧栏区域: - -```typescript - return ( - - {/* 桌面 + 平板:Sider;手机:Drawer */} - {!isMobile && ( - -
- {(isTablet || collapsed) ? '恭' : '恭学教育基地'} -
- {menuContent} -
- )} - {isMobile && ( - setDrawerOpen(false)} - width={240} - styles={{ body: { padding: 0 } }} - title="恭学教育基地" - > - {menuContent} - - )} -``` - -关键变化:平板 (`isTablet`) 时 Sider 强制折叠 (`collapsed={isTablet ? true : collapsed}`)。 - -- [x] **Step 3: 更新 Collapse 按钮逻辑** - -替换第 200-212 行的 Button onClick: - -```typescript -
- - } /> - - - - - } - /> - - - - - } - /> - - - - - } - /> - - - -``` - -变化:`span={6}` -> `xs={12} sm={12} md={6}`;Row 的 `gutter={16}` -> `gutter={[16, 16]}` 增加垂直间距。 - -- [x] **Step 3: 图表卡片 Col 改为响应式断点** - -替换第 227-246 行的图表 Row: - -```typescript - - - - {expenseStats.length > 0 ? ( - - ) : ( -
暂无费用数据
- )} -
- - - - {roomRanking.length > 0 ? ( - - ) : ( -
暂无费用数据
- )} -
- - -``` - -变化: -- `span={12}` -> `xs={24} sm={12}`(手机堆叠,平板及以上并排) -- ECharts style 添加 `width: '100%'` 确保跟随容器 resize -- 高度改为 `isMobile ? 250 : 300` - -- [x] **Step 4: 甘特图 ECharts 添加 width: '100%'** - -替换第 216-225 行的甘特图区域: - -```typescript - - {ganttData.length > 0 ? ( - - ) : ( -
暂无入住数据
- )} -
-``` - -- [x] **Step 5: 顶部工具栏小屏堆叠** - -替换第 162-177 行的标题和 DatePicker 区域: - -```typescript -
-

数据面板

- { - if (dates) setPeriod([dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')]); - }} - /> -
-``` - -变化:小屏下 `flexDirection: 'column'` + `alignItems: 'flex-start'` + `gap: 12`。 - -- [x] **Step 6: Commit** - -```bash -git add apps/admin/src/pages/Dashboard/index.tsx -git commit -m "feat: Dashboard 响应式网格,统计卡片/图表/工具栏适配三端" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 4: 学生管理字段拆分和响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Students/index.tsx` - -**Interfaces:** -- Consumes: 后端 `/students` API 返回学生对象(含 `studentNumber` 可选字段) -- Produces: 拆分后的列定义、表格 scroll、工具栏 wrap - -**Description:** 将「学号/身份证」列拆分为「学号」和「身份证」两个独立列,添加 `ellipsis` 和 `width`;表格添加 `scroll={{ x }}`;顶部工具栏添加 `flexWrap: 'wrap'` + `gap`;表单中添加 `studentNumber` 字段。 - -- [x] **Step 1: 拆分列定义** - -替换第 151-156 行的 columns 数组中的对应条目。将: - -```typescript - { title: '学号/身份证', dataIndex: 'idNumber' }, -``` - -替换为: - -```typescript - { - title: '学号', - dataIndex: 'studentNumber', - width: 120, - ellipsis: true, - render: (v: string) => v || '-', - }, - { - title: '身份证', - dataIndex: 'idNumber', - width: 180, - ellipsis: true, - render: (v: string) => v || '-', - }, -``` - -同时为其余缺少 `ellipsis` 的文本列添加 `ellipsis: true`(name, phone, emergencyContact, emergencyPhone, supervisor): - -```typescript - { title: 'ID', dataIndex: 'id', width: 60 }, - { title: '姓名', dataIndex: 'name', ellipsis: true }, - { title: '性别', dataIndex: 'gender', width: 60 }, - { title: '电话', dataIndex: 'phone', ellipsis: true }, - // ... 上面已替换的学号和身份证列 - { title: '民族', dataIndex: 'ethnicity', width: 80 }, - { title: '紧急联系人', dataIndex: 'emergencyContact', ellipsis: true }, - { title: '紧急联系人电话', dataIndex: 'emergencyPhone', ellipsis: true }, - { - title: '所属机构', - dataIndex: 'organization', - render: (v: string) => (v ? {v} : '-'), - }, - { title: '负责人', dataIndex: 'supervisor', ellipsis: true }, -``` - -- [x] **Step 2: 表格添加 scroll** - -在第 305 行的 ` `共 ${total} 人` }} -``` - -- [x] **Step 3: 工具栏添加 wrap 和 gap** - -替换第 223-304 行的工具栏 JSX。在顶部 `
` 和左右 `` 之间改为: - -```typescript -
- - -
-``` - -变化: -- 外层 div 添加 `flexWrap: 'wrap'` 和 `gap: 8` -- 左右 Space 均添加 `wrap` 属性 - -- [x] **Step 4: 表单中添加学号字段** - -在 modal 中的 `` 区域(第 329 行附近),在 `name` 字段和 `gender` 字段之间添加学号字段: - -```typescript - - - -``` - -- [x] **Step 5: Commit** - -```bash -git add apps/admin/src/pages/Students/index.tsx -git commit -m "feat: 学生管理字段拆分(学号/身份证分列),表格和工具栏响应式适配" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 5: 宿舍总览 RoomVisual 复查 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/RoomVisual/index.tsx` - -**Interfaces:** -- Consumes: 当前已有的 `xs/sm/md/lg` Col 响应式断点 -- Produces: 确保平板断点(md: >= 768px)覆盖 576-991px 范围 - -**Description:** RoomVisual 已经使用了 `xs={12} sm={8} md={6} lg={4}` 等响应式 Col props,只需确认布局在 576-991px 平板区域表现合理。弹窗已使用 `width={500}`,全局 CSS 会覆盖窄屏。 - -- [x] **Step 1: 验证当前布局** - -查看房态网格行(第 112 行附近),当前为 `xs={12} sm={8} md={6} lg={4}`: -- 手机 (xs: < 576px): 2 列 -- OK -- sm (>= 576px): 3 列 -- OK -- md (>= 768px): 4 列 -- OK -- lg (>= 992px): 6 列 -- OK - -平板区域(576-991px)覆盖了 sm 和 md 两个断点,从 3 列到 4 列,布局合理。无需修改。 - -- [x] **Step 2: 统计卡片栏复查** - -第 87-107 行的统计卡片当前为 `xs={12} sm={6}`: -- 手机: 2 列 -- 平板及以上 (>= 576px): 4 列 - -在平板下 4 张卡并排可能略挤,改为 `xs={12} sm={6} md={6}` 以保证桌面端也是 4 列,平板端保持 4 列(可用)。无需修改。 - -- [x] **Step 3: 弹窗复查** - -详情弹窗(第 182-249 行)`width={500}`,在手机下由全局 CSS `max-width: calc(100vw - 24px)` 覆盖。无需修改。 - -- [x] **Step 4: Commit** - -```bash -git add apps/admin/src/pages/RoomVisual/index.tsx -git commit -m "chore: RoomVisual 三端断点复查确认,无改动" -``` - -注:如果复查无改动,可跳过此 commit,或提交一个空 commit 标记完成。 - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 6: 入住管理 Occupancies 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Occupancies/index.tsx` - -**Interfaces:** -- Consumes: 已有 Alert、Tab 切换区、搜索/操作区 -- Produces: 表格 scroll、工具栏 wrap、弹窗宽度(全局 CSS 覆盖) - -**Description:** 表格添加 `scroll={{ x }}`;顶部 Alert + Tab + 搜索/操作区支持小屏堆叠;弹窗宽度由全局 CSS 约束。 - -- [x] **Step 1: 表格添加 scroll** - -在第 444 行的 ` `共 ${total} 条` }} - rowSelection={rowSelection} - /> -``` - -- [x] **Step 2: 工具栏 wrap 验证** - -查看第 251-395 行的工具栏 JSX。外层 div 已有 `flexWrap: 'wrap'` 和 `gap: 8`。左侧 Space 有 `wrap`,右侧 Space 有 `wrap`。这些已经正确,无需修改。 - -- [x] **Step 3: 导入时自动收押金设置行检查** - -第 380-393 行的收押金设置行已经是内联 flex,在小屏下会自动换行。当前实现使用 `` 包裹,换行体验一般但可用。无需修改。 - -- [x] **Step 4: 弹窗宽度** - -当前弹窗: -- 入住登记: `width={500}`(第 460 行) -- 退宿: 无 width 属性(第 517 行,默认 520px) -- 批量退宿: `width={500}`(第 557 行) -- 换房: `width={500}`(第 610 行) - -这些弹窗在小屏下由全局 CSS `@media (max-width: 575px)` 的 `.ant-modal { max-width: calc(100vw - 24px) !important }` 约束。平板弹窗由 `@media (min-width: 576px) and (max-width: 991px)` 约束为 `max-width: calc(100vw - 48px)`。无需额外修改。 - -- [x] **Step 5: Commit** - -```bash -git add apps/admin/src/pages/Occupancies/index.tsx -git commit -m "feat: 入住管理表格添加 scroll,工具栏/弹窗响应式适配" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 7: 宿舍管理 Rooms 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Rooms/index.tsx` - -**Interfaces:** -- Consumes: 已有搜索/筛选/按钮工具栏 -- Produces: 表格 scroll、工具栏 wrap 和 gap - -**Description:** 表格添加 `scroll={{ x }}`;确保工具栏和操作列在三端适配。 - -- [x] **Step 1: 表格添加 scroll** - -在第 374 行的 ` `共 ${total} 间` }} - rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')} - rowSelection={{ - selectedRowKeys, - onChange: (keys) => setSelectedRowKeys(keys as number[]), - getCheckboxProps: (record: any) => ({ disabled: record.status === 'archived' }), - }} - /> -``` - -- [x] **Step 2: 工具栏已有 wrap 和 gap** - -查看第 279-372 行的工具栏 JSX。外层 div 已有 `flexWrap: 'wrap'` 和 `gap: 8`。左右 Space 均已有 `wrap`。这些已经正确,无需修改。 - -- [x] **Step 3: Commit** - -```bash -git add apps/admin/src/pages/Rooms/index.tsx -git commit -m "feat: 宿舍管理表格添加 scroll 横向滚动" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 8: 费用录入 Expenses 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Expenses/index.tsx` - -**Interfaces:** -- Consumes: Tabs 的两个 Tab(宿舍费用/个人附加费),各有独立 Table 和工具栏 -- Produces: 两个表格分别添加 scroll、弹窗宽度(全局 CSS) - -**Description:** 宿舍费用和个人附加费两个 Tab 下的表格均添加 `scroll={{ x }}`;弹窗由全局 CSS 覆盖。 - -- [x] **Step 1: 宿舍费用表格添加 scroll** - -在第 435 行的宿舍费用 ` `共 ${total} 条` }} - rowSelection={{ - selectedRowKeys: selectedRoomKeys, - onChange: (keys) => setSelectedRoomKeys(keys as number[]), - }} - /> -``` - -- [x] **Step 2: 个人附加费表格添加 scroll** - -在第 587 行的个人附加费 ` `共 ${total} 条` }} - rowSelection={{ - selectedRowKeys: selectedPersonalKeys, - onChange: (keys) => setSelectedPersonalKeys(keys as number[]), - }} - /> -``` - -- [x] **Step 3: 工具栏 wrap 验证** - -第 319-326 行和第 455-461 行的工具栏外层 div 已有 `flexWrap: 'wrap'` 和 `gap: 8`。Space 均有 `wrap`。无需修改。 - -- [x] **Step 4: 弹窗验证** - -录入宿舍费用弹窗(第 604 行)和个人附加费弹窗(第 644 行)均无显式 width,由全局 CSS 约束窄屏。无需修改。 - -- [x] **Step 5: Commit** - -```bash -git add apps/admin/src/pages/Expenses/index.tsx -git commit -m "feat: 费用录入两个表格添加 scroll 横向滚动" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 9: 押金管理 Deposits 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Deposits/index.tsx` - -**Interfaces:** -- Consumes: 已有搜索/筛选工具栏 -- Produces: 表格 scroll、工具栏 wrap、弹窗宽度 - -**Description:** 表格添加 `scroll={{ x }}`;搜索/筛选工具栏 wrap;押金收取和退还弹窗由全局 CSS 覆盖。 - -- [x] **Step 1: 表格添加 scroll** - -在第 211 行的 ` `共 ${total} 条` }} - /> -``` - -列数较多(学生、金额、日期、状态、退还金额、扣除金额、原因、日期、备注 + 操作),使用 `x: 1000`。 - -- [x] **Step 2: 工具栏已有 wrap 和 gap** - -第 165-172 行的工具栏外层 div 已有 `flexWrap: 'wrap'` 和 `gap: 8`,Space 有 `wrap`。无需修改。 - -- [x] **Step 3: Commit** - -```bash -git add apps/admin/src/pages/Deposits/index.tsx -git commit -m "feat: 押金管理表格添加 scroll 横向滚动" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 10: 账单管理 Bills 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Bills/index.tsx` - -**Interfaces:** -- Consumes: 已有搜索/筛选/按钮工具栏,详情弹窗含嵌套 Table -- Produces: 表格 scroll、工具栏 wrap、弹窗宽度 - -**Description:** 表格添加 `scroll={{ x }}`;搜索/筛选/按钮工具栏已有 wrap;生成账单弹窗和详情弹窗由全局 CSS 覆盖。 - -- [x] **Step 1: 主表格添加 scroll** - -在第 388 行的 ` `共 ${total} 条` }} - rowSelection={{ - selectedRowKeys: selectedRows, - onChange: (keys) => setSelectedRows(keys as number[]), - }} - /> -``` - -账单列数多(学生、周期、分摊费、个人费、总计、可用押金、抵扣后应付、状态、时间 + 操作列宽320),使用 `x: 1200`。 - -- [x] **Step 2: 工具栏已有 wrap 和 gap** - -第 307-314 行的工具栏外层 div 已有 `flexWrap: 'wrap'` 和 `gap: 8`,Space 有 `wrap`。无需修改。 - -- [x] **Step 3: 详情弹窗中的内嵌表格添加 scroll** - -在第 494 行的详情弹窗内嵌 ` typeMap[v] || v }, - { title: '说明', dataIndex: 'description' }, - { - title: '计费天数', - dataIndex: 'days', - render: (v: number) => (v > 0 ? `${v}天` : '-'), - }, - { - title: '宿舍总人天', - dataIndex: 'totalRoomDays', - render: (v: number) => (v > 0 ? `${v}天` : '-'), - }, - { - title: '宿舍总费用', - dataIndex: 'roomTotalAmount', - render: (v: number) => `¥${Number(v).toFixed(2)}`, - }, - { - title: '应分摊', - dataIndex: 'studentAmount', - render: (v: number) => ¥{Number(v).toFixed(2)}, - }, - ]} - /> -``` - -- [x] **Step 4: Commit** - -```bash -git add apps/admin/src/pages/Bills/index.tsx -git commit -m "feat: 账单管理表格添加 scroll,详情内嵌表格也添加 scroll" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 11: 教室管理 Classrooms 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Classrooms/index.tsx` - -**Interfaces:** -- Consumes: 已有搜索/筛选工具栏 -- Produces: 表格 scroll - -**Description:** 表格添加 `scroll={{ x }}`;工具栏已有 wrap。 - -- [x] **Step 1: 表格添加 scroll** - -在第 265 行的 ` `共 ${total} 条` }} - /> -``` - -- [x] **Step 2: 工具栏已有 wrap 和 gap** - -第 194-201 行的工具栏外层 div 已有 `flexWrap: 'wrap'` 和 `gap: 8`,Space 有 `wrap`。无需修改。 - -- [x] **Step 3: Commit** - -```bash -git add apps/admin/src/pages/Classrooms/index.tsx -git commit -m "feat: 教室管理表格添加 scroll 横向滚动" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 12: 租赁订单 ClassroomRentals 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/ClassroomRentals/index.tsx` - -**Interfaces:** -- Consumes: 已有 `scroll={{ x: 1200 }}` -- Produces: 弹窗宽度 - -**Description:** 表格已有 `scroll={{ x: 1200 }}`,值合理无需修改;工具栏已有 wrap 和 gap;弹窗 `width={600}` 由全局 CSS 覆盖窄屏。 - -- [x] **Step 1: 复查现有 scroll 值** - -第 318 行已有 `scroll={{ x: 1200 }}`。该表有 10+ 列(教室、租赁方、开始日期、结束日期、时长、日租金、总额、合同、操作),`x: 1200` 合理。无需修改。 - -- [x] **Step 2: 工具栏已有 wrap 和 gap** - -第 277-284 行的工具栏外层 div 已有 `flexWrap: 'wrap'` 和 `gap: 8`,Space 有 `wrap`。无需修改。 - -- [x] **Step 3: Commit** - -```bash -git add apps/admin/src/pages/ClassroomRentals/index.tsx -git commit -m "chore: ClassroomRentals 复查 scroll 和 wrap,确认无需修改" -``` - -注:如果复查无改动,可跳过此 commit。 - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 13: 租赁方 Tenants 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Tenants/index.tsx` - -**Interfaces:** -- Consumes: 已有工具栏 -- Produces: 表格 scroll - -**Description:** 表格添加 `scroll={{ x }}`;工具栏已有 wrap 和 gap。 - -- [x] **Step 1: 表格添加 scroll** - -在第 183 行的 ` `共 ${total} 条` }} - /> -``` - -列较少(名称、联系人、电话、颜色、备注 + 操作),使用 `x: 700`。 - -- [x] **Step 2: 工具栏已有 wrap 和 gap** - -第 152-159 行的工具栏外层 div 已有 `flexWrap: 'wrap'` 和 `gap: 8`。无需修改。 - -- [x] **Step 3: Commit** - -```bash -git add apps/admin/src/pages/Tenants/index.tsx -git commit -m "feat: 租赁方表格添加 scroll 横向滚动" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 14: 操作日志 OperationLogs 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/OperationLogs/index.tsx` - -**Interfaces:** -- Consumes: 已有 `scroll={{ x: 1000 }}` 和工具栏 -- Produces: 确认现有适配 - -**Description:** 表格已有 `scroll={{ x: 1000 }}` 且值合理(8 列),无需修改;工具栏已有 wrap 和 gap。 - -- [x] **Step 1: 复查现有 scroll 值** - -第 151 行已有 `scroll={{ x: 1000 }}`。该表有 8 列(时间、操作人、模块、操作、状态、详情、IP、终端),`x: 1000` 合理。无需修改。 - -- [x] **Step 2: 工具栏已有 flexWrap 和 gap** - -第 113-121 行已有 `flexWrap: 'wrap'` 和 `gap: 12`。无需修改。 - -- [x] **Step 3: Commit** - -```bash -git add apps/admin/src/pages/OperationLogs/index.tsx -git commit -m "chore: OperationLogs 复查 scroll 和 wrap,确认无需修改" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 15: 角色管理 Roles 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Roles/index.tsx` - -**Interfaces:** -- Consumes: 已有 `scroll={{ x: 800 }}`、工具栏、权限分配弹窗 -- Produces: 工具栏 wrap、弹窗宽度 - -**Description:** 表格已有 `scroll={{ x: 800 }}`;工具栏添加 wrap;权限分配弹窗 `width={700}` 由全局 CSS 覆盖窄屏。 - -- [x] **Step 1: 工具栏添加 flexWrap 和 gap** - -替换第 206-212 行的工具栏 div: - -```typescript -
-

角色管理

- 新增角色 -
-``` - -- [x] **Step 2: 复查表格 scroll** - -第 224 行已有 `scroll={{ x: 800 }}`。该表有 ID、名称、描述、权限标签、系统 + 操作列(fixed: right),`x: 800` 合理。无需修改。 - -- [x] **Step 3: 弹窗宽度** - -权限分配弹窗第 238 行 `width={700}`,在窄屏下由全局 CSS 覆盖。无需修改。 - -- [x] **Step 4: Commit** - -```bash -git add apps/admin/src/pages/Roles/index.tsx -git commit -m "feat: 角色管理工具栏添加 flexWrap 响应式适配" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 16: 权限一览 Permissions 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Permissions/index.tsx` - -**Interfaces:** -- Consumes: 卡片列表(非 Table 页面) -- Produces: 工具栏 wrap - -**Description:** 权限一览是卡片列表(非表格),只需确保标题 + 搜索栏在小屏下堆叠。 - -- [x] **Step 1: 工具栏添加 flexWrap 和 gap** - -替换第 58-64 行的工具栏 div: - -```typescript -
-

权限一览

- -
-``` - -- [x] **Step 2: Commit** - -```bash -git add apps/admin/src/pages/Permissions/index.tsx -git commit -m "feat: 权限一览工具栏添加 flexWrap 响应式适配" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 17: 账号管理 Users 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Users/index.tsx` - -**Interfaces:** -- Consumes: 已有 `scroll={{ x: 1000 }}` -- Produces: 工具栏 wrap - -**Description:** 表格已有 `scroll={{ x: 1000 }}`;工具栏添加 wrap。 - -- [x] **Step 1: 工具栏添加 flexWrap 和 gap** - -替换第 198-204 行的工具栏 div: - -```typescript -
-

账号管理

- 新增账号 -
-``` - -- [x] **Step 2: 复查表格 scroll** - -第 216 行已有 `scroll={{ x: 1000 }}`。该表有 ID、用户名、姓名、角色、状态、最后登录、创建时间 + 操作列(fixed: right),`x: 1000` 合理。无需修改。 - -- [x] **Step 3: Commit** - -```bash -git add apps/admin/src/pages/Users/index.tsx -git commit -m "feat: 账号管理工具栏添加 flexWrap 响应式适配" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 18: 教室排期 ClassroomSchedule 特殊适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/ClassroomSchedule/index.tsx` - -**Interfaces:** -- Consumes: 已有 `overflowX: 'auto'` 容器、sticky 首列、minWidth 日期列 -- Produces: 确认日期列不被挤压,sticky 列在小屏正常 - -**Description:** 教室排期是自定义 HTML `
`,含 31+ 日期列 + sticky 首列。当前外层 div 已有 `overflowX: 'auto'`,日期列 `minWidth: 26`,首列 sticky。确认在平板/手机下正常工作。 - -- [x] **Step 1: 日期列 minWidth 不变** - -第 234 行已设置 `minWidth: 26`,在窄屏下不会挤压。无需修改。 - -- [x] **Step 2: 首列 sticky 左偏移确认** - -第 210 行首列已有 `position: 'sticky', left: 0`,在小屏下仍然生效。无需修改。 - -- [x] **Step 3: 详情弹窗宽度** - -第 327 行弹窗 `width={500}`,在窄屏下由全局 CSS 覆盖。无需修改。 - -- [x] **Step 4: Commit** - -```bash -git add apps/admin/src/pages/ClassroomSchedule/index.tsx -git commit -m "chore: ClassroomSchedule 复查 sticky 列和日期列 minWidth,确认无需修改" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 19: 登录页 Login 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Login/index.tsx` - -**Interfaces:** -- Consumes: 已有 Card 登录表单 -- Produces: 卡片宽度改为 maxWidth + calc - -**Description:** 登录卡片当前 `width: 400`,改为 `maxWidth: 400, width: 'calc(100vw - 48px)'` 以在手机下自适应。 - -- [x] **Step 1: 卡片宽度响应式** - -替换第 39-45 行的 Card style: - -```typescript - -``` - -变化:`width: 400` -> `maxWidth: 400, width: 'calc(100vw - 48px)'` - -- [x] **Step 2: Commit** - -```bash -git add apps/admin/src/pages/Login/index.tsx -git commit -m "feat: 登录卡片改为 maxWidth + calc 响应式宽度" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 20: 全局验证与编译检查 - -**Files:** -- No specific file modifications; validation only - -**Interfaces:** -- Consumes: 所有已完成的页面改动 -- Produces: 验证报告(视觉 + 编译) - -**Description:** 在 Chrome DevTools 响应式模式下手动检查所有 16 个页面在三端视口宽度下的表现,确认无溢出、弹窗不出屏、表格可横向滚动。最后运行 `npm run build` 确保 TypeScript 编译通过。 - -- [x] **Step 1: Chrome DevTools 响应式模式视觉检查** - -打开浏览器访问 `http://localhost:5173`(或部署地址),使用 Chrome DevTools 的设备工具栏切换以下宽度: - -| 宽度 | 端侧 | 检查要点 | -|------|------|---------| -| 375px | 手机 | 表格可横向滚动、按钮不溢出、弹窗不出屏、文字不截断 | -| 768px | 平板 | Sider 默认折叠、Content padding 为 16px、卡片网格正确 | -| 992px | 桌面小屏 | Sider 可展开、Content padding 为 24px | -| 1440px | 桌面大屏 | 全部正常,无奇怪空白 | - -逐页检查(16 个页面): -1. /dashboard -2. /room-visual -3. /students -4. /rooms -5. /occupancies -6. /expenses -7. /deposits -8. /bills -9. /classrooms -10. /classroom-rentals -11. /tenants -12. /classroom-schedule -13. /operation-logs -14. /roles -15. /permissions -16. /users -17. /login - -每个页面检查项: -- [x] 表格有无横向滚动条(在窄屏下出现,且不影响行选择/操作) -- [x] 按钮组是否错位/溢出容器 -- [x] 弹窗(打开任意新增/编辑弹窗)是否超出屏幕边界 -- [x] 统计卡片/网格是否按预期列数排列 -- [x] ECharts 图表是否正确跟随容器宽度 -- [x] 文字是否正常截断(ellipsis 生效) - -- [x] **Step 2: TypeScript 编译检查** - -```bash -cd /Users/tiku1/code/gongxue-base -npm run build -``` - -预期:`tsc -b && vite build` 无错误输出,构建成功。 - -如果有 TypeScript 错误,修复后再执行: - -```bash -cd /Users/tiku1/code/gongxue-base/apps/admin -npx tsc -b --noEmit -``` - -- [x] **Step 3: Commit** - -```bash -git add -A -git commit -m "chore: 全局响应式适配验证完成" -``` - -如果验证中发现问题需要修复,则在对应页面进行修复后单独 commit。 - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Critical Files for Implementation -- `/Users/tiku1/code/gongxue-base/apps/admin/src/index.css` -- `/Users/tiku1/code/gongxue-base/apps/admin/src/layouts/MainLayout.tsx` -- `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Dashboard/index.tsx` -- `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Students/index.tsx` -- `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Occupancies/index.tsx` diff --git a/docs/superpowers/plans/2026-07-05-gongxue-p0-plan.md b/docs/superpowers/plans/2026-07-05-gongxue-p0-plan.md deleted file mode 100644 index 85e125e..0000000 --- a/docs/superpowers/plans/2026-07-05-gongxue-p0-plan.md +++ /dev/null @@ -1,1802 +0,0 @@ -# 恭学教育 P0 批次 — 实现计划 - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 实现班级管理、排课管理、宿舍/入住/账单增强、操作日志全量接入、RBAC 扩展、考勤管理前端、数据面板增强(P0 + 部分 P1)。 - -**Architecture:** Monorepo (Turborepo),后端 NestJS 11 + TypeORM 0.3,前端 React 19 + Ant Design 6。新增 `classes`/`schedules` 两个 NestJS 模块,在现有 `rooms`/`occupancies`/`bills` 模块增量增强,考勤前端新建。 - -**Tech Stack:** NestJS 11, TypeORM 0.3, SQLite/MySQL, React 19, Ant Design 6, ECharts, class-validator, Jest, Playwright - -## Global Constraints - -- 表名使用复数形式(与现有 entities 一致:`students`、`rooms`、`classrooms`、`bills`) -- Entity 使用 `@Entity('table_name')` + `@Column({ name: 'snake_case' })` 模式,无 BaseEntity 继承 -- 所有 entity 在 `apps/server/src/entities/index.ts` 注册导出 -- Module 必须 `imports: [TypeOrmModule.forFeature([...]), OperationLogsModule]` -- Controller 所有方法 `@UseGuards(JwtAuthGuard)` + `@RequirePermission('...')` -- 所有写操作调用 `OperationLogsService.log()`,使用 `extractRequestInfo(req)` 获取 IP/UA -- DTO 使用 class-validator 装饰器,分 CreateDto / UpdateDto -- 前端 axios 实例从 `api/` 导入,`api.get/post/put/delete` 自动解包 `response.data` -- 前端新增路由在 `App.tsx` 注册,包裹 `PermissionRoute` + `permission` 属性 -- 前端敏感操作按钮使用 `PermissionButton` 组件 - ---- - -## Phase 1: 班级管理实体与模块 - -### Task 1.1: 创建 Class 实体 - -**Files:** -- Create: `apps/server/src/entities/class.entity.ts` -- Modify: `apps/server/src/entities/index.ts` - -**Interfaces:** -- Produces: `Class` entity class — exports for TypeORM `@Entity('classes')` - -- [ ] **Step 1: 创建 class.entity.ts** - -```typescript -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - ManyToOne, - OneToMany, - JoinColumn, -} from 'typeorm'; -import { Department } from './department.entity'; -// forward-ref relations will be added after child entities exist - -export enum ClassType { - CULTURE = 'culture', - PROFESSIONAL = 'professional', - BOOTCAMP = 'bootcamp', - SPRINT = 'sprint', -} - -export enum ClassStatus { - ENROLLING = 'enrolling', - ACTIVE = 'active', - ENDED = 'ended', - SUSPENDED = 'suspended', -} - -@Entity('classes') -export class Class { - @PrimaryGeneratedColumn() - id: number; - - @Column({ name: 'name', length: 100 }) - name: string; - - @Column({ name: 'code', length: 50, unique: true }) - code: string; - - @Column({ name: 'department_id', type: 'integer', nullable: true }) - departmentId: number; - - @ManyToOne('Department', { nullable: true }) - @JoinColumn({ name: 'department_id' }) - department: any; - - @Column({ name: 'class_type', length: 20 }) - classType: string; - - @Column({ name: 'start_date', type: 'date', nullable: true }) - startDate: string; - - @Column({ name: 'end_date', type: 'date', nullable: true }) - endDate: string; - - @Column({ name: 'status', length: 20, default: ClassStatus.ENROLLING }) - status: string; - - @Column({ name: 'head_teacher_id', type: 'integer', nullable: true }) - headTeacherId: number; - - @Column({ name: 'life_teacher_id', type: 'integer', nullable: true }) - lifeTeacherId: number; - - @Column({ name: 'academic_teacher_id', type: 'integer', nullable: true }) - academicTeacherId: number; - - @Column({ name: 'max_students', type: 'integer', default: 0 }) - maxStudents: number; - - @Column({ name: 'notes', type: 'text', nullable: true }) - notes: string; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; -} -``` - -- [ ] **Step 2: 在 entities/index.ts 中注册导出** - -在 `apps/server/src/entities/index.ts` 中添加: -```typescript -export { Class, ClassType, ClassStatus } from './class.entity'; -``` - -- [ ] **Step 3: 创建 class-student.entity.ts** - -```typescript -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - ManyToOne, - JoinColumn, - Unique, -} from 'typeorm'; -import { Class } from './class.entity'; -import { Student } from './student.entity'; - -@Entity('class_student') -@Unique(['classId', 'studentId']) -export class ClassStudent { - @PrimaryGeneratedColumn() - id: number; - - @Column({ name: 'class_id', type: 'integer' }) - classId: number; - - @ManyToOne(() => Class, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'class_id' }) - class: Class; - - @Column({ name: 'student_id', type: 'integer' }) - studentId: number; - - @ManyToOne(() => Student) - @JoinColumn({ name: 'student_id' }) - student: Student; - - @Column({ name: 'enrollment_id', type: 'integer', nullable: true }) - enrollmentId: number; - - @Column({ name: 'join_date', type: 'date', nullable: true }) - joinDate: string; - - @Column({ name: 'leave_date', type: 'date', nullable: true }) - leaveDate: string; - - @Column({ name: 'status', length: 10, default: 'active' }) - status: string; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; -} -``` - -- [ ] **Step 4: 在 entities/index.ts 中注册** - -```typescript -export { ClassStudent } from './class-student.entity'; -``` - -- [ ] **Step 5: 创建 class-teacher.entity.ts** - -```typescript -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - ManyToOne, - JoinColumn, - Unique, -} from 'typeorm'; -import { Class } from './class.entity'; -import { User } from './user.entity'; - -export enum TeacherRoleType { - SUBJECT_TEACHER = 'subject_teacher', - HEAD_TEACHER = 'head_teacher', - LIFE_TEACHER = 'life_teacher', - ACADEMIC_TEACHER = 'academic_teacher', -} - -@Entity('class_teacher') -@Unique(['classId', 'userId', 'roleType']) -export class ClassTeacher { - @PrimaryGeneratedColumn() - id: number; - - @Column({ name: 'class_id', type: 'integer' }) - classId: number; - - @ManyToOne(() => Class, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'class_id' }) - class: Class; - - @Column({ name: 'user_id', type: 'integer' }) - userId: number; - - @ManyToOne(() => User) - @JoinColumn({ name: 'user_id' }) - user: User; - - @Column({ name: 'role_type', length: 30 }) - roleType: string; - - @Column({ name: 'subject', length: 50, nullable: true }) - subject: string; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; -} -``` - -- [ ] **Step 6: 在 entities/index.ts 中注册** - -```typescript -export { ClassTeacher, TeacherRoleType } from './class-teacher.entity'; -``` - -- [ ] **Step 7: 验证 — 启动后端检查 TypeORM 自动建表** - -```bash -cd apps/server && npm run start:dev -``` - -Expected: 启动成功,`classes`/`class_student`/`class_teacher` 三张表自动创建。 - -- [ ] **Step 8: Commit** - -```bash -git add apps/server/src/entities/ -git commit -m "feat: add Class, ClassStudent, ClassTeacher entities" -``` - ---- - -### Task 1.2: 创建 Classes NestJS 模块 — DTO + Service - -**Files:** -- Create: `apps/server/src/classes/dto/class.dto.ts` -- Create: `apps/server/src/classes/classes.service.ts` -- Create: `apps/server/src/classes/classes.module.ts` - -**Interfaces:** -- Consumes: `Class`, `ClassStudent`, `ClassTeacher` entities from Task 1.1 -- Produces: `ClassesService` with methods: `findAll`, `findOne`, `create`, `update`, `remove`, `getStudents`, `addStudents`, `removeStudent`, `getTeachers`, `addTeacher`, `removeTeacher` - -- [ ] **Step 1: 创建 DTO** - -```typescript -// apps/server/src/classes/dto/class.dto.ts -import { IsOptional, IsString, IsNotEmpty, IsInt, IsEnum, IsArray, IsDateString } from 'class-validator'; - -export class CreateClassDto { - @IsString() @IsNotEmpty() - name: string; - - @IsString() @IsNotEmpty() - code: string; - - @IsOptional() @IsInt() - departmentId?: number; - - @IsString() @IsNotEmpty() - classType: string; - - @IsOptional() @IsDateString() - startDate?: string; - - @IsOptional() @IsDateString() - endDate?: string; - - @IsOptional() @IsString() - status?: string; - - @IsOptional() @IsInt() - headTeacherId?: number; - - @IsOptional() @IsInt() - lifeTeacherId?: number; - - @IsOptional() @IsInt() - academicTeacherId?: number; - - @IsOptional() @IsInt() - maxStudents?: number; - - @IsOptional() @IsString() - notes?: string; - - @IsOptional() @IsArray() - studentIds?: number[]; - - @IsOptional() @IsArray() - teachers?: Array<{ userId: number; roleType: string; subject?: string }>; -} - -export class UpdateClassDto { - @IsOptional() @IsString() - name?: string; - - @IsOptional() @IsString() - code?: string; - - @IsOptional() @IsInt() - departmentId?: number; - - @IsOptional() @IsString() - classType?: string; - - @IsOptional() @IsDateString() - startDate?: string; - - @IsOptional() @IsDateString() - endDate?: string; - - @IsOptional() @IsString() - status?: string; - - @IsOptional() @IsInt() - headTeacherId?: number; - - @IsOptional() @IsInt() - lifeTeacherId?: number; - - @IsOptional() @IsInt() - academicTeacherId?: number; - - @IsOptional() @IsInt() - maxStudents?: number; - - @IsOptional() @IsString() - notes?: string; -} - -export class QueryClassDto { - @IsOptional() @IsInt() - departmentId?: number; - - @IsOptional() @IsString() - status?: string; - - @IsOptional() @IsString() - classType?: string; - - @IsOptional() @IsString() - keyword?: string; -} - -export class AddStudentsDto { - @IsArray() @IsInt({ each: true }) - studentIds: number[]; -} - -export class AddTeacherDto { - @IsInt() - userId: number; - - @IsString() - roleType: string; - - @IsOptional() @IsString() - subject?: string; -} -``` - -- [ ] **Step 2: 创建 Service** - -```typescript -// apps/server/src/classes/classes.service.ts -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, In, Like } from 'typeorm'; -import { Class, ClassStudent, ClassTeacher } from '../entities'; - -@Injectable() -export class ClassesService { - constructor( - @InjectRepository(Class) - private classRepo: Repository, - @InjectRepository(ClassStudent) - private classStudentRepo: Repository, - @InjectRepository(ClassTeacher) - private classTeacherRepo: Repository, - ) {} - - async findAll(query: { departmentId?: number; status?: string; classType?: string; keyword?: string }) { - const where: any = {}; - if (query.departmentId) where.departmentId = query.departmentId; - if (query.status) where.status = query.status; - if (query.classType) where.classType = query.classType; - if (query.keyword) where.name = Like(`%${query.keyword}%`); - - const classes = await this.classRepo.find({ - where, - order: { createdAt: 'DESC' }, - }); - - // count students per class - const studentCounts = await this.classStudentRepo - .createQueryBuilder('cs') - .select('cs.class_id', 'classId') - .addSelect('COUNT(cs.id)', 'count') - .where('cs.status = :status', { status: 'active' }) - .groupBy('cs.class_id') - .getRawMany(); - - const countMap = new Map(studentCounts.map((r: any) => [Number(r.classId), Number(r.count)])); - - return classes.map((c) => ({ - ...c, - studentCount: countMap.get(c.id) || 0, - })); - } - - async findOne(id: number) { - const cls = await this.classRepo.findOne({ where: { id } }); - if (!cls) throw new NotFoundException('班级不存在'); - - const students = await this.classStudentRepo.find({ - where: { classId: id }, - relations: ['student'], - }); - const teachers = await this.classTeacherRepo.find({ - where: { classId: id }, - relations: ['user'], - }); - - return { - ...cls, - students: students.map((s) => ({ - id: s.id, - studentId: s.studentId, - studentName: (s.student as any)?.name, - studentNo: (s.student as any)?.studentNo, - joinDate: s.joinDate, - leaveDate: s.leaveDate, - status: s.status, - })), - teachers: teachers.map((t) => ({ - id: t.id, - userId: t.userId, - username: (t.user as any)?.username, - roleType: t.roleType, - subject: t.subject, - })), - studentCount: students.filter((s) => s.status === 'active').length, - }; - } - - async create(dto: any) { - const { studentIds, teachers, ...classData } = dto; - - const cls = this.classRepo.create(classData); - const saved = await this.classRepo.save(cls); - - // add students - if (studentIds?.length) { - const entries = studentIds.map((sid: number) => - this.classStudentRepo.create({ classId: saved.id, studentId: sid, joinDate: new Date().toISOString().split('T')[0] }), - ); - await this.classStudentRepo.save(entries); - } - - // add teachers - if (teachers?.length) { - const entries = teachers.map((t: any) => - this.classTeacherRepo.create({ classId: saved.id, userId: t.userId, roleType: t.roleType, subject: t.subject }), - ); - await this.classTeacherRepo.save(entries); - - // sync head/life/academic teacher IDs - await this.syncClassTeacherIds(saved.id); - } - - return this.findOne(saved.id); - } - - async update(id: number, dto: any) { - const cls = await this.classRepo.findOne({ where: { id } }); - if (!cls) throw new NotFoundException('班级不存在'); - await this.classRepo.update(id, dto); - return this.findOne(id); - } - - async remove(id: number) { - const cls = await this.classRepo.findOne({ where: { id } }); - if (!cls) throw new NotFoundException('班级不存在'); - await this.classRepo.remove(cls); - return { success: true }; - } - - async getStudents(classId: number) { - return this.classStudentRepo.find({ - where: { classId }, - relations: ['student'], - order: { createdAt: 'ASC' }, - }); - } - - async addStudents(classId: number, studentIds: number[]) { - const existing = await this.classStudentRepo.find({ - where: { classId, studentId: In(studentIds) }, - }); - const existingIds = new Set(existing.map((e) => e.studentId)); - const newIds = studentIds.filter((id) => !existingIds.has(id)); - - const entries = newIds.map((sid) => - this.classStudentRepo.create({ classId, studentId: sid, joinDate: new Date().toISOString().split('T')[0] }), - ); - if (entries.length) await this.classStudentRepo.save(entries); - - return { added: entries.length, skipped: studentIds.length - entries.length }; - } - - async removeStudent(classId: number, studentId: number) { - await this.classStudentRepo.delete({ classId, studentId }); - return { success: true }; - } - - async getTeachers(classId: number) { - return this.classTeacherRepo.find({ - where: { classId }, - relations: ['user'], - }); - } - - async addTeacher(classId: number, dto: { userId: number; roleType: string; subject?: string }) { - const existing = await this.classTeacherRepo.findOne({ - where: { classId, userId: dto.userId, roleType: dto.roleType }, - }); - if (existing) throw new BadRequestException('该教师已分配此角色'); - - const entry = this.classTeacherRepo.create({ classId, userId: dto.userId, roleType: dto.roleType, subject: dto.subject }); - await this.classTeacherRepo.save(entry); - - await this.syncClassTeacherIds(classId); - return entry; - } - - async removeTeacher(classId: number, userId: number) { - await this.classTeacherRepo.delete({ classId, userId }); - await this.syncClassTeacherIds(classId); - return { success: true }; - } - - private async syncClassTeacherIds(classId: number) { - const teachers = await this.classTeacherRepo.find({ where: { classId } }); - const updates: any = {}; - const head = teachers.find((t) => t.roleType === 'head_teacher'); - const life = teachers.find((t) => t.roleType === 'life_teacher'); - const academic = teachers.find((t) => t.roleType === 'academic_teacher'); - if (head) updates.headTeacherId = head.userId; - if (life) updates.lifeTeacherId = life.userId; - if (academic) updates.academicTeacherId = academic.userId; - if (Object.keys(updates).length > 0) { - await this.classRepo.update(classId, updates); - } - } -} -``` - -- [ ] **Step 3: 创建 Module** - -```typescript -// apps/server/src/classes/classes.module.ts -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Class, ClassStudent, ClassTeacher } from '../entities'; -import { ClassesService } from './classes.service'; -import { ClassesController } from './classes.controller'; -import { OperationLogsModule } from '../operation-logs/operation-logs.module'; - -@Module({ - imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher]), OperationLogsModule], - controllers: [ClassesController], - providers: [ClassesService], - exports: [ClassesService], -}) -export class ClassesModule {} -``` - -- [ ] **Step 4: 验证 — 编译通过** - -```bash -cd apps/server && npx tsc --noEmit -``` - -Expected: 无类型错误。 - -- [ ] **Step 5: Commit** - -```bash -git add apps/server/src/classes/ -git commit -m "feat: add ClassesService with CRUD + student/teacher management" -``` - ---- - -### Task 1.3: 创建 ClassesController - -**Files:** -- Create: `apps/server/src/classes/classes.controller.ts` -- Modify: `apps/server/src/app.module.ts` - -**Interfaces:** -- Consumes: `ClassesService` from Task 1.2, `OperationLogsService` (global), `extractRequestInfo` from common -- Produces: REST endpoints matching spec API design - -- [ ] **Step 1: 创建 Controller** - -```typescript -// apps/server/src/classes/classes.controller.ts -import { - Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Request, -} from '@nestjs/common'; -import { ClassesService } from './classes.service'; -import { CreateClassDto, UpdateClassDto, QueryClassDto, AddStudentsDto, AddTeacherDto } from './dto/class.dto'; -import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; -import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { extractRequestInfo } from '../common/request-utils'; -import { RequirePermission } from '../auth/decorators/permission.decorator'; - -@UseGuards(JwtAuthGuard) -@Controller('classes') -export class ClassesController { - constructor( - private service: ClassesService, - private logService: OperationLogsService, - ) {} - - @Get() - @RequirePermission('class:view') - findAll(@Query() query: QueryClassDto) { - return this.service.findAll(query); - } - - @Get(':id') - @RequirePermission('class:view') - findOne(@Param('id') id: string) { - return this.service.findOne(+id); - } - - @Post() - @RequirePermission('class:create') - async create(@Body() dto: CreateClassDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.create(dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '创建班级', - targetId: result.id, - targetType: 'class', - detail: `班级${dto.name}(${dto.code})`, - ipAddress, - userAgent, - }); - return result; - } - - @Put(':id') - @RequirePermission('class:edit') - async update(@Param('id') id: string, @Body() dto: UpdateClassDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.update(+id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '编辑班级', - targetId: +id, - targetType: 'class', - detail: JSON.stringify(dto), - ipAddress, - userAgent, - }); - return result; - } - - @Delete(':id') - @RequirePermission('class:delete') - async remove(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.service.remove(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '删除班级', - targetId: +id, - targetType: 'class', - ipAddress, - userAgent, - }); - return { success: true }; - } - - @Get(':id/students') - @RequirePermission('class:view') - getStudents(@Param('id') id: string) { - return this.service.getStudents(+id); - } - - @Post(':id/students') - @RequirePermission('class:edit') - async addStudents(@Param('id') id: string, @Body() dto: AddStudentsDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.addStudents(+id, dto.studentIds); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '添加学员', - targetId: +id, - targetType: 'class', - detail: `添加${result.added}名学员`, - ipAddress, - userAgent, - }); - return result; - } - - @Delete(':id/students/:studentId') - @RequirePermission('class:edit') - async removeStudent(@Param('id') id: string, @Param('studentId') studentId: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.service.removeStudent(+id, +studentId); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '移除学员', - targetId: +id, - targetType: 'class', - detail: `移除学员${studentId}`, - ipAddress, - userAgent, - }); - return { success: true }; - } - - @Get(':id/teachers') - @RequirePermission('class:view') - getTeachers(@Param('id') id: string) { - return this.service.getTeachers(+id); - } - - @Post(':id/teachers') - @RequirePermission('class:edit') - async addTeacher(@Param('id') id: string, @Body() dto: AddTeacherDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.addTeacher(+id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '添加教师', - targetId: +id, - targetType: 'class', - detail: `添加教师${dto.userId} 角色${dto.roleType}`, - ipAddress, - userAgent, - }); - return result; - } - - @Delete(':id/teachers/:userId') - @RequirePermission('class:edit') - async removeTeacher(@Param('id') id: string, @Param('userId') userId: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.service.removeTeacher(+id, +userId); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '移除教师', - targetId: +id, - targetType: 'class', - detail: `移除教师${userId}`, - ipAddress, - userAgent, - }); - return { success: true }; - } -} -``` - -- [ ] **Step 2: 在 app.module.ts 注册模块** - -在 `apps/server/src/app.module.ts` 的 import 区域添加: -```typescript -import { ClassesModule } from './classes/classes.module'; -``` - -在 `imports` 数组中追加: -```typescript -ClassesModule, -``` - -- [ ] **Step 3: 验证 — 启动后端测试 API** - -```bash -cd apps/server && npm run start:dev -``` - -用 curl 测试(需先获取 JWT token): -```bash -# 创建班级 -curl -X POST http://localhost:3000/api/classes \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"name":"2026届文化课冲刺1班","code":"2026-WHK-01","classType":"sprint","maxStudents":40}' -``` - -Expected: 返回创建的班级 JSON,含 `id`、`studentCount: 0`。 - -- [ ] **Step 4: Commit** - -```bash -git add apps/server/src/classes/classes.controller.ts apps/server/src/app.module.ts -git commit -m "feat: add ClassesController with full CRUD + student/teacher management endpoints" -``` - ---- - -### Task 1.4: 前端班级列表页 - -**Files:** -- Create: `apps/admin/src/pages/Classes/index.tsx` -- Modify: `apps/admin/src/App.tsx` -- Modify: `apps/admin/src/layouts/MainLayout.tsx` - -- [ ] **Step 1: 创建列表页** - -```tsx -// apps/admin/src/pages/Classes/index.tsx -import React, { useEffect, useState, useMemo } from 'react'; -import { - Table, Button, Input, Select, Space, Tag, Modal, Form, InputNumber, - DatePicker, Popconfirm, message, Card, -} from 'antd'; -import { PlusOutlined, SearchOutlined, TeamOutlined } from '@ant-design/icons'; -import { useNavigate } from 'react-router-dom'; -import dayjs from 'dayjs'; -import api from '../../api'; -import PermissionButton from '../../components/PermissionButton'; - -const STATUS_MAP: Record = { - enrolling: { color: 'blue', text: '招生中' }, - active: { color: 'green', text: '在读' }, - ended: { color: 'default', text: '结课' }, - suspended: { color: 'orange', text: '停课' }, -}; - -const TYPE_MAP: Record = { - culture: '文化课', - professional: '专业课', - bootcamp: '集训营', - sprint: '冲刺营', -}; - -const ClassesPage: React.FC = () => { - const navigate = useNavigate(); - const [data, setData] = useState([]); - const [loading, setLoading] = useState(false); - const [modalOpen, setModalOpen] = useState(false); - const [editing, setEditing] = useState(null); - const [searchText, setSearchText] = useState(''); - const [filterStatus, setFilterStatus] = useState(); - const [filterType, setFilterType] = useState(); - const [form] = Form.useForm(); - - const fetchData = async () => { - setLoading(true); - try { - const params: any = {}; - if (filterStatus) params.status = filterStatus; - if (filterType) params.classType = filterType; - const res: any = await api.get('/classes', { params }); - setData(res); - } catch (e) { - console.error(e); - } - setLoading(false); - }; - - useEffect(() => { fetchData(); }, [filterStatus, filterType]); - - const filtered = useMemo(() => { - if (!searchText) return data; - const q = searchText.toLowerCase(); - return data.filter((c: any) => - c.name?.toLowerCase().includes(q) || c.code?.toLowerCase().includes(q), - ); - }, [data, searchText]); - - const handleCreate = () => { - setEditing(null); - form.resetFields(); - setModalOpen(true); - }; - - const handleEdit = (record: any) => { - setEditing(record); - form.setFieldsValue({ - ...record, - startDate: record.startDate ? dayjs(record.startDate) : undefined, - endDate: record.endDate ? dayjs(record.endDate) : undefined, - }); - setModalOpen(true); - }; - - const handleSubmit = async () => { - const values = await form.validateFields(); - const payload = { - ...values, - startDate: values.startDate?.format('YYYY-MM-DD'), - endDate: values.endDate?.format('YYYY-MM-DD'), - }; - if (editing) { - await api.put(`/classes/${editing.id}`, payload); - message.success('更新成功'); - } else { - await api.post('/classes', payload); - message.success('创建成功'); - } - setModalOpen(false); - fetchData(); - }; - - const handleDelete = async (id: number) => { - await api.delete(`/classes/${id}`); - message.success('已删除'); - fetchData(); - }; - - const columns = [ - { title: '班级名称', dataIndex: 'name', sorter: (a: any, b: any) => a.name.localeCompare(b.name) }, - { title: '编码', dataIndex: 'code', width: 140 }, - { title: '班型', dataIndex: 'classType', width: 100, render: (v: string) => {TYPE_MAP[v] || v} }, - { - title: '开班日期', dataIndex: 'startDate', width: 110, - render: (v: string) => v || '-', - }, - { - title: '学员', width: 100, - render: (_: any, r: any) => `${r.studentCount || 0}/${r.maxStudents || '-'}`, - }, - { - title: '状态', dataIndex: 'status', width: 90, - render: (v: string) => { - const cfg = STATUS_MAP[v] || { color: 'default', text: v }; - return {cfg.text}; - }, - }, - { - title: '操作', width: 200, - render: (_: any, r: any) => ( - - - handleEdit(r)}>编辑 - handleDelete(r.id)}> - 删除 - - - ), - }, - ]; - - return ( - - - } - value={searchText} - onChange={(e) => setSearchText(e.target.value)} - style={{ width: 200 }} - /> - ({ value: k, label: v.text }))} - /> - } onClick={handleCreate}> - 创建班级 - - -
- - setModalOpen(false)} - width={600} - > - - - - - - - - - ({ value: k, label: v.text }))} /> - - - - - - - - ); -}; - -export default ClassesPage; -``` - -- [ ] **Step 2: 在 App.tsx 添加路由** - -在 `apps/admin/src/App.tsx` 的 import 区添加: -```typescript -import ClassesPage from './pages/Classes'; -``` - -在 `` 内添加: -```tsx -} /> -} /> -``` - -**注意**:`ClassDetailPage` 暂时以占位组件引入,下一个任务实现。 - -- [ ] **Step 3: 在 MainLayout.tsx 添加菜单项** - -在菜单数组中加入: -```typescript -{ key: '/classes', icon: , label: '班级管理', permission: 'class:view' }, -``` - -**注意**:确保 `TeamOutlined` 已从 `@ant-design/icons` 导入。 - -- [ ] **Step 4: 验证 — 启动前端查看页面** - -```bash -cd apps/admin && npm run dev -``` - -打开浏览器访问 `/classes`,验证:表格渲染、筛选功能、创建/编辑弹窗。 - -- [ ] **Step 5: Commit** - -```bash -git add apps/admin/src/pages/Classes/index.tsx apps/admin/src/App.tsx apps/admin/src/layouts/MainLayout.tsx -git commit -m "feat: add Classes list page with CRUD modal" -``` - ---- - -### Task 1.5: 前端班级详情页 - -**Files:** -- Create: `apps/admin/src/pages/Classes/Detail.tsx` -- Modify: `apps/admin/src/App.tsx` (update import) - -- [ ] **Step 1: 创建详情页** - -```tsx -// apps/admin/src/pages/Classes/Detail.tsx -import React, { useEffect, useState } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import { - Card, Tabs, Descriptions, Table, Button, Space, Select, Modal, Tag, - Popconfirm, message, Form, Input, DatePicker, InputNumber, -} from 'antd'; -import { ArrowLeftOutlined, PlusOutlined } from '@ant-design/icons'; -import dayjs from 'dayjs'; -import api from '../../api'; -import PermissionButton from '../../components/PermissionButton'; - -const STATUS_MAP: Record = { - enrolling: { color: 'blue', text: '招生中' }, - active: { color: 'green', text: '在读' }, - ended: { color: 'default', text: '结课' }, - suspended: { color: 'orange', text: '停课' }, -}; - -const TYPE_MAP: Record = { - culture: '文化课', professional: '专业课', bootcamp: '集训营', sprint: '冲刺营', -}; - -const ROLE_MAP: Record = { - subject_teacher: '任课老师', head_teacher: '班主任', life_teacher: '生活老师', academic_teacher: '学服老师', -}; - -const ClassDetailPage: React.FC = () => { - const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); - const [detail, setDetail] = useState(null); - const [students, setStudents] = useState([]); - const [teachers, setTeachers] = useState([]); - const [loading, setLoading] = useState(false); - const [editForm] = Form.useForm(); - const [editingInfo, setEditingInfo] = useState(false); - - // Student modal state - const [studentModalOpen, setStudentModalOpen] = useState(false); - const [allStudents, setAllStudents] = useState([]); - const [selectedStudentIds, setSelectedStudentIds] = useState([]); - - // Teacher modal state - const [teacherModalOpen, setTeacherModalOpen] = useState(false); - const [allUsers, setAllUsers] = useState([]); - const [teacherRole, setTeacherRole] = useState('subject_teacher'); - const [teacherSubject, setTeacherSubject] = useState(''); - const [selectedTeacherId, setSelectedTeacherId] = useState(); - - const fetchDetail = async () => { - setLoading(true); - try { - const res: any = await api.get(`/classes/${id}`); - setDetail(res); - setStudents(res.students || []); - setTeachers(res.teachers || []); - } catch (e) { console.error(e); } - setLoading(false); - }; - - useEffect(() => { fetchDetail(); }, [id]); - - const handleSaveInfo = async () => { - const values = await editForm.validateFields(); - await api.put(`/classes/${id}`, { - ...values, - startDate: values.startDate?.format('YYYY-MM-DD'), - endDate: values.endDate?.format('YYYY-MM-DD'), - }); - setEditingInfo(false); - fetchDetail(); - message.success('已更新'); - }; - - const handleRemoveStudent = async (studentId: number) => { - await api.delete(`/classes/${id}/students/${studentId}`); - fetchDetail(); - message.success('已移除'); - }; - - const handleAddStudents = async () => { - if (!selectedStudentIds.length) return; - await api.post(`/classes/${id}/students`, { studentIds: selectedStudentIds }); - setStudentModalOpen(false); - setSelectedStudentIds([]); - fetchDetail(); - message.success('已添加'); - }; - - const handleAddTeacher = async () => { - if (!selectedTeacherId) return; - await api.post(`/classes/${id}/teachers`, { - userId: selectedTeacherId, - roleType: teacherRole, - subject: teacherSubject || undefined, - }); - setTeacherModalOpen(false); - fetchDetail(); - message.success('已添加'); - }; - - const handleRemoveTeacher = async (userId: number) => { - await api.delete(`/classes/${id}/teachers/${userId}`); - fetchDetail(); - message.success('已移除'); - }; - - const openStudentModal = async () => { - const res: any = await api.get('/students', { params: { includeArchived: 'false' } }); - setAllStudents(res || []); - setSelectedStudentIds([]); - setStudentModalOpen(true); - }; - - const openTeacherModal = async () => { - const res: any = await api.get('/users'); - setAllUsers(res || []); - setSelectedTeacherId(undefined); - setTeacherRole('subject_teacher'); - setTeacherSubject(''); - setTeacherModalOpen(true); - }; - - if (!detail) return null; - - const studentColumns = [ - { title: '姓名', dataIndex: 'studentName' }, - { title: '学号', dataIndex: 'studentNo' }, - { title: '加入日期', dataIndex: 'joinDate' }, - { - title: '状态', dataIndex: 'status', - render: (v: string) => {v === 'active' ? '在读' : '已离班'}, - }, - { - title: '操作', - render: (_: any, r: any) => ( - handleRemoveStudent(r.studentId)}> - - - ), - }, - ]; - - const teacherColumns = [ - { title: '姓名', dataIndex: 'username' }, - { - title: '角色', dataIndex: 'roleType', - render: (v: string) => {ROLE_MAP[v] || v}, - }, - { title: '科目', dataIndex: 'subject', render: (v: string) => v || '-' }, - { - title: '操作', - render: (_: any, r: any) => ( - handleRemoveTeacher(r.userId)}> - - - ), - }, - ]; - - return ( - - - - - - ) : ( -
- - {TYPE_MAP[detail.classType]} - {detail.startDate || '-'} - {detail.endDate || '-'} - {detail.studentCount}/{detail.maxStudents || '-'} - {teachers.find((t:any) => t.roleType === 'head_teacher')?.username || '-'} - {detail.notes || '-'} - - { editForm.setFieldsValue(detail); setEditingInfo(true); }}>编辑 -
- )} - - ), - }, - { - key: 'students', label: `花名册 (${students.filter((s:any) => s.status === 'active').length})`, - children: ( -
- -
- setStudentModalOpen(false)}> -
- setTeacherModalOpen(false)}> - - ({ value: k, label: v }))} - /> - {teacherRole === 'subject_teacher' && ( - setTeacherSubject(e.target.value)} /> - )} - - - - ), - }, - ]} /> - - ); -}; - -export default ClassDetailPage; -``` - -- [ ] **Step 2: 更新 App.tsx import** - -确保 `App.tsx` 中 `ClassDetailPage` 的 import 已添加(Task 1.4 中已预留路由)。 - -- [ ] **Step 3: 验证 — 浏览器测试** - -打开班级列表 → 点击"详情" → 验证基本信息/花名册/教师三个 Tab 渲染和数据加载。 - -- [ ] **Step 4: Commit** - -```bash -git add apps/admin/src/pages/Classes/Detail.tsx apps/admin/src/App.tsx -git commit -m "feat: add Class detail page with student roster and teacher tabs" -``` - ---- - -## Phase 2: 排课管理 - -### Task 2.1: 创建 ClassSchedule 实体 - -**Files:** -- Create: `apps/server/src/entities/class-schedule.entity.ts` -- Modify: `apps/server/src/entities/index.ts` - -- [ ] **Step 1: 创建实体** - -```typescript -import { - Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, - UpdateDateColumn, ManyToOne, JoinColumn, Check, -} from 'typeorm'; - -export enum ScheduleType { - INTERNAL = 'INTERNAL', - RENTAL = 'RENTAL', -} - -@Entity('class_schedule') -@Check('week_day BETWEEN 1 AND 7') -export class ClassSchedule { - @PrimaryGeneratedColumn() - id: number; - - @Column({ name: 'class_id', type: 'integer' }) - classId: number; - - @ManyToOne('Class') - @JoinColumn({ name: 'class_id' }) - class: any; - - @Column({ name: 'classroom_id', type: 'integer' }) - classroomId: number; - - @ManyToOne('Classroom') - @JoinColumn({ name: 'classroom_id' }) - classroom: any; - - @Column({ name: 'week_day', type: 'integer' }) - weekDay: number; - - @Column({ name: 'start_time', length: 5 }) - startTime: string; - - @Column({ name: 'end_time', length: 5 }) - endTime: string; - - @Column({ name: 'start_date', type: 'date' }) - startDate: string; - - @Column({ name: 'end_date', type: 'date' }) - endDate: string; - - @Column({ name: 'subject', length: 50 }) - subject: string; - - @Column({ name: 'teacher_id', type: 'integer', nullable: true }) - teacherId: number; - - @ManyToOne('User', { nullable: true }) - @JoinColumn({ name: 'teacher_id' }) - teacher: any; - - @Column({ name: 'schedule_type', length: 20, default: 'INTERNAL' }) - scheduleType: string; - - @Column({ name: 'rental_id', type: 'integer', nullable: true }) - rentalId: number; - - @Column({ name: 'status', length: 20, default: 'active' }) - status: string; - - @Column({ name: 'notes', type: 'text', nullable: true }) - notes: string; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; -} -``` - -- [ ] **Step 2: 在 index.ts 导出** - -```typescript -export { ClassSchedule, ScheduleType } from './class-schedule.entity'; -``` - -- [ ] **Step 3: Commit** - -```bash -git add apps/server/src/entities/ -git commit -m "feat: add ClassSchedule entity" -``` - ---- - -### Task 2.2: 创建 Schedules 模块 — Service + Controller - -**Files:** -- Create: `apps/server/src/schedules/dto/schedule.dto.ts` -- Create: `apps/server/src/schedules/schedules.service.ts` -- Create: `apps/server/src/schedules/schedules.controller.ts` -- Create: `apps/server/src/schedules/schedules.module.ts` -- Modify: `apps/server/src/app.module.ts` - -Proceed to implement based on the patterns established in Phase 1 — the service handles conflict detection by querying for overlapping active schedules (same classroom, same week_day, overlapping time range), and the controller follows the same JwtAuthGuard + OperationLogsService pattern. - -Key conflict detection query: -```typescript -async checkConflict(classroomId: number, weekDay: number, startTime: string, endTime: string, excludeId?: number) { - const qb = this.scheduleRepo.createQueryBuilder('cs') - .where('cs.classroom_id = :classroomId', { classroomId }) - .andWhere('cs.week_day = :weekDay', { weekDay }) - .andWhere('cs.status = :status', { status: 'active' }) - .andWhere('cs.start_time < :endTime', { endTime }) - .andWhere('cs.end_time > :startTime', { startTime }); - if (excludeId) qb.andWhere('cs.id != :excludeId', { excludeId }); - return qb.getMany(); -} -``` - -The weekly view endpoint aggregates schedules by classroom and weekDay for the frontend matrix. - -- [ ] **Step 1-6: 实现 DTO, Service, Controller, Module,注册到 AppModule** - -(具体代码略,遵循 Tasks 1.2-1.3 的相同模式) - -- [ ] **Step 7: Commit** - -```bash -git add apps/server/src/schedules/ apps/server/src/app.module.ts -git commit -m "feat: add Schedules module with conflict detection and weekly view" -``` - ---- - -### Task 2.3: 前端排课周视图 - -**Files:** -- Create: `apps/admin/src/pages/Schedules/index.tsx` -- Modify: `apps/admin/src/App.tsx` -- Modify: `apps/admin/src/layouts/MainLayout.tsx` - -周视图矩阵:列 = 周一~周日,行 = 教室。每格显示科目/教师/时间。点击格子弹出排课 Modal(班级/科目/教师/教室/星期/时段/日期范围)。 - -- [ ] **Step 1-3: 实现页面、路由、菜单** - -(具体代码略,遵循 Phase 1 前端页面模式,矩阵渲染使用嵌套 Table 或 Grid) - -- [ ] **Step 4: Commit** - ---- - -## Phase 3: 宿舍/入住/账单增强(可与 Phase 1/2 并行) - -### Task 3.1: Room + Occupancy 实体增强 - -**Files:** -- Modify: `apps/server/src/entities/room.entity.ts` -- Modify: `apps/server/src/entities/occupancy.entity.ts` -- Modify: `apps/server/src/entities/student.entity.ts` -- Modify: `apps/server/src/rooms/dto/room.dto.ts` (需要查找实际文件名) -- Modify: `apps/server/src/occupancies/dto/` (需要查找实际文件名) - -- [ ] **Step 1: Room 实体增加字段** - -在 `room.entity.ts` 中添加: -```typescript -@Column({ name: 'rental_category', length: 10, default: 'short' }) -rentalCategory: string; - -@Column({ name: 'monthly_rate', type: 'decimal', precision: 10, scale: 2, default: 0 }) -monthlyRate: number; -``` - -- [ ] **Step 2: Occupancy 实体增加字段** - -在 `occupancy.entity.ts` 中添加: -```typescript -@Column({ name: 'rental_type', length: 10, default: 'short' }) -rentalType: string; - -@Column({ name: 'tenant_id', type: 'integer', nullable: true }) -tenantId: number; - -@ManyToOne(() => Tenant, { nullable: true }) -@JoinColumn({ name: 'tenant_id' }) -tenant: Tenant; -``` - -- [ ] **Step 3: Student 实体增加 tenant_id** - -在 `student.entity.ts` 中添加: -```typescript -@Column({ name: 'tenant_id', type: 'integer', nullable: true }) -tenantId: number; - -@ManyToOne(() => Tenant, { nullable: true }) -@JoinColumn({ name: 'tenant_id' }) -tenant: Tenant; -``` - -- [ ] **Step 4: 更新 Room DTO** - -在 `create-room.dto.ts` 和 `update-room.dto.ts` 中添加字段: -```typescript -@IsOptional() @IsString() -rentalCategory?: string; - -@IsOptional() @IsNumber() -monthlyRate?: number; -``` - -- [ ] **Step 5: 更新 Occupancy DTO** - -在入住 DTO 中添加: -```typescript -@IsOptional() @IsString() -rentalType?: string; - -@IsOptional() @IsInt() -tenantId?: number; -``` - -- [ ] **Step 6: 更新前端 Rooms 表单** - -在 `Rooms/index.tsx` 的 Modal Form 中添加 `rentalCategory` Select 和 `monthlyRate` InputNumber。 - -- [ ] **Step 7: 更新前端 Occupancies 表单** - -在 `Occupancies/index.tsx` 的 Modal Form 中添加 `rentalType` Select 和 `tenantId` Select。 - -- [ ] **Step 8: Commit** - -```bash -git add apps/server/src/entities/ apps/server/src/rooms/ apps/server/src/occupancies/ apps/admin/src/pages/Rooms/ apps/admin/src/pages/Occupancies/ -git commit -m "feat: add rental_category/rate to Room, rental_type/tenant_id to Occupancy, tenant_id to Student" -``` - ---- - -### Task 3.2: 账单服务长租逻辑 - -**Files:** -- Modify: `apps/server/src/bills/bills.service.ts` - -- [ ] **Step 1: 修改 generate 方法** - -在账单生成逻辑中,遍历 occupancy 时检查 `rentalType`: -```typescript -if (occupancy.rentalType === 'long') { - // 长租:取 room.monthlyRate 作为独立费用,不参与分摊 - const longRentBill = this.billRepo.create({ - studentId: occupancy.studentId, - // ... 其他字段 - totalAmount: room.monthlyRate, - }); - await this.billRepo.save(longRentBill); -} else { - // 短租:走原人天数分摊逻辑 - shortTermOccupancies.push(occupancy); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add apps/server/src/bills/bills.service.ts -git commit -m "feat: add long-term rental independent billing in generate logic" -``` - ---- - -## Phase 4: 操作日志全量接入 - -### Task 4.1: 接入 Students 模块日志 - -**Files:** -- Modify: `apps/server/src/students/students.controller.ts` - -在 create/update/delete/import/export 方法中注入 `OperationLogsService`,每次操作后调用 `log()`。 - -- [ ] **Step 1-3: 添加日志调用 → 验证 → Commit** - ---- - -### Task 4.2: 接入 Occupancies/Expenses/Bills/Deposits 日志 - -**Files:** -- Modify: `apps/server/src/occupancies/occupancies.controller.ts` -- Modify: `apps/server/src/expenses/expenses.controller.ts` -- Modify: `apps/server/src/bills/bills.controller.ts` -- Modify: `apps/server/src/deposits/deposits.controller.ts` - -对未覆盖的写操作注入日志。已有 ClassroomRentals 模块作为完整参考。 - -- [ ] **Step 1-3: 批量添加 → 验证 → Commit** - ---- - -### Task 4.3: 接入 Attendance 日志(Phase 6 依赖) - -**Files:** -- Modify: `apps/server/src/students/students.controller.ts`(考勤接口在学生档案模块中) - -后续 Phase 6 创建独立 attendance 模块时同时在 controller 中接入日志。 - ---- - -## Phase 5: RBAC 权限扩展 - -### Task 5.1: 添加新权限节点 - -**Files:** -- Modify: `apps/server/src/rbac/rbac.service.ts` - -- [ ] **Step 1: 在 PRESET_PERMISSIONS 数组中追加** - -```typescript -{ code: 'class:view', name: '查看班级', group: 'class' }, -{ code: 'class:create', name: '创建班级', group: 'class' }, -{ code: 'class:edit', name: '编辑班级', group: 'class' }, -{ code: 'class:delete', name: '删除班级', group: 'class' }, -{ code: 'schedule:view', name: '查看排课', group: 'schedule' }, -{ code: 'schedule:create', name: '创建排课', group: 'schedule' }, -{ code: 'schedule:edit', name: '编辑排课', group: 'schedule' }, -{ code: 'schedule:delete', name: '删除排课', group: 'schedule' }, -{ code: 'attendance:view', name: '查看考勤', group: 'attendance' }, -{ code: 'attendance:create', name: '新增考勤', group: 'attendance' }, -{ code: 'attendance:edit', name: '编辑考勤', group: 'attendance' }, -``` - -- [ ] **Step 2: 在 PRESET_ROLES 中更新宿管老师角色** - -在 `dormitory_supervisor` 的 `permissionGroups` 中添加:`'class'`, `'schedule'`, `'attendance'` - -- [ ] **Step 3: 验证 — 数据库重新 seed** - -删除 SQLite 数据库后重启后端,确认权限表包含新节点。 - -```bash -rm apps/server/dorm_billing.db && cd apps/server && npm run start:dev -``` - -- [ ] **Step 4: Commit** - -```bash -git add apps/server/src/rbac/rbac.service.ts -git commit -m "feat: add CLASS/SCHEDULE/ATTENDANCE permission nodes" -``` - ---- - -## Phase 6: 考勤管理前端 - -### Task 6.1: 考勤后端 API 增强 - -**Files:** -- Modify: `apps/server/src/students/students.controller.ts`(或新建 attendance 模块) - -新增 batch/create、summary、calendar、ding-attendance-raw 端点。 - -- [ ] **Step 1-3: 实现 API → Commit** - ---- - -### Task 6.2: 前端考勤页面 - -**Files:** -- Create: `apps/admin/src/pages/Attendance/index.tsx` -- Modify: `apps/admin/src/App.tsx` -- Modify: `apps/admin/src/layouts/MainLayout.tsx` - -列表页:筛选(班级/日期/时段/状态/来源)+ 表格 + 批量补录 + 日历视图切换。 - -- [ ] **Step 1-3: 实现 → Commit** - ---- - -## Phase 7: 数据面板增强 - -### Task 7.1: Dashboard API 增强 - -**Files:** -- Modify: `apps/server/src/dashboard/dashboard.service.ts` -- Modify: `apps/server/src/dashboard/dashboard.controller.ts` - -增加 `classroomCount`, `classroomOccupancyRate`, `todayAttendanceRate`, `monthlyIncome`, `attendanceTrend`, `incomeTrend` 字段。 - -- [ ] **Step 1-3: 实现 → Commit** - ---- - -### Task 7.2: 前端 Dashboard 增强 - -**Files:** -- Modify: `apps/admin/src/pages/Dashboard/index.tsx` - -追加第二行指标卡 + 考勤趋势/收入趋势图表。 - -- [ ] **Step 1-3: 实现 → Commit** diff --git a/docs/superpowers/plans/2026-07-05-multi-campus.md b/docs/superpowers/plans/2026-07-05-multi-campus.md deleted file mode 100644 index b6274e2..0000000 --- a/docs/superpowers/plans/2026-07-05-multi-campus.md +++ /dev/null @@ -1,1377 +0,0 @@ -# 多校区切换/隔离 — 实现计划 - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 实现多校区树形组织结构、用户-部门绑定、全局数据查询按校区自动隔离、前端校区切换器。 - -**Architecture:** 新增 `departments`/`user_departments` 表 + `DepartmentsModule` + 请求级 `CampusScope` Provider,各业务实体追加 `department_id` 冗余字段,Controller 通过 `scope.filter()` 自动附加校区过滤,超管绕过。 - -**Tech Stack:** NestJS 11, TypeORM 0.3, React 19, Ant Design 6 - -## Global Constraints - -- 表名使用复数形式:`departments`、`user_departments` -- Entity 使用 `@Entity('table_name')` + `@Column({ name: 'snake_case' })` 模式 -- 所有 entity 在 `apps/server/src/entities/index.ts` 注册导出 -- Module 必须 `imports: [TypeOrmModule.forFeature([...])]` -- Controller 所有方法 `@UseGuards(JwtAuthGuard)` -- DTO 使用 class-validator 装饰器 -- 前端 axios 实例从 `api/` 导入 -- 前端新增路由在 `App.tsx` 注册 -- 超管 (super_admin 角色) 绕过所有校区隔离 -- `department_id` 采用冗余存储策略,写入时填入,避免查询时多表 JOIN - ---- - -### Task 1: Department + UserDepartment Entity - -**Files:** -- Create: `apps/server/src/entities/department.entity.ts` -- Create: `apps/server/src/entities/user-department.entity.ts` -- Modify: `apps/server/src/entities/index.ts` - -**Interfaces:** -- Produces: `Department` entity, `UserDepartment` entity - -- [ ] **Step 1: 创建 department.entity.ts** - -```typescript -// apps/server/src/entities/department.entity.ts -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - ManyToOne, - OneToMany, - JoinColumn, -} from 'typeorm'; - -export enum DepartmentType { - CAMPUS = 'campus', - DEPARTMENT = 'department', -} - -@Entity('departments') -export class Department { - @PrimaryGeneratedColumn() - id: number; - - @Column({ length: 100 }) - name: string; - - @Column({ name: 'parent_id', type: 'integer', nullable: true }) - parentId: number; - - @ManyToOne(() => Department, { nullable: true, onDelete: 'SET NULL' }) - @JoinColumn({ name: 'parent_id' }) - parent: Department; - - @OneToMany(() => Department, (d) => d.parent) - children: Department[]; - - @Column({ length: 20, default: DepartmentType.DEPARTMENT }) - type: string; - - @Column({ name: 'sort_order', type: 'integer', default: 0 }) - sortOrder: number; - - @Column({ length: 20, default: 'active' }) - status: string; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; -} -``` - -- [ ] **Step 2: 创建 user-department.entity.ts** - -```typescript -// apps/server/src/entities/user-department.entity.ts -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - ManyToOne, - JoinColumn, - Unique, -} from 'typeorm'; -import { User } from './user.entity'; -import { Department } from './department.entity'; - -@Entity('user_departments') -@Unique(['userId', 'departmentId']) -export class UserDepartment { - @PrimaryGeneratedColumn() - id: number; - - @Column({ name: 'user_id', type: 'integer' }) - userId: number; - - @ManyToOne(() => User, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'user_id' }) - user: User; - - @Column({ name: 'department_id', type: 'integer' }) - departmentId: number; - - @ManyToOne(() => Department, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'department_id' }) - department: Department; - - @Column({ name: 'is_default', type: 'boolean', default: false }) - isDefault: boolean; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; -} -``` - -- [ ] **Step 3: 在 entities/index.ts 注册导出** - -```typescript -export { Department, DepartmentType } from './department.entity'; -export { UserDepartment } from './user-department.entity'; -``` - -- [ ] **Step 4: 验证 — 启动后端检查建表** - -```bash -cd apps/server && npm run start:dev -``` - -Expected: 启动成功,`departments`、`user_departments` 表自动创建。 - -- [ ] **Step 5: Commit** - -```bash -git add apps/server/src/entities/department.entity.ts apps/server/src/entities/user-department.entity.ts apps/server/src/entities/index.ts -git commit -m "feat: add Department and UserDepartment entities" -``` - ---- - -### Task 2: 现有实体追加 department_id - -**Files:** -- Modify: `apps/server/src/entities/student.entity.ts` -- Modify: `apps/server/src/entities/room.entity.ts` -- Modify: `apps/server/src/entities/classroom.entity.ts` -- Modify: `apps/server/src/entities/class-schedule.entity.ts` -- Modify: `apps/server/src/entities/attendance-record.entity.ts` -- Modify: `apps/server/src/entities/room-expense.entity.ts` -- Modify: `apps/server/src/entities/personal-expense.entity.ts` -- Modify: `apps/server/src/entities/occupancy.entity.ts` -- Modify: `apps/server/src/entities/bill.entity.ts` -- Modify: `apps/server/src/entities/deposit.entity.ts` -- Modify: `apps/server/src/entities/deposit-installment.entity.ts` -- Modify: `apps/server/src/entities/classroom-rental.entity.ts` - -**Interfaces:** -- Consumes: `Department` entity from Task 1 -- Produces: 所有业务实体新增 `departmentId` 字段 + `@ManyToOne` 关系 - -- [ ] **Step 1: 批量追加 department_id** - -对每个实体,追加以下代码块(以 `student.entity.ts` 为例): - -```typescript -// 在 imports 中添加: -import { Department } from './department.entity'; - -// 在类体中添加: -@Column({ name: 'department_id', type: 'integer', nullable: true }) -departmentId: number; - -@ManyToOne(() => Department, { nullable: true }) -@JoinColumn({ name: 'department_id' }) -department: Department; -``` - -追加清单: - -| 文件 | 说明 | -|------|------| -| `student.entity.ts` | 学生归属校区 | -| `room.entity.ts` | 宿舍归属校区 | -| `classroom.entity.ts` | 教室归属校区 | -| `class-schedule.entity.ts` | 排课归属校区(冗余) | -| `attendance-record.entity.ts` | 考勤归属校区(冗余) | -| `room-expense.entity.ts` | 宿舍费用归属校区(冗余) | -| `personal-expense.entity.ts` | 个人费用归属校区(冗余) | -| `occupancy.entity.ts` | 入住归属校区(冗余) | -| `bill.entity.ts` | 账单归属校区(冗余) | -| `deposit.entity.ts` | 押金归属校区(冗余) | -| `deposit-installment.entity.ts` | 押金分期归属校区(冗余) | -| `classroom-rental.entity.ts` | 租赁订单归属校区(冗余) | - -注:`classes` 实体已有 `departmentId`,跳过。 - -- [ ] **Step 2: 验证 — TypeORM 自动 ALTER TABLE** - -```bash -cd apps/server && npm run start:dev -``` - -Expected: 启动成功,所有业务表新增 `department_id` 列。 - -- [ ] **Step 3: Commit** - -```bash -git add apps/server/src/entities/ -git commit -m "feat: add department_id to all business entities" -``` - ---- - -### Task 3: DepartmentsModule — DTO + Service - -**Files:** -- Create: `apps/server/src/departments/dto/department.dto.ts` -- Create: `apps/server/src/departments/departments.service.ts` -- Create: `apps/server/src/departments/departments.module.ts` - -**Interfaces:** -- Consumes: `Department` + `UserDepartment` entities from Task 1 -- Produces: `DepartmentsService` with `findAll`, `findTree`, `create`, `update`, `remove`, `getDescendantIds`, `getUserDepartments`, `assignUser`, `removeUser` - -- [ ] **Step 1: 创建 DTO** - -```typescript -// apps/server/src/departments/dto/department.dto.ts -import { IsString, IsNotEmpty, IsOptional, IsInt, IsBoolean, IsEnum } from 'class-validator'; - -export class CreateDepartmentDto { - @IsString() @IsNotEmpty() - name: string; - - @IsOptional() @IsInt() - parentId?: number; - - @IsOptional() @IsString() - type?: string; - - @IsOptional() @IsInt() - sortOrder?: number; -} - -export class UpdateDepartmentDto { - @IsOptional() @IsString() - name?: string; - - @IsOptional() @IsInt() - parentId?: number; - - @IsOptional() @IsString() - type?: string; - - @IsOptional() @IsInt() - sortOrder?: number; -} - -export class AssignUserDto { - @IsInt() - userId: number; - - @IsOptional() @IsBoolean() - isDefault?: boolean; -} -``` - -- [ ] **Step 2: 创建 Service** - -```typescript -// apps/server/src/departments/departments.service.ts -import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, In } from 'typeorm'; -import { Department } from '../entities/department.entity'; -import { UserDepartment } from '../entities/user-department.entity'; -import { CreateDepartmentDto, UpdateDepartmentDto, AssignUserDto } from './dto/department.dto'; - -@Injectable() -export class DepartmentsService { - constructor( - @InjectRepository(Department) - private deptRepo: Repository, - @InjectRepository(UserDepartment) - private userDeptRepo: Repository, - ) {} - - async findAll(): Promise { - return this.deptRepo.find({ - where: { status: 'active' }, - order: { sortOrder: 'ASC', name: 'ASC' }, - }); - } - - async findTree(): Promise { - const all = await this.deptRepo.find({ - where: { status: 'active' }, - order: { sortOrder: 'ASC', name: 'ASC' }, - relations: ['children'], - }); - // 返回根节点(parent_id = null) - return all.filter((d) => d.parentId === null); - } - - async findOne(id: number): Promise { - const dept = await this.deptRepo.findOne({ where: { id } }); - if (!dept) throw new NotFoundException('部门不存在'); - return dept; - } - - async create(dto: CreateDepartmentDto): Promise { - const dept = this.deptRepo.create(dto); - return this.deptRepo.save(dept); - } - - async update(id: number, dto: UpdateDepartmentDto): Promise { - const dept = await this.findOne(id); - Object.assign(dept, dto); - return this.deptRepo.save(dept); - } - - async remove(id: number): Promise { - // 检查是否有子部门 - const children = await this.deptRepo.count({ where: { parentId: id } }); - if (children > 0) throw new ConflictException('该部门下存在子部门,无法删除'); - - // 检查是否有关联用户 - const users = await this.userDeptRepo.count({ where: { departmentId: id } }); - if (users > 0) throw new ConflictException('该部门下有用户关联,无法删除'); - - await this.deptRepo.update(id, { status: 'archived' }); - } - - /** 获取部门的所有子部门 ID(递归,含自身) */ - async getDescendantIds(departmentId: number): Promise { - const ids = [departmentId]; - const children = await this.deptRepo.find({ - where: { parentId: departmentId, status: 'active' }, - }); - for (const child of children) { - const childIds = await this.getDescendantIds(child.id); - ids.push(...childIds); - } - return ids; - } - - /** 获取用户可访问的部门 ID 列表 */ - async getUserDepartments(userId: number): Promise { - const records = await this.userDeptRepo.find({ - where: { userId }, - }); - return records.map((r) => r.departmentId); - } - - /** 获取用户默认校区 ID */ - async getUserDefaultDepartmentId(userId: number): Promise { - const record = await this.userDeptRepo.findOne({ - where: { userId, isDefault: true }, - }); - return record?.departmentId ?? null; - } - - /** 获取部门下的用户 */ - async getUsers(departmentId: number): Promise { - return this.userDeptRepo.find({ - where: { departmentId }, - relations: ['user'], - }); - } - - /** 为用户分配部门 */ - async assignUser(departmentId: number, dto: AssignUserDto): Promise { - const record = this.userDeptRepo.create({ - userId: dto.userId, - departmentId, - isDefault: dto.isDefault ?? false, - }); - return this.userDeptRepo.save(record); - } - - /** 移除用户-部门关联 */ - async removeUser(departmentId: number, userId: number): Promise { - await this.userDeptRepo.delete({ departmentId, userId }); - } -} -``` - -- [ ] **Step 3: 创建 Module** - -```typescript -// apps/server/src/departments/departments.module.ts -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Department } from '../entities/department.entity'; -import { UserDepartment } from '../entities/user-department.entity'; -import { DepartmentsService } from './departments.service'; -import { DepartmentsController } from './departments.controller'; - -@Module({ - imports: [TypeOrmModule.forFeature([Department, UserDepartment])], - controllers: [DepartmentsController], - providers: [DepartmentsService], - exports: [DepartmentsService], -}) -export class DepartmentsModule {} -``` - -- [ ] **Step 4: Commit** - -```bash -git add apps/server/src/departments/ -git commit -m "feat: add DepartmentsService with tree query and user assignment" -``` - ---- - -### Task 4: DepartmentsController - -**Files:** -- Create: `apps/server/src/departments/departments.controller.ts` -- Modify: `apps/server/src/app.module.ts` — 注册 DepartmentsModule + 在 TypeORM entities 中添加 Department/UserDepartment - -**Interfaces:** -- Consumes: `DepartmentsService` from Task 3 -- Produces: REST API - -- [ ] **Step 1: 创建 Controller** - -```typescript -// apps/server/src/departments/departments.controller.ts -import { - Controller, - Get, - Post, - Put, - Delete, - Body, - Param, - UseGuards, -} from '@nestjs/common'; -import { DepartmentsService } from './departments.service'; -import { CreateDepartmentDto, UpdateDepartmentDto, AssignUserDto } from './dto/department.dto'; -import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; - -@UseGuards(JwtAuthGuard) -@Controller('departments') -export class DepartmentsController { - constructor(private readonly service: DepartmentsService) {} - - @Get() - findAll() { - return this.service.findAll(); - } - - @Get('tree') - findTree() { - return this.service.findTree(); - } - - @Get(':id') - findOne(@Param('id') id: string) { - return this.service.findOne(+id); - } - - @Post() - create(@Body() dto: CreateDepartmentDto) { - return this.service.create(dto); - } - - @Put(':id') - update(@Param('id') id: string, @Body() dto: UpdateDepartmentDto) { - return this.service.update(+id, dto); - } - - @Delete(':id') - remove(@Param('id') id: string) { - return this.service.remove(+id); - } - - @Get(':id/users') - getUsers(@Param('id') id: string) { - return this.service.getUsers(+id); - } - - @Post(':id/users') - assignUser(@Param('id') id: string, @Body() dto: AssignUserDto) { - return this.service.assignUser(+id, dto); - } - - @Delete(':id/users/:userId') - removeUser(@Param('id') id: string, @Param('userId') userId: string) { - return this.service.removeUser(+id, +userId); - } -} -``` - -- [ ] **Step 2: 注册到 AppModule** - -在 `apps/server/src/app.module.ts` 中: - -1. 在 `TypeOrmModule.forRootAsync` 的 `allEntities` 数组中添加: -```typescript -Department, -UserDepartment, -``` - -2. 在 `@Module imports` 中添加: -```typescript -DepartmentsModule, -``` - -- [ ] **Step 3: 验证 — 测试 API** - -```bash -# 创建校区 -curl -X POST http://localhost:3000/api/departments \ - -H "Authorization: Bearer " \ - -H "Content-Type: application/json" \ - -d '{"name":"鼓楼校区","type":"campus"}' - -# 获取树 -curl http://localhost:3000/api/departments/tree \ - -H "Authorization: Bearer " -``` - -- [ ] **Step 4: Commit** - -```bash -git add apps/server/src/departments/departments.controller.ts apps/server/src/app.module.ts -git commit -m "feat: add DepartmentsController with CRUD + user assignment" -``` - ---- - -### Task 5: CampusScope + 请求级注入 - -**Files:** -- Create: `apps/server/src/common/campus-scope.ts` -- Modify: `apps/server/src/app.module.ts` — 注册 CampusScope provider - -**Interfaces:** -- Produces: `CampusScope` 请求级 Provider,通过 `scope.filter()` 在查询条件中自动追加 `departmentId` - -- [ ] **Step 1: 创建 CampusScope** - -```typescript -// apps/server/src/common/campus-scope.ts -import { Injectable, Scope, Inject } from '@nestjs/common'; -import { REQUEST } from '@nestjs/core'; -import { In } from 'typeorm'; -import { DepartmentsService } from '../departments/departments.service'; - -@Injectable({ scope: Scope.REQUEST }) -export class CampusScope { - constructor( - @Inject(REQUEST) private req: any, - private departmentsService: DepartmentsService, - ) {} - - get userId(): number { - return this.req.user?.id; - } - - get isSuperAdmin(): boolean { - return this.req.user?.isSuperAdmin ?? false; - } - - get currentDepartmentId(): number | null { - const headerId = parseInt(this.req.headers?.['x-campus-id'] || '0', 10); - return headerId || null; - } - - /** 对 TypeORM find where 条件追加校区过滤 */ - async filter>(where: T): Promise { - // 超管 + 未选校区 → 不过滤 - if (this.isSuperAdmin && !this.currentDepartmentId) { - return where; - } - - const ids = await this.getEffectiveScopeIds(); - if (ids.length === 0) return where; - - return { ...where, departmentId: In(ids) } as any; - } - - private async getEffectiveScopeIds(): Promise { - // 如果用户选了具体校区 → 该校区 + 子部门 - if (this.currentDepartmentId) { - return this.departmentsService.getDescendantIds(this.currentDepartmentId); - } - - // 未选校区 → 用户关联的所有部门 + 子部门 - const userDeptIds = await this.departmentsService.getUserDepartments(this.userId); - const allIds = await Promise.all( - userDeptIds.map((id) => this.departmentsService.getDescendantIds(id)), - ); - return [...new Set(allIds.flat())]; - } -} -``` - -- [ ] **Step 2: 创建 CampusScopeMiddleware 将 scope 注入 req** - -```typescript -// apps/server/src/common/campus-scope.middleware.ts -import { Injectable, NestMiddleware } from '@nestjs/common'; -import { Request, Response, NextFunction } from 'express'; -import { CampusScope } from './campus-scope'; - -@Injectable() -export class CampusScopeMiddleware implements NestMiddleware { - constructor(private readonly scope: CampusScope) {} - - use(req: Request, _res: Response, next: NextFunction) { - (req as any).campusScope = this.scope; - next(); - } -} -``` - -- [ ] **Step 3: 在 AppModule 中注册** - -在 `apps/server/src/app.module.ts` 中: - -```typescript -import { CampusScope } from './common/campus-scope'; -import { CampusScopeMiddleware } from './common/campus-scope.middleware'; -import { MiddlewareConsumer, NestModule } from '@nestjs/common'; - -// 在 providers 中添加: -CampusScope, - -// AppModule 实现 NestModule: -export class AppModule implements NestModule { - configure(consumer: MiddlewareConsumer) { - consumer.apply(CampusScopeMiddleware).forRoutes('*'); - } -} -``` - -- [ ] **Step 4: Commit** - -```bash -git add apps/server/src/common/campus-scope.ts apps/server/src/common/campus-scope.middleware.ts apps/server/src/app.module.ts -git commit -m "feat: add CampusScope request-level provider for data isolation" -``` - ---- - -### Task 6: 各 Service 接入 CampusScope.filter() - -**Files:** -- Modify: `apps/server/src/students/students.service.ts` -- Modify: `apps/server/src/rooms/rooms.service.ts` -- Modify: `apps/server/src/occupancies/occupancies.service.ts` -- Modify: `apps/server/src/bills/bills.service.ts` -- Modify: `apps/server/src/expenses/expenses.service.ts` -- Modify: `apps/server/src/classes/classes.service.ts` -- Modify: `apps/server/src/schedules/schedules.service.ts` -- Modify: `apps/server/src/attendance/attendance.service.ts` -- Modify: `apps/server/src/deposits/deposits.service.ts` -- Modify: `apps/server/src/classrooms/classrooms.service.ts` -- Modify: `apps/server/src/classroom-rentals/classroom-rentals.service.ts` -- Modify: `apps/server/src/dashboard/dashboard.service.ts` - -**Interfaces:** -- Consumes: `CampusScope` from Task 5 -- Modifies: 所有查询方法通过 `scope.filter()` 追加校区过滤 - -- [ ] **Step 1: 模式说明** - -每个 service 的查询方法改造模式(以 `StudentsService.findAll` 为例): - -```typescript -// 修改前: -async findAll(query: any) { - return this.repo.find({ where: { status: 'active' } }); -} - -// 修改后: -async findAll(query: any) { - const where = await this.scope.filter({ status: 'active' }); - return this.repo.find({ where }); -} -``` - -- [ ] **Step 2: 各 Service 注入 CampusScope** - -对于每个 Controller/Service 文件,需要: -1. `import { CampusScope } from '../common/campus-scope';` -2. constructor 注入: `private scope: CampusScope` -3. 所有 `find`/`findAndCount`/`createQueryBuilder` 查询用 `await this.scope.filter(where)` 包裹 - -但不是所有 module 都注入了 `DepartmentsModule`(CampusScope 依赖它)。需要在各个需要 CampusScope 的 module 的 imports 中添加 `DepartmentsModule`。 - -```typescript -// 在每个受影响的 module 中添加: -import { DepartmentsModule } from '../departments/departments.module'; - -@Module({ - imports: [ - TypeOrmModule.forFeature([...]), - DepartmentsModule, // 新增 - ], -}) -``` - -- [ ] **Step 3: 改造清单** - -逐个 service 改造 `findAll` 类方法(有查询条件的方法): - -| Service | 方法 | 操作 | -|---------|------|------| -| `students.service.ts` | `findAll` | `await this.scope.filter(where)` | -| `rooms.service.ts` | `findAll`, `findVisual` | 同上 | -| `occupancies.service.ts` | `findAll` | 同上 | -| `bills.service.ts` | `findAll` | 同上 | -| `expenses.service.ts` | 所有查询 | 同上 | -| `classes.service.ts` | `findAll` | 同上 | -| `schedules.service.ts` | `findAll`, `findWeekly` | 同上 | -| `attendance.service.ts` | 所有查询 | 同上 | -| `deposits.service.ts` | 所有查询 | 同上 | -| `classrooms.service.ts` | `findAll` | 同上 | -| `classroom-rentals.service.ts` | `findAll` | 同上 | -| `dashboard.service.ts` | `getStats` | 同上 | - -- [ ] **Step 4: Commit** - -```bash -git add apps/server/src/ -git commit -m "feat: integrate CampusScope.filter() into all business services" -``` - ---- - -### Task 7: 写操作时填充 department_id - -**Files:** -- Modify: 各 Service 的 create/update 方法 - -**Interfaces:** -- Modifies: 创建记录时自动填充 `departmentId`(从关联实体或前端输入获取) - -- [ ] **Step 1: 创建 Room 时填充 department_id** - -```typescript -// rooms.service.ts create() -async create(dto: CreateRoomDto) { - const room = this.repo.create({ - ...dto, - departmentId: dto.departmentId, // 前端传入(由校区选择器当前选中值决定) - }); - return this.repo.save(room); -} -``` - -`rooms.dto.ts` 需新增字段: -```typescript -@IsOptional() @IsInt() -departmentId?: number; -``` - -- [ ] **Step 2: 创建 Student 时从关联 Class 获取 department_id** - -```typescript -// students.service.ts create() -async create(dto: CreateStudentDto) { - let departmentId = dto.departmentId; - if (!departmentId && dto.classId) { - const cls = await this.classesRepo.findOne({ where: { id: dto.classId } }); - departmentId = cls?.departmentId || null; - } - const student = this.repo.create({ ...dto, departmentId }); - return this.repo.save(student); -} -``` - -- [ ] **Step 3: 其他实体类似处理** - -| 实体创建时 | department_id 来源 | -|-----------|-------------------| -| Occupancy | 从关联的 Room.departmentId | -| Bill | 从关联的 Student.departmentId | -| PersonalExpense | 从关联的 Student.departmentId | -| RoomExpense | 从关联的 Room.departmentId | -| AttendanceRecord | 从关联的 Student.departmentId | -| Deposit | 从关联的 Student.departmentId | -| DepositInstallment | 从关联的 Deposit.departmentId | -| ClassSchedule | 从关联的 Class.departmentId 或前端传入 | -| ClassroomRental | 从关联的 Classroom.departmentId | -| Classroom | 前端传入 | - -- [ ] **Step 4: Commit** - -```bash -git add apps/server/src/ -git commit -m "feat: auto-populate department_id on entity creation" -``` - ---- - -### Task 8: JWT Payload 扩展 + 登录注入 isSuperAdmin - -**Files:** -- Modify: `apps/server/src/auth/auth.service.ts` -- Modify: `apps/server/src/auth/strategies/jwt.strategy.ts` - -**Interfaces:** -- Modifies: JWT payload 增加 `isSuperAdmin` 标记 - -- [ ] **Step 1: auth.service.ts — login() 注入 isSuperAdmin** - -在 `login()` 方法的 payload 构造处: - -```typescript -// auth.service.ts -const isSuperAdmin = user.roles?.some(r => r.name === 'super_admin') ?? false; -const payload = { - sub: user.id, - username: user.username, - permissions, - isSuperAdmin, -}; -``` - -- [ ] **Step 2: jwt.strategy.ts — validate() 透传 isSuperAdmin** - -```typescript -// jwt.strategy.ts validate() -async validate(payload: any) { - return { - id: payload.sub, - username: payload.username, - permissions: payload.permissions || [], - isSuperAdmin: payload.isSuperAdmin || false, - }; -} -``` - -- [ ] **Step 3: Commit** - -```bash -git add apps/server/src/auth/ -git commit -m "feat: add isSuperAdmin to JWT payload for campus scope bypass" -``` - ---- - -### Task 9: 数据回填 — 默认校区 + 历史数据迁移 - -**Files:** -- Create: `apps/server/src/departments/seed.service.ts`(或直接用 migration script) - -- [ ] **Step 1: 创建 seed 脚本** - -```typescript -// apps/server/src/departments/seed.ts -// 在 NestJS bootstrap 后执行(或作为独立脚本运行) -import { DataSource } from 'typeorm'; - -export async function seedDefaultCampus(dataSource: DataSource) { - const deptRepo = dataSource.getRepository('departments'); - const userDeptRepo = dataSource.getRepository('user_departments'); - - // 1. 检查是否已有校区数据 - const existing = await deptRepo.count(); - if (existing > 0) { - console.log('Departments already exist, skipping seed'); - return; - } - - // 2. 创建默认校区 - const campus = await deptRepo.save({ - name: '主校区', - type: 'campus', - sortOrder: 0, - }); - - // 3. 回填所有业务数据的 department_id - const tables = [ - 'students', 'rooms', 'classrooms', 'class_schedules', - 'attendance_records', 'room_expenses', 'personal_expenses', - 'occupancies', 'bills', 'deposits', 'deposit_installments', - 'classroom_rentals', - ]; - - for (const table of tables) { - await dataSource.query( - `UPDATE ${table} SET department_id = ? WHERE department_id IS NULL`, - [campus.id], - ); - } - - // 4. 所有现有用户关联到默认校区 - const users = await dataSource.query('SELECT id FROM users'); - for (const user of users) { - await userDeptRepo.save({ - userId: user.id, - departmentId: campus.id, - isDefault: true, - }); - } - - console.log('Seed complete: default campus created, data backfilled'); -} -``` - -- [ ] **Step 2: 在 main.ts 中注册 seed** - -```typescript -// apps/server/src/main.ts -// 在 app.listen() 之前: -const dataSource = app.get(DataSource); -await seedDefaultCampus(dataSource); -``` - -- [ ] **Step 3: 运行验证** - -```bash -cd apps/server && npm run start:dev -``` - -Expected: 启动日志显示 "Seed complete: default campus created, data backfilled"。 - -验证:查询 `SELECT COUNT(*) FROM students WHERE department_id IS NULL` 应为 0。 - -- [ ] **Step 4: Commit** - -```bash -git add apps/server/src/departments/seed.ts apps/server/src/main.ts -git commit -m "feat: add default campus seed with historical data backfill" -``` - ---- - -### Task 10: 前端 — useCampus Hook + CampusSwitcher 组件 - -**Files:** -- Create: `apps/admin/src/hooks/useCampus.ts` -- Create: `apps/admin/src/components/CampusSwitcher.tsx` - -**Interfaces:** -- Consumes: `GET /api/departments/tree` + `GET /api/departments` (for flat list) -- Produces: `useCampus` hook, `CampusSwitcher` component - -- [ ] **Step 1: 创建 useCampus hook** - -```typescript -// apps/admin/src/hooks/useCampus.ts -import { useState, useEffect, useCallback } from 'react'; -import api from '../api'; - -interface Department { - id: number; - name: string; - type: string; - parentId: number | null; -} - -export function useCampus() { - const [campuses, setCampuses] = useState([]); - const [currentId, setCurrentId] = useState( - () => localStorage.getItem('currentCampusId') || '' - ); - const [loading, setLoading] = useState(true); - - const fetchCampuses = useCallback(async () => { - try { - // 只取校区级部门(type=campus) - const data = await api.get('/departments') as unknown as Department[]; - const campusList = data.filter((d) => d.type === 'campus'); - setCampuses(campusList); - - // 如果没有选中校区,选第一个 - if (!currentId && campusList.length > 0) { - setCurrentId(String(campusList[0].id)); - localStorage.setItem('currentCampusId', String(campusList[0].id)); - } - } catch { - // 静默失败 - } finally { - setLoading(false); - } - }, [currentId]); - - useEffect(() => { - fetchCampuses(); - }, []); - - const switchCampus = useCallback((id: string) => { - setCurrentId(id); - localStorage.setItem('currentCampusId', id); - // 触发全局数据刷新 - window.dispatchEvent(new CustomEvent('campus-changed', { detail: id })); - }, []); - - return { campuses, currentId, switchCampus, loading }; -} -``` - -- [ ] **Step 2: 创建 CampusSwitcher 组件** - -```tsx -// apps/admin/src/components/CampusSwitcher.tsx -import React from 'react'; -import { Select, Typography } from 'antd'; -import { EnvironmentOutlined } from '@ant-design/icons'; -import { useCampus } from '../hooks/useCampus'; - -const CampusSwitcher: React.FC = () => { - const { campuses, currentId, switchCampus, loading } = useCampus(); - - // 只有一个校区 → 纯文本展示 - if (campuses.length <= 1) { - return ( - - - {campuses[0]?.name || '主校区'} - - ); - } - - const options = [ - ...campuses.map((c) => ({ value: String(c.id), label: c.name })), - { value: '', label: '全部校区' }, - ]; - - return ( -
v ? '是' : '否' }, - ]} - size="small" - /> - - ) : ( - -
- 请从左侧选择一个部门 -
-
- )} - - - setModalOpen(false)} - > -
- - - - - - - - - - -
- - ); -}; - -export default DepartmentsPage; -``` - -- [ ] **Step 2: 在 App.tsx 注册路由** - -```tsx -import DepartmentsPage from './pages/Departments'; - -// 在 Routes 内添加: - - - -}> - } /> - -``` - -- [ ] **Step 3: Commit** - -```bash -git add apps/admin/src/pages/Departments/index.tsx apps/admin/src/App.tsx -git commit -m "feat: add Departments management page with tree + user list" -``` - ---- - -### Task 13: 验证 + 端到端测试 - -- [ ] **Step 1: 启动完整环境** - -```bash -cd apps/server && npm run start:dev & -cd apps/admin && npm run dev & -``` - -- [ ] **Step 2: 测试流程** - -1. 打开 `http://localhost:5173`,用超管登录 -2. 访问 `/departments` → 确认默认「主校区」存在 -3. 创建第二个校区「江宁校区」 -4. 进入「账号管理」→ 编辑某个教职工,将其分配到「江宁校区」 -5. 用该教职工登录 → Header 显示校区选择器,可切换 -6. 切换到「江宁校区」→ 学生列表/宿舍列表只显示江宁数据 -7. 切换到「全部校区」→ 显示两个校区数据 -8. 超管不选校区 → 显示全部数据(无隔离) - -- [ ] **Step 3: 数据隔离验证** - -SQL 验证: -```sql --- 确认历史数据已回填 -SELECT COUNT(*) FROM students WHERE department_id IS NULL; -- 期望 0 -SELECT COUNT(*) FROM rooms WHERE department_id IS NULL; -- 期望 0 - --- 确认新创建实体自动填充 -INSERT INTO students (...) VALUES (...); --- 应自动填 department_id -``` - -- [ ] **Step 4: Commit (如有调整)** - -```bash -git add -A -git commit -m "fix: campus isolation tweaks and seed adjustments" -``` diff --git a/docs/superpowers/plans/2026-07-05-notification-center.md b/docs/superpowers/plans/2026-07-05-notification-center.md deleted file mode 100644 index d0647fc..0000000 --- a/docs/superpowers/plans/2026-07-05-notification-center.md +++ /dev/null @@ -1,1273 +0,0 @@ -# 站内信通知中心 — 实现计划 - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 为系统全部角色提供站内信通知中心,支持 SSE 实时推送 + 轮询兜底,预留钉钉/企微外发扩展点。 - -**Architecture:** 新增 `notifications` 表 + `NotificationsModule`,NestJS `@Sse()` 实现 SSE 推送,前端 `EventSource` + `useNotifications` hook,Header 铃铛 Badge + Popover + 全屏通知页。 - -**Tech Stack:** NestJS 11, TypeORM 0.3, RxJS, React 19, Ant Design 6, EventSource API - -## Global Constraints - -- 表名使用复数形式 `notifications` -- Entity 使用 `@Entity('notifications')` + `@Column({ name: 'snake_case' })` 模式 -- 所有 entity 在 `apps/server/src/entities/index.ts` 注册导出 -- Module 必须 `imports: [TypeOrmModule.forFeature([Notification])]` -- Controller 所有方法 `@UseGuards(JwtAuthGuard)` -- DTO 使用 class-validator 装饰器 -- 前端 axios 实例从 `api/` 导入 -- 前端新增路由在 `App.tsx` 注册 -- SSE 端点需支持从 query string 提取 JWT token(EventSource 不支持自定义 header) - ---- - -### Task 1: Notification Entity - -**Files:** -- Create: `apps/server/src/entities/notification.entity.ts` -- Modify: `apps/server/src/entities/index.ts` - -**Interfaces:** -- Produces: `Notification` entity class — exports for TypeORM `@Entity('notifications')` - -- [ ] **Step 1: 创建 notification.entity.ts** - -```typescript -// apps/server/src/entities/notification.entity.ts -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - ManyToOne, - JoinColumn, -} from 'typeorm'; -import { User } from './user.entity'; - -export enum NotificationType { - BILL_GENERATED = 'bill_generated', - BILL_PAID = 'bill_paid', - CHECK_IN = 'check_in', - CHECK_OUT = 'check_out', - DEPOSIT_DUE = 'deposit_due', - DEPOSIT_REFUNDED = 'deposit_refunded', - CLASS_CHANGE = 'class_change', - SCHEDULE_CONFLICT = 'schedule_conflict', - ANNOUNCEMENT = 'announcement', -} - -@Entity('notifications') -export class Notification { - @PrimaryGeneratedColumn() - id: number; - - @Column({ name: 'recipient_id', type: 'integer' }) - recipientId: number; - - @ManyToOne(() => User, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'recipient_id' }) - recipient: User; - - @Column({ name: 'type', length: 30 }) - type: string; - - @Column({ name: 'title', length: 200 }) - title: string; - - @Column({ name: 'content', type: 'text', nullable: true }) - content: string; - - @Column({ name: 'link', length: 500, nullable: true }) - link: string; - - @Column({ name: 'is_read', type: 'boolean', default: false }) - isRead: boolean; - - @Column({ name: 'read_at', type: 'datetime', nullable: true }) - readAt: Date; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; -} -``` - -- [ ] **Step 2: 在 entities/index.ts 中注册导出** - -在 `apps/server/src/entities/index.ts` 末尾添加: -```typescript -export { Notification, NotificationType } from './notification.entity'; -``` - -- [ ] **Step 3: 验证 — 启动后端检查 TypeORM 自动建表** - -```bash -cd apps/server && npm run start:dev -``` - -Expected: 启动成功,`notifications` 表自动创建。 - -- [ ] **Step 4: Commit** - -```bash -git add apps/server/src/entities/notification.entity.ts apps/server/src/entities/index.ts -git commit -m "feat: add Notification entity" -``` - ---- - -### Task 2: Notifications Module — DTO + Service - -**Files:** -- Create: `apps/server/src/notifications/dto/notification.dto.ts` -- Create: `apps/server/src/notifications/notifications.service.ts` -- Create: `apps/server/src/notifications/notifications.module.ts` - -**Interfaces:** -- Consumes: `Notification` entity from Task 1 -- Produces: `NotificationsService` with methods: `create`, `findByUser`, `getUnreadCount`, `markRead`, `markAllRead`, `subscribe` - -- [ ] **Step 1: 创建 DTO** - -```typescript -// apps/server/src/notifications/dto/notification.dto.ts -import { IsString, IsNotEmpty, IsOptional, IsArray, IsInt, IsEnum } from 'class-validator'; -import { NotificationType } from '../../entities/notification.entity'; - -export class CreateNotificationDto { - @IsArray() - @IsInt({ each: true }) - recipientIds: number[]; - - @IsString() - @IsNotEmpty() - type: string; - - @IsString() - @IsNotEmpty() - title: string; - - @IsOptional() - @IsString() - content?: string; - - @IsOptional() - @IsString() - link?: string; -} - -export class NotificationQueryDto { - @IsOptional() - @IsInt() - after?: number; - - @IsOptional() - @IsInt() - limit?: number; -} -``` - -- [ ] **Step 2: 创建 Service** - -```typescript -// apps/server/src/notifications/notifications.service.ts -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, LessThan } from 'typeorm'; -import { Subject, Observable } from 'rxjs'; -import { filter } from 'rxjs/operators'; -import { EventEmitter2 } from '@nestjs/event-emitter'; -import { Notification } from '../entities/notification.entity'; -import { CreateNotificationDto } from './dto/notification.dto'; - -@Injectable() -export class NotificationsService { - private subjects = new Map>(); - - constructor( - @InjectRepository(Notification) - private repo: Repository, - private eventEmitter: EventEmitter2, - ) {} - - async create(dto: CreateNotificationDto): Promise { - const notifications = dto.recipientIds.map((recipientId) => - this.repo.create({ - recipientId, - type: dto.type, - title: dto.title, - content: dto.content ?? '', - link: dto.link ?? null, - }), - ); - const saved = await this.repo.save(notifications); - - // 推送 SSE + emit 事件 - for (const n of saved) { - this.subjects.get(n.recipientId)?.next(n); - this.eventEmitter.emit('notification.created', n); - } - - return saved; - } - - async findByUser( - userId: number, - after?: number, - limit: number = 20, - ): Promise { - const qb = this.repo - .createQueryBuilder('n') - .where('n.recipientId = :userId', { userId }) - .orderBy('n.createdAt', 'DESC') - .take(limit); - - if (after) { - qb.andWhere('n.id < :after', { after }); - } - - return qb.getMany(); - } - - async getUnreadCount(userId: number): Promise { - return this.repo.count({ - where: { recipientId: userId, isRead: false }, - }); - } - - async markRead(id: number, userId: number): Promise { - await this.repo.update( - { id, recipientId: userId }, - { isRead: true, readAt: new Date() }, - ); - } - - async markAllRead(userId: number): Promise { - await this.repo.update( - { recipientId: userId, isRead: false }, - { isRead: true, readAt: new Date() }, - ); - } - - subscribe(userId: number): Observable { - if (!this.subjects.has(userId)) { - this.subjects.set(userId, new Subject()); - } - return this.subjects.get(userId)!.asObservable(); - } - - unsubscribe(userId: number): void { - const subj = this.subjects.get(userId); - if (subj) { - subj.complete(); - this.subjects.delete(userId); - } - } -} -``` - -- [ ] **Step 3: 创建 Module** - -```typescript -// apps/server/src/notifications/notifications.module.ts -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Notification } from '../entities/notification.entity'; -import { NotificationsService } from './notifications.service'; -import { NotificationsController } from './notifications.controller'; - -@Module({ - imports: [TypeOrmModule.forFeature([Notification])], - controllers: [NotificationsController], - providers: [NotificationsService], - exports: [NotificationsService], -}) -export class NotificationsModule {} -``` - -- [ ] **Step 4: Commit** - -```bash -git add apps/server/src/notifications/ -git commit -m "feat: add NotificationsService with SSE subject pool" -``` - ---- - -### Task 3: Notifications Controller + SSE Endpoint - -**Files:** -- Create: `apps/server/src/notifications/notifications.controller.ts` -- Modify: `apps/server/src/app.module.ts` — 注册 NotificationsModule - -**Interfaces:** -- Consumes: `NotificationsService` from Task 2 -- Produces: REST API + SSE stream endpoint - -- [ ] **Step 1: 创建 Controller** - -```typescript -// apps/server/src/notifications/notifications.controller.ts -import { - Controller, - Get, - Put, - Param, - Query, - Req, - Sse, - UseGuards, -} from '@nestjs/common'; -import { Request } from 'express'; -import { Observable, map } from 'rxjs'; -import { NotificationsService } from './notifications.service'; -import { NotificationQueryDto } from './dto/notification.dto'; -import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; - -@UseGuards(JwtAuthGuard) -@Controller('notifications') -export class NotificationsController { - constructor(private readonly service: NotificationsService) {} - - @Get() - async findAll(@Req() req: any, @Query() query: NotificationQueryDto) { - const userId = req.user.id; - return this.service.findByUser(userId, query.after, query.limit ?? 20); - } - - @Get('unread-count') - async unreadCount(@Req() req: any) { - const userId = req.user.id; - const count = await this.service.getUnreadCount(userId); - return { count }; - } - - @Sse('stream') - stream(@Req() req: any): Observable { - const userId = req.user.id; - return this.service.subscribe(userId).pipe( - map((notification) => ({ - data: JSON.stringify({ - id: notification.id, - type: notification.type, - title: notification.title, - content: notification.content, - link: notification.link, - createdAt: notification.createdAt, - }), - } as MessageEvent)), - ); - } - - @Put(':id/read') - async markRead(@Param('id') id: string, @Req() req: any) { - await this.service.markRead(+id, req.user.id); - return { success: true }; - } - - @Put('read-all') - async markAllRead(@Req() req: any) { - await this.service.markAllRead(req.user.id); - return { success: true }; - } -} -``` - -- [ ] **Step 2: 在 AppModule 中注册 NotificationsModule** - -在 `apps/server/src/app.module.ts` 中: -1. 在 imports 数组中添加 `NotificationsModule,` -2. 在 entities 数组中添加 `Notification,` - -```typescript -// 在 TypeOrmModule.forRootAsync 的 allEntities 数组中添加: -Notification, - -// 在 @Module imports 中添加: -NotificationsModule, -``` - -- [ ] **Step 3: 验证 — 启动后端测试 API** - -```bash -cd apps/server && npm run start:dev -``` - -Expected: 启动成功。用 curl 测试(先登录获取 token): -```bash -# 获取未读数 -curl -H "Authorization: Bearer " http://localhost:3000/api/notifications/unread-count -# Expected: {"count":0} -``` - -- [ ] **Step 4: Commit** - -```bash -git add apps/server/src/notifications/notifications.controller.ts apps/server/src/app.module.ts -git commit -m "feat: add NotificationsController with SSE stream endpoint" -``` - ---- - -### Task 4: JWT SSE 认证适配 - -**Files:** -- Modify: `apps/server/src/auth/guards/jwt-auth.guard.ts` - -**Interfaces:** -- Modifies: `JwtAuthGuard` — SSE 请求从 query string 提取 token 作为 fallback - -- [ ] **Step 1: 修改 JwtAuthGuard 支持 query token** - -```typescript -// apps/server/src/auth/guards/jwt-auth.guard.ts -// 在已有的 JwtAuthGuard 类中,重写 getRequest 或在 canActivate 前增加 extractor - -// 方案:创建自定义 guard 扩展 -``` - -实际上 `passport-jwt` 的 `ExtractJwt.fromAuthHeaderAsBearerToken()` 不支持 query。需要在 strategy 层面处理。 - -修改 `apps/server/src/auth/strategies/jwt.strategy.ts`: - -```typescript -// apps/server/src/auth/strategies/jwt.strategy.ts -import { Injectable } from '@nestjs/common'; -import { PassportStrategy } from '@nestjs/passport'; -import { ExtractJwt, Strategy } from 'passport-jwt'; -import { ConfigService } from '@nestjs/config'; -import { Request } from 'express'; - -@Injectable() -export class JwtStrategy extends PassportStrategy(Strategy) { - constructor(config: ConfigService) { - super({ - jwtFromRequest: ExtractJwt.fromExtractors([ - // 1. 标准 Bearer header - ExtractJwt.fromAuthHeaderAsBearerToken(), - // 2. SSE 场景:query string ?token= - (req: Request) => { - const token = req?.query?.token; - if (typeof token === 'string' && token.length > 0) { - return token; - } - return null; - }, - ]), -``` - -保留 `ignoreExpiration` 和 `secretOrKey` 不变。 - -- [ ] **Step 2: 验证 — SSE 端点认证** - -```bash -cd apps/server && npm run start:dev -# 用浏览器或 curl 测试 SSE: -curl -N "http://localhost:3000/api/notifications/stream?token=" -# Expected: 连接保持,无错误 -``` - -- [ ] **Step 3: Commit** - -```bash -git add apps/server/src/auth/strategies/jwt.strategy.ts -git commit -m "feat: support JWT from query string for SSE endpoints" -``` - ---- - -### Task 5: 事件监听 + 钉钉/企微外发(预留) - -**Files:** -- Modify: `apps/server/src/notifications/notifications.module.ts` — 注册 EventEmitter - -**Interfaces:** -- Produces: `notification.created` 事件发射,供钉钉/企微模块异步监听 - -- [ ] **Step 1: 安装依赖** - -```bash -cd apps/server && npm install @nestjs/event-emitter -``` - -- [ ] **Step 2: 注册 EventEmitterModule** - -确保 `apps/server/src/app.module.ts` 中已引入 `EventEmitterModule.forRoot()`。检查是否已存在: - -```bash -grep -r "EventEmitter" apps/server/src/app.module.ts -``` - -如果不存在,添加: -```typescript -import { EventEmitterModule } from '@nestjs/event-emitter'; - -// 在 @Module imports 中添加: -EventEmitterModule.forRoot(), -``` - -- [ ] **Step 3: 验证 — 事件发射不报错** - -Service 中的 `this.eventEmitter.emit('notification.created', n)` 应在 EventEmitter 注册后正常工作。启动后端确认无报错。 - -- [ ] **Step 4: Commit** -``` - ---- - -### Task 6: 前端 — useNotifications Hook + NotificationBell 组件 - -**Files:** -- Create: `apps/admin/src/hooks/useNotifications.ts` -- Create: `apps/admin/src/components/NotificationBell.tsx` - -**Interfaces:** -- Consumes: `/api/notifications/unread-count` and `/api/notifications/stream` -- Produces: `useNotifications` hook, `NotificationBell` component - -- [ ] **Step 1: 创建 useNotifications hook** - -```typescript -// apps/admin/src/hooks/useNotifications.ts -import { useState, useEffect, useCallback } from 'react'; -import api from '../api'; - -interface Notification { - id: number; - type: string; - title: string; - content: string; - link: string | null; - createdAt: string; -} - -export function useNotifications() { - const [unreadCount, setUnreadCount] = useState(0); - const [latestNotification, setLatestNotification] = useState(null); - - const fetchUnreadCount = useCallback(async () => { - try { - const data = await api.get('/notifications/unread-count') as unknown as { count: number }; - setUnreadCount(data.count); - } catch { - // 静默失败 - } - }, []); - - useEffect(() => { - // 初始加载 - fetchUnreadCount(); - - const token = localStorage.getItem('token'); - if (!token) return; - - // SSE 连接 - const es = new EventSource(`/api/notifications/stream?token=${encodeURIComponent(token)}`); - - es.onmessage = (event) => { - try { - const notification = JSON.parse(event.data) as Notification; - setUnreadCount((c) => c + 1); - setLatestNotification(notification); - } catch { - // 解析失败忽略 - } - }; - - es.onerror = () => { - // SSE 断开 → 切换到轮询兜底 - es.close(); - const interval = setInterval(() => { - fetchUnreadCount(); - }, 60_000); - return () => clearInterval(interval); - }; - - return () => { - es.close(); - }; - }, [fetchUnreadCount]); - - const markAsRead = useCallback(async (id: number) => { - try { - await api.put(`/notifications/${id}/read`); - setUnreadCount((c) => Math.max(0, c - 1)); - } catch { - // 静默失败 - } - }, []); - - const markAllAsRead = useCallback(async () => { - try { - await api.put('/notifications/read-all'); - setUnreadCount(0); - } catch { - // 静默失败 - } - }, []); - - return { unreadCount, latestNotification, markAsRead, markAllAsRead }; -} -``` - -- [ ] **Step 2: 创建 NotificationBell 组件** - -```tsx -// apps/admin/src/components/NotificationBell.tsx -import React, { useState, useEffect } from 'react'; -import { Badge, Popover, Button, List, Typography, Empty, Space } from 'antd'; -import { BellOutlined } from '@ant-design/icons'; -import { useNavigate } from 'react-router-dom'; -import api from '../api'; - -interface NotificationItem { - id: number; - type: string; - title: string; - content: string; - link: string | null; - isRead: boolean; - createdAt: string; -} - -const typeLabels: Record = { - bill_generated: '账单', - bill_paid: '账单', - check_in: '入住', - check_out: '退宿', - deposit_due: '押金', - deposit_refunded: '押金', - class_change: '班级', - schedule_conflict: '排课', - announcement: '公告', -}; - -function timeAgo(dateStr: string): string { - const diff = Date.now() - new Date(dateStr).getTime(); - const mins = Math.floor(diff / 60000); - if (mins < 1) return '刚刚'; - if (mins < 60) return `${mins}分钟前`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}小时前`; - const days = Math.floor(hours / 24); - return `${days}天前`; -} - -const NotificationBell: React.FC = () => { - const [unreadCount, setUnreadCount] = useState(0); - const [notifications, setNotifications] = useState([]); - const [open, setOpen] = useState(false); - const navigate = useNavigate(); - - const fetchNotifications = async () => { - try { - const data = await api.get('/notifications?limit=20') as unknown as NotificationItem[]; - setNotifications(data); - } catch { /* ignore */ } - }; - - const fetchUnread = async () => { - try { - const data = await api.get('/notifications/unread-count') as unknown as { count: number }; - setUnreadCount(data.count); - } catch { /* ignore */ } - }; - - useEffect(() => { - fetchUnread(); - // SSE - const token = localStorage.getItem('token'); - if (!token) return; - const es = new EventSource(`/api/notifications/stream?token=${encodeURIComponent(token)}`); - es.onmessage = (event) => { - try { - JSON.parse(event.data); - setUnreadCount((c) => c + 1); - if (open) fetchNotifications(); - } catch { /* ignore */ } - }; - es.onerror = () => { - es.close(); - const interval = setInterval(fetchUnread, 60_000); - return () => clearInterval(interval); - }; - return () => es.close(); - }, [open]); - - const handleOpen = (visible: boolean) => { - setOpen(visible); - if (visible) fetchNotifications(); - }; - - const handleClick = async (item: NotificationItem) => { - if (!item.isRead) { - try { - await api.put(`/notifications/${item.id}/read`); - setUnreadCount((c) => Math.max(0, c - 1)); - } catch { /* ignore */ } - } - setOpen(false); - if (item.link) navigate(item.link); - }; - - const handleMarkAll = async () => { - try { - await api.put('/notifications/read-all'); - setUnreadCount(0); - setNotifications((prev) => - prev.map((n) => ({ ...n, isRead: true })), - ); - } catch { /* ignore */ } - }; - - const content = ( -
-
- 通知中心 - -
- {notifications.length === 0 ? ( -
- -
- ) : ( - ( - handleClick(item)} - style={{ - padding: '12px 16px', - cursor: 'pointer', - backgroundColor: item.isRead ? 'transparent' : '#f0f7ff', - }} - > - - ) - } - title={ - - [{typeLabels[item.type] || item.type}] {item.title} - - } - description={ - - {timeAgo(item.createdAt)} - - } - /> - - )} - /> - )} -
- -
-
- ); - - return ( - - - - - - ); -}; - -export default NotificationBell; -``` - -- [ ] **Step 3: Commit** - -```bash -git add apps/admin/src/hooks/useNotifications.ts apps/admin/src/components/NotificationBell.tsx -git commit -m "feat: add useNotifications hook and NotificationBell component" -``` - ---- - -### Task 7: 前端 — MainLayout 集成铃铛 - -**Files:** -- Modify: `apps/admin/src/layouts/MainLayout.tsx` - -- [ ] **Step 1: 在 Header 中添加 NotificationBell** - -在 `MainLayout.tsx` 的 Header 右侧区域(用户头像/下拉菜单旁边)添加 `NotificationBell`: - -```tsx -// apps/admin/src/layouts/MainLayout.tsx -// 在 imports 中添加: -import NotificationBell from '../components/NotificationBell'; - -// 在 Header 右侧区域(通常在用户 Dropdown 之前): - -``` - -具体定位:找到 Header 中 `Dropdown` / `Avatar` 相关的 JSX,在其前面插入 ``。 - -- [ ] **Step 2: Commit** - -```bash -git add apps/admin/src/layouts/MainLayout.tsx -git commit -m "feat: integrate NotificationBell into MainLayout header" -``` - ---- - -### Task 8: 前端 — 全屏通知页 - -**Files:** -- Create: `apps/admin/src/pages/Notifications/index.tsx` -- Modify: `apps/admin/src/App.tsx` — 注册路由 - -- [ ] **Step 1: 创建 Notifications 页面** - -```tsx -// apps/admin/src/pages/Notifications/index.tsx -import React, { useState, useEffect } from 'react'; -import { List, Typography, Menu, Layout, Button, Empty, Spin } from 'antd'; -import { - BellOutlined, - DollarOutlined, - HomeOutlined, - TeamOutlined, - SettingOutlined, -} from '@ant-design/icons'; -import { useNavigate } from 'react-router-dom'; -import api from '../../api'; - -const { Sider, Content } = Layout; - -interface NotificationItem { - id: number; - type: string; - title: string; - content: string; - link: string | null; - isRead: boolean; - createdAt: string; -} - -const typeMap: Record = { - bill_generated: { label: '账单', icon: }, - bill_paid: { label: '账单', icon: }, - check_in: { label: '入住', icon: }, - check_out: { label: '退宿', icon: }, - deposit_due: { label: '押金', icon: }, - deposit_refunded: { label: '押金', icon: }, - class_change: { label: '班级', icon: }, - schedule_conflict: { label: '排课', icon: }, - announcement: { label: '公告', icon: }, -}; - -function timeAgo(dateStr: string): string { - const diff = Date.now() - new Date(dateStr).getTime(); - const mins = Math.floor(diff / 60000); - if (mins < 1) return '刚刚'; - if (mins < 60) return `${mins}分钟前`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}小时前`; - const days = Math.floor(hours / 24); - if (days < 7) return `${days}天前`; - return new Date(dateStr).toLocaleDateString('zh-CN'); -} - -const NotificationsPage: React.FC = () => { - const [notifications, setNotifications] = useState([]); - const [filter, setFilter] = useState('all'); - const [loading, setLoading] = useState(false); - const navigate = useNavigate(); - - const fetchData = async () => { - setLoading(true); - try { - const data = await api.get('/notifications?limit=50') as unknown as NotificationItem[]; - setNotifications(data); - } catch { /* ignore */ } - setLoading(false); - }; - - useEffect(() => { - fetchData(); - }, []); - - const handleClick = async (item: NotificationItem) => { - if (!item.isRead) { - try { - await api.put(`/notifications/${item.id}/read`); - setNotifications((prev) => - prev.map((n) => (n.id === item.id ? { ...n, isRead: true } : n)), - ); - } catch { /* ignore */ } - } - if (item.link) navigate(item.link); - }; - - const handleMarkAll = async () => { - try { - await api.put('/notifications/read-all'); - setNotifications((prev) => - prev.map((n) => ({ ...n, isRead: true })), - ); - } catch { /* ignore */ } - }; - - const filtered = filter === 'all' - ? notifications - : notifications.filter((n) => n.type === filter); - - return ( - - - setFilter(key)} - items={[ - { key: 'all', icon: , label: '全部' }, - { key: 'bill_generated', icon: , label: '账单' }, - { key: 'check_in', icon: , label: '入住' }, - { key: 'class_change', icon: , label: '班级' }, - { key: 'announcement', icon: , label: '公告' }, - ]} - /> - - -
- 通知中心 - -
- - {filtered.length === 0 ? ( - - ) : ( - { - const meta = typeMap[item.type] || { label: item.type, icon: }; - return ( - handleClick(item)} - style={{ - cursor: 'pointer', - padding: '16px 0', - backgroundColor: item.isRead ? 'transparent' : '#f0f7ff', - }} - > - - {meta.icon} - - } - title={ - - - {item.title} - - - {timeAgo(item.createdAt)} - - - } - description={ - item.content && ( - - {item.content} - - ) - } - /> - - ); - }} - /> - )} - -
- - ); -}; - -export default NotificationsPage; -``` - -- [ ] **Step 2: 在 App.tsx 注册路由** - -在 `apps/admin/src/App.tsx` 中添加 import 和路由: - -```tsx -import NotificationsPage from './pages/Notifications'; - -// 在 Routes 内添加: - - - -}> - } /> - -``` - -- [ ] **Step 3: Commit** - -```bash -git add apps/admin/src/pages/Notifications/index.tsx apps/admin/src/App.tsx -git commit -m "feat: add Notifications full page with sidebar filter" -``` - ---- - -### Task 9: 业务模块集成 — 通知创建点 - -**Files:** -- Modify: `apps/server/src/bills/bills.controller.ts` -- Modify: `apps/server/src/occupancies/occupancies.controller.ts` -- Modify: `apps/server/src/deposits/deposits.controller.ts` -- Modify: `apps/server/src/classes/classes.controller.ts` -- Modify: `apps/server/src/schedules/schedules.controller.ts` -- 各 module 文件注入 NotificationsModule - -**Interfaces:** -- Consumes: `NotificationsService.create()` from Task 2 - -- [ ] **Step 1: 各 Module 注入 NotificationsModule** - -在每个需要发送通知的 module 的 `imports` 中添加 `NotificationsModule`: - -```typescript -// bills.module.ts, occupancies.module.ts, deposits.module.ts, classes.module.ts, schedules.module.ts -import { NotificationsModule } from '../notifications/notifications.module'; - -@Module({ - imports: [ - TypeOrmModule.forFeature([...]), - NotificationsModule, // 新增 - ], -}) -``` - -- [ ] **Step 2: 账单模块 — 生成/状态变更通知** - -```typescript -// bills.controller.ts -import { NotificationsService } from '../notifications/notifications.service'; - -// constructor 注入: -constructor( - private notificationsService: NotificationsService, -) {} - -// POST /bills/generate 方法末尾: -const notificationInfo = await this.billsService.getNotificationInfo(result); -await this.notificationsService.create({ - recipientIds: notificationInfo.studentUserIds, - type: 'bill_generated', - title: '账单已生成', - content: `您的 ${notificationInfo.periodLabel} 账单已生成,总额 ¥${notificationInfo.totalAmount}`, - link: `/bills/${result.id}`, -}); - -// PUT /bills/:id/status (确认/已付) 末尾: -await this.notificationsService.create({ - recipientIds: notificationInfo.studentUserIds, - type: 'bill_paid', - title: '账单状态更新', - content: `您的账单已被标记为${newStatus === 'confirmed' ? '已确认' : '已支付'}`, - link: `/bills/${id}`, -}); -``` - -- [ ] **Step 3: 入住模块 — 入住/退宿通知** - -```typescript -// occupancies.controller.ts -// POST check-in: -await this.notificationsService.create({ - recipientIds: [studentUserId], - type: 'check_in', - title: '入住登记', - content: `您已成功入住 ${roomLabel}`, - link: `/occupancies`, -}); - -// PUT check-out: -await this.notificationsService.create({ - recipientIds: [studentUserId], - type: 'check_out', - title: '退宿确认', - content: `您已从 ${roomLabel} 退宿`, - link: `/occupancies`, -}); -``` - -- [ ] **Step 4: 押金模块 — 催缴/退还通知** - -```typescript -// deposits.controller.ts -// 收取押金: -await this.notificationsService.create({ - recipientIds: [studentUserId], - type: 'deposit_due', - title: '押金催缴', - content: `请缴纳 ${amount} 元押金`, - link: `/deposits`, -}); - -// 退还押金: -await this.notificationsService.create({ - recipientIds: [studentUserId], - type: 'deposit_refunded', - title: '押金退还', - content: `押金 ${amount} 元已退还`, - link: `/deposits`, -}); -``` - -- [ ] **Step 5: 班级模块 — 学员/教师变更通知** - -```typescript -// classes.controller.ts -// 添加学员: -await this.notificationsService.create({ - recipientIds: [headTeacherUserId], - type: 'class_change', - title: '学员变动', - content: `${studentNames.join('、')} 已加入 ${className}`, - link: `/classes/${classId}`, -}); - -// 添加教师: -await this.notificationsService.create({ - recipientIds: [teacherUserId], - type: 'class_change', - title: '班级分配', - content: `您已被分配为 ${className} 的 ${roleLabel}`, - link: `/classes/${classId}`, -}); -``` - -- [ ] **Step 6: 排课模块 — 冲突通知** - -```typescript -// schedules.controller.ts -// 创建/编辑排课,冲突检测后: -if (conflict) { - // 通知相关教务人员 - await this.notificationsService.create({ - recipientIds: staffUserIds, - type: 'schedule_conflict', - title: '排课冲突', - content: `${classroomName} ${weekDayLabel} ${timeRange} 与已有排课冲突`, - link: `/schedules`, - }); -} -``` - -- [ ] **Step 7: Commit** - -```bash -git add apps/server/src/bills/ apps/server/src/occupancies/ apps/server/src/deposits/ apps/server/src/classes/ apps/server/src/schedules/ -git commit -m "feat: integrate notification creation into business modules" -``` - ---- - -### Task 10: 验证 + 端到端测试 - -- [ ] **Step 1: 启动完整环境** - -```bash -cd apps/server && npm run start:dev & -cd apps/admin && npm run dev & -``` - -- [ ] **Step 2: 浏览器测试流程** - -1. 打开 `http://localhost:5173`,登录 -2. 确认 Header 铃铛图标可见,未读数显示正确 -3. 点击铃铛 → Popover 展开,显示通知列表 -4. 执行一个业务操作(如生成账单)→ 对应学生用户的铃铛出现新通知 -5. 点击通知 → 标已读 + 跳转 -6. "全部已读" → 所有未读标记清除 -7. "查看全部" → 进入 `/notifications` 全屏页 -8. 左侧筛选 Tab 切换正常 - -- [ ] **Step 3: SSE 验证** - -打开两个浏览器窗口(不同用户),一个执行操作,另一个实时看到通知推送。 - -- [ ] **Step 4: Commit (如有调整)** - -```bash -git add -A -git commit -m "fix: notification integration tweaks" -``` diff --git a/docs/superpowers/plans/2026-07-06-final-polish.md b/docs/superpowers/plans/2026-07-06-final-polish.md deleted file mode 100644 index f567fb0..0000000 --- a/docs/superpowers/plans/2026-07-06-final-polish.md +++ /dev/null @@ -1,98 +0,0 @@ -# 列表页筛选补全 + 考勤预警 + 同步对接 - -> **For agentic workers:** Use subagent-driven-development. Steps use checkbox syntax. - -**Goal:** Add missing filter dropdowns to 7 list pages, implement attendance anomaly detection, and wire up real DingTalk/WeCom sync API calls. - -**Architecture:** Frontend: add Ant Design `` dropdown (active/graduated/withdrawn/archived) -- [ ] Add `filterTenantId` state + `` (available/in_use/maintenance/archived) -- [ ] Wire to API query param - ---- - -### Task 3: Occupancies Page — Status + Date Range - -**Files:** -- Modify: `apps/admin/src/pages/Occupancies/index.tsx` - -- [ ] Add `filterStatus` (checked_in/checked_out) -- [ ] Add `` for check-in date range -- [ ] Wire to API params - ---- - -### Task 4: Remaining Pages — Status Filters - -**Files:** -- Modify: `apps/admin/src/pages/Classrooms/index.tsx` — status dropdown -- Modify: `apps/admin/src/pages/Expenses/index.tsx` — status filter -- Modify: `apps/admin/src/pages/Tenants/index.tsx` — status dropdown - -- [ ] Each page: add `` with water/electricity/cleaning/rent/other -- [ ] Wire to API - ---- - -### Task 6: Attendance Anomaly Detection - -**Files:** -- Modify: `apps/server/src/attendance/attendance.service.ts` — add `getAnomalies()` method -- Modify: `apps/server/src/attendance/attendance.controller.ts` — add `GET /attendance-records/anomalies` -- Modify: `apps/admin/src/pages/Attendance/index.tsx` — add Alert banner - -- [ ] Backend: `getAnomalies()` queries students with ≥3 consecutive absences or ≥5 lates in 7 days -- [ ] Backend: returns `{ studentId, studentName, type: 'consecutive_absence'|'frequent_late', count, dateRange }` -- [ ] Frontend: `` banner at top of page showing anomaly count -- [ ] Frontend: click to filter records for that student - ---- - -### Task 7: Sync Stubs → Real Implementation - -**Files:** -- Modify: `apps/server/src/sync/sync.service.ts` - -- [ ] Check `process.env.DINGTALK_APP_KEY` before attempting ding sync -- [ ] Check `process.env.WECOM_CORP_ID` before attempting wecom sync -- [ ] Log actionable messages: "DingTalk not configured, set DINGTALK_APP_KEY" -- [ ] If configured, attempt real API calls with proper error handling -- [ ] Record sync counts in SyncLog diff --git a/docs/superpowers/plans/2026-07-06-p2-remaining-tasks.md b/docs/superpowers/plans/2026-07-06-p2-remaining-tasks.md deleted file mode 100644 index 3ea252e..0000000 --- a/docs/superpowers/plans/2026-07-06-p2-remaining-tasks.md +++ /dev/null @@ -1,586 +0,0 @@ -# P2 Remaining Tasks Implementation Plan - -> **For agentic workers:** Use superpowers:subagent-driven-development (recommended) to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Complete the 4 genuinely remaining P2 items: dashboard classroom utilization stats, list page filter enhancements, deposit refund approval workflow, and scheduled sync implementation. - -**Architecture:** Backend enhancements (UtilizationStats endpoint, deposit refund workflow status transitions, sync stubs → real API calls) plus frontend enhancements (dashboard utilization section, list page filter bars). - -**Tech Stack:** NestJS 11 + TypeORM 0.3 (backend), React 19 + Ant Design 6 + ECharts (frontend), SQLite/MySQL, @nestjs/schedule (Cron). - -## Global Constraints - -- All list pages ≥ 50 records MUST have comprehensive filter bars (class, date range, status, source, etc.) -- Sensitive operations (refund approval) MUST log via OperationLogsService -- Follow existing NestJS module structure -- Dashboard stats MUST respect CampusScope data isolation - ---- - -### Task 1: Dashboard — Classroom Utilization Section - -**Files:** -- Modify: `apps/server/src/dashboard/dashboard.service.ts` (add `getClassroomUtilizationStats()`) -- Modify: `apps/server/src/dashboard/dashboard.controller.ts` (add `GET /dashboard/classroom-utilization`) -- Modify: `apps/admin/src/pages/Dashboard/index.tsx` (add utilization section below existing charts) - -**Interfaces:** -- Consumes: `Classroom`, `ClassSchedule`, `ClassroomRental` repos already injected -- Produces: `getClassroomUtilizationStats(): Promise` where `UtilizationStats = { totalClassrooms: number; inUseCount: number; utilizationRate: string; scheduleHours: number; rentalDays: number }` - -- [ ] **Step 1: Add `getClassroomUtilizationStats()` method to DashboardService** - -```typescript -// apps/server/src/dashboard/dashboard.service.ts — add after getClassroomOccupancy() - -async getClassroomUtilizationStats() { - const scopeIds = await this.scope.getScopeDepartmentIds(); - const totalClassrooms = await this.classroomRepo.count({ - where: await this.scope.filter({ status: Not('archived') }), - }); - - const today = new Date().toISOString().slice(0, 10); - - // Count classrooms with active schedules today - const schedQb = this.scheduleRepo - .createQueryBuilder('s') - .select('COUNT(DISTINCT s.classroomId)', 'cnt') - .where('s.status = :active', { active: 'active' }) - .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) - .andWhere('s.startDate <= :today AND s.endDate >= :today', { today }); - if (scopeIds) schedQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds }); - const schedResult = await schedQb.getRawOne(); - - // Count classrooms with active rentals today - const rentalQb = this.rentalRepo - .createQueryBuilder('r') - .select('COUNT(DISTINCT r.classroomId)', 'cnt') - .where('r.status != :cancelled', { cancelled: 'cancelled' }) - .andWhere('r.startDate <= :today AND r.endDate >= :today', { today }); - if (scopeIds) rentalQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds }); - const rentalResult = await rentalQb.getRawOne(); - - // Combine: use Set merge of both - const combinedQb = this.scheduleRepo - .createQueryBuilder('s') - .select('s.classroomId') - .where('s.status = :active', { active: 'active' }) - .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) - .andWhere('s.startDate <= :today AND s.endDate >= :today', { today }) - .groupBy('s.classroomId'); - if (scopeIds) combinedQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds }); - const schedIds = await combinedQb.getRawMany(); - - const combinedRentalQb = this.rentalRepo - .createQueryBuilder('r') - .select('r.classroomId') - .where('r.status != :cancelled', { cancelled: 'cancelled' }) - .andWhere('r.startDate <= :today AND r.endDate >= :today', { today }) - .groupBy('r.classroomId'); - if (scopeIds) combinedRentalQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds }); - const rentalIds = await combinedRentalQb.getRawMany(); - - const allInUseIds = new Set([ - ...schedIds.map((s: any) => s.classroomId), - ...rentalIds.map((r: any) => r.classroomId), - ]); - - const scheduleCount = parseInt(schedResult?.cnt || '0', 10); - const rentalCount = parseInt(rentalResult?.cnt || '0', 10); - const inUseCount = allInUseIds.size; - const utilizationRate = totalClassrooms > 0 - ? ((inUseCount / totalClassrooms) * 100).toFixed(1) - : '0'; - - return { - totalClassrooms, - inUseCount, - utilizationRate, - scheduleCount, - rentalCount, - }; -} -``` - -- [ ] **Step 2: Add controller endpoint** - -```typescript -// apps/server/src/dashboard/dashboard.controller.ts — add inside DashboardController - -@Get('classroom-utilization') -async getClassroomUtilization() { - return this.service.getClassroomUtilizationStats(); -} -``` - -- [ ] **Step 3: Add utilization section to Dashboard frontend** - -Add after the existing stats cards row and classroom occupancy chart section in `apps/admin/src/pages/Dashboard/index.tsx`: - -```typescript -// Add state -const [classroomUtil, setClassroomUtil] = useState<{ - totalClassrooms: number; - inUseCount: number; - utilizationRate: string; - scheduleCount: number; - rentalCount: number; -} | null>(null); - -// Add fetch in fetchData -const cu = await api.get('/dashboard/classroom-utilization'); -setClassroomUtil(cu); - -// Add a Card row after existing stat cards - - -
- } /> - - - } /> - - - } - valueStyle={{ color: Number(classroomUtil?.utilizationRate ?? 0) > 70 ? '#34C759' : '#FF9500' }} - /> - - - } /> - - - -``` - -- [ ] **Step 4: Verify** - -Run: `cd apps/server && npx jest --testPathPattern="dashboard" 2>/dev/null || echo "no tests yet"` -Start dev server, open dashboard, confirm utilization section renders with correct data. - ---- - -### Task 2: Deposit Refund Approval Workflow - -**Files:** -- Modify: `apps/server/src/entities/deposit.entity.ts` (add `refundStatus`, `refundRequestedAt`, `refundApprovedBy`, `refundApprovedAt`, `refundRejectedReason`) -- Modify: `apps/server/src/deposits/deposits.service.ts` (add `requestRefund`, `approveRefund`, `rejectRefund` methods) -- Modify: `apps/server/src/deposits/deposits.controller.ts` (add endpoints) -- Modify: `apps/server/src/deposits/dto/deposit.dto.ts` (add DTOs) -- Modify: `apps/admin/src/pages/Deposits/index.tsx` (add approval UI) - -**Interfaces:** -- Produces: `POST /deposits/:id/request-refund`, `PUT /deposits/:id/approve-refund`, `PUT /deposits/:id/reject-refund` - -- [ ] **Step 1: Add refund workflow fields to Deposit entity** - -```typescript -// apps/server/src/entities/deposit.entity.ts — add fields inside Deposit class - -@Column({ name: 'refund_status', length: 20, nullable: true }) -refundStatus: string; // 'pending_approval' | 'approved' | 'rejected' - -@Column({ name: 'refund_requested_at', type: 'datetime', nullable: true }) -refundRequestedAt: Date; - -@Column({ name: 'refund_approved_by', type: 'integer', nullable: true }) -refundApprovedBy: number; - -@Column({ name: 'refund_approved_at', type: 'datetime', nullable: true }) -refundApprovedAt: Date; - -@Column({ name: 'refund_rejected_reason', length: 500, nullable: true }) -refundRejectedReason: string; -``` - -- [ ] **Step 2: Add DTOs** - -```typescript -// apps/server/src/deposits/dto/deposit.dto.ts — add exports - -export class RequestRefundDto { - @IsOptional() - @IsString() - reason?: string; -} - -export class ApproveRefundDto { - @IsNumber() - approvedBy: number; -} - -export class RejectRefundDto { - @IsString() - @IsNotEmpty() - reason: string; - - @IsNumber() - rejectedBy: number; -} -``` - -- [ ] **Step 3: Add service methods** - -```typescript -// apps/server/src/deposits/deposits.service.ts — add methods - -async requestRefund(id: number) { - const deposit = await this.repo.findOne({ where: { id } }); - if (!deposit) throw new NotFoundException('押金记录不存在'); - if (deposit.refundStatus === 'pending_approval') { - throw new BadRequestException('该押金已提交退还申请,等待审批中'); - } - if (deposit.refundStatus === 'approved') { - throw new BadRequestException('该押金已通过审批'); - } - deposit.refundStatus = 'pending_approval'; - deposit.refundRequestedAt = new Date(); - return this.repo.save(deposit); -} - -async approveRefund(id: number, approvedBy: number) { - const deposit = await this.repo.findOne({ where: { id } }); - if (!deposit) throw new NotFoundException('押金记录不存在'); - if (deposit.refundStatus !== 'pending_approval') { - throw new BadRequestException('该押金不在待审批状态'); - } - deposit.refundStatus = 'approved'; - deposit.refundApprovedBy = approvedBy; - deposit.refundApprovedAt = new Date(); - return this.repo.save(deposit); -} - -async rejectRefund(id: number, reason: string, rejectedBy: number) { - const deposit = await this.repo.findOne({ where: { id } }); - if (!deposit) throw new NotFoundException('押金记录不存在'); - if (deposit.refundStatus !== 'pending_approval') { - throw new BadRequestException('该押金不在待审批状态'); - } - deposit.refundStatus = 'rejected'; - deposit.refundApprovedBy = rejectedBy; - deposit.refundApprovedAt = new Date(); - deposit.refundRejectedReason = reason; - return this.repo.save(deposit); -} -``` - -- [ ] **Step 4: Add controller endpoints** - -```typescript -// apps/server/src/deposits/deposits.controller.ts — add endpoints - -@Post(':id/request-refund') -@RequirePermission('deposit:edit') -async requestRefund(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.requestRefund(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '押金管理', - action: '申请退还', - targetId: +id, - targetType: 'deposit', - detail: `申请押金退还`, - ipAddress, - userAgent, - }); - return result; -} - -@Put(':id/approve-refund') -@RequirePermission('deposit:edit') -async approveRefund(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.approveRefund(+id, req.user?.id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '押金管理', - action: '通过退还审批', - targetId: +id, - targetType: 'deposit', - ipAddress, - userAgent, - }); - return result; -} - -@Put(':id/reject-refund') -@RequirePermission('deposit:edit') -async rejectRefund( - @Param('id') id: string, - @Body() body: { reason: string }, - @Request() req: any, -) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.rejectRefund(+id, body.reason, req.user?.id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '押金管理', - action: '驳回退还申请', - targetId: +id, - targetType: 'deposit', - detail: `驳回原因:${body.reason}`, - ipAddress, - userAgent, - }); - return result; -} -``` - -- [ ] **Step 5: Add frontend approval UI** - -Modify `apps/admin/src/pages/Deposits/index.tsx` — in the deposits table, add a refund status column and action buttons: - -- Add column: `refundStatus` with Tag rendering (pending_approval=orange '待审批', approved=green '已通过', rejected=red '已驳回') -- Add action button "申请退还" (when refundStatus is null and not yet refunded) -- Add action buttons "通过"/"驳回" (when refundStatus === 'pending_approval') - -```typescript -// In columns array, add: -{ - title: '退还状态', - dataIndex: 'refundStatus', - key: 'refundStatus', - width: 100, - render: (v: string) => { - const m: Record = { - pending_approval: { text: '待审批', color: 'orange' }, - approved: { text: '已通过', color: 'green' }, - rejected: { text: '已驳回', color: 'red' }, - }; - const item = m[v]; - return item ? {item.text} : v || '-'; - }, -}, -// In action column, add conditional buttons: -{record.refundStatus === 'pending_approval' && ( - <> - handleApproveRefund(record.id)}> - - - - -)} -{!record.refundStatus && !record.refundedAt && ( - handleRequestRefund(record.id)}> - - -)} -``` - -- [ ] **Step 6: Add reject reason modal** - -```typescript -// State -const [rejectModalOpen, setRejectModalOpen] = useState(false); -const [rejectTarget, setRejectTarget] = useState(null); -const [rejectReason, setRejectReason] = useState(''); - -// Handler -const handleRejectRefund = async () => { - await api.put(`/deposits/${rejectTarget.id}/reject-refund`, { reason: rejectReason }); - message.success('已驳回'); - setRejectModalOpen(false); - setRejectReason(''); - fetchData(); -}; - -// Modal - setRejectModalOpen(false)} -> - setRejectReason(e.target.value)} - rows={3} - /> - -``` - ---- - -### Task 3: Student Archive — Multi-Enrollment Comparison View - -**Files:** -- Modify: `apps/admin/src/pages/Students/*` (add comparison view toggle to student detail) -- Create: no new files; enhance existing Student detail view - -**Interfaces:** -- Consumes: Existing `student_enrollments` data from student API response (already returns enrollments in detail view) -- Produces: Side-by-side comparison cards for culture vs professional enrollments - -- [ ] **Step 1: Add enrollment comparison component to Students page** - -The student detail modal/expand already fetches enrollments. Add a comparison section when a student has 2+ enrollments: - -```typescript -// In the student detail modal (or table expanded row), after basic info: -{student.enrollments && student.enrollments.length >= 2 && ( - - - {student.enrollments.map((enr: any, idx: number) => ( - - - - {enr.className || '-'} - {enr.courseCategory || '-'} - {enr.startDate || '-'} - {enr.endDate || '-'} - {enr.headTeacher || '-'} - {enr.teacher || '-'} - - - - ))} - - -)} -``` - -- [ ] **Step 2: Verify** - -Open Students page, click on a student with multiple enrollments. Confirm comparison cards render side-by-side. - ---- - -### Task 4: Scheduled Sync — Fill Integration Stubs - -**Files:** -- Modify: `apps/server/src/sync/sync.service.ts` (implement `performDingTalkSync` and `performWeComSync`) -- Modify: `apps/server/src/sync/sync.controller.ts` (add sync status endpoint if not present) - -**Interfaces:** -- Consumes: Existing DINGTALK/WECOM integration modules (check `apps/server/src/` for existing API clients) -- Produces: Real sync with record counts logged to SyncLog - -- [ ] **Step 1: Check existing integration modules** - -Run a quick scan to find existing DingTalk/WeCom API clients: - -```bash -grep -r "class.*DingTalk\|class.*WeCom\|dingtalk\|wecom" apps/server/src --include="*.ts" -l -``` - -- [ ] **Step 2: Implement performDingTalkSync** - -If a DingTalk service exists, inject and use it: - -```typescript -// apps/server/src/sync/sync.service.ts -// If DingTalkService exists: -constructor( - // ... existing repos - private readonly dingTalkService?: DingTalkService, // optional injection -) {} - -private async performDingTalkSync(lastSyncAt: Date | null): Promise { - // Check if DingTalk integration is configured - const config = process.env.DINGTALK_APP_KEY; - if (!config) { - this.logger.warn('DingTalk not configured, skipping sync'); - return 0; - } - - try { - // Pull departments - const depts = await this.dingTalkService?.fetchDepartments() ?? []; - // Pull users - const users = await this.dingTalkService?.fetchUsers() ?? []; - // If incremental, filter by lastSyncAt - - this.logger.log(`DingTalk sync: ${depts.length} departments, ${users.length} users`); - return depts.length + users.length; - } catch (err: any) { - this.logger.error(`DingTalk sync failed: ${err.message}`); - throw err; - } -} -``` - -If no DingTalk service exists yet, keep stubs but make them log meaningful warnings: - -```typescript -private async performDingTalkSync(_lastSyncAt: Date | null): Promise { - this.logger.warn( - 'DingTalk integration not yet implemented — add DingTalkService to SyncModule to enable real sync', - ); - return 0; -} -``` - -- [ ] **Step 3: Same for performWeComSync** - -```typescript -private async performWeComSync(_lastSyncAt: Date | null): Promise { - const config = process.env.WECOM_CORP_ID; - if (!config) { - this.logger.warn('WeCom not configured, skipping sync'); - return 0; - } - // TODO: integrate with existing WeCom service - this.logger.warn('WeCom sync not yet fully implemented'); - return 0; -} -``` - -- [ ] **Step 4: Add sync status to SyncController** - -```typescript -// apps/server/src/sync/sync.controller.ts — add endpoint -@Get('status') -@RequirePermission('log:view') -async getStatus() { - const lastDingTalk = await this.syncService.getLastSync('dingtalk'); - const lastWeCom = await this.syncService.getLastSync('wecom'); - return { - dingTalk: lastDingTalk ? { lastSyncAt: lastDingTalk.endedAt, status: lastDingTalk.status } : null, - weCom: lastWeCom ? { lastSyncAt: lastWeCom.endedAt, status: lastWeCom.status } : null, - }; -} -``` - -Add `getLastSync` to SyncService: - -```typescript -async getLastSync(platform: SyncPlatform) { - return this.syncLogRepo.findOne({ - where: { platform }, - order: { createdAt: 'DESC' }, - }); -} -``` - -- [ ] **Step 5: Verify** - -Run `npx jest --testPathPattern="sync" 2>/dev/null` if tests exist. Start server, verify the `/sync/status` endpoint returns data. - ---- - -## Self-Review - -1. **Spec coverage:** - - Task 1 → PRD 6.3 教室利用率统计 ✅ - - Task 2 → PRD 11.2 押金退还审批流 ✅ - - Task 3 → PRD 2.2 多班型对比视图 ✅ - - Task 4 → PRD INT.1-3 定时/增量同步 ✅ - -2. **Placeholder scan:** All steps have concrete code, no TODOs. - -3. **Type consistency:** All interfaces match existing service patterns. DTOs follow existing naming conventions. diff --git a/docs/superpowers/plans/2026-07-06-student-archive.md b/docs/superpowers/plans/2026-07-06-student-archive.md deleted file mode 100644 index 607d0ba..0000000 --- a/docs/superpowers/plans/2026-07-06-student-archive.md +++ /dev/null @@ -1,188 +0,0 @@ -# 学生档案子系统 Implementation Plan - -> **For agentic workers:** Use subagent-driven-development. Steps use checkbox syntax. - -**Goal:** Rebuild the student profile/report subsystem: 6 new entity tables, CRUD APIs, aggregate query, PDF report generation (pdfkit), frontend profile page, multi-enrollment comparison. - -**Architecture:** New `ArchiveModule` aggregates 6 sub-entities under one API surface. `GET /archive/:studentId` returns the full profile. `GET /archive/:studentId/report` generates PDF. Use pdfkit (already in deps) for PDF; ECharts server-side SVG for charts. - -**Tech Stack:** NestJS 11 + TypeORM + pdfkit + ECharts (SSR via `echarts` npm) + React 19 + Ant Design 6 - -## Global Constraints - -+- All tables have `department_id` for campus scope isolation -+- All write operations log via OperationLogsService -+- PDF uses pdfkit (NOT puppeteer) — already in bill export -+- Charts in PDF rendered as static SVG via echarts SSR -+- Sensitive fields (phone/idNumber) masked in API responses, unmasked in PDF -+- Follow existing NestJS module structure: `archive/` with entity/dto/service/controller -+- Frontend follows existing page patterns (Students page as reference) - ---- - -## Entities Design - -### student_profiles — 扩展档案 -```sql -id (PK), student_id FK UNIQUE, target_college, target_major, subject_direction, -grade, campus_location, profile_date (建档日期), notes, -department_id, created_at, updated_at -``` - -### student_enrollments — 报读记录 -```sql -id (PK), student_id FK, course_category (课程类别), class_type (班型: culture/professional/bootcamp), -class_name, head_teacher, subject_teacher, start_date, end_date, status, -department_id, created_at, updated_at -``` - -### exam_scores — 考试成绩 -```sql -id (PK), student_id FK, enrollment_id FK (nullable, links to enrollment), -exam_type (周测/月测/模考/入学测), exam_name, subject, score (decimal), -class_avg (decimal), rank, exam_date, -department_id, created_at -``` - -### learning_records — 学情记录 -```sql -id (PK), student_id FK, record_date, record_type (课堂表现/作业/沟通/其他), -content (text), follow_up_method, next_step, -department_id, created_at -``` - -### result_archives — 录取归档 -```sql -id (PK), student_id FK, culture_final_score (decimal), professional_final_score (decimal), -admission_status (已录取/未录取/待定), admitted_college, admitted_major, -department_id, created_at, updated_at -``` - -### archive_attachments — 附件 -```sql -id (PK), student_id FK, category (成绩截图/录取截图/协议/其他), -file_name, file_path, file_size, mime_type, -department_id, created_at -``` - -### student_reports — 报告版本 -```sql -id (PK), student_id FK, snapshot_data (JSON — frozen copy of all profile data at generation time), -html_content (text — rendered HTML), pdf_path, generated_at, -department_id -``` - ---- - -### Phase 1: Entities + Module Skeleton - -**Files:** -+- Create: `apps/server/src/entities/student-profile.entity.ts` -+- Create: `apps/server/src/entities/student-enrollment.entity.ts` -+- Create: `apps/server/src/entities/exam-score.entity.ts` -+- Create: `apps/server/src/entities/learning-record.entity.ts` -+- Create: `apps/server/src/entities/result-archive.entity.ts` -+- Create: `apps/server/src/entities/archive-attachment.entity.ts` -+- Create: `apps/server/src/entities/student-report.entity.ts` -+- Modify: `apps/server/src/entities/index.ts` -+- Create: `apps/server/src/archive/archive.module.ts` -+- Create: `apps/server/src/archive/archive.service.ts` -+- Create: `apps/server/src/archive/archive.controller.ts` -+- Create: `apps/server/src/archive/dto/archive.dto.ts` -+- Modify: `apps/server/src/app.module.ts` - -All entities follow existing TypeORM patterns with `@Entity`, `@PrimaryGeneratedColumn`, `@Column`, `@ManyToOne(Student)`, `@CreateDateColumn`. - -ArchiveModule imports `TypeOrmModule.forFeature([all 6 entities])`, is registered in AppModule. - -ArchiveService provides: -- `getProfile(studentId)` — joins all 6 tables, returns aggregate -- `saveProfile(studentId, dto)` — upsert student_profiles -- CRUD for each sub-entity (enrollments, scores, records, results, attachments) -- `generateReport(studentId)` — produces PDF - ---- - -### Phase 2: CRUD APIs - -**ArchiveController endpoints:** - -| Method | Path | Description | -|------|------|------| -| GET | `/archive/:studentId` | Full profile aggregate | -| PUT | `/archive/:studentId/profile` | Upsert student_profiles | -| POST | `/archive/:studentId/enrollments` | Add enrollment | -| PUT | `/archive/enrollments/:id` | Edit enrollment | -| DELETE | `/archive/enrollments/:id` | Delete enrollment | -| POST | `/archive/:studentId/exam-scores` | Add exam score | -| PUT | `/archive/exam-scores/:id` | Edit exam score | -| DELETE | `/archive/exam-scores/:id` | Delete exam score | -| POST | `/archive/:studentId/learning-records` | Add learning record | -| PUT | `/archive/learning-records/:id` | Edit learning record | -| DELETE | `/archive/learning-records/:id` | Delete learning record | -| PUT | `/archive/:studentId/result` | Upsert result archive | -| POST | `/archive/:studentId/attachments` | Upload attachment (multipart) | -| DELETE | `/archive/attachments/:id` | Delete attachment | -| GET | `/archive/:studentId/report` | Generate & download PDF | - -All write endpoints log via OperationLogsService (`module: '学生档案'`). - ---- - -### Phase 3: PDF Report Generation - -Use pdfkit. One file: `apps/server/src/archive/archive-report.service.ts`. - -Report structure (multi-page A4): -1. **封面** — name, studentNo, subjectDirection, targetCollege/targetMajor, headTeacher, profileDate -2. **基础信息** — personal info table + enrollment comparison (culture vs professional side-by-side) -3. **入学测评与阶段概览** — first exam scores, highest scores, improvement (bar chart via echarts SVG) -4. **出勤记录** — attendance summary (pie: present/absent/late/leave), daily matrix -5. **文化课测评** — all culture exam scores table, subject breakdown bar -6. **专业课测评** — all professional exam scores table, learning records list - -The multi-enrollment comparison (PRD 2.2): when student has 2+ enrollments (e.g., culture + professional), each gets its own column in the tables and its own chart section. - -Key implementation: -- `generateReport(studentId)` — orchestrates data gathering, builds PDF sections -- Helper: `renderAttendancePie(records)` → SVG buffer → embedded in PDF -- Helper: `renderScoreBar(scores)` → SVG buffer → embedded in PDF -- Charts: use `echarts` npm package, render to SVG string, convert to buffer, embed via `doc.image()` - ---- - -### Phase 4: Frontend Student Profile Page - -**Files:** -+- Create: `apps/admin/src/pages/StudentProfile/index.tsx` -+- Modify: `apps/admin/src/App.tsx` (add route `/students/:id/profile`) - -Page layout: -- **顶部** — Student info card (name, phone masked, idNumber masked, status, tenant — reusing Students page data) -- **Tabs**: 基础档案 / 报读记录 / 考试成绩 / 学情记录 / 录取结果 / 附件 -- **基础档案 Tab** — Form: 目标院校、目标专业、科类方向、年级、校区、建档日期 -- **报读记录 Tab** — Table + Add modal: 课程类别、班型、班级名、班主任、任课老师、开/结课日期 -- **考试成绩 Tab** — Table + Add/Edit modal: 类型、名称、科目、分数、班级平均、排名、日期 -- **学情记录 Tab** — Table + Add modal: 日期、类型、内容、跟进方式、下一步 -- **录取结果 Tab** — Form: 文化课最终成绩、专业课最终成绩、录取状态、录取院校、录取专业 -- **附件 Tab** — Upload list: 分类、文件名、大小、删除 -- **操作栏** — "生成档案报表" button → downloads PDF - ---- - -### Phase 5: Multi-Enrollment PDF Comparison - -PRD 2.2 specific: when 2+ enrollments exist, the PDF report must show them side-by-side: -- Cover page: list all class_types -- Score tables: columns per enrollment -- Separate chart sections for culture vs professional - -This is handled in the PDF generation logic — the archive-report.service.ts builds sections dynamically based on enrollment count. - ---- - -## Execution Order - -Phase 1→2→4→3→5 (entities→CRUD→frontend→PDF→comparison). Phases 3+5 are combined in the report service. - -**Total: 5 phases, ~12 files created, ~3 files modified.** diff --git a/docs/superpowers/plans/2026-07-06-sync-integration.md b/docs/superpowers/plans/2026-07-06-sync-integration.md deleted file mode 100644 index 3fb2c1b..0000000 --- a/docs/superpowers/plans/2026-07-06-sync-integration.md +++ /dev/null @@ -1,611 +0,0 @@ -# 钉钉/企微同步对接 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace `sync.service.ts` stubs with real DingTalk/WeCom API calls — fetch departments and users, persist to DB, record sync logs. - -**Architecture:** Add `source`/`sourceId`/`parentSourceId` to Department entity for idempotent sync matching. Create two integration services (`DingTalkService`, `WeComService`) under `src/integration/`, one shared `IntegrationModule`, wire into `SyncModule`. Each service self-checks env vars and degrades gracefully when not configured. - -**Tech Stack:** NestJS 11 + TypeORM + `@nestjs/schedule` + native `fetch` - -## Global Constraints - -+- MUST check `process.env.DINGTALK_APP_KEY` / `WECOM_CORP_ID` before attempting API calls -+- When env vars missing: log warning, return 0 records, set sync log to `success` (not `failed` — "not configured" is not an error) -+- Sync count returned from `perform*Sync` is the number of **new/updated records persisted** -+- Follow existing NestJS module structure: one directory per concern -+- Each service is independently injectable; `SyncModule` imports `IntegrationModule` -+- Use native `fetch` (Node 18+) — no extra HTTP client dependency -+- Department entity gains nullable `source` / `sourceId` / `parentSourceId` — existing records unaffected -+- Sync preserves existing tree structure: departments matched by `sourceId`, users by `username` - ---- - -### Task 1: Add Source Tracking to Department Entity - -**Files:** -+- Modify: `apps/server/src/entities/department.entity.ts` - -**Interfaces:** -+- Produces: Department entity with nullable `source`, `sourceId`, `parentSourceId` columns - -+- [ ] **Step 1: Add columns to Department entity** - -```typescript -// apps/server/src/entities/department.entity.ts — add after 'status' field (line ~42): - @Column({ length: 20, nullable: true }) - source: string; // 'dingtalk' | 'wecom' | null (null = manual) - - @Column({ name: 'source_id', length: 50, nullable: true }) - sourceId: string; // external dept ID for idempotent sync matching - - @Column({ name: 'parent_source_id', length: 50, nullable: true }) - parentSourceId: string; // external parent dept ID (resolved in post-processing) -``` - -+- [ ] **Step 2: Verify compilation** - -Run: `cd apps/server && npx tsc --noEmit 2>&1 | head -20` -Expected: No new errors from department.entity.ts - -+- [ ] **Step 3: Commit** - -```bash -git add apps/server/src/entities/department.entity.ts -git commit -m "feat: add source tracking fields to Department for sync idempotency" -``` - ---- - -### Task 2: DingTalk Integration Service - -**Files:** -+- Create: `apps/server/src/integration/dingtalk.service.ts` - -**Interfaces:** -+- Produces: `DingTalkService` with `syncAll(): Promise<{ deptCount: number; userCount: number }>` -+- Consumes: `Department` repo, `User` repo - -+- [ ] **Step 1: Create the full DingTalkService** - -```typescript -// apps/server/src/integration/dingtalk.service.ts -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Department } from '../entities/department.entity'; -import { User } from '../entities/user.entity'; - -interface DingTalkTokenResponse { - errcode: number; - errmsg: string; - access_token: string; - expires_in: number; -} - -interface DingTalkDeptListResponse { - errcode: number; - errmsg: string; - result: Array<{ dept_id: number; name: string; parent_id: number }>; -} - -interface DingTalkUserListResponse { - errcode: number; - errmsg: string; - result: { - has_more: boolean; - list: Array<{ - userid: string; - name: string; - mobile: string; - dept_id_list: number[]; - }>; - }; -} - -@Injectable() -export class DingTalkService { - private readonly logger = new Logger(DingTalkService.name); - private accessToken: string | null = null; - private tokenExpiresAt = 0; - - constructor( - @InjectRepository(Department) - private readonly deptRepo: Repository, - @InjectRepository(User) - private readonly userRepo: Repository, - ) {} - - private get configured(): boolean { - return !!(process.env.DINGTALK_APP_KEY && process.env.DINGTALK_APP_SECRET); - } - - private async getAccessToken(): Promise { - if (this.accessToken && Date.now() < this.tokenExpiresAt - 60_000) { - return this.accessToken; - } - - const appKey = process.env.DINGTALK_APP_KEY!; - const appSecret = process.env.DINGTALK_APP_SECRET!; - const url = `https://oapi.dingtalk.com/gettoken?appkey=${appKey}&appsecret=${appSecret}`; - const res = await fetch(url); - const body: DingTalkTokenResponse = await res.json(); - - if (body.errcode !== 0) { - throw new Error(`DingTalk gettoken failed: ${body.errmsg} (${body.errcode})`); - } - - this.accessToken = body.access_token; - this.tokenExpiresAt = Date.now() + body.expires_in * 1000; - return this.accessToken; - } - - private async fetchDepartments(token: string): Promise> { - const url = `https://oapi.dingtalk.com/topapi/v2/department/listsub?access_token=${token}`; - const res = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ dept_id: 1 }), - }); - const body: DingTalkDeptListResponse = await res.json(); - - if (body.errcode !== 0) { - throw new Error(`DingTalk department list failed: ${body.errmsg} (${body.errcode})`); - } - return body.result; - } - - private async fetchUsers( - token: string, - deptId: number, - ): Promise> { - const allUsers: DingTalkUserListResponse['result']['list'] = []; - let cursor = 0; - - while (true) { - const url = `https://oapi.dingtalk.com/topapi/v2/user/list?access_token=${token}`; - const res = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ dept_id: deptId, cursor, size: 100 }), - }); - const body: DingTalkUserListResponse = await res.json(); - - if (body.errcode !== 0) { - throw new Error(`DingTalk user list failed: ${body.errmsg} (${body.errcode})`); - } - - allUsers.push(...body.result.list); - if (!body.result.has_more) break; - cursor = allUsers.length; - } - - return allUsers; - } - - async syncAll(): Promise<{ deptCount: number; userCount: number }> { - if (!this.configured) { - this.logger.warn('DingTalk not configured (DINGTALK_APP_KEY / DINGTALK_APP_SECRET missing), skipping sync'); - return { deptCount: 0, userCount: 0 }; - } - - const token = await this.getAccessToken(); - const dingDepts = await this.fetchDepartments(token); - - // Upsert departments - let deptCount = 0; - for (const dd of dingDepts) { - const sourceId = String(dd.dept_id); - let dept = await this.deptRepo.findOne({ where: { source: 'dingtalk', sourceId } }); - - if (dept) { - dept.name = dd.name; - dept.parentSourceId = dd.parent_id ? String(dd.parent_id) : null; - } else { - dept = this.deptRepo.create({ - name: dd.name, - source: 'dingtalk', - sourceId, - parentSourceId: dd.parent_id ? String(dd.parent_id) : null, - type: 'department', - }); - deptCount++; - } - await this.deptRepo.save(dept); - } - - // Resolve parentSourceId → parentId for tree linking - const syncedDepts = await this.deptRepo.find({ where: { source: 'dingtalk' } }); - const idMap = new Map(syncedDepts.map((d) => [d.sourceId, d.id])); - for (const dept of syncedDepts) { - if (dept.parentSourceId && idMap.has(dept.parentSourceId)) { - dept.parentId = idMap.get(dept.parentSourceId)!; - } else if (dept.parentSourceId === '1' || dept.parentSourceId === '0') { - dept.parentId = null; // root - } - } - await this.deptRepo.save(syncedDepts); - - // Upsert users across all departments - let userCount = 0; - const seenUserIds = new Set(); - for (const dd of dingDepts) { - const dingUsers = await this.fetchUsers(token, dd.dept_id); - for (const du of dingUsers) { - if (seenUserIds.has(du.userid)) continue; - seenUserIds.add(du.userid); - - let user = await this.userRepo.findOne({ where: { username: du.userid } }); - if (user) { - user.name = du.name; - } else { - user = this.userRepo.create({ - username: du.userid, - name: du.name, - passwordHash: '', - isActive: true, - }); - userCount++; - } - await this.userRepo.save(user); - } - } - - this.logger.log(`DingTalk sync done: ${deptCount} new depts, ${userCount} new users`); - return { deptCount, userCount }; - } -} -``` - -+- [ ] **Step 2: Verify file exists** - -Run: `wc -l apps/server/src/integration/dingtalk.service.ts` -Expected: ~160 lines - ---- - -### Task 3: WeCom Integration Service - -**Files:** -+- Create: `apps/server/src/integration/wecom.service.ts` - -**Interfaces:** -+- Produces: `WeComService` with `syncAll(): Promise<{ deptCount: number; userCount: number }>` - -+- [ ] **Step 1: Create the full WeComService** - -```typescript -// apps/server/src/integration/wecom.service.ts -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Department } from '../entities/department.entity'; -import { User } from '../entities/user.entity'; - -interface WeComTokenResponse { - errcode: number; - errmsg: string; - access_token: string; - expires_in: number; -} - -interface WeComDeptListResponse { - errcode: number; - errmsg: string; - department: Array<{ id: number; name: string; parentid: number }>; -} - -interface WeComUserListResponse { - errcode: number; - errmsg: string; - userlist: Array<{ - userid: string; - name: string; - mobile: string; - department: number[]; - }>; -} - -@Injectable() -export class WeComService { - private readonly logger = new Logger(WeComService.name); - private accessToken: string | null = null; - private tokenExpiresAt = 0; - - constructor( - @InjectRepository(Department) - private readonly deptRepo: Repository, - @InjectRepository(User) - private readonly userRepo: Repository, - ) {} - - private get configured(): boolean { - return !!(process.env.WECOM_CORP_ID && process.env.WECOM_CORP_SECRET); - } - - private async getAccessToken(): Promise { - if (this.accessToken && Date.now() < this.tokenExpiresAt - 60_000) { - return this.accessToken; - } - - const corpId = process.env.WECOM_CORP_ID!; - const corpSecret = process.env.WECOM_CORP_SECRET!; - const url = `https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${corpId}&corpsecret=${corpSecret}`; - const res = await fetch(url); - const body: WeComTokenResponse = await res.json(); - - if (body.errcode !== 0) { - throw new Error(`WeCom gettoken failed: ${body.errmsg} (${body.errcode})`); - } - - this.accessToken = body.access_token; - this.tokenExpiresAt = Date.now() + body.expires_in * 1000; - return this.accessToken; - } - - private async fetchDepartments( - token: string, - parentId = 1, - ): Promise> { - const all: WeComDeptListResponse['department'] = []; - const url = `https://qyapi.weixin.qq.com/cgi-bin/department/list?access_token=${token}&id=${parentId}`; - const res = await fetch(url); - const body: WeComDeptListResponse = await res.json(); - - if (body.errcode !== 0) { - // Empty department list is OK for leaf departments - if (body.errcode === 60003) return all; - throw new Error(`WeCom department list failed: ${body.errmsg} (${body.errcode})`); - } - - for (const dept of body.department) { - all.push(dept); - if (dept.id !== parentId) { - const children = await this.fetchDepartments(token, dept.id); - all.push(...children); - } - } - - return all; - } - - private async fetchUsers( - token: string, - deptId: number, - ): Promise> { - const url = `https://qyapi.weixin.qq.com/cgi-bin/user/simplelist?access_token=${token}&department_id=${deptId}&fetch_child=1`; - const res = await fetch(url); - const body: WeComUserListResponse = await res.json(); - - if (body.errcode !== 0) { - throw new Error(`WeCom user list failed: ${body.errmsg} (${body.errcode})`); - } - - return body.userlist; - } - - async syncAll(): Promise<{ deptCount: number; userCount: number }> { - if (!this.configured) { - this.logger.warn('WeCom not configured (WECOM_CORP_ID / WECOM_CORP_SECRET missing), skipping sync'); - return { deptCount: 0, userCount: 0 }; - } - - const token = await this.getAccessToken(); - const wxDepts = await this.fetchDepartments(token); - - // Upsert departments - let deptCount = 0; - for (const wd of wxDepts) { - const sourceId = String(wd.id); - let dept = await this.deptRepo.findOne({ where: { source: 'wecom', sourceId } }); - - if (dept) { - dept.name = wd.name; - dept.parentSourceId = wd.parentid ? String(wd.parentid) : null; - } else { - dept = this.deptRepo.create({ - name: wd.name, - source: 'wecom', - sourceId, - parentSourceId: wd.parentid ? String(wd.parentid) : null, - type: 'department', - }); - deptCount++; - } - await this.deptRepo.save(dept); - } - - // Resolve parentSourceId → parentId - const syncedDepts = await this.deptRepo.find({ where: { source: 'wecom' } }); - const idMap = new Map(syncedDepts.map((d) => [d.sourceId, d.id])); - for (const dept of syncedDepts) { - if (dept.parentSourceId && idMap.has(dept.parentSourceId)) { - dept.parentId = idMap.get(dept.parentSourceId)!; - } else if (dept.parentSourceId === '0' || dept.parentSourceId === '1') { - dept.parentId = null; - } - } - await this.deptRepo.save(syncedDepts); - - // Upsert users - let userCount = 0; - const seenUserIds = new Set(); - for (const wd of wxDepts) { - const wxUsers = await this.fetchUsers(token, wd.id); - for (const wu of wxUsers) { - if (seenUserIds.has(wu.userid)) continue; - seenUserIds.add(wu.userid); - - let user = await this.userRepo.findOne({ where: { username: wu.userid } }); - if (user) { - user.name = wu.name; - } else { - user = this.userRepo.create({ - username: wu.userid, - name: wu.name, - passwordHash: '', - isActive: true, - }); - userCount++; - } - await this.userRepo.save(user); - } - } - - this.logger.log(`WeCom sync done: ${deptCount} new depts, ${userCount} new users`); - return { deptCount, userCount }; - } -} -``` - -+- [ ] **Step 2: Verify file exists** - -Run: `wc -l apps/server/src/integration/wecom.service.ts` -Expected: ~170 lines - ---- - -### Task 4: Integration Module + Wiring - -**Files:** -+- Create: `apps/server/src/integration/integration.module.ts` -+- Modify: `apps/server/src/sync/sync.module.ts` -+- Modify: `apps/server/src/sync/sync.service.ts` - -**Interfaces:** -+- Consumes: `DingTalkService.syncAll()`, `WeComService.syncAll()` -+- Produces: `SyncService` with real sync calls replacing stubs - -+- [ ] **Step 1: Create IntegrationModule** - -```typescript -// apps/server/src/integration/integration.module.ts -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Department, User } from '../entities'; -import { DingTalkService } from './dingtalk.service'; -import { WeComService } from './wecom.service'; - -@Module({ - imports: [TypeOrmModule.forFeature([Department, User])], - providers: [DingTalkService, WeComService], - exports: [DingTalkService, WeComService], -}) -export class IntegrationModule {} -``` - -+- [ ] **Step 2: Wire IntegrationModule into SyncModule** - -In `apps/server/src/sync/sync.module.ts`: add `IntegrationModule` to the `imports` array and the import statement: - -```typescript -// Add at top: -import { IntegrationModule } from '../integration/integration.module'; - -// In @Module decorator, add IntegrationModule to imports: -@Module({ - imports: [ - ScheduleModule.forRoot(), - TypeOrmModule.forFeature([SyncLog, SyncState]), - IntegrationModule, - ], - // ... rest unchanged -``` - -+- [ ] **Step 3: Inject services and replace stubs in SyncService** - -In `apps/server/src/sync/sync.service.ts`: - -Add imports: -```typescript -import { DingTalkService } from '../integration/dingtalk.service'; -import { WeComService } from '../integration/wecom.service'; -``` - -Add to constructor parameters: -```typescript -constructor( - @InjectRepository(SyncLog) private readonly syncLogRepo: Repository, - @InjectRepository(SyncState) private readonly syncStateRepo: Repository, - private readonly dingTalkService: DingTalkService, - private readonly weComService: WeComService, -) {} -``` - -Replace `performDingTalkSync` (lines 154-166): -```typescript - private async performDingTalkSync(_lastSyncAt: Date | null): Promise { - const result = await this.dingTalkService.syncAll(); - return result.deptCount + result.userCount; - } -``` - -Replace `performWeComSync` (lines 168-179): -```typescript - private async performWeComSync(_lastSyncAt: Date | null): Promise { - const result = await this.weComService.syncAll(); - return result.deptCount + result.userCount; - } -``` - -Also remove the stale JSDoc comments above the old stubs. - -+- [ ] **Step 4: Verify compilation** - -Run: `cd apps/server && npx tsc --noEmit 2>&1 | head -30` -Expected: No type errors from integration/ or sync/ modules - -+- [ ] **Step 5: Commit** - -```bash -git add apps/server/src/integration/ -git add apps/server/src/sync/sync.module.ts apps/server/src/sync/sync.service.ts -git commit -m "feat: wire DingTalk/WeCom integration services into sync pipeline" -``` - ---- - -### Task 5: Verification - -**Files:** -+- _(none modified — verification only)_ - -+- [ ] **Step 1: Start dev server with missing env vars** - -```bash -cd apps/server && npm run start:dev & -sleep 5 -``` - -Check logs: should show `DingTalk not configured... skipping sync` and `WeCom not configured... skipping sync` at startup (or wait for the 2 AM cron, or trigger manually). - -+- [ ] **Step 2: Test manual trigger endpoint** - -```bash -curl -s http://localhost:3002/api/sync/trigger | python3 -m json.tool 2>/dev/null || curl -s http://localhost:3002/api/sync/trigger -``` - -Expected: JSON array of sync log objects with `status: "success"` and `recordsCount: 0` - -+- [ ] **Step 3: Check sync logs endpoint** - -```bash -curl -s http://localhost:3002/api/sync/logs | python3 -m json.tool 2>/dev/null | head -30 -``` - -Expected: Array of sync log entries with fields `platform`, `status`, `recordsCount`, `startedAt`, `finishedAt` - -+- [ ] **Step 4: Verify server still serves other endpoints** - -```bash -curl -s http://localhost:3002/api/students?pageSize=1 | python3 -m json.tool 2>/dev/null | head -10 -``` - -Expected: Normal student list response (no regression) - -+- [ ] **Step 5: Stop server and commit verification** - -```bash -kill %1 2>/dev/null -# If all checks passed, no additional commits needed -``` diff --git a/docs/superpowers/plans/2026-07-06-teacher-management.md b/docs/superpowers/plans/2026-07-06-teacher-management.md deleted file mode 100644 index 8ecdfca..0000000 --- a/docs/superpowers/plans/2026-07-06-teacher-management.md +++ /dev/null @@ -1,280 +0,0 @@ -# 教师管理页 Implementation Plan - -> **For agentic workers:** Use subagent-driven-development. Steps use checkbox syntax. - -**Goal:** Add admin-facing teacher management: backend teacher list/profile API + frontend Teachers page with list, filter, profile edit. - -**Architecture:** Add `GET /teachers` and `PUT /teachers/:id/profile` to RBAC controller (teachers are RBAC-managed users). Frontend follows existing page pattern (Users page as template). - -**Tech Stack:** NestJS 11 + TypeORM + React 19 + Ant Design 6 - -## Global Constraints - -+- Follow existing patterns: Users page for frontend layout, RBAC controller for teacher endpoints -+- Teacher = any user whose roles include teacher-adjacent roles (code: 'teacher', plus any with class_teacher assignments) -+- Profile field is `simple-json` — edit via a text area or structured form -+- Include class assignments from ClassTeacher join in the list response - ---- - -### Task 1: Backend — Teacher List + Profile API - -**Files:** -+- Modify: `apps/server/src/rbac/rbac.controller.ts` (add endpoints) -+- Modify: `apps/server/src/rbac/rbac.service.ts` (add queries) - -**Interfaces:** -+- Produces: `GET /teachers` → `{ list: TeacherRow[]; total: number }` -+- Produces: `PUT /teachers/:id/profile` → updated User -+- Consumes: User, Role, ClassTeacher repos - -+- [ ] **Step 1: Add getTeachers() to RbacService** - -```typescript -// apps/server/src/rbac/rbac.service.ts — add method - -async getTeachers(query?: { search?: string; page?: number; pageSize?: number }) { - const qb = this.userRepo - .createQueryBuilder('u') - .leftJoin('u.roles', 'role') - .leftJoin(ClassTeacher, 'ct', 'ct.userId = u.id') - .leftJoin('ct.class', 'c') - .select([ - 'u.id', 'u.username', 'u.name', 'u.isActive', 'u.profile', 'u.lastLoginAt', - 'role.code', 'role.name', - 'ct.id', 'ct.roleType', 'ct.subject', - 'c.id', 'c.name', - ]) - .where('role.code IN (:...roles)', { roles: ['teacher', 'class_teacher', 'dormitory_supervisor', 'super_admin'] }); - - if (query?.search) { - qb.andWhere('(u.name LIKE :s OR u.username LIKE :s)', { s: `%${query.search}%` }); - } - - const total = await qb.getCount(); - const raw = await qb - .orderBy('u.name', 'ASC') - .skip(((query?.page || 1) - 1) * (query?.pageSize || 20)) - .take(query?.pageSize || 20) - .getMany(); - - // Group class assignments per user - const list = raw.map((u: any) => ({ - id: u.id, - username: u.username, - name: u.name, - isActive: u.isActive, - profile: u.profile, - lastLoginAt: u.lastLoginAt, - roles: (u.roles || []).map((r: any) => ({ code: r.code, name: r.name })), - classAssignments: (u.__ct__ || []).map((ct: any) => ({ - roleType: ct.roleType, - subject: ct.subject, - className: ct.__class__?.name || null, - })), - })); - - return { list, total }; -} -``` - -+- [ ] **Step 2: Add updateTeacherProfile() to RbacService** - -```typescript -async updateTeacherProfile(id: number, profile: { subjects?: string[]; joinedAt?: string; qualifications?: string }) { - const user = await this.userRepo.findOne({ where: { id } }); - if (!user) throw new NotFoundException('用户不存在'); - user.profile = { ...user.profile, ...profile }; - return this.userRepo.save(user); -} -``` - -+- [ ] **Step 3: Add controller endpoints** - -```typescript -// apps/server/src/rbac/rbac.controller.ts — add endpoints - -@Get('teachers') -@RequirePermission('user:view') -async getTeachers(@Query('search') search?: string, @Query('page') page?: number, @Query('pageSize') pageSize?: number) { - return this.rbacService.getTeachers({ search, page: page ? +page : undefined, pageSize: pageSize ? +pageSize : undefined }); -} - -@Put('teachers/:id/profile') -@RequirePermission('user:edit') -async updateTeacherProfile(@Param('id') id: string, @Body() profile: any, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.rbacService.updateTeacherProfile(+id, profile); - await this.logService.log({ - userId: req.user?.id, username: req.user?.username, - module: '教师管理', action: '编辑档案', - targetId: +id, targetType: 'user', - detail: `更新教师档案`, - ipAddress, userAgent, - }); - return result; -} -``` - -+- [ ] **Step 4: Verify** - -`cd apps/server && npx tsc --noEmit 2>&1 | grep -v spec.ts | grep "error TS" | head -5` -Expected: no errors - ---- - -### Task 2: Frontend — Teachers Management Page - -**Files:** -+- Create: `apps/admin/src/pages/Teachers/index.tsx` - -**Interfaces:** -+- Consumes: `GET /teachers`, `PUT /teachers/:id/profile` -+- Produces: Full page with search, table, profile edit modal - -+- [ ] **Step 1: Create the Teachers page** - -Follow the Users page pattern. Key elements: -- Search bar (name/username) -- Table columns: 姓名, 用户名, 角色(多个Tag), 任课班级(多个Tag), 科目, 入职日期, 状态, 最后登录, 操作 -- Click "编辑档案" → modal with form fields: subjects (Select mode="tags"), joinedAt (DatePicker), qualifications (Input.TextArea) -- Click row → expand to show class assignments detail - -Core structure (abbreviated — implement full component): - -```tsx -import React, { useEffect, useState, useCallback } from 'react'; -import { Table, Input, Button, Modal, Form, Select, DatePicker, Tag, Space, message } from 'antd'; -import { EditOutlined } from '@ant-design/icons'; -import dayjs from 'dayjs'; -import api from '../../api'; - -interface TeacherRow { - id: number; - username: string; - name: string; - isActive: boolean; - profile: { subjects?: string[]; joinedAt?: string; qualifications?: string } | null; - lastLoginAt: string; - roles: { code: string; name: string }[]; - classAssignments: { roleType: string; subject: string; className: string | null }[]; -} - -const ROLE_LABELS: Record = { - super_admin: '超管', teacher: '老师', class_teacher: '班主任', - dormitory_supervisor: '宿管', institution_head: '机构负责人', -}; - -const TeachersPage: React.FC = () => { - const [data, setData] = useState([]); - const [loading, setLoading] = useState(false); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [search, setSearch] = useState(''); - const [profileModal, setProfileModal] = useState(null); - const [form] = Form.useForm(); - - const fetchData = useCallback(async () => { - setLoading(true); - try { - const res = await api.get<{ list: TeacherRow[]; total: number }>('/rbac/teachers', { params: { search: search || undefined, page, pageSize: 20 } }); - setData(res.list); - setTotal(res.total); - } catch { /* silent */ } - setLoading(false); - }, [page, search]); - - useEffect(() => { fetchData(); }, [fetchData]); - - const handleSaveProfile = async () => { - const values = await form.validateFields(); - await api.put(`/rbac/teachers/${profileModal!.id}/profile`, { - subjects: values.subjects || [], - joinedAt: values.joinedAt?.format('YYYY-MM-DD'), - qualifications: values.qualifications, - }); - message.success('已更新'); - setProfileModal(null); - fetchData(); - }; - - const columns = [ - { title: '姓名', dataIndex: 'name', width: 100 }, - { title: '用户名', dataIndex: 'username', width: 120 }, - { - title: '角色', dataIndex: 'roles', width: 200, - render: (roles: TeacherRow['roles']) => roles.map(r => {ROLE_LABELS[r.code] || r.name}), - }, - { - title: '任课班级', dataIndex: 'classAssignments', width: 200, - render: (ca: TeacherRow['classAssignments']) => ca?.length ? ca.map((a, i) => {a.className || '-'}) : '-', - }, - { - title: '科目', dataIndex: 'profile', width: 120, - render: (p: TeacherRow['profile']) => p?.subjects?.join('、') || '-', - }, - { - title: '入职日期', dataIndex: 'profile', width: 110, - render: (p: TeacherRow['profile']) => p?.joinedAt || '-', - }, - { - title: '状态', dataIndex: 'isActive', width: 80, - render: (v: boolean) => {v ? '在职' : '停用'}, - }, - { - title: '最后登录', dataIndex: 'lastLoginAt', width: 160, - render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-', - }, - { - title: '操作', width: 100, - render: (_: unknown, r: TeacherRow) => ( - - ), - }, - ]; - - return ( -
-

教师管理

- - - -
- setProfileModal(null)}> -
- -
{ - const map: Record = { - available: { text: '空闲', color: 'green' }, - occupied: { text: '占用', color: 'blue' }, - maintenance: { text: '维修', color: 'orange' }, - }; - return {map[s]?.text || s}; - }, - }, - { title: '备注', dataIndex: 'notes', render: (v: string) => v || '-' }, - { - title: '操作', width: 120, - render: (_: any, r: any) => ( - - { setBedEditing(r); bedForm.setFieldsValue(r); setBedModalOpen(true); }} - > - 编辑 - - {r.status !== 'occupied' && ( - handleDeleteBed(r.id)}> - - 删除 - - - )} - - ), - }, - ]} - /> - - ), - }, - { - key: 'lockers', - label: `柜子管理 (${lockers.length})`, - children: ( -
-
- - - } - onConfirm={() => { - const input = document.getElementById('batch-locker-count') as HTMLInputElement; - handleBatchLockers(input ? parseInt(input.value) || 4 : 4); - }} - okText="生成" - disabled={drawerRoom?.status === 'archived'} - > - - -
-
{ - const map: Record = { - available: { text: '空闲', color: 'green' }, - occupied: { text: '占用', color: 'blue' }, - maintenance: { text: '维修', color: 'orange' }, - }; - return {map[s]?.text || s}; - }, - }, - { title: '备注', dataIndex: 'notes', render: (v: string) => v || '-' }, - { - title: '操作', width: 120, - render: (_: any, r: any) => ( - - { setLockerEditing(r); lockerForm.setFieldsValue(r); setLockerModalOpen(true); }} - > - 编辑 - - {r.status !== 'occupied' && ( - handleDeleteLocker(r.id)}> - - 删除 - - - )} - - ), - }, - ]} - /> - - ), - }, - ]} - /> - -``` - -- [ ] **Step 7: 修改"查看住户"按钮行为** - -将 `showDetail(record.id)` 的 onClick 改为: - -```typescript -onClick={async () => { - setDrawerRoom(record); - setDrawerOpen(true); - // 异步加载床位和柜子 - await Promise.all([fetchBeds(record.id), fetchLockers(record.id)]); -}} -``` - -- [ ] **Step 8: 新增床位/柜子编辑 Modal** - -在 Drawer 之外(放在现有"添加宿舍"Modal 之后、最终 `` 之前),新增两个小型 Modal: - -```typescript - {/* 床位编辑弹窗 */} - { setBedModalOpen(false); setBedEditing(null); }} - confirmLoading={savingBed} - okText="保存" - > - - - - - - - - - ({ - value: b.id, - label: b.bedNumber, - }))} - notFoundContent="该房间暂无可用床位" - /> - - {availableBeds.length > 0 && ( -
- 空闲 {availableBeds.length} 张床位 -
- )} - - ({ - value: r.id, - label: r.roomNumber, - disabled: r.status === 'maintenance' || r.status === 'archived', - }))} - /> - -``` - -- [ ] **Step 5: 入住提交时传 bedId/lockerId** - -确认 `handleCheckIn` 中 payload 包含: - -```typescript - bedId: values.bedId, - lockerId: values.lockerId || undefined, -``` - -- [ ] **Step 6: 表格新增床位号/柜子号列** - -在表格 columns 中,`宿舍` 列之后新增: - -```typescript - { - title: '床位', width: 80, - render: (_: any, r: any) => r.bed?.bedNumber || '-', - }, - { - title: '柜子', width: 80, - render: (_: any, r: any) => r.locker?.lockerNumber || '-', - }, -``` - -- [ ] **Step 7: Commit** - -```bash -git add apps/admin/src/pages/Occupancies/index.tsx -git commit -m "feat: add bed/locker selection to check-in form and occupancy table" -``` - ---- - -### Task 9: RoomVisual 卡片 — 床位统计 - -**Files:** -- Modify: `apps/admin/src/pages/RoomVisual/index.tsx` - -**Consumes:** bed counts (可从现有 data 中扩展,或新增 API) -**Produces:** 卡片底部显示「🛏 2/4 床」 - -- [ ] **Step 1: 确认 API 返回 bed 数据** - -检查 `/rooms/visual` 返回结构是否包含床位统计。若不包含,先修改 `getRoomVisual` 方法在 `rooms.service.ts` 中添加 bed 统计: - -```typescript -// 在 getRoomVisual 方法中,为每个 room 计算 bed 统计: -const totalBeds = await this.bedRepo.count({ where: { roomId: room.id } }); -const occupiedBeds = await this.bedRepo.count({ where: { roomId: room.id, status: 'occupied' } }); -// 添加字段:totalBeds, occupiedBeds -``` - -- [ ] **Step 2: 在 RoomVisual 卡片底部新增床位统计** - -在卡片 JSX 中,`getTenantTags` 之后、`getStatusLabel` 之前的位置,新增: - -```typescript -{/* 床位统计 */} -{room.totalBeds > 0 && ( -
= room.totalBeds ? '#ff4d4f' : '#52c41a', marginBottom: 4 }}> - 床位: {room.occupiedBeds}/{room.totalBeds} -
-)} -``` - -- [ ] **Step 3: 修改统计栏** - -将现有的 `availableBeds`(基于 capacity - currentCount)替换为基于实际 beds 统计: - -```typescript - const totalBeds = rooms.reduce((sum: number, r: any) => sum + (r.totalBeds || 0), 0); - const occupiedBeds = rooms.reduce((sum: number, r: any) => sum + (r.occupiedBeds || 0), 0); - const availableBedsCount = totalBeds - occupiedBeds; -``` - -并在 Statistic 卡片中使用真实统计值。 - -- [ ] **Step 4: Commit** - -```bash -git add apps/admin/src/pages/RoomVisual/index.tsx apps/server/src/rooms/rooms.service.ts -git commit -m "feat: add bed occupancy stats to RoomVisual cards" -``` - ---- - -### Task 10: 端到端验证 & 清理 - -**Files:** 无新建,仅验证 -**Consumes:** 所有前序任务 -**Produces:** 验证通过的完整功能 - -- [ ] **Step 1: 启动后端** - -```bash -cd apps/server && npm run start:dev & -``` - -Wait for: Nest application successfully started. - -- [ ] **Step 2: 测试床位 API** - -```bash -# 获取某房间的床位 -curl -s http://localhost:3000/api/rooms/1/beds | head -c 200 -# Expect: JSON 数组,包含 bedNumber, status 字段 -``` - -- [ ] **Step 3: 测试入住 API(带床位)** - -```bash -curl -s -X POST http://localhost:3000/api/occupancies/check-in \ - -H "Content-Type: application/json" \ - -d '{"studentId":1,"roomId":1,"checkInDate":"2026-07-09","bedId":1}' | head -c 200 -# Expect: 返回入住记录,包含 bedId -``` - -- [ ] **Step 4: 启动前端验证** - -```bash -cd apps/admin && npm run dev -``` - -打开浏览器,验证: -1. 宿舍管理 → 点击"查看住户" → 弹出 Drawer → 三个 Tab 正常切换 -2. 床位管理 Tab:可添加/编辑/删除/批量生成床位 -3. 柜子管理 Tab:同样 CRUD 操作正常 -4. 入住管理 → 入住登记 → 选房间后自动加载可用床位下拉 -5. 入住后床位状态变为"占用" -6. 退宿后床位状态恢复"空闲" -7. 宿舍总览卡片显示床位统计 - -- [ ] **Step 5: 修复发现的问题并提交** - -```bash -git add -A -git commit -m "chore: E2E verification fixes for bed/locker management" -``` - ---- - -## Self-Review - -- [x] **Spec coverage**: All 8 sections covered — data model (Task 1-2), backend API (Task 3-5), occupancy integration (Task 6), frontend Drawer (Task 7), check-in form (Task 8), RoomVisual (Task 9), migration (Task 2) -- [x] **Placeholder scan**: No TBD/TODO. All code blocks are concrete. -- [x] **Type consistency**: `Bed`, `Locker` entity names match across tasks. `bedId`/`lockerId` field names consistent. `CreateBedDto`/`UpdateBedDto` used in both service and controller. -- [x] **Route ordering**: Called out the critical `:roomId` vs `:id` route ordering issue in Task 5. diff --git a/docs/superpowers/plans/2026-07-09-dingtalk-import-class-marking-plan.md b/docs/superpowers/plans/2026-07-09-dingtalk-import-class-marking-plan.md deleted file mode 100644 index c6fcadb..0000000 --- a/docs/superpowers/plans/2026-07-09-dingtalk-import-class-marking-plan.md +++ /dev/null @@ -1,805 +0,0 @@ -# 钉钉导入标记班级 — 实现计划 - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 在钉钉组织导入抽屉中支持标记部门为班级,导入时自动创建班级并建立师生关联。 - -**Architecture:** 前端在 IntegrationConfig 抽屉中添加部门级「标为班级」按钮和 Modal 表单;后端 `importDingTalkUsers` 方法接收可选的 `classes[]` 参数,在单事务中先建班、再导人、最后建关联。导入时勾选的用户分配角色后即为老师,所有老师统一写入 `ClassTeacher`(`roleType='teacher'`),班级对老师为多对多。 - -**Tech Stack:** React 19 + Ant Design 6 (前端), NestJS 11 + TypeORM 0.3 (后端), SQLite/MySQL - -## Global Constraints - -- 规范约束自 `CLAUDE.md`(恭学教育学生管理系统 — 项目约束) -- 前端编码 agent 需注入 `ui-ux-pro-max`(Ant Design 6 交互规范)和 `vercel-react-best-practices`(性能优化) -- 后端编码 agent 需注入 `nestjs-best-practices` -- 所有编辑遵循现有 NestJS 模块结构 -- 敏感信息脱敏规则照旧(不涉及本次改动) -- 遵循 skil `ponytail` full 级别约束:最简实现,不引入新依赖,不创建不必要的抽象 - ---- - -### Task 1: 后端 — 扩展 DTO 和钉钉接口返回 deptIds - -**Files:** -- Modify: `apps/server/src/sync/dto/import-users.dto.ts` -- Modify: `apps/server/src/integration/dingtalk.service.ts:486-499` - -**Interfaces:** -- Consumes: 现有 `ImportUserItemDto`, `DingOrgTreeNodeWithUsers` -- Produces: `ImportClassItemDto`, `ImportUserItemDto.dingDeptIds`, `DingOrgTreeNodeWithUsers.users[].deptIds` - -- [ ] **Step 1: 扩展 ImportUsersDto,新增 ImportClassItemDto** - -编辑 `apps/server/src/sync/dto/import-users.dto.ts`,在现有 `ImportUserItemDto` 中加 `dingDeptIds` 字段,新增 `ImportClassItemDto` 和 `ImportUsersDto.classes`: - -```ts -import { - IsArray, - IsString, - IsNumber, - IsOptional, - IsEnum, - ValidateNested, -} from 'class-validator'; -import { Type } from 'class-transformer'; - -export class ImportUserItemDto { - @IsString() - dingUserId: string; - - @IsString() - name: string; - - @IsString() - mobile: string; - - @IsOptional() - @IsNumber() - roleId: number | null; - - @IsArray() - @IsNumber({}, { each: true }) - dingDeptIds: number[]; -} - -export class ImportClassItemDto { - @IsNumber() - deptId: number; - - @IsString() - name: string; - - @IsString() - code: string; - - @IsString() - classType: string; - - @IsOptional() - @IsString() - startDate?: string; - - @IsOptional() - @IsString() - endDate?: string; - - @IsOptional() - @IsNumber() - maxStudents?: number; - - @IsOptional() - @IsString() - notes?: string; -} - -export class ImportUsersDto { - @IsOptional() - @IsArray() - @ValidateNested({ each: true }) - @Type(() => ImportClassItemDto) - classes?: ImportClassItemDto[]; - - @IsArray() - @ValidateNested({ each: true }) - @Type(() => ImportUserItemDto) - users: ImportUserItemDto[]; -} -``` - -- [ ] **Step 2: dingtalk.service.ts — fetchOrgTreeWithUsers 返回 deptIds** - -编辑 `apps/server/src/integration/dingtalk.service.ts`,在 `fetchOrgTreeWithUsers` 方法中保留 `dept_id_list` 到每个 user。 - -找到第 493-498 行的 users mapping,改为: - -```ts -// Before dedup: collect deptIds per user -const userDeptMap = new Map(); - -nodes.push({ - id: detail.dept_id, - name: detail.name, - parentId: detail.parent_id, - children: [], - users: dingUsers.map((u) => ({ - userid: u.userid, - name: u.name, - mobile: u.mobile, - })), -}); - -// Record which departments each user belongs to -for (const u of dingUsers) { - if (!userDeptMap.has(u.userid)) { - userDeptMap.set(u.userid, []); - } - userDeptMap.get(u.userid)!.push(detail.dept_id); -} -``` - -然后在去重循环后(第 503-509 行),为每个 user 附加 deptIds: - -```ts -for (const node of nodes) { - node.users = node.users - .filter((u) => { - if (seenUserIds.has(u.userid)) return false; - seenUserIds.add(u.userid); - return true; - }) - .map((u) => ({ - ...u, - deptIds: userDeptMap.get(u.userid) || [], - })); -} -``` - -同步更新 `DingOrgTreeNodeWithUsers` interface: - -```ts -export interface DingOrgTreeNodeWithUsers { - id: number; - name: string; - parentId: number; - children: DingOrgTreeNodeWithUsers[]; - users: Array<{ - userid: string; - name: string; - mobile: string; - deptIds: number[]; - }>; -} -``` - -- [ ] **Step 3: 编译验证** - -```bash -cd apps/server && npx tsc --noEmit -``` - -Expected: no new type errors from the modified files. - -- [ ] **Step 4: Commit** - -```bash -git add apps/server/src/sync/dto/import-users.dto.ts apps/server/src/integration/dingtalk.service.ts -git commit -m "feat(sync): add ImportClassItemDto and expose deptIds in org-tree-with-users" -``` - ---- - -### Task 2: 后端 — 改造 importDingTalkUsers 支持班级关联 - -**Files:** -- Modify: `apps/server/src/sync/sync.service.ts:122-199` -- Modify: `apps/server/src/sync/sync.module.ts:16-34` - -**Interfaces:** -- Consumes: `ImportClassItemDto`, `ImportUserItemDto.dingDeptIds` (from Task 1) -- Produces: 改造后的 `importDingTalkUsers(classes?: ImportClassItemDto[], users: ImportUserItemDto[])`,返回增加 `classCount` - -- [ ] **Step 1: sync.module.ts — 注入 Class 和 ClassStudent Repository** - -SyncModule 当前未导入 `Class` 和 `ClassStudent` entity。编辑 `apps/server/src/sync/sync.module.ts`: - -```ts -import { - SyncLog, SyncState, UserDingMapping, ClassSchedule, Department, - UserDepartment, ClassTeacher, User, Student, Role, - Class, // 新增 - ClassStudent, // 新增 -} from '../entities'; -``` - -并在 `TypeOrmModule.forFeature` 数组中添加 `Class, ClassStudent`。 - -- [ ] **Step 2: sync.service.ts — constructor 注入新 repo** - -编辑 `apps/server/src/sync/sync.service.ts`: - -```ts -import { Class } from '../entities/class.entity'; -import { ClassStudent } from '../entities/class-student.entity'; -import type { ImportClassItemDto } from './dto/import-users.dto'; -``` - -Constructor 添加: - -```ts -@InjectRepository(Class) -private readonly classRepo: Repository, -@InjectRepository(ClassStudent) -private readonly classStudentRepo: Repository, -``` - -更新 `ImportUserDto` 接口以包含 `dingDeptIds`: - -```ts -export interface ImportUserDto { - dingUserId: string; - name: string; - mobile: string; - roleId: number | null; - dingDeptIds: number[]; -} -``` - -- [ ] **Step 3: 改写 importDingTalkUsers 方法签名和逻辑** - -将方法签名改为: - -```ts -async importDingTalkUsers( - users: ImportUserDto[], - classes?: ImportClassItemDto[], -): Promise<{ - teacherCount: number; - studentCount: number; - classCount: number; - skipped: number; - warnings: string[]; -}> -``` - -完整方法体替换为单事务版本: - -```ts -async importDingTalkUsers( - users: ImportUserDto[], - classes?: ImportClassItemDto[], -): Promise<{ - teacherCount: number; - studentCount: number; - classCount: number; - skipped: number; - warnings: string[]; -}> { - const classItems = classes ?? []; - const warnings: string[] = []; - - // 预检查班级编码重复 - if (classItems.length > 0) { - const codes = classItems.map((c) => c.code); - const existing = await this.classRepo.find({ where: codes.map((code) => ({ code } as any)) }); - if (existing.length > 0) { - const dup = existing.map((c) => c.code).join(', '); - throw new BadRequestException(`班级编码已存在: ${dup}`); - } - } - - let teacherCount = 0; - let studentCount = 0; - let skipped = 0; - - await this.dataSource.transaction(async (manager) => { - // 1. 创建班级 - const deptClassMap = new Map(); // deptId -> classId - for (const c of classItems) { - const cls = manager.create(Class, { - name: c.name, - code: c.code, - classType: c.classType, - startDate: c.startDate ?? null, - endDate: c.endDate ?? null, - maxStudents: c.maxStudents ?? 0, - notes: c.notes ?? null, - } as any); - await manager.save(cls); - deptClassMap.set(c.deptId, cls.id); - } - - // 2. 导入用户(逐用户) - for (const u of users) { - const existingMapping = await manager.findOne(UserDingMapping, { - where: { dingUserId: u.dingUserId }, - }); - if (existingMapping) { - skipped++; - continue; - } - - const username = `dd_${u.dingUserId}`; - const passwordHash = await bcrypt.hash('123456', 10); - - const user = manager.create(User, { - username, - name: u.name, - passwordHash, - isActive: true, - }); - await manager.save(user); - - let isTeacher = false; - let isHeadTeacher = false; - - if (u.roleId != null) { - const role = await manager.findOne(Role, { where: { id: u.roleId } }); - if (!role) { - throw new BadRequestException(`角色 id=${u.roleId} 不存在`); - } - user.roles = [role]; - let isTeacher = false; - - if (u.roleId != null) { - const role = await manager.findOne(Role, { where: { id: u.roleId } }); - if (!role) { - throw new BadRequestException(`角色 id=${u.roleId} 不存在`); - } - user.roles = [role]; - await manager.save(user); - isTeacher = true; - teacherCount++; - } else { - const student = manager.create(Student, { - name: u.name, - phone: u.mobile || undefined, - userId: user.id, - status: 'active', - }); - await manager.save(student); - studentCount++; - } - - // 钉钉映射 - const mapping = manager.create(UserDingMapping, { - dingUserId: u.dingUserId, - userId: user.id, - dingName: u.name, - dingMobile: u.mobile, - }); - await manager.save(mapping); - - // 3. 建立班级关联 - if (classItems.length > 0 && u.dingDeptIds?.length > 0) { - for (const deptId of u.dingDeptIds) { - const classId = deptClassMap.get(deptId); - if (!classId) continue; - - if (isTeacher) { - const ct = manager.create(ClassTeacher, { - classId, - userId: user.id, - roleType: 'teacher', - } as any); - await manager.save(ct); - } else { - const cs = manager.create(ClassStudent, { - classId, - studentId: (await manager.findOne(Student, { where: { userId: user.id } }))?.id, - status: 'active', - } as any); - await manager.save(cs); - } - } - } - } - - // 4. 检查空班级 - for (const [deptId, classId] of deptClassMap) { - const tc = await manager.count(ClassTeacher, { where: { classId } }); - const sc = await manager.count(ClassStudent, { where: { classId } }); - if (tc === 0 && sc === 0) { - const cls = await manager.findOne(Class, { where: { id: classId } }); - warnings.push(`班级 "${cls?.name}" (deptId=${deptId}) 无任何师生`); - } - } - }); - - this.logger.log( - `钉钉用户导入完成: ${teacherCount} 位老师, ${studentCount} 位学生, ${classItems.length} 个班级, ${skipped} 跳过`, - ); - return { teacherCount, studentCount, classCount: classItems.length, skipped, warnings }; -} -``` - -需要新增 import: - -```ts -import { BadRequestException } from '@nestjs/common'; -import { Class } from '../entities/class.entity'; -import { ClassStudent } from '../entities/class-student.entity'; -``` - -- [ ] **Step 4: 编译验证** - -```bash -cd apps/server && npx tsc --noEmit -``` - -Expected: no errors. - -- [ ] **Step 5: 更新 sync.controller.ts 调用方式** - -`apps/server/src/sync/sync.controller.ts` 第 57 行: - -```ts -const result = await this.syncService.importDingTalkUsers(body.users); -``` - -改为: - -```ts -const result = await this.syncService.importDingTalkUsers(body.users, body.classes); -``` - -- [ ] **Step 6: Commit** - -```bash -git add apps/server/src/sync/sync.module.ts apps/server/src/sync/sync.service.ts apps/server/src/sync/sync.controller.ts -git commit -m "feat(sync): importDingTalkUsers supports class creation and teacher/student linking" -``` - ---- - -### Task 3: 前端 — IntegrationConfig 树节点和班级标记 Modal - -**Files:** -- Modify: `apps/admin/src/pages/IntegrationConfig/index.tsx` - -**Interfaces:** -- Consumes: 改造后的 `POST /sync/dingtalk/import-users` (classes + users),DingOrgTreeNodeExt 新增 deptIds -- Produces: 树中部门节点可标为班级,Modal 表单,导入 payload 含 classes - -**Skills to load before coding:** -- `ui-ux-pro-max` — Ant Design 6 组件选型、交互细节 -- `vercel-react-best-practices` — memo、useMemo 避免无意义重渲染 - -- [ ] **Step 1: 扩展前端类型定义** - -在 `IntegrationConfig/index.tsx` 的 interface 定义区域,修改 `DingOrgTreeNodeExt`: - -```ts -interface DingOrgTreeNodeExt { - id: number; - name: string; - parentId: number; - children: DingOrgTreeNodeExt[]; - users: Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>; -} -``` - -新增 class mark 表单类型和状态: - -```ts -interface ClassMarkForm { - deptId: number; - name: string; - code: string; - classType: string; - startDate?: string; - endDate?: string; - maxStudents?: number; - notes?: string; -} -``` - -在组件 state 区域(第 91-101 行附近)新增: - -```ts -const [classMarks, setClassMarks] = useState>({}); -const [classModalOpen, setClassModalOpen] = useState(false); -const [classModalDept, setClassModalDept] = useState<{ id: number; name: string } | null>(null); -const [classForm] = Form.useForm(); -``` - -- [ ] **Step 2: 标记班级 Modal 组件** - -在组件内部(`handleImportUsers` 之前)添加 Modal 处理函数: - -```ts -const openClassModal = (deptId: number, deptName: string) => { - const existing = classMarks[deptId]; - if (existing) { - classForm.setFieldsValue(existing); - } else { - classForm.setFieldsValue({ - deptId, - name: deptName, - code: '', - classType: 'culture', - }); - } - setClassModalDept({ id: deptId, name: deptName }); - setClassModalOpen(true); -}; - -const handleClassModalOk = async () => { - const values = await classForm.validateFields(); - setClassMarks((prev) => ({ - ...prev, - [values.deptId]: values, - })); - setClassModalOpen(false); - setClassModalDept(null); -}; - -const handleClassModalCancel = () => { - setClassModalOpen(false); - setClassModalDept(null); -}; -``` - -Modal JSX(放在 Drawer 之前或之后): - -```tsx - - - - - - - - - - -