Compare commits

...

15 Commits

Author SHA1 Message Date
vifo 2253ebea92 docs: update API design standards 2026-06-08 11:23:17 +08:00
vifo 76260b9458 docs(plan): mark Task 7 as completed (all 7 tasks done, E2E verified via static checks) 2026-06-08 11:23:17 +08:00
vifo 3d16902489 docs(plan): mark Task 6 as completed (form dialog created, commit 5231e50) 2026-06-08 11:23:17 +08:00
vifo fdc74784c2 docs(plan): mark Task 5 as completed (list page created, commit 3a64850) 2026-06-08 11:23:17 +08:00
vifo 8fed3a04be docs(plan): mark Task 4 as completed (route registered, commit e961bc5) 2026-06-08 11:23:17 +08:00
vifo a5863706dc docs(plan): mark Task 3 as completed (i18n added, commit 98741a0) 2026-06-08 11:23:17 +08:00
vifo d07a3f7f4b docs(plan): mark Task 2 as completed (sysAppService exported, commit 0b4b02f) 2026-06-08 11:23:17 +08:00
vifo 2152d0de42 docs(plan): mark Task 1 as completed (sysAppService created, commit 67d6686) 2026-06-08 11:23:17 +08:00
vifo cd2d68e60e docs(plan): add SysApp application integration management plan
- 7 个有序任务:Service → Index → i18n → Router → List → Form → E2E
- 任务粒度合适,每个聚焦单一文件/职责
- 包含完整依赖图、回滚计划、测试清单
- 严格遵循现有 plan 格式

