liuyun 0e257d444d docs: add developer guide for the scaffold
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 15:01:25 +08:00

22 KiB
Raw Blame History

通用系统管理脚手架 · 开发帮助手册

面向 fork 本工程进行二次开发的工程师。读完能跑起来、能看懂结构、能照着新增功能。 更底层的架构与 spec 约定见 docs/architecture.md、docs/spec-conventions.md,工作流见根目录 CLAUDE.md。


0. 功能介绍与功能清单

一套可 fork 的「通用系统管理脚手架」:开箱即用后台基础能力,fork 后只改业务、不重复造轮子。后端 NestJS + Prisma + MySQL,前端 Vue 3 + Vite + Pinia + UnoCSS。

功能块 说明
认证 登录 / 注册 / 忘记密码 / 登出;JWT(access 2h + refresh 7d),401 自动刷新
权限管理 用户(增删改/重置密码/启停/锁定)、角色(分配资源 + 数据权限)、资源(菜单/按钮/接口统一资源树)、组织(部门树)
系统管理 租户、字典(类型 + 项,支持内置保护)、参数配置(键值)
系统监控 操作日志、登录日志(只读查询)
布局与主题 多布局(左右 / 上下 / 纯上下 + MixBar)、暗黑模式、主题色、多页签、面包屑、全屏
平台能力 多租户隔离(tenant_id)、RBAC(资源表统一授权 + 数据权限 data_scope)、审计日志落库、字典驱动渲染

代码规范:本工程用 openspec 追踪需求与变更(spec-driven),新增功能走 propose → apply → archive 流程,见 CLAUDE.md。本文只讲「代码怎么改」,不讲「流程怎么走」。


1. 快速开始

1.1 环境要求

依赖 版本
Node 22.23.1(以 .nvmrc / engines 为准)
pnpm 12.10.1(以 packageManager 为准)
MySQL 8.x
Redis 6+

1.2 拉取与安装

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(后端)

DATABASE_URL="mysql://root:密码@127.0.0.1:3306/sysmgr"
REDIS_URL="redis://127.0.0.1:6379"
JWT_ACCESS_SECRET="..."
JWT_REFRESH_SECRET="..."
PORT=3000

1.4 启动

# 后端(: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/
│                  # 每个域一个目录:<name>.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,控制器与 seed 共用。

三级鉴权(由全局 JwtAuthGuard + PermissionsGuard 实现):

层级 写法 效果
公开 @Public() 无需 token
仅登录 不加权限装饰器 只要登录态
权限码 @RequirePermissions(Permissions.user.list) 登录 + 拥有该权限码
@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 一一对应,由 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):

const data = await http.get<User[]>('/users', { params: { page: 1 } })  // 自动解包,直接拿 data
  • 自动注入 Authorization;响应自动拆 {code,message,data}(code=0 返回 data);401 自动刷新 token 并重放;失败抛 ApiError。
  • 方法:http.get/post/put/patch/delete/request<T>(url, data?, config?)。

useRequest(@/composables/useRequest)管理请求状态:

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 读取,不要硬编码中文/颜色映射。

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/** 目录自动生成;页面用 <route lang="yaml"> 自定义块声明 meta.layout(default | blank)选择布局:
<route lang="yaml">
meta:
  layout: blank
  requiresAuth: false
</route>
  • 菜单从后端 GET /auth/menus 拉取(超管=全部启用菜单,普通用户=角色可访问菜单+祖先),存 menu store。
  • 页签存 tabs store,登出/401 清空。

3.5 样式:UnoCSS @apply

优先用 @apply 工具类,少写裸 CSS。注意主题色工具类写法:

.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<string, any> — 必填,表单数据(就地绑定)
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' 时的树数据与字段映射
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<string, any> — 搜索条件
collapseCount number 3 折叠时显示字段数,超出「展开」

Emits:search、reset。

3.6.3 ty-table(表格)

Props

属性 类型 默认 说明
columns TableColumn[] — 列配置
data Record<string, any>[] — 行数据
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<string,string> {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<string, {label,type}> type:'tag' 的值→标签映射
dictCode string type:'tag' 时按字典渲染(优先于 tagMap)
formatter (value, row) => string 自定义格式化
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<PageResult> — 分页查询
create (data) => Promise<unknown> — 新增
update (id, data) => Promise<unknown> — 更新
remove (id) => Promise<unknown> — 删除
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()。

<ty-crud
  entity-name="用户"
  :columns="columns"
  :search-schema="searchSchema"
  :form-schema="formSchema"
  :list="list" :create="create" :update="update" :remove="remove"
  id-key="id" name-key="username" :edit-readonly-keys="['username']"
/>

3.6.5 showDialog / ty-dialog(弹窗)

showDialog(options) 打开弹窗,返回 Promise(确认 resolve 内容组件 submit() 数据,取消/关闭 resolve null)。<ty-dialog /> 需挂载一次作为宿主(通常已在根组件)。

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<string, any> 传给内容组件的 props
confirmText / cancelText string 按钮文案
showFooter boolean 默认显示取消/确定;false 时内容组件自带底部
onConfirm (data) => void|Promise 确认回调

3.6.6 useRequest

useRequest<TData, TParams>(fetcher, options?)
// fetcher: (...args: [...TParams, AbortSignal]) => Promise<TData>

Options:manual(默认 false 自动请求)、defaultParams、refreshDeps(依赖变化重请求)、onBefore/onSuccess/onError/onFinally。 返回:data、loading、error、run(...args)、refresh()、cancel()、mutate(data|fn)。

3.6.7 useDict

useDict(code)  // → { items, tagMap, options },均响应式

4. 联调契约

4.1 响应封装

{ "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: '<code>' 或 useDict('<code>') 引用。

内置字典(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()
登录失败 确认 seed 已跑、账号 touyun / Touyun@123、租户 touyun 存在且 enabled
内置项 value 改不动 内置项受保护,仅 label/排序/颜色可改(有意为之)