diff --git a/backend/api/internal/logic/platform/platform/account.go b/backend/api/internal/logic/platform/platform/account.go index b954ec0..95808dc 100644 --- a/backend/api/internal/logic/platform/platform/account.go +++ b/backend/api/internal/logic/platform/platform/account.go @@ -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 diff --git a/backend/api/internal/routers/platform.go b/backend/api/internal/routers/platform.go index 3239aea..c63a125 100644 --- a/backend/api/internal/routers/platform.go +++ b/backend/api/internal/routers/platform.go @@ -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) diff --git a/backend/api/internal/routers/platform_test.go b/backend/api/internal/routers/platform_test.go index d22d94a..6293280 100644 --- a/backend/api/internal/routers/platform_test.go +++ b/backend/api/internal/routers/platform_test.go @@ -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) } diff --git a/docs/05-平台总后台需求.md b/docs/05-平台总后台需求.md index 0798397..cbdf47a 100644 --- a/docs/05-平台总后台需求.md +++ b/docs/05-平台总后台需求.md @@ -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 智能气阀管理 diff --git a/docs/11-数据接口与安全.md b/docs/11-数据接口与安全.md index 1bddf2d..35adf96 100644 --- a/docs/11-数据接口与安全.md +++ b/docs/11-数据接口与安全.md @@ -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 <= 可提现余额 <= 总余额`。普通消费扣减总余额后,必须同步把可提现余额限制在剩余总余额以内。 - 提现申请在同一数据库事务内锁定钱包、同时预扣总余额和可提现余额并写入不可变流水;驳回只返还该申请实际预扣的两类余额,完成打款只确认外部结果,不得再次扣款。 diff --git a/docs/操作日志_标准资源全页改造_20260810.md b/docs/操作日志_标准资源全页改造_20260810.md new file mode 100644 index 0000000..b206369 --- /dev/null +++ b/docs/操作日志_标准资源全页改造_20260810.md @@ -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 的标准资源仍沿用原交互,不受影响。 diff --git a/docs/操作日志_标准资源表单布局优化_20260811.md b/docs/操作日志_标准资源表单布局优化_20260811.md new file mode 100644 index 0000000..951368b --- /dev/null +++ b/docs/操作日志_标准资源表单布局优化_20260811.md @@ -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 和树形资源页面保持原状。 diff --git a/docs/操作日志_标准资源详情页布局优化_20260811.md b/docs/操作日志_标准资源详情页布局优化_20260811.md new file mode 100644 index 0000000..ea34734 --- /dev/null +++ b/docs/操作日志_标准资源详情页布局优化_20260811.md @@ -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 和树形资源页面保持原状。 diff --git a/docs/项目文档_标准资源全页管理_v1.0.md b/docs/项目文档_标准资源全页管理_v1.0.md new file mode 100644 index 0000000..78c6223 --- /dev/null +++ b/docs/项目文档_标准资源全页管理_v1.0.md @@ -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 尚未迁移。 +- 树形资源仍保留现有抽屉与确认交互。 +- 附件下载、预览和权限签名不在本次实现范围。 +- 聚合子表在本地没有完整业务样本时主要依赖结构检查和生产构建,后续应补充固定测试数据的端到端用例。 diff --git a/frontend/platform_admin/package.json b/frontend/platform_admin/package.json index dcb4563..bfab755 100644 --- a/frontend/platform_admin/package.json +++ b/frontend/platform_admin/package.json @@ -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 .", diff --git a/frontend/platform_admin/scripts/check-backend-contract.mjs b/frontend/platform_admin/scripts/check-backend-contract.mjs index 978d2ba..9b9237a 100644 --- a/frontend/platform_admin/scripts/check-backend-contract.mjs +++ b/frontend/platform_admin/scripts/check-backend-contract.mjs @@ -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)) diff --git a/frontend/platform_admin/scripts/check-resource-pages.mjs b/frontend/platform_admin/scripts/check-resource-pages.mjs new file mode 100644 index 0000000..ab1f232 --- /dev/null +++ b/frontend/platform_admin/scripts/check-resource-pages.mjs @@ -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(' createModes.has(item.mode)).length; +console.log( + `资源独立页面检查通过:详情 ${listResources.length} 类,新建 ${createCount} 类,编辑 ${editableResources.length} 类。`, +); diff --git a/frontend/platform_admin/src/api/http.ts b/frontend/platform_admin/src/api/http.ts index 9d68dc8..fad2d1b 100644 --- a/frontend/platform_admin/src/api/http.ts +++ b/frontend/platform_admin/src/api/http.ts @@ -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 = { 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 = { 'Invalid Argument': '请求参数不正确,请检查填写内容', + 'Record Not Found': '记录不存在', + 'Permission Denied': '无权访问该记录', }; // 优先保留后端提供的具体中文信息,仅翻译已知的通用英文错误。 @@ -31,9 +46,16 @@ export async function request(path: string, init?: RequestInit): Promise { } 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; } diff --git a/frontend/platform_admin/src/api/resource-display.ts b/frontend/platform_admin/src/api/resource-display.ts new file mode 100644 index 0000000..61b3eb1 --- /dev/null +++ b/frontend/platform_admin/src/api/resource-display.ts @@ -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 = { + 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, +) { + 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, +) { + 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, + ); +} diff --git a/frontend/platform_admin/src/api/resource-navigation.ts b/frontend/platform_admin/src/api/resource-navigation.ts new file mode 100644 index 0000000..2d95b7a --- /dev/null +++ b/frontend/platform_admin/src/api/resource-navigation.ts @@ -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 }); +} diff --git a/frontend/platform_admin/src/api/resource-page-rules.ts b/frontend/platform_admin/src/api/resource-page-rules.ts new file mode 100644 index 0000000..7c0fe3a --- /dev/null +++ b/frontend/platform_admin/src/api/resource-page-rules.ts @@ -0,0 +1,325 @@ +/** + * 功能:定义标准资源在新建、详情和编辑页面中的字段能力与动态编辑限制。 + * 版本:v1.0.0 + */ +import type { ResourceField, ResourceUiDefinition } from './resources'; + +export type RecordPageMode = 'create' | 'detail' | 'edit'; +export type ResourceRow = Record; + +export type ResourcePageContext = { + role: string; + accountIdentity: string; +}; + +type ResourcePageRule = { + createHiddenKeys?: string[]; + editKeys: string[]; + editOptionalKeys?: string[]; + editSubmitOnlyKeys?: string[]; + accountSummary?: boolean; +}; + +/** 每个可编辑资源都显式声明更新字段,禁止把创建字段直接提交给更新接口。 */ +const pageRules: Record = { + 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 = { + 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, + 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, +) { + 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('、')}`); +} diff --git a/frontend/platform_admin/src/api/resource-record-form.ts b/frontend/platform_admin/src/api/resource-record-form.ts new file mode 100644 index 0000000..ee6c234 --- /dev/null +++ b/frontend/platform_admin/src/api/resource-record-form.ts @@ -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, + 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, + 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 ''; +} diff --git a/frontend/platform_admin/src/api/resources.ts b/frontend/platform_admin/src/api/resources.ts index b55630a..a5914e2 100644 --- a/frontend/platform_admin/src/api/resources.ts +++ b/frontend/platform_admin/src/api/resources.ts @@ -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', []), diff --git a/frontend/platform_admin/src/router/routes/modules/dashboard-route.ts b/frontend/platform_admin/src/router/routes/modules/dashboard-route.ts new file mode 100644 index 0000000..1731406 --- /dev/null +++ b/frontend/platform_admin/src/router/routes/modules/dashboard-route.ts @@ -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', + }, + }, + ], +}; diff --git a/frontend/platform_admin/src/router/routes/modules/finance-route.ts b/frontend/platform_admin/src/router/routes/modules/finance-route.ts new file mode 100644 index 0000000..2fdc2d3 --- /dev/null +++ b/frontend/platform_admin/src/router/routes/modules/finance-route.ts @@ -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', + ), + ], +); diff --git a/frontend/platform_admin/src/router/routes/modules/platform.ts b/frontend/platform_admin/src/router/routes/modules/platform.ts index da336c5..99f63f7 100644 --- a/frontend/platform_admin/src/router/routes/modules/platform.ts +++ b/frontend/platform_admin/src/router/routes/modules/platform.ts @@ -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', + ), ]), ]; diff --git a/frontend/platform_admin/src/router/routes/modules/resource-route-builder.ts b/frontend/platform_admin/src/router/routes/modules/resource-route-builder.ts new file mode 100644 index 0000000..a289347 --- /dev/null +++ b/frontend/platform_admin/src/router/routes/modules/resource-route-builder.ts @@ -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', '编辑')] + : []), + ]; + }); +} diff --git a/frontend/platform_admin/src/router/typings.d.ts b/frontend/platform_admin/src/router/typings.d.ts index 1f18575..11a3b1b 100644 --- a/frontend/platform_admin/src/router/typings.d.ts +++ b/frontend/platform_admin/src/router/typings.d.ts @@ -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 diff --git a/frontend/platform_admin/src/views/account/AccountProfilePage.less b/frontend/platform_admin/src/views/account/AccountProfilePage.less deleted file mode 100644 index 2abc2b4..0000000 --- a/frontend/platform_admin/src/views/account/AccountProfilePage.less +++ /dev/null @@ -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; - } -} diff --git a/frontend/platform_admin/src/views/account/AccountProfilePage.vue b/frontend/platform_admin/src/views/account/AccountProfilePage.vue deleted file mode 100644 index 89e81bb..0000000 --- a/frontend/platform_admin/src/views/account/AccountProfilePage.vue +++ /dev/null @@ -1,455 +0,0 @@ - - - - - - diff --git a/frontend/platform_admin/src/views/resource/ResourceAccountSummary.vue b/frontend/platform_admin/src/views/resource/ResourceAccountSummary.vue new file mode 100644 index 0000000..47dad2f --- /dev/null +++ b/frontend/platform_admin/src/views/resource/ResourceAccountSummary.vue @@ -0,0 +1,192 @@ + + + + + + diff --git a/frontend/platform_admin/src/views/resource/ResourceActionDialog.vue b/frontend/platform_admin/src/views/resource/ResourceActionDialog.vue new file mode 100644 index 0000000..3ad495c --- /dev/null +++ b/frontend/platform_admin/src/views/resource/ResourceActionDialog.vue @@ -0,0 +1,164 @@ + + + + + + diff --git a/frontend/platform_admin/src/views/resource/ResourceDetailContent.vue b/frontend/platform_admin/src/views/resource/ResourceDetailContent.vue new file mode 100644 index 0000000..b352f5b --- /dev/null +++ b/frontend/platform_admin/src/views/resource/ResourceDetailContent.vue @@ -0,0 +1,214 @@ + + + + + + diff --git a/frontend/platform_admin/src/views/resource/ResourceFieldForm.vue b/frontend/platform_admin/src/views/resource/ResourceFieldForm.vue new file mode 100644 index 0000000..c25f236 --- /dev/null +++ b/frontend/platform_admin/src/views/resource/ResourceFieldForm.vue @@ -0,0 +1,172 @@ + + + + + + diff --git a/frontend/platform_admin/src/views/resource/ResourceRecordPage.less b/frontend/platform_admin/src/views/resource/ResourceRecordPage.less new file mode 100644 index 0000000..7179d51 --- /dev/null +++ b/frontend/platform_admin/src/views/resource/ResourceRecordPage.less @@ -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; + } +} diff --git a/frontend/platform_admin/src/views/resource/ResourceRecordPage.vue b/frontend/platform_admin/src/views/resource/ResourceRecordPage.vue new file mode 100644 index 0000000..500a222 --- /dev/null +++ b/frontend/platform_admin/src/views/resource/ResourceRecordPage.vue @@ -0,0 +1,483 @@ + + + + + + diff --git a/frontend/platform_admin/src/views/resource/ResourceWalletSummary.vue b/frontend/platform_admin/src/views/resource/ResourceWalletSummary.vue new file mode 100644 index 0000000..f884315 --- /dev/null +++ b/frontend/platform_admin/src/views/resource/ResourceWalletSummary.vue @@ -0,0 +1,98 @@ + + + + + + diff --git a/frontend/platform_admin/src/views/resource/resource-record-navigation.ts b/frontend/platform_admin/src/views/resource/resource-record-navigation.ts new file mode 100644 index 0000000..3bd8903 --- /dev/null +++ b/frontend/platform_admin/src/views/resource/resource-record-navigation.ts @@ -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 }; +} diff --git a/frontend/platform_admin/src/views/resource/use-resource-avatar.ts b/frontend/platform_admin/src/views/resource/use-resource-avatar.ts new file mode 100644 index 0000000..aaba183 --- /dev/null +++ b/frontend/platform_admin/src/views/resource/use-resource-avatar.ts @@ -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(); + 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) { + 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, + }; +} diff --git a/frontend/platform_admin/src/views/resource/use-resource-relations.ts b/frontend/platform_admin/src/views/resource/use-resource-relations.ts new file mode 100644 index 0000000..85ac62b --- /dev/null +++ b/frontend/platform_admin/src/views/resource/use-resource-relations.ts @@ -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>({}); + const loading = reactive>({}); + const timers = new Map>(); + + /** 加载一类关联资源,工作人员关系默认只选择配送人员。 */ + async function load(resource: string, keyword = '') { + loading[resource] = true; + try { + const filters: Record = keyword ? { keyword } : {}; + if (resource === '/staff_account') filters.role_code = 'delivery'; + options[resource] = ( + await resourceApi.list(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 }; +} diff --git a/frontend/platform_admin/src/views/resource/use-unsaved-record.ts b/frontend/platform_admin/src/views/resource/use-unsaved-record.ts new file mode 100644 index 0000000..1e7d7d0 --- /dev/null +++ b/frontend/platform_admin/src/views/resource/use-unsaved-record.ts @@ -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((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 }; +} diff --git a/frontend/platform_admin/src/views/shared/CrudListPage.vue b/frontend/platform_admin/src/views/shared/CrudListPage.vue index 4aac14e..05b7913 100644 --- a/frontend/platform_admin/src/views/shared/CrudListPage.vue +++ b/frontend/platform_admin/src/views/shared/CrudListPage.vue @@ -1,5 +1,9 @@ +