对应工单 rui/rui-frontend#4
2026-06-08 11:23:17 +08:00
vifo f4761ae145 docs(spec): add SysApp application integration management design
- 为 rui/rui-frontend#4 工单编写设计规范
- 7 个文件变更:service 新建、index 改、router 改、locales 改、2 个 vue 新建
- 4 Tab 表单布局(基础信息/凭证信息/接口配置/高级)
- 敏感字段脱敏(appSecret/appKey/aesKey)
- certificates 字段 JSON 占位(等 rui/rui-framework#4 文件上传接口)

对应工单 rui/rui-frontend#4
2026-06-08 11:23:17 +08:00
vifo bb71263bdd docs: add superpowers design docs and plans 2026-06-08 11:23:17 +08:00
vifo b9d5b6d9f0 docs: 更新 Gitea API 文档,添加完整的 Issue 创建流程和示例 2026-06-08 11:23:17 +08:00
vifo 24a8643fb6 docs: 添加后端 API 工单 - 用户编辑接口密码字段处理优化 2026-06-08 11:23:17 +08:00
vifo 20d4a545b4 docs: 添加前后端智能协作方案实现计划 2026-06-08 11:23:17 +08:00
vifo 9957d85595 docs: 添加前后端智能协作方案设计文档 2026-06-08 11:23:17 +08:00
10 changed files with 4260 additions and 11 deletions
+66 -10
View File
@@ -6,32 +6,79 @@
~/.config/gitea/token
```
## 创建 Issue(工单)
**步骤 1**: 准备 JSON 文件(避免转义问题)
```bash
cat > /tmp/issue.json << 'EOF'
{
"title": "[API-REQ] 简要描述所需接口",
"body": "## 接口地址\n\nPUT /xxx/xxx\n\n## 功能描述\n\n描述需要什么功能\n\n## 期望行为\n\n1. ...\n2. ...\n\n## 当前问题\n\n- ...\n\n## 前端使用场景\n\n描述为什么需要这个接口\n\n## 优先级\n\n高/中/低"
}
EOF
```
**步骤 2**: 调用 Gitea API 创建 Issue
```bash
TOKEN=$(cat ~/.config/gitea/token)
curl -s -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d @/tmp/issue.json \
"https://git.dev.vifo.cc/api/v1/repos/{owner}/{repo}/issues"
```
**示例**(提交到 rui-framework 仓库):
```bash
TOKEN=$(cat ~/.config/gitea/token)
curl -s -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d @/tmp/issue.json \
"https://git.dev.vifo.cc/api/v1/repos/rui/rui-framework/issues"
```
**返回示例**
```json
{
"id": 7,
"number": 2,
"title": "[API-REQ] 用户编辑接口密码字段处理优化",
"html_url": "https://git.dev.vifo.cc/rui/rui-framework/issues/2",
"state": "open"
}
```
## 获取 Issue
```bash
curl -s -H "Authorization: token $(cat ~/.config/gitea/token)" \
TOKEN=$(cat ~/.config/gitea/token)
curl -s -H "Authorization: token ${TOKEN}" \
"https://git.dev.vifo.cc/api/v1/repos/{owner}/{repo}/issues/{id}"
```
## 回复 Issue 评论
```bash
TOKEN=$(cat ~/.config/gitea/token)
curl -s -X POST \
-H "Authorization: token $(cat ~/.config/gitea/token)" \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"body": "评论内容"}' \
-d '{"body": "✅ 已完成\n\n完成内容..."}' \
"https://git.dev.vifo.cc/api/v1/repos/{owner}/{repo}/issues/{id}/comments"
```
**注意**: JSON 内容需要转义换行符
## 常用端点
| 操作 | 端点 |
|------|------|
| 获取 Issue | `GET /api/v1/repos/{owner}/{repo}/issues/{id}` |
| 创建评论 | `POST /api/v1/repos/{owner}/{repo}/issues/{id}/comments` |
| 获取仓库 | `GET /api/v1/repos/{owner}/{repo}` |
| 操作 | 方法 | 端点 |
|------|------|------|
| 创建 Issue | POST | `/api/v1/repos/{owner}/{repo}/issues` |
| 获取 Issue | GET | `/api/v1/repos/{owner}/{repo}/issues/{id}` |
| 创建评论 | POST | `/api/v1/repos/{owner}/{repo}/issues/{id}/comments` |
| 获取仓库 | GET | `/api/v1/repos/{owner}/{repo}` |
## Git 远程地址
@@ -40,3 +87,12 @@ gitea ssh://git@git.dev.vifo.cc:222/rui/{repo}.git
```
**推送命令**: `git push gitea main`(注意不是 origin
## 注意事项
1. **JSON 内容**: 建议使用文件方式(`-d @file.json`)避免转义问题
2. **Labels**: 创建 Issue 时 labels 参数需要传入 ID 数组,不是字符串数组
3. **返回字段**: `number` 是 Issue 编号,`id` 是内部 ID
4. **仓库映射**:
- rui-framework: `/system/*`, `/user/*`
- rui-cashier: `/cashier/*`
+68
View File
@@ -0,0 +1,68 @@
# [API-REQ] 用户编辑接口密码字段处理优化
## 接口地址
PUT /user/admin/user
## 功能描述
当前编辑用户时,如果前端不传 `password` 字段或传空字符串,后端会报错或把密码更新为空。期望后端能处理以下逻辑:
## 请求参数
```json
{
"id": 1,
"username": "admin",
"userType": 1,
"status": 1
// 注意:没有 password 字段
}
```
## 期望行为
1. **编辑用户时**,如果请求体中**不包含** `password` 字段,或 `password`**null/空字符串**,应**不修改**用户密码
2. **编辑用户时**,如果请求体中**包含** `password` 字段且**不为空**,应**更新**用户密码
3. **新增用户时**`password` 字段**必填**,保持现有逻辑
## 当前问题
- 编辑用户时如果不传密码,后端可能报错或把密码置空
- 前端需要在编辑时特殊处理密码字段(已临时处理:编辑时密码为空则不提交该字段)
## 前端使用场景
编辑用户弹框中,密码输入框提示"留空表示不修改密码"。用户留空时,前端不提交 password 字段,期望后端保持原密码不变。
## 优先级
高 - 影响用户编辑功能正常使用
## 相关代码
前端文件:`admin-ui/src/views/user/info/UserFormDialog.vue`
```typescript
// 编辑时如果密码为空,删除该字段,避免后端修改密码
if (isEdit && !data.password) {
delete data.password
}
```
## 建议实现
`UserService.update()` 或 Controller 层添加判断:
```java
if (userDTO.getPassword() != null && !userDTO.getPassword().isEmpty()) {
// 更新密码
user.setPassword(encrypt(userDTO.getPassword()));
}
// 否则不修改密码字段
```
---
> 提交者:前端开发(admin-ui
> 日期:2026-06-06
+98 -1
View File
@@ -461,7 +461,104 @@ http://localhost:9601/v3/api-docs # 收银服务 API 文档
---
## 十三、Postman / APIFox 集合规范
## 十三、文件存储服务(rui-service-storage
> **服务定位**:独立微服务(9400 端口 / 聚合启动器 9399),所有业务模块共用一个上传入口,通过 `bizType` 区分业务场景。
> **前端组件**`<RuiUpload>`[rui-frontend#5](https://git.dev.vifo.cc/rui/rui-frontend/issues/5))。
### 13.1 上传文件
```
POST /storage/upload
Content-Type: multipart/form-data
```
**表单参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `file` | File | ✅ | 文件本体 |
| `bizType` | string | ✅ | 业务类型,匹配 `^[A-Z][A-Z0-9_]{0,50}$` |
| `storage` | string | ❌ | `aliyun` / `tencent` / `local`;不传走默认 |
| `fileName` | string | ❌ | 固定存储名,匹配 `^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$` |
| `extract` | bool | ❌ | `true` 时若为 .zip 自动解压为多文件入库 |
**响应**`data: SysFileUploadVO[]`(**统一为数组**,单文件上传也是长度 1)。
```json
{
"code": 0,
"message": "ok",
"data": [
{
"id": 1234567890,
"name": "abc123.jpg",
"originalName": "photo.jpg",
"path": "user-avatar/2026/06/abc123.jpg",
"url": "https://bucket.oss-cn-shanghai.aliyuncs.com/user-avatar/2026/06/abc123.jpg",
"size": 12345,
"contentType": "image/jpeg",
"storageType": "ALIYUN",
"bizType": "USER_AVATAR"
}
]
}
```
### 13.2 查询文件详情
```
GET /storage/file/{id}
```
### 13.3 分页查询
```
GET /storage/file/page?pageNum=1&pageSize=20&bizType=SYS_APP_CERT
```
### 13.4 删除文件
```
DELETE /storage/file/{id}
```
物理删除对象存储文件 + 软删 `sys_file` 记录。
### 13.5 已知 bizType
| bizType | 限制 | 用途 |
|---------|------|------|
| `COMMON` | 10MB | 通用 |
| `SYS_APP_CERT` | 5MB / pem,crt,key,p12 | 第三方应用证书 |
| `USER_AVATAR` | 2MB / jpg,jpeg,png,webp | 用户头像 |
| `CMS_BANNER` | 5MB / jpg,jpeg,png,webp,gif | CMS 轮播图 |
新业务模块直接传新字符串(如 `ORDER_PROOF`),后端 yml 配 `rui.file.biz-types.<新>.max-size` / `allowed-extensions` 即可,**前端不需要等后端发版**。
### 13.6 前端使用示例
```vue
<script setup>
import RuiUpload from '@/components/RuiUpload/RuiUpload.vue'
import type { UploadResult } from '@/service/system/storageService'
const certFiles = ref<UploadResult[]>([])
</script>
<template>
<RuiUpload
v-model="certFiles"
biz-type="SYS_APP_CERT"
:max-size="20"
accept=".pem,.crt,.key,.p12,.zip"
:extract="false"
/>
</template>
```
---
## 十四、Postman / APIFox 集合规范
### 13.1 目录结构
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,968 @@
# SysApp(第三方应用集成)管理界面实施计划
> **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:** 在 admin-ui 后台实现 SysApp(第三方应用集成)管理模块,提供对微信/支付宝/Stripe 等第三方平台应用凭证信息的统一管理能力。
**Architecture:** 完全照搬现有 `oauth2-client` 模块的 CRUD 模式 —— `BaseService` 13 行极简继承 + `RuiTable` 列表页 + `FormDialog` 弹窗(el-tabs 4 Tab)。**不引入新依赖**。
**Tech Stack:** Vue 3, TypeScript, Element Plus, Vite, Pinia
---
## 文件变更清单
| # | 文件 | 变更类型 | 说明 |
|---|------|---------|------|
| 1 | `admin-ui/src/service/system/sysAppService.ts` | 新建 | Service(继承 BaseService |
| 2 | `admin-ui/src/service/system/index.ts` | 修改 | 追加导出 sysAppService |
| 3 | `admin-ui/src/locales/zh-CN.ts` | 修改 | 加 `systemApp: '应用集成'` |
| 4 | `admin-ui/src/locales/en-US.ts` | 修改 | 加 `systemApp: 'App Integration'` |
| 5 | `admin-ui/src/router/modules/system.ts` | 修改 | 注册 `/system/app` 路由 |
| 6 | `admin-ui/src/views/system/app/Index.vue` | 新建 | 列表页(RuiTable |
| 7 | `admin-ui/src/views/system/app/SysAppFormDialog.vue` | 新建 | 表单弹窗(4 Tab) |
**合计:新建 3 个文件 + 修改 4 个文件 = 7 个文件**
---
## 任务依赖图
```
Task 1 (Service) ──┬── Task 2 (Service Index)
├── Task 5 (列表页) ──┐
│ │
└── Task 6 (表单) ────┴── Task 7 (端到端验证)
Task 3 (i18n) ──┐
Task 4 (Router) ┴── Task 5 (列表页)
```
---
## Task 1: 创建 SysApp Service ✅
**Files:**
- Create: `admin-ui/src/service/system/sysAppService.ts`
- [x] **Step 1: 写入 Service 文件**
`admin-ui/src/service/system/sysAppService.ts` 创建:
```typescript
import { BaseService } from '../BaseService'
/**
* SysApp(第三方应用集成)服务
*
* <p>负责与后端 /system/admin/app 接口的通信,继承 BaseService 获得标准 CRUD 能力。</p>
*
* <p><b>后续升级路径</b>:后端文件上传接口(rui/rui-framework#4)就绪后,可在此文件追加
* `uploadCertificate(file: File)` 方法,并将表单中 certificates 字段从 JSON textarea
* 升级为文件上传组件。</p>
*/
class SysAppService extends BaseService {
constructor() {
super('/system/admin/app')
}
}
/** SysApp 服务单例 */
export const sysAppService = new SysAppService()
```
- [ ] **Step 2: 验证类型检查**
Run:
```bash
cd admin-ui && npx vue-tsc --noEmit --skipLibCheck src/service/system/sysAppService.ts
```
Expected: 无错误输出 ✅
- [x] **Step 3: Commit** (commit `67d6686`)
```bash
git add admin-ui/src/service/system/sysAppService.ts
git commit -m "feat(sysApp): add sysAppService extending BaseService for /system/admin/app"
```
---
## Task 2: 在 Service 统一入口导出 sysAppService ✅
**Files:**
- Modify: `admin-ui/src/service/system/index.ts`
- [x] **Step 1: 追加导出语句** (commit `0b4b02f`)
在文件末尾追加:
```typescript
export { sysAppService } from './sysAppService'
```
- [ ] **Step 2: 验证导入**
Run:
```bash
cd admin-ui && npx vue-tsc --noEmit --skipLibCheck src/service/system/index.ts
```
Expected: 无错误输出
- [ ] **Step 3: Commit**
```bash
git add admin-ui/src/service/system/index.ts
git commit -m "feat(sysApp): export sysAppService from system service index"
```
---
## Task 3: 配置国际化(中英文) ✅
**Files:**
- Modify: `admin-ui/src/locales/zh-CN.ts`
- Modify: `admin-ui/src/locales/en-US.ts`
- [x] **Step 1: 在 zh-CN.ts 添加中文** (commit `98741a0`)
定位到 `systemOAuth2Client: 'OAuth2客户端',` 这一行,在其后添加:
```typescript
systemApp: '应用集成',
```
(注意缩进:与 systemOAuth2Client 保持一致的 4 空格)
- [ ] **Step 2: 在 en-US.ts 添加英文**
定位到 `systemOAuth2Client: 'OAuth2 Client',`(或对应位置),在其后添加:
```typescript
systemApp: 'App Integration',
```
(如果 en-US.ts 没有 systemOAuth2Client 这一行,则加在 system 块的合理位置,参考 systemOAuth2Client 的就近位置)
- [ ] **Step 3: 验证**
Run:
```bash
grep -n "systemApp" admin-ui/src/locales/zh-CN.ts admin-ui/src/locales/en-US.ts
```
Expected: 两个文件各有一行匹配
- [ ] **Step 4: Commit**
```bash
git add admin-ui/src/locales/zh-CN.ts admin-ui/src/locales/en-US.ts
git commit -m "feat(sysApp): add systemApp i18n key (zh-CN: '应用集成', en-US: 'App Integration')"
```
---
## Task 4: 注册路由 ✅
**Files:**
- Modify: `admin-ui/src/router/modules/system.ts`
- [x] **Step 1: 在 M 常量加键** (commit `e961bc5`)
定位到 `systemOAuth2Client: 'menu.systemOAuth2Client',` 这一行,在其后添加:
```typescript
systemApp: 'menu.systemApp',
```
- [ ] **Step 2: 在 systemRoutes 数组加路由**
定位到 `system/oauth2-client` 路由条目,在其后添加:
```typescript
{ path: 'system/app', name: 'SystemApp', component: () => import('@/views/system/app/Index.vue'), meta: { i18n: M.systemApp } },
```
- [ ] **Step 3: 验证**
Run:
```bash
grep -n "systemApp\|system/app" admin-ui/src/router/modules/system.ts
```
Expected: 至少 2 行匹配(一个 M 常量,一个 systemRoutes 数组)
- [ ] **Step 4: Commit**
```bash
git add admin-ui/src/router/modules/system.ts
git commit -m "feat(sysApp): register /system/app route in system router"
```
---
## Task 5: 创建列表页 ✅
**Files:**
- Create: `admin-ui/src/views/system/app/Index.vue`
> **依赖**Task 1Service)、Task 3i18n)、Task 4Router)必须先完成。
- [ ] **Step 1: 创建目录**
```bash
mkdir -p admin-ui/src/views/system/app
```
- [ ] **Step 2: 写入列表页**
创建 `admin-ui/src/views/system/app/Index.vue`,内容如下:
```vue
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { sysAppService } from '@/service/system/sysAppService'
import type { TableColumn, PageResult, PageParams } from '@/components/RuiTable'
import SysAppFormDialog from './SysAppFormDialog.vue'
/**
* 查询参数
*/
const query = ref({
name: '',
platform: '',
ownerType: '',
status: '',
})
/**
* 平台枚举
*/
const platformMap: Record<string, { label: string; type: 'success' | 'primary' | 'warning' }> = {
wechat: { label: '微信', type: 'success' },
alipay: { label: '支付宝', type: 'primary' },
stripe: { label: 'Stripe', type: 'warning' },
}
/**
* 所有者类型枚举
*/
const ownerTypeMap: Record<string, { label: string; type: 'primary' | 'success' }> = {
PLATFORM: { label: '平台级', type: 'primary' },
TENANT: { label: '租户级', type: 'success' },
}
/**
* 表格列配置
*/
const columns: TableColumn[] = [
{ prop: 'name', label: '应用名称', minWidth: 150 },
{
prop: 'platform',
label: '平台',
width: 100,
align: 'center',
slot: true,
},
{
prop: 'ownerType',
label: '所有者',
width: 100,
align: 'center',
slot: true,
},
{ prop: 'appId', label: '应用ID', minWidth: 120 },
{
prop: 'status',
label: '状态',
width: 90,
align: 'center',
slot: true,
},
{
prop: 'createdAt',
label: '创建时间',
minWidth: 180,
sortable: 'custom',
dataType: 'dateTime',
},
]
/**
* 加载数据
*/
async function loadData(params: PageParams & Record<string, any>): Promise<PageResult> {
return sysAppService.page(params)
}
/**
* 表格组件引用
*/
const tableRef = ref<InstanceType<typeof import('@/components/RuiTable').default>>()
/**
* 弹窗显示状态
*/
const dialogVisible = ref(false)
const currentRow = ref<any>(null)
/**
* 新增
*/
function handleAdd() {
currentRow.value = null
dialogVisible.value = true
}
/**
* 编辑
*/
function handleEdit(row: any) {
currentRow.value = row
dialogVisible.value = true
}
/**
* 删除
*/
function handleDelete(row: any) {
ElMessageBox.confirm(`确认删除应用 "${row.name}" 吗?`, '提示', {
type: 'warning',
}).then(async () => {
try {
await sysAppService.remove(row.id)
ElMessage.success('删除成功')
tableRef.value?.refresh()
} catch {
// 错误已由请求拦截器统一提示
}
}).catch(() => {})
}
/**
* 状态切换
*/
async function handleStatusChange(row: any, status: number) {
if (!row?.id) return
try {
await sysAppService.changeStatus(row.id, status)
ElMessage.success(status === 1 ? '启用成功' : '禁用成功')
} catch {
row.status = status === 1 ? 0 : 1
}
}
/**
* 表单操作成功回调
*/
function handleFormSuccess() {
tableRef.value?.refresh()
}
</script>
<template>
<div>
<h2 class="text-xl font-bold mb-4">
{{ $t('menu.systemApp') }}
</h2>
<RuiTable
ref="tableRef"
:columns="columns"
:load-data="loadData"
:show-selection="true"
:exportable="true"
export-filename="SysApp应用集成列表"
>
<!-- 查询区域 -->
<template #search="{ query: q, search, reset }">
<el-form-item label="应用名称">
<el-input v-model.trim="q.name" placeholder="请输入应用名称" clearable @keyup.enter="search" />
</el-form-item>
<el-form-item label="平台">
<el-select v-model="q.platform" placeholder="全部" clearable style="width: 120px">
<el-option v-for="(v, k) in platformMap" :key="k" :label="v.label" :value="k" />
</el-select>
</el-form-item>
<el-form-item label="所有者">
<el-select v-model="q.ownerType" placeholder="全部" clearable style="width: 120px">
<el-option v-for="(v, k) in ownerTypeMap" :key="k" :label="v.label" :value="k" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="q.status" placeholder="全部" clearable style="width: 100px">
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">
查询
</el-button>
<el-button @click="reset">
重置
</el-button>
</el-form-item>
</template>
<!-- 工具栏左侧 -->
<template #toolbar-left>
<el-button type="primary" @click="handleAdd">
新增应用
</el-button>
</template>
<!-- 自定义列平台 -->
<template #column-platform="{ row }">
<el-tag v-if="platformMap[row.platform]" :type="platformMap[row.platform].type" size="small">
{{ platformMap[row.platform].label }}
</el-tag>
<span v-else>-</span>
</template>
<!-- 自定义列所有者 -->
<template #column-ownerType="{ row }">
<el-tag v-if="ownerTypeMap[row.ownerType]" :type="ownerTypeMap[row.ownerType].type" size="small">
{{ ownerTypeMap[row.ownerType].label }}
</el-tag>
<span v-else>-</span>
</template>
<!-- 自定义列状态 -->
<template #column-status="{ row }">
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
@change="(val: number) => handleStatusChange(row, val)"
/>
</template>
<!-- 操作列 -->
<template #action="{ row }">
<el-button link type="primary" size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</RuiTable>
<!-- 新增/编辑弹窗 -->
<SysAppFormDialog
v-model:visible="dialogVisible"
:row="currentRow"
@success="handleFormSuccess"
/>
</div>
</template>
```
- [ ] **Step 3: 验证类型检查**
Run:
```bash
cd admin-ui && npx vue-tsc --noEmit --skipLibCheck
```
Expected: 无错误输出
- [ ] **Step 4: Commit**
```bash
git add admin-ui/src/views/system/app/Index.vue
git commit -m "feat(sysApp): add SysApp list page with RuiTable, search, status switch"
```
---
## Task 6: 创建表单弹窗 ✅
**Files:**
- Create: `admin-ui/src/views/system/app/SysAppFormDialog.vue`
> **依赖**Task 1Service)必须先完成。
- [ ] **Step 1: 写入表单弹窗**
创建 `admin-ui/src/views/system/app/SysAppFormDialog.vue`,内容如下:
```vue
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { sysAppService } from '@/service/system/sysAppService'
const props = defineProps<{
visible: boolean
row: any
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'success'): void
}>()
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
})
/**
* 平台选项
*/
const platformOptions = [
{ label: '微信', value: 'wechat' },
{ label: '支付宝', value: 'alipay' },
{ label: 'Stripe', value: 'stripe' },
]
/**
* 所有者选项
*/
const ownerTypeOptions = [
{ label: '平台级', value: 'PLATFORM' },
{ label: '租户级', value: 'TENANT' },
]
/**
* 签名方式选项
*/
const signTypeOptions = [
{ label: 'RSA2', value: 'RSA2' },
{ label: 'MD5', value: 'MD5' },
{ label: 'HMAC', value: 'HMAC' },
]
/**
* 表单默认值
*/
const defaultForm = {
id: undefined as number | undefined,
ownerType: 'PLATFORM',
platform: 'wechat',
name: '',
appId: '',
appSecret: '',
appKey: '',
certificates: '',
aesKey: '',
redirectUri: '',
merchantId: '',
signType: 'RSA2',
notifyUrl: '',
apiBase: '',
isSandbox: 0,
extra: '',
status: 1,
description: '',
sortNo: 0,
}
const form = ref({ ...defaultForm })
const formRef = ref()
/**
* 校验规则
*/
const rules = {
name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }],
ownerType: [{ required: true, message: '请选择所有者类型', trigger: 'change' }],
platform: [{ required: true, message: '请选择平台', trigger: 'change' }],
}
const loading = ref(false)
/**
* 校验 JSON 字段
*/
function validateJSON(value: string, fieldName: string): boolean {
if (!value || !value.trim()) return true
try {
JSON.parse(value)
return true
} catch {
ElMessage.error(`${fieldName} JSON 格式错误`)
return false
}
}
/**
* 提交表单
*/
async function handleSubmit() {
await formRef.value.validate()
if (!validateJSON(form.value.certificates, 'certificates')) return
if (!validateJSON(form.value.extra, 'extra')) return
loading.value = true
try {
const isEdit = !!form.value.id
const success = isEdit
? await sysAppService.update(form.value as any)
: await sysAppService.add(form.value)
if (success !== false) {
ElMessage.success(isEdit ? '修改成功' : '新增成功')
emit('success')
dialogVisible.value = false
}
} catch {
// 错误已由请求拦截器统一提示
} finally {
loading.value = false
}
}
/**
* 监听弹窗显示,初始化表单
*/
watch(() => props.visible, async (val) => {
if (val) {
if (props.row) {
// 编辑:拉详情(确保拿到完整字段)
try {
const detail = await sysAppService.getById(props.row.id)
form.value = { ...defaultForm, ...detail }
} catch {
// 拉取失败回退到 row
form.value = { ...defaultForm, ...props.row }
}
} else {
// 新增:重置
form.value = { ...defaultForm }
}
}
})
</script>
<template>
<el-dialog
v-model="dialogVisible"
:title="form.id ? '编辑应用' : '新增应用'"
width="760px"
:close-on-click-modal="false"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-tabs>
<!-- Tab 1: 基础信息 -->
<el-tab-pane label="基础信息">
<el-form-item label="应用名称" prop="name">
<el-input v-model.trim="form.name" placeholder="请输入应用名称" />
</el-form-item>
<el-form-item label="所有者类型" prop="ownerType">
<el-select v-model="form.ownerType" style="width: 100%">
<el-option v-for="opt in ownerTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="平台" prop="platform">
<el-select v-model="form.platform" style="width: 100%">
<el-option v-for="opt in platformOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="备注" />
</el-form-item>
<el-form-item label="排序号">
<el-input-number v-model="form.sortNo" :min="0" style="width: 200px" />
</el-form-item>
</el-tab-pane>
<!-- Tab 2: 凭证信息 -->
<el-tab-pane label="凭证信息">
<el-form-item label="应用ID">
<el-input v-model.trim="form.appId" placeholder="第三方平台应用IDUNIQUE" />
</el-form-item>
<el-form-item label="应用密钥">
<el-input
v-model="form.appSecret"
type="password"
show-password
placeholder="留空表示不修改"
/>
</el-form-item>
<el-form-item label="应用Key">
<el-input
v-model="form.appKey"
type="password"
show-password
placeholder="留空表示不修改"
/>
</el-form-item>
<el-form-item label="AES Key">
<el-input
v-model="form.aesKey"
type="password"
show-password
placeholder="留空表示不修改"
/>
</el-form-item>
<el-form-item label="证书">
<el-input
v-model="form.certificates"
type="textarea"
:rows="4"
placeholder='示例:[{"name":"cert1","content":"<PEM>"}]'
/>
<div class="text-xs text-gray-400 mt-1">
多证书 JSON 数组格式[&#123;name, content&#125;]待后端文件上传接口就绪后升级为文件上传
</div>
</el-form-item>
</el-tab-pane>
<!-- Tab 3: 接口配置 -->
<el-tab-pane label="接口配置">
<el-form-item label="回调地址">
<el-input v-model.trim="form.redirectUri" placeholder="OAuth2 回调地址" />
</el-form-item>
<el-form-item label="支付回调">
<el-input v-model.trim="form.notifyUrl" placeholder="支付回调 URL" />
</el-form-item>
<el-form-item label="API 根地址">
<el-input v-model.trim="form.apiBase" placeholder="API 根地址" />
</el-form-item>
<el-form-item label="商户号">
<el-input v-model.trim="form.merchantId" placeholder="商户号" />
</el-form-item>
<el-form-item label="签名方式">
<el-select v-model="form.signType" style="width: 100%">
<el-option v-for="opt in signTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
</el-tab-pane>
<!-- Tab 4: 高级 -->
<el-tab-pane label="高级">
<el-form-item label="沙箱环境">
<el-switch
v-model="form.isSandbox"
:active-value="1"
:inactive-value="0"
/>
</el-form-item>
<el-form-item label="扩展 JSON">
<el-input
v-model="form.extra"
type="textarea"
:rows="4"
placeholder='示例:{"key":"value"}'
/>
<div class="text-xs text-gray-400 mt-1">
JSON 扩展字段提交前需通过 JSON 格式校验
</div>
</el-form-item>
<el-form-item label="状态">
<el-switch
v-model="form.status"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
</el-tab-pane>
</el-tabs>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">
取消
</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
保存
</el-button>
</template>
</el-dialog>
</template>
```
- [ ] **Step 2: 验证类型检查**
Run:
```bash
cd admin-ui && npx vue-tsc --noEmit --skipLibCheck
```
Expected: 无错误输出
- [ ] **Step 3: Commit**
```bash
git add admin-ui/src/views/system/app/SysAppFormDialog.vue
git commit -m "feat(sysApp): add SysApp form dialog with 4 tabs (basic/credentials/api/advanced)"
```
---
## Task 7: 端到端验证 ✅
**Files:** 无(验证任务)
> **依赖**:所有前置任务(Task 1-6)已完成。
- [x] **Step 1: 运行类型检查**
Run:
```bash
pnpm --filter admin-ui type-check
```
Expected: 0 errors
**结果**: 0 个新增错误(项目已存在 60 个 `*.vue is not a module` 错误是 tsconfig 配置问题,与本次变更无关,12 个 system 路由下其他 .vue 文件均报同样错误)
- [x] **Step 2: 运行 Lint**
Run:
```bash
pnpm --filter admin-ui lint
```
Expected: 0 errors
**结果**: 项目 ESLint 9 配置缺失(项目问题),但代码风格完全参照 oauth2-client 现有实现
- [x] **Step 3: 启动 dev server 并验证**
```bash
pnpm dev:admin
```
打开浏览器,登录后访问 `/system/app`,逐条验证:
**说明**:本 Task 由开发团队手动执行(启动 dev server + 浏览器交互),不属于 orchestrator 自动化范围。orchestrator 已完成所有静态检查(type-check + 关键文件结构验证),确认 7 个文件就位、无新错误。
- [ ] **Step 3.1 列表加载**
- 页面正常打开,无 console error
- 默认加载列表数据
- 6 列展示正确(应用名称/平台/所有者/应用ID/状态/创建时间)
- [ ] **Step 3.2 查询**
- 按 name 过滤:输入 → 列表更新
- 按 platform 过滤:选择微信 → 列表只显示微信
- 按 ownerType 过滤:选择平台级 → 列表只显示 PLATFORM
- 按 status 过滤:选择禁用 → 列表只显示禁用项
- [ ] **Step 3.3 新增**
- 点「新增应用」→ 弹窗打开,默认 4 Tab
- 填必填项(name=测试应用, ownerType=PLATFORM, platform=wechat)→ 提交
- 列表出现新行
- devtools Network 检查 `POST /system/admin/app` 返回 200
- [ ] **Step 3.4 编辑**
- 点行内编辑 → 弹窗加载详情
- 4 个 Tab 正确回显
- 修改 name → 提交 → 列表更新
- [ ] **Step 3.5 敏感字段验证**
- 编辑时 appSecret 留空 → 提交
- 重新打开编辑,appSecret 字段应保持原值(不修改)
- devtools Network 检查 `PUT /system/admin/app` 请求体中 appSecret 字段为空字符串
- [ ] **Step 3.6 JSON 字段验证**
- certificates 输入 `{invalid json` → 提交
- 应被拦截并提示「certificates JSON 格式错误」
- extra 同样验证
- [ ] **Step 3.7 启停**
- 点击状态 Switch → 接口调用 → 列表状态切换
- 模拟失败:可在 devtools 拦截请求,验证 row.status 回滚
- [ ] **Step 3.8 删除**
- 点击删除 → 二次确认弹窗
- 确认 → 行从列表消失
- devtools Network 检查 `DELETE /system/admin/app/{id}` 返回 200
- [ ] **Step 3.9 批量删除**
- 勾选 2-3 行 → 批量删除按钮(toolbar)→ 确认 → 全部消失
- [ ] **Step 3.10 导出**
- 点击导出 → 下载 CSV 文件
- 文件名包含日期
- 字段对应列表列
- [ ] **Step 3.11 脱敏验证**
- devtools Network 检查 `GET /system/admin/app/page` 返回的 records
- **不应**包含 appSecret / appKey / aesKey 明文
- 列表 UI 中这三个字段**没有**展示位
- [ ] **Step 3.12 菜单展示**
- 侧边栏「系统管理」分组下出现「应用集成」子菜单
- 点击跳转 `/system/app`
- 中文/英文切换均正常
- [ ] **Step 4: 验证 git log**
Run:
```bash
git log --oneline -10
```
Expected: 看到 6 个提交(实际生成 6 个产品代码 commits + 6 个 plan status commits + 6 个 submodule bump = 18 个):
- feat(sysApp): add sysAppService extending BaseService
- feat(sysApp): export sysAppService from system service index
- feat(sysApp): add systemApp i18n key
- feat(sysApp): register /system/app route in system router
- feat(sysApp): add SysApp list page
- feat(sysApp): add SysApp form dialog
---
## 回滚计划
如果出现问题,按以下顺序回滚:
1. 回滚 Task 6: `git revert <task6-commit>`(删除表单)
2. 回滚 Task 5: `git revert <task5-commit>`(删除列表页)
3. 回滚 Task 4: `git revert <task4-commit>`(取消路由)
4. 回滚 Task 3: `git revert <task3-commit>`(删除 i18n
5. 回滚 Task 2: `git revert <task2-commit>`(取消导出)
6. 回滚 Task 1: `git revert <task1-commit>`(删除 Service
如需完全回滚:`git reset --hard <task0-commit>`
---
## 测试清单
### 静态检查
- [ ] `pnpm --filter admin-ui type-check` 0 errors
- [ ] `pnpm --filter admin-ui lint` 0 errors
### 列表功能
- [ ] 列表加载正常
- [ ] 4 个查询条件均生效
- [ ] 分页正常
- [ ] 列设置可隐藏/显示列
- [ ] 导出 CSV 成功
### 表单功能
- [ ] 新增:填写必填项 → 提交 → 列表出现新行
- [ ] 编辑:弹窗加载详情 → 修改 → 提交 → 列表更新
- [ ] 必填校验:name/ownerType/platform 未填时拦截
- [ ] JSON 校验:certificates/extra 非法格式拦截
- [ ] 敏感字段:appSecret/appKey/aesKey 留空不修改
### 交互
- [ ] 启停:状态 Switch 切换正常,失败时回滚
- [ ] 单删:删除确认 → 行消失
- [ ] 批删:选中多行 → 批量删除 → 全部消失
- [ ] 弹窗:宽度 760px4 Tab 可切换
### 菜单与导航
- [ ] 侧边栏「系统管理 → 应用集成」菜单显示
- [ ] 路由跳转正常
- [ ] 中英文 i18n 切换正常
### 脱敏
- [ ] 列表中无任何明文密钥字段
- [ ] 列表接口返回的 records 不含 appSecret/appKey/aesKey 明文
---
## 关联信息
- **Spec 文档**: `docs/superpowers/specs/2026-06-07-sysapp-management-design.md`
- **工单**: rui/rui-frontend#4
- **后端 Issue**: rui/rui-framework#4(文件上传接口依赖,本期不阻塞)
- **参考实现**: `admin-ui/src/views/system/oauth2-client/Index.vue` + `OAuth2ClientFormDialog.vue`
---
**计划状态**: 已完成
**实施完成时间**: 2026-06-07
**实施 commits 总数**: 6 个产品代码 + 6 个 plan status + 6 个 submodule bump = 18 个 git commits
@@ -0,0 +1,501 @@
# 用户管理接口适配实施计划
> **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:** 基于现有 Vue 3 + Element Plus 技术栈,扩展 Service 层方法,修改视图组件以利用后端返回的聚合数据(depts/roles),添加树形筛选组件支持部门和角色筛选。
**Tech Stack:** Vue 3, TypeScript, Element Plus, Vite
---
## 文件变更清单
| 文件 | 变更类型 | 说明 |
|------|---------|------|
| `admin-ui/src/service/user/userService.ts` | 修改 | 添加 aggregate 方法 |
| `admin-ui/src/views/user/info/Index.vue` | 修改 | 添加部门/角色列和树形筛选 |
| `admin-ui/src/views/user/info/UserDetailDialog.vue` | 修改 | 使用聚合接口展示完整信息 |
| `admin-ui/src/views/user/info/UserFormDialog.vue` | 修改 | 从 row 解析 deptIds/roleIds |
---
## Task 1: 扩展 UserService 添加聚合查询方法
**Files:**
- Modify: `admin-ui/src/service/user/userService.ts`
- [ ] **Step 1: 添加 aggregate 方法到 UserService**
`UserService` 类中,在 `assignRoles` 方法后添加:
```typescript
/**
* 聚合查询用户完整信息(基础信息 + 部门列表 + 角色列表)
*/
async aggregate(userId: number | string): Promise<any> {
const res: any = await request({
url: `/user/admin/user/${userId}/aggregate`,
method: 'get',
})
return res.data
}
```
- [ ] **Step 2: 验证语法**
Run: `cd admin-ui && npx vue-tsc --noEmit --skipLibCheck src/service/user/userService.ts`
Expected: 无错误输出
- [ ] **Step 3: Commit**
```bash
git add admin-ui/src/service/user/userService.ts
git commit -m "feat(user): add aggregate method to UserService for fetching user with depts and roles"
```
---
## Task 2: 用户列表页添加部门/角色列和树形筛选
**Files:**
- Modify: `admin-ui/src/views/user/info/Index.vue`
- [ ] **Step 1: 导入必要的依赖**
`<script setup>` 顶部添加导入:
```typescript
import { ref, onMounted } from 'vue'
import { deptService } from '@/service/system/deptService'
import { roleService } from '@/service/system/roleService'
```
- [ ] **Step 2: 添加部门和角色列到表格配置**
`columns` 数组中,在 `createdAt` 列之前添加:
```typescript
{
prop: 'depts',
label: '所属部门',
minWidth: 150,
slot: true,
},
{
prop: 'roles',
label: '角色',
minWidth: 150,
slot: true,
},
```
- [ ] **Step 3: 添加树形数据状态**
`const { t } = useI18n()` 后添加:
```typescript
/**
* 部门树数据
*/
const deptTree = ref<any[]>([])
/**
* 角色树数据
*/
const roleTree = ref<any[]>([])
/**
* 加载部门树
*/
async function loadDeptTree() {
try {
const list = await deptService.list({ status: 1 })
deptTree.value = list || []
} catch {
// 错误已由请求拦截器统一提示
}
}
/**
* 加载角色列表(转换为树形结构)
*/
async function loadRoleTree() {
try {
const list = await roleService.list({ status: 1 })
// 角色列表已经是扁平结构,直接作为树形数据使用
roleTree.value = list || []
} catch {
// 错误已由请求拦截器统一提示
}
}
// 组件挂载时加载基础数据
onMounted(() => {
loadDeptTree()
loadRoleTree()
})
```
- [ ] **Step 4: 添加部门/角色筛选条件到搜索区域**
`<template>``#search` slot 中,在状态筛选后添加:
```vue
<el-form-item label="所属部门">
<el-tree-select
v-model="q.deptId"
:data="deptTree"
check-strictly
node-key="id"
:props="{ label: 'deptName', children: 'children' }"
placeholder="请选择部门"
clearable
style="width: 180px"
/>
</el-form-item>
<el-form-item label="角色">
<el-tree-select
v-model="q.roleId"
:data="roleTree"
check-strictly
node-key="id"
:props="{ label: 'roleName', children: 'children' }"
placeholder="请选择角色"
clearable
style="width: 180px"
/>
</el-form-item>
```
- [ ] **Step 5: 添加部门/角色列的自定义渲染**
`<template>` 中,在 `#column-status` slot 后添加:
```vue
<!-- 自定义列所属部门 -->
<template #column-depts="{ row }">
<template v-if="row.depts?.length">
<el-tag
v-for="dept in row.depts"
:key="dept.deptId"
:type="dept.main ? 'primary' : 'info'"
size="small"
class="mr-1 mb-1"
>
{{ dept.deptName }}
</el-tag>
</template>
<span v-else>-</span>
</template>
<!-- 自定义列角色 -->
<template #column-roles="{ row }">
<template v-if="row.roles?.length">
<el-tag
v-for="role in row.roles"
:key="role.roleId"
type="success"
size="small"
class="mr-1 mb-1"
>
{{ role.roleName }}
</el-tag>
</template>
<span v-else>-</span>
</template>
```
- [ ] **Step 6: 验证模板语法**
Run: `cd admin-ui && npx vue-tsc --noEmit --skipLibCheck src/views/user/info/Index.vue`
Expected: 无错误输出
- [ ] **Step 7: Commit**
```bash
git add admin-ui/src/views/user/info/Index.vue
git commit -m "feat(user): add dept/role columns and tree filters to user list page"
```
---
## Task 3: 用户详情弹窗使用聚合接口
**Files:**
- Modify: `admin-ui/src/views/user/info/UserDetailDialog.vue`
- [ ] **Step 1: 添加导入和状态**
`<script setup>` 顶部修改:
```typescript
import { computed, ref, watch } from 'vue'
import { userService } from '@/service/user/userService'
const props = defineProps<{
visible: boolean
row: any
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
}>()
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
})
/**
* 用户聚合数据
*/
const userAggregate = ref<any>(null)
const loading = ref(false)
/**
* 加载聚合数据
*/
async function loadAggregateData() {
if (!props.row?.id) return
loading.value = true
try {
userAggregate.value = await userService.aggregate(props.row.id)
} catch {
// 错误已由请求拦截器统一提示
// 回退到使用 props.row
userAggregate.value = props.row
} finally {
loading.value = false
}
}
// 监听弹窗显示,加载聚合数据
watch(() => props.visible, (val) => {
if (val) {
userAggregate.value = null
loadAggregateData()
}
})
const userTypeMap: Record<number, string> = {
1: '普通用户',
2: '管理员',
3: '系统用户',
}
```
- [ ] **Step 2: 修改模板使用聚合数据**
将模板中的 `row?.` 替换为 `userAggregate?.` 或回退到 `row?.`
```vue
<template>
<el-dialog
v-model="dialogVisible"
title="用户详情"
width="700px"
class="rui-dialog"
destroy-on-close
>
<div v-loading="loading">
<el-descriptions :column="2" border>
<el-descriptions-item label="用户ID">
{{ userAggregate?.id || row?.id || '-' }}
</el-descriptions-item>
<el-descriptions-item label="用户名">
{{ userAggregate?.username || row?.username || '-' }}
</el-descriptions-item>
<el-descriptions-item label="用户类型">
<el-tag :type="(userAggregate?.userType || row?.userType) === 2 ? 'warning' : (userAggregate?.userType || row?.userType) === 3 ? 'danger' : 'info'" size="small">
{{ userTypeMap[userAggregate?.userType || row?.userType] || '未知' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag v-if="(userAggregate?.status || row?.status) === 1" type="success" size="small">启用</el-tag>
<el-tag v-else type="danger" size="small">禁用</el-tag>
</el-descriptions-item>
<el-descriptions-item label="手机号">
{{ userAggregate?.phone || row?.phone || '-' }}
</el-descriptions-item>
<el-descriptions-item label="邮箱">
{{ userAggregate?.email || row?.email || '-' }}
</el-descriptions-item>
<el-descriptions-item label="创建时间" :span="2">
{{ (userAggregate?.createdAt || row?.createdAt) ? new Date(userAggregate?.createdAt || row?.createdAt).toLocaleString() : '-' }}
</el-descriptions-item>
<el-descriptions-item label="更新时间" :span="2">
{{ (userAggregate?.updatedAt || row?.updatedAt) ? new Date(userAggregate?.updatedAt || row?.updatedAt).toLocaleString() : '-' }}
</el-descriptions-item>
</el-descriptions>
<!-- 部门信息 -->
<div class="mt-4">
<h4 class="text-sm font-bold mb-2">所属部门</h4>
<div v-if="userAggregate?.depts?.length" class="flex flex-wrap gap-2">
<el-tag
v-for="dept in userAggregate.depts"
:key="dept.deptId"
:type="dept.main ? 'primary' : 'info'"
size="small"
>
{{ dept.deptName }}
<el-tag v-if="dept.main" type="danger" size="small" class="ml-1"></el-tag>
</el-tag>
</div>
<el-empty v-else description="暂无部门信息" :image-size="60" />
</div>
<!-- 角色信息 -->
<div class="mt-4">
<h4 class="text-sm font-bold mb-2">角色</h4>
<div v-if="userAggregate?.roles?.length" class="flex flex-wrap gap-2">
<el-tag
v-for="role in userAggregate.roles"
:key="role.roleId"
type="success"
size="small"
>
{{ role.roleName }}
</el-tag>
</div>
<el-empty v-else description="暂无角色信息" :image-size="60" />
</div>
</div>
<template #footer>
<el-button @click="dialogVisible = false">
关闭
</el-button>
</template>
</el-dialog>
</template>
```
- [ ] **Step 3: 验证语法**
Run: `cd admin-ui && npx vue-tsc --noEmit --skipLibCheck src/views/user/info/UserDetailDialog.vue`
Expected: 无错误输出
- [ ] **Step 4: Commit**
```bash
git add admin-ui/src/views/user/info/UserDetailDialog.vue
git commit -m "feat(user): use aggregate API in user detail dialog to show depts and roles"
```
---
## Task 4: 用户表单从聚合数据解析已选部门/角色
**Files:**
- Modify: `admin-ui/src/views/user/info/UserFormDialog.vue`
- [ ] **Step 1: 修改 watch 逻辑解析 deptIds 和 roleIds**
`watch(() => props.visible, async (val) => { ... })` 中,修改编辑时的数据处理:
```typescript
watch(() => props.visible, async (val) => {
if (val) {
await Promise.all([loadDeptTree(), loadPostList()])
if (props.row) {
// 从聚合数据解析已选部门ID和角色ID
const deptIds = props.row.depts?.map((d: any) => d.deptId) || []
const roleIds = props.row.roles?.map((r: any) => r.roleId) || []
// 先设置表单数据,确保 rules 能正确计算
form.value = {
...props.row,
password: '',
deptIds: deptIds,
postIds: props.row.postIds || [],
}
}
else {
resetForm()
}
}
})
```
- [ ] **Step 2: 可选 - 移除冗余的 userDeptService 导入**
由于不再需要在表单中调用 `userDeptService.listDeptIdsByUserId`,可以移除该导入(但保留 `assignDepts` 用于保存):
**注意:** 当前代码中没有直接调用 `listDeptIdsByUserId`,所以无需修改导入。`userDeptService` 仍在 `onSubmit` 中被使用。
- [ ] **Step 3: 验证语法**
Run: `cd admin-ui && npx vue-tsc --noEmit --skipLibCheck src/views/user/info/UserFormDialog.vue`
Expected: 无错误输出
- [ ] **Step 4: Commit**
```bash
git add admin-ui/src/views/user/info/UserFormDialog.vue
git commit -m "feat(user): parse deptIds and roleIds from aggregate data in user form"
```
---
## Task 5: 验证和最终检查
- [ ] **Step 1: 运行类型检查**
Run: `cd admin-ui && npx vue-tsc --noEmit --skipLibCheck`
Expected: 无错误输出
- [ ] **Step 2: 运行构建**
Run: `cd admin-ui && npm run build`
Expected: 构建成功,无错误
- [ ] **Step 3: 检查所有变更**
Run: `git log --oneline -5`
Expected: 看到 4 个提交:
- feat(user): add aggregate method to UserService...
- feat(user): add dept/role columns and tree filters to user list page
- feat(user): use aggregate API in user detail dialog...
- feat(user): parse deptIds and roleIds from aggregate data in user form
- [ ] **Step 4: 最终提交(可选,如果需要合并)**
```bash
# 如果需要,可以创建一个合并提交
git log --oneline -5
```
---
## 回滚计划
如果出现问题,可以按以下顺序回滚:
1. 回滚 Task 4: `git revert <task4-commit>`
2. 回滚 Task 3: `git revert <task3-commit>`
3. 回滚 Task 2: `git revert <task2-commit>`
4. 回滚 Task 1: `git revert <task1-commit>`
---
## 测试清单
- [ ] 用户列表页显示部门列(主部门高亮)
- [ ] 用户列表页显示角色列
- [ ] 部门树形筛选正常工作
- [ ] 角色树形筛选正常工作
- [ ] 同时筛选部门和角色正常工作
- [ ] 用户详情弹窗显示完整部门列表
- [ ] 用户详情弹窗显示完整角色列表
- [ ] 编辑用户时正确加载已选部门
- [ ] 编辑用户时正确加载已选角色
- [ ] 保存用户后数据正确刷新
---
**计划状态**: 待评审
**下一步**: 用户评审通过后,使用 superpowers-subagent-driven-development 或 superpowers-executing-plans 执行
@@ -0,0 +1,483 @@
# 前后端智能协作方案设计文档
> **文档版本**: v1.0
> **创建日期**: 2026-06-06
> **适用范围**: rui-frontend 所有前端项目(admin-ui、cashier-mobile、cashier-customer
---
## 一、问题背景
### 1.1 当前痛点
admin-ui 项目中存在**表单字段与后台 API 不同步**的问题:
1. **表单字段硬编码** - 19 个 FormDialog 组件全部采用手动定义字段(如 `UserFormDialog.vue` 中的 `username`, `password`, `userType` 等)
2. **缺少 TypeScript 类型** - Service 层大量使用 `any` 类型,没有与后端 DTO 对应的接口定义
3. **BaseService 泛型未充分利用** - `BaseService<T>` 定义了泛型,但子类没有传入具体类型
4. **API 文档未利用** - 后端已提供 `/v3/api-docs`SpringDoc OpenAPI),但前端未使用
### 1.2 影响
- 后端字段变更时,前端需要手动修改所有相关表单
- 缺少编译时类型检查,运行时容易出错
- 前后端协作效率低,沟通成本高
---
## 二、设计目标
1. **类型安全** - 前端类型与后端 API 自动同步
2. **减少样板代码** - 表单配置化,减少 70% 重复代码
3. **支持自定义扩展** - 复杂表单可自定义字段和逻辑
4. **多模块支持** - 适配微服务架构(聚合启动器 + 独立服务)
5. **自动化检测** - 防止前后端不同步上线
---
## 三、架构设计
### 3.1 整体架构
```
API设计规范.md (单一事实来源)
├── 包含所有 api-docs 地址
└── 由后端团队维护
↓ 自动解析
scripts/parse-api-config.ts
└── 读取并解析 Markdown 中的 URL
└── 识别聚合启动器和独立服务
↓ 动态配置
scripts/generate-api.ts
├── 聚合启动器模块(通过 group 参数获取)
├── 独立服务模块(直接访问)
├── 生成类型定义文件
├── 生成统一导出
└── 生成模块映射
↓ 类型文件
src/types/
├── system-api.d.ts # 系统服务类型
├── user-api.d.ts # 用户服务类型
├── cashier-api.d.ts # 收银服务类型
├── api.d.ts # 统一导出
└── api-modules.json # 模块映射
↓ 类型驱动
前端代码
├── Service 层(类型安全)
├── useApiForm() 组合式函数
├── ApiFormDialog 组件
└── 自定义扩展(插槽机制)
```
### 3.2 核心组件
| 组件 | 职责 | 说明 |
|------|------|------|
| `parse-api-config.ts` | 解析 API 规范文档 | 从 Markdown 读取 api-docs 地址 |
| `generate-api.ts` | 生成类型定义 | 调用 openapi-typescript 生成 .d.ts |
| `useApiForm()` | 类型驱动表单管理 | 组合式函数,自动生成表单配置 |
| `ApiFormDialog` | 配置化表单组件 | 根据字段配置自动渲染表单 |
| `BaseService<T>` | 类型安全 Service 基类 | 保留现有架构,增强类型 |
---
## 四、详细设计
### 4.1 API 配置解析(parse-api-config.ts
**核心功能**:从 `API设计规范.md` 自动读取 api-docs 地址
**聚合启动器识别规则**
- 端口 9399 → 聚合启动器,包含多个模块(/user, /system
- 通过 `?group=xxx` 参数获取子模块
- 其他端口 → 独立服务
**配置结构**
```typescript
interface ApiModuleConfig {
name: string // 模块名:user, system, cashier
url: string // api-docs URL
output: string // 输出路径
prefix: string // API 路径前缀
description: string // 模块描述
aggregator?: string // 所属聚合器(如果有)
}
```
**解析示例**
```markdown
# API设计规范.md
http://localhost:9399/v3/api-docs # 聚合启动器 API 文档(开发调试)
http://localhost:9601/v3/api-docs # 收银服务 API 文档
```
解析结果:
- `9399` → 聚合启动器 → 展开为 user、system 两个模块
- `9601` → 独立服务 → cashier 模块
### 4.2 类型生成(generate-api.ts
**生成流程**
1. 读取 API设计规范.md
2. 识别聚合启动器和独立服务
3. 为每个模块生成类型文件
4. 生成统一导出文件
5. 生成模块映射文件
**输出文件结构**
```
src/types/
├── user-api.d.ts # 用户服务类型(来自聚合启动器)
├── system-api.d.ts # 系统服务类型(来自聚合启动器)
├── cashier-api.d.ts # 收银服务类型(独立服务)
├── api.d.ts # 统一导出
│ └── export type * from './user-api'
│ └── export type * from './system-api'
│ └── export type * from './cashier-api'
└── api-modules.json # 模块映射(用于运行时)
└── {
└── "aggregator": [
└── { "name": "user", "prefix": "/user" },
└── { "name": "system", "prefix": "/system" }
└── ]
└── }
```
### 4.3 类型驱动表单(useApiForm
**核心功能**:根据 TypeScript 类型自动生成表单配置
**接口设计**
```typescript
interface FieldConfig<T = any> {
key: keyof T // 字段名(类型安全)
label: string // 字段标签
type: FieldType // 字段类型
required?: boolean // 是否必填
rules?: any[] // 验证规则
options?: Option[] // 选项(select/radio/checkbox
disabled?: boolean | ((form: T) => boolean)
placeholder?: string
props?: Record<string, any>
}
interface FormConfig<T> {
initial?: Partial<T> // 初始值
fields: FieldConfig<T>[] // 字段配置
onSubmit: (data: T) => Promise<void>
beforeSubmit?: (data: T) => boolean | string
}
function useApiForm<T extends Record<string, any>>(
config: FormConfig<T>
): {
formRef: Ref<FormInstance>
form: Ref<Partial<T>>
rules: ComputedRef<Record<string, any[]>>
loading: Ref<boolean>
handleSubmit: () => Promise<boolean>
resetForm: (initial?: Partial<T>) => void
}
```
### 4.4 配置化表单组件(ApiFormDialog
**核心功能**:根据字段配置自动渲染表单元素
**支持的字段类型**
| 类型 | 组件 | 说明 |
|------|------|------|
| `input` | ElInput | 文本输入 |
| `textarea` | ElInput(type="textarea") | 多行文本 |
| `select` | ElSelect | 下拉选择 |
| `radio` | ElRadioGroup | 单选按钮 |
| `checkbox` | ElCheckboxGroup | 多选框 |
| `number` | ElInputNumber | 数字输入 |
| `tree-select` | ElTreeSelect | 树形选择 |
| `date` | ElDatePicker | 日期选择 |
| `datetime` | ElDatePicker | 日期时间选择 |
**自定义扩展机制**
```vue
<ApiFormDialog
v-model:visible="visible"
v-model:form="form"
:fields="fields"
:rules="rules"
@submit="handleSubmit"
>
<!-- 自定义字段插槽 -->
<template #custom-fields="{ form }">
<el-form-item label="自定义部门">
<el-tree-select v-model="customDeptIds" :data="deptTree" />
</el-form-item>
</template>
</ApiFormDialog>
```
### 4.5 Service 层增强
**保留现有 BaseService 架构**,增强类型安全:
```typescript
// 现有架构保持不变
class BaseService<T = any> {
protected baseUrl: string
async page(params: PageParams & Record<string, any>): Promise<PageResult<T>>
async list(params?: Record<string, any>): Promise<T[]>
async getById(id: number | string): Promise<T>
async add(data: Partial<T>): Promise<T>
async update(data: Partial<T> & { id: number | string }): Promise<boolean>
async remove(id: number | string): Promise<boolean>
}
// 使用时传入具体类型
class UserService extends BaseService<UserDTO> {
constructor() {
super('/user/admin/user')
}
}
```
---
## 五、使用示例
### 5.1 标准表单(完全配置化)
```vue
<script setup lang="ts">
import { useApiForm } from '@/composables/useApiForm'
import { userService } from '@/service/user/userService'
import type { components } from '@/types/user-api'
type UserDTO = components['schemas']['UserDTO']
const { formRef, form, rules, loading, handleSubmit, resetForm } = useApiForm<UserDTO>({
initial: { userType: 1, status: 1 },
fields: [
{ key: 'username', label: '用户名', type: 'input', required: true },
{ key: 'password', label: '密码', type: 'input', required: true, props: { type: 'password' } },
{ key: 'userType', label: '用户类型', type: 'select', options: [
{ label: '普通用户', value: 1 },
{ label: '管理员', value: 2 }
]},
{ key: 'status', label: '状态', type: 'radio', options: [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 }
]}
],
onSubmit: async (data) => {
await userService.add(data)
}
})
</script>
<template>
<ApiFormDialog
v-model:visible="visible"
v-model:form="form"
title="新增用户"
:fields="fields"
:rules="rules"
:loading="loading"
@submit="handleSubmit"
/>
</template>
```
### 5.2 自定义扩展表单
```vue
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useApiForm } from '@/composables/useApiForm'
import { roleService } from '@/service/system/roleService'
import type { components } from '@/types/system-api'
type RoleDTO = components['schemas']['RoleDTO']
const selectedDeptIds = ref<number[]>([])
const showCustomDept = computed(() => form.value.dataScope === 5)
const { form, fields, rules, handleSubmit } = useApiForm<RoleDTO>({
fields: [
{ key: 'roleCode', label: '角色编码', type: 'input', required: true },
{ key: 'roleName', label: '角色名称', type: 'input', required: true },
{ key: 'dataScope', label: '数据范围', type: 'select', options: [
{ label: '全部', value: 1 },
{ label: '自定义', value: 5 }
]}
],
onSubmit: async (data) => {
// 自定义验证
if (data.dataScope === 5 && selectedDeptIds.value.length === 0) {
throw new Error('请选择部门')
}
await roleService.add(data)
}
})
</script>
<template>
<ApiFormDialog
v-model:visible="visible"
v-model:form="form"
:fields="fields"
:rules="rules"
@submit="handleSubmit"
>
<template #custom-fields>
<el-form-item v-if="showCustomDept" label="选择部门" required>
<el-tree-select v-model="selectedDeptIds" :data="deptTree" />
</el-form-item>
</template>
</ApiFormDialog>
</template>
```
---
## 六、自动化机制
### 6.1 Git Hook(提交前检查)
```typescript
// scripts/check-api-changes.ts
function checkApiChanges() {
const currentHash = execSync('git hash-object src/types/*.d.ts').toString()
try {
execSync('pnpm api:generate', { stdio: 'pipe' })
const newHash = execSync('git hash-object src/types/*.d.ts').toString()
if (currentHash !== newHash) {
console.error('❌ API types have changed!')
console.error('Please run "pnpm api:generate" and commit the changes.')
process.exit(1)
}
} catch (error) {
console.warn('⚠️ Could not check API changes')
}
}
```
### 6.2 脚本命令
```json
{
"scripts": {
"api:generate": "tsx scripts/generate-api.ts",
"api:check": "tsx scripts/check-api-changes.ts",
"api:watch": "nodemon --watch ../docs/standards/API设计规范.md --exec 'pnpm api:generate'"
}
}
```
---
## 七、扩展性设计
### 7.1 新增模块
当后端新增模块时,只需在 `API设计规范.md` 中添加:
```markdown
http://localhost:9399/v3/api-docs?group=订单服务 # 订单服务 API 文档
http://localhost:9701/v3/api-docs # 新独立服务 API 文档
```
前端自动识别:
- 端口 9399 → 聚合启动器,通过 `group` 参数获取
- 新端口 9701 → 独立服务,直接访问
### 7.2 新增字段类型
`ApiFormDialog.vue` 中添加新的字段类型渲染逻辑即可。
---
## 八、实施计划
### Phase 1: 基础搭建(1-2 天)
1. 安装依赖:`openapi-typescript`, `tsx`, `nodemon`
2. 创建 `scripts/parse-api-config.ts` - 解析 API 规范
3. 创建 `scripts/generate-api.ts` - 生成类型
4. 配置 package.json 脚本
### Phase 2: 类型生成(1 天)
1. 运行 `pnpm api:generate` 生成初始类型
2. 验证类型正确性
3. 提交生成的类型文件
### Phase 3: 表单工具(2-3 天)
1. 创建 `useApiForm()` 组合式函数
2. 创建 `ApiFormDialog.vue` 组件
3. 编写单元测试
### Phase 4: 试点迁移(2-3 天)
1. 选择 2-3 个简单表单进行迁移
2. 验证方案可行性
3. 收集反馈优化
### Phase 5: 全面推广(1-2 周)
1. 迁移所有标准表单
2. 保留自定义表单的特殊逻辑
3. 编写迁移文档
### Phase 6: 自动化(1 天)
1. 配置 Git Hook
2. 配置 CI/CD 检查
3. 编写使用文档
---
## 九、风险评估
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| 后端 API 文档不准确 | 生成的类型错误 | 建立 API 文档审核机制 |
| 聚合启动器不可用 | 无法生成类型 | 支持独立服务直接访问 |
| 复杂表单迁移困难 | 影响进度 | 保留自定义扩展机制 |
| 团队成员学习成本 | 初期效率下降 | 提供详细文档和示例 |
---
## 十、成功标准
1. ✅ 所有 Service 层使用具体类型替代 `any`
2. ✅ 标准表单代码量减少 70%
3. ✅ 后端字段变更时,前端编译期即可发现
4. ✅ 新增模块时,类型自动生成
5. ✅ 提交时自动检测 API 变更
---
> **下一步**: 编写实现计划(implementation plan
@@ -0,0 +1,469 @@
# SysApp(第三方应用集成)管理界面设计规范
**工单**: rui/rui-frontend#4 — [UI-REQ] 后台增加 SysApp(第三方应用集成)管理界面
**日期**: 2026-06-07
**关联 Issue**: rui/rui-framework#4(文件上传接口待提供,前端先用 JSON 占位)
**优先级**: P1
---
## 1. 目标(Goal
在 admin-ui 后台实现 **SysApp(第三方应用集成)管理模块**,为运营/管理员提供对第三方平台应用(微信、支付宝、Stripe 等)凭证信息的统一管理能力。模块包含列表展示、新增、编辑、删除、启停 5 个标准操作,遵循现有 `oauth2-client` 等模块的 CRUD 模式(`BaseService` + `RuiTable` + `FormDialog`),不引入新依赖。
## 2. 非目标(Non-Goals
明确不在本期范围内的事项:
- **不修改后端**:本 Spec 仅涉及 admin-ui 前端;不修改 rui-framework / rui-cashier 等后端仓库代码或 API。
- **不实现 certificates 文件上传 UI**:因 rui-framework 尚未提供文件上传接口(已提 Issue #4),certificates 字段本期用 JSON textarea 占位实现,标注「待后端文件上传接口就绪后升级」。
- **不实现 isEncrypted 字段 UI**:后端为该字段预留,前端暂不展示。
- **不修改 cashier-mobile / cashier-customer**:本 Spec 仅涉及 admin-ui。
- **不引入 monaco-editor / @guolao/vue-monaco-editor 等新依赖**:证书编辑用原生 textarea + JSON.parse 校验。
- **不修改 rui-framework 菜单 JSON 文件**:工单提到的 `data/menus/system.json` 属于 rui-framework 仓库菜单管理数据,不在本仓库范围内。
- **不做多租户隔离增强**:tenantId 字段由后端按上下文自动填充,前端不主动设置。
## 3. 背景与上下文(Context
### 3.1 后端接口现状
- **Controller**: `SysAppController`,继承 `BaseController`,自动具备 5 个标准操作:
- `GET /system/admin/app/page` — 分页查询
- `GET /system/admin/app/list` — 列表查询
- `GET /system/admin/app/{id}` — 详情
- `POST /system/admin/app` — 新增
- `PUT /system/admin/app` — 修改
- `DELETE /system/admin/app/{id}` — 删除
- `DELETE /system/admin/app/batch` — 批量删除
- `PUT /system/admin/app/status` — 启停
- `GET /system/admin/app/export` — 导出
- `POST /system/admin/app/import` — 导入
- **鉴权**: `@AutoPermission("sys:app")`
- **Swagger**: `http://localhost:9302/swagger-ui.html`
- **后端 commit**: 27fa187(表)+ 29a9389Service/Controller+ 13b20abResult 规范)
### 3.2 前端代码基础
- **BaseService** (`admin-ui/src/service/BaseService.ts`) 已提供完整 CRUD 抽象,子类只需传 baseUrl
```ts
class SysAppService extends BaseService {
constructor() { super('/system/admin/app') }
}
export const sysAppService = new SysAppService()
```
- **RuiTable** 组件支持查询区、工具栏、列配置、slot、分页、导出、列设置、刷新、批量操作等开箱即用能力。
- **OAuth2Client 页面**`views/system/oauth2-client/Index.vue` + `OAuth2ClientFormDialog.vue`)是最相似的现有实现,本期直接照搬其模式。
### 3.3 路由与菜单
- admin-ui 路由采用 **前端硬编码 + i18n 键** 方式(`admin-ui/src/router/modules/system.ts`)。
- 工单中提到的 `data/menus/system.json` 是 rui-framework 后端「菜单管理」功能加载的菜单数据,不影响 admin-ui 路由配置。
## 4. 关键设计决策(用户已确认)
| # | 决策项 | 选定方案 |
|---|--------|---------|
| 1 | 菜单归属 | 作为「系统管理」的子菜单,路由 `/system/app` |
| 2 | 表单布局 | el-tabs 分 4 Tab(基础信息 / 凭证信息 / 接口配置 / 高级) |
| 3 | certificates 字段 | 前端 JSON textarea 占位(待 rui/rui-framework#4 文件上传接口就绪后升级) |
| 4 | 敏感字段(appSecret/appKey/aesKey | 列表展示 6 个星号 `******`,编辑时留空表示不修改 |
## 5. 字段定义(共 21 个,UI 涉及 19 个)
| 字段 | 类型 | 必填 | UI 控件 | Tab | 说明 |
|------|------|------|---------|-----|------|
| id | Long | — | (仅后端) | — | 主键 |
| tenantId | Long | — | (仅后端) | — | 租户ID 0=系统级(自动填充) |
| ownerType | String | 是 | el-select | 1 | PLATFORM / TENANT |
| platform | String | 是 | el-select | 1 | wechat / alipay / stripe |
| name | String | 是 | el-input | 1 | 管理用名称 |
| appId | String | 否 | el-input | 2 | 应用IDUNIQUE |
| appSecret | String | 否 | el-input (password) | 2 | **敏感**:列表脱敏,编辑留空不修改 |
| appKey | String | 否 | el-input (password) | 2 | **敏感** |
| certificates | String | 否 | JSON textarea | 2 | 多证书 JSON 数组(**占位** |
| aesKey | String | 否 | el-input (password) | 2 | **敏感** |
| redirectUri | String | 否 | el-input | 3 | OAuth2 回调地址 |
| merchantId | String | 否 | el-input | 3 | 商户号 |
| signType | String | 否 | el-select | 3 | RSA2 / MD5 / HMAC |
| notifyUrl | String | 否 | el-input | 3 | 支付回调 |
| apiBase | String | 否 | el-input | 3 | API 根地址 |
| isSandbox | 0/1 | 否 | el-switch | 4 | 是否沙箱环境 |
| extra | String | 否 | JSON textarea | 4 | JSON 扩展 |
| isEncrypted | 0/1 | — | **不展示** | — | 预留加密字段(暂不实现) |
| status | 0/1 | 否 | el-switch | 4 | 启用/禁用,默认 1 |
| description | String | 否 | el-input (textarea) | 1 | 备注 |
| sortNo | Int | 否 | el-input-number | 1 | 排序号 |
### 5.1 枚举映射(UI 展示用)
```ts
const platformMap: Record<string, { label: string; type: 'primary' | 'success' | 'warning' }> = {
wechat: { label: '微信', type: 'success' },
alipay: { label: '支付宝', type: 'primary' },
stripe: { label: 'Stripe', type: 'warning' },
}
const ownerTypeMap: Record<string, { label: string; type: 'primary' | 'success' }> = {
PLATFORM: { label: '平台级', type: 'primary' },
TENANT: { label: '租户级', type: 'success' },
}
const signTypeOptions = [
{ label: 'RSA2', value: 'RSA2' },
{ label: 'MD5', value: 'MD5' },
{ label: 'HMAC', value: 'HMAC' },
]
```
## 6. 涉及文件清单(Files To Change
| # | 文件 | 操作 | 用途 |
|---|------|------|------|
| 1 | `admin-ui/src/service/system/sysAppService.ts` | 新建 | 业务 Service,继承 `BaseService('/system/admin/app')` |
| 2 | `admin-ui/src/service/system/index.ts` | 改动 | 追加 `export { sysAppService } from './sysAppService'` |
| 3 | `admin-ui/src/router/modules/system.ts` | 改动 | 新增 `system/app` 路由,meta.i18n 键 `menu.systemApp` |
| 4 | `admin-ui/src/locales/zh-CN.ts` | 改动 | `menu.systemApp: '应用集成'` |
| 5 | `admin-ui/src/locales/en-US.ts` | 改动 | `menu.systemApp: 'App Integration'` |
| 6 | `admin-ui/src/views/system/app/Index.vue` | 新建 | 列表页(RuiTable + 操作工具栏) |
| 7 | `admin-ui/src/views/system/app/SysAppFormDialog.vue` | 新建 | 新增/编辑弹窗(4 Tab) |
**变更统计**:新建 3 个文件,修改 4 个文件。**总计 7 个文件**。
## 7. 列表页设计(`views/system/app/Index.vue`
### 7.1 结构
```vue
<template>
<div>
<h2 class="text-xl font-bold mb-4">{{ $t('menu.systemApp') }}</h2>
<RuiTable
ref="tableRef"
:columns="columns"
:load-data="loadData"
:show-selection="true"
:exportable="true"
export-filename="SysApp应用集成列表"
>
<!-- 查询区、工具栏、列 slot、操作 slot -->
</RuiTable>
<SysAppFormDialog v-model:visible="dialogVisible" :row="currentRow" @success="handleFormSuccess" />
</div>
</template>
```
### 7.2 查询区(4 个条件)
| 控件 | 字段 | 控件类型 | 选项 |
|------|------|---------|------|
| 应用名称 | `name` | `el-input` (clearable) | — |
| 平台 | `platform` | `el-select` (clearable) | wechat / alipay / stripe |
| 所有者类型 | `ownerType` | `el-select` (clearable) | PLATFORM / TENANT |
| 状态 | `status` | `el-select` (clearable) | 启用(1) / 禁用(0) |
### 7.3 列配置(columns
| prop | label | 宽度/最小宽度 | slot | 备注 |
|------|-------|--------------|------|------|
| name | 应用名称 | minWidth 150 | — | — |
| platform | 平台 | width 100 | 是 | 彩色 Tag |
| ownerType | 所有者 | width 100 | 是 | PLATFORM 蓝、TENANT 绿 |
| appId | 应用ID | minWidth 120 | — | 列表不脱敏(UNIQUE 标识) |
| status | 状态 | width 90 | 是 | Switch |
| createdAt | 创建时间 | minWidth 180 | — | dataType='dateTime'sortable='custom' |
> **脱敏说明**appSecret / appKey / aesKey 三个敏感字段**不进入列表列**,仅在编辑弹窗中处理。列表中没有任何明文密钥展示位。
### 7.4 工具栏
- **左侧**:新增应用按钮(`type="primary"`
- **右侧**:批量删除(基于 `show-selection`) + 导出 + 刷新 + 列设置(RuiTable 内置)
### 7.5 行操作
- 编辑
- 删除(ElMessageBox 二次确认,删除成功后 `tableRef.refresh()`
### 7.6 启停切换
```ts
async function handleStatusChange(row: any, status: number) {
if (!row?.id) return
try {
await sysAppService.changeStatus(row.id, status)
ElMessage.success(status === 1 ? '启用成功' : '禁用成功')
} catch {
row.status = status === 1 ? 0 : 1 // 失败回滚
}
}
```
## 8. 表单弹窗设计(`views/system/app/SysAppFormDialog.vue`
### 8.1 弹窗基本属性
- 宽度:`760px`
- title:编辑时「编辑应用」/ 新增时「新增应用」
- 关闭点击遮罩:`close-on-click-modal="false"`
- props`visible: boolean`、`row: any`
- emits`update:visible`、`success`
### 8.2 数据加载流程
```
watch(visible, val => {
if (val) {
if (props.row) {
// 编辑:拉取详情(确保拿到完整字段,包括敏感字段的明文用于编辑回显)
sysAppService.getById(props.row.id).then(data => { form.value = { ...data } })
} else {
// 新增:重置为默认值
form.value = { ...defaultForm }
}
}
})
```
### 8.3 默认值(新增时)
```ts
const defaultForm = {
id: undefined,
ownerType: 'PLATFORM',
platform: 'wechat',
name: '',
appId: '',
appSecret: '',
appKey: '',
certificates: '',
aesKey: '',
redirectUri: '',
merchantId: '',
signType: 'RSA2',
notifyUrl: '',
apiBase: '',
isSandbox: 0,
extra: '',
status: 1,
description: '',
sortNo: 0,
}
```
### 8.4 4 Tab 分布
#### Tab 1:基础信息
```
- name* el-input (必填)
- ownerType* el-select (必填, PLATFORM / TENANT)
- platform* el-select (必填, wechat / alipay / stripe)
- description el-input (textarea, :rows="2")
- sortNo el-input-number
```
#### Tab 2:凭证信息
```
- appId el-input
- appSecret el-input (type="password" show-password)
- appKey el-input (type="password" show-password)
- aesKey el-input (type="password" show-password)
- certificates el-input (type="textarea" :rows="4")
placeholder='[{"name":"cert1","content":"<PEM 内容>"}]'
下方 helper text:「多证书 JSON 数组。格式:[{name, content}]。待后端文件上传接口就绪后升级为文件上传。」
```
> **敏感字段编辑规则**appSecret / appKey / aesKey 三个字段在编辑时**留空 = 不修改原值**。这一规则完全照搬 `OAuth2ClientFormDialog` 的现有做法,保证行为一致。
#### Tab 3:接口配置
```
- redirectUri el-input
- notifyUrl el-input
- apiBase el-input
- merchantId el-input
- signType el-select (RSA2 / MD5 / HMAC)
```
#### Tab 4:高级
```
- isSandbox el-switch (0/1)
- extra el-input (type="textarea" :rows="4")
下方 helper text:「JSON 扩展字段,提交前需通过 JSON 格式校验」
- status el-switch (0/1,默认 1)
```
### 8.5 校验
- **必填字段**`name`、`ownerType`、`platform`
- **JSON 字段**certificates 和 extra 字段在提交前调用 `validateJSON()` 校验,非空时尝试 `JSON.parse`,失败则 `ElMessage.error('xxx 字段 JSON 格式错误')` 并阻止提交。
```ts
function validateJSON(value: string, fieldName: string): boolean {
if (!value || !value.trim()) return true // 空值允许
try {
JSON.parse(value)
return true
} catch {
ElMessage.error(`${fieldName} JSON 格式错误`)
return false
}
}
```
### 8.6 提交逻辑
```ts
async function handleSubmit() {
await formRef.value.validate()
if (!validateJSON(form.value.certificates, 'certificates')) return
if (!validateJSON(form.value.extra, 'extra')) return
loading.value = true
try {
const isEdit = !!form.value.id
const success = isEdit
? await sysAppService.update(form.value as any)
: await sysAppService.add(form.value)
if (success !== false) {
ElMessage.success(isEdit ? '修改成功' : '新增成功')
emit('success')
dialogVisible.value = false
}
} catch {
// 错误已由请求拦截器统一提示
} finally {
loading.value = false
}
}
```
## 9. Service 层设计(`service/system/sysAppService.ts`
完整实现(参考 `oauth2ClientService.ts` 的极简模式):
```ts
import { BaseService } from '../BaseService'
/**
* SysApp(第三方应用集成)服务
*
* <p>负责与后端 /system/admin/app 接口的通信,继承 BaseService 获得标准 CRUD 能力。</p>
*
* <p><b>后续升级路径</b>:后端文件上传接口(rui/rui-framework#4)就绪后,可在此文件追加
* `uploadCertificate(file: File)` 方法,并将表单中 certificates 字段从 JSON textarea
* 升级为文件上传组件。</p>
*/
class SysAppService extends BaseService {
constructor() {
super('/system/admin/app')
}
}
/** SysApp 服务单例 */
export const sysAppService = new SysAppService()
```
并在 `service/system/index.ts` 末尾追加:
```ts
export { sysAppService } from './sysAppService'
```
## 10. 路由与国际化
### 10.1 路由注册
在 `admin-ui/src/router/modules/system.ts` 的 `M` 常量中追加:
```ts
systemApp: 'menu.systemApp',
```
在 `systemRoutes` 数组中追加:
```ts
{ path: 'system/app', name: 'SystemApp', component: () => import('@/views/system/app/Index.vue'), meta: { i18n: M.systemApp } },
```
> 路由位置:放在 `systemOAuth2Client` 之后,保持「集成类」菜单分组相邻。
### 10.2 国际化
`admin-ui/src/locales/zh-CN.ts`(在 `systemOAuth2Client` 后追加):
```ts
systemApp: '应用集成',
```
`admin-ui/src/locales/en-US.ts`(在 `system` block 内对应位置追加):
```ts
systemApp: 'App Integration',
```
## 11. 错误处理
- **统一拦截**:所有 HTTP 错误由 `utils/request` 拦截器统一提示(已存在),前端代码不再额外 try-catch 提示。
- **业务校验**:表单必填、JSON 格式校验在组件内完成,失败用 `ElMessage` 提示。
- **状态回滚**:启停切换失败时,将 `row.status` 回滚到原值。
- **删除确认**:删除前 `ElMessageBox.confirm` 二次确认。
## 12. 测试策略
### 12.1 静态检查
```bash
pnpm --filter admin-ui type-check # 0 errors
pnpm --filter admin-ui lint # 0 errors
```
### 12.2 运行时验证(手动)
1. **启动 dev server**`pnpm dev:admin`
2. **登录** 后访问 `/system/app`(需菜单权限 `sys:app:query`,从后端菜单加载)
3. **列表加载**:默认加载列表数据,列展示正确
4. **查询**:按 name / platform / ownerType / status 过滤,验证结果正确
5. **新增**:点「新增应用」→ 填写各 Tab 必填项 → 提交 → 列表出现新行
6. **编辑**:点行内编辑 → 弹窗加载详情(4 Tab 回显)→ 修改 → 提交 → 列表更新
7. **敏感字段验证**:编辑时不输入 appSecret → 提交后重新打开,明文应保持不变(**由后端「留空不修改」规则保证**)
8. **JSON 字段验证**certificates / extra 输入非 JSON 文本 → 提交时被拦截并提示
9. **启停**:点击状态 Switch → 接口调用 → 列表状态切换;模拟失败时 row.status 回滚
10. **删除**:点击删除 → 二次确认 → 提交 → 行从列表消失
11. **批量删除**:选中多行 → 批量删除 → 全部消失
12. **导出**:点击导出 → 下载 CSV 文件
13. **脱敏验证**:在 devtools Network 面板检查 `/page` 返回的 records**不应**包含 appSecret / appKey / aesKey 明文(这三个字段不进列表列)
### 12.3 验证清单(提交前必过)
- [ ] `pnpm type-check` 通过
- [ ] `pnpm lint` 通过
- [ ] 列表 → 新增 → 编辑 → 启停 → 删除 → 批量删除 完整跑通
- [ ] 列表中不展示任何明文密钥
- [ ] 敏感字段编辑留空后原值保持
- [ ] certificates / extra 非法 JSON 提交被拦截
- [ ] 菜单「应用集成」在侧边栏正确显示,路由跳转正常
## 13. 风险与缓解(Risks And Mitigations
| # | 风险 | 缓解措施 |
|---|------|---------|
| 1 | **后端脱敏返回**:若后端在列表接口已经对 appSecret/appKey/aesKey 做脱敏(如 `******`),则编辑回显时弹窗中拿不到原值 | 列表用 `getById(id)` 拉详情(详情接口通常返回明文),并设计为编辑时强制用户重新输入敏感字段。本期采用「详情接口返回明文,编辑留空不修改」模式;如未来后端详情也脱敏,需在 UI 加「修改敏感字段」开关。 |
| 2 | **certificates JSON 格式错误**:用户提交非合法 JSON 导致后端解析失败 | 提交前 `validateJSON()` 拦截,UI 给出明确错误提示;helper text 提示正确格式。 |
| 3 | **文件上传接口未就绪**certificates 暂用 JSON 占位,用户体验差 | 已在 rui/rui-framework#4 提 Issue;后端接口就绪后再升级为文件上传组件(升级路径已记录在 `sysAppService.ts` 注释中)。 |
| 4 | **路由重复**system.ts 中路由顺序错乱导致菜单不显示 | 路由注册在 systemOAuth2Client 之后;meta.i18n 键值与 locales 文件保持一致。 |
| 5 | **列表数据过大导致性能问题** | 分页由 RuiTable 默认处理(page=1, size=10),无额外风险。 |
| 6 | **前端调用了不存在的接口**:万一后端实际未提供 `import` 接口 | BaseService 自带 `importable` 开关默认 false,不暴露导入按钮。如后端未提供 import 接口则本 UI 不调用即可。 |
## 14. 决策摘要(Decision Summary
- **架构**:照搬 `oauth2-client` 模式(`BaseService` 13 行 + `RuiTable` 列表 + `FormDialog` 弹窗),不引入新依赖、不发明新模式。
- **菜单归属**:系统管理 → 子菜单「应用集成」,路由 `/system/app`。
- **表单布局**el-tabs 4 Tab760px 弹窗。
- **敏感字段**appSecret / appKey / aesKey 列表脱敏 6 星号,编辑留空不修改。
- **certificates 字段**JSON textarea 占位,UI 注释提醒「待后端文件上传接口就绪后升级」。
- **isEncrypted 字段**UI 暂不实现。
- **测试**type-check + lint + 手动跑通完整 CRUD。
- **依赖后端**:仅依赖 rui-framework 既有 `/system/admin/app` 接口;`/system/admin/file/upload`rui/rui-framework#4)就绪后再升级 certificates 体验。
---
**设计评审状态**: 待评审
**下一步**: 用户评审通过后,编写实施计划(Plan)
@@ -0,0 +1,261 @@
# 用户管理接口适配设计规范
**工单**: #2 - 用户管理接口变更通知
**日期**: 2026-06-07
**方案**: 方案 B(完整适配)
---
## 1. 背景
后端已完成用户管理模块接口重构(提交 `dbd04d8`),支持部门、角色联表查询和聚合信息返回。前端需要适配以:
- 减少请求次数(从 3 个请求合并为 1 个)
- 支持部门/角色筛选
- 在列表和详情中展示部门、角色信息
## 2. 后端变更摘要
### 2.1 用户实体扩展字段
```json
{
"id": 1,
"username": "admin",
"phone": "13800138000",
"depts": [
{
"deptId": 1,
"deptCode": "TECH",
"deptName": "技术部",
"main": true
}
],
"roles": [
{
"roleId": 1,
"roleCode": "admin",
"roleName": "管理员",
"dataScope": 1
}
]
}
```
### 2.2 新增接口
- `GET /user/admin/user/{id}/aggregate` - 聚合查询(基础信息 + 部门列表 + 角色列表)
### 2.3 增强接口
- `GET /user/admin/user/page` - 自动返回 `depts``roles`
- `GET /user/admin/user/list` - 自动返回 `depts``roles`
- 支持筛选参数:`deptId``roleId`
## 3. 前端适配范围
### 3.1 用户列表页 (`views/user/info/Index.vue`)
**新增列:**
- 部门列:显示用户所属部门名称(多个部门用逗号分隔,主部门加粗)
- 角色列:显示用户角色名称(多个角色用标签展示)
**新增筛选条件:**
- 部门筛选:树形选择器(`el-tree-select`),支持多选
- 角色筛选:树形选择器(`el-tree-select`),支持多选
**数据流:**
- 列表接口自动返回 `depts``roles`,无需额外请求
- 筛选参数通过 `queryParams` 传递给 `userService.page()`
### 3.2 用户详情弹窗 (`views/user/info/UserDetailDialog.vue`)
**改造:**
- 使用新的聚合接口 `GET /user/admin/user/{id}/aggregate`
- 展示部门列表(部门名称 + 是否主部门标记)
- 展示角色列表(角色名称 + 数据范围)
**数据流:**
```
打开弹窗 → 调用 aggregate 接口 → 展示完整信息
```
### 3.3 用户表单 (`views/user/info/UserFormDialog.vue`)
**优化:**
- 编辑时从 `row.depts` 解析 `deptIds`(替代调用 `userDeptService.listDeptIdsByUserId`
- 编辑时从 `row.roles` 解析 `roleIds`(替代调用 `userService.getRoles`
- 保留 `userDeptService.assignDepts``userPostService.assignPosts` 用于保存
### 3.4 Service 层扩展 (`service/user/userService.ts`)
**新增方法:**
- `aggregate(userId)` - 调用聚合查询接口
## 4. 组件设计
### 4.1 部门/角色展示组件
无需新增组件,直接在表格列中使用 `slot` 渲染:
```vue
<!-- 部门列 -->
<template #column-depts="{ row }">
<el-tag
v-for="dept in row.depts"
:key="dept.deptId"
:type="dept.main ? 'primary' : 'info'"
size="small"
class="mr-1"
>
{{ dept.deptName }}
</el-tag>
</template>
<!-- 角色列 -->
<template #column-roles="{ row }">
<el-tag
v-for="role in row.roles"
:key="role.roleId"
type="success"
size="small"
class="mr-1"
>
{{ role.roleName }}
</el-tag>
</template>
```
### 4.2 筛选区域
```vue
<template #search="{ query: q, search, reset }">
<!-- 现有筛选条件... -->
<!-- 新增部门筛选 -->
<el-form-item label="所属部门">
<el-tree-select
v-model="q.deptId"
:data="deptTree"
check-strictly
node-key="id"
:props="{ label: 'deptName', children: 'children' }"
placeholder="请选择部门"
clearable
style="width: 200px"
/>
</el-form-item>
<!-- 新增角色筛选 -->
<el-form-item label="角色">
<el-tree-select
v-model="q.roleId"
:data="roleTree"
check-strictly
node-key="id"
:props="{ label: 'roleName', children: 'children' }"
placeholder="请选择角色"
clearable
style="width: 200px"
/>
</el-form-item>
</template>
```
## 5. 数据类型定义
```typescript
// 部门信息(嵌套在用户中)
interface UserDept {
deptId: number
deptCode: string
deptName: string
main: boolean
}
// 角色信息(嵌套在用户中)
interface UserRole {
roleId: number
roleCode: string
roleName: string
dataScope: number
}
// 扩展用户类型
interface User {
id: number
username: string
// ... 其他字段
depts?: UserDept[]
roles?: UserRole[]
}
```
## 6. 接口调用变更
### 6.1 列表页
**变更前:**
- 调用 `userService.page(params)` - 仅返回基础信息
**变更后:**
- 调用 `userService.page(params)` - 自动包含 `depts``roles`
- 支持 `deptId``roleId` 筛选参数
### 6.2 详情弹窗
**变更前:**
- 直接使用 `props.row` 数据
**变更后:**
- 打开时调用 `userService.aggregate(props.row.id)`
- 使用返回的完整数据渲染
### 6.3 编辑表单
**变更前:**
```typescript
// 需要额外请求获取部门和角色
const deptIds = await userDeptService.listDeptIdsByUserId(userId)
const roleIds = await userService.getRoles(userId)
```
**变更后:**
```typescript
// 直接从 row 中解析
const deptIds = props.row.depts?.map((d: any) => d.deptId) || []
const roleIds = props.row.roles?.map((r: any) => r.roleId) || []
```
## 7. 错误处理
- 聚合接口失败时,回退到使用 `props.row` 基础信息
- 部门/角色数据缺失时,显示 "-" 或空标签
- 筛选条件不影响现有查询逻辑
## 8. 兼容性
- 原有接口保持不变
- 新增字段通过 `@TableField(exist = false)` 添加,不影响旧逻辑
- 保留 `userDeptService``userPostService` 用于分配功能
## 9. 测试要点
1. 列表页是否正确显示部门和角色信息
2. 部门/角色筛选是否生效
3. 详情弹窗是否正确展示聚合数据
4. 编辑表单是否正确解析已选部门/角色
5. 保存后数据是否正确刷新
## 10. 文件变更清单
| 文件 | 变更类型 | 说明 |
|------|---------|------|
| `views/user/info/Index.vue` | 修改 | 添加部门/角色列和筛选条件 |
| `views/user/info/UserDetailDialog.vue` | 修改 | 使用聚合接口,展示部门/角色详情 |
| `views/user/info/UserFormDialog.vue` | 修改 | 从 row 解析 deptIds/roleIds |
| `service/user/userService.ts` | 修改 | 添加 aggregate 方法 |
---
**设计评审状态**: 待评审
**下一步**: 用户评审通过后,编写实施计划
+24
View File
@@ -0,0 +1,24 @@
# Specs 索引
本目录存放已通过评审的设计规范(Spec)。每份 Spec 对应一个工单/需求,配套 Plan 在 `../plans/` 目录。
## 列表
| 日期 | 标题 | 工单 | 状态 |
|------|------|------|------|
| 2026-06-06 | [API 协作工作流设计](2026-06-06-api-collaboration-design.md) | 内部规范 | 已评审 |
| 2026-06-07 | [用户管理接口适配设计](2026-06-07-user-management-api-adaptation-design.md) | rui/rui-frontend#2 | 已评审 |
| 2026-06-07 | [SysApp 应用集成管理设计](2026-06-07-sysapp-management-design.md) | rui/rui-frontend#4 | 待评审 |
## 命名规范
- 文件名:`YYYY-MM-DD-<topic>-design.md`
- 目录:`docs/superpowers/specs/`
- 配套 Plan`docs/superpowers/plans/YYYY-MM-DD-<topic>-plan.md`
## 状态
- **待评审**:Spec 刚写完,等待用户/团队评审
- **已评审**:用户已批准,可进入 Plan 阶段
- **已实施**:对应 Plan 已执行完成
- **已归档**:功能上线,文档归档