完善配送端资源搜索与资质导航

This commit is contained in:
czl231
2026-08-22 23:24:12 +08:00
parent 5d4bd61672
commit 4334719051
22 changed files with 432 additions and 58 deletions

View File

@@ -188,7 +188,8 @@ func writeDeliveryResourceContract(output io.Writer) error {
contracts := make([]contract, 0, len(expected))
for _, item := range expected {
contracts = append(contracts, contract{
Domain: item.Domain, Name: item.Name, Path: item.Path, PageKind: item.PageKind, Mode: item.Mode,
Domain: item.Domain, Name: item.Name, Path: item.Path,
PageKind: item.PageKind, Mode: item.Mode, SearchFields: item.SearchFields,
})
}
return json.NewEncoder(output).Encode(manifest{Resources: contracts, Routes: routes})

View File

@@ -119,7 +119,7 @@ func ListPageFiltered[T any](ctx *gin.Context, filter func(*gorm.DB) *gorm.DB) {
var keywordSafeColumns = map[string]bool{
"code": true, "name": true, "username": true, "display_name": true,
"role_code": true, "delivery_code": true, "work_status": true,
"credential_type": true, "device_no": true, "model": true,
"credential_type": true, "credential_no": true, "device_no": true, "model": true,
"online_status": true, "rule_code": true, "action": true,
"event_code": true, "title": true, "result": true,
"product_code": true, "value": true, "order_no": true,

View File

@@ -18,7 +18,7 @@ type KeywordSearchKind string
const (
// KeywordSearchText 按数据库原始文本执行不区分大小写的包含匹配。
KeywordSearchText KeywordSearchKind = "text"
// KeywordSearchEnum 按页面展示的中文枚举名称匹配,不暴露内部英文编码
// KeywordSearchEnum 同时按页面中文名称和内部稳定编码匹配
KeywordSearchEnum KeywordSearchKind = "enum"
)
@@ -85,7 +85,7 @@ func useConfiguredKeywordSearch(ctx *gin.Context) bool {
}
// configuredKeywordConditions 将用户关键字编译为参数化 SQL 条件。
// 枚举字段接受中文展示名称,普通文本字段保持原有包含匹配行为。
// 枚举字段接受中文展示名称和内部编码,普通文本字段保持原有包含匹配行为。
func configuredKeywordConditions(model any, keyword string) ([]string, []any) {
fields := ConfiguredKeywordSearchFields(model)
conditions := make([]string, 0, len(fields))
@@ -113,7 +113,8 @@ func configuredKeywordConditions(model any, keyword string) ([]string, []any) {
func matchingKeywordEnumValues(values []KeywordSearchValue, keyword string) []string {
matched := make([]string, 0, len(values))
for _, value := range values {
if strings.Contains(strings.ToLower(value.Label), keyword) {
if strings.Contains(strings.ToLower(value.Label), keyword) ||
strings.Contains(strings.ToLower(value.Value), keyword) {
matched = append(matched, value.Value)
}
}

View File

@@ -6,7 +6,7 @@ import (
"testing"
)
// keywordSearchEnumModel 用于验证中文枚举别名不会退化为英文编码搜索。
// keywordSearchEnumModel 用于验证中文枚举别名和稳定编码均可搜索。
type keywordSearchEnumModel struct {
RoleCode string `gorm:"column:role_code"`
}
@@ -48,8 +48,9 @@ func TestConfiguredKeywordConditionsMatchChineseEnumLabels(t *testing.T) {
}
conditions, arguments = configuredKeywordConditions(&keywordSearchEnumModel{}, "delivery")
if len(conditions) != 0 || len(arguments) != 0 {
t.Fatalf("英文枚举编码不应继续可搜conditions=%v arguments=%v", conditions, arguments)
if !reflect.DeepEqual(conditions, []string{`"role_code" IN (?)`}) ||
!reflect.DeepEqual(arguments, []any{"delivery"}) {
t.Fatalf("英文枚举编码应保持可搜conditions=%v arguments=%v", conditions, arguments)
}
}

View File

@@ -1,33 +1,44 @@
package delivery
import "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
type ResourceContract struct {
Domain string `json:"domain"`
Name string `json:"name"`
Path string `json:"path"`
PageKind string `json:"pageKind"`
Mode string `json:"mode"`
Domain string `json:"domain"`
Name string `json:"name"`
Path string `json:"path"`
PageKind string `json:"pageKind"`
Mode string `json:"mode"`
SearchFields []common.KeywordSearchField `json:"searchFields,omitempty"`
}
func ExpectedResources() []ResourceContract {
items := []ResourceContract{
{"profile", "delivery_profile", "/delivery_profile", "list", "readonly"},
{"staff", "staff_account", "/staff_account", "list", "writable"},
{"staff", "staff_credential", "/staff_credential", "list", "writable"},
{"user", "user_account", "/user_account", "list", "writable"},
{"user", "user_address", "/user_address", "list", "writable"},
{"contract", "gasorder_contract", "/gasorder_contract", "list", "managed"},
{"contract", "gasorder_contract_product", "/gasorder_contract_product", "list", "append_only"},
{"contract", "gasorder_contract_revision", "/gasorder_contract_revision", "list", "readonly"},
{"contract", "product_info", "/product_info", "list", "readonly"},
{"gasorder", "gasorder_basic", "/gasorder_basic", "list", "append_only"},
{"finance", "wallet_basic", "/wallet_basic", "list", "readonly"},
{"finance", "wallet_bank", "/wallet_bank", "list", "readonly"},
{"finance", "payment_order", "/payment_order", "list", "readonly"},
{"finance", "wallet_record", "/wallet_record", "list", "readonly"},
{"finance", "payment_refund", "/payment_refund", "list", "readonly"},
{"finance", "wallet_recharge", "/wallet_recharge", "list", "append_only"},
{"finance", "wallet_apply_cash", "/wallet_apply_cash", "list", "append_only"},
{"finance", "fin_settlement", "/fin_settlement", "list", "readonly"},
resourceContract("profile", "delivery_profile", "readonly"),
resourceContract("staff", "staff_account", "writable"),
resourceContract("staff", "staff_credential", "writable"),
resourceContract("user", "user_account", "writable"),
resourceContract("user", "user_address", "writable"),
resourceContract("contract", "gasorder_contract", "managed"),
resourceContract("contract", "gasorder_contract_product", "append_only"),
resourceContract("contract", "gasorder_contract_revision", "readonly"),
resourceContract("contract", "product_info", "readonly"),
resourceContract("gasorder", "gasorder_basic", "append_only"),
resourceContract("finance", "wallet_basic", "readonly"),
resourceContract("finance", "wallet_bank", "readonly"),
resourceContract("finance", "payment_order", "readonly"),
resourceContract("finance", "wallet_record", "readonly"),
resourceContract("finance", "payment_refund", "readonly"),
resourceContract("finance", "wallet_recharge", "append_only"),
resourceContract("finance", "wallet_apply_cash", "append_only"),
resourceContract("finance", "fin_settlement", "readonly"),
}
return items
}
// resourceContract 创建配送端标准资源契约,并附加显式搜索字段。
func resourceContract(domain, name, mode string) ResourceContract {
return ResourceContract{
Domain: domain, Name: name, Path: "/" + name, PageKind: "list", Mode: mode,
SearchFields: resourceSearchFields(name),
}
}

View File

@@ -0,0 +1,26 @@
// 功能描述验证配送端资源搜索契约与页面能力保持一致。版本v1.0.0。
package delivery
import "testing"
// TestExpectedResourcesExposeSearchContract 验证有效搜索与隐藏搜索的资源边界。
func TestExpectedResourcesExposeSearchContract(t *testing.T) {
resources := ExpectedResources()
byName := make(map[string]ResourceContract, len(resources))
for _, resource := range resources {
byName[resource.Name] = resource
}
credential := byName["staff_credential"].SearchFields
if len(credential) != 2 || credential[0].Key != "credential_type" || credential[1].Key != "credential_no" {
t.Fatalf("人员资质搜索契约不完整:%#v", credential)
}
for _, name := range []string{"delivery_profile", "user_address", "wallet_bank"} {
if len(byName[name].SearchFields) != 0 {
t.Fatalf("%s 不应显示无效搜索:%#v", name, byName[name].SearchFields)
}
}
channel := byName["payment_order"].SearchFields[2]
if channel.Kind != "enum" || len(channel.Values) != 3 {
t.Fatalf("支付渠道中英文搜索契约不完整:%#v", channel)
}
}

View File

@@ -0,0 +1,62 @@
// Package delivery 定义配送点管理端公开的资源搜索契约。
// 版本v1.0.0
package delivery
import (
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
)
// deliveryResourceSearchFields 只公开页面能够解释且服务端确实执行的搜索字段。
var deliveryResourceSearchFields = map[string][]common.KeywordSearchField{
"staff_account": {searchText("username")},
"staff_credential": {searchText("credential_type"), searchText("credential_no")},
"user_account": {searchText("username")},
"gasorder_contract": {searchText("contract_no"), searchText("title")},
"gasorder_contract_product": {searchText("product_code"), searchText("product_type_name")},
"gasorder_contract_revision": {searchEnum("action", searchOption("activate", "启用"), searchOption("renew", "续签"), searchOption("terminate", "终止"))},
"product_info": {searchText("code"), searchText("name")},
"gasorder_basic": {searchText("request_no"), searchEnum("creator_type",
searchOption("user", "用户"), searchOption("staff", "工作人员"),
searchOption("delivery", "配送点"), searchOption("gas", "气站"))},
"payment_order": {searchText("payment_no"), searchText("request_no"), searchEnum("channel",
searchOption("wechat", "微信"), searchOption("alipay", "支付宝"), searchOption("mock", "模拟支付"))},
"wallet_record": {searchText("record_no"), searchText("request_no")},
"payment_refund": {searchText("refund_no"), searchText("request_no")},
"wallet_recharge": {searchText("record_no"), searchText("request_no")},
"wallet_apply_cash": {searchText("cash_no"), searchText("request_no"), searchEnum("channel",
searchOption("bank", "银行卡"), searchOption("alipay", "支付宝"), searchOption("wechat", "微信"))},
"fin_settlement": {searchText("settlement_no")},
}
// init 只注册平台总后台尚未注册的模型;共享模型继续复用全局策略。
func init() {
common.RegisterKeywordSearchPolicy(&models.GasorderContractProduct{}, deliveryResourceSearchFields["gasorder_contract_product"])
common.RegisterKeywordSearchPolicy(&models.GasorderContractRevision{}, deliveryResourceSearchFields["gasorder_contract_revision"])
common.RegisterKeywordSearchPolicy(&models.PaymentOrder{}, deliveryResourceSearchFields["payment_order"])
common.RegisterKeywordSearchPolicy(&models.WalletRecord{}, deliveryResourceSearchFields["wallet_record"])
}
// resourceSearchFields 返回搜索契约副本,防止调用方修改全局定义。
func resourceSearchFields(name string) []common.KeywordSearchField {
fields := deliveryResourceSearchFields[name]
result := make([]common.KeywordSearchField, 0, len(fields))
for _, field := range fields {
copied := field
copied.Values = append([]common.KeywordSearchValue(nil), field.Values...)
result = append(result, copied)
}
return result
}
func searchText(key string) common.KeywordSearchField {
return common.KeywordSearchField{Key: key, Kind: common.KeywordSearchText}
}
func searchEnum(key string, values ...common.KeywordSearchValue) common.KeywordSearchField {
return common.KeywordSearchField{Key: key, Kind: common.KeywordSearchEnum, Values: values}
}
func searchOption(value, label string) common.KeywordSearchValue {
return common.KeywordSearchValue{Value: value, Label: label}
}

View File

@@ -1,6 +1,7 @@
package delivery
import (
"strings"
"time"
"git.apinb.com/bsm-sdk/core/errcode"
@@ -187,17 +188,38 @@ func ArchiveStaff(ctx *gin.Context) {
}
func credentialQuery(point models.DeliveryBasic) *gorm.DB {
return common.ActiveRecords(db().Model(&models.StaffCredential{})).
return credentialQueryWithDB(db(), point)
}
// credentialQueryWithDB 固定资质所属配送点、气站和配送角色范围。
func credentialQueryWithDB(databaseService *gorm.DB, point models.DeliveryBasic) *gorm.DB {
return common.ActiveRecords(databaseService.Model(&models.StaffCredential{})).
Joins("JOIN staff_account ON staff_account.id = staff_credential.staff_account_id").
Where("staff_account.delivery_basic_id = ? AND staff_account.gas_basic_id = ? AND staff_account.role_code = ?",
point.ID, point.GasBasicID, "delivery")
}
// scopedCredentialQuery 将资质列表进一步锁定到已验证的配送人员。
func scopedCredentialQuery(databaseService *gorm.DB, point models.DeliveryBasic, staffID uint64) *gorm.DB {
return credentialQueryWithDB(databaseService, point).
Where("staff_credential.staff_account_id = ?", staffID)
}
func ListCredential(ctx *gin.Context) {
point, _, ok := currentScope(ctx)
if ok {
listScoped(ctx, &models.StaffCredential{}, credentialQuery(point), "staff_credential.created_at desc")
if !ok {
return
}
staffIdentity := strings.TrimSpace(ctx.Query("staff_account_identity"))
if staffIdentity == "" {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
staff, found := scopedStaff(ctx, staffIdentity, point)
if !found {
return
}
listScoped(ctx, &models.StaffCredential{}, scopedCredentialQuery(db(), point, staff.ID), "staff_credential.created_at desc")
}
func GetCredential(ctx *gin.Context) {
@@ -260,9 +282,13 @@ func UpdateCredential(ctx *gin.Context) {
if !ok {
return
}
if staff.ID != existing.StaffAccountID {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if err := db().Model(&existing).Updates(map[string]any{
"staff_account_id": staff.ID, "credential_type": request.CredentialType,
"credential_no": request.CredentialNo, "expired_at": request.ExpiredAt,
"credential_type": request.CredentialType, "credential_no": request.CredentialNo,
"expired_at": request.ExpiredAt,
}).Error; err != nil {
infra.Response.Error(ctx, err)
return

View File

@@ -31,3 +31,24 @@ func TestDeliveryStaffQueryKeepsRoleScope(t *testing.T) {
}
}
}
// TestScopedCredentialQueryKeepsOwnerAndRoleScope 验证资质列表同时限制人员和配送角色。
func TestScopedCredentialQueryKeepsOwnerAndRoleScope(t *testing.T) {
connection, _, err := sqlmock.New()
if err != nil {
t.Fatalf("创建 SQL mock 失败:%v", err)
}
t.Cleanup(func() { _ = connection.Close() })
databaseService, err := gorm.Open(postgres.New(postgres.Config{Conn: connection}), &gorm.Config{DryRun: true})
if err != nil {
t.Fatalf("打开 GORM 失败:%v", err)
}
point := models.DeliveryBasic{Entity: models.Entity{ID: 22}, GasBasicID: 11}
statement := scopedCredentialQuery(databaseService, point, 33).
Find(&[]models.StaffCredential{}).Statement.SQL.String()
for _, required := range []string{"delivery_basic_id", "gas_basic_id", "role_code", "staff_account_id"} {
if !strings.Contains(statement, required) {
t.Fatalf("配送人员资质范围缺少 %s%s", required, statement)
}
}
}

View File

@@ -19,7 +19,7 @@ var resourceSearchDefinitions = []resourceSearchDefinition{
searchDefinition("delivery_basic", &models.DeliveryBasic{}, text("delivery_code"), text("name")),
searchDefinition("delivery_account", &models.DeliveryAccount{}, text("username"), text("display_name"), enum("role_code", value("admin", "配送点管理员"))),
searchDefinition("staff_account", &models.StaffAccount{}, text("username"), enum("role_code", value("installer", "安装人员"), value("delivery", "配送人员"), value("operations", "运维人员"))),
searchDefinition("staff_credential", &models.StaffCredential{}, text("credential_type")),
searchDefinition("staff_credential", &models.StaffCredential{}, text("credential_type"), text("credential_no")),
searchDefinition("user_account", &models.UserAccount{}, text("username")),
searchDefinition("producer_account", &models.ProducerAccount{}, text("name")),
searchDefinition("product_type", &models.ProductType{}, text("code"), text("name")),
@@ -36,8 +36,8 @@ var resourceSearchDefinitions = []resourceSearchDefinition{
searchDefinition("platform_account", &models.PlatformAccount{}, text("username"), text("display_name")),
searchDefinition("platform_role", &models.PlatformRole{}, text("role_code"), text("name"), enum("location_scope", value("standard", "脱敏坐标"), value("precise", "精确坐标"))),
searchDefinition("wallet_basic", &models.WalletBasic{}, text("owner_type")),
searchDefinition("payment_refund", &models.PaymentRefund{}, text("refund_no")),
searchDefinition("wallet_apply_cash", &models.WalletApplyCash{}, text("cash_no")),
searchDefinition("payment_refund", &models.PaymentRefund{}, text("refund_no"), text("request_no")),
searchDefinition("wallet_apply_cash", &models.WalletApplyCash{}, text("cash_no"), text("request_no"), enum("channel", value("bank", "银行卡"), value("alipay", "支付宝"), value("wechat", "微信"))),
}
func init() {

View File

@@ -4,6 +4,7 @@ import (
"fmt"
sdkmiddleware "git.apinb.com/bsm-sdk/core/middleware"
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
deliverylogic "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/delivery"
"github.com/gin-gonic/gin"
)
@@ -16,6 +17,7 @@ func RegisterDelivery(serviceKey string, engine *gin.Engine) {
protected := engine.Group(basePath)
protected.Use(sdkmiddleware.JwtAuth(true))
protected.Use(deliverylogic.RequireDeliveryAdmin())
protected.Use(common.EnableConfiguredKeywordSearch())
protected.GET("/auth/profile", deliverylogic.CurrentProfile)
protected.PUT("/auth/password", deliverylogic.ChangePassword)
protected.GET("/delivery_menu", deliverylogic.ListMenu)

View File

@@ -91,6 +91,9 @@ Global:
- 新建人员的 `role_code` 由服务端固定为 `delivery`
- 人员的气站及配送点归属由服务端固定为当前范围。
- 资质支持新增、编辑、启停和归档。
- 人员资质必须从配送人员列表进入,服务端按当前气站、配送点、配送角色和人员标识强制过滤;缺少、无效或越权人员上下文时不得返回全部资质。
- 资质列表显示服务端回查的人员姓名和可复制唯一标识;新建自动预填所属人员,创建和编辑均不得更换所属人员。
- 标准资源搜索必须由后端显式字段契约驱动;没有有效搜索字段时不显示搜索区,枚举字段支持中文名称和内部稳定编码。
- 删除均为归档;存在未完成订单时禁止归档人员。
- 不允许创建安装、运维、仓管、调度员或质控角色账号。
- 配送人员列表不得展示数据库自增 ID联系电话之后显示 32px 圆形头像缩略图,头像仅通过当前配送点 JWT 范围内的受保护接口懒加载。无头像或读取异常时显示默认头像,不得从列表响应读取或暴露头像 URI。

View File

@@ -22,6 +22,10 @@
10. 配送人员和用户账户列表增加 32px 受控头像缩略图,复用 5173 的懒加载、并发 6、当前页缓存、取消和 Blob URL 回收策略。
11. 删除全部标准资源列表的数据库自增 ID 列,只保留系统唯一标识;同时把该规则加入自动检查。
12. 配送人员头像读取补充 `role_code = delivery` 范围限制,并新增 SQL 范围测试。
13. 修复配送人员资质列表未应用人员筛选的问题,增加气站、配送点、配送角色和人员 ID 四重范围校验。
14. 资质列表新增人员姓名上下文、安全返回、重复人员列隐藏;新建预填所属人员,创建和编辑锁定归属。
15. 新增后端生成的 `searchFields` 契约;无有效字段时隐藏搜索区,枚举字段支持中文名称和内部编码。
16. 标准列表搜索标签统一为“模糊搜索”,输入框继续提示当前资源的具体可搜索字段。
## 操作后状态
@@ -41,7 +45,7 @@
- 变更前:列表抽屉承载记录操作,浏览器地址不随记录变化。
- 变更后:每个允许的操作拥有独立 URL详情页集中承载业务动作。
- 兼容性:现有列表菜单地址、后端接口和配送点数据范围保持不变
- 兼容性:现有列表菜单地址和响应结构保持不变;人员资质列表现在必须提供人员标识,修复了原先可能扩大到本点全部资质的范围错误
## 验证结果
@@ -49,13 +53,15 @@
- `contract:check`通过18 个资源与后端契约一致。
- `profile:check`:通过,资料专用只读页未回退。
- `type:check`:通过。
- `build`通过2632 个模块完成生产构建。
- `go test ./internal/logic/delivery ./internal/routers`:通过,包含配送人员头像角色范围测试。
- `build`通过2634 个模块完成生产构建。
- `go test ./internal/logic/common ./internal/logic/delivery ./internal/logic/platform ./internal/routers ./cmd/cli`:通过,包含搜索枚举、配送人员头像角色范围和资质人员范围测试。
- `lint`:通过;仅报告项目既有警告,未产生失败项。
- 浏览器回归:工作人员列表、详情、编辑、正式新建地址、返回链路、未保存保护、配送订单新建页、支付列表均通过。
- 布局回归:工作人员详情、编辑和配送订单新建页已在应用内浏览器逐页截图检查,与 5173 的页面结构和响应式断点一致。
- 头像回归:配送人员新建、编辑页已确认不再显示头像文本框,头像选择按钮、格式大小提示、默认头像和身份摘要均正常;后端头像路由测试通过。
- 列表头像回归:配送人员、用户账户显示受控圆形头像,其他资源不生成无效头像列;数据库 ID 列已从全部标准列表移除;点击刷新后头像重新加载正常。
- 资质关联回归:有效人员仅返回本人资质,标题和上下文显示服务端姓名及唯一标识;新建页预填并锁定人员,缺少上下文自动返回,伪造人员标识显示错误且不渲染表格。
- 搜索回归:资质页提示“可搜索:资质类型、资质编号”;银行卡页不显示关键字、查询和重置,仅保留刷新。
## 风险评估

View File

@@ -25,6 +25,7 @@ frontend/delivery_admin/
├── api/
│ ├── resource-display.ts # 中文字段、状态和详情值展示
│ ├── avatar.ts # 头像上传与配送点鉴权读取
│ ├── resource-search-contract.ts # 后端生成的显式搜索字段契约
│ ├── resource-navigation.ts # 独立页面路由和安全返回地址
│ └── resource-record-form.ts # 表单初始化、字段白名单和校验
├── router/routes/modules/
@@ -55,6 +56,10 @@ frontend/delivery_admin/
配送人员和用户账户列表同样参考 5173联系电话后显示 32px 圆形头像,最多并发 6 个鉴权请求,接近可视区域才加载;当前页缓存结果,刷新或离页时取消请求并释放 Blob URL。404、网络错误和图片解码失败均回退本地默认头像。全部标准资源列表隐藏数据库自增 ID只展示可复制的系统唯一标识。
配送人员资质列表必须由配送人员列表进入。页面先通过受保护人员详情接口回查姓名,再以当前气站、配送点、`delivery` 角色和人员 ID 四重条件加载资质;缺少、无效或越权人员上下文时停止加载,绝不回退为全部资质。标题和上下文区域显示人员姓名及可复制唯一标识,表格隐藏重复人员列。新建页自动预填所属人员,创建和编辑均锁定该关系,返回地址只接受安全站内路径。
资源搜索由后端 `searchFields` 契约驱动。没有有效字段的配送点资料、用户地址和银行卡页面不渲染搜索区域;其他页面显示“可搜索:具体字段”提示。枚举字段同时接受中文展示名称和内部稳定编码,查询、分页和重置始终保留人员上下文及安全来源参数。
`resource-record-form.ts` 为五类可编辑资源声明后端更新字段白名单,防止用户名、合同编号等只读字段出现在编辑页或被无效提交。
支付与退款资源统一使用后端正式名称 `payment_order``payment_refund`,菜单地址仍保持 `/finance/payments``/finance/refunds`,避免接口路径不一致导致 404。
@@ -79,6 +84,8 @@ npm.cmd run build
- 修复支付、退款资源与后端契约名称不一致的问题。
- 新增 17/9/5 页面能力矩阵自动检查。
- 新增配送人员、用户账户的受控头像缩略图,并移除全部标准列表的数据库自增 ID。
- 新增配送人员资质的强制人员范围、上下文展示、关系预填锁定和安全返回链路。
- 新增后端生成的资源搜索契约,隐藏无效搜索并支持枚举中文名称与稳定编码。
- 保持配送点资料专用只读页面和现有公共后端接口不变。
## 7. 已知边界

View File

@@ -24,6 +24,7 @@ const creatable = new Set([
const editable = new Set([
'staff_account', 'staff_credential', 'user_account', 'user_address', 'gasorder_contract',
]);
const resourcesByName = new Map(contract.resources.map((resource) => [resource.name, resource]));
/** 在条件不成立时中止检查,并给出可直接定位的原因。 */
function assert(condition, message) {
@@ -72,5 +73,20 @@ assert(listPage.includes('avatarLoader.reset()'), '列表刷新未清理头像
const avatarLoader = read('src/views/shared/protected-list-avatar-loader.ts');
assert(avatarLoader.includes('MAX_CONCURRENT_REQUESTS = 6'), '头像请求并发上限未与 5173 对齐');
assert(avatarLoader.includes("new Set(['staff_account', 'user_account'])"), '头像列表资源白名单不正确');
assert(
resourcesByName.get('staff_credential').searchFields.map((field) => field.key).join(',') ===
'credential_type,credential_no',
'人员资质搜索契约必须包含资质类型和资质编号',
);
for (const name of ['delivery_profile', 'user_address', 'wallet_bank']) {
assert(!(resourcesByName.get(name).searchFields?.length), `${name} 不应显示无效搜索`);
}
assert(listPage.includes('v-if="searchEnabled"'), '列表搜索区域未按后端契约控制显示');
assert(listPage.includes('label="模糊搜索"'), '列表搜索标签未明确说明模糊匹配');
assert(!listPage.includes('label="关键字"'), '列表仍使用含义不清的关键字标签');
assert(!listPage.includes('placeholder="关键字段模糊搜索"'), '列表仍使用无意义的通用搜索提示');
assert(listPage.includes('ensureCredentialContext'), '人员资质列表缺少服务端人员上下文校验');
assert(listPage.includes("field.key === 'staff_account_identity'"), '人员范围资质列表未隐藏重复人员列');
assert(recordPage.includes('已锁定,不可更换'), '人员资质独立页未锁定所属配送人员');
console.log(`独立资源页面契约通过:详情 ${listResources.length},新建 ${creatable.size},编辑 ${editable.size}`);

View File

@@ -25,11 +25,12 @@ export function recordRouteLocation(
mode: RecordNavigationMode,
identity: string,
returnTo: string,
context: Record<string, string> = {},
): RouteLocationRaw {
return {
name: `${listRouteName}-${mode}`,
...(mode === 'create' ? {} : { params: { identity } }),
query: returnTo ? { return_to: returnTo } : {},
query: { ...context, ...(returnTo ? { return_to: returnTo } : {}) },
};
}

View File

@@ -0,0 +1,29 @@
/**
* 功能描述:读取配送端生成的资源搜索契约,统一搜索字段与枚举别名。
* 版本v1.0.0。
*/
import deliveryContract from '@/contracts/delivery-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 fieldsByResource = Object.fromEntries(
(deliveryContract.resources as ContractResource[]).map((resource) => [
resource.name,
resource.searchFields ?? [],
]),
) as Record<string, ResourceSearchField[]>;
/** 返回后端确认可用的搜索字段副本。 */
export function resourceSearchFields(name: string): ResourceSearchField[] {
return (fieldsByResource[name] ?? []).map((field) => ({
...field,
values: field.values?.map((value) => ({ ...value })),
}));
}

View File

@@ -2,6 +2,8 @@
* 功能描述:定义配送点后台资源能力、字段契约和受控业务动作。
* 版本v1.1.0。
*/
import { resourceSearchFields, type ResourceSearchField } from './resource-search-contract';
export type ResourceMode =
| 'writable'
| 'readonly'
@@ -59,6 +61,7 @@ export type ResourceUiDefinition = {
mode: ResourceMode;
pageKind: ResourcePageKind;
fields: ResourceField[];
searchFields: ResourceSearchField[];
detailActions?: DetailAction[];
canCreate: boolean;
canEdit: boolean;
@@ -338,6 +341,7 @@ function define(
mode,
pageKind,
fields,
searchFields: resourceSearchFields(name),
...defaults,
...capabilities,
...(detailActions ? { detailActions } : {}),

File diff suppressed because one or more lines are too long

View File

@@ -66,3 +66,14 @@
.section-card :deep(.arco-card-header),
.section-card :deep(.arco-card-body) { padding-right: 16px; padding-left: 16px; }
}
.credential-owner-card :deep(.arco-card-body) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.credential-owner-card strong {
display: block;
margin-bottom: 8px;
}

View File

@@ -29,6 +29,18 @@
</a-result>
<template v-else>
<a-card
v-if="isCredentialResource && credentialOwner"
title="所属配送人员"
:bordered="false"
class="section-card credential-owner-card"
>
<div>
<strong>{{ credentialOwner.name || credentialOwner.username || '未命名人员' }}</strong>
<IdentityText :value="String(credentialOwner.identity)" />
</div>
<a-tag color="blue">已锁定不可更换</a-tag>
</a-card>
<ResourceAccountSummary
v-if="hasAvatarField"
:mode="mode"
@@ -111,6 +123,7 @@ import {
import { primaryRecord, recordEditReason } from '@/api/resource-display';
import { recordRouteLocation, returnToList, safeReturnPath } from '@/api/resource-navigation';
import { getResource, type DetailAction, type ResourceField } from '@/api/resources';
import IdentityText from '@/components/IdentityText.vue';
import ResourceActionDialog from './ResourceActionDialog.vue';
import ResourceAccountSummary from './ResourceAccountSummary.vue';
import ResourceDetailContent from './ResourceDetailContent.vue';
@@ -131,6 +144,7 @@ const loading = ref(false);
const saving = ref(false);
const errorMessage = ref('');
const detail = ref<ResourceRow>({});
const credentialOwner = ref<ResourceRow>();
const record = computed(() => primaryRecord(detail.value));
const form = reactive<Record<string, any>>({});
const relationOptions = reactive<Record<string, ResourceRow[]>>({});
@@ -142,6 +156,7 @@ const avatar = useResourceAvatar();
const hasAvatarField = computed(() =>
definition.value.fields.some((field) => field.key === 'avatar'),
);
const isCredentialResource = computed(() => definition.value.name === 'staff_credential');
const summaryRecord = computed(() =>
mode.value === 'detail' ? record.value : { ...record.value, ...form },
);
@@ -149,7 +164,11 @@ const formFields = computed(() => recordFormFields(
definition.value.name,
definition.value.fields,
mode.value as 'create' | 'edit',
).filter((field) => field.key !== 'avatar'));
).filter((field) => field.key !== 'avatar').map((field) =>
isCredentialResource.value && field.key === 'staff_account_identity'
? { ...field, readonly: true }
: field,
));
const canEditRecord = computed(
() => definition.value.canEdit && !recordEditReason(definition.value, record.value),
);
@@ -197,6 +216,23 @@ async function loadRecord() {
}
}
/** 回查并锁定资质所属配送人员,禁止信任 URL 中的展示名称。 */
async function loadCredentialOwner() {
if (!isCredentialResource.value) return;
const queryOwner = typeof route.query.owner_identity === 'string'
? route.query.owner_identity.trim()
: '';
const recordOwner = String(record.value.staff_account_identity ?? '');
const targetIdentity = mode.value === 'create' ? queryOwner : recordOwner;
if (!targetIdentity) throw new Error('缺少配送人员上下文,请从配送人员列表进入');
const owner = await resourceApi.detail<ResourceRow>('/staff_account', targetIdentity);
if (!owner || String(owner.identity ?? '') !== targetIdentity)
throw new Error('人员详情响应无效');
credentialOwner.value = owner;
relationOptions['/staff_account'] = [owner];
if (mode.value !== 'detail') form.staff_account_identity = targetIdentity;
}
/** 保存新建或编辑表单并进入记录详情。 */
async function save() {
const validation = validateResourceRecordForm(form, formFields.value, mode.value as 'create' | 'edit');
@@ -280,6 +316,10 @@ function confirmArchive() {
/** 加载关联候选,配送人员关系固定为配送角色。 */
async function loadRelation(resource: string, keyword = '') {
if (isCredentialResource.value && resource === '/staff_account') {
await loadCredentialOwner();
return;
}
relationLoading[resource] = true;
try {
const filters: Record<string, string> = keyword ? { keyword } : {};
@@ -301,13 +341,28 @@ function searchRelation(resource: string | undefined, keyword: string) {
}
onMounted(async () => {
if (
isCredentialResource.value && mode.value === 'create' &&
(typeof route.query.owner_identity !== 'string' ||
route.query.relation_key !== 'staff_account_identity')
) {
Message.warning('请从配送人员列表进入资质新建页');
await router.replace({ name: 'staff-delivery' });
return;
}
await loadRecord();
if (errorMessage.value) return;
const paths = new Set(
[...definition.value.fields, ...(definition.value.detailActions ?? []).flatMap((item) => item.fields ?? [])]
.map((field: ResourceField) => field.relation)
.filter((value): value is string => Boolean(value)),
);
await Promise.all([...paths].map((resource) => loadRelation(resource)));
await loadRecord();
try {
await Promise.all([...paths].map((resource) => loadRelation(resource)));
if (isCredentialResource.value && mode.value === 'detail') await loadCredentialOwner();
} catch (error) {
errorMessage.value = `所属配送人员加载失败:${(error as Error).message}`;
}
});
</script>

View File

@@ -1,6 +1,9 @@
<!-- 功能描述展示配送点标准资源列表并导航到独立记录页版本v1.0.0 -->
<template>
<a-card :title="definition.title" :bordered="false">
<a-card :title="listTitle" :bordered="false">
<template v-if="isCredentialList" #extra>
<a-button @click="returnToStaffList"><template #icon><icon-left /></template>返回配送人员列表</a-button>
</template>
<a-alert
v-if="definition.detailActions?.length"
class="workflow-alert"
@@ -10,10 +13,26 @@
>
请从详情页中的专用操作推进流程操作结果由服务端状态校验并保留审计记录
</a-alert>
<div class="list-toolbar">
<a-form :model="filters" layout="inline" @submit="search">
<a-form-item label="关键字">
<a-input v-model="filters.keyword" allow-clear placeholder="关键字段模糊搜索" />
<div v-if="credentialOwner" class="owner-context">
<div>
<div class="owner-label">当前配送人员</div>
<strong>{{ credentialOwner.name || credentialOwner.username || '未命名人员' }}</strong>
</div>
<IdentityText :value="String(credentialOwner.identity)" />
</div>
<a-result
v-if="contextError"
status="error"
title="无法加载配送人员资质"
:subtitle="contextError"
>
<template #extra><a-button type="primary" @click="returnToStaffList">返回配送人员列表</a-button></template>
</a-result>
<template v-else>
<div class="list-toolbar" :class="{ 'no-search': !searchEnabled }">
<a-form v-if="searchEnabled" :model="filters" layout="inline" @submit="search">
<a-form-item label="模糊搜索">
<a-input v-model="filters.keyword" allow-clear :placeholder="searchPlaceholder" />
</a-form-item>
<a-button type="primary" html-type="submit">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
@@ -94,6 +113,7 @@
@change="changePage"
/>
</div>
</template>
</a-card>
</template>
@@ -108,9 +128,9 @@ import {
recordStatusColor,
recordStatusLabel,
} from '@/api/resource-display';
import { recordRouteLocation } from '@/api/resource-navigation';
import { recordRouteLocation, safeReturnPath } from '@/api/resource-navigation';
import type { ResourceRow } from '@/api/resource-record-form';
import type { ResourceField, ResourceUiDefinition } from '@/api/resources';
import { resourceFieldLabel, type ResourceField, type ResourceUiDefinition } from '@/api/resources';
import IdentityText from '@/components/IdentityText.vue';
import ProtectedAvatarThumbnail from './ProtectedAvatarThumbnail.vue';
import {
@@ -127,16 +147,57 @@ const page = ref(Math.max(1, Number(route.query.page) || 1));
const pageSize = 50;
const total = ref(0);
const list = ref<ResourceRow[]>([]);
const credentialOwner = ref<ResourceRow>();
const contextError = ref('');
const avatarLoader = createProtectedListAvatarLoader();
const avatarRefreshKey = ref(0);
const filters = reactive({ keyword: typeof route.query.keyword === 'string' ? route.query.keyword : '' });
const isCredentialList = computed(() => props.definition.name === 'staff_credential');
const ownerIdentity = computed(() =>
typeof route.query.owner_identity === 'string' ? route.query.owner_identity.trim() : '',
);
const listTitle = computed(() => {
if (!isCredentialList.value || !credentialOwner.value) return props.definition.title;
const name = credentialOwner.value.name ?? credentialOwner.value.username ?? '未命名人员';
return `${props.definition.title} · ${name}`;
});
const searchEnabled = computed(() => props.definition.searchFields.length > 0);
const searchPlaceholder = computed(() =>
`可搜索:${props.definition.searchFields.map((field) => resourceFieldLabel(field.key)).join('、')}`,
);
const displayFields = computed(() =>
props.definition.fields
.filter((field) => !['identity', 'password', 'status'].includes(field.key))
.filter((field) => field.key !== 'avatar' || isProtectedListAvatarField(props.definition.name, field.key))
.filter((field) => !(isCredentialList.value && credentialOwner.value && field.key === 'staff_account_identity'))
.slice(0, 6),
);
/** 验证人员资质必须来自一个当前范围内的配送人员。 */
async function ensureCredentialContext() {
if (!isCredentialList.value) return true;
if (!ownerIdentity.value || route.query.relation_key !== 'staff_account_identity') {
Message.warning('请先选择配送人员查看资质');
await returnToStaffList();
return false;
}
if (credentialOwner.value?.identity === ownerIdentity.value) return true;
try {
const owner = await resourceApi.detail<ResourceRow>('/staff_account', ownerIdentity.value);
if (!owner || String(owner.identity ?? '') !== ownerIdentity.value)
throw new Error('人员详情响应无效');
credentialOwner.value = owner;
contextError.value = '';
return true;
} catch (error) {
credentialOwner.value = undefined;
contextError.value = `人员不存在或无权访问:${(error as Error).message}`;
list.value = [];
total.value = 0;
return false;
}
}
/** 返回列表列宽。 */
function columnWidth(field: ResourceField) {
if (isProtectedListAvatarField(props.definition.name, field.key)) return 72;
@@ -156,7 +217,9 @@ async function load() {
avatarRefreshKey.value += 1;
loading.value = true;
try {
const serverFilters: Record<string, string> = filters.keyword ? { keyword: filters.keyword } : {};
if (!(await ensureCredentialContext())) return;
const keyword = searchEnabled.value ? filters.keyword.trim() : '';
const serverFilters: Record<string, string> = keyword ? { keyword } : {};
if (typeof route.query.owner_identity === 'string' && typeof route.query.relation_key === 'string')
serverFilters[route.query.relation_key] = route.query.owner_identity;
const result = await resourceApi.list<ResourceRow>(
@@ -179,8 +242,9 @@ async function syncQuery() {
const query: Record<string, string> = {};
if (typeof route.query.owner_identity === 'string') query.owner_identity = route.query.owner_identity;
if (typeof route.query.relation_key === 'string') query.relation_key = route.query.relation_key;
if (typeof route.query.return_to === 'string') query.return_to = route.query.return_to;
if (page.value > 1) query.page = String(page.value);
if (filters.keyword.trim()) query.keyword = filters.keyword.trim();
if (searchEnabled.value && filters.keyword.trim()) query.keyword = filters.keyword.trim();
await router.replace({ query });
}
@@ -197,7 +261,12 @@ async function resetSearch() {
/** 打开当前列表对应的正式新建页。 */
function openCreate() {
return router.push(recordRouteLocation(String(route.name), 'create', '', route.fullPath));
const context: Record<string, string> = {};
if (isCredentialList.value) {
context.owner_identity = ownerIdentity.value;
context.relation_key = 'staff_account_identity';
}
return router.push(recordRouteLocation(String(route.name), 'create', '', route.fullPath, context));
}
/** 打开独立详情或编辑页。 */
@@ -216,10 +285,17 @@ function viewCredentials(row: ResourceRow) {
query: {
owner_identity: String(row.identity ?? ''),
relation_key: 'staff_account_identity',
return_to: route.fullPath,
},
});
}
/** 返回经过校验的来源人员列表。 */
function returnToStaffList() {
const returnPath = safeReturnPath(route.query.return_to);
return router.push(returnPath || { name: 'staff-delivery' });
}
async function changePage(next: number) {
page.value = next;
await syncQuery();
@@ -228,9 +304,11 @@ async function changePage(next: number) {
onMounted(load);
onBeforeUnmount(avatarLoader.reset);
watch(() => props.definition.resource, async () => {
watch(() => [props.definition.resource, ownerIdentity.value], async () => {
page.value = 1;
filters.keyword = '';
credentialOwner.value = undefined;
contextError.value = '';
await load();
});
</script>
@@ -238,6 +316,19 @@ watch(() => props.definition.resource, async () => {
<style scoped>
.workflow-alert { margin-bottom: 16px; }
.list-toolbar { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.list-toolbar.no-search { justify-content: flex-end; }
.owner-context {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
margin-bottom: 16px;
border: 1px solid var(--color-border-2);
border-radius: 4px;
background: var(--color-fill-1);
}
.owner-label { margin-bottom: 4px; color: var(--color-text-3); font-size: 12px; }
.pagination { display: flex; justify-content: flex-end; margin-top: 16px; }
@media (max-width: 768px) { .list-toolbar { align-items: stretch; flex-direction: column; } }
</style>