# 通用系统管理脚手架 · 开发帮助手册 > 面向 fork 本工程进行二次开发的工程师。读完能跑起来、能看懂结构、能照着新增功能。 > 更底层的架构与 spec 约定见 [`docs/architecture.md`](./architecture.md)、[`docs/spec-conventions.md`](./spec-conventions.md),工作流见根目录 [`CLAUDE.md`](../CLAUDE.md)。 --- ## 0. 功能介绍与功能清单 一套可 fork 的「通用系统管理脚手架」:开箱即用后台基础能力,fork 后只改业务、不重复造轮子。后端 NestJS + Prisma + MySQL,前端 Vue 3 + Vite + Pinia + UnoCSS。 | 功能块 | 说明 | |---|---| | **认证** | 登录 / 登出;JWT(access 2h + refresh 7d),401 自动刷新;注册/忘记密码默认隐藏(`showRegister`/`showForgetPassword` 可开启) | | **权限管理** | 用户(增删改/重置密码/启停/锁定)、角色(分配资源 + 数据权限)、资源(菜单/按钮/接口统一资源树)、组织(部门树) | | **系统管理** | 租户、字典(类型 + 项,支持内置保护)、参数配置(键值) | | **系统监控** | 操作日志、登录日志(只读查询) | | **布局与主题** | 多布局(左右 / 上下 / 纯上下 + MixBar)、暗黑模式、主题色、多页签、面包屑、全屏 | | **平台能力** | 多租户隔离(tenantCode)、RBAC(资源表统一授权 + 数据权限 data_scope)、审计日志落库、字典驱动渲染 | **代码规范**:本工程用 openspec 追踪需求与变更(spec-driven),新增功能走 `propose → apply → archive` 流程,见 [`CLAUDE.md`](../CLAUDE.md)。本文只讲「代码怎么改」,不讲「流程怎么走」。 --- ## 1. 快速开始 ### 1.1 环境要求 | 依赖 | 版本 | |---|---| | Node | 22.23.1(以 `.nvmrc` / `engines` 为准) | | pnpm | 12.10.1(以 `packageManager` 为准) | | MySQL | 8.x | | Redis | 6+ | ### 1.2 拉取与安装 ```bash git clone <仓库地址> && cd <目录> git submodule update --init --recursive # 拉取 sysmgr-nest / sysmgr-admin # 后端 cd sources/sysmgr-nest cp .env.example .env # 若无 .env.example 则手建,见下 pnpm install npx prisma migrate deploy # 应用已有迁移(开发期可用 migrate dev) pnpm seed # 初始化租户/用户/角色/资源/内置字典 # 前端 cd ../sysmgr-admin pnpm install ``` ### 1.3 `.env`(后端) ```env DATABASE_URL="mysql://root:密码@127.0.0.1:3306/sysmgr" REDIS_HOST="127.0.0.1" REDIS_PORT=6379 REDIS_DB=0 REDIS_KEY_PREFIX="sysmgr:" JWT_SECRET="请替换为强密钥(生产至少 32 位)" JWT_ACCESS_TTL=7200 JWT_REFRESH_TTL=604800 PORT=3000 ``` ### 1.4 启动 ```bash # 后端(:3000,全局前缀 /api/v1) cd sources/sysmgr-nest && pnpm start:dev # 前端(Vite,直连 VITE_API_BASE_URL,默认 http://localhost:3000/api/v1,见 .env) cd sources/sysmgr-admin && pnpm dev:dev ``` ### 1.5 默认账号 | 项 | 值 | |---|---| | 租户 | `touyun` | | 用户名 | `touyun` | | 密码 | `Touyun@123` | | 角色 | `super_admin`(超管,绕过权限校验、数据权限看全量) | --- ## 2. 后端开发指南(sysmgr-nest) ### 2.1 目录与分层 ``` src/ ├── auth/ # 认证:登录/刷新/登出、菜单树、JWT 守卫、权限守卫、装饰器、请求上下文 ├── users/ roles/ resources/ orgs/ dicts/ configs/ tenants/ audit-logs/ login-logs/ │ # 每个域一个目录:.module.ts / controller / service / dto ├── prisma/ # PrismaService:统一注入多租户过滤 + 软删过滤 ├── common/ # BaseCrudService、enums、permissions、page.dto、拦截器/过滤器、Redis └── logging/ # pino 日志、请求体日志拦截器 ``` 每个域遵循同构结构(以 `tenants` 为例): ``` tenants/ ├── tenants.module.ts # 声明 controller + service ├── tenants.controller.ts # 路由 + @RequirePermissions + @AuditLog ├── tenants.service.ts # extends BaseCrudService,写业务 └── dto.ts # class-validator DTO(创建/更新/查询) ``` ### 2.2 Prisma 约定 - 表名 `sys_` 前缀,列名 snake_case(模型 camelCase + `@map`)。 - 主键 UUID v7:`id String @id @default(uuid(7)) @db.VarChar(36)`。 - 业务键关联用 `code`(角色/资源/组织/字典/租户),引用用户用 `id`,树引用用 `parent_code`。 - 业务软删实体带 `created_at / created_by / updated_at / deleted_at`;日志/会话实体无 `deleted_at`。 - 多租户:业务实体一律带 `tenant_code`。 ### 2.3 BaseCrudService + PrismaService 所有域 service 继承 `BaseCrudService(prisma, '模型名')`,直接获得: | 方法 | 说明 | |---|---| | `paginate(page, pageSize, where?, orderBy?)` | 分页,返回 `{ list, total, page, pageSize }` | | `list(where?, orderBy?)` | 列表 | | `findById(id)` | 按 id 查 | | `create(data)` | 创建,自动注入 `tenantCode`(Tenant 除外) | | `update(id, data)` | 更新 | | `softRemove(id)` | 软删(置 `deletedAt`) | `PrismaService` 通过 `$extends` 全局拦截,**无需手写**: - 读操作自动追加 `tenantCode` 过滤(`Tenant` 模型除外); - 软删模型(User/Role/Resource/Org/DictType/DictItem/Config)读操作自动追加 `deletedAt: null`。 > 因此业务 service 里写 `this.list({ status: 'enabled' })` 即可,租户与软删已由框架兜底。 ### 2.4 RBAC:资源表统一授权 - 菜单、按钮、接口统一在 `resource` 表,`type ∈ { menu, button, api }`。 - `api`/`button` 型资源的 `code` 即**权限码**,形如 `domain:action`(`user:list`、`user:create`)。 - 权限码清单在 [`src/common/permissions.ts`](../sources/sysmgr-nest/src/common/permissions.ts),控制器与 seed 共用。 **三级鉴权**(由全局 `JwtAuthGuard` + `PermissionsGuard` 实现): | 层级 | 写法 | 效果 | |---|---|---| | 公开 | `@Public()` | 无需 token | | 仅登录 | 不加权限装饰器 | 只要登录态 | | 权限码 | `@RequirePermissions(Permissions.user.list)` | 登录 + 拥有该权限码 | ```ts @Get() @RequirePermissions(Permissions.user.list) // 登录 + user:list list(@Query() q: PageQuery) { return this.service.paginate(q.page, q.pageSize) } ``` - 角色 `code === 'super_admin'` 自动绕过权限码校验、`data_scope` 看全量。 - 数据权限 `role.data_scope ∈ { all, dept, dept_and_child, self, custom }`,`custom` 时经 `role_org` 关联可见部门。 ### 2.5 装饰器与拦截器 | 项 | 用途 | |---|---| | `@Public()` | 跳过登录校验 | | `@RequirePermissions(...codes)` | 要求权限码 | | `@AuditLog('模块', '动作')` | 写操作自动落 `sys_audit_log`(异步,失败不阻塞业务) | | `@CurrentUser` | 取当前登录用户 | 全局拦截器/过滤器(`app.module.ts` 注册):`TransformInterceptor`(响应封装 `{code,message,data}`)、`AuditLogInterceptor`(审计)、`AllExceptionsFilter`(异常→`code = HTTP*100`)。 ### 2.6 字典域(内置字典 + 保护) - 字典类型/字典项在 `dicts/`;内置字典 `built_in=true`。 - **保护规则**:内置类型不可删、内置项不可删、内置项 `value` 不可改;`label/sort/color_type` 可改;内置类型下可新增非内置项。 - `GET /dicts`(仅登录态)一次返回当前租户全部启用类型 + 启用项,供前端缓存。 - 内置字典取值与 [`src/common/enums.ts`](../sources/sysmgr-nest/src/common/enums.ts) 一一对应,由 seed 预置(`common_status` / `user_status` / `resource_type` / `data_scope` / `login_status`)。 ### 2.7 seed 脚本 `prisma/seed.ts`:幂等 upsert 租户 `touyun`、用户、`super_admin` 角色、全量资源树(菜单+接口+按钮)、内置字典。新增资源/字典/权限码时同步补进 seed。 --- ## 3. 前端开发指南(sysmgr-admin) ### 3.1 目录与自动导入 ``` src/ ├── api/ # http.ts(axios 封装)+ 每域 api(user/role/resource/org/dict/...) ├── components/ # ty-* 组件库(全局自动导入,无需 import) ├── composables/ # useRequest、useDict ├── config/ # layout.ts(布局设置)、login.ts(登录页配置)、menu.ts(兜底菜单) ├── layouts/ # default.vue(后台壳)、blank.vue(登录页等) ├── pages/ # 页面(unplugin-vue-router 按目录自动生成路由) ├── stores/modules/ # Pinia:app/auth/menu/tabs/dict ├── types/ # crud.ts(JsonSchema/TableColumn 等)、resource.ts └── utils/ # dialog.ts(showDialog)、tree.ts、token.ts、theme.ts ``` 已配置 `unplugin-auto-import` + `unplugin-vue-components`:Vue API、Element Plus、`@/components` 下的组件**均无需手动 import**。 ### 3.2 网络请求:axios 封装 + useRequest 统一用全局 `http`(`@/api/http`): ```ts const data = await http.get('/users', { params: { page: 1 } }) // 自动解包,直接拿 data ``` - 自动注入 `Authorization`;响应自动拆 `{code,message,data}`(`code=0` 返回 `data`);`401` 自动刷新 token 并重放;失败抛 `ApiError`。 - 方法:`http.get/post/put/patch/delete/request(url, data?, config?)`。 `useRequest`(`@/composables/useRequest`)管理请求状态: ```ts const { data, loading, run, refresh } = useRequest((signal: AbortSignal) => listUsers(q, signal)) // 或手动触发:useRequest(fetcher, { manual: true }) → run(...args) ``` ### 3.3 字典:dict store + useDict + dictCode 登录后自动缓存全部启用字典,页面/组件通过 `dictCode` 或 `useDict` 读取,**不要硬编码中文/颜色映射**。 ```ts const { items, tagMap, options } = useDict('common_status') // items : [{ label:'启用', value:'enabled', colorType:'success', sort:1 }, ...] // tagMap : { enabled: { label:'启用', type:'success' }, ... } // options: [{ label:'启用', value:'enabled' }, ...] ``` 在 JSON Schema / 表格列里直接写 `dictCode` 即可(见 §3.7 组件 API)。 ### 3.4 菜单 / 路由 / 布局 - 路由由 `unplugin-vue-router` 按 `src/pages/**` 目录自动生成;页面用 `` 自定义块声明 `meta.layout`(`default` | `blank`)选择布局: ```vue meta: layout: blank requiresAuth: false ``` - 菜单从后端 `GET /auth/menus` 拉取(超管=全部启用菜单,普通用户=角色可访问菜单+祖先),存 `menu` store。 - 页签存 `tabs` store,登出/401 清空。 ### 3.5 样式:UnoCSS @apply 优先用 `@apply` 工具类,少写裸 CSS。注意主题色工具类写法: ```scss .foo { @apply bg-bg-base text-text-primary; // 背景/文字主题色:bg-bg-* / text-text-* @apply border-solid rounded-base; // 语义色:primary/success/warning/danger/info } ``` ### 3.6 组件 API 文档 下面 5 个业务组件 + 2 个 composable 是日常开发主力。布局类组件(ty-header/ty-sidebar/ty-tabs 等)由 `layouts/default.vue` 组装,一般业务页无需直接使用。 #### 3.6.1 ty-form(JSON Schema 表单) **Props** | 属性 | 类型 | 默认 | 说明 | |---|---|---|---| | `schema` | `JsonSchema` | — | 必填,表单结构描述 | | `modelValue` | `Record` | — | 必填,表单数据(就地绑定) | | `inline` | `boolean` | `false` | 行内布局(搜索用) | | `mode` | `'create' \| 'edit'` | `undefined` | 配合字段 `showOn` 过滤 | | `enterToSubmit` | `boolean` | `false` | 输入框回车触发 `submit` | | `readonlyKeys` | `string[]` | `[]` | 强制只读的字段名 | **Expose(通过 `ref` 调用)** | 方法 | 说明 | |---|---| | `submit()` | 校验并返回表单数据(校验失败抛错) | | `validate()` | 仅校验 | | `resetFields()` | 重置 | | `clearValidate()` | 清空校验状态 | **Emits**:`submit`。 **JsonSchemaProperty 字段速查** | 字段 | 类型 | 说明 | |---|---|---| | `type` | `'string'\|'integer'\|'number'\|'boolean'` | 控件类型 | | `title` | `string` | 标签 | | `placeholder` | `string` | 占位 | | `enum` / `enumNames` | `(string\|number\|boolean)[]` / `string[]` | 下拉选项(一一对应) | | `dictCode` | `string` | 字典 code,声明后自动生成下拉(优先于 enum) | | `format` | `'password'\|'email'\|'textarea'\|'tree-select'\|'date'\|'datetime'` | 特殊控件;`date/datetime` 目前按普通输入框渲染 | | `default` | `unknown` | 默认值 | | `readOnly` | `boolean` | 只读 | | `showOn` | `('create'\|'edit')[]` | 仅指定模式显示 | | `when` | `{ field; equals?; in? }` | 依赖字段值联动显隐 | | `minLength`/`maxLength`/`minimum`/`maximum` | `number` | 校验 | | `prefixIcon` | `string` | 输入框前缀 iconify 图标 | | `width` | `string` | 控件宽度 | | `treeData`/`treeProps` | — | `format:'tree-select'` 时的树数据与字段映射 | ```ts const schema: JsonSchema = { type: 'object', properties: { status: { type: 'string', title: '状态', dictCode: 'common_status', default: 'enabled' }, path: { type: 'string', title: '路由', when: { field: 'type', equals: 'menu' } }, }, required: ['status'], } ``` #### 3.6.2 ty-search-form(搜索 + 折叠) **Props** | 属性 | 类型 | 默认 | 说明 | |---|---|---|---| | `schema` | `JsonSchema` | — | 搜索字段 | | `modelValue` | `Record` | — | 搜索条件 | | `collapseCount` | `number` | `3` | 折叠时显示字段数,超出「展开」 | **Emits**:`search`、`reset`。 #### 3.6.3 ty-table(表格) **Props** | 属性 | 类型 | 默认 | 说明 | |---|---|---|---| | `columns` | `TableColumn[]` | — | 列配置 | | `data` | `Record[]` | — | 行数据 | | `loading` | `boolean` | `false` | 加载态 | | `selection` | `boolean` | `false` | 多选列 | | `total` / `page` / `pageSize` / `pageSizes` | `number`/`number[]` | `0`/`1`/`10`/`[10,20,50]` | 分页 | | `actionsWidth` | `number\|string` | `200` | 操作列宽 | | `rowKey` | `string` | `'id'` | 行唯一键(树形用 code) | | `treeProps` | `Record` | `{children:'children'}` | 树形字段映射 | | `defaultExpandAll` | `boolean` | `false` | 树形默认展开 | **Emits**:`update:page`、`update:pageSize`、`selection-change`。 **Slots**:`actions`(作用域 `{ row }`)。 **TableColumn 字段** | 字段 | 类型 | 说明 | |---|---|---| | `prop` | `string` | 字段名 | | `label` | `string` | 列标题 | | `width` / `minWidth` | `number\|string` | 列宽 | | `align` | `'left'\|'center'\|'right'` | 对齐 | | `type` | `'text'\|'tag'\|'datetime'` | 渲染类型(默认 text) | | `tagMap` | `Record` | `type:'tag'` 的值→标签映射 | | `dictCode` | `string` | `type:'tag'` 时按字典渲染(优先于 tagMap) | | `formatter` | `(value, row) => string` | 自定义格式化 | ```ts const columns: TableColumn[] = [ { prop: 'status', label: '状态', width: 90, type: 'tag', dictCode: 'common_status' }, { prop: 'createdAt', label: '创建时间', width: 160, type: 'datetime' }, ] ``` #### 3.6.4 ty-crud(整页 CRUD) 组合「搜索 + 工具栏 + 表格 + 弹窗表单 + 删除」,多数单表页面用它几行搞定。 **Props** | 属性 | 类型 | 默认 | 说明 | |---|---|---|---| | `entityName` | `string` | — | 实体名(标题/确认文案) | | `columns` | `TableColumn[]` | — | 列 | | `formSchema` | `JsonSchema` | — | 新增/编辑表单 | | `searchSchema` | `JsonSchema` | — | 搜索(可选) | | `list` | `(query, signal?) => Promise` | — | 分页查询 | | `create` | `(data) => Promise` | — | 新增 | | `update` | `(id, data) => Promise` | — | 更新 | | `remove` | `(id) => Promise` | — | 删除 | | `idKey` / `nameKey` | `string` | `'id'`/`'name'` | 主键/删除确认显示字段 | | `editReadonlyKeys` | `string[]` | `[]` | 编辑只读字段 | | `getEditReadonlyKeys` | `(row) => string[]` | — | 按行计算只读(优先) | | `actions` | `CrudAction[]` | `['edit','delete']` | 操作列(内置或自定义 `{label,type,onClick,show}`) | | `collapseCount` | `number` | `2` | 操作按钮折叠数 | | `selection` | `boolean` | `false` | 多选(配合 `#toolbar-right`) | | `actionsWidth` | `number\|string` | `140` | 操作列宽 | **Slots**:`toolbar`、`toolbar-right`(作用域 `{ selectedRows }`)。 **Expose**:`reload()`。 ```vue ``` #### 3.6.5 showDialog / ty-dialog(弹窗) `showDialog(options)` 打开弹窗,返回 Promise(确认 resolve 内容组件 `submit()` 数据,取消/关闭 resolve `null`)。`` 需挂载一次作为宿主(通常已在根组件)。 ```ts const data = await showDialog({ component: TyForm, // 内容组件(通常传 TyForm) title: '新增用户', width: '520px', props: { schema, modelValue: model, mode: 'create' }, }) if (data == null) return // 取消 await createUser(data) ``` **DialogOptions** | 字段 | 类型 | 说明 | |---|---|---| | `component` | `Component` | 内容组件(须 expose `submit()`) | | `title` / `width` | `string` | 标题/宽度(默认 480px) | | `props` | `Record` | 传给内容组件的 props | | `confirmText` / `cancelText` | `string` | 按钮文案 | | `showFooter` | `boolean` | 默认显示取消/确定;`false` 时内容组件自带底部 | | `onConfirm` | `(data) => void\|Promise` | 确认回调 | #### 3.6.6 useRequest ```ts useRequest(fetcher, options?) // fetcher: (...args: [...TParams, AbortSignal]) => Promise ``` **Options**:`manual`(默认 false 自动请求)、`defaultParams`、`refreshDeps`(依赖变化重请求)、`onBefore/onSuccess/onError/onFinally`。 **返回**:`data`、`loading`、`error`、`run(...args)`、`refresh()`、`cancel()`、`mutate(data|fn)`。 #### 3.6.7 useDict ```ts useDict(code) // → { items, tagMap, options },均响应式 ``` --- ## 4. 联调契约 ### 4.1 响应封装 ```json { "code": 0, "message": "ok", "data": { } } ``` - `code=0` 成功;失败 `code = HTTP状态码 * 100`(401→40100,403→40300,400→40000,500→50000)。 - 前端 `http` 已自动拆包:成功拿 `data`,失败抛 `ApiError(code, message)`。 ### 4.2 分页 列表接口 `page/pageSize`,返回 `{ list, total, page, pageSize }`。 ### 4.3 三级鉴权 公开(`@Public`)→ 仅登录(无装饰器)→ 权限码(`@RequirePermissions`),见 §2.4。 ### 4.4 契约锚点 `shared/api-contract`(响应/错误码/分页/鉴权/接口目录)与 `shared/data-model`(字段字典)是前后端唯一锚点,字段/接口变更先改 `shared/`。 --- ## 5. 常用操作手册(Cookbook) ### 5.1 新增一个业务实体(含权限码 + 页面)完整走查 以新增「岗位 post」为例: **后端** 1. `prisma/schema.prisma` 加 `Post` 模型(`sys_post`,带 `tenant_code` + 公共字段)。 2. `npx prisma migrate dev --name add_post` + `prisma generate`。 3. `src/common/permissions.ts` 加 `post: { list, create, update, delete }`。 4. 新建 `src/posts/`(module/controller/service/dto),service `extends BaseCrudService`。 5. controller 挂 `@RequirePermissions(Permissions.post.xxx)` + 写操作 `@AuditLog('post', 'xxx')`。 6. `app.module.ts` 注册 `PostsModule`。 7. `prisma/seed.ts` 的 `APIS` 加 4 条接口资源(method/path/code),超管即全量授权。 **前端** 1. `src/api/post.ts` 加类型 + `listPosts/createPost/...`。 2. `src/pages/system/post/index.vue` 用 `ty-crud` 组装(列用 `dictCode`,表单用 JSON Schema)。 3. seed 的 `MENUS` 加菜单节点(path/component/icon),路由自动生成。 ### 5.2 新增一个字典类型 1. 登录进入「系统管理 → 字典管理」→ 新增字典类型(填 code/name)。 2. 选中类型 → 新增字典项(label/value/标签色/排序)。 3. 页面/组件里用 `dictCode: ''` 或 `useDict('')` 引用。 > 内置字典(seed 预置)的 `value` 与内置项受保护,只能改 label/排序/颜色;普通字典完全自由。 ### 5.3 新增一个权限码 / 按钮 1. `common/permissions.ts` 加 `domain.action`。 2. `seed.ts` 的 `APIS`(接口)或 `BUTTONS`(纯前端按钮)加对应资源,跑 `pnpm seed`。 3. 后端 controller 用 `@RequirePermissions(...)`;前端按钮权限后续按资源 code 下发渲染。 ### 5.4 新增租户 「系统管理 → 租户管理」新增;或 seed 里 upsert。租户之间 `tenant_code` 隔离,登录时按账号+租户识别当前租户。 ### 5.5 扩展 seed 改 `prisma/seed.ts` 的 `MENUS` / `APIS` / `BUTTONS` / `DICTS` 常量,跑 `pnpm seed`(幂等,重复运行不重复插入)。 --- ## 6. 规范与约定 - **提交信息**:Conventional Commits(`feat`/`fix`/`chore`/`docs`/`refactor`)。 - **命名**:后端模型 camelCase + `@map` snake_case;业务键统一 `code`;权限码 `domain:action`。 - **样式**:UnoCSS `@apply`,少写裸 CSS;主题色用 `bg-bg-*` / `text-text-*`。 - **自测后停服务**:冒烟测试临时起的服务,测完立即停掉。 - **单一事实源**:字段/接口以 `shared/data-model`、`shared/api-contract` 为准,不各自命名。 --- ## 7. 排障 FAQ | 现象 | 原因 / 处理 | |---|---| | 启动报 `EADDRINUSE :3000` | 端口被占,换 `PORT=3100` 或停掉旧进程 | | `prisma migrate dev` 非交互报错 | 用 `prisma migrate diff` 生成 SQL + `prisma migrate deploy`,或开发期用 `prisma db push` | | seed 重复插入报唯一约束 | 检查 upsert 的 `where` 是否用了唯一键(如 `tenantCode_code`) | | 接口返回 401 | token 缺失/过期;前端会自动刷新,刷新失败跳登录 | | 接口返回 403 | 权限码未授权;确认 resource 表登记了该接口、角色已分配 | | 字典下拉/标签不显示 | 确认登录后 `dictStore.fetch()` 成功;字典类型/项 `status=enabled`;`dictCode` 拼写一致 | | 改字典 label 页面没变 | 字典缓存未刷新:重新登录,或字典页保存后 `dictStore.fetch(true)` 强制刷新 | | 登录失败 | 确认 seed 已跑、账号 `touyun / Touyun@123`、租户 `touyun` 存在且 enabled | | 内置项 value 改不动 | 内置项受保护,仅 label/排序/颜色可改(有意为之) |