feat: 标准资源使用独立页面并优化详情布局

This commit is contained in:
czl231
2026-08-11 00:49:41 +08:00
parent 7242048abf
commit 13daa996a2
39 changed files with 3826 additions and 2106 deletions

View File

@@ -6,6 +6,7 @@ import (
"git.apinb.com/bsm-sdk/core/middleware"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/upload"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
@@ -61,6 +62,26 @@ func GetPlatformAccount(ctx *gin.Context) {
infra.Response.Success(ctx, common.ProtectPreciseLocation(ctx, &models.PlatformAccount{}, view))
}
// GetPlatformAccountAvatar 返回已鉴权平台账户资料页使用的头像二进制内容。
func GetPlatformAccountAvatar(ctx *gin.Context) {
claims, err := middleware.ParseAuth(ctx)
if err != nil {
infra.Response.Error(ctx, err)
return
}
// 平台账户头像沿用账户资料权限root 可查看全部,普通管理员只可查看自己。
if claims.Role != "root" && claims.Identity != ctx.Param("identity") {
infra.Response.Error(ctx, errcode.ErrPermissionDenied)
return
}
var account models.PlatformAccount
if err := common.ActiveRecords(impl.DBService).Select("avatar").Where("identity = ?", ctx.Param("identity")).First(&account).Error; err != nil {
common.RespondRecordError(ctx, err)
return
}
upload.ServeAvatar(ctx, account.Avatar)
}
func CreatePlatformAccount(ctx *gin.Context) {
if !common.RequirePlatformRoot(ctx) {
return
@@ -95,7 +116,7 @@ func CreatePlatformAccount(ctx *gin.Context) {
func UpdatePlatformAccount(ctx *gin.Context) {
var request struct {
DisplayName string `json:"display_name" binding:"max=64"`
Avatar string `json:"avatar" binding:"max=512"`
Avatar *string `json:"avatar" binding:"omitempty,max=512"`
PlatformRoleCode *string `json:"platform_role_code" binding:"omitempty,max=64"`
Phone string `json:"phone" binding:"max=32"`
}
@@ -112,7 +133,11 @@ func UpdatePlatformAccount(ctx *gin.Context) {
infra.Response.Error(ctx, errcode.ErrPermissionDenied)
return
}
values := gin.H{"display_name": request.DisplayName, "avatar": request.Avatar, "phone": request.Phone}
values := gin.H{"display_name": request.DisplayName, "phone": request.Phone}
// 未选择新头像时不提交 avatar避免普通资料编辑误清空现有头像。
if request.Avatar != nil {
values["avatar"] = *request.Avatar
}
if request.PlatformRoleCode != nil {
if !common.RequirePlatformRoot(ctx) {
return

View File

@@ -202,6 +202,7 @@ func registerPlatformRoute(group *gin.RouterGroup) {
account.GET("", platformlogic.ListPlatformAccount)
account.POST("", platformlogic.CreatePlatformAccount)
account.GET("/:identity", platformlogic.GetPlatformAccount)
account.GET("/:identity/avatar", platformlogic.GetPlatformAccountAvatar)
account.PUT("/:identity", platformlogic.UpdatePlatformAccount)
account.PATCH("/:identity/status", platformlogic.UpdatePlatformAccountStatus)
account.DELETE("/:identity", platformlogic.ArchivePlatformAccount)

View File

@@ -111,6 +111,7 @@ func TestPlatformOrganizationAndAccountRoutesExposeResourceCRUD(t *testing.T) {
assertRouteMethods(t, routes, "/heqi/platform/v1/platform_role/:identity/menu", http.MethodGet, http.MethodPut)
assertRouteMethods(t, routes, "/heqi/platform/v1/staff_account/:identity/avatar", http.MethodGet)
assertRouteMethods(t, routes, "/heqi/platform/v1/user_account/:identity/avatar", http.MethodGet)
assertRouteMethods(t, routes, "/heqi/platform/v1/platform_account/:identity/avatar", http.MethodGet)
assertNoRouteMethods(t, routes, "/heqi/platform/v1/platform_role/:identity/menus", http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)
assertNoRouteMethods(t, routes, "/heqi/platform/v1/platfrom_account", http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)
}

View File

@@ -110,6 +110,14 @@
当前资源契约共 48 个资源。下表中的路径均相对于 `/heqi/platform/v1`
标准列表资源统一使用独立页面承载新建、详情和编辑,不再从列表打开抽屉。浏览器路径分别为“列表路径 `/new`”“列表路径 `/:identity`”“列表路径 `/:identity/edit`”;页面通过 `return_to` 保留来源列表、筛选上下文和返回位置。详情采用响应式信息分区与关联子表,编辑和新建采用双列表单。审核、启停、归档、重置密码、流程流转等短操作继续使用确认框或模态框,不与基础资料保存混合。树形资源仍沿用树页面交互。
新建和编辑页使用全宽信息卡,内部表单内容最大宽度为 `1440px`,并按内容容器宽度在双列与单列之间切换。页面内容从顶部自然排列,只保留面包屑和右侧返回按钮,不重复展示“新建/编辑资源名称”及操作说明;字段和底部操作按钮保持统一基线。账户头像摘要和详情页展示结构不随该表单布局调整而改变。
标准详情页同样只保留面包屑、编辑和返回操作,不重复展示“详情资源名称”及说明文字。详情卡片按内容自然高度从顶部排列,统一使用 `12px` 间距和紧凑内边距;基本信息在大屏、中屏和小屏分别采用三列、两列和单列。未开通钱包使用单行提示,气站启停继续保留独立状态管理卡片,不改变原有接口和权限校验。
新建与编辑使用不同字段规则。唯一标识、用户名、创建编码、不可变归属等创建后锁定字段只读展示且禁止修改;后端更新协议要求原归属标识时,页面只会原样回传该字段。合同状态、检修结果、系统角色和平台权限等限制同时由页面和后端校验。编辑页离开前检测未保存内容,详情页的聚合子表、钱包摘要、气站状态开关及资源专属业务动作继续保留。
### 6.1 机构管理
| 资源 | 路径 | 模式 | 已实现能力 |
@@ -130,7 +138,7 @@
工作人员支持安装人员 `installer`、配送人员 `delivery`、运维人员 `operations` 三种角色,可关联气站或配送点,并记录在岗/离岗状态。前端按角色提供独立列表和新增入口;后端按菜单和人员实际角色校验详情、修改及资质访问。
工作人员详情编辑使用独立资料页,不再使用列表抽屉。资料页默认只读,可切换编辑状态;顶部展示头像、用户名、唯一标识和创建时间,下方维护现有基本信息头像仅支持 JPG/PNG、本地预览和保存时上传读取继续受平台 JWT、菜单及人员角色权限保护。
工作人员的新建、详情编辑使用独立页面。账户页面顶部展示头像、用户名、唯一标识和创建时间,下方维护现有基本信息与钱包摘要;头像仅支持 JPG/PNG、本地预览和保存时上传读取继续受平台 JWT、菜单及人员角色权限保护。用户名和密码仅用于创建,编辑时用户名只读且密码不回填。
### 6.3 用户管理
@@ -142,7 +150,7 @@
用户页面同时提供配送合同入口。当前后台可直接维护用户、地址和服务关系,但尚未实现邀请二维码注册、服务关系审批和完整历史时间线。
用户账户详情编辑复用工作人员资料页布局,默认只读并支持页内编辑。用户头像通过受保护资源接口读取,不在通用详情响应中暴露头像 URI新增用户仍沿用现有新增抽屉
用户账户的新建、详情编辑复用工作人员账户布局,并使用各自独立 URL。用户头像通过受保护资源接口读取,不在通用详情响应中暴露头像 URI详情页继续展示钱包摘要并可进入钱包独立详情页
### 6.4 智能气阀管理

View File

@@ -121,7 +121,7 @@
- 银行卡号、身份证号、预留手机号使用 `Global.FieldEncryptionKey` 经 HKDF 派生独立 AES-GCM 加密键和 HMAC 指纹键;接口列表只返回末四位掩码。开发占位密钥不得用于生产。
- 支付密码独立于登录密码,仅允许六位数字,使用 bcrypt 保存;连续失败达到阈值后在 Redis 短时锁定。绑卡、解绑、余额支付和提现均要求支付密码或限定用途的一次性验证码。
- 公共上传接口 `/upload/file` 必须携带平台、气站、配送点、用户或工作人员任一合法 JWT图片/PDF 最大 10MB视频上限从配置读取。上传只返回资源 URI业务接口负责建立关联并记录操作者、采集与接收时间。
- 平台账户资料头像使用专用 `/upload/avatar` 上传入口,仅允许真实 JPG/PNG、最大 2MB、最大 4096×4096并在服务端完成扩展名、MIME、尺寸和完整图片解码校验。头像读取通过 `/heqi/platform/v1/{staff_account|user_account}/:identity/avatar` 受 JWT、菜单和对象角色权限保护通用列表及详情响应继续移除 `avatar` 字段。
- 平台账户资料头像使用专用 `/upload/avatar` 上传入口,仅允许真实 JPG/PNG、最大 2MB、最大 4096×4096并在服务端完成扩展名、MIME、尺寸和完整图片解码校验。头像读取通过 `/heqi/platform/v1/{staff_account|user_account|platform_account}/:identity/avatar` 受 JWT、菜单和对象角色权限保护通用列表及详情响应继续移除 `avatar` 字段。普通资料更新未提交 `avatar` 时保持原头像,只有明确上传或恢复默认头像时才修改该字段。
- 充值、支付、提现、工单证据、轨迹点、内容确认等写入均携带幂等号;资金入账在数据库事务内锁定钱包并同时写不可变流水。
- 钱包可提现余额是当前总余额的子集,始终满足 `0 <= 可提现余额 <= 总余额`。普通消费扣减总余额后,必须同步把可提现余额限制在剩余总余额以内。
- 提现申请在同一数据库事务内锁定钱包、同时预扣总余额和可提现余额并写入不可变流水;驳回只返还该申请实际预扣的两类余额,完成打款只确认外部结果,不得再次扣款。

View File

@@ -0,0 +1,67 @@
# 标准资源全页改造操作日志
## 记录一:范围确认与现状检查
操作时间2026-08-10
操作类型:扩展
影响模块:平台总后台标准资源管理
操作前状态:工作人员和用户账户已有独立资料页,其余标准资源的新建、详情和编辑主要使用 `CrudListPage.vue` 内的抽屉;机构账户管理使用二级模态框。
具体操作:盘点 48 个资源契约、三类管理端路由、后端 Create/Update DTO、聚合详情、钱包摘要、气站状态、详情动作和树形资源差异按用户确认将范围限定为 5173。
操作后状态:确定 46 类标准列表资源详情、25 类新建和 23 类编辑的全页迁移边界;树形资源和短操作保留原交互。
风险评估:创建字段与更新字段不一致是主要风险,采用显式资源字段规则和保存后详情复读缓解。
## 记录二:路由与共享页面改造
操作时间2026-08-10
操作类型:新增、修改
影响模块:`frontend/platform_admin`
具体操作:
- 新增标准资源路由生成器,统一创建 `/new``/:identity``/:identity/edit` 隐藏子路由。
- 新增共享资源页面、详情内容、双列表单、账户摘要、钱包摘要、业务动作、关系加载、头像和未保存保护组件。
-`CrudListPage.vue` 收敛为列表与短操作入口,移除标准 CRUD 抽屉和账户二级模态框。
- 将机构账户管理改为带归属过滤条件的隐藏列表及独立 CRUD 页面。
- 删除已被共享页面替代的 `AccountProfilePage` 和未使用的 `ReadOnlyListPage`
- 拆分仪表盘、财务和资源路由构建文件,控制主要代码文件规模。
操作后状态:所有标准资源的详情、新建和编辑均从列表导航到独立 URL详情动作、审核、启停、归档等短操作保持原有模态交互。
风险评估:路由数量增加但由构建器统一生成,并通过资源覆盖脚本检查,降低漏配和路径漂移风险。
## 记录三:字段规则与安全兼容
操作时间2026-08-10
操作类型:扩展
影响模块:资源表单、导航、平台账户头像
具体操作:
- 为可编辑资源逐项声明更新字段、创建隐藏字段、编辑必填和不可变归属。
- 增加合同、检修、系统角色和平台角色权限的动态编辑限制。
- 增加智能气阀归属变更专属动作,要求填写动作、原因和备注。
- 增加安全 `return_to`、404/403 中文错误页、密码长度校验和未保存离开确认。
- 增加平台账户受控头像读取路由;平台账户更新未提交头像时保持原头像。
操作后状态:编辑请求只提交服务端允许更新的字段,详情继续使用受保护接口和脱敏响应。
风险评估:附件 URI 仍受保护且没有受控下载接口,因此本次明确不提供附件预览,避免绕过资源权限。
## 记录四:验证
操作时间2026-08-10
操作类型:验证
影响模块:前端、后端、浏览器交互
验证结果:
- `npm.cmd run resource-pages:check`:通过,详情 46 类、新建 25 类、编辑 23 类,列表无标准 CRUD 抽屉。
- `npm.cmd run contract:check`通过48 个前后端资源契约一致。
- `npm.cmd run type:check`通过Vue 与 TypeScript 类型检查无错误。
- Biome 新增文件错误级检查:通过,无错误级诊断。
- `npm.cmd run build`通过Vue 类型检查和 Vite 生产构建成功。
- `go test ./...`:通过,后端全部包测试成功。
- 浏览器:通过用户详情/编辑/新建、钱包详情跳转、机构账户管理、气站状态区、404 中文提示、未保存“继续编辑/确认放弃”和 `/staff/add` 独立页检查。
- 数据安全:浏览器回归未执行创建、保存、启停、归档或流程动作,没有修改测试业务数据。
边界案例:聚合详情子表在本地缺少完整订单/合同样本,已完成代码结构检查和生产构建,后续使用固定测试数据补充端到端验证。
风险评估本次只改平台总后台5175、5176 的标准资源仍沿用原交互,不受影响。

View File

@@ -0,0 +1,50 @@
# 标准资源表单布局优化操作日志
操作时间2026-08-11
操作类型:修改
影响模块:平台总后台标准资源新建、编辑页
## 操作前状态
- 新建和编辑页使用 `1080px` 最大宽度卡片,在宽屏主内容区两侧产生大面积灰色空白。
- 面包屑已经表达“资源列表 / 新建或编辑”,页头又重复展示操作大标题及说明文字,下方卡片仍有“基本信息”标题,形成三层重复信息。
- 页面根节点为网格布局但没有限制纵向内容对齐,在确定高度下可能拉伸自动行,造成顶部和底部空白。
- 表单只按浏览器视口宽度切换单双列,没有考虑左侧菜单占用后的真实内容宽度。
- 保存和取消按钮使用底部吸附定位,长表单可能出现覆盖或视觉脱节。
## 具体操作
- 修改 `frontend/platform_admin/src/views/resource/ResourceRecordPage.vue`
- 仅为新建、编辑模式增加专用布局类。
- 增加共享 `form-shell` 内容容器,使卡片标题、字段和按钮使用同一基线。
- 新建、编辑模式隐藏重复的操作大标题和说明文字,详情模式继续保留原有标题区。
- 保留原字段组件、校验、保存和取消事件,不调整业务逻辑。
- 修改 `frontend/platform_admin/src/views/resource/ResourceRecordPage.less`
- 表单卡片改为主内容区全宽,内部内容最大宽度为 `1440px`
- 页面网格使用顶部自然对齐,并压缩新建、编辑页头间距。
- 桌面卡片内边距调整为横向 `32px`,小屏调整为 `16px`
- 表单内容以 `900px` 容器宽度作为单双列切换阈值。
- 保存和取消按钮恢复普通文档流定位。
## 操作后状态
- 宽屏下白色表单卡片与主内容区对齐,截图标注的顶部及左右大面积灰色空白已消除。
- 新建、编辑页顶部仅保留面包屑和返回按钮,表单卡片继续显示“基本信息”,页面层级更简洁;详情页标题和编辑入口保持不变。
- 输入区仍由最大宽度限制控制可读性,不会在超宽屏无限拉长。
- 账户头像和身份摘要保持现有居中设计,详情页和业务操作弹窗不受影响。
- 地址、备注、条款等长字段继续独占整行;普通字段根据容器宽度自动切换双列或单列。
## 验证结果
- `npm.cmd run type:check`:通过。
- `npm.cmd run build`通过Vite 生产构建包含 `@container record-form` 响应式规则。
- `npm.cmd run resource-pages:check`:通过,详情 46 类、新建 25 类、编辑 23 类。
- `npm.cmd run contract:check`通过48 个资源契约一致。
- Biome 错误级检查:通过。
- 浏览器回归:气站、配送点新建页卡片全宽且顶部自然排列;工作人员新建页头像摘要未变化,表单同步采用全宽布局;配送点新建页和用户编辑页均不再显示重复操作标题,用户详情页标题、说明及编辑入口保持原状;控制台无警告或错误;未执行保存操作。
## 风险评估
- 本次仅修改平台总后台 5173 的共享新建、编辑布局,不改变字段、校验、接口、数据库或详情页。
- 容器查询依赖现代浏览器;当前项目运行环境和生产构建均支持该 CSS 能力。
- 气站后台 5175、配送点后台 5176 和树形资源页面保持原状。

View File

@@ -0,0 +1,51 @@
# 标准资源详情页布局优化操作日志
操作时间2026-08-11 00:40:15
操作类型:修改
影响模块:平台总后台标准资源详情页
## 操作前状态
- 详情页同时显示“列表 / 详情”面包屑、操作大标题和说明文字,信息表达重复。
- 页面网格未限制纵向内容对齐,内容较少时标题区和多张卡片会被剩余视口高度拉伸,产生大块空白。
- 未开通钱包使用大尺寸空状态,占据过多纵向空间。
- 基本信息、钱包和状态管理卡片的标题高度与内容内边距缺少统一约束。
## 具体操作
- 修改 `frontend/platform_admin/src/views/resource/ResourceRecordPage.vue`
- 删除所有标准详情页重复的操作大标题和说明文字。
- 保留面包屑、编辑、返回、气站启停和资源业务动作。
- 移除不再使用的标题计算与说明文字依赖。
- 修改 `frontend/platform_admin/src/api/resource-display.ts`:删除已无调用方的页面说明文字函数,保留模式名称和错误提示格式化能力。
- 修改 `frontend/platform_admin/src/views/resource/ResourceRecordPage.less`
- 页面网格统一采用顶部自然对齐和 `12px` 间距。
- 统一业务操作、状态管理卡片的标题高度和内容内边距。
- 修改 `frontend/platform_admin/src/views/resource/ResourceDetailContent.vue`
- 详情卡片统一为紧凑标题和内容间距。
- 保留大屏三列、中屏两列、小屏单列的响应式信息布局。
- 修改 `frontend/platform_admin/src/views/resource/ResourceWalletSummary.vue`
- 未开通钱包改为小图标和单行提示。
- 保留已开通钱包摘要及详情入口。
## 操作后状态
- 详情页顶部只保留导航和操作,页面层级与新建、编辑页一致。
- 基本信息、钱包、状态管理和业务操作卡片均按内容自然高度紧凑排列。
- 钱包和气站状态能力不变,仅调整展示密度。
## 验证结果
- `npm.cmd run type:check`:通过。
- `npm.cmd run build`通过Vite 生产构建完成。
- `npm.cmd run resource-pages:check`:通过,详情 46 类、新建 25 类、编辑 23 类。
- `npm.cmd run contract:check`通过48 个资源契约一致。
- 浏览器回归:截图对应气站详情不再显示重复标题,页面网格为顶部自然对齐;基本信息、未开通钱包、状态管理卡片按内容高度排列且间距为 `12px`
- 已开通钱包账户详情继续显示钱包标识、余额、可提现余额、状态和详情入口。
- 响应式验证:`1440px` 三列、`1000px` 两列、`700px` 单列;浏览器控制台无警告或错误;未执行编辑、启停或保存操作。
## 风险评估
- 本次只修改平台总后台 5173 的标准详情页布局,不改变接口、数据库、权限、状态流转或表单保存逻辑。
- 聚合子表、账户摘要、钱包详情入口和资源专属业务动作继续使用原组件和事件。
- 气站后台 5175、配送点后台 5176 和树形资源页面保持原状。

View File

@@ -0,0 +1,140 @@
# 标准资源全页管理项目文档 v1.0
## 1. 项目概述
- 项目名称:平台总后台标准资源全页管理。
- 实施范围:仅端口 5173 的 `frontend/platform_admin`;气站后台、配送点后台和树形资源不在本次范围。
- 主要功能:把标准资源的新建、详情和编辑从列表抽屉迁移为独立 URL 页面,同时保留审核、归档、重置密码和流程流转等短操作。
- 技术栈Vue 3、TypeScript、Vue Router、Arco Design、Less、Go、Gin、GORM。
- 资源覆盖48 个资源契约中46 类标准列表资源提供详情页25 类提供新建页23 类提供编辑页;`ec_category``platform_menu` 两类树形资源保持树页面交互。
## 2. 页面与路由约定
标准资源路由由列表路由自动扩展,三类 URL 约定如下:
```text
列表路径/new # 新建页
列表路径/:identity # 详情页
列表路径/:identity/edit # 编辑页
```
- 路由元数据使用 `resource``recordMode``listRouteName` 驱动共享页面。
- 隐藏详情路由通过 `activeMenu` 保持来源菜单高亮。
- `return_to` 只接受站内绝对路径,防止开放重定向,并支持从钱包、机构账户等关联页面返回原记录。
- 旧的账户资料 `?mode=edit` 地址会转换到 `/edit`,保留已有书签兼容性。
- `/staff/add``/gasorder/create` 保留原业务入口名称,但直接渲染独立新建页。
## 3. 目录结构
```text
platforms/
├── frontend/platform_admin/
│ ├── scripts/
│ │ ├── check-backend-contract.mjs # 前后端资源契约检查
│ │ └── check-resource-pages.mjs # 独立页面覆盖与抽屉残留检查
│ └── src/
│ ├── api/
│ │ ├── resource-page-rules.ts # 新建、编辑、只读和状态限制规则
│ │ ├── resource-navigation.ts # 页面地址与安全返回路径
│ │ ├── resource-display.ts # 字段、状态、金额和日期展示
│ │ └── resource-record-form.ts # 表单初始化与页面校验
│ ├── router/routes/modules/
│ │ ├── resource-route-builder.ts # 自动生成详情、新建和编辑路由
│ │ ├── dashboard-route.ts # 仪表盘路由分组
│ │ ├── finance-route.ts # 钱包与财务路由分组
│ │ └── platform.ts # 平台业务列表路由
│ └── views/
│ ├── resource/ # 共享全页详情、表单、动作和摘要组件
│ └── shared/CrudListPage.vue # 列表与全页跳转入口
├── backend/api/internal/
│ ├── logic/platform/platform/account.go # 平台账户头像受控读取
│ └── routers/platform.go # 平台账户头像路由
└── docs/ # 同步需求、安全、项目与操作日志
```
## 4. 核心设计
### 4.1 显式页面规则
`resource-page-rules.ts` 按“资源 + 字段”声明能力,不通过字段名猜测更新权限:
- `identity` 永不进入创建或更新请求。
- 账户用户名、密码和业务创建编码按服务端 DTO 设为仅创建或只读。
- 工作人员资质所属人员、用户地址所属用户、服务关系所属用户、检修产品等归属字段在编辑时锁定;后端协议必传时只原样回传。
- 智能气阀归属变更使用专属业务动作并提交动作、原因和备注,不混入普通编辑。
- 配送合同仅草稿可编辑;已完成检修只允许维护备注;系统角色和平台角色权限按当前操作者锁定。
- 保存时只从允许更新字段构造请求,保存后重新读取详情校验服务端结果。
### 4.2 详情内容
- 详情页顶部只保留“列表 / 详情”面包屑、编辑和返回操作,不重复展示“详情资源名称”及说明文字。
- 页面网格从顶部按内容自然排列,详情卡片统一使用 `12px` 间距、圆角、标题高度和内容内边距,不随剩余视口高度拉伸。
- 普通资源使用响应式三列信息区,金额、状态、日期、布尔和关系字段统一格式化。
- 聚合详情中的数组继续以页签和表格展示,例如合同产品、修订记录和订单轨迹。
- 气站详情保留独立的紧凑启停区域;钱包归属资源保留钱包摘要和钱包详情入口,未开通钱包时显示单行紧凑提示。
- 工作人员、用户和平台账户保留头像、用户名、唯一标识、创建时间的账户摘要。
- 资源专属流程动作继续使用短模态框,危险动作显示风险提示,平台角色菜单分配保留专属适配。
### 4.3 新建与编辑
- 新建和编辑卡片铺满主内容区,卡片内部使用最大 `1440px` 的内容容器控制输入框行长;标题、字段和操作按钮保持同一左基线。
- 表单根据容器实际宽度响应:内容区达到约 `900px` 时使用双列,否则切换为单列;地址、备注、条款、参数和正文等长字段独占整行。
- 页面网格从顶部自然排列;新建、编辑页只保留“列表 / 当前操作”面包屑和右侧返回按钮,不重复展示操作大标题及说明文字;保存和取消按钮位于表单末尾,不吸附浏览器底部。
- 账户类资源继续保留头像和身份摘要的居中结构,详情页布局不受表单优化影响。
- 编辑页展示创建后不可修改字段,但禁用字段不会进入更新请求。
- 必填、密码最短 6 位、关系选项和金额转换复用统一校验与负载构建逻辑。
- 页面检测表单和头像变更;返回、取消或浏览器路由离开时均提示是否放弃未保存内容。
- 机构“账户管理”不再打开二级模态框,而是进入带机构过滤条件的隐藏账户列表及其独立 CRUD 页面。
### 4.4 安全与兼容
- 详情始终调用现有受保护资源接口,不使用列表缓存绕过脱敏和对象权限。
- 头像继续通过专用上传与受控读取接口;平台账户补充同等头像读取能力。
- 平台账户普通编辑没有提交头像时保持原值,避免更新其他资料时意外清空头像。
- `return_to` 拒绝协议相对地址和站外地址。
- 附件查看未接入:现有通用响应会剥离敏感 URI待受控下载接口完成后再扩展。
## 5. 行为变化
| 场景 | 变更前 | 变更后 |
| --- | --- | --- |
| 标准资源详情 | 列表内详情抽屉 | 独立详情 URL |
| 标准资源新建/编辑 | 共用表单抽屉 | 独立新建/编辑 URL |
| 账户管理 | 列表内二级模态框 | 隐藏账户列表与独立页面 |
| 审核、启停、删除 | 模态框或确认框 | 保持不变 |
| 树形资源 | 树页面抽屉 | 保持不变 |
| 返回来源 | 关闭抽屉 | 安全 `return_to` 返回 |
| 未保存修改 | 关闭即丢失 | 离开前二次确认 |
## 6. 验证方法
`frontend/platform_admin` 执行:
```powershell
npm.cmd run resource-pages:check
npm.cmd run contract:check
npm.cmd run build
```
`backend/api` 执行:
```powershell
go test ./...
```
浏览器回归至少覆盖:标准列表进入详情、详情进入编辑、新建页、只读资源 404、钱包跳转、机构账户管理、未保存取消/确认、`/staff/add` 专用入口和小屏单列布局。
## 7. 维护指南
- 新增资源时先维护资源契约和 `resources.ts`,再在页面规则中显式声明可编辑字段。
- 服务端 Update DTO 变化时必须同步 `resource-page-rules.ts`,禁止直接把创建字段复用于更新。
- 新增详情专属内容时优先扩展详情区块或动作适配器,不把长流程塞回列表。
- 新增关联跳转时必须使用安全返回路径工具,不直接信任查询参数。
- 新增标准资源后必须运行页面覆盖检查,保证详情、新建和编辑能力与资源模式一致。
## 8. 已知边界
- 气站后台 5175 和配送点后台 5176 尚未迁移。
- 树形资源仍保留现有抽屉与确认交互。
- 附件下载、预览和权限签名不在本次实现范围。
- 聚合子表在本地没有完整业务样本时主要依赖结构检查和生产构建,后续应补充固定测试数据的端到端用例。

View File

@@ -13,6 +13,7 @@
"type:check": "vue-tsc -p tsconfig.build.json --noEmit --skipLibCheck",
"contract:sync": "node scripts/sync-backend-contract.mjs",
"contract:check": "node scripts/check-backend-contract.mjs",
"resource-pages:check": "node scripts/check-resource-pages.mjs",
"audit:platform": "node scripts/check-backend-contract.mjs",
"lint": "biome lint .",
"lint:fix": "biome lint --write .",

View File

@@ -1,21 +1,36 @@
import { readFileSync } from 'node:fs';
/**
* 功能:校验平台总后台资源定义、页面路由与后端资源契约保持一致。
* 版本v1.1.0
*/
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const source = readFileSync(resolve(root, 'src/api/resources.ts'), 'utf8');
const routes = readFileSync(resolve(root, 'src/router/routes/modules/platform.ts'), 'utf8');
const routeFiles = ['platform.ts', 'finance-route.ts'];
const routes = routeFiles
.map((name) =>
readFileSync(resolve(root, 'src/router/routes/modules', name), 'utf8'),
)
.join('\n');
const contract = JSON.parse(
readFileSync(resolve(root, 'src/contracts/platform-resources.json'), 'utf8'),
);
const backend = new Map(contract.resources.map((item) => [item.name, item]));
const frontendDefinitions = [
...source.matchAll(/define\('([^']+)',\s*'[^']+',\s*'([^']+)'/g),
...source.matchAll(
/define\(\s*'([^']+)'\s*,\s*'[^']+'\s*,\s*'([^']+)'/g,
),
].map((match) => ({ name: match[1], mode: match[2] }));
const frontendNames = frontendDefinitions.map((item) => item.name);
const embeddedResources = new Set([
'wallet_basic', 'wallet_bank', 'payment_order', 'wallet_record',
'payment_refund', 'wallet_apply_cash',
'wallet_basic',
'wallet_bank',
'payment_order',
'wallet_record',
'payment_refund',
'wallet_apply_cash',
]);
for (const name of frontendNames) {
@@ -38,11 +53,23 @@ for (const item of contract.resources) {
}
const forbidden = [
'/platform/platform_', '/gas/gas_', '/delivery/delivery_', '/staff/',
'/user/', '/ec/ec_', '/finance/fin_', '/wallet/wallet',
'delivery_task', 'delivery_track', 'delivery_track_point',
'dev_device_binding', 'dev_smart_cylinder_valve', 'dev_telemetry',
'wallet_ledger', 'wallet_recharge', 'wallet_withdrawal',
'/platform/platform_',
'/gas/gas_',
'/delivery/delivery_',
'/staff/',
'/user/',
'/ec/ec_',
'/finance/fin_',
'/wallet/wallet',
'delivery_task',
'delivery_track',
'delivery_track_point',
'dev_device_binding',
'dev_smart_cylinder_valve',
'dev_telemetry',
'wallet_ledger',
'wallet_recharge',
'wallet_withdrawal',
];
for (const value of forbidden) {
if (source.includes(value) || routes.includes(value))

View File

@@ -0,0 +1,49 @@
/**
* 功能:静态检查平台总后台全部标准资源是否具备独立记录页配置。
* 版本v1.0.0
*/
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const contract = JSON.parse(readFileSync(resolve(root, 'src/contracts/platform-resources.json'), 'utf8'));
const routeSource = [
'platform.ts',
'finance-route.ts',
].map((file) => readFileSync(resolve(root, 'src/router/routes/modules', file), 'utf8')).join('\n');
const routeBuilderSource = readFileSync(resolve(root, 'src/router/routes/modules/resource-route-builder.ts'), 'utf8');
const ruleSource = readFileSync(resolve(root, 'src/api/resource-page-rules.ts'), 'utf8');
const listSource = readFileSync(resolve(root, 'src/views/shared/CrudListPage.vue'), 'utf8');
const treeResources = new Set(['ec_category', 'platform_menu']);
const listResources = contract.resources.filter((item) => !treeResources.has(item.name));
const createModes = new Set(['writable', 'editable', 'append_only', 'managed']);
const editModes = new Set(['writable', 'editable', 'managed']);
const editableResources = listResources.filter((item) => editModes.has(item.mode));
function fail(message) {
throw new Error(`资源独立页面检查失败:${message}`);
}
const missingRoutes = listResources
.filter((item) => !routeSource.includes(`'/${item.name}'`))
.map((item) => item.name);
if (missingRoutes.length) fail(`路由未覆盖 ${missingRoutes.join('、')}`);
const missingRules = editableResources
.filter((item) => !new RegExp(`\\n\\s*${item.name}:\\s*\\{`).test(ruleSource))
.map((item) => item.name);
if (missingRules.length) fail(`编辑字段规则未覆盖 ${missingRules.join('、')}`);
for (const marker of ["makeRecordRoute('create'", "makeRecordRoute('detail'", "makeRecordRoute('edit'"]) {
if (!routeBuilderSource.includes(marker)) fail(`通用路由生成器缺少 ${marker}`);
}
if (listSource.includes('<a-drawer')) fail('列表组件仍包含 CRUD Drawer');
if (existsSync(resolve(root, 'src/views/account/AccountProfilePage.vue'))) {
fail('旧账户资料组件仍存在,未统一到共享记录页');
}
const createCount = listResources.filter((item) => createModes.has(item.mode)).length;
console.log(
`资源独立页面检查通过:详情 ${listResources.length} 类,新建 ${createCount} 类,编辑 ${editableResources.length} 类。`,
);

View File

@@ -1,13 +1,28 @@
/** 平台总后台的共享 HTTP 客户端,统一处理响应体和 JWT 请求头。 */
const apiBaseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:12426/heqi/platform/v1';
const apiBaseURL =
import.meta.env.VITE_API_BASE_URL ||
'http://localhost:12426/heqi/platform/v1';
export const tokenStorageKey = 'token';
export type PageResult<T> = { total: number; list: T[] };
/** 保留 HTTP 状态,供独立页面区分无权限、不存在和普通请求错误。 */
export class ApiError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message);
this.name = 'ApiError';
}
}
// 将后端 SDK 的通用英文错误转换为面向用户的中文提示。
const API_ERROR_MESSAGES: Record<string, string> = {
'Invalid Argument': '请求参数不正确,请检查填写内容',
'Record Not Found': '记录不存在',
'Permission Denied': '无权访问该记录',
};
// 优先保留后端提供的具体中文信息,仅翻译已知的通用英文错误。
@@ -31,9 +46,16 @@ export async function request<T>(path: string, init?: RequestInit): Promise<T> {
} catch {
throw new Error('无法连接服务器,请确认服务已启动');
}
const payload = (await response.json()) as { code?: number; message?: string; details?: T };
const payload = (await response.json()) as {
code?: number;
message?: string;
details?: T;
};
if (!response.ok || payload.code !== 0) {
throw new Error(localizeApiErrorMessage(payload.message));
throw new ApiError(
localizeApiErrorMessage(payload.message),
response.status,
);
}
return payload.details as T;
}

View File

@@ -0,0 +1,191 @@
/**
* 功能:统一资源列表与详情页面的字段名称、关系、金额、状态和时间展示。
* 版本v1.1.0
*/
import dayjs from 'dayjs';
import type { ResourceField, ResourceUiDefinition } from './resources';
import type { RecordPageMode, ResourceRow } from './resource-page-rules';
const aliases: Record<string, string> = {
identity: '唯一标识',
id: 'ID',
created_at: '创建时间',
updated_at: '更新时间',
deleted_at: '删除时间',
DeletedAt: '删除时间',
status: '状态',
version: '版本',
items: '订单明细',
assignments: '分配记录',
statuses: '状态记录',
tracks: '运行轨迹',
confirmations: '确认记录',
payments: '支付记录',
revisions: '修订记录',
products: '合同气瓶',
order: '订单',
contract: '合同',
wallet: '钱包',
is_system: '系统内置',
};
const amountKeys = new Set([
'amount',
'unit_price',
'balance',
'withdrawal_balance',
'default_delivery_fee',
'discount_amount',
'total_amount',
'delivery_fee',
'price_amount',
'sale_amount',
'difference_amount',
'fee',
]);
/** 返回资源独立页面的模式名称。 */
export function recordPageModeLabel(mode: RecordPageMode) {
return { create: '新建', detail: '详情', edit: '编辑' }[mode];
}
/** 返回资源独立页面的错误标题。 */
export function recordPageErrorTitle(status: '403' | '404' | 'error') {
return {
'403': '无法执行此操作',
'404': '记录不存在',
error: '页面加载失败',
}[status];
}
/** 返回聚合详情中的主记录。 */
export function primaryRecord(detail: ResourceRow): ResourceRow {
if (detail.order && typeof detail.order === 'object')
return detail.order as ResourceRow;
if (detail.contract && typeof detail.contract === 'object')
return detail.contract as ResourceRow;
return detail;
}
/** 返回字段中文名称,脱敏字段沿用原字段名称。 */
export function resourceFieldLabel(
definition: ResourceUiDefinition,
key: string,
) {
const normalized = key.endsWith('_masked') ? key.slice(0, -7) : key;
return (
definition.fields.find((field) => field.key === normalized)?.label ??
aliases[key] ??
key
);
}
/** 将标准实体状态转换为稳定的中文展示。 */
export function recordStatusLabel(status: number) {
return (
{ 0: '待审核', 1: '启用', 2: '停用', 3: '已归档', 4: '已冻结' }[status] ??
`未知(${status}`
);
}
export function recordStatusColor(status: number) {
return (
{ 0: 'orange', 1: 'green', 2: 'red', 3: 'gray', 4: 'purple' }[status] ??
'gray'
);
}
/** 选择关系记录的首选可读名称。 */
export function optionLabel(option: ResourceRow) {
return String(
option.name ??
option.title ??
option.display_name ??
option.code ??
option.username ??
option.contract_no ??
option.order_no ??
option.identity ??
'-',
);
}
function relationLabel(
field: ResourceField,
identity: string,
relationOptions: Record<string, ResourceRow[]>,
) {
const match = (relationOptions[field.relation ?? ''] ?? []).find(
(option) => String(option.identity) === identity,
);
if (!match) return identity;
return field.displayRelationLabel
? optionLabel(match)
: `${optionLabel(match)} · ${identity}`;
}
/** 格式化不依赖字段定义的通用值。 */
export function displayRawValue(key: string, value: unknown) {
if (value == null || value === '') return '-';
if (typeof value === 'boolean') return value ? '是' : '否';
if (key === 'status') return recordStatusLabel(Number(value));
if (
amountKeys.has(key) ||
key.endsWith('_amount') ||
key.endsWith('_balance_after')
) {
const amount = Number(value);
return Number.isFinite(amount)
? `¥${(amount / 100).toFixed(2)}`
: String(value);
}
if (key.endsWith('_at') || ['created_at', 'updated_at'].includes(key)) {
const date = dayjs(String(value));
return date.isValid() ? date.format('YYYY-MM-DD HH:mm:ss') : String(value);
}
if (
['deleted_at', 'DeletedAt'].includes(key) &&
typeof value === 'object' &&
value &&
'Time' in value
) {
const date = dayjs(String((value as { Time?: unknown }).Time ?? ''));
return date.isValid() ? date.format('YYYY-MM-DD HH:mm:ss') : '-';
}
if (typeof value === 'object') return JSON.stringify(value, null, 2);
return String(value);
}
/** 按字段选项和关系配置格式化值。 */
export function displayResourceField(
field: ResourceField,
row: ResourceRow,
relationOptions: Record<string, ResourceRow[]>,
) {
const value = row[field.key] ?? row[`${field.key}_masked`];
if (value == null || value === '') return field.emptyText ?? '-';
const option = field.options?.find(
(item) => String(item.value) === String(value),
);
if (option) return option.label;
if (field.type === 'identity' && typeof value === 'string') {
return relationLabel(field, value, relationOptions);
}
if (field.type === 'identity-list' && Array.isArray(value)) {
return value
.map((item) => relationLabel(field, String(item), relationOptions))
.join('、');
}
return displayRawValue(field.key, value);
}
/** 判断软删除结构是否为空。 */
export function isEmptyDeletedAt(value: unknown) {
if (value == null || value === '') return true;
return Boolean(
typeof value === 'object' &&
value &&
'Valid' in value &&
!(value as { Valid?: boolean }).Valid,
);
}

View File

@@ -0,0 +1,60 @@
/**
* 功能:构造标准资源列表、新建、详情和编辑页面之间的安全导航地址。
* 版本v1.0.0
*/
import type { RouteLocationRaw, Router } from 'vue-router';
import type { ResourceRow } from './resource-page-rules';
export type RecordNavigationMode = 'create' | 'detail' | 'edit';
/** 只接受站内绝对路径,避免 return_to 被利用为外部跳转。 */
export function safeReturnPath(value: unknown) {
if (
typeof value !== 'string' ||
!value.startsWith('/') ||
value.startsWith('//') ||
value.includes('\\') ||
/[\r\n]/.test(value)
)
return '';
return value;
}
/** 返回共享资源页的命名路由位置。 */
export function recordRouteLocation(
listRouteName: string,
mode: RecordNavigationMode,
identity: string,
returnTo: string,
): RouteLocationRaw {
return {
name: `${listRouteName}-${mode}`,
...(mode === 'create' ? {} : { params: { identity } }),
query: returnTo ? { return_to: returnTo } : {},
};
}
/** 新建工作人员后按照实际角色进入对应详情页。 */
export function createdRecordListRoute(
resourceName: string,
fallback: string,
row: ResourceRow,
) {
if (resourceName !== 'staff_account') return fallback;
return (
{
installer: 'staff-installers',
delivery: 'staff-delivery',
operations: 'staff-operations',
}[String(row.role_code ?? '')] ?? fallback
);
}
/** 返回原列表;来源缺失时使用路由名称。 */
export function returnToList(
router: Router,
returnPath: string,
listRouteName: string,
) {
return router.push(returnPath || { name: listRouteName });
}

View File

@@ -0,0 +1,325 @@
/**
* 功能:定义标准资源在新建、详情和编辑页面中的字段能力与动态编辑限制。
* 版本v1.0.0
*/
import type { ResourceField, ResourceUiDefinition } from './resources';
export type RecordPageMode = 'create' | 'detail' | 'edit';
export type ResourceRow = Record<string, unknown>;
export type ResourcePageContext = {
role: string;
accountIdentity: string;
};
type ResourcePageRule = {
createHiddenKeys?: string[];
editKeys: string[];
editOptionalKeys?: string[];
editSubmitOnlyKeys?: string[];
accountSummary?: boolean;
};
/** 每个可编辑资源都显式声明更新字段,禁止把创建字段直接提交给更新接口。 */
const pageRules: Record<string, ResourcePageRule> = {
gas_basic: {
editKeys: [
'name',
'credit_code',
'principal',
'address',
'longitude',
'latitude',
],
},
gas_account: {
editKeys: ['display_name', 'role_code', 'gas_basic_identity'],
accountSummary: true,
},
delivery_basic: {
editKeys: ['name', 'gas_basic_identity', 'principal', 'address'],
},
delivery_account: {
createHiddenKeys: ['role_code'],
editKeys: ['display_name', 'delivery_basic_identity'],
accountSummary: true,
},
staff_account: {
editKeys: [
'name',
'phone',
'avatar',
'role_code',
'gas_basic_identity',
'delivery_basic_identity',
'work_status',
],
accountSummary: true,
},
staff_credential: {
editKeys: ['credential_type', 'credential_no', 'expired_at'],
editSubmitOnlyKeys: ['staff_account_identity'],
},
user_account: {
editKeys: ['name', 'phone', 'avatar', 'real_name'],
accountSummary: true,
},
user_address: {
editKeys: ['address', 'longitude', 'latitude', 'is_default'],
editSubmitOnlyKeys: ['user_account_identity'],
},
user_service_relation: {
editKeys: [
'gas_basic_identity',
'delivery_basic_identity',
'staff_account_identity',
],
editSubmitOnlyKeys: ['user_account_identity'],
},
producer_account: {
editKeys: [
'name',
'credit_code',
'principal',
'phone',
'address',
'password',
'display_name',
'role_code',
'remark',
],
editOptionalKeys: ['password'],
accountSummary: true,
},
product_type: { editKeys: ['code', 'name'] },
product_warehouse: {
editKeys: ['code', 'name', 'address', 'manager', 'phone'],
},
product_info: {
editKeys: [
'name',
'producer_account_identity',
'product_type_identity',
'params',
'produced_at',
],
},
product_repair: {
editKeys: [
'repair_no',
'repair_type',
'started_at',
'completed_at',
'result',
'target_product_status',
'content',
'operator',
'remark',
],
editSubmitOnlyKeys: ['product_info_identity'],
},
gasorder_contract: {
editKeys: [
'delivery_basic_identity',
'title',
'terms',
'file_uri',
'default_delivery_fee',
'signed_at',
'effective_at',
'expired_at',
],
},
ec_product: {
editKeys: [
'ec_category_identity',
'product_code',
'name',
'price_amount',
'stock_quantity',
],
},
ec_product_attribute: {
editKeys: ['ec_product_identity', 'name', 'value', 'sort_no'],
},
ec_product_image: {
editKeys: ['ec_product_identity', 'image_uri', 'sort_no', 'is_cover'],
},
fin_settlement: {
editKeys: [
'settlement_no',
'subject_type',
'subject_identity',
'period_start',
'period_end',
],
},
cms_content: {
editKeys: ['content_type', 'title', 'body', 'version_no', 'publish_status'],
},
cs_ticket: {
editKeys: ['user_account_identity', 'ticket_no', 'category', 'priority'],
},
platform_account: {
editKeys: ['display_name', 'avatar', 'platform_role_code', 'phone'],
accountSummary: true,
},
platform_role: { editKeys: ['name', 'location_scope'] },
};
const ownerKeys: Record<string, string[]> = {
staff_credential: ['staff_account_identity'],
user_address: ['user_account_identity'],
user_service_relation: ['user_account_identity'],
product_info: [
'warehouse_identity',
'gas_basic_identity',
'delivery_basic_identity',
'user_account_identity',
],
product_repair: ['product_info_identity'],
gasorder_contract: ['user_account_identity', 'gas_basic_identity'],
};
/** 返回当前模式需要展示的字段;编辑页会保留不可变字段但将其设为只读。 */
export function pageFields(
definition: ResourceUiDefinition,
mode: Exclude<RecordPageMode, 'detail'>,
row: ResourceRow,
context: ResourcePageContext,
) {
const rule = pageRules[definition.name];
if (mode === 'create') {
const hidden = new Set(rule?.createHiddenKeys ?? []);
return definition.fields.filter((field) => !hidden.has(field.key));
}
let editableKeys =
rule?.editKeys ?? definition.fields.map((field) => field.key);
if (definition.name === 'product_repair' && row.result !== 'pending') {
editableKeys = ['remark'];
}
if (definition.name === 'platform_account' && context.role !== 'root') {
editableKeys = editableKeys.filter((key) => key !== 'platform_role_code');
}
const visibleKeys = new Set([
...editableKeys,
...(ownerKeys[definition.name] ?? []),
...definition.fields
.filter((field) =>
[
'username',
'code',
'delivery_code',
'producer_code',
'contract_no',
'role_code',
].includes(field.key),
)
.map((field) => field.key),
]);
return definition.fields.filter(
(field) =>
visibleKeys.has(field.key) &&
(field.type !== 'password' || editableKeys.includes(field.key)),
);
}
/** 返回编辑请求允许提交的字段。 */
export function editableFields(
definition: ResourceUiDefinition,
row: ResourceRow,
context: ResourcePageContext,
): ResourceField[] {
const rule = pageRules[definition.name];
let keys = rule?.editKeys ?? definition.fields.map((field) => field.key);
if (definition.name === 'product_repair' && row.result !== 'pending')
keys = ['remark'];
if (definition.name === 'platform_account' && context.role !== 'root') {
keys = keys.filter((key) => key !== 'platform_role_code');
}
const allowed = new Set(keys);
return definition.fields.filter((field) => allowed.has(field.key));
}
/** 返回更新协议需要提交的字段,其中只读归属字段仅原样回传。 */
export function updatePayloadFields(
definition: ResourceUiDefinition,
row: ResourceRow,
context: ResourcePageContext,
) {
const keys = new Set([
...editableFields(definition, row, context).map((field) => field.key),
...(pageRules[definition.name]?.editSubmitOnlyKeys ?? []),
]);
return definition.fields.filter((field) => keys.has(field.key));
}
/** 判断编辑字段在当前模式是否必填。 */
export function fieldRequired(
definition: ResourceUiDefinition,
field: ResourceField,
mode: Exclude<RecordPageMode, 'detail'>,
) {
if (mode === 'create') return Boolean(field.required);
const optional = new Set(pageRules[definition.name]?.editOptionalKeys ?? []);
return Boolean(field.required && !optional.has(field.key));
}
/** 判断资源是否使用账户头像与账号摘要。 */
export function usesAccountSummary(definition: ResourceUiDefinition) {
return Boolean(pageRules[definition.name]?.accountSummary);
}
/** 返回直达新建页时的阻止原因,空字符串代表允许新建。 */
export function createBlockedReason(
definition: ResourceUiDefinition,
context: ResourcePageContext,
) {
if (!definition.canCreate) return '当前资源不支持新建';
if (
['platform_account', 'platform_role'].includes(definition.name) &&
context.role !== 'root'
) {
return '只有 root 可以新建平台账户或平台角色';
}
return '';
}
/** 返回直达编辑页时的阻止原因,空字符串代表允许编辑。 */
export function editBlockedReason(
definition: ResourceUiDefinition,
row: ResourceRow,
context: ResourcePageContext,
) {
if (!definition.canEdit) return '当前资源不支持编辑';
if (
definition.name === 'gasorder_contract' &&
Number(row.contract_status) !== 10
) {
return '只有草稿状态的合同可以编辑';
}
if (definition.name === 'platform_role') {
if (context.role !== 'root') return '只有 root 可以编辑平台角色';
if (row.is_system === true) return '系统角色不允许编辑';
}
if (
definition.name === 'platform_account' &&
context.role !== 'root' &&
String(row.identity ?? '') !== context.accountIdentity
) {
return '普通平台管理员只能编辑自己的账户资料';
}
return '';
}
/** 校验每个列表型可编辑资源都有显式更新字段规则。 */
export function assertResourcePageRules(definitions: ResourceUiDefinition[]) {
const missing = definitions
.filter(
(definition) => definition.pageKind === 'list' && definition.canEdit,
)
.filter((definition) => !pageRules[definition.name])
.map((definition) => definition.name);
if (missing.length)
throw new Error(`资源缺少编辑字段规则:${missing.join('、')}`);
}

View File

@@ -0,0 +1,47 @@
/**
* 功能:初始化和校验标准资源独立页面的表单数据。
* 版本v1.0.0
*/
import { isMissingField } from './resource-form';
import type { ResourceField } from './resources';
import type { ResourceRow } from './resource-page-rules';
/** 使用详情快照初始化表单,并应用来源关系和工作人员类型预填。 */
export function resetResourceRecordForm(
form: Record<string, any>,
fields: ResourceField[],
row: ResourceRow,
prefill: { relationKey?: string; ownerIdentity?: string; staffType?: string },
) {
for (const key of Object.keys(form)) delete form[key];
for (const field of fields) {
const value = row[field.key];
form[field.key] =
value == null
? undefined
: field.type === 'money'
? Number(value) / 100
: value;
}
if (prefill.relationKey && prefill.ownerIdentity) {
form[prefill.relationKey] = prefill.ownerIdentity;
}
if (prefill.staffType) form.role_code = prefill.staffType;
}
/** 返回表单首个校验错误,空字符串代表可以提交。 */
export function validateResourceRecordForm(
form: Record<string, any>,
fields: ResourceField[],
requiredKeys: string[],
) {
if (requiredKeys.some((key) => isMissingField(form[key])))
return '请填写必填字段';
const password = fields.find(
(field) => field.type === 'password' && !isMissingField(form[field.key]),
);
if (password && Array.from(String(form[password.key])).length < 6) {
return '密码长度不能少于 6 个字符';
}
return '';
}

View File

@@ -333,6 +333,7 @@ export const resources: ResourceUiDefinition[] = [
define('product_warehouse', '智能气阀库房', 'editable', [f('code', { required: true }), f('name', { required: true }), f('address'), f('manager'), f('phone')]),
define('product_info', '智能气阀', 'editable', [f('code', { required: true }), f('name', { required: true }), relation('producer_account_identity', '/producer_account', true), relation('product_type_identity', '/product_type', true), f('params', { required: true }), relation('warehouse_identity', '/product_warehouse'), relation('gas_basic_identity', '/gas_basic'), relation('delivery_basic_identity', '/delivery_basic'), relation('user_account_identity', '/user_account'), f('produced_at', { required: true })], 'list', [
{ name: '修改智能气阀状态', resource: '/product_info/:identity/lifecycle', method: 'PATCH', fields: [f('product_status', { required: true, type: 'select', options: [{ label: '待处理', value: 10 }, { label: '在库', value: 28 }, { label: '运输中', value: 29 }, { label: '使用中', value: 30 }, { label: '维修中', value: 31 }, { label: '已报废', value: 27 }] })] },
{ name: '变更智能气阀归属', resource: '/product_info/:identity', method: 'PUT', fields: [relation('warehouse_identity', '/product_warehouse'), relation('gas_basic_identity', '/gas_basic'), relation('delivery_basic_identity', '/delivery_basic'), relation('user_account_identity', '/user_account'), f('action', { required: true, type: 'select', options: [{ label: '入库', value: 'warehouse' }, { label: '分配', value: 'assigned' }, { label: '归还', value: 'returned' }, { label: '人工调整', value: 'manual' }] }), f('reason', { required: true }), f('remark')] },
]),
define('product_repair', '智能气阀检修记录', 'editable', [relation('product_info_identity', '/product_info', true), f('repair_no', { required: true }), f('repair_type', { required: true }), f('started_at', { required: true }), f('completed_at'), f('result', { type: 'select', options: [{ label: '待处理', value: 'pending' }, { label: '通过', value: 'passed' }, { label: '未通过', value: 'failed' }] }), f('target_product_status', { type: 'select', options: [{ label: '在库', value: 28 }, { label: '运输中', value: 29 }, { label: '使用中', value: 30 }, { label: '报废', value: 27 }] }), f('content'), f('operator'), f('remark')]),
define('product_owner', '智能气阀归属记录', 'readonly', []),

View File

@@ -0,0 +1,45 @@
/**
* 功能:配置平台总后台数据概览与统计报表路由。
* 版本v1.0.0
*/
import { DEFAULT_LAYOUT } from '../base';
import type { AppRouteRecordRaw } from '../types';
export const dashboardRoute: AppRouteRecordRaw = {
path: '/dashboard',
name: 'dashboard',
component: DEFAULT_LAYOUT,
redirect: '/dashboard/overview',
meta: {
title: '数据概览',
requiresAuth: true,
icon: 'icon-dashboard',
order: 0,
menuCode: 'dashboard',
hideInMenu: true,
},
children: [
{
path: 'overview',
name: 'dashboard-overview',
component: () => import('@/views/dashboard/DashboardPage.vue'),
meta: {
title: '数据概览',
requiresAuth: true,
menuCode: 'dashboard_overview',
},
},
{
path: 'reports',
name: 'dashboard-reports',
component: () => import('@/views/dashboard/ReportPage.vue'),
meta: {
title: '统计报表',
requiresAuth: true,
menuCode: 'dashboard_overview',
hideInMenu: true,
activeMenu: 'dashboard-overview',
},
},
],
};

View File

@@ -0,0 +1,95 @@
/**
* 功能:配置财务、钱包及其隐藏详情入口的资源路由。
* 版本v1.0.0
*/
import { child, group } from './resource-route-builder';
export const financeRoute = group(
'finance',
'finance',
'财务管理',
'icon-bar-chart',
90,
[
child(
'finance',
'withdrawals',
'withdrawals',
'提现记录',
'/wallet_apply_cash',
'wallet_apply_cash',
),
child(
'finance',
'payments',
'payments',
'支付记录',
'/fin_payment',
'fin_payment',
),
child(
'finance',
'refunds',
'refunds',
'退款审核',
'/payment_refund',
'payment_refund',
),
child(
'finance',
'settlements',
'settlements',
'财务结算',
'/fin_settlement',
'fin_settlement',
),
child(
'finance',
'reconciliations',
'reconciliations',
'财务对账',
'/fin_reconciliation',
'fin_reconciliation',
),
child(
'finance',
'wallets',
'wallets',
'钱包',
'/wallet_basic',
'wallet_apply_cash',
true,
'finance-withdrawals',
),
child(
'finance',
'wallet-banks',
'wallet-banks',
'钱包银行卡',
'/wallet_bank',
'wallet_apply_cash',
true,
'finance-withdrawals',
),
child(
'finance',
'wallet-payments',
'wallet-payments',
'钱包支付记录',
'/payment_order',
'fin_payment',
true,
'finance-payments',
),
child(
'finance',
'wallet-records',
'wallet-records',
'钱包流水',
'/wallet_record',
'fin_payment',
true,
'finance-payments',
),
],
);

View File

@@ -1,136 +1,461 @@
import { DEFAULT_LAYOUT } from '../base';
/**
* 功能:汇总平台总后台标准资源菜单与独立记录页路由。
* 版本v2.0.0
*/
import type { AppRouteRecordRaw } from '../types';
const resourcePage = () => import('@/views/shared/ResourcePage.vue');
const accountProfilePage = () => import('@/views/account/AccountProfilePage.vue');
function child(
domain: string,
path: string,
name: string,
title: string,
resource: string,
menuCode = domain,
hidden = false,
activeMenu?: string,
): AppRouteRecordRaw {
return {
path,
name: `${domain}-${name}`,
component: resourcePage,
meta: {
title,
resource,
requiresAuth: true,
menuCode,
hideInMenu: hidden,
...(activeMenu ? { activeMenu } : {}),
},
};
}
function group(
path: string,
name: string,
title: string,
icon: string,
order: number,
children: AppRouteRecordRaw[],
menuCode = name,
): AppRouteRecordRaw {
return {
path: `/${path}`,
name,
component: DEFAULT_LAYOUT,
redirect: `/${path}/${children[0].path}`,
meta: { title, requiresAuth: true, icon, order, menuCode },
children,
};
}
import { dashboardRoute } from './dashboard-route';
import { financeRoute } from './finance-route';
import { child, group, resourceRecordPage } from './resource-route-builder';
const routes: AppRouteRecordRaw[] = [
{
path: '/dashboard',
name: 'dashboard',
component: DEFAULT_LAYOUT,
redirect: '/dashboard/overview',
meta: { title: '数据概览', requiresAuth: true, icon: 'icon-dashboard', order: 0, menuCode: 'dashboard', hideInMenu: true },
children: [
{ path: 'overview', name: 'dashboard-overview', component: () => import('@/views/dashboard/DashboardPage.vue'), meta: { title: '数据概览', requiresAuth: true, menuCode: 'dashboard_overview' } },
{ path: 'reports', name: 'dashboard-reports', component: () => import('@/views/dashboard/ReportPage.vue'), meta: { title: '统计报表', requiresAuth: true, menuCode: 'dashboard_overview', hideInMenu: true, activeMenu: 'dashboard-overview' } },
],
},
dashboardRoute,
group('organization', 'organization', '机构管理', 'icon-storage', 10, [
child('organization', 'gas-basic', 'gas-basic', '气站管理', '/gas_basic', 'gas_basic'),
child('organization', 'delivery-basic', 'delivery-basic', '配送点管理', '/delivery_basic', 'delivery_basic'),
child('organization', 'gas-account', 'gas-account', '气站账户', '/gas_account', 'gas_basic', true, 'organization-gas-basic'),
child('organization', 'delivery-account', 'delivery-account', '配送点账户', '/delivery_account', 'delivery_basic', true, 'organization-delivery-basic'),
child(
'organization',
'gas-basic',
'gas-basic',
'气站管理',
'/gas_basic',
'gas_basic',
),
child(
'organization',
'delivery-basic',
'delivery-basic',
'配送点管理',
'/delivery_basic',
'delivery_basic',
),
child(
'organization',
'gas-account',
'gas-account',
'气站账户',
'/gas_account',
'gas_basic',
true,
'organization-gas-basic',
),
child(
'organization',
'delivery-account',
'delivery-account',
'配送点账户',
'/delivery_account',
'delivery_basic',
true,
'organization-delivery-basic',
),
]),
group('staff', 'staff', '工作人员管理', 'icon-user-group', 30, [
{ ...child('staff', 'add', 'add', '新增工作人员', '/staff_account', 'staff_add'), meta: { title: '新增工作人员', resource: '/staff_account', requiresAuth: true, menuCode: 'staff_add', createMode: true } },
{ ...child('staff', 'installers', 'installers', '安装人员管理', '/staff_account', 'staff_installer'), meta: { title: '安装人员管理', resource: '/staff_account', requiresAuth: true, menuCode: 'staff_installer', staffType: 'installer' } },
{ ...child('staff', 'delivery', 'delivery', '配送人员管理', '/staff_account', 'staff_delivery'), meta: { title: '配送人员管理', resource: '/staff_account', requiresAuth: true, menuCode: 'staff_delivery', staffType: 'delivery' } },
{ ...child('staff', 'operations', 'operations', '运维人员管理', '/staff_account', 'staff_operations'), meta: { title: '运维人员管理', resource: '/staff_account', requiresAuth: true, menuCode: 'staff_operations', staffType: 'operations' } },
{ path: 'installers/:identity', name: 'staff-installers-profile', component: accountProfilePage, meta: { title: '工作人员资料', resource: '/staff_account', requiresAuth: true, menuCode: 'staff_installer', staffType: 'installer', hideInMenu: true, activeMenu: 'staff-installers', listRouteName: 'staff-installers' } },
{ path: 'delivery/:identity', name: 'staff-delivery-profile', component: accountProfilePage, meta: { title: '工作人员资料', resource: '/staff_account', requiresAuth: true, menuCode: 'staff_delivery', staffType: 'delivery', hideInMenu: true, activeMenu: 'staff-delivery', listRouteName: 'staff-delivery' } },
{ path: 'operations/:identity', name: 'staff-operations-profile', component: accountProfilePage, meta: { title: '工作人员资料', resource: '/staff_account', requiresAuth: true, menuCode: 'staff_operations', staffType: 'operations', hideInMenu: true, activeMenu: 'staff-operations', listRouteName: 'staff-operations' } },
child('staff', 'credential', 'credential', '人员资质', '/staff_credential', 'staff', true, 'staff-installers'),
{
...child(
'staff',
'add',
'add',
'新增工作人员',
'/staff_account',
'staff_add',
),
component: resourceRecordPage,
meta: {
title: '新增工作人员',
resource: '/staff_account',
requiresAuth: true,
menuCode: 'staff_add',
createMode: true,
recordMode: 'create',
listRouteName: 'staff-installers',
},
},
{
...child(
'staff',
'installers',
'installers',
'安装人员管理',
'/staff_account',
'staff_installer',
),
meta: {
title: '安装人员管理',
resource: '/staff_account',
requiresAuth: true,
menuCode: 'staff_installer',
staffType: 'installer',
},
},
{
...child(
'staff',
'delivery',
'delivery',
'配送人员管理',
'/staff_account',
'staff_delivery',
),
meta: {
title: '配送人员管理',
resource: '/staff_account',
requiresAuth: true,
menuCode: 'staff_delivery',
staffType: 'delivery',
},
},
{
...child(
'staff',
'operations',
'operations',
'运维人员管理',
'/staff_account',
'staff_operations',
),
meta: {
title: '运维人员管理',
resource: '/staff_account',
requiresAuth: true,
menuCode: 'staff_operations',
staffType: 'operations',
},
},
child(
'staff',
'credential',
'credential',
'人员资质',
'/staff_credential',
'staff',
true,
'staff-installers',
),
]),
group('user', 'user', '用户管理', 'icon-user', 40, [
child('user', 'user-account', 'account', '用户账户', '/user_account', 'user_account'),
{ path: 'user-account/:identity', name: 'user-account-profile', component: accountProfilePage, meta: { title: '用户资料', resource: '/user_account', requiresAuth: true, menuCode: 'user_account', hideInMenu: true, activeMenu: 'user-account', listRouteName: 'user-account' } },
child('user', 'user-address', 'address', '用户地址', '/user_address', 'user_address'),
child('user', 'service-relation', 'service-relation', '服务关系', '/user_service_relation', 'user_service_relation'),
child('user', 'contracts', 'contracts', '合同管理', '/gasorder_contract', 'gasorder_contract'),
child('user', 'contract-products', 'contract-products', '合同气瓶', '/gasorder_contract_product', 'gasorder_contract', true, 'user-contracts'),
child('user', 'contract-revisions', 'contract-revisions', '合同修订记录', '/gasorder_contract_revision', 'gasorder_contract', true, 'user-contracts'),
child(
'user',
'user-account',
'account',
'用户账户',
'/user_account',
'user_account',
),
child(
'user',
'user-address',
'address',
'用户地址',
'/user_address',
'user_address',
),
child(
'user',
'service-relation',
'service-relation',
'服务关系',
'/user_service_relation',
'user_service_relation',
),
child(
'user',
'contracts',
'contracts',
'合同管理',
'/gasorder_contract',
'gasorder_contract',
),
child(
'user',
'contract-products',
'contract-products',
'合同气瓶',
'/gasorder_contract_product',
'gasorder_contract',
true,
'user-contracts',
),
child(
'user',
'contract-revisions',
'contract-revisions',
'合同修订记录',
'/gasorder_contract_revision',
'gasorder_contract',
true,
'user-contracts',
),
]),
group('product', 'product', '智能气阀管理', 'icon-common', 50, [
child('product', 'producers', 'producers', '生产商管理', '/producer_account', 'producer_account'),
child('product', 'product-type', 'type', '类型管理', '/product_type', 'product_type'),
child('product', 'warehouse', 'warehouse', '库房管理', '/product_warehouse', 'product_warehouse'),
child('product', 'product-info', 'info', '智能气阀管理', '/product_info', 'product_info'),
child('product', 'repair', 'repair', '智能气阀检修记录', '/product_repair', 'product_info', true, 'product-info'),
child('product', 'owner', 'owner', '智能气阀归属记录', '/product_owner', 'product_info', true, 'product-info'),
], 'device'),
group('gasorder', 'gasorder', '气体配送订单管理', 'icon-list', 60, [
{ ...child('gasorder', 'create', 'create', '创建订单', '/gasorder_basic', 'gasorder_create'), meta: { title: '创建订单', resource: '/gasorder_basic', requiresAuth: true, menuCode: 'gasorder_create', createMode: true } },
child('gasorder', 'orders', 'orders', '配送订单', '/gasorder_basic', 'gasorder_basic'),
child('gasorder', 'order-items', 'order-items', '订单明细', '/gasorder_item', 'gasorder_basic', true, 'gasorder-orders'),
child('gasorder', 'assignments', 'assignments', '分配记录', '/gasorder_assign', 'gasorder_basic', true, 'gasorder-orders'),
child('gasorder', 'statuses', 'statuses', '状态记录', '/gasorder_status', 'gasorder_basic', true, 'gasorder-orders'),
child('gasorder', 'tracks', 'tracks', '运行轨迹', '/gasorder_track', 'gasorder_track'),
child('gasorder', 'track-points', 'track-points', '轨迹点', '/gasorder_track_point', 'gasorder_track', true, 'gasorder-tracks'),
child('gasorder', 'confirms', 'confirms', '确认记录', '/gasorder_confirm', 'gasorder_basic', true, 'gasorder-orders'),
child('gasorder', 'payments', 'payments', '订单支付记录', '/gasorder_payment', 'gasorder_basic', true, 'gasorder-orders'),
], 'gasorder'),
group(
'product',
'product',
'智能气阀管理',
'icon-common',
50,
[
child(
'product',
'producers',
'producers',
'生产商管理',
'/producer_account',
'producer_account',
),
child(
'product',
'product-type',
'type',
'类型管理',
'/product_type',
'product_type',
),
child(
'product',
'warehouse',
'warehouse',
'库房管理',
'/product_warehouse',
'product_warehouse',
),
child(
'product',
'product-info',
'info',
'智能气阀管理',
'/product_info',
'product_info',
),
child(
'product',
'repair',
'repair',
'智能气阀检修记录',
'/product_repair',
'product_info',
true,
'product-info',
),
child(
'product',
'owner',
'owner',
'智能气阀归属记录',
'/product_owner',
'product_info',
true,
'product-info',
),
],
'device',
),
group(
'gasorder',
'gasorder',
'气体配送订单管理',
'icon-list',
60,
[
{
...child(
'gasorder',
'create',
'create',
'创建订单',
'/gasorder_basic',
'gasorder_create',
),
component: resourceRecordPage,
meta: {
title: '创建订单',
resource: '/gasorder_basic',
requiresAuth: true,
menuCode: 'gasorder_create',
createMode: true,
recordMode: 'create',
listRouteName: 'gasorder-orders',
},
},
child(
'gasorder',
'orders',
'orders',
'配送订单',
'/gasorder_basic',
'gasorder_basic',
),
child(
'gasorder',
'order-items',
'order-items',
'订单明细',
'/gasorder_item',
'gasorder_basic',
true,
'gasorder-orders',
),
child(
'gasorder',
'assignments',
'assignments',
'分配记录',
'/gasorder_assign',
'gasorder_basic',
true,
'gasorder-orders',
),
child(
'gasorder',
'statuses',
'statuses',
'状态记录',
'/gasorder_status',
'gasorder_basic',
true,
'gasorder-orders',
),
child(
'gasorder',
'tracks',
'tracks',
'运行轨迹',
'/gasorder_track',
'gasorder_track',
),
child(
'gasorder',
'track-points',
'track-points',
'轨迹点',
'/gasorder_track_point',
'gasorder_track',
true,
'gasorder-tracks',
),
child(
'gasorder',
'confirms',
'confirms',
'确认记录',
'/gasorder_confirm',
'gasorder_basic',
true,
'gasorder-orders',
),
child(
'gasorder',
'payments',
'payments',
'订单支付记录',
'/gasorder_payment',
'gasorder_basic',
true,
'gasorder-orders',
),
],
'gasorder',
),
group('ec', 'ec', '电商平台管理', 'icon-gift', 70, [
child('ec', 'categories', 'categories', '商品分类', '/ec_category', 'ec_category'),
child('ec', 'products', 'products', '商品管理', '/ec_product', 'ec_product'),
child('ec', 'attributes', 'attributes', '商品属性', '/ec_product_attribute', 'ec_product', true, 'ec-products'),
child('ec', 'images', 'images', '商品图片', '/ec_product_image', 'ec_product', true, 'ec-products'),
child(
'ec',
'categories',
'categories',
'商品分类',
'/ec_category',
'ec_category',
),
child(
'ec',
'products',
'products',
'商品管理',
'/ec_product',
'ec_product',
),
child(
'ec',
'attributes',
'attributes',
'商品属性',
'/ec_product_attribute',
'ec_product',
true,
'ec-products',
),
child(
'ec',
'images',
'images',
'商品图片',
'/ec_product_image',
'ec_product',
true,
'ec-products',
),
child('ec', 'carts', 'carts', '购物车', '/ec_cart', 'ec_cart'),
child('ec', 'orders', 'orders', '商城订单', '/ec_order', 'ec_order'),
child('ec', 'order-items', 'order-items', '订单明细', '/ec_order_item', 'ec_order', true, 'ec-orders'),
child(
'ec',
'order-items',
'order-items',
'订单明细',
'/ec_order_item',
'ec_order',
true,
'ec-orders',
),
child('ec', 'reviews', 'reviews', '商品评价', '/ec_review', 'ec_review'),
]),
group('finance', 'finance', '财务管理', 'icon-bar-chart', 90, [
child('finance', 'withdrawals', 'withdrawals', '提现记录', '/wallet_apply_cash', 'wallet_apply_cash'),
child('finance', 'payments', 'payments', '支付记录', '/fin_payment', 'fin_payment'),
child('finance', 'refunds', 'refunds', '退款审核', '/payment_refund', 'payment_refund'),
child('finance', 'settlements', 'settlements', '财务结算', '/fin_settlement', 'fin_settlement'),
child('finance', 'reconciliations', 'reconciliations', '财务对账', '/fin_reconciliation', 'fin_reconciliation'),
]),
financeRoute,
group('content', 'content', '内容管理', 'icon-file', 100, [
child('content', 'contents', 'contents', '内容', '/cms_content', 'cms_content'),
]),
group('customer-service', 'customer_service', '客服管理', 'icon-customer-service', 110, [
child('customer_service', 'tickets', 'tickets', '客服工单', '/cs_ticket', 'cs_ticket'),
child(
'content',
'contents',
'contents',
'内容',
'/cms_content',
'cms_content',
),
]),
group(
'customer-service',
'customer_service',
'客服管理',
'icon-customer-service',
110,
[
child(
'customer_service',
'tickets',
'tickets',
'客服工单',
'/cs_ticket',
'cs_ticket',
),
],
),
group('platform', 'platform', '平台管理', 'icon-settings', 120, [
child('platform', 'accounts', 'accounts', '平台账户', '/platform_account', 'platform_account'),
child('platform', 'roles', 'roles', '平台角色', '/platform_role', 'platform_role'),
child('platform', 'menus', 'menus', '平台菜单', '/platform_menu', 'platform_menu'),
child(
'platform',
'accounts',
'accounts',
'平台账户',
'/platform_account',
'platform_account',
),
child(
'platform',
'roles',
'roles',
'平台角色',
'/platform_role',
'platform_role',
),
child(
'platform',
'menus',
'menus',
'平台菜单',
'/platform_menu',
'platform_menu',
),
]),
];

View File

@@ -0,0 +1,98 @@
/**
* 功能:为平台标准资源构造列表菜单与新建、详情、编辑隐藏路由。
* 版本v1.0.0
*/
import { assertResourcePageRules } from '@/api/resource-page-rules';
import { getResource, resources } from '@/api/resources';
import { DEFAULT_LAYOUT } from '../base';
import type { AppRouteRecordRaw } from '../types';
const resourcePage = () => import('@/views/shared/ResourcePage.vue');
export const resourceRecordPage = () =>
import('@/views/resource/ResourceRecordPage.vue');
assertResourcePageRules(resources);
export function child(
domain: string,
path: string,
name: string,
title: string,
resource: string,
menuCode = domain,
hidden = false,
activeMenu?: string,
): AppRouteRecordRaw {
return {
path,
name: `${domain}-${name}`,
component: resourcePage,
meta: {
title,
resource,
requiresAuth: true,
menuCode,
hideInMenu: hidden,
...(activeMenu ? { activeMenu } : {}),
},
};
}
export function group(
path: string,
name: string,
title: string,
icon: string,
order: number,
children: AppRouteRecordRaw[],
menuCode = name,
): AppRouteRecordRaw {
return {
path: `/${path}`,
name,
component: DEFAULT_LAYOUT,
redirect: `/${path}/${children[0].path}`,
meta: { title, requiresAuth: true, icon, order, menuCode },
children: expandResourceChildren(children),
};
}
/** 为一个列表路由生成隐藏的新建、详情和编辑子页面。 */
function expandResourceChildren(children: AppRouteRecordRaw[]) {
return children.flatMap((route) => {
const resource = String(route.meta?.resource ?? '');
if (!resource || route.meta?.createMode) return [route];
const definition = getResource(resource);
if (definition.pageKind !== 'list') return [route];
const listRouteName = String(route.name);
const activeMenu = String(route.meta?.activeMenu ?? route.name);
const makeRecordRoute = (
mode: 'create' | 'detail' | 'edit',
suffix: string,
titlePrefix: string,
): AppRouteRecordRaw => ({
path: `${route.path}/${suffix}`,
name: `${listRouteName}-${mode}`,
component: resourceRecordPage,
meta: {
...route.meta,
requiresAuth: true,
title: `${titlePrefix}${definition.title}`,
hideInMenu: true,
activeMenu,
listRouteName,
recordMode: mode,
},
});
return [
route,
...(definition.canCreate
? [makeRecordRoute('create', 'new', '新建')]
: []),
makeRecordRoute('detail', ':identity', ''),
...(definition.canEdit
? [makeRecordRoute('edit', ':identity/edit', '编辑')]
: []),
];
});
}

View File

@@ -4,8 +4,10 @@ declare module 'vue-router' {
interface RouteMeta {
roles?: string[]; // Controls roles that have access to the page
menuCode?: string; // Server-assigned menu domain required by this route
resource?: string; // 标准资源 API 路径
staffType?: 'installer' | 'delivery' | 'operations';
listRouteName?: string; // 独立资料页返回的列表路由名称
listRouteName?: string; // 独立记录页返回的列表路由名称
recordMode?: 'create' | 'detail' | 'edit'; // 独立记录页模式
createMode?: boolean;
requiresAuth: boolean; // Whether login is required to access the current page (every route must declare)
icon?: string; // The icon show in the side menu

View File

@@ -1,209 +0,0 @@
/* 功能账户资料页布局、头像和响应式样式。版本v1.0.0 */
.account-profile-page {
min-height: 100%;
padding: 0 20px 28px;
background: var(--color-fill-2);
}
.profile-loading {
display: block;
width: 100%;
}
.summary-card,
.form-card {
margin-top: 16px;
border-radius: 8px;
}
.summary-content {
display: grid;
grid-template-columns: 180px minmax(360px, 1fr);
gap: 44px;
align-items: center;
width: min(900px, 100%);
min-height: 152px;
margin: 0 auto;
padding: 24px 32px;
}
.avatar-column {
display: flex;
flex-direction: column;
align-items: center;
width: 180px;
}
.avatar-control {
position: relative;
padding: 0;
background: transparent;
border: 0;
}
.avatar-control.editable {
cursor: pointer;
}
.profile-avatar {
overflow: hidden;
background: var(--color-fill-3);
}
.profile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.camera-badge {
position: absolute;
right: 2px;
bottom: 4px;
display: grid;
width: 38px;
height: 38px;
color: rgb(var(--primary-6));
font-size: 20px;
background: var(--color-bg-2);
border: 4px solid var(--color-bg-2);
border-radius: 50%;
place-items: center;
}
.avatar-input {
display: none;
}
.avatar-help {
margin-top: 10px;
color: var(--color-text-3);
font-size: 12px;
}
.identity-summary {
width: 100%;
max-width: 500px;
}
.identity-summary h2 {
margin: 0 0 16px;
color: var(--color-text-1);
font-size: 20px;
line-height: 28px;
}
.identity-summary dl {
margin: 0;
}
.identity-summary dl div {
display: flex;
align-items: center;
min-height: 36px;
font-size: 15px;
}
.identity-summary dt {
width: 88px;
color: var(--color-text-3);
text-align: left;
}
.identity-summary dd {
min-width: 0;
margin-left: 8px;
color: var(--color-text-1);
}
.form-card {
padding-bottom: 12px;
}
.profile-form {
width: min(760px, calc(100% - 48px));
margin: 0 auto;
padding: 20px 0 4px;
}
.profile-form :deep(.arco-form-item-label-col) {
flex: 0 0 116px;
justify-content: flex-start;
}
.profile-form :deep(.arco-form-item-content-flex) {
width: 100%;
max-width: 640px;
}
.profile-form :deep(.arco-form-item-label-required-symbol) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 8px;
margin-right: 4px;
font-size: 0;
}
.profile-form :deep(.arco-form-item-label-required-symbol svg) {
display: none;
}
.profile-form :deep(.arco-form-item-label-required-symbol::before) {
color: rgb(var(--danger-6));
font-size: 14px;
line-height: 1;
content: '*';
}
.profile-form :deep(.arco-input-wrapper),
.profile-form :deep(.arco-input-number),
.profile-form :deep(.arco-select-view),
.profile-form :deep(.arco-picker),
.profile-form :deep(.arco-textarea-wrapper) {
background: var(--color-bg-2);
border-color: var(--color-border-2);
}
.detail-value {
width: 100%;
min-height: 36px;
padding: 7px 0;
overflow: hidden;
color: var(--color-text-1);
line-height: 22px;
text-overflow: ellipsis;
border-bottom: 1px solid var(--color-fill-3);
white-space: nowrap;
}
.form-actions {
margin-top: 12px;
margin-bottom: 4px;
}
@media (max-width: 760px) {
.account-profile-page {
padding: 0 10px 20px;
}
.summary-content {
grid-template-columns: 1fr;
gap: 20px;
justify-items: center;
padding: 24px 16px;
}
.identity-summary {
width: min(420px, 100%);
}
.profile-form {
width: calc(100% - 24px);
padding: 16px 0 0;
}
.profile-form :deep(.arco-form-item-label-col) {
flex-basis: 96px;
}
}

View File

@@ -1,455 +0,0 @@
<!-- 功能工作人员与用户账户独立资料页版本v1.0.0 -->
<template>
<div class="account-profile-page">
<a-page-header
:title="`${definition.title}资料`"
subtitle="查看和维护账户基本信息"
@back="goBack"
>
<template v-if="!editing" #extra>
<a-button type="primary" @click="startEdit">
<template #icon><icon-edit /></template>
编辑资料
</a-button>
</template>
</a-page-header>
<a-spin :loading="loading" class="profile-loading" tip="正在加载资料">
<a-card :bordered="false" class="summary-card">
<div class="summary-content">
<div class="avatar-column">
<button
class="avatar-control"
:class="{ editable: editing }"
type="button"
:disabled="!editing"
aria-label="选择本地头像"
@click="chooseAvatar"
>
<a-avatar :size="120" class="profile-avatar">
<img :src="avatarPreview" alt="账户头像" />
</a-avatar>
<span v-if="editing" class="camera-badge">
<icon-camera />
</span>
</button>
<input
ref="avatarInput"
class="avatar-input"
type="file"
accept="image/jpeg,image/png,.jpg,.jpeg,.png"
@change="handleAvatarChange"
/>
<span v-if="editing" class="avatar-help">JPG/PNG最大 2 MB</span>
</div>
<div class="identity-summary">
<h2>{{ displayName }}</h2>
<dl>
<div>
<dt>用户名</dt>
<dd>{{ String(detail.username ?? '-') }}</dd>
</div>
<div>
<dt>唯一标识</dt>
<dd>
<IdentityText
v-if="detail.identity"
:value="String(detail.identity)"
/>
<template v-else>-</template>
</dd>
</div>
<div>
<dt>创建时间</dt>
<dd>{{ formatDate(detail.created_at) }}</dd>
</div>
</dl>
</div>
</div>
</a-card>
<a-card :bordered="false" class="form-card">
<a-tabs default-active-key="basic">
<a-tab-pane key="basic" title="基本信息">
<a-form :model="form" class="profile-form" layout="horizontal">
<a-form-item
v-for="field in profileFields"
:key="field.key"
:label="field.label"
:required="editing && isResourceFieldRequired(field, 'edit')"
>
<div v-if="!editing" class="detail-value">
{{ displayProfileValue(field) }}
</div>
<template v-else>
<a-switch
v-if="field.type === 'boolean'"
v-model="form[field.key]"
/>
<a-input-number
v-else-if="field.type === 'number' || field.type === 'money'"
v-model="form[field.key]"
:precision="field.type === 'money' ? 2 : 0"
/>
<a-date-picker
v-else-if="field.type === 'date'"
v-model="form[field.key]"
value-format="YYYY-MM-DD"
/>
<a-date-picker
v-else-if="field.type === 'datetime'"
v-model="form[field.key]"
show-time
value-format="YYYY-MM-DDTHH:mm:ssZ"
/>
<a-textarea
v-else-if="field.type === 'textarea'"
v-model="form[field.key]"
:auto-size="{ minRows: 3, maxRows: 8 }"
/>
<a-select
v-else-if="field.type === 'select'"
v-model="form[field.key]"
allow-clear
>
<a-option
v-for="option in field.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
<a-select
v-else-if="field.type === 'identity' || field.type === 'identity-list'"
v-model="form[field.key]"
:multiple="field.type === 'identity-list'"
:loading="relationLoading[field.relation ?? '']"
allow-clear
allow-search
>
<a-option
v-for="option in relationOptions[field.relation ?? ''] ?? []"
:key="String(option.identity)"
:value="String(option.identity)"
>
{{ optionLabel(option) }}
</a-option>
</a-select>
<a-input
v-else
v-model="form[field.key]"
:placeholder="`请输入${field.label}`"
/>
</template>
</a-form-item>
<a-form-item v-if="editing" class="form-actions">
<a-space>
<a-button type="primary" :loading="saving" @click="save">
保存
</a-button>
<a-button :disabled="saving" @click="cancelEdit">取消</a-button>
</a-space>
</a-form-item>
</a-form>
</a-tab-pane>
</a-tabs>
</a-card>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue';
import { IconCamera, IconEdit } from '@arco-design/web-vue/es/icon';
import dayjs from 'dayjs';
import {
computed,
onBeforeUnmount,
onMounted,
reactive,
ref,
watch,
} from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { avatarApi } from '@/api/avatar';
import { resourceApi } from '@/api/resource';
import {
buildResourcePayload,
isMissingField,
isResourceFieldRequired,
} from '@/api/resource-form';
import { getResource, type ResourceField } from '@/api/resources';
import IdentityText from '@/components/IdentityText.vue';
import { DEFAULT_USER_AVATAR } from '@/constants/avatar';
type Row = Record<string, unknown>;
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const saving = ref(false);
const detail = ref<Row>({});
const form = reactive<Record<string, any>>({});
const avatarInput = ref<HTMLInputElement>();
const avatarPreview = ref(DEFAULT_USER_AVATAR);
const selectedAvatar = ref<File>();
const relationOptions = reactive<Record<string, Row[]>>({});
const relationLoading = reactive<Record<string, boolean>>({});
let objectURL = '';
const definition = computed(() => getResource(String(route.meta.resource)));
const identity = computed(() => String(route.params.identity ?? ''));
const editing = computed(() => route.query.mode === 'edit');
const profileFields = computed<ResourceField[]>(() =>
definition.value.fields.filter(
(field) => !['username', 'password', 'avatar'].includes(field.key),
),
);
const displayName = computed(() =>
String(
detail.value.name ??
detail.value.real_name ??
detail.value.username ??
'账户资料',
),
);
/** 返回资料页所属的工作人员或用户列表。 */
function goBack() {
router.push({ name: String(route.meta.listRouteName) });
}
/** 将当前资料页切换为可编辑状态,并保留直达 URL 状态。 */
function startEdit() {
router.replace({ query: { ...route.query, mode: 'edit' } });
}
/** 移除编辑参数,恢复为只读详情状态。 */
async function leaveEditMode() {
const query = { ...route.query };
delete query.mode;
await router.replace({ query });
}
/** 将服务端详情复制到表单,金额字段保持前端元单位。 */
function resetForm() {
for (const field of profileFields.value) {
const value = detail.value[field.key];
form[field.key] =
value == null
? undefined
: field.type === 'money'
? Number(value) / 100
: value;
}
}
/** 加载账户详情、关联下拉选项和受保护头像。 */
async function loadProfile() {
loading.value = true;
try {
detail.value = await resourceApi.detail<Row>(
definition.value.resource,
identity.value,
);
resetForm();
await Promise.all([loadRelations(), loadAvatar()]);
} catch (error) {
Message.error((error as Error).message);
} finally {
loading.value = false;
}
}
/** 加载资料表单所需的关联资源选项。 */
async function loadRelations() {
const resources = new Set(
profileFields.value
.map((field) => field.relation)
.filter((value): value is string => Boolean(value)),
);
await Promise.all(
[...resources].map(async (resource) => {
relationLoading[resource] = true;
try {
relationOptions[resource] = (
await resourceApi.list<Row>(resource, 1, 100)
).list;
} catch {
relationOptions[resource] = [];
} finally {
relationLoading[resource] = false;
}
}),
);
}
/** 加载需要 JWT 的头像并生成仅限当前页面生命周期的 Blob URL。 */
async function loadAvatar() {
revokeObjectURL();
selectedAvatar.value = undefined;
const blob = await avatarApi.load(definition.value.resource, identity.value);
if (!blob) {
avatarPreview.value = DEFAULT_USER_AVATAR;
return;
}
objectURL = URL.createObjectURL(blob);
avatarPreview.value = objectURL;
}
/** 打开浏览器本地图片选择器。 */
function chooseAvatar() {
if (editing.value) avatarInput.value?.click();
}
/** 校验前端图片类型、大小、解码结果和像素尺寸。 */
async function validateAvatarFile(file: File) {
if (!['image/jpeg', 'image/png'].includes(file.type)) {
throw new Error('头像仅支持 JPG 或 PNG 格式');
}
if (file.size <= 0 || file.size > 2 * 1024 * 1024) {
throw new Error('头像大小不能超过 2 MB');
}
const bitmap = await createImageBitmap(file);
try {
if (
bitmap.width <= 0 ||
bitmap.height <= 0 ||
bitmap.width > 4096 ||
bitmap.height > 4096
) {
throw new Error('头像尺寸不能超过 4096×4096 像素');
}
} finally {
bitmap.close();
}
}
/** 选择头像后立即本地预览,文件在点击保存前不会上传。 */
async function handleAvatarChange(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
try {
await validateAvatarFile(file);
revokeObjectURL();
selectedAvatar.value = file;
objectURL = URL.createObjectURL(file);
avatarPreview.value = objectURL;
} catch (error) {
Message.warning((error as Error).message);
}
}
/** 取消编辑并重新加载服务端头像与表单值。 */
async function cancelEdit() {
resetForm();
await Promise.all([leaveEditMode(), loadAvatar()]);
}
/** 验证表单后保存资料;新头像在资料更新前完成受控上传。 */
async function save() {
if (
profileFields.value.some(
(field) =>
isResourceFieldRequired(field, 'edit') &&
isMissingField(form[field.key]),
)
) {
Message.warning('请填写必填字段');
return;
}
saving.value = true;
try {
const payload = buildResourcePayload(profileFields.value, form, 'edit');
if (selectedAvatar.value) {
const uploaded = await avatarApi.upload(selectedAvatar.value);
payload.avatar = uploaded.uri;
}
await resourceApi.update(
definition.value.resource,
identity.value,
payload,
);
Message.success('资料保存成功');
await leaveEditMode();
await loadProfile();
} catch (error) {
Message.error((error as Error).message);
} finally {
saving.value = false;
}
}
/** 格式化后端时间字段,兼容普通字符串与 GORM 时间对象。 */
function formatDate(value: unknown) {
const raw =
value && typeof value === 'object' && 'Time' in value
? (value as { Time?: unknown }).Time
: value;
const date = dayjs(String(raw ?? ''));
return date.isValid() ? date.format('YYYY-MM-DD HH:mm:ss') : '-';
}
/** 生成人可读的关联资源选项名称。 */
function optionLabel(option: Row) {
return String(
option.name ??
option.title ??
option.code ??
option.username ??
option.identity,
);
}
/** 将只读资料字段转换为适合页面展示的文本。 */
function displayProfileValue(field: ResourceField) {
const value = form[field.key];
if (isMissingField(value)) return field.emptyText ?? '-';
if (field.options) {
const selected = field.options.find(
(option) => String(option.value) === String(value),
);
if (selected) return selected.label;
}
if (field.type === 'boolean') return value === true ? '是' : '否';
if (field.type === 'money') {
const amount = Number(value);
return Number.isFinite(amount) ? `¥${amount.toFixed(2)}` : String(value);
}
if (field.type === 'date' || field.type === 'datetime')
return formatDate(value);
if (field.type === 'identity' && typeof value === 'string') {
const match = (relationOptions[field.relation ?? ''] ?? []).find(
(option) => String(option.identity) === value,
);
return match ? optionLabel(match) : value;
}
if (field.type === 'identity-list' && Array.isArray(value)) {
return value.join('、');
}
return String(value);
}
/** 释放浏览器创建的头像对象 URL避免页面切换后的内存泄漏。 */
function revokeObjectURL() {
if (objectURL) URL.revokeObjectURL(objectURL);
objectURL = '';
}
onMounted(loadProfile);
onBeforeUnmount(revokeObjectURL);
watch(
() => [route.meta.resource, route.params.identity],
([resource, nextIdentity], [previousResource, previousIdentity]) => {
if (resource !== previousResource || nextIdentity !== previousIdentity)
loadProfile();
},
);
</script>
<style scoped lang="less" src="./AccountProfilePage.less"></style>

View File

@@ -0,0 +1,192 @@
<!--
功能展示账户类资源的头像用户名唯一标识和创建时间摘要
版本v1.0.0
-->
<template>
<a-card class="account-card" :bordered="false">
<div class="account-summary">
<div class="avatar-column">
<button
class="avatar-button"
type="button"
:disabled="mode === 'detail' || !avatarEnabled"
:aria-label="mode === 'detail' || !avatarEnabled ? '账户头像' : '选择账户头像'"
@click="chooseAvatar"
>
<a-avatar :size="120" class="account-avatar">
<img :src="avatarUrl" alt="账户头像" />
</a-avatar>
<span v-if="mode !== 'detail' && avatarEnabled" class="camera-badge"><icon-camera /></span>
</button>
<input
ref="avatarInput"
class="avatar-input"
type="file"
accept="image/jpeg,image/png"
@change="onAvatarSelected"
/>
<span v-if="mode !== 'detail' && avatarEnabled" class="avatar-help">JPG/PNG最大 2 MB</span>
<a-button v-if="mode !== 'detail' && avatarEnabled && canClear" type="text" size="mini" @click="emit('clear-avatar')">
恢复默认头像
</a-button>
</div>
<div class="identity-summary">
<h2>{{ summaryTitle }}</h2>
<dl>
<div>
<dt>用户名</dt>
<dd>{{ String(record.username ?? '保存后确定') }}</dd>
</div>
<div>
<dt>唯一标识</dt>
<dd class="identity-value">{{ String(record.identity ?? '保存后生成') }}</dd>
</div>
<div>
<dt>创建时间</dt>
<dd>{{ displayRawValue('created_at', record.created_at) }}</dd>
</div>
</dl>
</div>
</div>
</a-card>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { IconCamera } from '@arco-design/web-vue/es/icon';
import { displayRawValue } from '@/api/resource-display';
import type { ResourceRow, RecordPageMode } from '@/api/resource-page-rules';
const props = defineProps<{
mode: RecordPageMode;
title: string;
record: ResourceRow;
avatarUrl: string;
avatarEnabled: boolean;
canClear: boolean;
}>();
const emit = defineEmits<{
'select-avatar': [file: File];
'clear-avatar': [];
}>();
const avatarInput = ref<HTMLInputElement>();
const summaryTitle = computed(() =>
String(
props.record.name ??
props.record.display_name ??
props.record.real_name ??
props.record.username ??
(props.mode === 'create' ? `新建${props.title}` : props.title),
),
);
function chooseAvatar() {
if (props.mode !== 'detail' && props.avatarEnabled)
avatarInput.value?.click();
}
function onAvatarSelected(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (file) emit('select-avatar', file);
input.value = '';
}
</script>
<style scoped lang="less">
.account-card {
border-radius: 10px;
}
.account-summary {
display: grid;
grid-template-columns: 180px minmax(360px, 500px);
justify-content: center;
align-items: center;
gap: 44px;
min-height: 168px;
padding: 18px 32px;
}
.avatar-column {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.avatar-button {
position: relative;
padding: 0;
background: transparent;
border: 0;
cursor: pointer;
}
.avatar-button:disabled {
cursor: default;
}
.account-avatar {
background: var(--color-fill-3);
}
.account-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.camera-badge {
position: absolute;
right: 2px;
bottom: 2px;
display: grid;
width: 34px;
height: 34px;
place-items: center;
color: rgb(var(--primary-6));
background: var(--color-bg-2);
border: 3px solid var(--color-bg-2);
border-radius: 50%;
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
}
.avatar-input {
display: none;
}
.avatar-help {
color: var(--color-text-3);
font-size: 12px;
}
.identity-summary h2 {
margin: 0 0 14px;
color: var(--color-text-1);
font-size: 20px;
}
.identity-summary dl,
.identity-summary dd {
margin: 0;
}
.identity-summary dl > div {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
align-items: center;
min-height: 38px;
}
.identity-summary dt {
color: var(--color-text-3);
text-align: right;
}
.identity-summary dd {
color: var(--color-text-1);
font-size: 15px;
}
.identity-value {
color: rgb(var(--primary-6)) !important;
word-break: break-all;
}
@media (max-width: 760px) {
.account-summary {
grid-template-columns: 1fr;
gap: 20px;
padding: 20px 8px;
}
.identity-summary {
width: min(100%, 440px);
margin: 0 auto;
}
}
</style>

View File

@@ -0,0 +1,164 @@
<!--
功能承载资源详情页中的审核流转归属调整和菜单分配等短业务操作
版本v1.0.0
-->
<template>
<a-modal
:visible="visible"
:title="action?.name"
:width="680"
:ok-loading="submitting"
:ok-button-props="{ status: action?.danger ? 'danger' : 'normal' }"
@cancel="close"
@ok="submit"
>
<a-alert v-if="action?.danger" class="action-alert" type="error" show-icon>
这是高风险操作可能改变业务状态且无法直接撤销请确认目标记录和填写内容准确
</a-alert>
<a-alert v-if="isOwnershipAction" class="action-alert" type="warning" show-icon>
智能气阀只能有一个当前归属选择新归属前请清空原归属操作会写入审计记录
</a-alert>
<a-form :model="form" layout="vertical">
<ResourceFieldForm
v-model="form"
:fields="action?.fields ?? []"
:required-keys="requiredKeys"
:relation-options="relations.options"
:relation-loading="relations.loading"
@search-relation="relations.search"
/>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue';
import { computed, reactive, ref, watch } from 'vue';
import { buildResourcePayload, isMissingField } from '@/api/resource-form';
import { resourceApi } from '@/api/resource';
import { platformApi } from '@/api/platform';
import type { DetailAction } from '@/api/resources';
import type { ResourceRow } from '@/api/resource-page-rules';
import ResourceFieldForm from './ResourceFieldForm.vue';
import { useResourceRelations } from './use-resource-relations';
const props = defineProps<{
visible: boolean;
action?: DetailAction;
identity: string;
record: ResourceRow;
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
completed: [];
}>();
const form = reactive<Record<string, any>>({});
const submitting = ref(false);
const relations = useResourceRelations();
const requiredKeys = computed(() =>
(props.action?.fields ?? [])
.filter((field) => field.required)
.map((field) => field.key),
);
const isOwnershipAction = computed(
() => props.action?.resource === '/product_info/:identity',
);
const ownershipKeys = [
'warehouse_identity',
'gas_basic_identity',
'delivery_basic_identity',
'user_account_identity',
];
watch(
() => [props.visible, props.action?.name] as const,
async ([visible]) => {
if (!visible || !props.action) return;
for (const field of props.action.fields ?? []) {
form[field.key] = isOwnershipAction.value
? props.record[field.key]
: undefined;
}
await relations.preload(props.action.fields ?? []);
if (props.action.resource.includes('/menu')) {
try {
const [menus, assigned] = await Promise.all([
platformApi.listMenu(),
platformApi.listRoleMenuIdentities(props.identity),
]);
relations.options['/platform_menu'] = menus.list;
form.menu_identities = assigned.menu_identities;
} catch (error) {
Message.error((error as Error).message);
close();
}
}
},
);
function close() {
emit('update:visible', false);
}
async function submit() {
const action = props.action;
if (!action) return;
if (
(action.fields ?? []).some(
(field) => field.required && isMissingField(form[field.key]),
)
) {
Message.warning('请填写必填字段');
return;
}
const invalidPassword = (action.fields ?? []).find(
(field) =>
field.type === 'password' &&
!isMissingField(form[field.key]) &&
String(form[field.key]).length < 6,
);
if (invalidPassword) {
Message.warning(`${invalidPassword.label}长度不能少于 6 位`);
return;
}
if (isOwnershipAction.value) {
const selected = ownershipKeys.filter((key) => !isMissingField(form[key]));
if (selected.length > 1) {
Message.warning('智能气阀只能选择一个当前归属');
return;
}
}
submitting.value = true;
try {
const payload = buildResourcePayload(action.fields ?? [], form, 'create');
if (isOwnershipAction.value) {
for (const key of ownershipKeys) payload[key] = String(form[key] ?? '');
}
if (action.resource.includes('/menu')) {
await platformApi.replaceRoleMenus(
props.identity,
payload.menu_identities as string[],
);
} else {
await resourceApi.action(
action.resource.replace(':identity', props.identity),
action.method ?? 'POST',
payload,
);
}
Message.success('操作成功');
close();
emit('completed');
} catch (error) {
Message.error((error as Error).message);
} finally {
submitting.value = false;
}
}
</script>
<style scoped lang="less">
.action-alert {
margin-bottom: 16px;
}
</style>

View File

@@ -0,0 +1,214 @@
<!--
功能以响应式信息卡和子表页签展示标准资源详情
版本v1.1.0
-->
<template>
<div class="detail-stack">
<a-card title="基本信息" :bordered="false" class="detail-card">
<div class="detail-grid">
<div v-for="entry in entries" :key="entry.key" class="detail-item" :class="{ 'detail-item-wide': entry.wide }">
<span class="detail-label">{{ entry.label }}</span>
<pre v-if="entry.objectValue" class="json-value">{{ entry.value }}</pre>
<IdentityText v-else-if="entry.key === 'identity'" :value="String(entry.value)" />
<span v-else class="detail-value">{{ entry.value }}</span>
</div>
</div>
</a-card>
<a-card v-if="collections.length" title="关联记录" :bordered="false" class="detail-card">
<a-tabs>
<a-tab-pane v-for="collection in collections" :key="collection.key" :title="collection.title">
<a-table :data="collection.rows" :pagination="false" size="small" table-layout-fixed>
<template #columns>
<a-table-column
v-for="column in collection.columns"
:key="column"
:title="resourceFieldLabel(definition, column)"
:width="column.includes('identity') ? 220 : 160"
ellipsis
tooltip
>
<template #cell="{ record }">
<IdentityText v-if="column.includes('identity') && record[column]" :value="String(record[column])" />
<template v-else>{{ displayRawValue(column, record[column]) }}</template>
</template>
</a-table-column>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
</a-card>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import IdentityText from '@/components/IdentityText.vue';
import {
displayRawValue,
displayResourceField,
isEmptyDeletedAt,
primaryRecord,
resourceFieldLabel,
} from '@/api/resource-display';
import type { ResourceUiDefinition } from '@/api/resources';
import type { ResourceRow } from '@/api/resource-page-rules';
const props = defineProps<{
definition: ResourceUiDefinition;
detail: ResourceRow;
relationOptions: Record<string, ResourceRow[]>;
accountSummary: boolean;
}>();
type DetailEntry = {
key: string;
label: string;
value: string;
objectValue: boolean;
wide: boolean;
};
const entries = computed<DetailEntry[]>(() => {
const row = primaryRecord(props.detail);
const excluded = new Set(['id', 'password', 'password_hash', 'avatar']);
if (props.accountSummary) {
for (const key of ['username', 'identity', 'created_at']) excluded.add(key);
}
const preferred = [
'identity',
'status',
...props.definition.fields.map((field) => field.key),
'created_at',
'updated_at',
];
const keys = [...new Set([...preferred, ...Object.keys(row)])];
return keys.flatMap((key) => {
const maskedKey = `${key}_masked`;
const actualKey = Object.prototype.hasOwnProperty.call(row, key)
? key
: Object.prototype.hasOwnProperty.call(row, maskedKey)
? maskedKey
: '';
if (!actualKey || excluded.has(key) || key.endsWith('_id')) return [];
const value = row[actualKey];
if (Array.isArray(value)) return [];
if (['deleted_at', 'DeletedAt'].includes(key) && isEmptyDeletedAt(value))
return [];
const field = props.definition.fields.find((item) => item.key === key);
const display = field
? displayResourceField(field, row, props.relationOptions)
: displayRawValue(actualKey, value);
const objectValue = typeof value === 'object' && value !== null;
return [
{
key,
label: resourceFieldLabel(props.definition, actualKey),
value: display,
objectValue,
wide:
objectValue ||
/(address|terms|content|body|remark|reason|params|args)$/.test(key),
},
];
});
});
const collections = computed(() =>
Object.entries(props.detail)
.filter(([, value]) => Array.isArray(value) && value.length > 0)
.map(([key, value]) => {
const rows = value as ResourceRow[];
const columns = [
...new Set(
rows.flatMap((row) =>
Object.keys(row).filter(
(column) => column !== 'id' && !column.endsWith('_id'),
),
),
),
].slice(0, 8);
return {
key,
title: resourceFieldLabel(props.definition, key),
rows,
columns,
};
}),
);
</script>
<style scoped lang="less">
.detail-stack {
display: grid;
gap: 12px;
}
.detail-card {
border-radius: 10px;
}
.detail-card :deep(.arco-card-header) {
height: 48px;
padding: 0 24px;
}
.detail-card :deep(.arco-card-body) {
padding: 20px 24px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px 42px;
padding: 0;
}
.detail-item {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
align-items: start;
min-height: 38px;
padding: 8px 0;
border-bottom: 1px solid var(--color-border-1);
}
.detail-item-wide {
grid-column: 1 / -1;
}
.detail-label {
color: var(--color-text-3);
text-align: right;
padding-right: 18px;
}
.detail-value {
color: var(--color-text-1);
white-space: pre-wrap;
word-break: break-word;
}
.json-value {
max-height: 360px;
margin: 0;
padding: 12px;
overflow: auto;
color: var(--color-text-1);
background: var(--color-fill-1);
border-radius: 6px;
white-space: pre-wrap;
}
@media (max-width: 1280px) {
.detail-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.detail-card :deep(.arco-card-header),
.detail-card :deep(.arco-card-body) {
padding-right: 16px;
padding-left: 16px;
}
.detail-grid {
grid-template-columns: 1fr;
gap: 4px;
}
.detail-item,
.detail-item-wide {
grid-column: auto;
grid-template-columns: 100px minmax(0, 1fr);
}
}
</style>

View File

@@ -0,0 +1,172 @@
<!--
功能渲染标准资源的新建编辑和业务动作字段控件
版本v1.0.0
-->
<template>
<div class="field-grid">
<a-form-item
v-for="field in fields"
:key="field.key"
:class="{ 'field-wide': isWide(field), 'field-readonly': disabledSet.has(field.key) }"
:label="field.label"
:required="requiredKeys.includes(field.key)"
>
<a-switch
v-if="field.type === 'boolean'"
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
/>
<a-input-number
v-else-if="field.type === 'number' || field.type === 'money'"
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
:precision="field.type === 'money' ? 2 : 0"
/>
<a-date-picker
v-else-if="field.type === 'date'"
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
value-format="YYYY-MM-DD"
/>
<a-date-picker
v-else-if="field.type === 'datetime'"
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
show-time
value-format="YYYY-MM-DDTHH:mm:ssZ"
/>
<a-textarea
v-else-if="field.type === 'textarea'"
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
:auto-size="{ minRows: 3, maxRows: 10 }"
/>
<a-input-password
v-else-if="field.type === 'password'"
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
:placeholder="requiredKeys.includes(field.key) ? '请输入至少 6 个字符' : '留空表示不修改密码'"
/>
<a-select
v-else-if="field.key === 'platform_role_code'"
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
allow-clear
>
<a-option v-for="role in roleOptions" :key="role.role_code" :value="role.role_code">
{{ role.name }}
</a-option>
</a-select>
<a-select
v-else-if="field.type === 'select'"
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
allow-clear
>
<a-option v-for="option in field.options" :key="option.value" :value="option.value">
{{ option.label }}
</a-option>
</a-select>
<a-select
v-else-if="field.type === 'identity' || field.type === 'identity-list'"
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
:multiple="field.type === 'identity-list'"
:placeholder="field.placeholder"
:loading="relationLoading[field.relation ?? '']"
allow-clear
allow-search
@search="(value: string) => emit('search-relation', field.relation, value)"
>
<a-option
v-for="option in relationOptions[field.relation ?? ''] ?? []"
:key="String(option.identity)"
:value="String(option.identity)"
>
{{ optionLabel(option) }}
</a-option>
</a-select>
<a-input
v-else
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
:placeholder="disabledSet.has(field.key) ? '创建后不可修改' : `请输入${field.label}`"
/>
<div v-if="disabledSet.has(field.key)" class="readonly-hint">创建后不可修改</div>
</a-form-item>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { optionLabel } from '@/api/resource-display';
import type { ResourceField } from '@/api/resources';
import type { ResourceRow } from '@/api/resource-page-rules';
import type { PlatformRole } from '@/api/platform';
const props = withDefaults(
defineProps<{
fields: ResourceField[];
requiredKeys?: string[];
disabledKeys?: string[];
relationOptions?: Record<string, ResourceRow[]>;
relationLoading?: Record<string, boolean>;
roleOptions?: PlatformRole[];
}>(),
{
requiredKeys: () => [],
disabledKeys: () => [],
relationOptions: () => ({}),
relationLoading: () => ({}),
roleOptions: () => [],
},
);
const emit = defineEmits<{
'search-relation': [resource: string | undefined, keyword: string];
}>();
const model = defineModel<Record<string, any>>({ required: true });
const disabledSet = computed(() => new Set(props.disabledKeys));
function isWide(field: ResourceField) {
return (
field.type === 'textarea' ||
field.type === 'identity-list' ||
/(address|terms|content|body|remark|reason|params|args)$/.test(field.key)
);
}
</script>
<style scoped lang="less">
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px 32px;
}
.field-wide {
grid-column: 1 / -1;
}
.field-readonly :deep(.arco-input-wrapper),
.field-readonly :deep(.arco-select-view),
.field-readonly :deep(.arco-picker),
.field-readonly :deep(.arco-input-number) {
background: var(--color-fill-1);
}
.readonly-hint {
margin-top: 4px;
color: var(--color-text-3);
font-size: 12px;
}
:deep(.arco-input-number),
:deep(.arco-picker) {
width: 100%;
}
@media (max-width: 900px) {
.field-grid {
grid-template-columns: 1fr;
}
.field-wide {
grid-column: auto;
}
}
</style>

View File

@@ -0,0 +1,96 @@
/*
* 功能:定义标准资源独立页面的布局、卡片和响应式样式。
* 版本v1.2.0
*/
.record-page {
display: grid;
align-content: start;
gap: 12px;
min-height: 100%;
padding: 16px 20px 20px;
background: var(--color-fill-2);
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
padding: 4px;
}
.state-card {
display: grid;
min-height: 260px;
place-items: center;
border-radius: 10px;
}
.section-card,
.form-card {
border-radius: 10px;
}
.section-card :deep(.arco-card-header) {
height: 48px;
padding: 0 24px;
}
.section-card :deep(.arco-card-body) {
padding: 18px 24px;
}
.form-card {
width: 100%;
margin: 0;
}
.form-card :deep(.arco-card-header) {
padding: 0 32px;
}
.form-card :deep(.arco-card-body) {
padding: 24px 32px 28px;
}
.form-shell {
width: 100%;
max-width: 1440px;
margin: 0 auto;
}
.form-content {
container: record-form / inline-size;
}
.form-alert,
.workflow-alert {
margin-bottom: 18px;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
}
.status-editor {
display: flex;
align-items: center;
gap: 12px;
}
@media (max-width: 760px) {
.record-page {
padding: 12px;
}
.form-card :deep(.arco-card-header) {
padding: 0 16px;
}
.form-card :deep(.arco-card-body) {
padding: 20px 16px 24px;
}
.page-header {
flex-direction: column;
}
.section-card :deep(.arco-card-header),
.section-card :deep(.arco-card-body) {
padding-right: 16px;
padding-left: 16px;
}
}
@container record-form (max-width: 899px) {
.form-content :deep(.field-grid) {
grid-template-columns: 1fr;
}
.form-content :deep(.field-wide) {
grid-column: auto;
}
}

View File

@@ -0,0 +1,483 @@
<!--
功能承载平台总后台全部标准资源的新建详情和编辑独立页面
版本v1.2.0
-->
<template>
<div class="record-page">
<header class="page-header">
<div>
<a-breadcrumb>
<a-breadcrumb-item><a-link @click="goBack">{{ definition.title }}列表</a-link></a-breadcrumb-item>
<a-breadcrumb-item>{{ modeLabel }}</a-breadcrumb-item>
</a-breadcrumb>
</div>
<a-space>
<a-button v-if="mode === 'detail' && canEditRecord" type="primary" @click="goEdit">
<template #icon><icon-edit /></template>编辑
</a-button>
<a-button @click="requestBack"><template #icon><icon-left /></template>返回</a-button>
</a-space>
</header>
<a-card v-if="loading" :bordered="false" class="state-card">
<a-spin tip="正在加载记录…" />
</a-card>
<a-result v-else-if="errorMessage" :status="errorStatus" :title="errorTitle" :subtitle="errorMessage">
<template #extra>
<a-space>
<a-button v-if="errorStatus === 'error'" type="primary" @click="initialize">重新加载</a-button>
<a-button @click="goBack">返回列表</a-button>
</a-space>
</template>
</a-result>
<template v-else>
<ResourceAccountSummary
v-if="accountSummary"
:mode="mode"
:title="definition.title"
:record="summaryRecord"
:avatar-url="avatarUrl"
:avatar-enabled="hasAvatarField"
:can-clear="avatarCanClear"
@select-avatar="selectAvatar"
@clear-avatar="clearAvatar"
/>
<template v-if="mode === 'detail'">
<ResourceDetailContent
:definition="definition"
:detail="detail"
:relation-options="relations.options"
:account-summary="accountSummary"
/>
<ResourceWalletSummary
v-if="definition.walletOwnerType"
:wallet="wallet"
@view="viewWallet"
/>
<a-card v-if="definition.name === 'gas_basic'" title="状态管理" :bordered="false" class="section-card">
<div class="status-editor">
<a-tag :color="recordStatusColor(Number(record.status))">
{{ recordStatusLabel(Number(record.status)) }}
</a-tag>
<span>启用状态</span>
<a-switch
:model-value="Number(record.status) === 1"
:loading="statusSaving"
:disabled="![0, 1, 2].includes(Number(record.status))"
checked-text="启用"
unchecked-text="停用"
@change="updateGasStatus"
/>
</div>
</a-card>
<a-card v-if="visibleActions.length" title="业务操作" :bordered="false" class="section-card">
<a-alert class="workflow-alert" type="warning" show-icon>
业务操作会按服务端状态校验并保留审计记录请确认目标记录和填写内容准确
</a-alert>
<a-space wrap>
<a-button
v-for="action in visibleActions"
:key="action.name"
type="primary"
:status="action.danger ? 'danger' : 'normal'"
@click="openAction(action)"
>{{ action.name }}</a-button>
</a-space>
</a-card>
</template>
<a-card v-else :bordered="false" class="form-card">
<template #title>
<div class="form-shell">基本信息</div>
</template>
<div class="form-shell form-content">
<a-alert v-if="mode === 'edit' && readonlyKeys.length" class="form-alert" type="info" show-icon>
灰色字段为创建后不可修改的信息不会提交到更新接口
</a-alert>
<a-form :model="form" layout="vertical">
<ResourceFieldForm
v-model="form"
:fields="formFields"
:required-keys="requiredKeys"
:disabled-keys="readonlyKeys"
:relation-options="relations.options"
:relation-loading="relations.loading"
:role-options="roleOptions"
@search-relation="relations.search"
/>
</a-form>
<div class="form-actions">
<a-button type="primary" :loading="saving" @click="save">保存</a-button>
<a-button :disabled="saving" @click="cancelForm">取消</a-button>
</div>
</div>
</a-card>
</template>
<ResourceActionDialog
v-model:visible="actionVisible"
:action="activeAction"
:identity="recordIdentity"
:record="record"
@completed="reloadDetail"
/>
</div>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue';
import { IconEdit, IconLeft } from '@arco-design/web-vue/es/icon';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ApiError } from '@/api/http';
import { platformApi, type PlatformRole } from '@/api/platform';
import { resourceApi } from '@/api/resource';
import { buildResourcePayload } from '@/api/resource-form';
import {
resetResourceRecordForm,
validateResourceRecordForm,
} from '@/api/resource-record-form';
import {
createBlockedReason,
editableFields,
editBlockedReason,
fieldRequired,
pageFields,
type RecordPageMode,
type ResourceRow,
updatePayloadFields,
usesAccountSummary,
} from '@/api/resource-page-rules';
import {
createdRecordListRoute,
recordRouteLocation,
safeReturnPath,
} from '@/api/resource-navigation';
import {
primaryRecord,
recordPageErrorTitle,
recordPageModeLabel,
recordStatusColor,
recordStatusLabel,
} from '@/api/resource-display';
import { getResource, type DetailAction } from '@/api/resources';
import { useUserStore } from '@/store';
import ResourceAccountSummary from './ResourceAccountSummary.vue';
import ResourceActionDialog from './ResourceActionDialog.vue';
import ResourceDetailContent from './ResourceDetailContent.vue';
import ResourceFieldForm from './ResourceFieldForm.vue';
import ResourceWalletSummary from './ResourceWalletSummary.vue';
import { createResourceRecordNavigation } from './resource-record-navigation';
import { useResourceAvatar } from './use-resource-avatar';
import { useResourceRelations } from './use-resource-relations';
import { useUnsavedRecord } from './use-unsaved-record';
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const definition = computed(() => getResource(String(route.meta.resource)));
const mode = computed<RecordPageMode>(() => route.meta.recordMode ?? 'detail');
const identity = computed(() => String(route.params.identity ?? ''));
const listRouteName = computed(() => String(route.meta.listRouteName ?? ''));
const returnPath = computed(() => safeReturnPath(route.query.return_to));
const context = computed(() => ({
role: userStore.role,
accountIdentity: userStore.accountId ?? '',
}));
const detail = ref<ResourceRow>({});
const record = computed(() => primaryRecord(detail.value));
const form = reactive<Record<string, any>>({});
const loading = ref(true);
const saving = ref(false);
const statusSaving = ref(false);
const errorMessage = ref('');
const errorStatus = ref<'403' | '404' | 'error'>('error');
const wallet = ref<ResourceRow>();
const roleOptions = ref<PlatformRole[]>([]);
const relations = useResourceRelations();
const actionVisible = ref(false);
const activeAction = ref<DetailAction>();
const avatar = useResourceAvatar();
const avatarUrl = avatar.url;
const avatarCanClear = avatar.canClear;
const selectAvatar = avatar.select;
const clearAvatar = avatar.clear;
const accountSummary = computed(() => usesAccountSummary(definition.value));
const hasAvatarField = computed(() =>
definition.value.fields.some((field) => field.key === 'avatar'),
);
const formFields = computed(() =>
mode.value === 'detail'
? []
: pageFields(
definition.value,
mode.value,
record.value,
context.value,
).filter((field) => field.key !== 'avatar'),
);
const payloadFields = computed(() =>
mode.value === 'edit'
? updatePayloadFields(definition.value, record.value, context.value).filter(
(field) => field.key !== 'avatar',
)
: formFields.value,
);
const editableKeySet = computed(
() =>
new Set(
editableFields(definition.value, record.value, context.value).map(
(field) => field.key,
),
),
);
const readonlyKeys = computed(() =>
mode.value === 'edit'
? formFields.value
.filter((field) => !editableKeySet.value.has(field.key))
.map((field) => field.key)
: [],
);
const requiredKeys = computed(() =>
payloadFields.value
.filter((field) =>
fieldRequired(definition.value, field, mode.value as 'create' | 'edit'),
)
.map((field) => field.key),
);
const summaryRecord = computed(() =>
mode.value === 'detail' ? record.value : { ...record.value, ...form },
);
const recordIdentity = computed(() =>
String(record.value.identity ?? identity.value),
);
const blockReason = computed(() =>
mode.value === 'create'
? createBlockedReason(definition.value, context.value)
: mode.value === 'edit'
? editBlockedReason(definition.value, record.value, context.value)
: '',
);
const canEditRecord = computed(
() => !editBlockedReason(definition.value, record.value, context.value),
);
const visibleActions = computed(() =>
(definition.value.detailActions ?? []).filter(
(action) =>
!action.visibleFor ||
action.visibleFor.values.includes(
record.value[action.visibleFor.field] as string | number,
),
),
);
const modeLabel = computed(() => recordPageModeLabel(mode.value));
const errorTitle = computed(() => recordPageErrorTitle(errorStatus.value));
function snapshot() {
return JSON.stringify({ form, avatar: avatar.marker() });
}
const unsaved = useUnsavedRecord(snapshot, () => mode.value !== 'detail');
const { goEdit, viewWallet, goBack, requestBack } =
createResourceRecordNavigation({
router,
listRouteName: () => listRouteName.value,
identity: () => identity.value,
returnPath: () => returnPath.value,
sourcePath: () => route.fullPath,
mode: () => mode.value,
walletIdentity: () => String(wallet.value?.identity ?? ''),
confirmDiscard: unsaved.confirmDiscard,
});
const cancelForm = requestBack;
async function initialize() {
if (mode.value === 'detail' && route.query.mode === 'edit') {
await router.replace(
recordRouteLocation(
listRouteName.value,
'edit',
identity.value,
returnPath.value,
),
);
return;
}
loading.value = true;
errorMessage.value = '';
try {
if (mode.value !== 'create')
detail.value = await resourceApi.detail<ResourceRow>(
definition.value.resource,
identity.value,
);
if (blockReason.value) {
errorStatus.value = '403';
errorMessage.value = blockReason.value;
return;
}
resetResourceRecordForm(form, formFields.value, record.value, {
relationKey:
mode.value === 'create' ? String(route.query.relation_key ?? '') : '',
ownerIdentity:
mode.value === 'create' ? String(route.query.owner_identity ?? '') : '',
staffType:
mode.value === 'create' ? String(route.meta.staffType ?? '') : '',
});
await loadRelationsAndRoles();
// 钱包和头像属于附加信息,读取失败时不能阻断主详情或基础表单。
await Promise.allSettled([loadWallet(), loadAvatar()]);
unsaved.markInitialized();
} catch (error) {
const message = (error as Error).message;
errorStatus.value =
(error instanceof ApiError && error.status === 404) ||
message.includes('记录不存在')
? '404'
: (error instanceof ApiError && error.status === 403) ||
message.includes('无权访问')
? '403'
: 'error';
errorMessage.value = message;
} finally {
loading.value = false;
}
}
async function loadRelationsAndRoles() {
await relations.preload(
mode.value === 'detail' ? definition.value.fields : formFields.value,
);
if (
definition.value.fields.some((field) => field.key === 'platform_role_code')
) {
roleOptions.value = (await platformApi.listRole()).list.filter(
(role) => !role.is_system && role.status === 1,
);
}
}
async function loadWallet() {
if (
!definition.value.walletOwnerType ||
mode.value === 'create' ||
!recordIdentity.value
)
return;
wallet.value = (
await resourceApi.list<ResourceRow>('/wallet_basic', 1, 1, {
owner_type: definition.value.walletOwnerType,
owner_identities: recordIdentity.value,
})
).list[0];
}
async function loadAvatar() {
if (!hasAvatarField.value || mode.value === 'create' || !recordIdentity.value)
return;
await avatar.load(definition.value.resource, recordIdentity.value);
}
async function save() {
const validationError = validateResourceRecordForm(
form,
payloadFields.value,
requiredKeys.value,
);
if (validationError) {
Message.warning(validationError);
return;
}
saving.value = true;
try {
const payload = buildResourcePayload(
payloadFields.value,
form,
mode.value as 'create' | 'edit',
);
await avatar.applyToPayload(payload);
if (mode.value === 'create') {
const created = await resourceApi.create<ResourceRow>(
definition.value.resource,
payload,
);
const createdRow = { ...form, ...created };
const targetList = createdRecordListRoute(
definition.value.name,
listRouteName.value,
createdRow,
);
unsaved.allowNextNavigation();
Message.success('创建成功');
await router.replace(
recordRouteLocation(
targetList,
'detail',
String(created.identity),
returnPath.value,
),
);
} else {
await resourceApi.update(
definition.value.resource,
identity.value,
payload,
);
await resourceApi.detail<ResourceRow>(
definition.value.resource,
identity.value,
);
unsaved.allowNextNavigation();
Message.success('保存成功');
await router.replace(
recordRouteLocation(
listRouteName.value,
'detail',
identity.value,
returnPath.value,
),
);
}
} catch (error) {
Message.error((error as Error).message);
} finally {
saving.value = false;
}
}
async function reloadDetail() {
detail.value = await resourceApi.detail<ResourceRow>(
definition.value.resource,
identity.value,
);
await Promise.allSettled([loadWallet(), loadAvatar()]);
}
async function updateGasStatus(enabled: string | number | boolean) {
statusSaving.value = true;
try {
await resourceApi.updateStatus(
definition.value.resource,
recordIdentity.value,
Boolean(enabled) ? 1 : 2,
);
Message.success('状态修改成功');
await reloadDetail();
} catch (error) {
Message.error((error as Error).message);
} finally {
statusSaving.value = false;
}
}
function openAction(action: DetailAction) {
activeAction.value = action;
actionVisible.value = true;
}
onMounted(initialize);
</script>
<style scoped lang="less" src="./ResourceRecordPage.less"></style>

View File

@@ -0,0 +1,98 @@
<!--
功能展示气站配送点工作人员和用户的关联钱包摘要
版本v1.1.0
-->
<template>
<a-card title="钱包信息" :bordered="false" class="wallet-card">
<div v-if="!wallet" class="wallet-empty">
<icon-info-circle />
<span>尚未开通钱包</span>
</div>
<div v-else class="wallet-grid">
<div><span>钱包标识</span><IdentityText :value="String(wallet.identity ?? '')" /></div>
<div><span>余额</span><strong>{{ displayRawValue('balance', wallet.balance) }}</strong></div>
<div><span>可提现余额</span><strong>{{ displayRawValue('withdrawal_balance', wallet.withdrawal_balance) }}</strong></div>
<div>
<span>状态</span>
<a-tag :color="recordStatusColor(Number(wallet.status))">
{{ recordStatusLabel(Number(wallet.status)) }}
</a-tag>
</div>
<div class="wallet-action">
<span>操作</span>
<a-button type="text" @click="emit('view')">查看钱包详情</a-button>
</div>
</div>
</a-card>
</template>
<script setup lang="ts">
import { IconInfoCircle } from '@arco-design/web-vue/es/icon';
import IdentityText from '@/components/IdentityText.vue';
import {
displayRawValue,
recordStatusColor,
recordStatusLabel,
} from '@/api/resource-display';
import type { ResourceRow } from '@/api/resource-page-rules';
defineProps<{ wallet?: ResourceRow }>();
const emit = defineEmits<{ view: [] }>();
</script>
<style scoped lang="less">
.wallet-card {
border-radius: 10px;
}
.wallet-card :deep(.arco-card-header) {
height: 48px;
padding: 0 24px;
}
.wallet-card :deep(.arco-card-body) {
padding: 18px 24px;
}
.wallet-empty {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 28px;
color: var(--color-text-3);
}
.wallet-empty :deep(svg) {
font-size: 18px;
}
.wallet-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 24px;
}
.wallet-grid > div {
display: grid;
gap: 8px;
min-width: 0;
}
.wallet-grid span {
color: var(--color-text-3);
}
.wallet-grid strong {
color: var(--color-text-1);
font-size: 18px;
}
.wallet-action :deep(.arco-btn) {
justify-content: flex-start;
padding-left: 0;
}
@media (max-width: 900px) {
.wallet-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.wallet-card :deep(.arco-card-header),
.wallet-card :deep(.arco-card-body) {
padding-right: 16px;
padding-left: 16px;
}
}
</style>

View File

@@ -0,0 +1,68 @@
/**
* 功能:封装资源详情、新建、编辑和关联钱包页面之间的导航动作。
* 版本v1.0.0
*/
import type { Router } from 'vue-router';
import { recordRouteLocation, returnToList } from '@/api/resource-navigation';
import type { RecordPageMode } from '@/api/resource-page-rules';
type ResourceRecordNavigationOptions = {
router: Router;
listRouteName: () => string;
identity: () => string;
returnPath: () => string;
sourcePath: () => string;
mode: () => RecordPageMode;
walletIdentity: () => string;
confirmDiscard: (action: () => unknown) => unknown;
};
/** 创建当前资源记录页需要的全部导航动作。 */
export function createResourceRecordNavigation(
options: ResourceRecordNavigationOptions,
) {
function currentRecordLocation(targetMode: 'detail' | 'edit') {
return recordRouteLocation(
options.listRouteName(),
targetMode,
options.identity(),
options.returnPath(),
);
}
function goEdit() {
return options.router.push(currentRecordLocation('edit'));
}
function viewWallet() {
const identity = options.walletIdentity();
if (!identity) return;
return options.router.push(
recordRouteLocation(
'finance-wallets',
'detail',
identity,
options.sourcePath(),
),
);
}
function goBack() {
return returnToList(
options.router,
options.returnPath(),
options.listRouteName(),
);
}
function requestBack() {
if (options.mode() === 'detail') return goBack();
const leave = () =>
options.mode() === 'edit'
? options.router.push(currentRecordLocation('detail'))
: goBack();
return options.confirmDiscard(leave);
}
return { goEdit, viewWallet, goBack, requestBack };
}

View File

@@ -0,0 +1,90 @@
/**
* 功能:管理资源页头像的鉴权读取、本地预览、清除和保存前上传。
* 版本v1.0.0
*/
import { Message } from '@arco-design/web-vue';
import { onBeforeUnmount, ref } from 'vue';
import { avatarApi } from '@/api/avatar';
import { DEFAULT_USER_AVATAR } from '@/constants/avatar';
export function useResourceAvatar() {
const url = ref(DEFAULT_USER_AVATAR);
const file = ref<File>();
const cleared = ref(false);
const canClear = ref(false);
let objectURL = '';
function revoke() {
if (objectURL) URL.revokeObjectURL(objectURL);
objectURL = '';
}
/** 加载现有受保护头像,记录没有头像时继续使用默认图。 */
async function load(resource: string, identity: string) {
revoke();
url.value = DEFAULT_USER_AVATAR;
canClear.value = false;
file.value = undefined;
cleared.value = false;
if (!identity) return;
const blob = await avatarApi.load(resource, identity);
if (!blob) return;
objectURL = URL.createObjectURL(blob);
url.value = objectURL;
canClear.value = true;
}
/** 校验并预览用户选择的新头像。 */
function select(next: File) {
if (
!['image/jpeg', 'image/png'].includes(next.type) ||
next.size <= 0 ||
next.size > 2 * 1024 * 1024
) {
Message.warning('请选择不超过 2 MB 的 JPG 或 PNG 图片');
return;
}
revoke();
objectURL = URL.createObjectURL(next);
url.value = objectURL;
file.value = next;
cleared.value = false;
canClear.value = true;
}
/** 标记清除头像,真正写入空值发生在保存资料时。 */
function clear() {
revoke();
url.value = DEFAULT_USER_AVATAR;
file.value = undefined;
cleared.value = true;
canClear.value = false;
}
/** 将头像变化写入资源更新载荷。 */
async function applyToPayload(payload: Record<string, unknown>) {
if (file.value) payload.avatar = (await avatarApi.upload(file.value)).uri;
else if (cleared.value) payload.avatar = '';
}
function marker() {
return file.value
? `${file.value.name}:${file.value.size}:${file.value.lastModified}`
: cleared.value
? 'clear'
: 'unchanged';
}
onBeforeUnmount(revoke);
return {
url,
file,
cleared,
canClear,
load,
select,
clear,
applyToPayload,
marker,
};
}

View File

@@ -0,0 +1,54 @@
/**
* 功能:为资源表单和详情页加载、缓存并搜索关联资源选项。
* 版本v1.0.0
*/
import { Message } from '@arco-design/web-vue';
import { reactive } from 'vue';
import { resourceApi } from '@/api/resource';
import type { ResourceField } from '@/api/resources';
import type { ResourceRow } from '@/api/resource-page-rules';
export function useResourceRelations() {
const options = reactive<Record<string, ResourceRow[]>>({});
const loading = reactive<Record<string, boolean>>({});
const timers = new Map<string, ReturnType<typeof setTimeout>>();
/** 加载一类关联资源,工作人员关系默认只选择配送人员。 */
async function load(resource: string, keyword = '') {
loading[resource] = true;
try {
const filters: Record<string, string> = keyword ? { keyword } : {};
if (resource === '/staff_account') filters.role_code = 'delivery';
options[resource] = (
await resourceApi.list<ResourceRow>(resource, 1, 100, filters)
).list;
} catch (error) {
Message.error(`关联数据加载失败:${(error as Error).message}`);
} finally {
loading[resource] = false;
}
}
/** 预加载当前页面实际使用的全部关系字段。 */
async function preload(fields: ResourceField[]) {
const resources = new Set(
fields
.map((field) => field.relation)
.filter((value): value is string => Boolean(value)),
);
await Promise.all([...resources].map((resource) => load(resource)));
}
/** 对远程关系选项进行防抖搜索。 */
function search(resource: string | undefined, keyword: string) {
if (!resource) return;
const timer = timers.get(resource);
if (timer) clearTimeout(timer);
timers.set(
resource,
setTimeout(() => load(resource, keyword.trim()), 250),
);
}
return { options, loading, load, preload, search };
}

View File

@@ -0,0 +1,65 @@
/**
* 功能:统一资源新建和编辑页面的未保存离开保护。
* 版本v1.0.0
*/
import { Modal } from '@arco-design/web-vue';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { onBeforeRouteLeave } from 'vue-router';
export function useUnsavedRecord(
snapshot: () => string,
active: () => boolean,
) {
const initial = ref('');
const bypass = ref(false);
const dirty = computed(
() => active() && initial.value !== '' && snapshot() !== initial.value,
);
function markInitialized() {
initial.value = snapshot();
}
function allowNextNavigation() {
bypass.value = true;
}
/** 对页面内的取消或返回操作进行一次明确确认。 */
function confirmDiscard(action: () => unknown) {
if (!dirty.value) return action();
Modal.warning({
title: '放弃未保存的修改?',
content: '当前页面内容尚未保存,离开后将无法恢复。',
hideCancel: false,
onOk: () => {
allowNextNavigation();
action();
},
});
}
function beforeUnload(event: BeforeUnloadEvent) {
if (!dirty.value) return;
event.preventDefault();
event.returnValue = '';
}
onBeforeRouteLeave(() => {
if (bypass.value || !dirty.value) return true;
return new Promise<boolean>((resolve) =>
Modal.warning({
title: '内容尚未保存',
content: '确定离开当前页面并放弃修改吗?',
hideCancel: false,
onOk: () => resolve(true),
onCancel: () => resolve(false),
}),
);
});
onMounted(() => window.addEventListener('beforeunload', beforeUnload));
onBeforeUnmount(() =>
window.removeEventListener('beforeunload', beforeUnload),
);
return { dirty, markInitialized, allowNextNavigation, confirmDiscard };
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,179 +0,0 @@
<template>
<a-card :title="definition.title" :bordered="false">
<div class="list-toolbar">
<a-form :model="filters" layout="inline" class="filters" @submit.prevent="load">
<a-form-item label="关键字"><a-input v-model="filters.keyword" allow-clear placeholder="关键字段模糊搜索" /></a-form-item>
<a-button type="primary" html-type="submit">查询</a-button>
</a-form>
<a-space class="list-actions"><a-button @click="load">刷新</a-button></a-space>
</div>
<a-table :data="list" :loading="loading" :pagination="false" row-key="identity">
<template #columns>
<a-table-column title="ID" data-index="id" :width="80" />
<a-table-column title="唯一标识" :width="150"><template #cell="{ record }"><IdentityText :value="String(record.identity)" /></template></a-table-column>
<a-table-column v-for="field in displayFields" :key="field.key" :title="field.label" :width="columnWidth(field)" ellipsis tooltip>
<template #cell="{ record }">
<IdentityText v-if="field.type === 'identity' && record[field.key]" :value="String(record[field.key])" />
<template v-else>{{ record[field.key] }}</template>
</template>
</a-table-column>
<a-table-column title="操作" :width="90"><template #cell="{ record }"><a-button size="mini" @click="openDetail(record)">详情</a-button></template></a-table-column>
</template>
</a-table>
<div class="pagination"><a-pagination :total="total" :current="page" :page-size="pageSize" show-total @change="changePage" /></div>
</a-card>
<a-drawer :visible="detailVisible" title="详情" :width="540" @cancel="detailVisible = false">
<a-descriptions :column="1" bordered><a-descriptions-item v-for="[key, value] in detailEntries" :key="key" :label="key">{{ value ?? '-' }}</a-descriptions-item></a-descriptions>
<a-space v-if="definition.detailActions?.length" class="detail-actions">
<a-button v-for="action in definition.detailActions" :key="action.name" :status="action.payload?.status === 'rejected' ? 'danger' : 'normal'" type="primary" @click="openDetailAction(action)">{{ action.name }}</a-button>
</a-space>
</a-drawer>
<a-modal :visible="actionVisible" :title="activeAction?.name" @cancel="actionVisible = false" @ok="submitDetailAction">
<a-form :model="actionForm" layout="vertical">
<a-form-item v-for="field in activeAction?.fields" :key="field.key" :label="field.label" :required="field.required">
<a-textarea v-if="field.type === 'textarea'" v-model="actionForm[field.key]" />
<a-input v-else v-model="actionForm[field.key]" />
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue';
import { computed, onMounted, reactive, ref } from 'vue';
import { resourceApi } from '@/api/resource';
import { buildResourcePayload, isMissingField } from '@/api/resource-form';
import type { DetailAction, ResourceField, ResourceUiDefinition } from '@/api/resources';
import IdentityText from '@/components/IdentityText.vue';
type Row = Record<string, unknown>;
const props = defineProps<{ definition: ResourceUiDefinition }>();
const loading = ref(false);
const page = ref(1);
const pageSize = 50;
const total = ref(0);
const list = ref<Row[]>([]);
const filters = reactive({ keyword: '' });
const detail = ref<Row>({});
const detailVisible = ref(false);
const actionVisible = ref(false);
const activeAction = ref<DetailAction>();
const actionForm = reactive<Record<string, any>>({});
const displayFields = computed(() =>
props.definition.fields.filter((field) => field.key !== 'identity' && field.key !== 'status'),
);
function columnWidth(field: ResourceField) {
if (field.type === 'datetime' || field.type === 'date') return 180;
if (field.type === 'money' || field.type === 'number') return 140;
if (field.type === 'boolean') return 110;
if (field.type === 'select') return 140;
if (field.type === 'identity' || field.type === 'identity-list') return 240;
if (field.type === 'textarea') return 280;
if (field.key.includes('phone')) return 150;
if (/(name|title|address|content|remark|reason|terms)$/.test(field.key)) return 220;
return 180;
}
const detailEntries = computed(() =>
Object.entries(detail.value).filter(
([key]) => key !== 'id' && !key.endsWith('_id'),
),
);
async function load() {
loading.value = true;
try {
const result = await resourceApi.list<Row>(
props.definition.resource,
page.value,
pageSize,
filters.keyword ? { keyword: filters.keyword } : {},
);
list.value = result.list;
total.value = result.total;
} catch (error) {
Message.error((error as Error).message);
} finally {
loading.value = false;
}
}
async function openDetail(row: Row) {
try {
detail.value = await resourceApi.detail<Row>(
props.definition.resource,
String(row.identity),
);
detailVisible.value = true;
} catch (error) {
Message.error((error as Error).message);
}
}
function openDetailAction(action: DetailAction) {
activeAction.value = action;
for (const field of action.fields) actionForm[field.key] = undefined;
actionVisible.value = true;
}
async function submitDetailAction() {
const action = activeAction.value;
if (!action) return;
if (
action.fields.some(
(field) => field.required && isMissingField(actionForm[field.key]),
)
) {
Message.warning('请填写必填字段');
return;
}
try {
const payload = {
...action.payload,
...buildResourcePayload(action.fields, actionForm),
};
await resourceApi.create(
action.resource.replace(':identity', String(detail.value.identity)),
payload,
);
Message.success('审批完成');
actionVisible.value = false;
await openDetail(detail.value);
await load();
} catch (error) {
Message.error((error as Error).message);
}
}
async function changePage(next: number) {
page.value = next;
await load();
}
onMounted(load);
</script>
<style scoped lang="less">
.filters {
flex: 1 1 420px;
}
.list-toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px 24px;
margin-bottom: 16px;
}
.list-actions {
margin-left: auto;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.detail-actions {
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,117 @@
/**
* 功能:加载资源列表所需的钱包、账户数量和配送点数量等扩展数据。
* 版本v1.0.0
*/
import { ref, type Ref } from 'vue';
import { resourceApi } from '@/api/resource';
import type { ResourceUiDefinition } from '@/api/resources';
import type { ResourceRow } from '@/api/resource-page-rules';
export function useResourceListExtras(
definition: Ref<ResourceUiDefinition> | { value: ResourceUiDefinition },
list: Ref<ResourceRow[]>,
) {
const accountCounts = ref<Record<string, number>>({});
const deliveryPointCounts = ref<Record<string, number>>({});
const walletByOwner = ref<Record<string, ResourceRow>>({});
async function loadAllRows(
resource: string,
filters: Record<string, string> = {},
) {
const rows: ResourceRow[] = [];
for (let page = 1; page <= 100; page += 1) {
const result = await resourceApi.list<ResourceRow>(
resource,
page,
100,
filters,
);
rows.push(...result.list);
if (rows.length >= result.total || result.list.length < 100) break;
}
return rows;
}
async function loadAccountCounts() {
const management = definition.value.accountManagement;
if (!management || !list.value.length) {
accountCounts.value = {};
return;
}
const filterKey =
management.relationKey === 'gas_basic_identity'
? 'gas_basic_identities'
: 'delivery_basic_identities';
const accounts = await loadAllRows(management.resource, {
[filterKey]: list.value
.map((row) => String(row.identity ?? ''))
.filter(Boolean)
.join(','),
});
accountCounts.value = accounts.reduce<Record<string, number>>(
(counts, account) => {
const identity = String(account[management.relationKey] ?? '');
if (identity) counts[identity] = (counts[identity] ?? 0) + 1;
return counts;
},
{},
);
}
async function loadDeliveryPointCounts() {
if (definition.value.name !== 'gas_basic' || !list.value.length) {
deliveryPointCounts.value = {};
return;
}
const rows = await loadAllRows('/delivery_basic', {
gas_basic_identities: list.value
.map((row) => String(row.identity ?? ''))
.filter(Boolean)
.join(','),
});
deliveryPointCounts.value = rows.reduce<Record<string, number>>(
(counts, row) => {
const identity = String(row.gas_basic_identity ?? '');
if (identity) counts[identity] = (counts[identity] ?? 0) + 1;
return counts;
},
{},
);
}
async function loadWallets() {
const ownerType = definition.value.walletOwnerType;
if (!ownerType || !list.value.length) {
walletByOwner.value = {};
return;
}
const wallets = (
await resourceApi.list<ResourceRow>('/wallet_basic', 1, 100, {
owner_type: ownerType,
owner_identities: list.value
.map((row) => String(row.identity ?? ''))
.filter(Boolean)
.join(','),
})
).list;
walletByOwner.value = wallets.reduce<Record<string, ResourceRow>>(
(result, wallet) => {
if (wallet.owner_type === ownerType)
result[String(wallet.owner_identity ?? '')] = wallet;
return result;
},
{},
);
}
async function loadExtras() {
await Promise.all([
loadAccountCounts(),
loadDeliveryPointCounts(),
loadWallets(),
]);
}
return { accountCounts, deliveryPointCounts, walletByOwner, loadExtras };
}