完善配送端资源搜索与资质导航
This commit is contained in:
@@ -188,7 +188,8 @@ func writeDeliveryResourceContract(output io.Writer) error {
|
|||||||
contracts := make([]contract, 0, len(expected))
|
contracts := make([]contract, 0, len(expected))
|
||||||
for _, item := range expected {
|
for _, item := range expected {
|
||||||
contracts = append(contracts, contract{
|
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})
|
return json.NewEncoder(output).Encode(manifest{Resources: contracts, Routes: routes})
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ func ListPageFiltered[T any](ctx *gin.Context, filter func(*gorm.DB) *gorm.DB) {
|
|||||||
var keywordSafeColumns = map[string]bool{
|
var keywordSafeColumns = map[string]bool{
|
||||||
"code": true, "name": true, "username": true, "display_name": true,
|
"code": true, "name": true, "username": true, "display_name": true,
|
||||||
"role_code": true, "delivery_code": true, "work_status": 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,
|
"online_status": true, "rule_code": true, "action": true,
|
||||||
"event_code": true, "title": true, "result": true,
|
"event_code": true, "title": true, "result": true,
|
||||||
"product_code": true, "value": true, "order_no": true,
|
"product_code": true, "value": true, "order_no": true,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type KeywordSearchKind string
|
|||||||
const (
|
const (
|
||||||
// KeywordSearchText 按数据库原始文本执行不区分大小写的包含匹配。
|
// KeywordSearchText 按数据库原始文本执行不区分大小写的包含匹配。
|
||||||
KeywordSearchText KeywordSearchKind = "text"
|
KeywordSearchText KeywordSearchKind = "text"
|
||||||
// KeywordSearchEnum 仅按页面展示的中文枚举名称匹配,不暴露内部英文编码。
|
// KeywordSearchEnum 同时按页面中文名称和内部稳定编码匹配。
|
||||||
KeywordSearchEnum KeywordSearchKind = "enum"
|
KeywordSearchEnum KeywordSearchKind = "enum"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ func useConfiguredKeywordSearch(ctx *gin.Context) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// configuredKeywordConditions 将用户关键字编译为参数化 SQL 条件。
|
// configuredKeywordConditions 将用户关键字编译为参数化 SQL 条件。
|
||||||
// 枚举字段只接受中文展示名称,普通文本字段保持原有包含匹配行为。
|
// 枚举字段接受中文展示名称和内部编码,普通文本字段保持原有包含匹配行为。
|
||||||
func configuredKeywordConditions(model any, keyword string) ([]string, []any) {
|
func configuredKeywordConditions(model any, keyword string) ([]string, []any) {
|
||||||
fields := ConfiguredKeywordSearchFields(model)
|
fields := ConfiguredKeywordSearchFields(model)
|
||||||
conditions := make([]string, 0, len(fields))
|
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 {
|
func matchingKeywordEnumValues(values []KeywordSearchValue, keyword string) []string {
|
||||||
matched := make([]string, 0, len(values))
|
matched := make([]string, 0, len(values))
|
||||||
for _, value := range 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)
|
matched = append(matched, value.Value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// keywordSearchEnumModel 用于验证中文枚举别名不会退化为英文编码搜索。
|
// keywordSearchEnumModel 用于验证中文枚举别名和稳定编码均可搜索。
|
||||||
type keywordSearchEnumModel struct {
|
type keywordSearchEnumModel struct {
|
||||||
RoleCode string `gorm:"column:role_code"`
|
RoleCode string `gorm:"column:role_code"`
|
||||||
}
|
}
|
||||||
@@ -48,8 +48,9 @@ func TestConfiguredKeywordConditionsMatchChineseEnumLabels(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
conditions, arguments = configuredKeywordConditions(&keywordSearchEnumModel{}, "delivery")
|
conditions, arguments = configuredKeywordConditions(&keywordSearchEnumModel{}, "delivery")
|
||||||
if len(conditions) != 0 || len(arguments) != 0 {
|
if !reflect.DeepEqual(conditions, []string{`"role_code" IN (?)`}) ||
|
||||||
t.Fatalf("英文枚举编码不应继续可搜:conditions=%v arguments=%v", conditions, arguments)
|
!reflect.DeepEqual(arguments, []any{"delivery"}) {
|
||||||
|
t.Fatalf("英文枚举编码应保持可搜:conditions=%v arguments=%v", conditions, arguments)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,44 @@
|
|||||||
package delivery
|
package delivery
|
||||||
|
|
||||||
|
import "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
|
||||||
|
|
||||||
type ResourceContract struct {
|
type ResourceContract struct {
|
||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
PageKind string `json:"pageKind"`
|
PageKind string `json:"pageKind"`
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
|
SearchFields []common.KeywordSearchField `json:"searchFields,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func ExpectedResources() []ResourceContract {
|
func ExpectedResources() []ResourceContract {
|
||||||
items := []ResourceContract{
|
items := []ResourceContract{
|
||||||
{"profile", "delivery_profile", "/delivery_profile", "list", "readonly"},
|
resourceContract("profile", "delivery_profile", "readonly"),
|
||||||
{"staff", "staff_account", "/staff_account", "list", "writable"},
|
resourceContract("staff", "staff_account", "writable"),
|
||||||
{"staff", "staff_credential", "/staff_credential", "list", "writable"},
|
resourceContract("staff", "staff_credential", "writable"),
|
||||||
{"user", "user_account", "/user_account", "list", "writable"},
|
resourceContract("user", "user_account", "writable"),
|
||||||
{"user", "user_address", "/user_address", "list", "writable"},
|
resourceContract("user", "user_address", "writable"),
|
||||||
{"contract", "gasorder_contract", "/gasorder_contract", "list", "managed"},
|
resourceContract("contract", "gasorder_contract", "managed"),
|
||||||
{"contract", "gasorder_contract_product", "/gasorder_contract_product", "list", "append_only"},
|
resourceContract("contract", "gasorder_contract_product", "append_only"),
|
||||||
{"contract", "gasorder_contract_revision", "/gasorder_contract_revision", "list", "readonly"},
|
resourceContract("contract", "gasorder_contract_revision", "readonly"),
|
||||||
{"contract", "product_info", "/product_info", "list", "readonly"},
|
resourceContract("contract", "product_info", "readonly"),
|
||||||
{"gasorder", "gasorder_basic", "/gasorder_basic", "list", "append_only"},
|
resourceContract("gasorder", "gasorder_basic", "append_only"),
|
||||||
{"finance", "wallet_basic", "/wallet_basic", "list", "readonly"},
|
resourceContract("finance", "wallet_basic", "readonly"),
|
||||||
{"finance", "wallet_bank", "/wallet_bank", "list", "readonly"},
|
resourceContract("finance", "wallet_bank", "readonly"),
|
||||||
{"finance", "payment_order", "/payment_order", "list", "readonly"},
|
resourceContract("finance", "payment_order", "readonly"),
|
||||||
{"finance", "wallet_record", "/wallet_record", "list", "readonly"},
|
resourceContract("finance", "wallet_record", "readonly"),
|
||||||
{"finance", "payment_refund", "/payment_refund", "list", "readonly"},
|
resourceContract("finance", "payment_refund", "readonly"),
|
||||||
{"finance", "wallet_recharge", "/wallet_recharge", "list", "append_only"},
|
resourceContract("finance", "wallet_recharge", "append_only"),
|
||||||
{"finance", "wallet_apply_cash", "/wallet_apply_cash", "list", "append_only"},
|
resourceContract("finance", "wallet_apply_cash", "append_only"),
|
||||||
{"finance", "fin_settlement", "/fin_settlement", "list", "readonly"},
|
resourceContract("finance", "fin_settlement", "readonly"),
|
||||||
}
|
}
|
||||||
return items
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
62
backend/api/internal/logic/delivery/resource_search.go
Normal file
62
backend/api/internal/logic/delivery/resource_search.go
Normal 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}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package delivery
|
package delivery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.apinb.com/bsm-sdk/core/errcode"
|
"git.apinb.com/bsm-sdk/core/errcode"
|
||||||
@@ -187,17 +188,38 @@ func ArchiveStaff(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func credentialQuery(point models.DeliveryBasic) *gorm.DB {
|
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").
|
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 = ?",
|
Where("staff_account.delivery_basic_id = ? AND staff_account.gas_basic_id = ? AND staff_account.role_code = ?",
|
||||||
point.ID, point.GasBasicID, "delivery")
|
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) {
|
func ListCredential(ctx *gin.Context) {
|
||||||
point, _, ok := currentScope(ctx)
|
point, _, ok := currentScope(ctx)
|
||||||
if ok {
|
if !ok {
|
||||||
listScoped(ctx, &models.StaffCredential{}, credentialQuery(point), "staff_credential.created_at desc")
|
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) {
|
func GetCredential(ctx *gin.Context) {
|
||||||
@@ -260,9 +282,13 @@ func UpdateCredential(ctx *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if staff.ID != existing.StaffAccountID {
|
||||||
|
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := db().Model(&existing).Updates(map[string]any{
|
if err := db().Model(&existing).Updates(map[string]any{
|
||||||
"staff_account_id": staff.ID, "credential_type": request.CredentialType,
|
"credential_type": request.CredentialType, "credential_no": request.CredentialNo,
|
||||||
"credential_no": request.CredentialNo, "expired_at": request.ExpiredAt,
|
"expired_at": request.ExpiredAt,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
infra.Response.Error(ctx, err)
|
infra.Response.Error(ctx, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ var resourceSearchDefinitions = []resourceSearchDefinition{
|
|||||||
searchDefinition("delivery_basic", &models.DeliveryBasic{}, text("delivery_code"), text("name")),
|
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("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_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("user_account", &models.UserAccount{}, text("username")),
|
||||||
searchDefinition("producer_account", &models.ProducerAccount{}, text("name")),
|
searchDefinition("producer_account", &models.ProducerAccount{}, text("name")),
|
||||||
searchDefinition("product_type", &models.ProductType{}, text("code"), 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_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("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("wallet_basic", &models.WalletBasic{}, text("owner_type")),
|
||||||
searchDefinition("payment_refund", &models.PaymentRefund{}, text("refund_no")),
|
searchDefinition("payment_refund", &models.PaymentRefund{}, text("refund_no"), text("request_no")),
|
||||||
searchDefinition("wallet_apply_cash", &models.WalletApplyCash{}, text("cash_no")),
|
searchDefinition("wallet_apply_cash", &models.WalletApplyCash{}, text("cash_no"), text("request_no"), enum("channel", value("bank", "银行卡"), value("alipay", "支付宝"), value("wechat", "微信"))),
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
sdkmiddleware "git.apinb.com/bsm-sdk/core/middleware"
|
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"
|
deliverylogic "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/delivery"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -16,6 +17,7 @@ func RegisterDelivery(serviceKey string, engine *gin.Engine) {
|
|||||||
protected := engine.Group(basePath)
|
protected := engine.Group(basePath)
|
||||||
protected.Use(sdkmiddleware.JwtAuth(true))
|
protected.Use(sdkmiddleware.JwtAuth(true))
|
||||||
protected.Use(deliverylogic.RequireDeliveryAdmin())
|
protected.Use(deliverylogic.RequireDeliveryAdmin())
|
||||||
|
protected.Use(common.EnableConfiguredKeywordSearch())
|
||||||
protected.GET("/auth/profile", deliverylogic.CurrentProfile)
|
protected.GET("/auth/profile", deliverylogic.CurrentProfile)
|
||||||
protected.PUT("/auth/password", deliverylogic.ChangePassword)
|
protected.PUT("/auth/password", deliverylogic.ChangePassword)
|
||||||
protected.GET("/delivery_menu", deliverylogic.ListMenu)
|
protected.GET("/delivery_menu", deliverylogic.ListMenu)
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ Global:
|
|||||||
- 新建人员的 `role_code` 由服务端固定为 `delivery`。
|
- 新建人员的 `role_code` 由服务端固定为 `delivery`。
|
||||||
- 人员的气站及配送点归属由服务端固定为当前范围。
|
- 人员的气站及配送点归属由服务端固定为当前范围。
|
||||||
- 资质支持新增、编辑、启停和归档。
|
- 资质支持新增、编辑、启停和归档。
|
||||||
|
- 人员资质必须从配送人员列表进入,服务端按当前气站、配送点、配送角色和人员标识强制过滤;缺少、无效或越权人员上下文时不得返回全部资质。
|
||||||
|
- 资质列表显示服务端回查的人员姓名和可复制唯一标识;新建自动预填所属人员,创建和编辑均不得更换所属人员。
|
||||||
|
- 标准资源搜索必须由后端显式字段契约驱动;没有有效搜索字段时不显示搜索区,枚举字段支持中文名称和内部稳定编码。
|
||||||
- 删除均为归档;存在未完成订单时禁止归档人员。
|
- 删除均为归档;存在未完成订单时禁止归档人员。
|
||||||
- 不允许创建安装、运维、仓管、调度员或质控角色账号。
|
- 不允许创建安装、运维、仓管、调度员或质控角色账号。
|
||||||
- 配送人员列表不得展示数据库自增 ID;联系电话之后显示 32px 圆形头像缩略图,头像仅通过当前配送点 JWT 范围内的受保护接口懒加载。无头像或读取异常时显示默认头像,不得从列表响应读取或暴露头像 URI。
|
- 配送人员列表不得展示数据库自增 ID;联系电话之后显示 32px 圆形头像缩略图,头像仅通过当前配送点 JWT 范围内的受保护接口懒加载。无头像或读取异常时显示默认头像,不得从列表响应读取或暴露头像 URI。
|
||||||
|
|||||||
@@ -22,6 +22,10 @@
|
|||||||
10. 配送人员和用户账户列表增加 32px 受控头像缩略图,复用 5173 的懒加载、并发 6、当前页缓存、取消和 Blob URL 回收策略。
|
10. 配送人员和用户账户列表增加 32px 受控头像缩略图,复用 5173 的懒加载、并发 6、当前页缓存、取消和 Blob URL 回收策略。
|
||||||
11. 删除全部标准资源列表的数据库自增 ID 列,只保留系统唯一标识;同时把该规则加入自动检查。
|
11. 删除全部标准资源列表的数据库自增 ID 列,只保留系统唯一标识;同时把该规则加入自动检查。
|
||||||
12. 配送人员头像读取补充 `role_code = delivery` 范围限制,并新增 SQL 范围测试。
|
12. 配送人员头像读取补充 `role_code = delivery` 范围限制,并新增 SQL 范围测试。
|
||||||
|
13. 修复配送人员资质列表未应用人员筛选的问题,增加气站、配送点、配送角色和人员 ID 四重范围校验。
|
||||||
|
14. 资质列表新增人员姓名上下文、安全返回、重复人员列隐藏;新建预填所属人员,创建和编辑锁定归属。
|
||||||
|
15. 新增后端生成的 `searchFields` 契约;无有效字段时隐藏搜索区,枚举字段支持中文名称和内部编码。
|
||||||
|
16. 标准列表搜索标签统一为“模糊搜索”,输入框继续提示当前资源的具体可搜索字段。
|
||||||
|
|
||||||
## 操作后状态
|
## 操作后状态
|
||||||
|
|
||||||
@@ -41,7 +45,7 @@
|
|||||||
|
|
||||||
- 变更前:列表抽屉承载记录操作,浏览器地址不随记录变化。
|
- 变更前:列表抽屉承载记录操作,浏览器地址不随记录变化。
|
||||||
- 变更后:每个允许的操作拥有独立 URL,详情页集中承载业务动作。
|
- 变更后:每个允许的操作拥有独立 URL,详情页集中承载业务动作。
|
||||||
- 兼容性:现有列表菜单地址、后端接口和配送点数据范围保持不变。
|
- 兼容性:现有列表菜单地址和响应结构保持不变;人员资质列表现在必须提供人员标识,修复了原先可能扩大到本点全部资质的范围错误。
|
||||||
|
|
||||||
## 验证结果
|
## 验证结果
|
||||||
|
|
||||||
@@ -49,13 +53,15 @@
|
|||||||
- `contract:check`:通过,18 个资源与后端契约一致。
|
- `contract:check`:通过,18 个资源与后端契约一致。
|
||||||
- `profile:check`:通过,资料专用只读页未回退。
|
- `profile:check`:通过,资料专用只读页未回退。
|
||||||
- `type:check`:通过。
|
- `type:check`:通过。
|
||||||
- `build`:通过,2632 个模块完成生产构建。
|
- `build`:通过,2634 个模块完成生产构建。
|
||||||
- `go test ./internal/logic/delivery ./internal/routers`:通过,包含配送人员头像角色范围测试。
|
- `go test ./internal/logic/common ./internal/logic/delivery ./internal/logic/platform ./internal/routers ./cmd/cli`:通过,包含搜索枚举、配送人员头像角色范围和资质人员范围测试。
|
||||||
- `lint`:通过;仅报告项目既有警告,未产生失败项。
|
- `lint`:通过;仅报告项目既有警告,未产生失败项。
|
||||||
- 浏览器回归:工作人员列表、详情、编辑、正式新建地址、返回链路、未保存保护、配送订单新建页、支付列表均通过。
|
- 浏览器回归:工作人员列表、详情、编辑、正式新建地址、返回链路、未保存保护、配送订单新建页、支付列表均通过。
|
||||||
- 布局回归:工作人员详情、编辑和配送订单新建页已在应用内浏览器逐页截图检查,与 5173 的页面结构和响应式断点一致。
|
- 布局回归:工作人员详情、编辑和配送订单新建页已在应用内浏览器逐页截图检查,与 5173 的页面结构和响应式断点一致。
|
||||||
- 头像回归:配送人员新建、编辑页已确认不再显示头像文本框,头像选择按钮、格式大小提示、默认头像和身份摘要均正常;后端头像路由测试通过。
|
- 头像回归:配送人员新建、编辑页已确认不再显示头像文本框,头像选择按钮、格式大小提示、默认头像和身份摘要均正常;后端头像路由测试通过。
|
||||||
- 列表头像回归:配送人员、用户账户显示受控圆形头像,其他资源不生成无效头像列;数据库 ID 列已从全部标准列表移除;点击刷新后头像重新加载正常。
|
- 列表头像回归:配送人员、用户账户显示受控圆形头像,其他资源不生成无效头像列;数据库 ID 列已从全部标准列表移除;点击刷新后头像重新加载正常。
|
||||||
|
- 资质关联回归:有效人员仅返回本人资质,标题和上下文显示服务端姓名及唯一标识;新建页预填并锁定人员,缺少上下文自动返回,伪造人员标识显示错误且不渲染表格。
|
||||||
|
- 搜索回归:资质页提示“可搜索:资质类型、资质编号”;银行卡页不显示关键字、查询和重置,仅保留刷新。
|
||||||
|
|
||||||
## 风险评估
|
## 风险评估
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ frontend/delivery_admin/
|
|||||||
├── api/
|
├── api/
|
||||||
│ ├── resource-display.ts # 中文字段、状态和详情值展示
|
│ ├── resource-display.ts # 中文字段、状态和详情值展示
|
||||||
│ ├── avatar.ts # 头像上传与配送点鉴权读取
|
│ ├── avatar.ts # 头像上传与配送点鉴权读取
|
||||||
|
│ ├── resource-search-contract.ts # 后端生成的显式搜索字段契约
|
||||||
│ ├── resource-navigation.ts # 独立页面路由和安全返回地址
|
│ ├── resource-navigation.ts # 独立页面路由和安全返回地址
|
||||||
│ └── resource-record-form.ts # 表单初始化、字段白名单和校验
|
│ └── resource-record-form.ts # 表单初始化、字段白名单和校验
|
||||||
├── router/routes/modules/
|
├── router/routes/modules/
|
||||||
@@ -55,6 +56,10 @@ frontend/delivery_admin/
|
|||||||
|
|
||||||
配送人员和用户账户列表同样参考 5173:联系电话后显示 32px 圆形头像,最多并发 6 个鉴权请求,接近可视区域才加载;当前页缓存结果,刷新或离页时取消请求并释放 Blob URL。404、网络错误和图片解码失败均回退本地默认头像。全部标准资源列表隐藏数据库自增 ID,只展示可复制的系统唯一标识。
|
配送人员和用户账户列表同样参考 5173:联系电话后显示 32px 圆形头像,最多并发 6 个鉴权请求,接近可视区域才加载;当前页缓存结果,刷新或离页时取消请求并释放 Blob URL。404、网络错误和图片解码失败均回退本地默认头像。全部标准资源列表隐藏数据库自增 ID,只展示可复制的系统唯一标识。
|
||||||
|
|
||||||
|
配送人员资质列表必须由配送人员列表进入。页面先通过受保护人员详情接口回查姓名,再以当前气站、配送点、`delivery` 角色和人员 ID 四重条件加载资质;缺少、无效或越权人员上下文时停止加载,绝不回退为全部资质。标题和上下文区域显示人员姓名及可复制唯一标识,表格隐藏重复人员列。新建页自动预填所属人员,创建和编辑均锁定该关系,返回地址只接受安全站内路径。
|
||||||
|
|
||||||
|
资源搜索由后端 `searchFields` 契约驱动。没有有效字段的配送点资料、用户地址和银行卡页面不渲染搜索区域;其他页面显示“可搜索:具体字段”提示。枚举字段同时接受中文展示名称和内部稳定编码,查询、分页和重置始终保留人员上下文及安全来源参数。
|
||||||
|
|
||||||
`resource-record-form.ts` 为五类可编辑资源声明后端更新字段白名单,防止用户名、合同编号等只读字段出现在编辑页或被无效提交。
|
`resource-record-form.ts` 为五类可编辑资源声明后端更新字段白名单,防止用户名、合同编号等只读字段出现在编辑页或被无效提交。
|
||||||
|
|
||||||
支付与退款资源统一使用后端正式名称 `payment_order`、`payment_refund`,菜单地址仍保持 `/finance/payments`、`/finance/refunds`,避免接口路径不一致导致 404。
|
支付与退款资源统一使用后端正式名称 `payment_order`、`payment_refund`,菜单地址仍保持 `/finance/payments`、`/finance/refunds`,避免接口路径不一致导致 404。
|
||||||
@@ -79,6 +84,8 @@ npm.cmd run build
|
|||||||
- 修复支付、退款资源与后端契约名称不一致的问题。
|
- 修复支付、退款资源与后端契约名称不一致的问题。
|
||||||
- 新增 17/9/5 页面能力矩阵自动检查。
|
- 新增 17/9/5 页面能力矩阵自动检查。
|
||||||
- 新增配送人员、用户账户的受控头像缩略图,并移除全部标准列表的数据库自增 ID。
|
- 新增配送人员、用户账户的受控头像缩略图,并移除全部标准列表的数据库自增 ID。
|
||||||
|
- 新增配送人员资质的强制人员范围、上下文展示、关系预填锁定和安全返回链路。
|
||||||
|
- 新增后端生成的资源搜索契约,隐藏无效搜索并支持枚举中文名称与稳定编码。
|
||||||
- 保持配送点资料专用只读页面和现有公共后端接口不变。
|
- 保持配送点资料专用只读页面和现有公共后端接口不变。
|
||||||
|
|
||||||
## 7. 已知边界
|
## 7. 已知边界
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const creatable = new Set([
|
|||||||
const editable = new Set([
|
const editable = new Set([
|
||||||
'staff_account', 'staff_credential', 'user_account', 'user_address', 'gasorder_contract',
|
'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) {
|
function assert(condition, message) {
|
||||||
@@ -72,5 +73,20 @@ assert(listPage.includes('avatarLoader.reset()'), '列表刷新未清理头像
|
|||||||
const avatarLoader = read('src/views/shared/protected-list-avatar-loader.ts');
|
const avatarLoader = read('src/views/shared/protected-list-avatar-loader.ts');
|
||||||
assert(avatarLoader.includes('MAX_CONCURRENT_REQUESTS = 6'), '头像请求并发上限未与 5173 对齐');
|
assert(avatarLoader.includes('MAX_CONCURRENT_REQUESTS = 6'), '头像请求并发上限未与 5173 对齐');
|
||||||
assert(avatarLoader.includes("new Set(['staff_account', 'user_account'])"), '头像列表资源白名单不正确');
|
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}`);
|
console.log(`独立资源页面契约通过:详情 ${listResources.length},新建 ${creatable.size},编辑 ${editable.size}`);
|
||||||
|
|||||||
@@ -25,11 +25,12 @@ export function recordRouteLocation(
|
|||||||
mode: RecordNavigationMode,
|
mode: RecordNavigationMode,
|
||||||
identity: string,
|
identity: string,
|
||||||
returnTo: string,
|
returnTo: string,
|
||||||
|
context: Record<string, string> = {},
|
||||||
): RouteLocationRaw {
|
): RouteLocationRaw {
|
||||||
return {
|
return {
|
||||||
name: `${listRouteName}-${mode}`,
|
name: `${listRouteName}-${mode}`,
|
||||||
...(mode === 'create' ? {} : { params: { identity } }),
|
...(mode === 'create' ? {} : { params: { identity } }),
|
||||||
query: returnTo ? { return_to: returnTo } : {},
|
query: { ...context, ...(returnTo ? { return_to: returnTo } : {}) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
29
frontend/delivery_admin/src/api/resource-search-contract.ts
Normal file
29
frontend/delivery_admin/src/api/resource-search-contract.ts
Normal 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 })),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
* 功能描述:定义配送点后台资源能力、字段契约和受控业务动作。
|
* 功能描述:定义配送点后台资源能力、字段契约和受控业务动作。
|
||||||
* 版本:v1.1.0。
|
* 版本:v1.1.0。
|
||||||
*/
|
*/
|
||||||
|
import { resourceSearchFields, type ResourceSearchField } from './resource-search-contract';
|
||||||
|
|
||||||
export type ResourceMode =
|
export type ResourceMode =
|
||||||
| 'writable'
|
| 'writable'
|
||||||
| 'readonly'
|
| 'readonly'
|
||||||
@@ -59,6 +61,7 @@ export type ResourceUiDefinition = {
|
|||||||
mode: ResourceMode;
|
mode: ResourceMode;
|
||||||
pageKind: ResourcePageKind;
|
pageKind: ResourcePageKind;
|
||||||
fields: ResourceField[];
|
fields: ResourceField[];
|
||||||
|
searchFields: ResourceSearchField[];
|
||||||
detailActions?: DetailAction[];
|
detailActions?: DetailAction[];
|
||||||
canCreate: boolean;
|
canCreate: boolean;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
@@ -338,6 +341,7 @@ function define(
|
|||||||
mode,
|
mode,
|
||||||
pageKind,
|
pageKind,
|
||||||
fields,
|
fields,
|
||||||
|
searchFields: resourceSearchFields(name),
|
||||||
...defaults,
|
...defaults,
|
||||||
...capabilities,
|
...capabilities,
|
||||||
...(detailActions ? { detailActions } : {}),
|
...(detailActions ? { detailActions } : {}),
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -66,3 +66,14 @@
|
|||||||
.section-card :deep(.arco-card-header),
|
.section-card :deep(.arco-card-header),
|
||||||
.section-card :deep(.arco-card-body) { padding-right: 16px; padding-left: 16px; }
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,18 @@
|
|||||||
</a-result>
|
</a-result>
|
||||||
|
|
||||||
<template v-else>
|
<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
|
<ResourceAccountSummary
|
||||||
v-if="hasAvatarField"
|
v-if="hasAvatarField"
|
||||||
:mode="mode"
|
:mode="mode"
|
||||||
@@ -111,6 +123,7 @@ import {
|
|||||||
import { primaryRecord, recordEditReason } from '@/api/resource-display';
|
import { primaryRecord, recordEditReason } from '@/api/resource-display';
|
||||||
import { recordRouteLocation, returnToList, safeReturnPath } from '@/api/resource-navigation';
|
import { recordRouteLocation, returnToList, safeReturnPath } from '@/api/resource-navigation';
|
||||||
import { getResource, type DetailAction, type ResourceField } from '@/api/resources';
|
import { getResource, type DetailAction, type ResourceField } from '@/api/resources';
|
||||||
|
import IdentityText from '@/components/IdentityText.vue';
|
||||||
import ResourceActionDialog from './ResourceActionDialog.vue';
|
import ResourceActionDialog from './ResourceActionDialog.vue';
|
||||||
import ResourceAccountSummary from './ResourceAccountSummary.vue';
|
import ResourceAccountSummary from './ResourceAccountSummary.vue';
|
||||||
import ResourceDetailContent from './ResourceDetailContent.vue';
|
import ResourceDetailContent from './ResourceDetailContent.vue';
|
||||||
@@ -131,6 +144,7 @@ const loading = ref(false);
|
|||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
const detail = ref<ResourceRow>({});
|
const detail = ref<ResourceRow>({});
|
||||||
|
const credentialOwner = ref<ResourceRow>();
|
||||||
const record = computed(() => primaryRecord(detail.value));
|
const record = computed(() => primaryRecord(detail.value));
|
||||||
const form = reactive<Record<string, any>>({});
|
const form = reactive<Record<string, any>>({});
|
||||||
const relationOptions = reactive<Record<string, ResourceRow[]>>({});
|
const relationOptions = reactive<Record<string, ResourceRow[]>>({});
|
||||||
@@ -142,6 +156,7 @@ const avatar = useResourceAvatar();
|
|||||||
const hasAvatarField = computed(() =>
|
const hasAvatarField = computed(() =>
|
||||||
definition.value.fields.some((field) => field.key === 'avatar'),
|
definition.value.fields.some((field) => field.key === 'avatar'),
|
||||||
);
|
);
|
||||||
|
const isCredentialResource = computed(() => definition.value.name === 'staff_credential');
|
||||||
const summaryRecord = computed(() =>
|
const summaryRecord = computed(() =>
|
||||||
mode.value === 'detail' ? record.value : { ...record.value, ...form },
|
mode.value === 'detail' ? record.value : { ...record.value, ...form },
|
||||||
);
|
);
|
||||||
@@ -149,7 +164,11 @@ const formFields = computed(() => recordFormFields(
|
|||||||
definition.value.name,
|
definition.value.name,
|
||||||
definition.value.fields,
|
definition.value.fields,
|
||||||
mode.value as 'create' | 'edit',
|
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(
|
const canEditRecord = computed(
|
||||||
() => definition.value.canEdit && !recordEditReason(definition.value, record.value),
|
() => 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() {
|
async function save() {
|
||||||
const validation = validateResourceRecordForm(form, formFields.value, mode.value as 'create' | 'edit');
|
const validation = validateResourceRecordForm(form, formFields.value, mode.value as 'create' | 'edit');
|
||||||
@@ -280,6 +316,10 @@ function confirmArchive() {
|
|||||||
|
|
||||||
/** 加载关联候选,配送人员关系固定为配送角色。 */
|
/** 加载关联候选,配送人员关系固定为配送角色。 */
|
||||||
async function loadRelation(resource: string, keyword = '') {
|
async function loadRelation(resource: string, keyword = '') {
|
||||||
|
if (isCredentialResource.value && resource === '/staff_account') {
|
||||||
|
await loadCredentialOwner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
relationLoading[resource] = true;
|
relationLoading[resource] = true;
|
||||||
try {
|
try {
|
||||||
const filters: Record<string, string> = keyword ? { keyword } : {};
|
const filters: Record<string, string> = keyword ? { keyword } : {};
|
||||||
@@ -301,13 +341,28 @@ function searchRelation(resource: string | undefined, keyword: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
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(
|
const paths = new Set(
|
||||||
[...definition.value.fields, ...(definition.value.detailActions ?? []).flatMap((item) => item.fields ?? [])]
|
[...definition.value.fields, ...(definition.value.detailActions ?? []).flatMap((item) => item.fields ?? [])]
|
||||||
.map((field: ResourceField) => field.relation)
|
.map((field: ResourceField) => field.relation)
|
||||||
.filter((value): value is string => Boolean(value)),
|
.filter((value): value is string => Boolean(value)),
|
||||||
);
|
);
|
||||||
await Promise.all([...paths].map((resource) => loadRelation(resource)));
|
try {
|
||||||
await loadRecord();
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<!-- 功能描述:展示配送点标准资源列表并导航到独立记录页。版本:v1.0.0。 -->
|
<!-- 功能描述:展示配送点标准资源列表并导航到独立记录页。版本:v1.0.0。 -->
|
||||||
<template>
|
<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
|
<a-alert
|
||||||
v-if="definition.detailActions?.length"
|
v-if="definition.detailActions?.length"
|
||||||
class="workflow-alert"
|
class="workflow-alert"
|
||||||
@@ -10,10 +13,26 @@
|
|||||||
>
|
>
|
||||||
请从详情页中的专用操作推进流程,操作结果由服务端状态校验并保留审计记录。
|
请从详情页中的专用操作推进流程,操作结果由服务端状态校验并保留审计记录。
|
||||||
</a-alert>
|
</a-alert>
|
||||||
<div class="list-toolbar">
|
<div v-if="credentialOwner" class="owner-context">
|
||||||
<a-form :model="filters" layout="inline" @submit="search">
|
<div>
|
||||||
<a-form-item label="关键字">
|
<div class="owner-label">当前配送人员</div>
|
||||||
<a-input v-model="filters.keyword" allow-clear placeholder="关键字段模糊搜索" />
|
<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-form-item>
|
||||||
<a-button type="primary" html-type="submit">查询</a-button>
|
<a-button type="primary" html-type="submit">查询</a-button>
|
||||||
<a-button @click="resetSearch">重置</a-button>
|
<a-button @click="resetSearch">重置</a-button>
|
||||||
@@ -94,6 +113,7 @@
|
|||||||
@change="changePage"
|
@change="changePage"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</a-card>
|
</a-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -108,9 +128,9 @@ import {
|
|||||||
recordStatusColor,
|
recordStatusColor,
|
||||||
recordStatusLabel,
|
recordStatusLabel,
|
||||||
} from '@/api/resource-display';
|
} 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 { 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 IdentityText from '@/components/IdentityText.vue';
|
||||||
import ProtectedAvatarThumbnail from './ProtectedAvatarThumbnail.vue';
|
import ProtectedAvatarThumbnail from './ProtectedAvatarThumbnail.vue';
|
||||||
import {
|
import {
|
||||||
@@ -127,16 +147,57 @@ const page = ref(Math.max(1, Number(route.query.page) || 1));
|
|||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const list = ref<ResourceRow[]>([]);
|
const list = ref<ResourceRow[]>([]);
|
||||||
|
const credentialOwner = ref<ResourceRow>();
|
||||||
|
const contextError = ref('');
|
||||||
const avatarLoader = createProtectedListAvatarLoader();
|
const avatarLoader = createProtectedListAvatarLoader();
|
||||||
const avatarRefreshKey = ref(0);
|
const avatarRefreshKey = ref(0);
|
||||||
const filters = reactive({ keyword: typeof route.query.keyword === 'string' ? route.query.keyword : '' });
|
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(() =>
|
const displayFields = computed(() =>
|
||||||
props.definition.fields
|
props.definition.fields
|
||||||
.filter((field) => !['identity', 'password', 'status'].includes(field.key))
|
.filter((field) => !['identity', 'password', 'status'].includes(field.key))
|
||||||
.filter((field) => field.key !== 'avatar' || isProtectedListAvatarField(props.definition.name, 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),
|
.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) {
|
function columnWidth(field: ResourceField) {
|
||||||
if (isProtectedListAvatarField(props.definition.name, field.key)) return 72;
|
if (isProtectedListAvatarField(props.definition.name, field.key)) return 72;
|
||||||
@@ -156,7 +217,9 @@ async function load() {
|
|||||||
avatarRefreshKey.value += 1;
|
avatarRefreshKey.value += 1;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
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')
|
if (typeof route.query.owner_identity === 'string' && typeof route.query.relation_key === 'string')
|
||||||
serverFilters[route.query.relation_key] = route.query.owner_identity;
|
serverFilters[route.query.relation_key] = route.query.owner_identity;
|
||||||
const result = await resourceApi.list<ResourceRow>(
|
const result = await resourceApi.list<ResourceRow>(
|
||||||
@@ -179,8 +242,9 @@ async function syncQuery() {
|
|||||||
const query: Record<string, string> = {};
|
const query: Record<string, string> = {};
|
||||||
if (typeof route.query.owner_identity === 'string') query.owner_identity = route.query.owner_identity;
|
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.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 (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 });
|
await router.replace({ query });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +261,12 @@ async function resetSearch() {
|
|||||||
|
|
||||||
/** 打开当前列表对应的正式新建页。 */
|
/** 打开当前列表对应的正式新建页。 */
|
||||||
function openCreate() {
|
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: {
|
query: {
|
||||||
owner_identity: String(row.identity ?? ''),
|
owner_identity: String(row.identity ?? ''),
|
||||||
relation_key: 'staff_account_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) {
|
async function changePage(next: number) {
|
||||||
page.value = next;
|
page.value = next;
|
||||||
await syncQuery();
|
await syncQuery();
|
||||||
@@ -228,9 +304,11 @@ async function changePage(next: number) {
|
|||||||
|
|
||||||
onMounted(load);
|
onMounted(load);
|
||||||
onBeforeUnmount(avatarLoader.reset);
|
onBeforeUnmount(avatarLoader.reset);
|
||||||
watch(() => props.definition.resource, async () => {
|
watch(() => [props.definition.resource, ownerIdentity.value], async () => {
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
filters.keyword = '';
|
filters.keyword = '';
|
||||||
|
credentialOwner.value = undefined;
|
||||||
|
contextError.value = '';
|
||||||
await load();
|
await load();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -238,6 +316,19 @@ watch(() => props.definition.resource, async () => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.workflow-alert { margin-bottom: 16px; }
|
.workflow-alert { margin-bottom: 16px; }
|
||||||
.list-toolbar { display: flex; justify-content: space-between; gap: 16px; 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; }
|
.pagination { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||||
@media (max-width: 768px) { .list-toolbar { align-items: stretch; flex-direction: column; } }
|
@media (max-width: 768px) { .list-toolbar { align-items: stretch; flex-direction: column; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user