610 lines
28 KiB
Markdown
610 lines
28 KiB
Markdown
# 通用系统管理脚手架 · 开发帮助手册
|
||
|
||
> 面向 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)、暗黑模式、主题色、多页签(keep-alive 状态保留)、面包屑、全屏 |
|
||
| **平台能力** | 多租户隔离(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 # 拉取 ec-nest / ec-admin
|
||
|
||
# 后端
|
||
cd sources/ec-nest
|
||
cp .env.example .env # 若无 .env.example 则手建,见下
|
||
pnpm install
|
||
npx prisma migrate deploy # 应用已有迁移(开发期可用 migrate dev)
|
||
pnpm seed # 初始化租户/用户/角色/资源/内置字典
|
||
|
||
# 前端
|
||
cd ../ec-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/ec-nest && pnpm start:dev
|
||
|
||
# 前端(Vite,直连 VITE_API_BASE_URL,默认 http://localhost:3000/api/v1,见 .env)
|
||
cd sources/ec-admin && pnpm dev:dev
|
||
```
|
||
|
||
### 1.5 默认账号
|
||
|
||
| 项 | 值 |
|
||
|---|---|
|
||
| 租户 | `touyun` |
|
||
| 用户名 | `touyun` |
|
||
| 密码 | `Touyun@123` |
|
||
| 角色 | `super_admin`(超管,绕过权限校验、数据权限看全量) |
|
||
|
||
---
|
||
|
||
## 2. 后端开发指南(ec-nest)
|
||
|
||
### 2.1 目录与分层
|
||
|
||
```
|
||
src/
|
||
├── auth/ # 认证:登录/刷新/登出、菜单树、JWT 守卫、权限守卫、装饰器、请求上下文
|
||
├── users/ roles/ resources/ orgs/ dicts/ configs/ tenants/ audit-logs/ login-logs/ sessions/
|
||
│ # 每个域一个目录:<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`](../sources/ec-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` 关联可见部门。
|
||
|
||
**资源注册(新模块接 RBAC)**:菜单/接口/按钮资源的**单一来源**是 [`src/common/tenant-template.ts`](../sources/ec-nest/src/common/tenant-template.ts) 的 `MENUS` / `APIS` / `BUTTONS`,权限码来源是 [`src/common/permissions.ts`](../sources/ec-nest/src/common/permissions.ts)。新增模块三步:
|
||
|
||
1. `permissions.ts` 加 `domain:action` 权限码;
|
||
2. `tenant-template.ts` 的 `MENUS`/`APIS`/`BUTTONS` 加资源(`parent` 挂到上级菜单 code);
|
||
3. 存量租户跑 `pnpm sync:resources` 补齐(幂等,不触碰用户/密码);新租户经 `createTenant` 自动带全。
|
||
|
||
### 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/ec-nest/src/common/enums.ts) 一一对应,由 seed 预置(`common_status` / `user_status` / `resource_type` / `data_scope` / `login_status`)。
|
||
|
||
### 2.7 seed 与资源同步
|
||
|
||
`prisma/seed.ts` 只初始化平台租户 `touyun`(租户/用户/角色/资源/字典)。资源与字典的真实单一来源在 `tenant-template.ts` 的 `MENUS`/`APIS`/`BUTTONS`/`DICTS`,`seed` 与 `createTenant` 都调用 `initTenant`;新增资源后对**存量租户**跑 `pnpm sync:resources` 补齐。
|
||
|
||
### 2.8 账号与会话安全
|
||
|
||
- **首登强制改密**:`user.must_change_password` 标记临时密码(管理员创建默认强制、重置密码、建租户随机密码);登录响应返回 `mustChangePassword`,`JwtAuthGuard` 对未改密用户**只放行** `PATCH /auth/me/password`,其余接口 403。
|
||
- **账号锁定**:`user.locked_at` / `lock_reason` 独立于 `status`(停用);管理员锁定即踢全部会话,解锁同时清登录失败计数。
|
||
- **在线会话**:会话 = `refresh_token`,`sid` 标识单次登录(刷新轮换沿用同一 `sid`);`GET /sessions` 列活跃会话,`POST /sessions/:sid/kick` 单会话强退(吊销 refresh + sid 黑名单),`POST /users/:id/kick-sessions` 踢全部(复用 `tokenVersion`)。
|
||
|
||
### 2.9 定时任务
|
||
|
||
用 `@nestjs/schedule` 的 `@Cron` 声明定时任务;`ScheduleModule.forRoot()` 已在 `app.module.ts` 注册。现有任务:[`src/common/cleanup.service.ts`](../sources/ec-nest/src/common/cleanup.service.ts) 每天 03:07 清理过期/吊销的 refresh token 与超过 90 天的审计/登录日志。
|
||
|
||
**新增定时任务**:
|
||
|
||
1. 新建 service,方法挂 `@Cron('秒 分 时 日 月 周')`(cron 表达式,如 `@Cron('0 3 * * *')` 每天 03:00);
|
||
2. 在 `app.module.ts` 的 `providers` 注册该 service;
|
||
3. 任务体内 `try/catch` 并记录日志(失败不抛出,避免 Cron 中断)。
|
||
|
||
> 本期定时任务仅清理一个,暂不做「任务注册表 + 界面手动触发」;有需要再扩展。
|
||
|
||
---
|
||
|
||
## 3. 前端开发指南(ec-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<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`)管理请求状态:
|
||
|
||
```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/**` 目录自动生成;页面用 `<route lang="yaml">` 自定义块声明 `meta.layout`(`default` | `blank`)选择布局:
|
||
|
||
```vue
|
||
<route lang="yaml">
|
||
meta:
|
||
layout: blank
|
||
requiresAuth: false
|
||
</route>
|
||
```
|
||
- 菜单从后端 `GET /auth/menus` 拉取(超管=全部启用菜单,普通用户=角色可访问菜单+祖先),存 `menu` store。
|
||
- 页签存 `tabs` store,登出/401 清空。
|
||
- **多页签 keep-alive**:布局用 `<keep-alive :include>` 缓存打开页签的组件,切换保留状态、关闭销毁;页面默认缓存,在 `<route>` 里声明 `meta.keepAlive: false` 可关闭。**每个菜单页面须 `defineOptions({ name })`**(name = 路径末段,如 `user`/`login-log`/`dashboard`),作为缓存标识;**name 须与路径末段一致且全局唯一**(避免未来同末段路径互相影响 keep-alive)。
|
||
|
||
### 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<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'` 时的树数据与字段映射 |
|
||
|
||
> `type:'boolean'` 渲染为开关(el-switch),配合 `default:true/false` 设定默认值;布尔字段常用 `showOn: ['create']` 只在新增时出现。
|
||
|
||
```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<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` | 自定义格式化 |
|
||
|
||
```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<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()`。
|
||
|
||
```vue
|
||
<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 />` 需挂载一次作为宿主(通常已在根组件)。
|
||
|
||
```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<string, any>` | 传给内容组件的 props |
|
||
| `confirmText` / `cancelText` | `string` | 按钮文案 |
|
||
| `showFooter` | `boolean` | 默认显示取消/确定;`false` 时内容组件自带底部 |
|
||
| `onConfirm` | `(data) => void\|Promise` | 确认回调 |
|
||
|
||
#### 3.6.6 useRequest
|
||
|
||
```ts
|
||
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
|
||
|
||
```ts
|
||
useDict(code) // → { items, tagMap, options },均响应式
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 联调契约
|
||
|
||
### 4.1 响应封装
|
||
|
||
```json
|
||
{ "code": 0, "message": "ok", "data": { } }
|
||
```
|
||
|
||
- `code=0` 成功;失败 `code` 为业务码(5 位 `[域码 2 位][序 3 位]`,单一注册表见 [`src/common/error-codes.ts`](../sources/ec-nest/src/common/error-codes.ts),如 `10001` 账号或密码错误、`11002` 用户名已存在);未登记业务码的异常回退 `HTTP状态码 × 100`(40000/40100/40300/40400/40900/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. **资源注册**:`src/common/tenant-template.ts` 的 `APIS` 加 4 条接口资源(`code=权限码`、`method`、`path`、`parent='post'`),`MENUS` 加菜单节点(`parent`、`path`、`component`、`icon`)。
|
||
8. 存量租户补资源:`pnpm sync:resources`(幂等);新租户 `createTenant` 自动带全。
|
||
|
||
**前端**
|
||
1. `src/api/post.ts` 加类型 + `listPosts/createPost/updatePost/deletePost`。
|
||
2. `src/pages/system/post/index.vue`:`<script setup>` 顶部加 `defineOptions({ name: 'post' })`(keep-alive 缓存标识),用 `ty-crud` 组装(列用 `dictCode`,表单用 JSON Schema,路由由文件路径自动生成)。
|
||
3. 若该页不要缓存,加 `<route lang="yaml">meta: keepAlive: false</route>`。
|
||
|
||
**授权验证**
|
||
1. 用超管登录「角色管理」给某角色分配 post 菜单 + 接口资源。
|
||
2. 切到该角色的用户登录,应看到「岗位管理」菜单、能操作;无权限用户看不到。
|
||
|
||
### 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. `tenant-template.ts` 的 `APIS`(接口)或 `BUTTONS`(纯前端按钮)加对应资源;存量租户跑 `pnpm sync:resources`。
|
||
3. 后端 controller 用 `@RequirePermissions(...)`;前端按钮用 `v-permission` 或资源 code 下发渲染。
|
||
|
||
### 5.4 新增租户
|
||
|
||
「系统管理 → 租户管理」新增;或 seed 里 upsert。租户之间 `tenant_code` 隔离,登录时按账号+租户识别当前租户。
|
||
|
||
### 5.5 扩展资源 / 字典
|
||
|
||
改 `tenant-template.ts` 的 `MENUS` / `APIS` / `BUTTONS` / `DICTS` 常量;新租户自动生效,存量租户跑 `pnpm sync:resources` 补齐(幂等,重复运行不重复插入)。
|
||
|
||
---
|
||
|
||
## 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/排序/颜色可改(有意为之) |
|
||
| 首登登录后所有接口 403 | `must_change_password=true`,需先改密:仅 `PATCH /auth/me/password` 放行,前端会强制跳改密页 |
|
||
| 强退后 token 仍短暂可用 | access 最长 2h 内失效(sid 黑名单);refresh 已被吊销无法续期 |
|
||
| 切页签数据没保留 | 该页声明了 `meta.keepAlive:false`,或页面缺 `defineOptions({ name })` 导致缓存标识失效 |
|
||
|
||
---
|
||
|
||
## 8. 升级与定制(fork 后如何同步上游)
|
||
|
||
脚手架是「通用底座」,fork 成具体项目后,**业务代码和脚手架代码必须物理分离**,才能在上游升级时干净合并。
|
||
|
||
### 8.1 升级同步(git upstream + merge)
|
||
|
||
```bash
|
||
git clone <脚手架地址> my-project
|
||
cd my-project
|
||
git remote rename origin upstream # 脚手架 = upstream
|
||
git remote add origin <我的业务仓库地址> # 业务 = origin
|
||
# 日常在 main 上开发业务,升级时:
|
||
git fetch upstream
|
||
git merge upstream/main # 冲突只在「你改过脚手架文件」的地方出现
|
||
```
|
||
|
||
- 业务代码**纯新增**(新模块/新页面/新资源)→ 升级几乎零冲突。
|
||
- 业务代码**改了脚手架文件** → 上游升级该文件即冲突,改得越深越痛。
|
||
- 将来项目变多、需要版本语义时,再评估把脚手架抽成 npm 包 / monorepo 依赖(彻底隔离、按版本升级)。
|
||
|
||
### 8.2 可定制面(三条铁律)
|
||
|
||
| 铁律 | 说明 |
|
||
|---|---|
|
||
| **框架代码只读** | 脚手架自带模块/页面/服务(`users`/`roles`/`system/*`/`common/*`/`ty-*`…)只读不改 |
|
||
| **业务代码落点** | 新增放独立业务目录:后端新域 `src/<domain>/`,前端新页面 `src/pages/<biz>/`,不侵入框架目录 |
|
||
| **定制走扩展点** | 外观/权限优先走数据驱动扩展点:租户品牌(`tenant.brand_*`)、字典、主题色、资源注册(`tenant-template.ts` + `pnpm sync:resources`),而非改源码 |
|