From 084eccc3ec4e85fbdf75a99e6299fd41a6418b8a Mon Sep 17 00:00:00 2001 From: czl231 <3286836406@qq.com> Date: Tue, 18 Aug 2026 00:26:39 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=B0=94=E7=AB=99=E6=A0=87?= =?UTF-8?q?=E5=87=86=E8=B5=84=E6=BA=90=E5=85=A8=E9=A1=B5=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E6=94=B9=E9=80=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/internal/logic/gas/finance.go | 57 +- backend/api/internal/logic/gas/gasorder.go | 39 + backend/api/internal/routers/gas_business.go | 11 + backend/api/internal/routers/gas_test.go | 29 + docs/06-气站管理系统需求.md | 12 +- .../操作日志_气站标准资源全页改造_20260817.md | 49 + docs/项目文档_气站标准资源全页管理_v1.0.md | 92 ++ frontend/gas_admin/package.json | 1 + .../scripts/check-resource-pages.mjs | 90 ++ frontend/gas_admin/src/api/avatar.ts | 63 + .../gas_admin/src/api/contract-attachment.ts | 74 + frontend/gas_admin/src/api/http.ts | 27 +- .../src/api/resource-detail-contract.ts | 213 +++ .../gas_admin/src/api/resource-display.ts | 546 +++++++ .../gas_admin/src/api/resource-navigation.ts | 60 + .../gas_admin/src/api/resource-page-rules.ts | 358 ++++ .../gas_admin/src/api/resource-record-form.ts | 47 + .../src/api/resource-search-contract.ts | 47 + .../src/api/resource-staff-relation.ts | 142 ++ frontend/gas_admin/src/api/resources.ts | 90 +- .../src/contracts/gas-resources.json | 2 +- .../src/router/routes/modules/platform.ts | 53 +- .../routes/modules/resource-route-builder.ts | 94 ++ frontend/gas_admin/src/router/typings.d.ts | 2 + .../resource/ContractAttachmentField.vue | 169 ++ .../src/views/resource/MenuPermissionTree.vue | 127 ++ .../views/resource/ResourceAccountSummary.vue | 206 +++ .../views/resource/ResourceActionDialog.vue | 346 ++++ .../views/resource/ResourceDetailContent.vue | 436 +++++ .../src/views/resource/ResourceFieldForm.vue | 381 +++++ .../views/resource/ResourceRecordPage.less | 100 ++ .../src/views/resource/ResourceRecordPage.vue | 570 +++++++ .../views/resource/ResourceWalletSummary.vue | 98 ++ .../src/views/resource/avatar-upload-cache.ts | 35 + .../load-resource-record-relations.ts | 57 + .../resource/resource-record-navigation.ts | 68 + .../resource-relation-linkage-policy.ts | 112 ++ .../views/resource/use-contract-attachment.ts | 149 ++ .../src/views/resource/use-resource-avatar.ts | 95 ++ .../resource/use-resource-relation-linkage.ts | 534 ++++++ .../views/resource/use-resource-relations.ts | 218 +++ .../use-staff-credential-owner-guard.ts | 96 ++ .../src/views/resource/use-unsaved-record.ts | 65 + .../src/views/shared/CrudListPage.less | 8 + .../src/views/shared/CrudListPage.vue | 1439 +++++------------ .../views/shared/ProtectedAvatarThumbnail.vue | 139 ++ .../src/views/shared/RelationNameText.vue | 79 +- .../shared/protected-list-avatar-loader.ts | 156 ++ .../shared/resource-list-field-display.ts | 133 ++ .../views/shared/use-resource-list-extras.ts | 117 ++ .../views/shared/use-resource-list-search.ts | 91 ++ 51 files changed, 7081 insertions(+), 1141 deletions(-) create mode 100644 docs/操作日志_气站标准资源全页改造_20260817.md create mode 100644 docs/项目文档_气站标准资源全页管理_v1.0.md create mode 100644 frontend/gas_admin/scripts/check-resource-pages.mjs create mode 100644 frontend/gas_admin/src/api/avatar.ts create mode 100644 frontend/gas_admin/src/api/contract-attachment.ts create mode 100644 frontend/gas_admin/src/api/resource-detail-contract.ts create mode 100644 frontend/gas_admin/src/api/resource-display.ts create mode 100644 frontend/gas_admin/src/api/resource-navigation.ts create mode 100644 frontend/gas_admin/src/api/resource-page-rules.ts create mode 100644 frontend/gas_admin/src/api/resource-record-form.ts create mode 100644 frontend/gas_admin/src/api/resource-search-contract.ts create mode 100644 frontend/gas_admin/src/api/resource-staff-relation.ts create mode 100644 frontend/gas_admin/src/router/routes/modules/resource-route-builder.ts create mode 100644 frontend/gas_admin/src/views/resource/ContractAttachmentField.vue create mode 100644 frontend/gas_admin/src/views/resource/MenuPermissionTree.vue create mode 100644 frontend/gas_admin/src/views/resource/ResourceAccountSummary.vue create mode 100644 frontend/gas_admin/src/views/resource/ResourceActionDialog.vue create mode 100644 frontend/gas_admin/src/views/resource/ResourceDetailContent.vue create mode 100644 frontend/gas_admin/src/views/resource/ResourceFieldForm.vue create mode 100644 frontend/gas_admin/src/views/resource/ResourceRecordPage.less create mode 100644 frontend/gas_admin/src/views/resource/ResourceRecordPage.vue create mode 100644 frontend/gas_admin/src/views/resource/ResourceWalletSummary.vue create mode 100644 frontend/gas_admin/src/views/resource/avatar-upload-cache.ts create mode 100644 frontend/gas_admin/src/views/resource/load-resource-record-relations.ts create mode 100644 frontend/gas_admin/src/views/resource/resource-record-navigation.ts create mode 100644 frontend/gas_admin/src/views/resource/resource-relation-linkage-policy.ts create mode 100644 frontend/gas_admin/src/views/resource/use-contract-attachment.ts create mode 100644 frontend/gas_admin/src/views/resource/use-resource-avatar.ts create mode 100644 frontend/gas_admin/src/views/resource/use-resource-relation-linkage.ts create mode 100644 frontend/gas_admin/src/views/resource/use-resource-relations.ts create mode 100644 frontend/gas_admin/src/views/resource/use-staff-credential-owner-guard.ts create mode 100644 frontend/gas_admin/src/views/resource/use-unsaved-record.ts create mode 100644 frontend/gas_admin/src/views/shared/CrudListPage.less create mode 100644 frontend/gas_admin/src/views/shared/ProtectedAvatarThumbnail.vue create mode 100644 frontend/gas_admin/src/views/shared/protected-list-avatar-loader.ts create mode 100644 frontend/gas_admin/src/views/shared/resource-list-field-display.ts create mode 100644 frontend/gas_admin/src/views/shared/use-resource-list-extras.ts create mode 100644 frontend/gas_admin/src/views/shared/use-resource-list-search.ts diff --git a/backend/api/internal/logic/gas/finance.go b/backend/api/internal/logic/gas/finance.go index dff9b01..cdf4b32 100644 --- a/backend/api/internal/logic/gas/finance.go +++ b/backend/api/internal/logic/gas/finance.go @@ -1,4 +1,4 @@ -package gas +package gas import ( "strings" @@ -33,6 +33,17 @@ func ListWalletBasic(ctx *gin.Context) { listScoped(ctx, &models.WalletBasic{}, query, "wallet_basic.created_at desc") } +// GetWalletBasic 返回当前气站拥有的单个钱包。 +func GetWalletBasic(ctx *gin.Context) { + station, ok := currentGas(ctx) + if !ok { + return + } + query := common.ActiveRecords(impl.DBService.Model(&models.WalletBasic{})). + Where("identity = ? AND owner_type = ? AND owner_id = ?", ctx.Param("identity"), "gas", station.ID) + respondScopedRecord(ctx, query, &models.WalletBasic{}) +} + func listWalletChild(ctx *gin.Context, model any, table string) { station, ok := currentGas(ctx) if !ok { @@ -44,19 +55,45 @@ func listWalletChild(ctx *gin.Context, model any, table string) { listScoped(ctx, model, query, table+".created_at desc") } +// getWalletChild 返回当前气站钱包范围内的单条子资源。 +func getWalletChild(ctx *gin.Context, model any, table string) { + station, ok := currentGas(ctx) + if !ok { + return + } + query := common.ActiveRecords(impl.DBService.Model(model)). + Select(table+".*"). + Joins("JOIN wallet_basic ON wallet_basic.id = "+table+".wallet_basic_id"). + Where(table+".identity = ? AND wallet_basic.owner_type = ? AND wallet_basic.owner_id = ?", ctx.Param("identity"), "gas", station.ID) + respondScopedRecord(ctx, query, model) +} + func ListWalletBank(ctx *gin.Context) { listWalletChild(ctx, &models.WalletBank{}, "wallet_bank") } +func GetWalletBank(ctx *gin.Context) { getWalletChild(ctx, &models.WalletBank{}, "wallet_bank") } func ListPaymentOrder(ctx *gin.Context) { listWalletChild(ctx, &models.PaymentOrder{}, "payment_order") } +func GetPaymentOrder(ctx *gin.Context) { + getWalletChild(ctx, &models.PaymentOrder{}, "payment_order") +} func ListWalletRecord(ctx *gin.Context) { listWalletChild(ctx, &models.WalletRecord{}, "wallet_record") } +func GetWalletRecord(ctx *gin.Context) { + getWalletChild(ctx, &models.WalletRecord{}, "wallet_record") +} func ListPaymentRefund(ctx *gin.Context) { listWalletChild(ctx, &models.PaymentRefund{}, "payment_refund") } +func GetPaymentRefund(ctx *gin.Context) { + getWalletChild(ctx, &models.PaymentRefund{}, "payment_refund") +} func ListWalletApplyCash(ctx *gin.Context) { listWalletChild(ctx, &models.WalletApplyCash{}, "wallet_apply_cash") } +func GetWalletApplyCash(ctx *gin.Context) { + getWalletChild(ctx, &models.WalletApplyCash{}, "wallet_apply_cash") +} func CreateWalletApplyCash(ctx *gin.Context) { account, station, ok := CurrentGasAccount(ctx) @@ -120,8 +157,26 @@ func ListFinSettlement(ctx *gin.Context) { listScoped(ctx, &models.FinSettlement{}, query, "fin_settlement.created_at desc") } +// GetFinSettlement 返回当前气站主体范围内的单条结算记录。 +func GetFinSettlement(ctx *gin.Context) { + station, ok := currentGas(ctx) + if !ok { + return + } + query := common.ActiveRecords(impl.DBService.Model(&models.FinSettlement{})). + Where("identity = ? AND subject_type = ? AND subject_id = ?", ctx.Param("identity"), "gas", station.ID) + respondScopedRecord(ctx, query, &models.FinSettlement{}) +} + // 对账表目前没有主体归属字段,气站端不得暴露平台全局渠道数据。 func ListFinReconciliation(ctx *gin.Context) { query := common.ActiveRecords(impl.DBService.Model(&models.FinReconciliation{})).Where("1 = 0") listScoped(ctx, &models.FinReconciliation{}, query, "fin_reconciliation.created_at desc") } + +// GetFinReconciliation 保持与列表相同的数据边界;当前气站端不暴露全局对账记录。 +func GetFinReconciliation(ctx *gin.Context) { + query := common.ActiveRecords(impl.DBService.Model(&models.FinReconciliation{})). + Where("identity = ? AND 1 = 0", ctx.Param("identity")) + respondScopedRecord(ctx, query, &models.FinReconciliation{}) +} diff --git a/backend/api/internal/logic/gas/gasorder.go b/backend/api/internal/logic/gas/gasorder.go index 9ead4a1..09b5bc3 100644 --- a/backend/api/internal/logic/gas/gasorder.go +++ b/backend/api/internal/logic/gas/gasorder.go @@ -163,6 +163,19 @@ func ListGasorderContractProduct(ctx *gin.Context) { listScoped(ctx, &models.GasorderContractProduct{}, query, "gasorder_contract_product.created_at desc") } +// GetGasorderContractProduct 返回当前气站合同范围内的单条合同气瓶。 +func GetGasorderContractProduct(ctx *gin.Context) { + station, ok := currentGas(ctx) + if !ok { + return + } + query := common.ActiveRecords(impl.DBService.Model(&models.GasorderContractProduct{})). + Select("gasorder_contract_product.*"). + Joins("JOIN gasorder_contract ON gasorder_contract.id = gasorder_contract_product.gasorder_contract_id"). + Where("gasorder_contract_product.identity = ? AND gasorder_contract.gas_basic_id = ?", ctx.Param("identity"), station.ID) + respondScopedRecord(ctx, query, &models.GasorderContractProduct{}) +} + // ListContractProductCandidate 仅返回当前气站服务用户名下可用于合同绑定的气瓶。 func ListContractProductCandidate(ctx *gin.Context) { station, ok := currentGas(ctx) @@ -176,6 +189,19 @@ func ListContractProductCandidate(ctx *gin.Context) { listScoped(ctx, &models.ProductInfo{}, query, "product_info.created_at desc") } +// GetContractProductCandidate 返回当前气站服务用户范围内的单条候选气瓶。 +func GetContractProductCandidate(ctx *gin.Context) { + station, ok := currentGas(ctx) + if !ok { + return + } + query := common.ActiveRecords(impl.DBService.Model(&models.ProductInfo{})). + Select("product_info.*"). + Joins("JOIN user_service_relation ON user_service_relation.user_account_id = product_info.user_account_id AND user_service_relation.status <> ?", common.StatusArchived). + Where("product_info.identity = ? AND user_service_relation.gas_basic_id = ?", ctx.Param("identity"), station.ID) + respondScopedRecord(ctx, query, &models.ProductInfo{}) +} + func ListGasorderContractRevision(ctx *gin.Context) { station, ok := currentGas(ctx) if !ok { @@ -187,6 +213,19 @@ func ListGasorderContractRevision(ctx *gin.Context) { listScoped(ctx, &models.GasorderContractRevision{}, query, "gasorder_contract_revision.created_at desc") } +// GetGasorderContractRevision 返回当前气站合同范围内的单条修订记录。 +func GetGasorderContractRevision(ctx *gin.Context) { + station, ok := currentGas(ctx) + if !ok { + return + } + query := common.ActiveRecords(impl.DBService.Model(&models.GasorderContractRevision{})). + Select("gasorder_contract_revision.*"). + Joins("JOIN gasorder_contract ON gasorder_contract.id = gasorder_contract_revision.gasorder_contract_id"). + Where("gasorder_contract_revision.identity = ? AND gasorder_contract.gas_basic_id = ?", ctx.Param("identity"), station.ID) + respondScopedRecord(ctx, query, &models.GasorderContractRevision{}) +} + func ListGasorderBasic(ctx *gin.Context) { station, ok := currentGas(ctx) if !ok { diff --git a/backend/api/internal/routers/gas_business.go b/backend/api/internal/routers/gas_business.go index e06ebf0..0adab02 100644 --- a/backend/api/internal/routers/gas_business.go +++ b/backend/api/internal/routers/gas_business.go @@ -72,9 +72,12 @@ func registerGasBusinessRoutes(group *gin.RouterGroup) { contractProduct := group.Group("/gasorder_contract_product") contractProduct.GET("", gaslogic.ListGasorderContractProduct) contractProduct.POST("", gaslogic.BindGasorderContractProduct) + contractProduct.GET("/:identity", gaslogic.GetGasorderContractProduct) contractProduct.POST("/:identity/unbind", gaslogic.UnbindGasorderContractProduct) group.GET("/gasorder_contract_revision", gaslogic.ListGasorderContractRevision) + group.GET("/gasorder_contract_revision/:identity", gaslogic.GetGasorderContractRevision) group.GET("/product_info", gaslogic.ListContractProductCandidate) + group.GET("/product_info/:identity", gaslogic.GetContractProductCandidate) order := group.Group("/gasorder_basic") order.GET("", gaslogic.ListGasorderBasic) @@ -88,14 +91,22 @@ func registerGasBusinessRoutes(group *gin.RouterGroup) { order.POST("/:identity/cancel", gaslogic.GasorderCancel) group.GET("/wallet_basic", gaslogic.ListWalletBasic) + group.GET("/wallet_basic/:identity", gaslogic.GetWalletBasic) group.GET("/wallet_bank", gaslogic.ListWalletBank) + group.GET("/wallet_bank/:identity", gaslogic.GetWalletBank) group.GET("/payment_order", gaslogic.ListPaymentOrder) + group.GET("/payment_order/:identity", gaslogic.GetPaymentOrder) group.GET("/wallet_record", gaslogic.ListWalletRecord) + group.GET("/wallet_record/:identity", gaslogic.GetWalletRecord) group.GET("/payment_refund", gaslogic.ListPaymentRefund) + group.GET("/payment_refund/:identity", gaslogic.GetPaymentRefund) group.GET("/wallet_apply_cash", gaslogic.ListWalletApplyCash) + group.GET("/wallet_apply_cash/:identity", gaslogic.GetWalletApplyCash) group.POST("/wallet_apply_cash", gaslogic.CreateWalletApplyCash) group.GET("/fin_settlement", gaslogic.ListFinSettlement) + group.GET("/fin_settlement/:identity", gaslogic.GetFinSettlement) group.GET("/fin_reconciliation", gaslogic.ListFinReconciliation) + group.GET("/fin_reconciliation/:identity", gaslogic.GetFinReconciliation) ticket := group.Group("/cs_ticket") ticket.GET("", gaslogic.ListTicket) diff --git a/backend/api/internal/routers/gas_test.go b/backend/api/internal/routers/gas_test.go index 05477cb..3fefb8d 100644 --- a/backend/api/internal/routers/gas_test.go +++ b/backend/api/internal/routers/gas_test.go @@ -52,3 +52,32 @@ func TestGasOrderRoutesDoNotExposeDownstreamActions(t *testing.T) { } } } + +// TestGasReadonlyDetailRoutes 验证所有标准只读资源都支持独立详情页直达。 +func TestGasReadonlyDetailRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + RegisterGas("heqi", engine) + routes := map[string]bool{} + for _, route := range engine.Routes() { + routes[route.Method+" "+route.Path] = true + } + for _, path := range []string{ + "/gasorder_contract_product/:identity", + "/gasorder_contract_revision/:identity", + "/product_info/:identity", + "/wallet_basic/:identity", + "/wallet_bank/:identity", + "/payment_order/:identity", + "/wallet_record/:identity", + "/payment_refund/:identity", + "/wallet_apply_cash/:identity", + "/fin_settlement/:identity", + "/fin_reconciliation/:identity", + } { + fullPath := "GET /heqi/gas/v1" + path + if !routes[fullPath] { + t.Fatalf("missing gas detail route %s", fullPath) + } + } +} diff --git a/docs/06-气站管理系统需求.md b/docs/06-气站管理系统需求.md index d8ce163..85ed329 100644 --- a/docs/06-气站管理系统需求.md +++ b/docs/06-气站管理系统需求.md @@ -148,11 +148,20 @@ | `gasorder_contract_product` | `append_only` | 解绑使用专用动作 | | `gasorder_contract_revision` | `readonly` | 不可变历史 | | `gasorder_basic` | `append_only` | 创建及专用状态动作 | -| `wallet_basic`、`wallet_bank`、`wallet_payment`、`wallet_record`、`wallet_refund` | `readonly` | 当前气站钱包范围 | +| `wallet_basic`、`wallet_bank`、`payment_order`、`wallet_record`、`payment_refund` | `readonly` | 当前气站钱包范围 | | `wallet_apply_cash` | `append_only` | 只允许申请,不允许审核 | | `fin_settlement`、`fin_reconciliation` | `readonly` | 不开放通用写入 | | `cs_ticket` | `writable` | 当前气站用户范围 | +标准列表资源使用独立记录页,不在列表抽屉中承载新建、详情或编辑: + +- 新建页:`列表路径/new`,仅为支持新建的资源生成。 +- 详情页:`列表路径/:identity`,20 类标准资源全部提供,并支持刷新和直达。 +- 编辑页:`列表路径/:identity/edit`,仅为支持通用编辑的资源生成。 +- `/staff/add` 与 `/gasorder/create` 保留原菜单入口,直接渲染独立新建页。 +- 审核、归档确认、重置密码、合同流转、合同气瓶解绑和订单状态动作继续使用短弹窗。 +- 详情接口必须校验当前气站数据范围;前端路由隐藏不能替代服务端对象权限。 + ## 6. 菜单输出 菜单定义保存在 `backend/api/internal/logic/gas/menu.go`,仅 `admin` 角色返回: @@ -189,3 +198,4 @@ 8. 每个用户只能归属一个气站和一个配送点。 9. 邀请二维码由配置 URL 和当前气站 identity 实时生成,不产生持久化记录。 10. 后端测试、路由权限测试、前端类型检查、契约检查和生产构建通过。 +11. 20 类标准资源均可从列表进入独立详情页并刷新;11 类可新建资源和 8 类可编辑资源具备对应独立页面。 diff --git a/docs/操作日志_气站标准资源全页改造_20260817.md b/docs/操作日志_气站标准资源全页改造_20260817.md new file mode 100644 index 0000000..7b0849f --- /dev/null +++ b/docs/操作日志_气站标准资源全页改造_20260817.md @@ -0,0 +1,49 @@ +# 气站标准资源全页改造操作日志 + +操作时间:2026-08-17 22:51:57 +操作类型:扩展 +影响模块:气站管理端 5175、气站 API 标准资源详情、资源契约 + +## 操作前状态 + +- 标准资源的新建、详情和编辑位于列表抽屉。 +- 11 类资源只有列表接口,详情抽屉请求单条记录时可能返回 404。 +- 支付、退款前端名称与后端真实资源路径不一致。 + +## 具体操作 + +- 参考 5173 的共享全页框架,为 5175 自动生成新建、详情和编辑路由。 +- 将列表入口改为页面导航,保留短业务动作弹窗。 +- 为可编辑资源增加显式字段白名单,并适配气站工作人员、账户、合同和订单规则。 +- 为 11 类资源增加受当前气站范围保护的详情接口。 +- 同步资源契约,增加独立页面覆盖检查和路由测试。 + +## 操作后状态 + +- 20 类标准资源具有独立详情页。 +- 11 类资源具有独立新建页,8 类资源具有独立编辑页。 +- `/staff/add` 和 `/gasorder/create` 原地址继续可用。 +- 头像和合同文件字段保持 5175 原能力,不访问平台专属端点。 + +## 代码变更 + +- 前端:资源路由构造、列表导航、共享记录页、表单与详情展示、资源能力规则、契约检查。 +- 后端:`gasorder.go`、`finance.go` 新增受限详情处理;`gas_business.go` 注册详情路由;`gas_test.go` 校验路由覆盖。 +- 文档:更新气站需求基线,新增项目文档与本操作日志。 + +## 验证结果 + +- `go test ./internal/routers ./internal/logic/gas`:通过。 +- `pnpm type:check`:通过。 +- `pnpm contract:sync`、`pnpm contract:check`:通过,20 个资源一致。 +- `pnpm resource-pages:check`:通过,详情 20、新建 11、编辑 8。 +- `pnpm lint`:通过,无错误;仓库既有提示保留。 +- `pnpm build`:通过。 +- 浏览器访问 `http://127.0.0.1:5175/delivery/points`:正确重定向到气站登录页,页面完整渲染且控制台无错误;当前浏览器无气站登录态,未提交账号或读取受保护业务数据。 + +## 风险评估 + +- 新增接口均为只读扩展,不改变现有写接口和公共响应结构。 +- 每个新详情查询保留气站、合同、服务关系或钱包归属条件,降低越权读取风险。 +- 对账资源没有气站归属字段,继续返回不可见,未扩大数据范围。 +- 尚需在具备登录态和业务样本的环境验证详情、编辑、新建及未保存提示的完整点击路径。 diff --git a/docs/项目文档_气站标准资源全页管理_v1.0.md b/docs/项目文档_气站标准资源全页管理_v1.0.md new file mode 100644 index 0000000..166c1aa --- /dev/null +++ b/docs/项目文档_气站标准资源全页管理_v1.0.md @@ -0,0 +1,92 @@ +# 气站标准资源全页管理项目文档 v1.0 + +## 1. 项目概述 + +- 项目名称:气站管理端标准资源全页管理。 +- 实施范围:`frontend/gas_admin`(开发端口 5175)及气站 API 必要的只读详情接口。 +- 主要功能:把标准资源的新建、详情和编辑从列表抽屉迁移为独立 URL 页面,同时保留气站端权限、数据范围和业务动作。 +- 技术栈:Vue 3、TypeScript、Vue Router、Arco Design、Vite、Go、Gin、GORM。 +- 页面覆盖:20 类详情页、11 类新建页、8 类编辑页;仪表盘、邀请二维码和短业务动作不改变页面形态。 + +## 2. 页面与路由约定 + +```text +列表路径/new +列表路径/:identity +列表路径/:identity/edit +``` + +- 路由生成器根据资源的 `canCreate`、`canEdit` 和列表模式自动创建隐藏子路由。 +- 独立页通过 `listRouteName`、`recordMode`、`activeMenu` 和安全的 `return_to` 保持返回来源及菜单高亮。 +- `/staff/add` 与 `/gasorder/create` 保持兼容,直接进入独立新建页。 +- 新建和编辑页检测未保存内容,取消、返回或切换路由前要求确认。 +- 配送点账户管理进入隐藏账户列表,再进入账户独立页面。 + +## 3. 目录结构说明 + +```text +platforms/ +├── frontend/gas_admin/ +│ ├── scripts/check-resource-pages.mjs # 独立页面覆盖静态检查 +│ └── src/ +│ ├── api/ +│ │ ├── resource-page-rules.ts # 新建、编辑字段及状态限制 +│ │ ├── resource-navigation.ts # 页面地址与安全返回路径 +│ │ ├── resource-record-form.ts # 表单初始化与校验 +│ │ ├── resource-display.ts # 字段与状态中文展示 +│ │ └── resources.ts # 气站资源和动作定义 +│ ├── router/routes/modules/ +│ │ ├── resource-route-builder.ts # 自动生成独立记录页路由 +│ │ └── platform.ts # 气站菜单及兼容入口 +│ └── views/ +│ ├── resource/ # 共享详情、表单、动作与导航组件 +│ └── shared/CrudListPage.vue # 标准列表及页面跳转入口 +└── backend/api/internal/ + ├── logic/gas/gasorder.go # 合同子资源受限详情 + ├── logic/gas/finance.go # 财务资源受限详情 + └── routers/gas_business.go # 气站详情路由注册 +``` + +## 4. 核心文件与行为 + +### 4.1 独立记录页 + +`ResourceRecordPage.vue` 统一承载三种模式。详情始终重新调用单条接口,不依赖列表缓存;编辑保存只提交规则白名单字段;创建成功后进入新记录详情页。 + +### 4.2 资源能力规则 + +`resource-page-rules.ts` 为 8 类可编辑资源显式声明更新字段,避免把创建字段、用户名、密码或归属字段误提交给更新接口。配送合同仅在草稿且记录状态允许时进入编辑页。 + +### 4.3 气站业务适配 + +- 保留配送点、工作人员、用户、合同、订单、钱包和工单的数据范围。 +- 保留密码重置、合同启用/续签/终止、合同气瓶解绑和气站订单动作。 +- 工作人员资质按来源角色校验;订单分配只加载配送人员。 +- 头像和 `file_uri` 继续作为普通字段,不调用平台总后台专属的受控头像或合同附件接口。 +- 支付与退款资源使用后端真实名称 `payment_order`、`payment_refund`。 + +### 4.4 只读详情接口 + +新增 11 类 `GET /:identity`:合同气瓶、合同修订、候选气瓶、钱包、银行卡、支付记录、钱包流水、退款、提现、结算和对账。查询继续附加当前气站、合同、服务关系或钱包归属条件;对账资源当前无气站主体字段,详情与列表一致返回不可见。 + +## 5. 变更记录 + +- 新增标准资源独立页面路由生成器和共享记录页组件。 +- 列表的新建、详情和编辑入口改为路由跳转。 +- 新增 11 类受数据范围保护的详情接口及路由测试。 +- 同步气站资源契约和独立页面覆盖检查。 +- 未修改数据库模型、状态机、金额规则或现有写接口。 + +## 6. 维护指南 + +- 新增标准资源时同步维护后端资源契约、气站路由、`resources.ts` 和页面规则。 +- 新增可编辑资源必须显式声明更新字段,并与后端 DTO 对照。 +- 新增详情页必须使用受气站范围保护的单条接口,禁止用列表记录或前端隐藏代替对象权限。 +- 业务状态流转继续使用专用动作接口,不进入通用编辑表单。 +- 修改后运行 `pnpm contract:sync`、`pnpm contract:check`、`pnpm resource-pages:check`、`pnpm type:check`、`pnpm lint` 和 `pnpm build`。 + +## 7. 已知边界 + +- 气站 API 暂无受控头像读取和合同附件预览接口,因此对应字段保持原普通文本能力。 +- 财务对账模型没有气站主体归属字段,气站端仍不展示全局对账数据。 +- 两个共享页面文件超过 500 行,但按列表编排与记录页编排保持单一职责;后续若继续增加气站专属区块,应优先拆分组合式函数或子组件。 diff --git a/frontend/gas_admin/package.json b/frontend/gas_admin/package.json index 5754450..800daa3 100644 --- a/frontend/gas_admin/package.json +++ b/frontend/gas_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/gas_admin/scripts/check-resource-pages.mjs b/frontend/gas_admin/scripts/check-resource-pages.mjs new file mode 100644 index 0000000..6a1ad64 --- /dev/null +++ b/frontend/gas_admin/scripts/check-resource-pages.mjs @@ -0,0 +1,90 @@ +/** + * 功能:静态检查气站管理端全部标准资源是否具备独立记录页配置。 + * 版本:v1.2.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/gas-resources.json'), 'utf8')); +const routeSource = readFileSync(resolve(root, 'src/router/routes/modules/platform.ts'), 'utf8'); +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 detailSource = readFileSync(resolve(root, 'src/views/resource/ResourceDetailContent.vue'), 'utf8'); +const displaySource = readFileSync(resolve(root, 'src/api/resource-display.ts'), 'utf8'); +const resourceSource = readFileSync(resolve(root, 'src/api/resources.ts'), 'utf8'); +const treeResources = new Set(); +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(' props.definition.resource, () => staffType.value]', + ) +) { + fail('工作人员角色菜单切换未触发列表重新加载'); +} +if (existsSync(resolve(root, 'src/views/account/AccountProfilePage.vue'))) { + fail('旧账户资料组件仍存在,未统一到共享记录页'); +} +if (!detailSource.includes('resourceFieldLabel(definition, column, collection.key)')) { + fail('聚合详情表格未按集合上下文解析中文字段名称'); +} +for (const marker of [ + "'gasorder_contract.products'", + "product_info_identity: '气瓶唯一标识'", + "product_code: '气瓶编码'", + "if (key === 'contract_status') return contractStatusLabel(Number(value))", + "terminate: '终止合同'", + "key === 'expired_at'", + "return '待补录'", +]) { + if (!displaySource.includes(marker)) fail(`合同详情中文展示缺少 ${marker}`); +} +if (!resourceSource.includes("visibleFor: { field: 'contract_status', values: [0] }")) { + fail('合同启用动作未限制为草稿状态'); +} +if (!resourceSource.includes("visibleFor: { field: 'contract_status', values: [11, 12, 13] }")) { + fail('合同续签动作未覆盖已终止状态'); +} +if (!detailSource.includes("!['deleted_at', 'DeletedAt'].includes(column)")) { + fail('聚合详情表未隐藏公共软删除字段'); +} +for (const marker of [ + "Number(row.contract_status) !== 0", + "![0, 1].includes(Number(row.status))", + "只有草稿状态的合同可以编辑", + "停用、已归档或已冻结的合同不可编辑", +]) { + if (!ruleSource.includes(marker)) fail(`合同编辑状态规则缺少 ${marker}`); +} +if (!listSource.includes('recordEditReason(record)')) { + fail('合同列表缺少编辑禁用原因提示'); +} + +const createCount = listResources.filter((item) => createModes.has(item.mode)).length; +console.log( + `资源独立页面检查通过:详情 ${listResources.length} 类,新建 ${createCount} 类,编辑 ${editableResources.length} 类。`, +); diff --git a/frontend/gas_admin/src/api/avatar.ts b/frontend/gas_admin/src/api/avatar.ts new file mode 100644 index 0000000..92b4e16 --- /dev/null +++ b/frontend/gas_admin/src/api/avatar.ts @@ -0,0 +1,63 @@ +/** + * 功能:平台总后台头像上传与鉴权读取客户端。 + * 版本:v1.1.0 + */ +import { getToken } from '@/utils/auth'; + +const platformApiBaseURL = + import.meta.env.VITE_API_BASE_URL || + 'http://localhost:12426/heqi/platform/v1'; + +export type AvatarUploadReply = { + uri: string; + original_name: string; + content_type: string; + size: number; +}; + +type ApiEnvelope = { code?: number; message?: string; details?: T }; + +/** 生成服务根路径 URL,确保上传请求不会错误拼接平台 API 前缀。 */ +function serviceURL(path: string) { + const platformURL = new URL(platformApiBaseURL, window.location.origin); + return new URL(path, platformURL.origin).toString(); +} + +/** 返回与现有平台请求一致的 JWT 请求头。 */ +function authorizationHeaders(): Record { + const token = getToken(); + return token ? { Authorization: token } : {}; +} + +/** 上传经过前端预检的头像文件,服务端仍会执行真实内容校验。 */ +async function upload(file: File): Promise { + const form = new FormData(); + form.append('file', file); + const response = await fetch(serviceURL('/upload/avatar'), { + method: 'POST', + headers: authorizationHeaders(), + body: form, + }); + const payload = (await response.json()) as ApiEnvelope; + if (!response.ok || payload.code !== 0 || !payload.details) { + throw new Error(payload.message || '头像上传失败'); + } + return payload.details; +} + +/** 读取受保护头像;记录没有头像时返回 undefined 以使用本地默认图。 */ +async function load( + resource: string, + identity: string, + signal?: AbortSignal, +): Promise { + const response = await fetch( + `${platformApiBaseURL}${resource}/${encodeURIComponent(identity)}/avatar`, + { headers: authorizationHeaders(), signal }, + ); + if (response.status === 404) return undefined; + if (!response.ok) throw new Error('头像读取失败'); + return response.blob(); +} + +export const avatarApi = { upload, load }; diff --git a/frontend/gas_admin/src/api/contract-attachment.ts b/frontend/gas_admin/src/api/contract-attachment.ts new file mode 100644 index 0000000..e17b3d2 --- /dev/null +++ b/frontend/gas_admin/src/api/contract-attachment.ts @@ -0,0 +1,74 @@ +/** + * 功能:配送合同 PDF 附件的上传、失败清理与鉴权预览客户端。 + * 版本:v1.0.0 + */ +import { getToken } from '@/utils/auth'; + +const platformApiBaseURL = + import.meta.env.VITE_API_BASE_URL || + 'http://localhost:12426/heqi/platform/v1'; + +export type ContractAttachmentMetadata = { + has_file: boolean; + available: boolean; + requires_reupload: boolean; + display_name: string; + version: string; +}; + +export type ContractAttachmentUploadReply = { + receipt: string; + cleanup_token: string; + display_name: string; + size: number; +}; + +type ApiEnvelope = { code?: number; message?: string; details?: T }; + +/** 返回当前登录凭证请求头。 */ +function authorizationHeaders(): Record { + const token = getToken(); + return token ? { Authorization: token } : {}; +} + +/** 上传经过前端预检的单个 PDF,服务端仍会验证真实内容。 */ +async function upload(file: File): Promise { + const form = new FormData(); + form.append('file', file); + const response = await fetch( + `${platformApiBaseURL}/gasorder_contract/attachment/upload`, + { method: 'POST', headers: authorizationHeaders(), body: form }, + ); + const payload = (await response.json()) as ApiEnvelope; + if (!response.ok || payload.code !== 0 || !payload.details) { + throw new Error(payload.message || '合同附件上传失败'); + } + return payload.details; +} + +/** 使用受签名保护的清理凭证删除尚未绑定的临时文件。 */ +async function cleanup(cleanupToken: string): Promise { + const response = await fetch( + `${platformApiBaseURL}/gasorder_contract/attachment/cleanup`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authorizationHeaders() }, + body: JSON.stringify({ cleanup_token: cleanupToken }), + }, + ); + if (!response.ok) throw new Error('临时合同附件清理失败'); + const payload = (await response.json()) as ApiEnvelope<{ deleted: boolean }>; + if (payload.code !== 0) throw new Error(payload.message || '临时合同附件清理失败'); +} + +/** 获取鉴权 PDF Blob;内部存储 URI 始终不会暴露给浏览器。 */ +async function load(identity: string): Promise { + const response = await fetch( + `${platformApiBaseURL}/gasorder_contract/${encodeURIComponent(identity)}/attachment`, + { headers: authorizationHeaders() }, + ); + if (!response.ok) throw new Error('合同附件不可用,请重新上传'); + return response.blob(); +} + +export const contractAttachmentApi = { upload, cleanup, load }; diff --git a/frontend/gas_admin/src/api/http.ts b/frontend/gas_admin/src/api/http.ts index 96c42bc..37806e0 100644 --- a/frontend/gas_admin/src/api/http.ts +++ b/frontend/gas_admin/src/api/http.ts @@ -5,6 +5,29 @@ const apiBaseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:12426/ 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'; + } +} + +const API_ERROR_MESSAGES: Record = { + 'Invalid Argument': '请求参数不正确,请检查填写内容', + 'Record Not Found': '记录不存在', + 'Permission Denied': '无权访问该记录', +}; + +/** 将后端通用英文错误转换为中文提示。 */ +function localizeApiErrorMessage(message?: string) { + if (!message) return '请求失败'; + return API_ERROR_MESSAGES[message.trim()] ?? message; +} + export async function request(path: string, init?: RequestInit): Promise { const token = getToken(); let response: Response; @@ -21,6 +44,8 @@ export async function request(path: string, init?: RequestInit): Promise { throw new Error('无法连接服务器,请确认服务已启动'); } const payload = (await response.json()) as { code?: number; message?: string; details?: T }; - if (!response.ok || payload.code !== 0) throw new Error(payload.message || '请求失败'); + if (!response.ok || payload.code !== 0) { + throw new ApiError(localizeApiErrorMessage(payload.message), response.status); + } return payload.details as T; } diff --git a/frontend/gas_admin/src/api/resource-detail-contract.ts b/frontend/gas_admin/src/api/resource-detail-contract.ts new file mode 100644 index 0000000..129a4a1 --- /dev/null +++ b/frontend/gas_admin/src/api/resource-detail-contract.ts @@ -0,0 +1,213 @@ +/** + * 功能描述:声明平台资源详情的字段顺序、固定关联表列和 JSON 展示规则。 + * 版本:v1.1.0 + */ + +export type ResourceCollectionContract = { + columns: string[]; + jsonColumns?: string[]; + rawColumns?: string[]; + relationIdentityKeys?: Record; +}; + +export type ResourceDetailContract = { + leadingKeys?: string[]; + hiddenKeys?: string[]; + relationResources?: Record; + collections?: Record; +}; + +const contracts: Record = { + gasorder_contract: { + leadingKeys: [ + 'contract_no', 'contract_status', 'title', 'user_account_identity', + 'gas_basic_identity', 'delivery_basic_identity', 'identity', + ], + collections: { + products: { + columns: [ + 'bound_at', 'product_name', 'product_code', 'product_type_name', + 'product_params', 'unit_price', 'product_info_identity', + 'unbound_at', 'unbind_reason', + ], + jsonColumns: ['product_params'], + }, + revisions: { + columns: [ + 'occurred_at', 'action', 'contract_status', 'effective_at', + 'expired_at', 'operator_name', 'operator_identity', 'reason', + ], + relationIdentityKeys: { operator_name: 'operator_identity' }, + }, + }, + }, + gasorder_basic: { + leadingKeys: [ + 'order_no', 'request_no', 'order_status', 'gasorder_contract_identity', + 'creator_type', 'creator_identity', 'user_account_identity', + 'gas_basic_identity', 'delivery_basic_identity', + 'staff_account_identity', 'identity', 'status', + ], + hiddenKeys: ['operator_name'], + relationResources: { + gasorder_contract_identity: '/gasorder_contract', + user_account_identity: '/user_account', + gas_basic_identity: '/gas_basic', + delivery_basic_identity: '/delivery_basic', + staff_account_identity: '/staff_account', + }, + collections: { + items: { + columns: [ + 'product_name', 'product_code', 'product_type_name', + 'product_params', 'unit_price', + ], + jsonColumns: ['product_params'], + }, + assignments: { + columns: [ + 'assigned_at', 'gas_basic_display_name', + 'delivery_basic_display_name', 'staff_account_display_name', + 'assigner_name', 'reason', + ], + relationIdentityKeys: { + gas_basic_display_name: 'gas_basic_identity', + delivery_basic_display_name: 'delivery_basic_identity', + staff_account_display_name: 'staff_account_identity', + assigner_name: 'assigner_identity', + }, + }, + statuses: { + columns: ['occurred_at', 'from_status', 'to_status', 'operator_name', 'reason'], + relationIdentityKeys: { operator_name: 'operator_identity' }, + }, + tracks: { + columns: [ + 'attempt_no', 'staff_account_display_name', + 'staff_account_identity', 'started_at', 'completed_at', + ], + relationIdentityKeys: { + staff_account_display_name: 'staff_account_identity', + }, + }, + confirmations: { + columns: [ + 'confirmed_at', 'confirm_type', 'recipient_name', 'recipient_phone', + 'proof_uri', 'remark', + ], + }, + payments: { + columns: ['attempt_no', 'payment_no', 'payment_order_identity', 'amount', 'payment_status'], + }, + }, + }, + gasorder_track: { + leadingKeys: [ + 'gasorder_basic_identity', 'staff_account_identity', 'attempt_no', + 'started_at', 'completed_at', 'identity', 'status', + ], + relationResources: { + gasorder_basic_identity: '/gasorder_basic', + staff_account_identity: '/staff_account', + }, + collections: { + points: { + columns: [ + 'occurred_at', 'received_at', 'longitude', 'latitude', 'accuracy', + 'speed', 'direction', 'source', 'request_no', + ], + rawColumns: ['direction'], + }, + }, + }, + payment_order: { + leadingKeys: [ + 'payment_no', 'payment_status', 'request_no', 'business_type', + 'business_identity', 'user_identity', 'amount', 'identity', + ], + }, + fin_payment: { + leadingKeys: [ + 'ec_order_identity', 'product_summary', 'payment_status', 'channel', + 'amount', 'paid_at', 'identity', 'status', + ], + }, + wallet_record: { + leadingKeys: [ + 'record_no', 'request_no', 'direction', 'trade_type', 'amount', + 'wallet_basic_identity', 'identity', + ], + }, + wallet_apply_cash: { + leadingKeys: [ + 'cash_no', 'apply_status', 'wallet_basic_identity', 'amount', + 'reviewer_identity', 'review_reason', 'reviewed_at', 'identity', + ], + // 审核姓名作为审核人关系的可读快照使用,不再重复渲染独立字段。 + hiddenKeys: ['reviewer_name'], + }, + payment_refund: { + leadingKeys: [ + 'refund_no', 'refund_status', 'business_type', 'business_identity', + 'user_identity', 'refund_product_summary', 'amount', 'reason', + 'description', 'reviewer_identity', 'review_remark', 'reviewed_at', + 'completed_at', 'payment_order_identity', 'wallet_basic_identity', 'identity', + ], + // 审核人可读名称仅作为标识字段主文案,不重复显示为独立详情项。 + hiddenKeys: ['reviewer_display_name'], + }, + ec_order: { + leadingKeys: [ + 'order_no', 'order_status', 'request_no', 'user_account_identity', + 'payable_amount', 'total_amount', 'identity', + ], + }, +}; + +const jsonFieldKeys = new Set([ + 'params', 'product_params', 'product_snapshot', 'args', 'callback_msg', +]); + +/** 返回资源专属详情契约;没有专属规则时使用空契约。 */ +export function resourceDetailContract(name: string): ResourceDetailContract { + return contracts[name] ?? {}; +} + +/** 判断主详情字段是否应使用完整 JSON 查看器,内容不做脱敏或改写。 */ +export function isResourceJsonField(key: string) { + return jsonFieldKeys.has(key); +} + +/** 返回集合字段对应的稳定唯一标识字段。 */ +export function collectionRelationIdentityKey( + resourceName: string, + collectionKey: string, + column: string, +) { + return contracts[resourceName]?.collections?.[collectionKey] + ?.relationIdentityKeys?.[column] ?? ''; +} + +/** 判断集合字段是否应使用完整 JSON 查看器。 */ +export function isCollectionJsonField( + resourceName: string, + collectionKey: string, + column: string, +) { + return Boolean( + contracts[resourceName]?.collections?.[collectionKey] + ?.jsonColumns?.includes(column), + ); +} + +/** 判断集合字段是否必须保留接口原始值,避免同名全局枚举误用到其他业务语义。 */ +export function isCollectionRawField( + resourceName: string, + collectionKey: string, + column: string, +) { + return Boolean( + contracts[resourceName]?.collections?.[collectionKey] + ?.rawColumns?.includes(column), + ); +} diff --git a/frontend/gas_admin/src/api/resource-display.ts b/frontend/gas_admin/src/api/resource-display.ts new file mode 100644 index 0000000..710c5bd --- /dev/null +++ b/frontend/gas_admin/src/api/resource-display.ts @@ -0,0 +1,546 @@ +/** + * 功能:统一资源列表与详情页面的字段名称、关系、金额、状态和时间展示。 + * 版本:v1.7.0 + */ +import dayjs from 'dayjs'; +import type { RecordPageMode, ResourceRow } from './resource-page-rules'; +import { staffRoleLabel } from './resource-staff-relation'; +import { + defaultResourceFieldLabel, + type ResourceField, + type ResourceUiDefinition, +} from './resources'; + +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: '系统内置', + gas_basic_display_name: '气站', + delivery_basic_display_name: '配送点', + staff_account_display_name: '工作人员', + user_account_display_name: '用户', + creator_display_name: '创建方', + contract_display_name: '配送合同', + assigner_name: '分配人', + points: '轨迹点', +}; + +/** 聚合子表字段可能与全局同名字段具有不同业务语义,按父资源和集合名称覆盖。 */ +const collectionFieldAliases: Record> = { + 'gasorder_contract.products': { + gasorder_contract_identity: '配送合同唯一标识', + product_info_identity: '气瓶唯一标识', + product_name: '智能气阀名称', + product_code: '气瓶编码', + product_type_name: '气瓶类型名称', + product_params: '气瓶规格参数', + bound_at: '绑定时间', + unbound_at: '解绑时间', + unbind_reason: '解绑原因', + unit_price: '约定充装单价(元)', + }, + 'gasorder_basic.items': { + product_name: '智能气阀名称', + product_code: '设备编码', + product_type_name: '设备类型', + product_params: '设备参数', + unit_price: '成交单价(元)', + active: '是否有效', + }, + 'gasorder_basic.assignments': { + assigned_at: '分配时间', + gas_basic_display_name: '气站', + delivery_basic_display_name: '配送点', + staff_account_display_name: '配送人员', + assigner_name: '分配人', + reason: '分配原因', + }, + 'gasorder_basic.statuses': { + occurred_at: '发生时间', + from_status: '原状态', + to_status: '新状态', + operator_name: '操作人', + reason: '原因', + }, + 'gasorder_basic.tracks': { + attempt_no: '配送尝试次数', + staff_account_display_name: '配送人员', + staff_account_identity: '配送人员唯一标识', + started_at: '开始时间', + completed_at: '完成时间', + }, + 'gasorder_track.points': { + request_no: '上报流水号', + longitude: '经度', + latitude: '纬度', + occurred_at: '定位时间', + received_at: '接收时间', + source: '定位来源', + accuracy: '定位精度', + speed: '行进速度', + direction: '行进方向', + }, + 'gasorder_basic.confirmations': { + confirmed_at: '确认时间', + confirm_type: '确认方式', + recipient_name: '签收人姓名', + recipient_phone: '签收人电话', + proof_uri: '凭证地址', + remark: '备注', + }, + 'gasorder_basic.payments': { + attempt_no: '支付尝试次数', + payment_no: '支付单号', + payment_order_identity: '支付记录唯一标识', + amount: '支付金额(元)', + payment_status: '支付状态', + }, + 'gasorder_contract.revisions': { + occurred_at: '发生时间', + action: '合同动作', + contract_status: '合同状态', + effective_at: '生效时间', + expired_at: '到期时间', + operator_name: '操作人', + operator_identity: '操作人唯一标识', + reason: '变更原因', + }, +}; + +/** 主资源详情中的关联标识使用业务名称,技术标识仅作为辅助复制信息。 */ +const resourceFieldAliases: Record> = { + gasorder_basic: { + identity: '系统唯一标识', + gas_basic_identity: '履约气站', + delivery_basic_identity: '当前配送点', + staff_account_identity: '当前配送人员', + user_account_identity: '服务用户', + creator_identity: '创建方', + operator_identity: '建单操作人', + }, +}; + +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; + if (detail.track && typeof detail.track === 'object') + return detail.track as ResourceRow; + return detail; +} + +/** 返回字段中文名称,脱敏字段沿用原字段名称。 */ +export function resourceFieldLabel( + definition: ResourceUiDefinition, + key: string, + collectionKey = '', +) { + const normalized = key.endsWith('_masked') ? key.slice(0, -7) : key; + const collectionAliases = + collectionFieldAliases[`${definition.name}.${collectionKey}`]; + return ( + collectionAliases?.[normalized] ?? + definition.fields.find((field) => field.key === normalized)?.label ?? + (collectionKey + ? undefined + : resourceFieldAliases[definition.name]?.[normalized]) ?? + defaultResourceFieldLabel(normalized) ?? + aliases[normalized] ?? + key + ); +} + +/** 判断字段是否具有显式中文契约,未知响应键不得直接回显英文。 */ +export function hasResourceFieldLabel( + definition: ResourceUiDefinition, + key: string, + collectionKey = '', +) { + return resourceFieldLabel(definition, key, collectionKey) !== 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 contractStatusLabel(status: number) { + return ( + { + 0: '草稿', + 10: '待处理', + 11: '生效中', + 12: '已过期', + 13: '已终止', + }[status] ?? `未知(${status})` + ); +} + +/** 将配送订单业务状态转换为状态机约定的中文名称。 */ +export function gasorderStatusLabel(status: number) { + return ( + { + 0: '草稿', + 16: '已创建', + 18: '已分配', + 19: '充装中', + 20: '已就绪', + 21: '异常', + 22: '已取消', + 23: '已完成', + 33: '配送中', + 34: '待确认', + }[status] ?? `未知(${status})` + ); +} + +/** 选择关系记录的首选可读名称。 */ +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 ?? + '-', + ); +} + +/** 工作人员关系额外展示角色,其他关系保持原有可读名称。 */ +export function relationOptionLabel( + field: ResourceField, + option: ResourceRow, + options: ResourceRow[] = [], +) { + if (field.relationOptionDisplay === 'product-name-type') { + return productNameTypeLabel(option, options); + } + if (field.relationOptionDisplay === 'wallet-owner') { + return walletOwnerLabel(option); + } + const configuredLabel = field.relationOptionLabelKey + ? option[field.relationOptionLabelKey] + : undefined; + const label = + configuredLabel == null || configuredLabel === '' + ? optionLabel(option) + : String(configuredLabel); + if ( + field.relationOptionDefaultKey && + option[field.relationOptionDefaultKey] + ) { + return `${label}(默认地址)`; + } + return field.relation === '/staff_account' + ? `${label}(${staffRoleLabel(option.role_code)})` + : label; +} + +/** 钱包没有独立业务名称时,按归属主体类型提供稳定可读摘要。 */ +function walletOwnerLabel(option: ResourceRow) { + const ownerType = String(option.owner_type ?? ''); + return ( + { + user: '用户钱包', + staff: '工作人员钱包', + gas: '气站钱包', + delivery: '配送点钱包', + platform: '平台钱包', + }[ownerType] ?? '钱包账户' + ); +} + +/** 合同气瓶优先显示具体设备名称和绑定时类型;同名同类型时追加编码以便区分。 */ +function productNameTypeLabel(option: ResourceRow, options: ResourceRow[]) { + const name = String(option.product_name ?? '').trim(); + const type = String(option.product_type_name ?? '').trim(); + const code = String(option.product_code ?? '').trim(); + const identity = String(option.identity ?? '').trim(); + if (!name) { + if (code) return type ? `${code}(${type})` : code; + return identity || type || '-'; + } + const base = type ? `${name}(${type})` : name; + const duplicateCount = options.filter( + (item) => + String(item.product_name ?? '').trim() === name && + String(item.product_type_name ?? '').trim() === type, + ).length; + if (duplicateCount <= 1) return base; + return `${base} · ${code || identity || '未编号'}`; +} + +function relationLabel( + field: ResourceField, + identity: string, + relationOptions: Record, + row: ResourceRow, +) { + const resource = fieldRelationResource(field, row); + const match = (relationOptions[resource] ?? []).find( + (option) => String(option.identity) === identity, + ); + if (!match) return '名称加载失败'; + return relationOptionLabel(field, match, relationOptions[resource] ?? []); +} + +/** 根据当前记录的主体类型解析静态或动态关系资源。 */ +export function fieldRelationResource(field: ResourceField, row: ResourceRow) { + if (field.relation) return field.relation; + if (!field.dynamicRelation) return ''; + const parentValue = String(row[field.dynamicRelation.parentKey] ?? ''); + return field.dynamicRelation.resources[parentValue] ?? ''; +} + +/** 格式化不依赖字段定义的通用值。 */ +export function displayRawValue(key: string, value: unknown) { + // 历史合同可能缺少到期时间,明确提示补录,避免将数据缺失误认为无期限合同。 + if (key === 'expired_at' && (value == null || value === '')) return '待补录'; + // 配送订单尚未分配时不存在工作人员标识,使用业务语义替代通用占位符。 + if (key === 'staff_account_identity' && (value == null || value === '')) + return '暂未分配'; + if (value == null || value === '') return '-'; + if (typeof value === 'boolean') return value ? '是' : '否'; + if (key === 'status') return recordStatusLabel(Number(value)); + if (key === 'contract_status') return contractStatusLabel(Number(value)); + if (key === 'previous_order_status' && Number(value) === 0) return '无'; + if (key === 'reason' && String(value) === 'order created') return '订单创建'; + if ( + [ + 'order_status', + 'previous_order_status', + 'from_status', + 'to_status', + ].includes(key) + ) + return gasorderStatusLabel(Number(value)); + if (key === 'action') { + return ( + { + activate: '启用合同', + renew: '续签合同', + terminate: '终止合同', + }[String(value)] ?? `未知动作(${String(value)})` + ); + } + if (key === 'payment_status') { + return ( + { + 10: '待支付', + 20: '确认中', + 23: '支付成功', + 30: '已关闭', + 40: '支付失败', + 50: '支付异常', + }[Number(value)] ?? `未知支付状态(${String(value)})` + ); + } + if (key === 'logistics_status') { + return ( + { 10: '待发货', 20: '已发货', 30: '已收货' }[Number(value)] ?? + `未知物流状态(${String(value)})` + ); + } + if (key === 'confirm_type') { + return ( + { signature: '签名确认', receipt_code: '收货码确认' }[String(value)] ?? + `未知确认方式(${String(value)})` + ); + } + if (key === 'source') { + return ( + { gps: 'GPS定位', network: '网络定位', manual: '人工上报' }[ + String(value) + ] ?? `未知定位来源(${String(value)})` + ); + } + if (key === 'owner_type' || key === 'subject_type') { + return ( + { + user: '用户', + staff: '工作人员', + gas: '气站', + delivery: '配送点', + platform: '平台', + }[String(value)] ?? `未知主体类型(${String(value)})` + ); + } + if (key === 'business_type') { + return ( + { + gasorder: '气体配送订单', + ec_order: '商城订单', + recharge: '钱包充值', + }[String(value)] ?? `未知业务类型(${String(value)})` + ); + } + if (key === 'direction') { + return ( + { income: '收入', expense: '支出' }[String(value)] ?? + `未知收支方向(${String(value)})` + ); + } + if (key.endsWith('_status')) { + return ( + { + 10: '待处理', 11: '生效中', 12: '已过期', 13: '已终止', + 14: '已记录', 15: '已绑定', 16: '已创建', 17: '已下单', + 18: '已分配', 19: '充装中', 20: '已就绪', 21: '异常', + 22: '已取消', 23: '已完成', 24: '已入账', 25: '已通过', + 26: '已驳回', 27: '已报废', 28: '在库', 29: '运输中', + 30: '使用中', 31: '维修中', 32: '待受理', 33: '配送中', + 34: '待确认', 35: '已支付', 36: '已发布', 37: '成功', + 38: '已匹配', + }[Number(value)] ?? `未知状态(${String(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, + fieldOptions: Record< + string, + Array<{ label: string; value: string | number }> + > = {}, +) { + const displayValue = field.detailDisplayKey + ? row[field.detailDisplayKey] + : undefined; + const value = displayValue ?? row[field.key] ?? row[`${field.key}_masked`]; + if (value == null || value === '') return field.emptyText ?? '-'; + const hasOptionSource = + Object.prototype.hasOwnProperty.call(fieldOptions, field.key) || + Boolean(field.options); + const options = fieldOptions[field.key] ?? field.options; + const option = options?.find((item) => String(item.value) === String(value)); + if (option) return option.label; + if (hasOptionSource && field.unknownValueLabel) { + return `${field.unknownValueLabel}(${String(value)})`; + } + if (field.displayPrecision != null) { + const numericValue = Number(value); + return Number.isFinite(numericValue) + ? numericValue.toFixed(field.displayPrecision) + : String(value); + } + // 纯日期字段不展示后端序列化附带的时间和时区部分。 + if (field.type === 'date') { + const date = dayjs(String(value)); + return date.isValid() ? date.format('YYYY-MM-DD') : String(value); + } + // 日期时间应以字段类型为准,避免 period_start 等非 `_at` 字段回显原始 RFC3339 文本。 + if (field.type === 'datetime') { + const date = dayjs(String(value)); + return date.isValid() ? date.format('YYYY-MM-DD HH:mm:ss') : String(value); + } + if (field.type === 'identity' && typeof value === 'string') { + return relationLabel(field, value, relationOptions, row); + } + if (field.type === 'identity-list' && Array.isArray(value)) { + return value + .map((item) => relationLabel(field, String(item), relationOptions, row)) + .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/gas_admin/src/api/resource-navigation.ts b/frontend/gas_admin/src/api/resource-navigation.ts new file mode 100644 index 0000000..2d95b7a --- /dev/null +++ b/frontend/gas_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/gas_admin/src/api/resource-page-rules.ts b/frontend/gas_admin/src/api/resource-page-rules.ts new file mode 100644 index 0000000..f838c90 --- /dev/null +++ b/frontend/gas_admin/src/api/resource-page-rules.ts @@ -0,0 +1,358 @@ +/** + * 功能:定义标准资源在新建、详情和编辑页面中的字段能力与动态编辑限制。 + * 版本: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', 'gas_basic_identity'], + accountSummary: true, + }, + delivery_basic: { + editKeys: ['name', 'principal', 'address'], + }, + delivery_account: { + editKeys: ['display_name'], + accountSummary: true, + }, + staff_account: { + editKeys: [ + 'name', + 'phone', + 'avatar', + 'role_code', + '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', 'delivery_basic_identity'], + 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', + 'remark', + ], + editOptionalKeys: ['password'], + accountSummary: true, + }, + product_type: { editKeys: ['code', 'name'] }, + product_warehouse: { + editKeys: ['code', 'name', 'address', 'manager', 'phone'], + }, + product_info: { + createHiddenKeys: ['product_status'], + 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: { + createHiddenKeys: [ + 'delivery_basic_identity', + 'staff_account_identity', + 'gas_basic_identity', + 'operator_identity', + 'description', + 'request_no', + 'address', + 'appointment_at', + 'started_at', + 'completed_at', + 'result', + ], + 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 = { + delivery_account: ['delivery_basic_identity'], + 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'], +}; + +/** 判断目标是否为受保护的 root 平台账户。 */ +function isProtectedPlatformRootAccount( + definition: ResourceUiDefinition, + row: ResourceRow, +) { + return ( + definition.name === 'platform_account' && + String(row.platform_role_code ?? '') === 'root' + ); +} + +/** 返回当前模式需要展示的字段;编辑页会保留不可变字段但将其设为只读。 */ +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']; + } + const protectedRootAccount = isProtectedPlatformRootAccount(definition, row); + if ( + definition.name === 'platform_account' && + (context.role !== 'root' || protectedRootAccount) + ) { + 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), + ]); + // root 角色保持可见但只读,避免管理员误以为账户没有关联角色。 + if (protectedRootAccount) visibleKeys.add('platform_role_code'); + 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' || + isProtectedPlatformRootAccount(definition, row)) + ) { + 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') { + if (Number(row.contract_status) !== 0) + return '只有草稿状态的合同可以编辑'; + if (![0, 1].includes(Number(row.status))) + 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/gas_admin/src/api/resource-record-form.ts b/frontend/gas_admin/src/api/resource-record-form.ts new file mode 100644 index 0000000..4b2a7fe --- /dev/null +++ b/frontend/gas_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 + ? field.defaultValue + : 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/gas_admin/src/api/resource-search-contract.ts b/frontend/gas_admin/src/api/resource-search-contract.ts new file mode 100644 index 0000000..82b411a --- /dev/null +++ b/frontend/gas_admin/src/api/resource-search-contract.ts @@ -0,0 +1,47 @@ +/** + * 功能描述:读取后端生成的气站资源搜索契约,统一搜索字段和中文枚举选项。 + * 版本:v1.0.0 + */ +import gasContract from '@/contracts/gas-resources.json'; + +export type ResourceSearchValue = { + value: string; + label: string; +}; + +export type ResourceSearchField = { + key: string; + kind: 'text' | 'enum'; + values?: ResourceSearchValue[]; +}; + +type ContractResource = { + name: string; + searchFields?: ResourceSearchField[]; +}; + +const searchFieldsByResource = Object.fromEntries( + (gasContract.resources as ContractResource[]).map((resource) => [ + resource.name, + resource.searchFields ?? [], + ]), +) as Record; + +/** 返回后端已确认可用的资源搜索字段副本。 */ +export function resourceSearchFields(name: string): ResourceSearchField[] { + return (searchFieldsByResource[name] ?? []).map((field) => ({ + ...field, + values: field.values?.map((value) => ({ ...value })), + })); +} + +/** 返回搜索枚举的中文展示选项,确保页面显示与后端匹配规则同源。 */ +export function resourceSearchEnumOptions(resource: string, key: string) { + const field = (searchFieldsByResource[resource] ?? []).find( + (item) => item.key === key && item.kind === 'enum', + ); + if (!field?.values?.length) { + throw new Error(`资源搜索枚举缺失:${resource}.${key}`); + } + return field.values.map((value) => ({ ...value })); +} diff --git a/frontend/gas_admin/src/api/resource-staff-relation.ts b/frontend/gas_admin/src/api/resource-staff-relation.ts new file mode 100644 index 0000000..53c8b63 --- /dev/null +++ b/frontend/gas_admin/src/api/resource-staff-relation.ts @@ -0,0 +1,142 @@ +/** + * 功能:定义工作人员关联字段的显式过滤、角色展示与资质来源校验策略。 + * 版本:v1.1.0 + */ + +export const staffRoleCodes = ['installer', 'delivery', 'operations'] as const; + +export type StaffRoleCode = (typeof staffRoleCodes)[number]; + +export type StaffRelationPolicy = { + roles: readonly StaffRoleCode[] | 'context'; + enabledOnly?: boolean; + workStatus?: 'on_duty' | 'off_duty'; + /** 仅展示至少拥有一条启用且未过期资质的工作人员。 */ + validCredentialOnly?: boolean; + lockPrefilled?: boolean; + showIdentityCopy?: boolean; + organizationScoped?: boolean; +}; + +export type StaffRelationContext = { + staffType?: string; +}; + +type StaffRelationField = { + relation?: string; + staffRelation?: StaffRelationPolicy; +}; + +type StaffOwner = { + identity?: unknown; + role_code?: unknown; + status?: unknown; +}; + +const roleLabels: Record = { + installer: '安装人员', + delivery: '配送人员', + operations: '运维人员', +}; + +/** 将外部字符串收敛为工作人员闭集角色。 */ +export function normalizeStaffRole(value: unknown): StaffRoleCode | '' { + const role = String(value ?? '').trim(); + return staffRoleCodes.includes(role as StaffRoleCode) + ? (role as StaffRoleCode) + : ''; +} + +/** 返回工作人员角色的中文名称。 */ +export function staffRoleLabel(value: unknown) { + const role = normalizeStaffRole(value); + return role ? roleLabels[role] : '未知角色'; +} + +/** + * 为工作人员关联字段构造服务端过滤条件;缺少显式策略时立即失败, + * 防止业务字段再次继承隐含的配送人员默认值。 + */ +export function staffRelationFilters( + field: StaffRelationField, + context: StaffRelationContext = {}, +) { + if (field.relation !== '/staff_account') return {}; + if (!field.staffRelation) { + throw new Error('工作人员关联字段缺少显式过滤策略'); + } + const roles = + field.staffRelation.roles === 'context' + ? [normalizeStaffRole(context.staffType)].filter( + (role): role is StaffRoleCode => Boolean(role), + ) + : [...field.staffRelation.roles]; + if (!roles.length) throw new Error('工作人员关联字段缺少有效角色上下文'); + const filters: Record = + roles.length === 1 + ? { role_code: roles[0] } + : { role_codes: roles.join(',') }; + if (field.staffRelation.enabledOnly) filters.status = '1'; + if (field.staffRelation.workStatus) { + filters.work_status = field.staffRelation.workStatus; + } + if (field.staffRelation.validCredentialOnly) { + filters.valid_credential_only = '1'; + } + return filters; +} + +/** 从站内返回地址中推断工作人员菜单角色,仅用作显式参数缺失时的回退。 */ +export function staffRoleFromReturnPath(returnTo: string) { + let current = returnTo; + for (let depth = 0; depth < 3 && current; depth += 1) { + const url = new URL(current, 'http://codex.local'); + const queryRole = normalizeStaffRole(url.searchParams.get('staff_type')); + if (queryRole) return queryRole; + const pathRole = [ + ['/staff/installers', 'installer'], + ['/staff/delivery', 'delivery'], + ['/staff/operations', 'operations'], + ].find(([path]) => url.pathname.startsWith(path))?.[1]; + if (pathRole) return pathRole as StaffRoleCode; + current = url.searchParams.get('return_to') ?? ''; + } + return ''; +} + +/** 解析资质页面角色来源,并拒绝显式参数与返回路径互相冲突。 */ +export function resolveCredentialStaffRole( + explicitRole: unknown, + returnTo: string, +) { + const explicit = normalizeStaffRole(explicitRole); + const inferred = staffRoleFromReturnPath(returnTo); + if (explicit && inferred && explicit !== inferred) { + return { role: '' as const, error: '工作人员角色与来源菜单不一致' }; + } + const role = explicit || inferred; + return role + ? { role, error: '' } + : { + role: '' as const, + error: '缺少工作人员角色来源,请从工作人员页面进入', + }; +} + +/** 校验资质所有者与经服务端查询得到的真实人员记录是否一致。 */ +export function credentialOwnerValidationMessage( + ownerIdentity: string, + expectedRole: StaffRoleCode, + owner: StaffOwner | undefined, +) { + if (!ownerIdentity) return '缺少工作人员唯一标识,请从工作人员页面进入'; + if (!owner) return '工作人员不存在、已归档或无权访问'; + if (String(owner.identity ?? '') !== ownerIdentity) { + return '工作人员唯一标识与查询结果不一致'; + } + if (normalizeStaffRole(owner.role_code) !== expectedRole) { + return '工作人员角色已变化,请从当前角色菜单重新进入'; + } + if (Number(owner.status) === 3) return '已归档工作人员不能新增资质'; + return ''; +} diff --git a/frontend/gas_admin/src/api/resources.ts b/frontend/gas_admin/src/api/resources.ts index 583acdd..03359e7 100644 --- a/frontend/gas_admin/src/api/resources.ts +++ b/frontend/gas_admin/src/api/resources.ts @@ -4,19 +4,55 @@ export type ResourceMode = | 'append_only' | 'editable' | 'managed'; +import { + type ResourceSearchField, + resourceSearchFields, +} from './resource-search-contract'; export type ResourcePageKind = 'list' | 'tree'; export type ResourceFieldType = | 'text' | 'password' | 'identity' | 'identity-list' + | 'menu-tree' | 'number' | 'money' | 'boolean' | 'date' | 'datetime' | 'textarea' - | 'select'; + | 'select' + | 'contract-file'; + +/** 关联下拉的父子联动配置,未配置时保持独立加载。 */ +export type ResourceRelationLinkage = { + parentKey: string; + optionParentKey: string; + filterKey: string; + backfillParent?: boolean; + filterOnly?: boolean; + requiresParent?: boolean; + parentChangeMessage?: string; +}; + +/** 根据父字段值选择实际关系资源。 */ +export type ResourceDynamicRelation = { + parentKey: string; + parentLabel: string; + resources: Record; + parentChangeMessage?: string; + contextParentKey?: string; + fixedIdentityKeys?: Record; + missingIdentityMessages?: Record; + scopeFilters?: Record>; + contextChangeMessage?: string; +}; export type ResourceField = { key: string; @@ -25,6 +61,7 @@ export type ResourceField = { type?: ResourceFieldType; required?: boolean; relation?: string; + displayRelationLabel?: boolean; listRelationNameOnly?: boolean; emptyText?: string; placeholder?: string; @@ -32,10 +69,29 @@ export type ResourceField = { readonlyRelationText?: boolean; /** 列表中以可悬浮、可复制的紧凑文本展示普通字段。 */ listCopyable?: boolean; + listDisplayKey?: string; + detailDisplayKey?: string; + listDetailLink?: boolean; + listDisplayIdentityCopy?: boolean; + listHidden?: boolean; + displayPrecision?: number; options?: Array<{ label: string; value: string | number }>; defaultValue?: string | number | boolean; readonly?: boolean; + readonlyOnCreate?: boolean; unknownValueLabel?: string; + relationLinkage?: ResourceRelationLinkage; + dynamicRelation?: ResourceDynamicRelation; + relationOptionLabelKey?: string; + relationOptionDisplay?: 'product-name-type' | 'wallet-owner'; + relationOptionDefaultKey?: string; + relationAutoSelectKey?: string; + relationEmptyText?: string; + relationEmptyAction?: { label: string; routeName: string }; + relationFilters?: Record; + relationStrictFilter?: boolean; + relationInvalidMessage?: string; + staffRelation?: import('./resource-staff-relation').StaffRelationPolicy; }; export type DetailAction = { @@ -55,6 +111,7 @@ export type ResourceUiDefinition = { mode: ResourceMode; pageKind: ResourcePageKind; fields: ResourceField[]; + searchFields: ResourceSearchField[]; detailActions?: DetailAction[]; canCreate: boolean; canEdit: boolean; @@ -234,6 +291,11 @@ const fieldLabels: Record = { withdrawable: '计入可提现余额', }; +/** 返回字段的全局中文名称,供只读详情页统一兜底。 */ +export function defaultResourceFieldLabel(key: string) { + return fieldLabels[key]; +} + const numbers = new Set([ 'sort_no', 'stock_quantity', 'quantity', 'score', 'version_no', 'attempt_no', 'target_product_status', @@ -296,6 +358,7 @@ function fixedAdminRole(label: string): ResourceField { options: [{ label, value: 'admin' }], defaultValue: 'admin', readonly: true, + readonlyOnCreate: true, unknownValueLabel: '未知角色', }); } @@ -328,6 +391,7 @@ function define( mode, pageKind, fields, + searchFields: resourceSearchFields(name), ...defaults, ...capabilities, ...(detailActions ? { detailActions } : {}), @@ -342,7 +406,7 @@ const platformResources: ResourceUiDefinition[] = [ { ...define('delivery_basic', '配送点管理', 'writable', [f('delivery_code', { required: true }), f('name', { required: true }), relation('gas_basic_identity', '/gas_basic'), f('principal'), f('address')]), accountManagement: { resource: '/delivery_account', relationKey: 'delivery_basic_identity', title: '配送点账户' }, walletOwnerType: 'delivery' }, define('delivery_account', '配送点账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), fixedAdminRole('配送点管理员'), relation('delivery_basic_identity', '/delivery_basic', true)]), { ...define('staff_account', '工作人员', 'writable', [f('username', { required: true }), f('password', { required: true }), f('name', { required: true }), f('phone'), f('avatar'), f('role_code', { required: true, type: 'select', options: [{ label: '安装人员', value: 'installer' }, { label: '配送人员', value: 'delivery' }, { label: '运维人员', value: 'operations' }] }), relation('gas_basic_identity', '/gas_basic'), relation('delivery_basic_identity', '/delivery_basic'), f('work_status', { type: 'select', options: [{ label: '在岗', value: 'on_duty' }, { label: '离岗', value: 'off_duty' }] })]), walletOwnerType: 'staff' }, - define('staff_credential', '工作人员资质', 'writable', [relation('staff_account_identity', '/staff_account', true), f('credential_type', { required: true }), f('credential_no'), f('expired_at')]), + define('staff_credential', '工作人员资质', 'writable', [relation('staff_account_identity', '/staff_account', true, { staffRelation: { roles: 'context', lockPrefilled: true, showIdentityCopy: true } }), f('credential_type', { required: true }), f('credential_no'), f('expired_at')]), { ...define('user_account', '用户账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('name', { required: true }), f('phone'), f('avatar'), f('real_name')]), walletOwnerType: 'user' }, define('user_address', '用户地址', 'writable', [relation('user_account_identity', '/user_account', true), f('address', { required: true }), f('longitude'), f('latitude'), f('is_default')]), define('user_service_relation', '用户服务关系', 'writable', [ @@ -370,7 +434,7 @@ const platformResources: ResourceUiDefinition[] = [ ]), define('gasorder_contract_revision', '合同修订记录', 'readonly', []), define('gasorder_basic', '气体配送订单', 'append_only', [f('request_no', { required: true }), relation('gasorder_contract_identity', '/gasorder_contract', true), f('creator_type', { required: true, type: 'select', options: [{ label: '用户', value: 'user' }, { label: '工作人员', value: 'staff' }, { label: '配送站', value: 'delivery' }, { label: '气站', value: 'gas' }] }), f('creator_identity', { required: true }), relation('user_address_identity', '/user_address', true), f('gasorder_contract_product_identities', { required: true, type: 'identity-list', relation: '/gasorder_contract_product' }), f('contact_name', { required: true }), f('contact_phone', { required: true }), f('discount_amount'), f('remark')], 'list', [ - { name: '分配订单', resource: '/gasorder_basic/:identity/assign', fields: [relation('delivery_basic_identity', '/delivery_basic', true), relation('staff_account_identity', '/staff_account', true), ...reason], visibleFor: { field: 'order_status', values: [16, 18] } }, + { name: '分配订单', resource: '/gasorder_basic/:identity/assign', fields: [relation('delivery_basic_identity', '/delivery_basic', true), relation('staff_account_identity', '/staff_account', true, { staffRelation: { roles: ['delivery'], enabledOnly: true } }), ...reason], visibleFor: { field: 'order_status', values: [16, 18] } }, { name: '开始罐装', resource: '/gasorder_basic/:identity/filling', fields: reason, visibleFor: { field: 'order_status', values: [18] } }, { name: '待配送', resource: '/gasorder_basic/:identity/ready', fields: reason, visibleFor: { field: 'order_status', values: [19] } }, { name: '开始配送', resource: '/gasorder_basic/:identity/delivering', fields: reason, visibleFor: { field: 'order_status', values: [20] } }, @@ -442,13 +506,13 @@ const platformResources: ResourceUiDefinition[] = [ const gasOverrides: ResourceUiDefinition[] = [ { ...define('delivery_basic', '配送点管理', 'writable', [f('delivery_code', { required: true }), f('name', { required: true }), f('principal'), f('address')]), accountManagement: { resource: '/delivery_account', relationKey: 'delivery_basic_identity', title: '配送点账户' } }, - define('delivery_account', '配送点账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), fixedAdminRole('配送点管理员')], 'list', [ + define('delivery_account', '配送点账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), fixedAdminRole('配送点管理员'), relation('delivery_basic_identity', '/delivery_basic', true)], 'list', [ { name: '重置密码', resource: '/delivery_account/:identity/password', method: 'PUT', fields: [f('password', { required: true })] }, ]), define('staff_account', '工作人员', 'writable', [f('username', { required: true }), f('password', { required: true }), f('name', { required: true }), f('phone'), f('avatar'), f('role_code', { required: true, type: 'select', options: [{ label: '安装人员', value: 'installer' }, { label: '配送人员', value: 'delivery' }, { label: '运维人员', value: 'operations' }] }), relation('delivery_basic_identity', '/delivery_basic'), f('work_status', { type: 'select', options: [{ label: '在岗', value: 'on_duty' }, { label: '离岗', value: 'off_duty' }] })], 'list', [ { name: '重置密码', resource: '/staff_account/:identity/password', method: 'PUT', fields: [f('password', { required: true })] }, ]), - define('staff_credential', '工作人员资质', 'writable', [relation('staff_account_identity', '/staff_account', true), f('credential_type', { required: true }), f('credential_no'), f('expired_at')]), + define('staff_credential', '工作人员资质', 'writable', [relation('staff_account_identity', '/staff_account', true, { staffRelation: { roles: 'context', lockPrefilled: true, showIdentityCopy: true } }), f('credential_type', { required: true }), f('credential_no'), f('expired_at')]), define('user_account', '用户账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('name', { required: true }), f('phone'), f('avatar'), f('real_name'), relation('delivery_basic_identity', '/delivery_basic', true)], 'list', [ { name: '重置密码', resource: '/user_account/:identity/password', method: 'PUT', fields: [f('password', { required: true })] }, ]), @@ -464,7 +528,7 @@ const gasOverrides: ResourceUiDefinition[] = [ define('gasorder_contract_revision', '合同修订记录', 'readonly', []), define('product_info', '合同可选气瓶', 'readonly', []), define('gasorder_basic', '燃气配送订单', 'append_only', [f('request_no', { required: true }), relation('gasorder_contract_identity', '/gasorder_contract', true), relation('user_address_identity', '/user_address', true), f('gasorder_contract_product_identities', { required: true, type: 'identity-list', relation: '/gasorder_contract_product' }), f('contact_name', { required: true }), f('contact_phone', { required: true }), f('discount_amount'), f('remark')], 'list', [ - { name: '分配订单', resource: '/gasorder_basic/:identity/assign', fields: [relation('delivery_basic_identity', '/delivery_basic', true), relation('staff_account_identity', '/staff_account', true), ...reason], visibleFor: { field: 'order_status', values: [16, 18] } }, + { name: '分配订单', resource: '/gasorder_basic/:identity/assign', fields: [relation('delivery_basic_identity', '/delivery_basic', true), relation('staff_account_identity', '/staff_account', true, { staffRelation: { roles: ['delivery'], enabledOnly: true } }), ...reason], visibleFor: { field: 'order_status', values: [16, 18] } }, { name: '开始罐装', resource: '/gasorder_basic/:identity/filling', fields: reason, visibleFor: { field: 'order_status', values: [18] } }, { name: '待配送', resource: '/gasorder_basic/:identity/ready', fields: reason, visibleFor: { field: 'order_status', values: [19] } }, { name: '标记异常', resource: '/gasorder_basic/:identity/exception', fields: reason, visibleFor: { field: 'order_status', values: [19, 20, 33, 34] } }, @@ -473,9 +537,9 @@ const gasOverrides: ResourceUiDefinition[] = [ ]), define('wallet_basic', '钱包', 'readonly', []), define('wallet_bank', '银行卡', 'readonly', []), - define('wallet_payment', '支付记录', 'readonly', []), + define('payment_order', '支付记录', 'readonly', []), define('wallet_record', '钱包流水', 'readonly', []), - define('wallet_refund', '退款记录', 'readonly', []), + define('payment_refund', '退款记录', 'readonly', []), define('wallet_apply_cash', '提现申请', 'append_only', [relation('wallet_bank_identity', '/wallet_bank'), f('request_no', { required: true }), f('amount', { required: true }), f('channel', { required: true }), f('remark')]), define('fin_settlement', '财务结算', 'readonly', []), define('fin_reconciliation', '财务对账', 'readonly', []), @@ -497,3 +561,13 @@ export function getResource(resourcePath: string): ResourceUiDefinition { if (!definition) throw new Error(`未知资源:${resourcePath}`); return definition; } + +/** 返回动态关系字段当前使用的资源;没有动态配置时返回固定资源。 */ +export function dynamicRelationResource( + field: ResourceField, + values: Record, +) { + const dynamic = field.dynamicRelation; + if (!dynamic) return field.relation ?? ''; + return dynamic.resources[String(values[dynamic.parentKey] ?? '').trim()] ?? ''; +} diff --git a/frontend/gas_admin/src/contracts/gas-resources.json b/frontend/gas_admin/src/contracts/gas-resources.json index dc38262..e0d8ece 100644 --- a/frontend/gas_admin/src/contracts/gas-resources.json +++ b/frontend/gas_admin/src/contracts/gas-resources.json @@ -1 +1 @@ -{"resources":[{"domain":"delivery","name":"delivery_basic","path":"/delivery_basic","pageKind":"list","mode":"writable"},{"domain":"delivery","name":"delivery_account","path":"/delivery_account","pageKind":"list","mode":"writable"},{"domain":"staff","name":"staff_account","path":"/staff_account","pageKind":"list","mode":"writable"},{"domain":"staff","name":"staff_credential","path":"/staff_credential","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_account","path":"/user_account","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_address","path":"/user_address","pageKind":"list","mode":"writable"},{"domain":"contract","name":"gasorder_contract","path":"/gasorder_contract","pageKind":"list","mode":"managed"},{"domain":"contract","name":"gasorder_contract_product","path":"/gasorder_contract_product","pageKind":"list","mode":"append_only"},{"domain":"contract","name":"gasorder_contract_revision","path":"/gasorder_contract_revision","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_basic","path":"/gasorder_basic","pageKind":"list","mode":"append_only"},{"domain":"contract","name":"product_info","path":"/product_info","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"wallet_basic","path":"/wallet_basic","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"wallet_bank","path":"/wallet_bank","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"wallet_payment","path":"/wallet_payment","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"wallet_record","path":"/wallet_record","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"wallet_refund","path":"/wallet_refund","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"wallet_apply_cash","path":"/wallet_apply_cash","pageKind":"list","mode":"append_only"},{"domain":"finance","name":"fin_settlement","path":"/fin_settlement","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"fin_reconciliation","path":"/fin_reconciliation","pageKind":"list","mode":"readonly"},{"domain":"ticket","name":"cs_ticket","path":"/cs_ticket","pageKind":"list","mode":"writable"}],"routes":[{"method":"POST","path":"/gasorder_basic"},{"method":"POST","path":"/gasorder_basic/:identity/ready"},{"method":"POST","path":"/gasorder_basic/:identity/recover"},{"method":"POST","path":"/gasorder_basic/:identity/assign"},{"method":"POST","path":"/gasorder_basic/:identity/filling"},{"method":"POST","path":"/gasorder_basic/:identity/exception"},{"method":"POST","path":"/gasorder_basic/:identity/cancel"},{"method":"POST","path":"/gasorder_contract"},{"method":"POST","path":"/gasorder_contract/:identity/activate"},{"method":"POST","path":"/gasorder_contract/:identity/renew"},{"method":"POST","path":"/gasorder_contract/:identity/terminate"},{"method":"POST","path":"/gasorder_contract_product"},{"method":"POST","path":"/gasorder_contract_product/:identity/unbind"},{"method":"POST","path":"/delivery_basic"},{"method":"POST","path":"/delivery_account"},{"method":"POST","path":"/staff_account"},{"method":"POST","path":"/staff_credential"},{"method":"POST","path":"/user_account"},{"method":"POST","path":"/user_address"},{"method":"POST","path":"/auth/login"},{"method":"POST","path":"/wallet_apply_cash"},{"method":"POST","path":"/cs_ticket"},{"method":"GET","path":"/gasorder_contract"},{"method":"GET","path":"/gasorder_contract_product"},{"method":"GET","path":"/gasorder_contract_revision"},{"method":"GET","path":"/gasorder_contract/:identity"},{"method":"GET","path":"/gasorder_basic"},{"method":"GET","path":"/gasorder_basic/:identity"},{"method":"GET","path":"/gas_menu"},{"method":"GET","path":"/delivery_basic"},{"method":"GET","path":"/delivery_basic/:identity"},{"method":"GET","path":"/delivery_account"},{"method":"GET","path":"/delivery_account/:identity"},{"method":"GET","path":"/dashboard/overview"},{"method":"GET","path":"/dashboard/reports"},{"method":"GET","path":"/wallet_basic"},{"method":"GET","path":"/wallet_bank"},{"method":"GET","path":"/wallet_record"},{"method":"GET","path":"/wallet_refund"},{"method":"GET","path":"/wallet_payment"},{"method":"GET","path":"/wallet_apply_cash"},{"method":"GET","path":"/staff_account"},{"method":"GET","path":"/staff_account/:identity"},{"method":"GET","path":"/staff_credential"},{"method":"GET","path":"/staff_credential/:identity"},{"method":"GET","path":"/user_account"},{"method":"GET","path":"/user_account/:identity"},{"method":"GET","path":"/user_address"},{"method":"GET","path":"/user_address/:identity"},{"method":"GET","path":"/fin_settlement"},{"method":"GET","path":"/fin_reconciliation"},{"method":"GET","path":"/cs_ticket"},{"method":"GET","path":"/cs_ticket/:identity"},{"method":"GET","path":"/auth/profile"},{"method":"GET","path":"/invitation/qrcode"},{"method":"GET","path":"/product_info"},{"method":"PUT","path":"/delivery_account/:identity"},{"method":"PUT","path":"/delivery_account/:identity/password"},{"method":"PUT","path":"/delivery_basic/:identity"},{"method":"PUT","path":"/staff_account/:identity"},{"method":"PUT","path":"/staff_account/:identity/password"},{"method":"PUT","path":"/staff_credential/:identity"},{"method":"PUT","path":"/user_account/:identity"},{"method":"PUT","path":"/user_account/:identity/password"},{"method":"PUT","path":"/user_address/:identity"},{"method":"PUT","path":"/auth/password"},{"method":"PUT","path":"/gasorder_contract/:identity"},{"method":"PUT","path":"/cs_ticket/:identity"},{"method":"PATCH","path":"/delivery_basic/:identity/status"},{"method":"PATCH","path":"/delivery_account/:identity/status"},{"method":"PATCH","path":"/staff_account/:identity/status"},{"method":"PATCH","path":"/staff_credential/:identity/status"},{"method":"PATCH","path":"/user_account/:identity/status"},{"method":"PATCH","path":"/user_address/:identity/status"},{"method":"PATCH","path":"/cs_ticket/:identity/status"},{"method":"DELETE","path":"/delivery_basic/:identity"},{"method":"DELETE","path":"/delivery_account/:identity"},{"method":"DELETE","path":"/staff_account/:identity"},{"method":"DELETE","path":"/staff_credential/:identity"},{"method":"DELETE","path":"/user_account/:identity"},{"method":"DELETE","path":"/user_address/:identity"},{"method":"DELETE","path":"/cs_ticket/:identity"}]} +{"resources":[{"domain":"delivery","name":"delivery_basic","path":"/delivery_basic","pageKind":"list","mode":"writable"},{"domain":"delivery","name":"delivery_account","path":"/delivery_account","pageKind":"list","mode":"writable"},{"domain":"staff","name":"staff_account","path":"/staff_account","pageKind":"list","mode":"writable"},{"domain":"staff","name":"staff_credential","path":"/staff_credential","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_account","path":"/user_account","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_address","path":"/user_address","pageKind":"list","mode":"writable"},{"domain":"contract","name":"gasorder_contract","path":"/gasorder_contract","pageKind":"list","mode":"managed"},{"domain":"contract","name":"gasorder_contract_product","path":"/gasorder_contract_product","pageKind":"list","mode":"append_only"},{"domain":"contract","name":"gasorder_contract_revision","path":"/gasorder_contract_revision","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_basic","path":"/gasorder_basic","pageKind":"list","mode":"append_only"},{"domain":"contract","name":"product_info","path":"/product_info","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"wallet_basic","path":"/wallet_basic","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"wallet_bank","path":"/wallet_bank","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"payment_order","path":"/payment_order","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"wallet_record","path":"/wallet_record","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"payment_refund","path":"/payment_refund","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"wallet_apply_cash","path":"/wallet_apply_cash","pageKind":"list","mode":"append_only"},{"domain":"finance","name":"fin_settlement","path":"/fin_settlement","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"fin_reconciliation","path":"/fin_reconciliation","pageKind":"list","mode":"readonly"},{"domain":"ticket","name":"cs_ticket","path":"/cs_ticket","pageKind":"list","mode":"writable"}],"routes":[{"method":"POST","path":"/gasorder_basic"},{"method":"POST","path":"/gasorder_basic/:identity/ready"},{"method":"POST","path":"/gasorder_basic/:identity/recover"},{"method":"POST","path":"/gasorder_basic/:identity/assign"},{"method":"POST","path":"/gasorder_basic/:identity/filling"},{"method":"POST","path":"/gasorder_basic/:identity/exception"},{"method":"POST","path":"/gasorder_basic/:identity/cancel"},{"method":"POST","path":"/gasorder_contract"},{"method":"POST","path":"/gasorder_contract/:identity/activate"},{"method":"POST","path":"/gasorder_contract/:identity/renew"},{"method":"POST","path":"/gasorder_contract/:identity/terminate"},{"method":"POST","path":"/gasorder_contract_product"},{"method":"POST","path":"/gasorder_contract_product/:identity/unbind"},{"method":"POST","path":"/delivery_basic"},{"method":"POST","path":"/delivery_account"},{"method":"POST","path":"/staff_account"},{"method":"POST","path":"/staff_credential"},{"method":"POST","path":"/user_account"},{"method":"POST","path":"/user_address"},{"method":"POST","path":"/auth/login"},{"method":"POST","path":"/wallet_apply_cash"},{"method":"POST","path":"/cs_ticket"},{"method":"GET","path":"/gasorder_contract"},{"method":"GET","path":"/gasorder_contract_product"},{"method":"GET","path":"/gasorder_contract_product/:identity"},{"method":"GET","path":"/gasorder_contract_revision"},{"method":"GET","path":"/gasorder_contract_revision/:identity"},{"method":"GET","path":"/gasorder_contract/:identity"},{"method":"GET","path":"/gasorder_basic"},{"method":"GET","path":"/gasorder_basic/:identity"},{"method":"GET","path":"/gas_menu"},{"method":"GET","path":"/wallet_basic"},{"method":"GET","path":"/wallet_basic/:identity"},{"method":"GET","path":"/wallet_bank"},{"method":"GET","path":"/wallet_bank/:identity"},{"method":"GET","path":"/wallet_record"},{"method":"GET","path":"/wallet_record/:identity"},{"method":"GET","path":"/wallet_apply_cash"},{"method":"GET","path":"/wallet_apply_cash/:identity"},{"method":"GET","path":"/delivery_basic"},{"method":"GET","path":"/delivery_basic/:identity"},{"method":"GET","path":"/delivery_account"},{"method":"GET","path":"/delivery_account/:identity"},{"method":"GET","path":"/dashboard/overview"},{"method":"GET","path":"/dashboard/reports"},{"method":"GET","path":"/payment_order"},{"method":"GET","path":"/payment_order/:identity"},{"method":"GET","path":"/payment_refund"},{"method":"GET","path":"/payment_refund/:identity"},{"method":"GET","path":"/product_info"},{"method":"GET","path":"/product_info/:identity"},{"method":"GET","path":"/staff_account"},{"method":"GET","path":"/staff_account/:identity"},{"method":"GET","path":"/staff_credential"},{"method":"GET","path":"/staff_credential/:identity"},{"method":"GET","path":"/user_account"},{"method":"GET","path":"/user_account/:identity"},{"method":"GET","path":"/user_address"},{"method":"GET","path":"/user_address/:identity"},{"method":"GET","path":"/fin_settlement"},{"method":"GET","path":"/fin_settlement/:identity"},{"method":"GET","path":"/fin_reconciliation"},{"method":"GET","path":"/fin_reconciliation/:identity"},{"method":"GET","path":"/cs_ticket"},{"method":"GET","path":"/cs_ticket/:identity"},{"method":"GET","path":"/auth/profile"},{"method":"GET","path":"/invitation/qrcode"},{"method":"PUT","path":"/delivery_account/:identity"},{"method":"PUT","path":"/delivery_account/:identity/password"},{"method":"PUT","path":"/delivery_basic/:identity"},{"method":"PUT","path":"/staff_account/:identity"},{"method":"PUT","path":"/staff_account/:identity/password"},{"method":"PUT","path":"/staff_credential/:identity"},{"method":"PUT","path":"/user_account/:identity"},{"method":"PUT","path":"/user_account/:identity/password"},{"method":"PUT","path":"/user_address/:identity"},{"method":"PUT","path":"/auth/password"},{"method":"PUT","path":"/gasorder_contract/:identity"},{"method":"PUT","path":"/cs_ticket/:identity"},{"method":"PATCH","path":"/delivery_basic/:identity/status"},{"method":"PATCH","path":"/delivery_account/:identity/status"},{"method":"PATCH","path":"/staff_account/:identity/status"},{"method":"PATCH","path":"/staff_credential/:identity/status"},{"method":"PATCH","path":"/user_account/:identity/status"},{"method":"PATCH","path":"/user_address/:identity/status"},{"method":"PATCH","path":"/cs_ticket/:identity/status"},{"method":"DELETE","path":"/delivery_basic/:identity"},{"method":"DELETE","path":"/delivery_account/:identity"},{"method":"DELETE","path":"/staff_account/:identity"},{"method":"DELETE","path":"/staff_credential/:identity"},{"method":"DELETE","path":"/user_account/:identity"},{"method":"DELETE","path":"/user_address/:identity"},{"method":"DELETE","path":"/cs_ticket/:identity"}]} diff --git a/frontend/gas_admin/src/router/routes/modules/platform.ts b/frontend/gas_admin/src/router/routes/modules/platform.ts index f3627d5..bc1aecf 100644 --- a/frontend/gas_admin/src/router/routes/modules/platform.ts +++ b/frontend/gas_admin/src/router/routes/modules/platform.ts @@ -1,41 +1,6 @@ import { DEFAULT_LAYOUT } from '../base'; import type { AppRouteRecordRaw } from '../types'; - -const resourcePage = () => import('@/views/shared/ResourcePage.vue'); - -function child( - domain: string, - path: string, - title: string, - resource: string, - menuCode: string, - meta: Record = {}, -): AppRouteRecordRaw { - return { - path, - name: `${domain}-${path}`, - component: resourcePage, - meta: { title, resource, requiresAuth: true, menuCode, ...meta }, - }; -} - -function group( - path: string, - name: string, - title: string, - icon: string, - order: number, - children: AppRouteRecordRaw[], -): AppRouteRecordRaw { - return { - path: `/${path}`, - name, - component: DEFAULT_LAYOUT, - redirect: `/${path}/${children[0].path}`, - meta: { title, requiresAuth: true, icon, order, menuCode: name }, - children, - }; -} +import { child, group, resourceRecordPage } from './resource-route-builder'; const routes: AppRouteRecordRaw[] = [ { @@ -54,7 +19,11 @@ const routes: AppRouteRecordRaw[] = [ child('delivery', 'accounts', '配送点账户', '/delivery_account', 'delivery_basic', { hideInMenu: true, activeMenu: 'delivery-points' }), ]), group('staff', 'staff', '工作人员管理', 'icon-user-group', 30, [ - child('staff', 'add', '新增工作人员', '/staff_account', 'staff_add', { createMode: true }), + { + ...child('staff', 'add', '新增工作人员', '/staff_account', 'staff_add', { createMode: true }), + component: resourceRecordPage, + meta: { title: '新增工作人员', resource: '/staff_account', requiresAuth: true, menuCode: 'staff_add', createMode: true, recordMode: 'create', listRouteName: 'staff-installers' }, + }, child('staff', 'installers', '安装人员', '/staff_account', 'staff_installer', { staffType: 'installer' }), child('staff', 'delivery', '配送人员', '/staff_account', 'staff_delivery', { staffType: 'delivery' }), child('staff', 'operations', '运维人员', '/staff_account', 'staff_operations', { staffType: 'operations' }), @@ -71,15 +40,19 @@ const routes: AppRouteRecordRaw[] = [ child('contract', 'products-candidates', '合同可选气瓶', '/product_info', 'gasorder_contract', { hideInMenu: true, activeMenu: 'contract-contracts' }), ]), group('gasorder', 'gasorder', '燃气配送订单', 'icon-list', 60, [ - child('gasorder', 'create', '创建订单', '/gasorder_basic', 'gasorder_create', { createMode: true }), + { + ...child('gasorder', 'create', '创建订单', '/gasorder_basic', 'gasorder_create', { createMode: true }), + component: resourceRecordPage, + meta: { title: '创建订单', resource: '/gasorder_basic', requiresAuth: true, menuCode: 'gasorder_create', createMode: true, recordMode: 'create', listRouteName: 'gasorder-orders' }, + }, child('gasorder', 'orders', '配送订单', '/gasorder_basic', 'gasorder_basic'), ]), group('finance', 'finance', '财务管理', 'icon-bar-chart', 70, [ child('finance', 'wallet', '钱包', '/wallet_basic', 'wallet_basic'), child('finance', 'banks', '银行卡', '/wallet_bank', 'wallet_bank'), - child('finance', 'payments', '支付记录', '/wallet_payment', 'wallet_payment'), + child('finance', 'payments', '支付记录', '/payment_order', 'payment_order'), child('finance', 'records', '钱包流水', '/wallet_record', 'wallet_record'), - child('finance', 'refunds', '退款记录', '/wallet_refund', 'wallet_refund'), + child('finance', 'refunds', '退款记录', '/payment_refund', 'payment_refund'), child('finance', 'withdrawals', '提现申请', '/wallet_apply_cash', 'wallet_apply_cash'), child('finance', 'settlements', '财务结算', '/fin_settlement', 'fin_settlement'), child('finance', 'reconciliations', '财务对账', '/fin_reconciliation', 'fin_reconciliation'), diff --git a/frontend/gas_admin/src/router/routes/modules/resource-route-builder.ts b/frontend/gas_admin/src/router/routes/modules/resource-route-builder.ts new file mode 100644 index 0000000..721465e --- /dev/null +++ b/frontend/gas_admin/src/router/routes/modules/resource-route-builder.ts @@ -0,0 +1,94 @@ +/** + * 功能:为气站标准资源构造列表菜单与新建、详情、编辑隐藏路由。 + * 版本: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, + title: string, + resource: string, + menuCode: string, + meta: Record = {}, +): AppRouteRecordRaw { + return { + path, + name: `${domain}-${path}`, + component: resourcePage, + meta: { + title, + resource, + requiresAuth: true, + menuCode, + ...meta, + }, + }; +} + +export function group( + path: string, + name: string, + title: string, + icon: string, + order: number, + children: AppRouteRecordRaw[], +): AppRouteRecordRaw { + return { + path: `/${path}`, + name, + component: DEFAULT_LAYOUT, + redirect: `/${path}/${children[0].path}`, + meta: { title, requiresAuth: true, icon, order, menuCode: name }, + 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/gas_admin/src/router/typings.d.ts b/frontend/gas_admin/src/router/typings.d.ts index 1f85192..606dc60 100644 --- a/frontend/gas_admin/src/router/typings.d.ts +++ b/frontend/gas_admin/src/router/typings.d.ts @@ -5,6 +5,8 @@ declare module 'vue-router' { roles?: string[]; // Controls roles that have access to the page menuCode?: string; // Server-assigned menu domain required by this route staffType?: 'installer' | 'delivery' | 'operations'; + 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/gas_admin/src/views/resource/ContractAttachmentField.vue b/frontend/gas_admin/src/views/resource/ContractAttachmentField.vue new file mode 100644 index 0000000..0d1e8ff --- /dev/null +++ b/frontend/gas_admin/src/views/resource/ContractAttachmentField.vue @@ -0,0 +1,169 @@ + + + + + + diff --git a/frontend/gas_admin/src/views/resource/MenuPermissionTree.vue b/frontend/gas_admin/src/views/resource/MenuPermissionTree.vue new file mode 100644 index 0000000..1c814af --- /dev/null +++ b/frontend/gas_admin/src/views/resource/MenuPermissionTree.vue @@ -0,0 +1,127 @@ + + + + + + diff --git a/frontend/gas_admin/src/views/resource/ResourceAccountSummary.vue b/frontend/gas_admin/src/views/resource/ResourceAccountSummary.vue new file mode 100644 index 0000000..c90382d --- /dev/null +++ b/frontend/gas_admin/src/views/resource/ResourceAccountSummary.vue @@ -0,0 +1,206 @@ + + + + + + diff --git a/frontend/gas_admin/src/views/resource/ResourceActionDialog.vue b/frontend/gas_admin/src/views/resource/ResourceActionDialog.vue new file mode 100644 index 0000000..f098203 --- /dev/null +++ b/frontend/gas_admin/src/views/resource/ResourceActionDialog.vue @@ -0,0 +1,346 @@ + + + + + + diff --git a/frontend/gas_admin/src/views/resource/ResourceDetailContent.vue b/frontend/gas_admin/src/views/resource/ResourceDetailContent.vue new file mode 100644 index 0000000..1e2d8f8 --- /dev/null +++ b/frontend/gas_admin/src/views/resource/ResourceDetailContent.vue @@ -0,0 +1,436 @@ + + + + + + diff --git a/frontend/gas_admin/src/views/resource/ResourceFieldForm.vue b/frontend/gas_admin/src/views/resource/ResourceFieldForm.vue new file mode 100644 index 0000000..e61b311 --- /dev/null +++ b/frontend/gas_admin/src/views/resource/ResourceFieldForm.vue @@ -0,0 +1,381 @@ + + + + + + diff --git a/frontend/gas_admin/src/views/resource/ResourceRecordPage.less b/frontend/gas_admin/src/views/resource/ResourceRecordPage.less new file mode 100644 index 0000000..2b13e25 --- /dev/null +++ b/frontend/gas_admin/src/views/resource/ResourceRecordPage.less @@ -0,0 +1,100 @@ +/* + * 功能:定义标准资源独立页面的布局、卡片和响应式样式。 + * 版本:v1.4.0 + */ +.record-page { + display: grid; + align-content: start; + width: 100%; + min-width: 0; + max-width: 100%; + 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 { + min-width: 0; + max-width: 100%; + 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; +} +.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/gas_admin/src/views/resource/ResourceRecordPage.vue b/frontend/gas_admin/src/views/resource/ResourceRecordPage.vue new file mode 100644 index 0000000..40f18dd --- /dev/null +++ b/frontend/gas_admin/src/views/resource/ResourceRecordPage.vue @@ -0,0 +1,570 @@ + + + + + + diff --git a/frontend/gas_admin/src/views/resource/ResourceWalletSummary.vue b/frontend/gas_admin/src/views/resource/ResourceWalletSummary.vue new file mode 100644 index 0000000..f884315 --- /dev/null +++ b/frontend/gas_admin/src/views/resource/ResourceWalletSummary.vue @@ -0,0 +1,98 @@ + + + + + + diff --git a/frontend/gas_admin/src/views/resource/avatar-upload-cache.ts b/frontend/gas_admin/src/views/resource/avatar-upload-cache.ts new file mode 100644 index 0000000..7648c8c --- /dev/null +++ b/frontend/gas_admin/src/views/resource/avatar-upload-cache.ts @@ -0,0 +1,35 @@ +/** + * 功能:缓存一次资源保存流程中已经完成的头像上传结果。 + * 版本:v1.0.0 + */ + +export type AvatarUpload = (file: File) => Promise<{ uri: string }>; + +/** 创建头像上传缓存;选择新文件或清除头像时由调用方主动重置。 */ +export function createAvatarUploadCache(upload: AvatarUpload) { + let cachedFile: File | undefined; + let cachedURI: Promise | undefined; + + /** 返回当前文件的受控资源地址;同一文件重试时复用首次成功结果。 */ + async function resolve(file: File) { + if (cachedFile !== file || !cachedURI) { + cachedFile = file; + cachedURI = upload(file) + .then((result) => result.uri) + .catch((error) => { + // 上传本身失败时清空缓存,允许用户直接重试。 + if (cachedFile === file) cachedURI = undefined; + throw error; + }); + } + return cachedURI; + } + + /** 清空已上传地址,确保新选择的文件不会错误复用旧头像。 */ + function reset() { + cachedFile = undefined; + cachedURI = undefined; + } + + return { resolve, reset }; +} diff --git a/frontend/gas_admin/src/views/resource/load-resource-record-relations.ts b/frontend/gas_admin/src/views/resource/load-resource-record-relations.ts new file mode 100644 index 0000000..140b1e7 --- /dev/null +++ b/frontend/gas_admin/src/views/resource/load-resource-record-relations.ts @@ -0,0 +1,57 @@ +/** + * 功能:统一加载资源记录页的关系选项和平台角色选项。 + * 版本:v1.0.0 + */ +import { Message } from '@arco-design/web-vue'; +import type { Ref } from 'vue'; +import { platformApi, type PlatformRole } from '@/api/platform'; +import type { ResourceField, ResourceUiDefinition } from '@/api/resources'; +import type { ResourceRow } from '@/api/resource-page-rules'; +import type { ResourceRelations } from './use-resource-relations'; + +type LoaderOptions = { + detailMode: boolean; + definition: ResourceUiDefinition; + formFields: ResourceField[]; + record: ResourceRow; + relations: ResourceRelations; + relationLinkage: { preload: (fields: ResourceField[]) => Promise }; + fieldOptions: Record< + string, + Array<{ label: string; value: string | number }> + >; + roleOptions: Ref; +}; + +/** 加载当前页面实际使用的关系和动态平台角色。 */ +export async function loadResourceRecordRelations(options: LoaderOptions) { + if (options.detailMode) { + await options.relations.preload(options.definition.fields); + await options.relations.ensureValues( + options.definition.fields, + options.record, + ); + } else { + await options.relationLinkage.preload(options.formFields); + } + if ( + !options.definition.fields.some( + (field) => field.key === 'platform_role_code', + ) + ) { + return; + } + try { + const roles = (await platformApi.listRole()).list; + options.fieldOptions.platform_role_code = roles.map((role) => ({ + label: role.name, + value: role.role_code, + })); + options.roleOptions.value = roles.filter( + (role) => !role.is_system && role.status === 1, + ); + } catch (error) { + options.fieldOptions.platform_role_code = []; + Message.warning(`平台角色加载失败:${(error as Error).message}`); + } +} diff --git a/frontend/gas_admin/src/views/resource/resource-record-navigation.ts b/frontend/gas_admin/src/views/resource/resource-record-navigation.ts new file mode 100644 index 0000000..a8331d3 --- /dev/null +++ b/frontend/gas_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-wallet', + '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/gas_admin/src/views/resource/resource-relation-linkage-policy.ts b/frontend/gas_admin/src/views/resource/resource-relation-linkage-policy.ts new file mode 100644 index 0000000..a2844a2 --- /dev/null +++ b/frontend/gas_admin/src/views/resource/resource-relation-linkage-policy.ts @@ -0,0 +1,112 @@ +/** + * 功能:提供父子关联下拉的纯业务判断,避免页面层重复推断组织归属。 + * 版本:v1.0.0 + */ +import type { ResourceRow } from '@/api/resource-page-rules'; + +/** 将表单或关联记录中的标识规范化为空字符串或稳定字符串。 */ +export function relationIdentity(value: unknown) { + return value == null ? '' : String(value).trim(); +} + +/** 读取子选项记录声明的父级标识。 */ +export function optionParentIdentity( + option: ResourceRow | undefined, + optionParentKey: string, +) { + return option ? relationIdentity(option[optionParentKey]) : ''; +} + +/** + * 仅在已取得子选项记录且父子关系明确不兼容时返回 true。 + * 父级为空时只允许平台主管子项;父级有值时只允许其直属子项。 + */ +export function shouldClearLinkedOption( + parentIdentity: string, + option: ResourceRow | undefined, + optionParentKey: string, +) { + if (!option) return false; + const optionParent = optionParentIdentity(option, optionParentKey); + return parentIdentity ? optionParent !== parentIdentity : Boolean(optionParent); +} + +/** 返回父子组合的保存前提示;无法取得子选项时交由后端最终校验。 */ +export function linkedRelationValidationMessage( + parentIdentity: string, + childIdentity: string, + option: ResourceRow | undefined, + optionParentKey: string, +) { + if (!childIdentity || !option) return ''; + const optionParent = optionParentIdentity(option, optionParentKey); + if (parentIdentity && !optionParent) { + return '平台主管配送点不能与所属气站同时选择'; + } + if (parentIdentity && optionParent !== parentIdentity) { + return '所选配送点不属于所属气站,请重新选择'; + } + if (!parentIdentity && optionParent) { + return '所选配送点已有所属气站,请确认所属气站'; + } + return ''; +} + +/** 根据当前父级生成服务端筛选条件;父级为空时保留全量可选范围。 */ +export function linkedRelationFilters( + parentIdentity: string, + filterKey: string, +) { + return parentIdentity ? { [filterKey]: parentIdentity } : undefined; +} + +/** 为服务人员关系生成精确组织范围;空组织使用显式未分配条件,避免返回其他组织人员。 */ +export function staffOrganizationFilters( + gasIdentity: string, + deliveryIdentity: string, +) { + return { + ...(gasIdentity + ? { gas_basic_identities: gasIdentity } + : { gas_basic_unassigned: '1' }), + ...(deliveryIdentity + ? { delivery_basic_identities: deliveryIdentity } + : { delivery_basic_unassigned: '1' }), + }; +} + +/** 校验服务人员记录与当前气站、配送点完全一致,空字符串代表可以提交。 */ +export function staffOrganizationValidationMessage( + gasIdentity: string, + deliveryIdentity: string, + staffIdentity: string, + option: ResourceRow | undefined, +) { + if (!staffIdentity) return ''; + if (!option) return '无法确认所选服务人员的组织归属,请重新选择'; + if (optionParentIdentity(option, 'gas_basic_identity') !== gasIdentity) { + return '所选服务人员不属于当前气站,请重新选择'; + } + if ( + optionParentIdentity(option, 'delivery_basic_identity') !== + deliveryIdentity + ) { + return '所选服务人员不属于当前配送点,请重新选择'; + } + return ''; +} + +/** 为同一关联资源生成递增版本号,用于识别并丢弃过期响应。 */ +export function createRelationRequestVersionGuard() { + const versions = new Map(); + return { + begin(resource: string) { + const version = (versions.get(resource) ?? 0) + 1; + versions.set(resource, version); + return version; + }, + isLatest(resource: string, version: number) { + return versions.get(resource) === version; + }, + }; +} diff --git a/frontend/gas_admin/src/views/resource/use-contract-attachment.ts b/frontend/gas_admin/src/views/resource/use-contract-attachment.ts new file mode 100644 index 0000000..820f778 --- /dev/null +++ b/frontend/gas_admin/src/views/resource/use-contract-attachment.ts @@ -0,0 +1,149 @@ +/** + * 功能:管理合同附件的本地选择、保存前上传、失败清理和鉴权预览状态。 + * 版本:v1.0.0 + */ +import { Message } from '@arco-design/web-vue'; +import { computed, ref } from 'vue'; +import { + contractAttachmentApi, + type ContractAttachmentMetadata, + type ContractAttachmentUploadReply, +} from '@/api/contract-attachment'; + +const maxContractAttachmentSize = 10 * 1024 * 1024; + +/** 提供单合同、单 PDF 附件的页面状态与保存编排。 */ +export function useContractAttachment() { + const selectedFile = ref(); + const metadata = ref(); + const removed = ref(false); + let uploaded: ContractAttachmentUploadReply | undefined; + let uploadedMarker = ''; + + const hasExisting = computed( + () => Boolean(metadata.value?.has_file) && !removed.value, + ); + const available = computed( + () => Boolean(metadata.value?.available) && !removed.value, + ); + const requiresReupload = computed( + () => Boolean(metadata.value?.requires_reupload) && !removed.value, + ); + + /** 从合同详情恢复附件存在性和并发版本,不读取内部 URI。 */ + function load(next?: ContractAttachmentMetadata) { + selectedFile.value = undefined; + metadata.value = next; + removed.value = false; + uploaded = undefined; + uploadedMarker = ''; + } + + /** 校验并保存用户选择的本地 PDF,真正上传发生在点击保存后。 */ + function select(file: File) { + if ( + file.size <= 0 || + file.size > maxContractAttachmentSize || + file.type !== 'application/pdf' || + !file.name.toLowerCase().endsWith('.pdf') + ) { + Message.warning('请选择不超过 10 MiB 的 PDF 文件'); + return false; + } + selectedFile.value = file; + removed.value = false; + uploaded = undefined; + uploadedMarker = ''; + return true; + } + + /** 标记移除已绑定附件;实际删除只在合同保存成功后执行。 */ + function remove() { + selectedFile.value = undefined; + uploaded = undefined; + uploadedMarker = ''; + removed.value = true; + } + + /** 取消尚未保存的新选择,不影响已绑定附件。 */ + function clearSelection() { + selectedFile.value = undefined; + uploaded = undefined; + uploadedMarker = ''; + } + + /** 保存前上传新文件并向合同载荷写入签名收据及并发版本。 */ + async function applyToPayload(payload: Record) { + delete payload.file_uri; + if (selectedFile.value) { + const currentMarker = fileMarker(selectedFile.value); + if (!uploaded || uploadedMarker !== currentMarker) { + uploaded = await contractAttachmentApi.upload(selectedFile.value); + uploadedMarker = currentMarker; + } + payload.attachment_receipt = uploaded.receipt; + if (metadata.value) payload.attachment_version = metadata.value.version; + return; + } + if (removed.value && metadata.value) { + payload.remove_attachment = true; + payload.attachment_version = metadata.value.version; + } + } + + /** 合同保存失败时清理本轮临时上传,同时保留本地文件以便重试。 */ + async function rollbackUpload() { + const pending = uploaded; + uploaded = undefined; + uploadedMarker = ''; + if (!pending) return; + try { + await contractAttachmentApi.cleanup(pending.cleanup_token); + } catch { + // 服务端已记录最终清理错误;页面保留本地文件供用户再次保存。 + } + } + + /** 在新标签页打开鉴权取得的 PDF Blob。 */ + async function preview(identity: string) { + const blob = await contractAttachmentApi.load(identity); + const objectURL = URL.createObjectURL(blob); + const opened = window.open(objectURL, '_blank', 'noopener,noreferrer'); + if (!opened) { + URL.revokeObjectURL(objectURL); + throw new Error('浏览器阻止了合同附件预览窗口'); + } + window.setTimeout(() => URL.revokeObjectURL(objectURL), 60_000); + } + + /** 返回未保存变更标识,供离开页面前确认。 */ + function marker() { + return selectedFile.value + ? `select:${fileMarker(selectedFile.value)}` + : removed.value + ? 'remove' + : 'unchanged'; + } + + return { + selectedFile, + metadata, + removed, + hasExisting, + available, + requiresReupload, + load, + select, + remove, + clearSelection, + applyToPayload, + rollbackUpload, + preview, + marker, + }; +} + +/** 生成稳定的本地文件选择标识。 */ +function fileMarker(file: File) { + return `${file.name}:${file.size}:${file.lastModified}`; +} diff --git a/frontend/gas_admin/src/views/resource/use-resource-avatar.ts b/frontend/gas_admin/src/views/resource/use-resource-avatar.ts new file mode 100644 index 0000000..2acedb2 --- /dev/null +++ b/frontend/gas_admin/src/views/resource/use-resource-avatar.ts @@ -0,0 +1,95 @@ +/** + * 功能:管理资源页头像的鉴权读取、本地预览、清除和保存前上传。 + * 版本: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'; +import { createAvatarUploadCache } from './avatar-upload-cache'; + +export function useResourceAvatar() { + const url = ref(DEFAULT_USER_AVATAR); + const file = ref(); + const cleared = ref(false); + const canClear = ref(false); + const uploadCache = createAvatarUploadCache(avatarApi.upload); + let objectURL = ''; + + function revoke() { + if (objectURL) URL.revokeObjectURL(objectURL); + objectURL = ''; + } + + /** 加载现有受保护头像,记录没有头像时继续使用默认图。 */ + async function load(resource: string, identity: string) { + revoke(); + uploadCache.reset(); + 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(); + uploadCache.reset(); + objectURL = URL.createObjectURL(next); + url.value = objectURL; + file.value = next; + cleared.value = false; + canClear.value = true; + } + + /** 标记清除头像,真正写入空值发生在保存资料时。 */ + function clear() { + revoke(); + uploadCache.reset(); + 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 uploadCache.resolve(file.value); + 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/gas_admin/src/views/resource/use-resource-relation-linkage.ts b/frontend/gas_admin/src/views/resource/use-resource-relation-linkage.ts new file mode 100644 index 0000000..5208939 --- /dev/null +++ b/frontend/gas_admin/src/views/resource/use-resource-relation-linkage.ts @@ -0,0 +1,534 @@ +/** + * 功能:协调资源表单中显式启用的多组父子关联下拉、搜索和保存前校验。 + * 版本:v1.2.0 + */ +import { Message } from '@arco-design/web-vue'; +import { ref } from 'vue'; +import { dynamicRelationResource, type ResourceField } from '@/api/resources'; +import { + linkedRelationFilters, + linkedRelationValidationMessage, + optionParentIdentity, + relationIdentity, + shouldClearLinkedOption, + staffOrganizationFilters, + staffOrganizationValidationMessage, +} from './resource-relation-linkage-policy'; +import type { ResourceRelations } from './use-resource-relations'; + +type ActiveLinkage = { + childField: ResourceField; + parentField?: ResourceField; +}; + +export function useResourceRelationLinkage( + form: Record, + relations: ResourceRelations, +) { + let active: ActiveLinkage[] = []; + const configuredFields = ref([]); + let organizationStaffField: ResourceField | undefined; + let initialized = false; + + function findOption(resource: string, identity: string) { + return (relations.options[resource] ?? []).find( + (row) => relationIdentity(row.identity) === identity, + ); + } + + /** 收集页面内全部显式联动,允许一个父字段同时驱动多个业务子项。 */ + function configure(fields: ResourceField[]) { + configuredFields.value = fields; + active = fields + .filter((field) => field.relationLinkage) + .map((childField) => ({ + childField, + parentField: fields.find( + (field) => field.key === childField.relationLinkage?.parentKey, + ), + })); + organizationStaffField = fields.find( + (field) => field.staffRelation?.organizationScoped, + ); + } + + /** 为动态关系字段补入当前父值对应的实际资源。 */ + function resolvedField(field: ResourceField) { + const resource = dynamicRelationResource(field, form); + return resource && resource !== field.relation + ? { ...field, relation: resource } + : field; + } + + /** 读取动态关系所依赖的业务上下文选项,例如当前选择的配送合同。 */ + function dynamicContextOption(field: ResourceField) { + const contextKey = field.dynamicRelation?.contextParentKey; + if (!contextKey) return undefined; + const contextField = configuredFields.value.find( + (item) => item.key === contextKey, + ); + const contextIdentity = relationIdentity(form[contextKey]); + if (!contextField?.relation || !contextIdentity) return undefined; + return findOption(contextField.relation, contextIdentity); + } + + /** 将上下文选项转换为动态候选接口需要的精确筛选参数。 */ + function dynamicRequest(field: ResourceField) { + const dynamic = field.dynamicRelation; + const type = relationIdentity(dynamic ? form[dynamic.parentKey] : ''); + const option = dynamicContextOption(field); + const filters: Record = {}; + for (const filter of dynamic?.scopeFilters?.[type] ?? []) { + const value = relationIdentity(option?.[filter.optionKey]); + if (value) { + filters[filter.filterKey] = value; + } else if (filter.emptyFilterKey) { + filters[filter.emptyFilterKey] = filter.emptyFilterValue ?? 'true'; + } + } + const identity = relationIdentity(form[field.key]); + return { + filters, + preserveIdentities: identity ? [identity] : [], + }; + } + + /** 加载由父字段类型决定的关系候选。 */ + async function reloadDynamicOptions(field: ResourceField) { + const resolved = resolvedField(field); + if (!resolved.relation) return; + const dynamic = field.dynamicRelation; + const type = relationIdentity(dynamic ? form[dynamic.parentKey] : ''); + const contextIdentity = relationIdentity( + dynamic?.contextParentKey ? form[dynamic.contextParentKey] : '', + ); + const contextOption = dynamicContextOption(field); + if (dynamic?.contextParentKey && (!contextIdentity || !contextOption)) { + form[field.key] = ''; + relations.clear(resolved.relation); + return; + } + relations.cancelSearch(resolved.relation); + const fixedIdentityKey = dynamic?.fixedIdentityKeys?.[type]; + if (fixedIdentityKey) { + const fixedIdentity = relationIdentity(contextOption?.[fixedIdentityKey]); + form[field.key] = ''; + relations.clear(resolved.relation); + if (!fixedIdentity) return; + await relations.ensure(resolved.relation, fixedIdentity); + if ( + relationIdentity(form[dynamic.parentKey]) === type && + relationIdentity(form[dynamic.contextParentKey as string]) === + contextIdentity + ) { + form[field.key] = fixedIdentity; + } + return; + } + await relations.loadField(resolved, '', dynamicRequest(field)); + } + + function staffRequest() { + const staffIdentity = relationIdentity( + organizationStaffField ? form[organizationStaffField.key] : '', + ); + return { + filters: staffOrganizationFilters( + relationIdentity(form.gas_basic_identity), + relationIdentity(form.delivery_basic_identity), + ), + preserveIdentities: staffIdentity ? [staffIdentity] : [], + }; + } + + async function reloadStaffOptions() { + const field = organizationStaffField; + if (!field?.relation) return; + relations.cancelSearch(field.relation); + await relations.loadField(field, '', staffRequest()); + } + + /** 组织变化后清理不再匹配的服务人员,并刷新精确范围选项。 */ + async function synchronizeStaff() { + const field = organizationStaffField; + if (!field?.relation) return; + const staffIdentity = relationIdentity(form[field.key]); + if (staffIdentity) { + const option = + findOption(field.relation, staffIdentity) ?? + (await relations.ensure(field.relation, staffIdentity)); + const warning = staffOrganizationValidationMessage( + relationIdentity(form.gas_basic_identity), + relationIdentity(form.delivery_basic_identity), + staffIdentity, + option, + ); + if (warning && relationIdentity(form[field.key]) === staffIdentity) { + form[field.key] = ''; + Message.info(`${warning},已清空原服务人员`); + } + } + await reloadStaffOptions(); + } + + function childRequest(linkageItem: ActiveLinkage) { + const linkage = linkageItem.childField.relationLinkage; + if (!linkage) return {}; + const parentIdentity = relationIdentity(form[linkage.parentKey]); + const rawChildValue = form[linkageItem.childField.key]; + const childIdentities = ( + Array.isArray(rawChildValue) ? rawChildValue : [rawChildValue] + ) + .map(relationIdentity) + .filter(Boolean); + return { + filters: linkedRelationFilters(parentIdentity, linkage.filterKey), + preserveIdentities: childIdentities, + }; + } + + /** 加载一组子选项,并在配置要求时自动选中默认业务记录。 */ + async function reloadChildOptions(linkageItem: ActiveLinkage) { + const childField = linkageItem.childField; + const linkage = childField.relationLinkage; + const resource = childField.relation; + if (!linkage || !resource) return; + const parentIdentity = relationIdentity(form[linkage.parentKey]); + if (linkage.requiresParent && !parentIdentity) { + relations.clear(resource); + return; + } + relations.cancelSearch(resource); + await relations.loadField(childField, '', childRequest(linkageItem)); + if ( + !relationIdentity(form[childField.key]) && + childField.relationAutoSelectKey + ) { + const defaultOption = (relations.options[resource] ?? []).find((option) => + Boolean(option[childField.relationAutoSelectKey as string]), + ); + if (defaultOption) { + form[childField.key] = relationIdentity(defaultOption.identity); + } + } + } + + /** 预加载普通关联项,并分别按父级条件加载所有启用联动的子选项。 */ + async function preload(fields: ResourceField[]) { + initialized = false; + configure(fields); + const linkedChildKeys = new Set(active.map((item) => item.childField.key)); + await relations.preload( + fields.filter( + (field) => + !linkedChildKeys.has(field.key) && + field.key !== organizationStaffField?.key && + !field.dynamicRelation, + ), + ); + await relations.ensureValues( + fields.filter( + (field) => !field.relationStrictFilter && !field.dynamicRelation, + ), + form, + ); + await Promise.all(active.map((item) => reloadChildOptions(item))); + await Promise.all( + fields + .filter((field) => field.dynamicRelation) + .map((field) => reloadDynamicOptions(field)), + ); + await reloadStaffOptions(); + clearInvalidStrictSelections(); + initialized = true; + + const warning = validationMessage(); + if (warning) Message.warning(`当前组织归属需要确认:${warning}`); + } + + /** 严格候选中不存在当前值时清空,避免补载详情绕过业务筛选。 */ + function clearInvalidStrictSelections() { + for (const field of configuredFields.value) { + if (!field.relationStrictFilter || !field.relation) continue; + const rawValue = form[field.key]; + const identities = (Array.isArray(rawValue) ? rawValue : [rawValue]) + .map(relationIdentity) + .filter(Boolean); + if (identities.length === 0) continue; + const valid = new Set( + (relations.options[field.relation] ?? []).map((option) => + relationIdentity(option.identity), + ), + ); + if (identities.some((identity) => !valid.has(identity))) { + form[field.key] = field.type === 'identity-list' ? [] : ''; + for (const linkageItem of active.filter( + (item) => item.childField.relationLinkage?.parentKey === field.key, + )) { + const childField = linkageItem.childField; + form[childField.key] = childField.type === 'identity-list' ? [] : ''; + relations.clear(childField.relation); + } + Message.info( + field.relationInvalidMessage ?? '所选关联数据已失效,已清空', + ); + } + } + } + + async function handleParentChange( + linkageItem: ActiveLinkage, + parentIdentity: string, + ) { + const childField = linkageItem.childField; + const linkage = childField.relationLinkage; + if (!linkage || !childField.relation) return; + const childIdentity = relationIdentity(form[childField.key]); + if (linkage.filterOnly) relations.clear(childField.relation); + if (linkage.filterOnly && childIdentity) { + form[childField.key] = childField.type === 'identity-list' ? [] : ''; + Message.info( + linkage.parentChangeMessage ?? '上级数据已变更,请重新选择关联数据', + ); + } + if (childIdentity && !linkage.filterOnly) { + const option = + findOption(childField.relation, childIdentity) ?? + (await relations.ensure(childField.relation, childIdentity)); + if ( + relationIdentity(form[linkage.parentKey]) !== parentIdentity || + relationIdentity(form[childField.key]) !== childIdentity + ) { + return; + } + if ( + shouldClearLinkedOption(parentIdentity, option, linkage.optionParentKey) + ) { + form[childField.key] = ''; + Message.info( + linkage.parentChangeMessage ?? '所属气站已变更,请重新选择配送点', + ); + } + } + await reloadChildOptions(linkageItem); + } + + async function handleChildChange( + linkageItem: ActiveLinkage, + childIdentity: string, + ) { + const childField = linkageItem.childField; + const linkage = childField.relationLinkage; + if ( + !linkage || + !childField.relation || + !childIdentity || + linkage.filterOnly + ) + return; + const option = + findOption(childField.relation, childIdentity) ?? + (await relations.ensure(childField.relation, childIdentity)); + if (!option || relationIdentity(form[childField.key]) !== childIdentity) + return; + if (linkage.backfillParent) { + const nextParent = optionParentIdentity(option, linkage.optionParentKey); + const currentParent = relationIdentity(form[linkage.parentKey]); + if (nextParent && linkageItem.parentField?.relation) { + await relations.ensure(linkageItem.parentField.relation, nextParent); + if (relationIdentity(form[childField.key]) !== childIdentity) return; + } + if (currentParent !== nextParent) { + form[linkage.parentKey] = nextParent; + if (!nextParent && currentParent) { + Message.info('平台主管配送点不隶属于气站,已清空所属气站'); + } + } + } + await reloadChildOptions(linkageItem); + } + + /** 响应用户主动修改关联字段;初始化回显不会触发自动清理。 */ + async function change(field: ResourceField, value: unknown) { + if (!initialized) return; + const identity = relationIdentity(value); + const dynamicChildren = configuredFields.value.filter( + (item) => item.dynamicRelation?.parentKey === field.key, + ); + for (const childField of dynamicChildren) { + const previousIdentity = relationIdentity(form[childField.key]); + form[childField.key] = ''; + await reloadDynamicOptions(childField); + if (previousIdentity) { + Message.info( + childField.dynamicRelation?.parentChangeMessage ?? + '关联类型已变更,请重新选择关联数据', + ); + } + } + const parentLinkages = active.filter( + (item) => item.childField.relationLinkage?.parentKey === field.key, + ); + await Promise.all( + parentLinkages.map((item) => handleParentChange(item, identity)), + ); + const contextDynamicChildren = configuredFields.value.filter( + (item) => item.dynamicRelation?.contextParentKey === field.key, + ); + for (const childField of contextDynamicChildren) { + const previousIdentity = relationIdentity(form[childField.key]); + form[childField.key] = ''; + await reloadDynamicOptions(childField); + if (previousIdentity) { + Message.info( + childField.dynamicRelation?.contextChangeMessage ?? + '业务上下文已变更,已重新匹配关联数据', + ); + } + } + const childLinkage = active.find( + (item) => item.childField.key === field.key, + ); + if (childLinkage) await handleChildChange(childLinkage, identity); + if ( + field.key === 'gas_basic_identity' || + field.key === 'delivery_basic_identity' || + field.key === organizationStaffField?.key + ) { + await synchronizeStaff(); + } + } + + /** 搜索联动子项时始终保留对应父级过滤和当前选项。 */ + function search(field: ResourceField, keyword: string) { + const resolved = resolvedField(field); + if (!resolved.relation) return; + const dynamic = field.dynamicRelation; + if (dynamic?.contextParentKey) { + const type = relationIdentity(form[dynamic.parentKey]); + if (!dynamicContextOption(field)) { + relations.clear(resolved.relation); + return; + } + // 合同直接确定的主体为只读值,不提供跨合同搜索入口。 + if (dynamic.fixedIdentityKeys?.[type]) return; + relations.searchField(resolved, keyword, dynamicRequest(field)); + return; + } + const linkageItem = active.find( + (item) => item.childField.key === field.key, + ); + if (linkageItem) { + const linkage = linkageItem.childField.relationLinkage; + if ( + linkage?.requiresParent && + !relationIdentity(form[linkage.parentKey]) + ) { + relations.clear(resolved.relation); + return; + } + relations.searchField(resolved, keyword, childRequest(linkageItem)); + return; + } + if (field.key === organizationStaffField?.key) { + relations.searchField(resolved, keyword, staffRequest()); + return; + } + relations.searchField(resolved, keyword); + } + + /** 返回保存前的父子关系错误,空字符串代表当前组合可提交。 */ + function validationMessage() { + for (const field of configuredFields.value.filter( + (item) => item.dynamicRelation?.contextParentKey, + )) { + const dynamic = field.dynamicRelation; + if (!dynamic) continue; + const type = relationIdentity(form[dynamic.parentKey]); + const contextOption = dynamicContextOption(field); + if (!type || !contextOption) continue; + const fixedIdentityKey = dynamic.fixedIdentityKeys?.[type]; + if (fixedIdentityKey) { + const expected = relationIdentity(contextOption[fixedIdentityKey]); + if (!expected) { + return ( + dynamic.missingIdentityMessages?.[type] ?? + '当前合同缺少该类型的创建方' + ); + } + if (relationIdentity(form[field.key]) !== expected) { + return '创建方与当前配送合同不一致,请重新选择'; + } + } + } + for (const linkageItem of active) { + const childField = linkageItem.childField; + const linkage = childField.relationLinkage; + if (!linkage || !childField.relation || linkage.filterOnly) continue; + const parentIdentity = relationIdentity(form[linkage.parentKey]); + const childIdentity = relationIdentity(form[childField.key]); + const linkedMessage = linkedRelationValidationMessage( + parentIdentity, + childIdentity, + findOption(childField.relation, childIdentity), + linkage.optionParentKey, + ); + if (linkedMessage) return linkedMessage; + } + const staffField = organizationStaffField; + if (!staffField?.relation) return ''; + const staffIdentity = relationIdentity(form[staffField.key]); + return staffOrganizationValidationMessage( + relationIdentity(form.gas_basic_identity), + relationIdentity(form.delivery_basic_identity), + staffIdentity, + findOption(staffField.relation, staffIdentity), + ); + } + + /** 必填联动候选为空时返回业务引导,用于阻止提交无效表单。 */ + function unavailableMessage() { + for (const field of configuredFields.value) { + const linkage = field.relationLinkage; + const resource = dynamicRelationResource(field, form); + const dynamic = field.dynamicRelation; + if (dynamic?.contextParentKey) { + if (!relationIdentity(form[dynamic.contextParentKey])) continue; + const type = relationIdentity(form[dynamic.parentKey]); + const contextOption = dynamicContextOption(field); + const fixedIdentityKey = dynamic.fixedIdentityKeys?.[type]; + if ( + type && + contextOption && + fixedIdentityKey && + !relationIdentity(contextOption[fixedIdentityKey]) + ) { + return ( + dynamic.missingIdentityMessages?.[type] ?? + '当前合同缺少该类型的创建方' + ); + } + } + if ( + !field.required || + !resource || + !field.relationEmptyText || + relations.loading[resource] + ) { + continue; + } + if ( + linkage?.requiresParent && + !relationIdentity(form[linkage.parentKey]) + ) { + continue; + } + if ((relations.options[resource] ?? []).length === 0) { + return field.relationEmptyText; + } + } + return ''; + } + + return { preload, change, search, validationMessage, unavailableMessage }; +} diff --git a/frontend/gas_admin/src/views/resource/use-resource-relations.ts b/frontend/gas_admin/src/views/resource/use-resource-relations.ts new file mode 100644 index 0000000..bc6a8c4 --- /dev/null +++ b/frontend/gas_admin/src/views/resource/use-resource-relations.ts @@ -0,0 +1,218 @@ +/** + * 功能:为资源表单和详情页加载、缓存并搜索关联资源选项。 + * 版本:v1.0.0 + */ +import { Message } from '@arco-design/web-vue'; +import { onBeforeUnmount, reactive } from 'vue'; +import { resourceApi } from '@/api/resource'; +import { + staffRelationFilters, + type StaffRelationContext, +} from '@/api/resource-staff-relation'; +import type { ResourceField } from '@/api/resources'; +import type { ResourceRow } from '@/api/resource-page-rules'; +import { fieldRelationResource } from '@/api/resource-display'; +import { createRelationRequestVersionGuard } from './resource-relation-linkage-policy'; + +export type ResourceRelationLoadOptions = { + filters?: Record; + preserveIdentities?: string[]; +}; + +export function useResourceRelations( + context: () => StaffRelationContext = () => ({}), +) { + const options = reactive>({}); + const loading = reactive>({}); + const timers = new Map>(); + const requestGuard = createRelationRequestVersionGuard(); + + /** 合并需要保留的当前选项,搜索和筛选时不让下拉框退回显示原始标识。 */ + function mergePreserved( + resource: string, + rows: ResourceRow[], + identities: string[] = [], + ) { + const merged = [...rows]; + const seen = new Set(rows.map((row) => String(row.identity ?? ''))); + for (const identity of identities.filter(Boolean)) { + if (seen.has(identity)) continue; + const current = (options[resource] ?? []).find( + (row) => String(row.identity ?? '') === identity, + ); + if (current) { + merged.push(current); + seen.add(identity); + } + } + return merged; + } + + /** 加载一类关联资源,并丢弃已经过期的搜索或筛选响应。 */ + async function load( + resource: string, + keyword = '', + request: ResourceRelationLoadOptions = {}, + ) { + const version = requestGuard.begin(resource); + loading[resource] = true; + try { + const filters: Record = { + ...(request.filters ?? {}), + ...(keyword ? { keyword } : {}), + }; + const rows = ( + await resourceApi.list(resource, 1, 100, filters) + ).list; + if (!requestGuard.isLatest(resource, version)) return; + options[resource] = mergePreserved( + resource, + rows, + request.preserveIdentities, + ); + } catch (error) { + if (!requestGuard.isLatest(resource, version)) return; + Message.error(`关联数据加载失败:${(error as Error).message}`); + } finally { + if (requestGuard.isLatest(resource, version)) { + loading[resource] = false; + } + } + } + + /** 按字段的显式关系策略加载选项,工作人员字段不得依赖全局默认值。 */ + function loadField( + field: ResourceField, + keyword = '', + request: ResourceRelationLoadOptions = {}, + ) { + if (!field.relation) return Promise.resolve(); + return load(field.relation, keyword, { + ...request, + filters: { + ...(field.relationFilters ?? {}), + ...staffRelationFilters(field, context()), + ...(request.filters ?? {}), + }, + }); + } + + /** 按唯一标识补载当前选项,解决编辑记录不在列表前 100 条时的回显。 */ + async function ensure(resource: string, identity: string) { + if (!identity) return undefined; + const current = (options[resource] ?? []).find( + (row) => String(row.identity ?? '') === identity, + ); + if (current) return current; + try { + const row = await resourceApi.detail(resource, identity); + const resolved = (options[resource] ?? []).find( + (item) => String(item.identity ?? '') === identity, + ); + if (resolved) return resolved; + options[resource] = [...(options[resource] ?? []), row]; + return row; + } catch (error) { + Message.error(`关联数据加载失败:${(error as Error).message}`); + return undefined; + } + } + + /** 预加载当前页面实际使用的全部关系字段。 */ + async function preload(fields: ResourceField[]) { + const fieldsByResource = new Map(); + for (const field of fields) { + if (field.relation && !fieldsByResource.has(field.relation)) { + fieldsByResource.set(field.relation, field); + } + } + await Promise.all( + [...fieldsByResource.values()].map((field) => loadField(field)), + ); + } + + /** 补载表单或详情中已经保存的关系值,避免分页和筛选导致回显裸标识。 */ + async function ensureValues(fields: ResourceField[], values: ResourceRow) { + const requests = fields.flatMap((field) => { + const resource = fieldRelationResource(field, values); + if (!resource) return []; + const value = values[field.key]; + const identities = Array.isArray(value) ? value : [value]; + return identities + .map((identity) => String(identity ?? '')) + .filter(Boolean) + .map((identity) => ensure(resource, identity)); + }); + await Promise.all(requests); + } + + /** 对远程关系选项进行防抖搜索。 */ + function search( + resource: string | undefined, + keyword: string, + request: ResourceRelationLoadOptions = {}, + ) { + if (!resource) return; + cancelSearch(resource); + timers.set( + resource, + setTimeout(() => { + timers.delete(resource); + void load(resource, keyword.trim(), request); + }, 250), + ); + } + + /** 按字段的显式关系策略执行防抖搜索。 */ + function searchField( + field: ResourceField, + keyword: string, + request: ResourceRelationLoadOptions = {}, + ) { + if (!field.relation) return; + search(field.relation, keyword, { + ...request, + filters: { + ...(field.relationFilters ?? {}), + ...staffRelationFilters(field, context()), + ...(request.filters ?? {}), + }, + }); + } + + /** 取消关联资源尚未触发的防抖搜索。 */ + function cancelSearch(resource: string) { + const timer = timers.get(resource); + if (timer) clearTimeout(timer); + timers.delete(resource); + } + + /** 清空依赖父级但当前尚无有效父级的候选项。 */ + function clear(resource: string | undefined) { + if (!resource) return; + cancelSearch(resource); + requestGuard.begin(resource); + options[resource] = []; + loading[resource] = false; + } + + onBeforeUnmount(() => { + for (const resource of timers.keys()) cancelSearch(resource); + }); + + return { + options, + loading, + load, + loadField, + ensure, + ensureValues, + preload, + search, + searchField, + cancelSearch, + clear, + }; +} + +export type ResourceRelations = ReturnType; diff --git a/frontend/gas_admin/src/views/resource/use-staff-credential-owner-guard.ts b/frontend/gas_admin/src/views/resource/use-staff-credential-owner-guard.ts new file mode 100644 index 0000000..d66e1e8 --- /dev/null +++ b/frontend/gas_admin/src/views/resource/use-staff-credential-owner-guard.ts @@ -0,0 +1,96 @@ +/** + * 功能:校验工作人员资质页面的所有者、来源角色和访问状态。 + * 版本:v1.0.0 + */ +import { ref } from 'vue'; +import type { LocationQueryValue } from 'vue-router'; +import { ApiError } from '@/api/http'; +import { resourceApi } from '@/api/resource'; +import { + credentialOwnerValidationMessage, + normalizeStaffRole, + resolveCredentialStaffRole, + type StaffRoleCode, +} from '@/api/resource-staff-relation'; +import type { RecordPageMode, ResourceRow } from '@/api/resource-page-rules'; +import { useResourceRelations } from './use-resource-relations'; + +type GuardOptions = { + definitionName: () => string; + mode: () => RecordPageMode; + record: () => ResourceRow; + ownerIdentity: () => string; + explicitStaffType: () => LocationQueryValue | LocationQueryValue[]; + routeStaffType: () => unknown; + returnPath: () => string; +}; + +type GuardResult = + | { ok: true } + | { ok: false; status: '403' | '404'; message: string }; + +/** 创建资质所有者守卫,并向关系加载器提供已验证的角色上下文。 */ +export function useStaffCredentialOwnerGuard(options: GuardOptions) { + const resolvedStaffType = ref( + normalizeStaffRole(options.routeStaffType()), + ); + const relations = useResourceRelations(() => ({ + staffType: resolvedStaffType.value, + })); + + /** 每次页面重新初始化时重置路由角色,避免复用上一次记录的上下文。 */ + function reset() { + resolvedStaffType.value = normalizeStaffRole(options.routeStaffType()); + } + + async function prepare(): Promise { + if (options.definitionName() !== 'staff_credential') return { ok: true }; + const ownerIdentity = options.ownerIdentity(); + if (options.mode() === 'create') { + const resolved = resolveCredentialStaffRole( + options.explicitStaffType(), + options.returnPath(), + ); + if (resolved.error) { + return { ok: false, status: '403', message: resolved.error }; + } + resolvedStaffType.value = resolved.role; + } + if (!ownerIdentity) { + return { + ok: false, + status: '403', + message: '缺少工作人员唯一标识,请从工作人员页面进入', + }; + } + let owner: ResourceRow; + try { + owner = await resourceApi.detail( + '/staff_account', + ownerIdentity, + ); + } catch (error) { + return { + ok: false, + status: + error instanceof ApiError && error.status === 404 ? '404' : '403', + message: `无法确认资质所属工作人员:${(error as Error).message}`, + }; + } + if (options.mode() !== 'create') { + resolvedStaffType.value = normalizeStaffRole(owner.role_code); + } + const validationMessage = credentialOwnerValidationMessage( + ownerIdentity, + resolvedStaffType.value as StaffRoleCode, + owner, + ); + if (validationMessage) { + return { ok: false, status: '403', message: validationMessage }; + } + relations.options['/staff_account'] = [owner]; + return { ok: true }; + } + + return { resolvedStaffType, relations, reset, prepare }; +} diff --git a/frontend/gas_admin/src/views/resource/use-unsaved-record.ts b/frontend/gas_admin/src/views/resource/use-unsaved-record.ts new file mode 100644 index 0000000..1e7d7d0 --- /dev/null +++ b/frontend/gas_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/gas_admin/src/views/shared/CrudListPage.less b/frontend/gas_admin/src/views/shared/CrudListPage.less new file mode 100644 index 0000000..40f02ca --- /dev/null +++ b/frontend/gas_admin/src/views/shared/CrudListPage.less @@ -0,0 +1,8 @@ +/* 功能描述:标准资源列表工具栏、响应式表格、分页和辅助文本布局。版本:v1.1.0。 */ +.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; } +.workflow-alert { margin-bottom: 16px; } +.pagination { display: flex; justify-content: flex-end; margin-top: 16px; } +.muted-text { color: var(--color-text-3); } +.resource-table-shell { width: 100%; min-width: 0; max-width: 100%; overflow-x: auto; } diff --git a/frontend/gas_admin/src/views/shared/CrudListPage.vue b/frontend/gas_admin/src/views/shared/CrudListPage.vue index edb450c..bed8e2c 100644 --- a/frontend/gas_admin/src/views/shared/CrudListPage.vue +++ b/frontend/gas_admin/src/views/shared/CrudListPage.vue @@ -1,5 +1,9 @@ + - - + diff --git a/frontend/gas_admin/src/views/shared/RelationNameText.vue b/frontend/gas_admin/src/views/shared/RelationNameText.vue index 084c55b..dc0cf63 100644 --- a/frontend/gas_admin/src/views/shared/RelationNameText.vue +++ b/frontend/gas_admin/src/views/shared/RelationNameText.vue @@ -1,11 +1,22 @@ - +