功能:按资源配置平台列表搜索
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/config"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/initdb"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
|
||||
deliverylogic "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/delivery"
|
||||
gaslogic "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/gas"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform"
|
||||
@@ -79,11 +80,12 @@ type route struct {
|
||||
}
|
||||
|
||||
type contract 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"`
|
||||
}
|
||||
|
||||
type manifest struct {
|
||||
@@ -107,7 +109,7 @@ func writeResourceContract(output io.Writer) error {
|
||||
for _, item := range expected {
|
||||
contracts = append(contracts, contract{
|
||||
Domain: item.Domain, Name: item.Name, Path: item.Path,
|
||||
PageKind: item.PageKind, Mode: string(item.Mode),
|
||||
PageKind: item.PageKind, Mode: string(item.Mode), SearchFields: item.SearchFields,
|
||||
})
|
||||
}
|
||||
return json.NewEncoder(output).Encode(manifest{Resources: contracts, Routes: routes})
|
||||
|
||||
@@ -141,6 +141,13 @@ func ApplyKeywordFilter(ctx *gin.Context, query *gorm.DB, model any) *gorm.DB {
|
||||
if keyword == "" {
|
||||
return query
|
||||
}
|
||||
if useConfiguredKeywordSearch(ctx) {
|
||||
conditions, arguments := configuredKeywordConditions(model, keyword)
|
||||
if len(conditions) == 0 {
|
||||
return query
|
||||
}
|
||||
return query.Where("("+strings.Join(conditions, " OR ")+")", arguments...)
|
||||
}
|
||||
columns := keywordColumns(model)
|
||||
if len(columns) == 0 {
|
||||
return query
|
||||
|
||||
163
backend/api/internal/logic/common/keyword_search.go
Normal file
163
backend/api/internal/logic/common/keyword_search.go
Normal file
@@ -0,0 +1,163 @@
|
||||
// Package common 提供平台资源可复用的配置化模糊搜索能力。
|
||||
// 版本:v1.0.0
|
||||
package common
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const configuredKeywordSearchContextKey = "configured_keyword_search"
|
||||
|
||||
// KeywordSearchKind 描述搜索字段的匹配方式。
|
||||
type KeywordSearchKind string
|
||||
|
||||
const (
|
||||
// KeywordSearchText 按数据库原始文本执行不区分大小写的包含匹配。
|
||||
KeywordSearchText KeywordSearchKind = "text"
|
||||
// KeywordSearchEnum 仅按页面展示的中文枚举名称匹配,不暴露内部英文编码。
|
||||
KeywordSearchEnum KeywordSearchKind = "enum"
|
||||
)
|
||||
|
||||
// KeywordSearchValue 描述枚举搜索中的可信编码与页面中文名称。
|
||||
type KeywordSearchValue struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// KeywordSearchField 描述一个资源允许搜索的可见字段。
|
||||
type KeywordSearchField struct {
|
||||
Key string `json:"key"`
|
||||
Kind KeywordSearchKind `json:"kind"`
|
||||
Values []KeywordSearchValue `json:"values,omitempty"`
|
||||
}
|
||||
|
||||
var configuredKeywordPolicies = struct {
|
||||
sync.RWMutex
|
||||
fields map[reflect.Type][]KeywordSearchField
|
||||
}{fields: make(map[reflect.Type][]KeywordSearchField)}
|
||||
|
||||
// EnableConfiguredKeywordSearch 标记当前路由使用资源级搜索配置。
|
||||
// 返回值:Gin 中间件,仅影响挂载该中间件的平台总后台路由。
|
||||
func EnableConfiguredKeywordSearch() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
ctx.Set(configuredKeywordSearchContextKey, true)
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterKeywordSearchPolicy 注册模型的搜索字段,并在启动阶段拒绝不安全配置。
|
||||
// 参数:model 必须为模型结构体指针;fields 只能引用安全白名单中的直接字符串列。
|
||||
func RegisterKeywordSearchPolicy(model any, fields []KeywordSearchField) {
|
||||
modelType := indirectModelType(model)
|
||||
validated := make([]KeywordSearchField, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
validateKeywordSearchField(model, modelType, field)
|
||||
validated = append(validated, cloneKeywordSearchField(field))
|
||||
}
|
||||
configuredKeywordPolicies.Lock()
|
||||
defer configuredKeywordPolicies.Unlock()
|
||||
if _, exists := configuredKeywordPolicies.fields[modelType]; exists {
|
||||
panic("重复注册资源搜索策略:" + modelType.String())
|
||||
}
|
||||
configuredKeywordPolicies.fields[modelType] = validated
|
||||
}
|
||||
|
||||
// ConfiguredKeywordSearchFields 返回模型的只读搜索契约副本。
|
||||
func ConfiguredKeywordSearchFields(model any) []KeywordSearchField {
|
||||
modelType := indirectModelType(model)
|
||||
configuredKeywordPolicies.RLock()
|
||||
defer configuredKeywordPolicies.RUnlock()
|
||||
fields := configuredKeywordPolicies.fields[modelType]
|
||||
result := make([]KeywordSearchField, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
result = append(result, cloneKeywordSearchField(field))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func useConfiguredKeywordSearch(ctx *gin.Context) bool {
|
||||
enabled, exists := ctx.Get(configuredKeywordSearchContextKey)
|
||||
return exists && enabled == true
|
||||
}
|
||||
|
||||
// configuredKeywordConditions 将用户关键字编译为参数化 SQL 条件。
|
||||
// 枚举字段只接受中文展示名称,普通文本字段保持原有包含匹配行为。
|
||||
func configuredKeywordConditions(model any, keyword string) ([]string, []any) {
|
||||
fields := ConfiguredKeywordSearchFields(model)
|
||||
conditions := make([]string, 0, len(fields))
|
||||
arguments := make([]any, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
switch field.Kind {
|
||||
case KeywordSearchText:
|
||||
conditions = append(conditions, `LOWER("`+field.Key+`") LIKE ?`)
|
||||
arguments = append(arguments, "%"+keyword+"%")
|
||||
case KeywordSearchEnum:
|
||||
values := matchingKeywordEnumValues(field.Values, keyword)
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
placeholders := strings.TrimRight(strings.Repeat("?,", len(values)), ",")
|
||||
conditions = append(conditions, `"`+field.Key+`" IN (`+placeholders+")")
|
||||
for _, value := range values {
|
||||
arguments = append(arguments, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return conditions, arguments
|
||||
}
|
||||
|
||||
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) {
|
||||
matched = append(matched, value.Value)
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
func validateKeywordSearchField(model any, modelType reflect.Type, field KeywordSearchField) {
|
||||
if !keywordSafeColumns[field.Key] || isSensitiveKeywordColumn(model, field.Key) {
|
||||
panic("资源搜索字段不在安全白名单中:" + modelType.String() + "." + field.Key)
|
||||
}
|
||||
if !hasDirectStringColumn(modelType, field.Key) {
|
||||
panic("资源搜索字段不是模型直接字符串列:" + modelType.String() + "." + field.Key)
|
||||
}
|
||||
if field.Kind != KeywordSearchText && field.Kind != KeywordSearchEnum {
|
||||
panic("资源搜索字段类型无效:" + string(field.Kind))
|
||||
}
|
||||
if field.Kind == KeywordSearchEnum && len(field.Values) == 0 {
|
||||
panic("枚举搜索字段缺少中文值:" + modelType.String() + "." + field.Key)
|
||||
}
|
||||
}
|
||||
|
||||
func hasDirectStringColumn(modelType reflect.Type, column string) bool {
|
||||
for index := 0; index < modelType.NumField(); index++ {
|
||||
field := modelType.Field(index)
|
||||
if !field.Anonymous && field.Type.Kind() == reflect.String && gormColumn(field.Tag.Get("gorm")) == column {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func indirectModelType(model any) reflect.Type {
|
||||
modelType := reflect.TypeOf(model)
|
||||
for modelType.Kind() == reflect.Pointer {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
if modelType.Kind() != reflect.Struct {
|
||||
panic("资源搜索模型必须为结构体或结构体指针")
|
||||
}
|
||||
return modelType
|
||||
}
|
||||
|
||||
func cloneKeywordSearchField(field KeywordSearchField) KeywordSearchField {
|
||||
clone := field
|
||||
clone.Values = append([]KeywordSearchValue(nil), field.Values...)
|
||||
return clone
|
||||
}
|
||||
76
backend/api/internal/logic/common/keyword_search_test.go
Normal file
76
backend/api/internal/logic/common/keyword_search_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// keywordSearchEnumModel 用于验证中文枚举别名不会退化为英文编码搜索。
|
||||
type keywordSearchEnumModel struct {
|
||||
RoleCode string `gorm:"column:role_code"`
|
||||
}
|
||||
|
||||
// keywordSearchTextModel 用于验证普通文本字段继续执行包含匹配。
|
||||
type keywordSearchTextModel struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
|
||||
var registerKeywordSearchTestPolicies sync.Once
|
||||
|
||||
// registerKeywordSearchPoliciesForTest 保证单测可独立或组合运行。
|
||||
func registerKeywordSearchPoliciesForTest() {
|
||||
registerKeywordSearchTestPolicies.Do(func() {
|
||||
RegisterKeywordSearchPolicy(&keywordSearchEnumModel{}, []KeywordSearchField{{
|
||||
Key: "role_code",
|
||||
Kind: KeywordSearchEnum,
|
||||
Values: []KeywordSearchValue{
|
||||
{Value: "installer", Label: "安装人员"},
|
||||
{Value: "delivery", Label: "配送人员"},
|
||||
{Value: "operations", Label: "运维人员"},
|
||||
},
|
||||
}})
|
||||
RegisterKeywordSearchPolicy(&keywordSearchTextModel{}, []KeywordSearchField{{
|
||||
Key: "name", Kind: KeywordSearchText,
|
||||
}})
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfiguredKeywordConditionsMatchChineseEnumLabels(t *testing.T) {
|
||||
registerKeywordSearchPoliciesForTest()
|
||||
|
||||
conditions, arguments := configuredKeywordConditions(&keywordSearchEnumModel{}, "人员")
|
||||
if !reflect.DeepEqual(conditions, []string{`"role_code" IN (?,?,?)`}) {
|
||||
t.Fatalf("中文枚举条件不符合预期:%v", conditions)
|
||||
}
|
||||
if !reflect.DeepEqual(arguments, []any{"installer", "delivery", "operations"}) {
|
||||
t.Fatalf("中文枚举编码不符合预期:%v", arguments)
|
||||
}
|
||||
|
||||
conditions, arguments = configuredKeywordConditions(&keywordSearchEnumModel{}, "delivery")
|
||||
if len(conditions) != 0 || len(arguments) != 0 {
|
||||
t.Fatalf("英文枚举编码不应继续可搜:conditions=%v arguments=%v", conditions, arguments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredKeywordConditionsKeepTextFuzzySearch(t *testing.T) {
|
||||
registerKeywordSearchPoliciesForTest()
|
||||
|
||||
conditions, arguments := configuredKeywordConditions(&keywordSearchTextModel{}, "气站")
|
||||
if !reflect.DeepEqual(conditions, []string{`LOWER("name") LIKE ?`}) {
|
||||
t.Fatalf("普通文本条件不符合预期:%v", conditions)
|
||||
}
|
||||
if !reflect.DeepEqual(arguments, []any{"%气站%"}) {
|
||||
t.Fatalf("普通文本参数不符合预期:%v", arguments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredKeywordSearchFieldsReturnsCopy(t *testing.T) {
|
||||
registerKeywordSearchPoliciesForTest()
|
||||
fields := ConfiguredKeywordSearchFields(&keywordSearchEnumModel{})
|
||||
fields[0].Values[0].Label = "已篡改"
|
||||
again := ConfiguredKeywordSearchFields(&keywordSearchEnumModel{})
|
||||
if again[0].Values[0].Label != "安装人员" {
|
||||
t.Fatal("搜索契约必须返回副本,避免调用方修改全局策略")
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,12 @@ const (
|
||||
|
||||
// ResourceContract is the expected cross-layer representation of one resource.
|
||||
type ResourceContract struct {
|
||||
Domain string
|
||||
Name string
|
||||
Path string
|
||||
PageKind string
|
||||
Mode ResourceMode
|
||||
Domain string
|
||||
Name string
|
||||
Path string
|
||||
PageKind string
|
||||
Mode ResourceMode
|
||||
SearchFields []common.KeywordSearchField
|
||||
}
|
||||
|
||||
// ResourceDefinition describes a route resource and the fields it may change.
|
||||
@@ -94,7 +95,10 @@ func ExpectedResources() []ResourceContract {
|
||||
}
|
||||
|
||||
func resourceContract(domain, name string, mode ResourceMode, pageKind string) ResourceContract {
|
||||
return ResourceContract{Domain: domain, Name: name, Path: resourcePath(domain, name), Mode: mode, PageKind: pageKind}
|
||||
return ResourceContract{
|
||||
Domain: domain, Name: name, Path: resourcePath(domain, name), Mode: mode,
|
||||
PageKind: pageKind, SearchFields: resourceSearchFields(name),
|
||||
}
|
||||
}
|
||||
|
||||
func resourcePath(domain, name string) string {
|
||||
|
||||
73
backend/api/internal/logic/platform/resource_search.go
Normal file
73
backend/api/internal/logic/platform/resource_search.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Package platform 定义平台总后台各资源的可见字段搜索策略。
|
||||
// 版本:v1.0.0
|
||||
package platform
|
||||
|
||||
import (
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
)
|
||||
|
||||
type resourceSearchDefinition struct {
|
||||
name string
|
||||
model any
|
||||
fields []common.KeywordSearchField
|
||||
}
|
||||
|
||||
var resourceSearchDefinitions = []resourceSearchDefinition{
|
||||
searchDefinition("gas_basic", &models.GasBasic{}, text("code"), text("name")),
|
||||
searchDefinition("gas_account", &models.GasAccount{}, text("username"), text("display_name"), enum("role_code", value("admin", "气站管理员"))),
|
||||
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("user_account", &models.UserAccount{}, text("username")),
|
||||
searchDefinition("producer_account", &models.ProducerAccount{}, text("name")),
|
||||
searchDefinition("product_type", &models.ProductType{}, text("code"), text("name")),
|
||||
searchDefinition("product_warehouse", &models.ProductWarehouse{}, text("code"), text("name")),
|
||||
searchDefinition("product_info", &models.ProductInfo{}, text("code"), text("name")),
|
||||
searchDefinition("product_repair", &models.ProductRepair{}, enum("result", value("pending", "待处理"), value("passed", "通过"), value("failed", "未通过"))),
|
||||
searchDefinition("ec_product", &models.EcProduct{}, text("product_code"), text("name")),
|
||||
searchDefinition("ec_product_attribute", &models.EcProductAttribute{}, text("name"), text("value")),
|
||||
searchDefinition("gasorder_contract", &models.GasorderContract{}, text("contract_no"), text("title")),
|
||||
searchDefinition("gasorder_basic", &models.GasorderBasic{}, text("request_no"), enum("creator_type", value("user", "用户"), value("staff", "工作人员"), value("delivery", "配送站"), value("gas", "气站"))),
|
||||
searchDefinition("fin_settlement", &models.FinSettlement{}, text("settlement_no"), text("subject_type")),
|
||||
searchDefinition("cms_content", &models.CmsContent{}, text("content_type"), text("title"), text("publish_status")),
|
||||
searchDefinition("cs_ticket", &models.CsTicket{}, text("ticket_no"), text("category"), text("priority")),
|
||||
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")),
|
||||
}
|
||||
|
||||
func init() {
|
||||
for _, definition := range resourceSearchDefinitions {
|
||||
common.RegisterKeywordSearchPolicy(definition.model, definition.fields)
|
||||
}
|
||||
}
|
||||
|
||||
// resourceSearchFields 返回资源公开搜索契约的副本;未配置资源明确不支持搜索。
|
||||
func resourceSearchFields(name string) []common.KeywordSearchField {
|
||||
for _, definition := range resourceSearchDefinitions {
|
||||
if definition.name == name {
|
||||
return common.ConfiguredKeywordSearchFields(definition.model)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func searchDefinition(name string, model any, fields ...common.KeywordSearchField) resourceSearchDefinition {
|
||||
return resourceSearchDefinition{name: name, model: model, fields: fields}
|
||||
}
|
||||
|
||||
func text(key string) common.KeywordSearchField {
|
||||
return common.KeywordSearchField{Key: key, Kind: common.KeywordSearchText}
|
||||
}
|
||||
|
||||
func enum(key string, values ...common.KeywordSearchValue) common.KeywordSearchField {
|
||||
return common.KeywordSearchField{Key: key, Kind: common.KeywordSearchEnum, Values: values}
|
||||
}
|
||||
|
||||
func value(code, label string) common.KeywordSearchValue {
|
||||
return common.KeywordSearchValue{Value: code, Label: label}
|
||||
}
|
||||
51
backend/api/internal/logic/platform/resource_search_test.go
Normal file
51
backend/api/internal/logic/platform/resource_search_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package platform
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResourceSearchContractMatchesConfirmedScope(t *testing.T) {
|
||||
tests := []struct {
|
||||
resource string
|
||||
keys []string
|
||||
}{
|
||||
{resource: "staff_account", keys: []string{"username", "role_code"}},
|
||||
{resource: "user_address", keys: nil},
|
||||
{resource: "platform_account", keys: []string{"username", "display_name"}},
|
||||
{resource: "product_repair", keys: []string{"result"}},
|
||||
}
|
||||
|
||||
contracts := ExpectedResources()
|
||||
for _, test := range tests {
|
||||
contract := findResourceContract(contracts, test.resource)
|
||||
if contract == nil {
|
||||
t.Fatalf("资源契约不存在:%s", test.resource)
|
||||
}
|
||||
if len(contract.SearchFields) != len(test.keys) {
|
||||
t.Fatalf("%s 搜索字段数量=%d,期望=%d", test.resource, len(contract.SearchFields), len(test.keys))
|
||||
}
|
||||
for index, key := range test.keys {
|
||||
if contract.SearchFields[index].Key != key {
|
||||
t.Fatalf("%s 第 %d 个搜索字段=%s,期望=%s", test.resource, index, contract.SearchFields[index].Key, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaffRoleSearchUsesChineseLabels(t *testing.T) {
|
||||
contract := findResourceContract(ExpectedResources(), "staff_account")
|
||||
role := contract.SearchFields[1]
|
||||
if role.Kind != "enum" || len(role.Values) != 3 {
|
||||
t.Fatalf("工作人员角色搜索契约不完整:%+v", role)
|
||||
}
|
||||
if role.Values[1].Value != "delivery" || role.Values[1].Label != "配送人员" {
|
||||
t.Fatalf("工作人员角色中英文映射错误:%+v", role.Values[1])
|
||||
}
|
||||
}
|
||||
|
||||
func findResourceContract(contracts []ResourceContract, name string) *ResourceContract {
|
||||
for index := range contracts {
|
||||
if contracts[index].Name == name {
|
||||
return &contracts[index]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -32,6 +32,7 @@ func RegisterPlatform(serviceKey string, engine *gin.Engine) {
|
||||
protected := engine.Group(basePath)
|
||||
protected.Use(middleware.JwtAuth(true))
|
||||
protected.Use(platformlogic.RequirePlatformMenuAccess())
|
||||
protected.Use(common.EnableConfiguredKeywordSearch())
|
||||
protected.GET("/auth/profile", platformbase.CurrentProfile)
|
||||
protected.PUT("/auth/password", platformbase.ChangePassword)
|
||||
protected.GET("/dashboard/overview", dashboard.DashboardOverview)
|
||||
|
||||
43
docs/操作日志_平台资源搜索配置_20260811.md
Normal file
43
docs/操作日志_平台资源搜索配置_20260811.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 平台资源搜索配置操作日志
|
||||
|
||||
操作时间:2026-08-11 22:12:26
|
||||
操作类型:扩展
|
||||
影响模块:平台总后台 5173、平台 API 搜索、资源契约
|
||||
|
||||
## 操作前状态
|
||||
|
||||
- 标准列表页无条件显示“关键字”输入框。
|
||||
- 后端没有搜索元数据,零搜索字段资源会静默返回未筛选列表。
|
||||
- 工作人员角色等字段页面显示中文,但只能按数据库英文编码搜索。
|
||||
- 用户地址页面显示搜索框,但实际没有允许搜索的字段。
|
||||
|
||||
## 具体操作
|
||||
|
||||
- 新增平台路由专用的配置化搜索中间件,其他后台保留原有行为。
|
||||
- 新增资源搜索策略注册和启动期安全校验。
|
||||
- 普通文本继续模糊搜索;固定枚举改为中文标签包含匹配并参数化查询编码。
|
||||
- 资源契约新增 `searchFields`,前端资源定义和可搜索枚举选项消费同一生成契约。
|
||||
- 标准列表搜索提示与实际显示列求交集;空集合隐藏表单并清除陈旧 `keyword`。
|
||||
- 抽离列表搜索组合函数,使 `CrudListPage.vue` 降至 500 行以内。
|
||||
- 新增后端单元测试、平台契约测试和前端资源搜索检查脚本。
|
||||
|
||||
## 行为变化
|
||||
|
||||
- 变更前:所有列表显示笼统搜索框;可能无效或只能输入隐藏英文编码。
|
||||
- 变更后:仅支持搜索的页面显示,例如“可搜索:用户名、角色”;用户地址等页面不显示。
|
||||
- 输入“配送”可匹配“配送人员”;输入 `delivery` 不再通过角色枚举字段命中。
|
||||
- 动态关系名称本轮保持不可搜索,避免扩大个人信息和关联查询边界。
|
||||
|
||||
## 验证结果
|
||||
|
||||
- 后端 `common`、`platform`、`routers` 相关测试通过。
|
||||
- 前端 `resource-search:check`、`contract:check`、`type:check` 通过。
|
||||
- 用户地址既有展示检查通过,前端生产构建通过(2615 个模块)。
|
||||
- `git diff --check` 通过;相关核心文件均控制在 500 行以内。
|
||||
- 5173 服务可访问且登录页无控制台错误;当前内置浏览器会话未登录,无法对受保护列表进行登录后人工点击验证。
|
||||
|
||||
## 风险评估
|
||||
|
||||
- 平台总后台的搜索结果集合会按明确可见字段收敛,隐藏字段和枚举英文编码不再产生隐式命中;这是已确认的产品行为。
|
||||
- 中文枚举匹配在内存中的小型可信目录完成,不拼接用户输入;SQL 列名和编码均来自静态策略。
|
||||
- 当前未开放动态关系名称搜索;若后续开放,需要额外权限审计和数据库索引评估。
|
||||
50
docs/项目文档_平台资源搜索配置_v1.0.md
Normal file
50
docs/项目文档_平台资源搜索配置_v1.0.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 平台资源搜索配置
|
||||
|
||||
## 项目概述
|
||||
|
||||
平台总后台原先在所有标准列表页显示统一关键字输入框,但后端只会搜索安全白名单中的部分字符串列。无可搜索字段的页面会静默忽略关键字,枚举字段还存在“页面显示中文、只能输入英文编码”的语义断层。
|
||||
|
||||
本版本将搜索能力改为资源级配置:后端策略同时驱动实际 SQL 和生成契约,前端只展示“当前可见列”与“后端搜索字段”的交集。
|
||||
|
||||
## 核心规则
|
||||
|
||||
- 普通文本字段按页面显示的原始文本执行不区分大小写的包含匹配。
|
||||
- 固定枚举按中文名称模糊匹配,再转换为可信编码执行参数化 `IN` 查询。
|
||||
- 枚举英文编码不作为搜索入口,例如 `delivery`、`on_duty` 不用于枚举搜索。
|
||||
- 用户姓名、平台角色名称等动态关系字段本轮不开放关系搜索。
|
||||
- 没有合格字段的页面隐藏搜索表单,不发送 `keyword`,并清除 URL 中遗留参数。
|
||||
- 搜索提示明确列出字段,例如“可搜索:用户名、角色”。
|
||||
- 用户地址页面只有动态用户关系和受保护地址数据,因此隐藏搜索框。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
backend/api/internal/logic/common/
|
||||
└── keyword_search.go # 配置注册、中文枚举匹配和 SQL 条件编译
|
||||
backend/api/internal/logic/platform/
|
||||
└── resource_search.go # 平台资源搜索策略与中文枚举目录
|
||||
frontend/platform_admin/src/api/
|
||||
└── resource-search-contract.ts # 消费后端生成的搜索契约
|
||||
frontend/platform_admin/src/views/shared/
|
||||
└── use-resource-list-search.ts # 可见列交集、提示、URL 与关键字状态
|
||||
```
|
||||
|
||||
## 核心文件说明
|
||||
|
||||
- `keyword_search.go`:注册模型字段策略;启动时校验列必须是直接字符串列、安全白名单字段且非敏感字段。
|
||||
- `resource_search.go`:按资源声明 `text` 或 `enum` 字段。新增搜索能力必须先在此处明确授权。
|
||||
- `resource_contract.go` 与 CLI:将同一策略输出为 `searchFields`,避免文档能力与 SQL 漂移。
|
||||
- `resource-search-contract.ts`:读取生成契约;可搜索枚举的页面选项也从契约生成。
|
||||
- `use-resource-list-search.ts`:按实际列表列求交集,控制显示、请求和陈旧 URL 参数清理。
|
||||
|
||||
## 维护指南
|
||||
|
||||
1. 在后端 `resource_search.go` 为资源增加字段,只允许列表实际可见且安全的直接文本字段。
|
||||
2. 固定枚举必须同时声明编码和中文名称;动态关系不得伪装成枚举。
|
||||
3. 执行 `pnpm contract:sync` 更新前端生成契约。
|
||||
4. 执行 `pnpm resource-search:check`、`pnpm contract:check`、`pnpm type:check` 和后端相关测试。
|
||||
5. 若未来开放关系名称搜索,必须单独设计受控 `EXISTS` 查询、权限边界和索引,不可由前端关系配置自动推断。
|
||||
|
||||
## 变更记录
|
||||
|
||||
- v1.0:新增平台资源级搜索策略、中文枚举模糊搜索、动态字段提示、无能力隐藏和 URL 清理。
|
||||
@@ -19,6 +19,7 @@
|
||||
"staff-organization:check": "node scripts/check-staff-organization-linkage.mjs",
|
||||
"staff-relations:check": "node scripts/check-staff-relation-policy.mjs",
|
||||
"user-address-display:check": "node scripts/check-user-address-relation-display.mjs",
|
||||
"resource-search:check": "node scripts/check-resource-search.mjs",
|
||||
"audit:platform": "node scripts/check-backend-contract.mjs",
|
||||
"lint": "biome lint .",
|
||||
"lint:fix": "biome lint --write .",
|
||||
|
||||
@@ -32,6 +32,11 @@ const embeddedResources = new Set([
|
||||
'payment_refund',
|
||||
'wallet_apply_cash',
|
||||
]);
|
||||
const fieldLabelKeys = new Set(
|
||||
[...source.matchAll(/^\s{2}([a-z0-9_]+):\s*'/gm)].map(
|
||||
(match) => match[1],
|
||||
),
|
||||
);
|
||||
|
||||
for (const name of frontendNames) {
|
||||
const item = backend.get(name);
|
||||
@@ -50,8 +55,31 @@ for (const item of contract.resources) {
|
||||
);
|
||||
if (!embeddedResources.has(item.name) && !routes.includes(`'/${item.name}'`))
|
||||
throw new Error(`缺少后端资源路由:${item.name}`);
|
||||
const searchKeys = new Set();
|
||||
for (const field of item.searchFields ?? []) {
|
||||
if (searchKeys.has(field.key))
|
||||
throw new Error(`搜索字段重复:${item.name}.${field.key}`);
|
||||
searchKeys.add(field.key);
|
||||
if (!fieldLabelKeys.has(field.key))
|
||||
throw new Error(`搜索字段缺少中文列名:${item.name}.${field.key}`);
|
||||
if (!['text', 'enum'].includes(field.kind))
|
||||
throw new Error(`搜索字段类型无效:${item.name}.${field.key}`);
|
||||
if (field.kind === 'enum') {
|
||||
if (!field.values?.length)
|
||||
throw new Error(`搜索枚举缺少值:${item.name}.${field.key}`);
|
||||
for (const value of field.values) {
|
||||
if (!value.value || !value.label || value.value === value.label)
|
||||
throw new Error(`搜索枚举中英文映射无效:${item.name}.${field.key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (item.pageKind === 'tree' && searchKeys.size)
|
||||
throw new Error(`树形资源不应声明标准列表搜索:${item.name}`);
|
||||
}
|
||||
|
||||
if (backend.get('user_address')?.searchFields?.length)
|
||||
throw new Error('用户地址包含动态用户关系,本轮必须隐藏搜索框');
|
||||
|
||||
const forbidden = [
|
||||
'/platform/platform_',
|
||||
'/gas/gas_',
|
||||
|
||||
40
frontend/platform_admin/scripts/check-resource-search.mjs
Normal file
40
frontend/platform_admin/scripts/check-resource-search.mjs
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 功能:校验标准资源列表仅在具备真实搜索能力时显示动态中文提示。
|
||||
* 版本:v1.0.0
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const listPage = readFileSync(
|
||||
resolve(root, 'src/views/shared/CrudListPage.vue'),
|
||||
'utf8',
|
||||
);
|
||||
const searchState = readFileSync(
|
||||
resolve(root, 'src/views/shared/use-resource-list-search.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const resources = readFileSync(resolve(root, 'src/api/resources.ts'), 'utf8');
|
||||
|
||||
const expectations = [
|
||||
[listPage, 'v-if="searchEnabled"', '不支持搜索时隐藏表单'],
|
||||
[listPage, ':placeholder="searchPlaceholder"', '动态显示可搜索字段'],
|
||||
[listPage, 'const keyword = requestKeyword();', '只发送受支持的关键字'],
|
||||
[searchState, 'definition.value.searchFields', '读取后端搜索契约'],
|
||||
[searchState, 'resourceListDisplayFields', '搜索字段与实际列表列求交集'],
|
||||
[searchState, 'if (searchEnabled.value || !route.query.keyword) return;', '清理不支持页面的陈旧关键字'],
|
||||
[searchState, '`可搜索:${searchableFields.value', '明确列出可搜索中文字段'],
|
||||
[resources, 'searchFields: resourceSearchFields(name)', '资源定义消费后端搜索契约'],
|
||||
[resources, "resourceSearchEnumOptions('staff_account', 'role_code')", '工作人员中文角色与搜索契约同源'],
|
||||
[resources, "resourceSearchEnumOptions('gasorder_basic', 'creator_type')", '订单创建方中文枚举与搜索契约同源'],
|
||||
];
|
||||
|
||||
for (const [content, fragment, description] of expectations) {
|
||||
if (!content.includes(fragment)) throw new Error(`资源搜索检查失败:${description}`);
|
||||
}
|
||||
if (listPage.includes('关键字段模糊搜索')) {
|
||||
throw new Error('仍存在未说明具体字段的旧搜索提示');
|
||||
}
|
||||
|
||||
console.log('资源搜索检查通过:动态提示、隐藏逻辑、URL 清理和中文枚举均已覆盖');
|
||||
47
frontend/platform_admin/src/api/resource-search-contract.ts
Normal file
47
frontend/platform_admin/src/api/resource-search-contract.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 功能描述:读取后端生成的平台资源搜索契约,统一搜索字段和中文枚举选项。
|
||||
* 版本:v1.0.0
|
||||
*/
|
||||
import platformContract from '@/contracts/platform-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(
|
||||
(platformContract.resources as ContractResource[]).map((resource) => [
|
||||
resource.name,
|
||||
resource.searchFields ?? [],
|
||||
]),
|
||||
) as Record<string, ResourceSearchField[]>;
|
||||
|
||||
/** 返回后端已确认可用的资源搜索字段副本。 */
|
||||
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 }));
|
||||
}
|
||||
@@ -4,6 +4,12 @@
|
||||
| 'append_only'
|
||||
| 'editable'
|
||||
| 'managed';
|
||||
import {
|
||||
type ResourceSearchField,
|
||||
resourceSearchEnumOptions,
|
||||
resourceSearchFields,
|
||||
} from './resource-search-contract';
|
||||
|
||||
export type ResourcePageKind = 'list' | 'tree';
|
||||
export type ResourceFieldType =
|
||||
| 'text'
|
||||
@@ -63,6 +69,7 @@ export type ResourceUiDefinition = {
|
||||
mode: ResourceMode;
|
||||
pageKind: ResourcePageKind;
|
||||
fields: ResourceField[];
|
||||
searchFields: ResourceSearchField[];
|
||||
detailActions?: DetailAction[];
|
||||
canCreate: boolean;
|
||||
canEdit: boolean;
|
||||
@@ -302,13 +309,13 @@ function relation(
|
||||
}
|
||||
|
||||
/** 创建固定管理员角色字段,页面展示中文名称,接口仍使用稳定编码。 */
|
||||
function fixedAdminRole(label: string): ResourceField {
|
||||
function fixedAdminRole(resource: string): ResourceField {
|
||||
return f('role_code', {
|
||||
label: '角色',
|
||||
listLabel: '角色',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [{ label, value: 'admin' }],
|
||||
options: resourceSearchEnumOptions(resource, 'role_code'),
|
||||
defaultValue: 'admin',
|
||||
readonlyOnCreate: true,
|
||||
unknownValueLabel: '未知角色',
|
||||
@@ -343,6 +350,7 @@ function define(
|
||||
mode,
|
||||
pageKind,
|
||||
fields,
|
||||
searchFields: resourceSearchFields(name),
|
||||
...defaults,
|
||||
...capabilities,
|
||||
...(detailActions ? { detailActions } : {}),
|
||||
@@ -353,10 +361,10 @@ const reason = [f('reason', { required: true })];
|
||||
|
||||
export const resources: ResourceUiDefinition[] = [
|
||||
{ ...define('gas_basic', '气站管理', 'writable', [f('code', { required: true }), f('name', { required: true }), f('credit_code'), f('principal'), f('address'), f('longitude'), f('latitude')]), accountManagement: { resource: '/gas_account', relationKey: 'gas_basic_identity', title: '气站账户' }, walletOwnerType: 'gas' },
|
||||
define('gas_account', '气站账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), fixedAdminRole('气站管理员'), relation('gas_basic_identity', '/gas_basic', true)]),
|
||||
define('gas_account', '气站账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), fixedAdminRole('gas_account'), relation('gas_basic_identity', '/gas_basic', true)]),
|
||||
{ ...define('delivery_basic', '配送点管理', 'writable', [f('delivery_code', { required: true }), f('name', { required: true }), f('gas_basic_identity', { label: '气站', listLabel: '气站名称', type: 'identity', relation: '/gas_basic', displayRelationLabel: true, emptyText: '平台直属', placeholder: '请选择气站,留空表示平台直属' }), 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' }] }), f('gas_basic_identity', { label: '所属气站', type: 'identity', relation: '/gas_basic' }), f('delivery_basic_identity', { label: '所属配送点', type: 'identity', relation: '/delivery_basic', relationLinkage: { parentKey: 'gas_basic_identity', optionParentKey: 'gas_basic_identity', filterKey: 'gas_basic_identities', backfillParent: true } }), f('work_status', { type: 'select', options: [{ label: '在岗', value: 'on_duty' }, { label: '离岗', value: 'off_duty' }] })]), walletOwnerType: 'staff' },
|
||||
define('delivery_account', '配送点账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), fixedAdminRole('delivery_account'), 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: resourceSearchEnumOptions('staff_account', 'role_code') }), f('gas_basic_identity', { label: '所属气站', type: 'identity', relation: '/gas_basic' }), f('delivery_basic_identity', { label: '所属配送点', type: 'identity', relation: '/delivery_basic', relationLinkage: { parentKey: 'gas_basic_identity', optionParentKey: 'gas_basic_identity', filterKey: 'gas_basic_identities', backfillParent: true } }), 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, { 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, { listLabel: '用户账户', listRelationNameOnly: true }), f('address', { required: true, emptyText: '未填写', placeholder: '未填写' }), f('longitude', { displayPrecision: 6, emptyText: '未填写', placeholder: '未填写' }), f('latitude', { displayPrecision: 6, emptyText: '未填写', placeholder: '未填写' }), f('is_default')]),
|
||||
@@ -369,7 +377,7 @@ export const resources: ResourceUiDefinition[] = [
|
||||
{ name: '修改智能气阀状态', resource: '/product_info/:identity/lifecycle', method: 'PATCH', fields: [f('product_status', { required: true, type: 'select', options: [{ label: '待处理', value: 10 }, { label: '在库', value: 28 }, { label: '运输中', value: 29 }, { label: '使用中', value: 30 }, { label: '维修中', value: 31 }, { label: '已报废', value: 27 }] })] },
|
||||
{ name: '变更智能气阀归属', resource: '/product_info/:identity', method: 'PUT', fields: [relation('warehouse_identity', '/product_warehouse'), relation('gas_basic_identity', '/gas_basic'), relation('delivery_basic_identity', '/delivery_basic'), relation('user_account_identity', '/user_account'), f('action', { required: true, type: 'select', options: [{ label: '入库', value: 'warehouse' }, { label: '分配', value: 'assigned' }, { label: '归还', value: 'returned' }, { label: '人工调整', value: 'manual' }] }), f('reason', { required: true }), f('remark')] },
|
||||
]),
|
||||
define('product_repair', '智能气阀检修记录', 'editable', [relation('product_info_identity', '/product_info', true), f('repair_no', { required: true }), f('repair_type', { required: true }), f('started_at', { required: true }), f('completed_at'), f('result', { type: 'select', options: [{ label: '待处理', value: 'pending' }, { label: '通过', value: 'passed' }, { label: '未通过', value: 'failed' }] }), f('target_product_status', { type: 'select', options: [{ label: '在库', value: 28 }, { label: '运输中', value: 29 }, { label: '使用中', value: 30 }, { label: '报废', value: 27 }] }), f('content'), f('operator'), f('remark')]),
|
||||
define('product_repair', '智能气阀检修记录', 'editable', [relation('product_info_identity', '/product_info', true), f('repair_no', { required: true }), f('repair_type', { required: true }), f('started_at', { required: true }), f('completed_at'), f('result', { type: 'select', options: resourceSearchEnumOptions('product_repair', 'result') }), f('target_product_status', { type: 'select', options: [{ label: '在库', value: 28 }, { label: '运输中', value: 29 }, { label: '使用中', value: 30 }, { label: '报废', value: 27 }] }), f('content'), f('operator'), f('remark')]),
|
||||
define('product_owner', '智能气阀归属记录', 'readonly', []),
|
||||
|
||||
define('gasorder_contract', '配送合同', 'managed', [f('contract_no', { required: true }), relation('user_account_identity', '/user_account', true), relation('gas_basic_identity', '/gas_basic', true), relation('delivery_basic_identity', '/delivery_basic'), f('title', { required: true }), f('terms'), f('file_uri'), f('default_delivery_fee'), f('signed_at', { required: true }), f('effective_at', { required: true }), f('expired_at')], 'list', [
|
||||
@@ -381,7 +389,7 @@ export const resources: ResourceUiDefinition[] = [
|
||||
{ name: '解绑气瓶', resource: '/gasorder_contract_product/:identity/unbind', danger: true, fields: reason },
|
||||
]),
|
||||
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', [
|
||||
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: resourceSearchEnumOptions('gasorder_basic', 'creator_type') }), 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, { staffRelation: { roles: ['delivery'], enabledOnly: true, workStatus: 'on_duty' } }), ...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] } },
|
||||
@@ -453,7 +461,7 @@ export const resources: ResourceUiDefinition[] = [
|
||||
define('cms_content', '内容', 'writable', [f('content_type', { required: true }), f('title', { required: true }), f('body', { required: true }), f('version_no'), f('publish_status')]),
|
||||
define('cs_ticket', '客服工单', 'writable', [relation('user_account_identity', '/user_account', true), f('ticket_no', { required: true }), f('category', { required: true }), f('priority', { required: true })]),
|
||||
define('platform_account', '平台账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), f('avatar'), f('platform_role_code', { required: true, unknownValueLabel: '未知角色' }), f('phone')]),
|
||||
define('platform_role', '平台角色', 'writable', [f('role_code', { required: true }), f('name', { required: true }), f('location_scope', { required: true, type: 'select', options: [{ label: '脱敏坐标', value: 'standard' }, { label: '精确坐标', value: 'precise' }] })], 'list', [
|
||||
define('platform_role', '平台角色', 'writable', [f('role_code', { required: true }), f('name', { required: true }), f('location_scope', { required: true, type: 'select', options: resourceSearchEnumOptions('platform_role', 'location_scope') })], 'list', [
|
||||
{ name: '分配菜单', resource: '/platform_role/:identity/menu', method: 'PUT', fields: [f('menu_identities', { type: 'identity-list', relation: '/platform_menu' })] },
|
||||
]),
|
||||
define('platform_menu', '平台菜单', 'readonly', [], 'tree'),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -14,9 +14,9 @@
|
||||
请从详情页中的专用操作推进流程,操作结果会按服务端状态校验并保留审计记录。
|
||||
</a-alert>
|
||||
<div class="list-toolbar">
|
||||
<a-form :model="filters" layout="inline" class="filters" @submit="search">
|
||||
<a-form-item label="关键字">
|
||||
<a-input v-model="filters.keyword" allow-clear placeholder="关键字段模糊搜索" />
|
||||
<a-form v-if="searchEnabled" :model="filters" layout="inline" class="filters" @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>
|
||||
@@ -185,6 +185,7 @@ import {
|
||||
protectedListAvatarDisplayName,
|
||||
} from './protected-list-avatar-loader';
|
||||
import { useResourceListExtras } from './use-resource-list-extras';
|
||||
import { useResourceListSearch } from './use-resource-list-search';
|
||||
|
||||
const props = defineProps<{ definition: ResourceUiDefinition }>();
|
||||
const route = useRoute();
|
||||
@@ -196,9 +197,16 @@ const page = ref(Math.max(1, Number(route.query.page) || 1));
|
||||
const pageSize = 50;
|
||||
const total = ref(0);
|
||||
const list = ref<ResourceRow[]>([]);
|
||||
const filters = reactive({
|
||||
keyword: typeof route.query.keyword === 'string' ? route.query.keyword : '',
|
||||
});
|
||||
const {
|
||||
clearUnsupportedKeyword,
|
||||
displayFields,
|
||||
filters,
|
||||
requestKeyword,
|
||||
resetSearchState,
|
||||
searchEnabled,
|
||||
searchPlaceholder,
|
||||
syncQuery,
|
||||
} = useResourceListSearch(definitionRef, page);
|
||||
const relations = useResourceRelations(() => ({
|
||||
staffType: String(route.query.staff_type ?? route.meta.staffType ?? ''),
|
||||
}));
|
||||
@@ -244,20 +252,6 @@ const canArchive = computed(
|
||||
props.definition.canArchive &&
|
||||
(!requiresRoot.value || userStore.role === 'root'),
|
||||
);
|
||||
const displayFields = computed(() =>
|
||||
props.definition.fields
|
||||
.filter((field) => field.key !== 'identity' && field.type !== 'password')
|
||||
.filter(
|
||||
(field) =>
|
||||
!(
|
||||
props.definition.name === 'gas_basic' &&
|
||||
['credit_code', 'address', 'longitude', 'latitude'].includes(
|
||||
field.key,
|
||||
)
|
||||
),
|
||||
)
|
||||
.slice(0, 6),
|
||||
);
|
||||
const listTitle = computed(() => {
|
||||
const owner = String(route.query.owner_name ?? '');
|
||||
return owner
|
||||
@@ -280,7 +274,8 @@ async function load() {
|
||||
if (props.definition.name === 'delivery_account')
|
||||
serverFilters.delivery_basic_identities = managedOwnerIdentity.value;
|
||||
}
|
||||
if (filters.keyword.trim()) serverFilters.keyword = filters.keyword.trim();
|
||||
const keyword = requestKeyword();
|
||||
if (keyword) serverFilters.keyword = keyword;
|
||||
const result = await resourceApi.list<ResourceRow>(
|
||||
props.definition.resource,
|
||||
page.value,
|
||||
@@ -423,23 +418,6 @@ function confirmArchive(row: ResourceRow) {
|
||||
});
|
||||
}
|
||||
|
||||
async function syncQuery() {
|
||||
const query: Record<string, string> = {};
|
||||
for (const key of [
|
||||
'owner_identity',
|
||||
'owner_name',
|
||||
'relation_key',
|
||||
'staff_type',
|
||||
'return_to',
|
||||
]) {
|
||||
const value = route.query[key];
|
||||
if (typeof value === 'string' && value) query[key] = value;
|
||||
}
|
||||
if (page.value > 1) query.page = String(page.value);
|
||||
if (filters.keyword.trim()) query.keyword = filters.keyword.trim();
|
||||
await router.replace({ query });
|
||||
}
|
||||
|
||||
async function search() {
|
||||
page.value = 1;
|
||||
await syncQuery();
|
||||
@@ -483,6 +461,7 @@ async function loadFieldOptions() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await clearUnsupportedKeyword();
|
||||
await Promise.all([
|
||||
relations.preload(displayFields.value),
|
||||
loadFieldOptions(),
|
||||
@@ -493,8 +472,7 @@ onBeforeUnmount(avatarLoader.reset);
|
||||
watch(
|
||||
() => props.definition.resource,
|
||||
async () => {
|
||||
page.value = 1;
|
||||
filters.keyword = '';
|
||||
await resetSearchState();
|
||||
await Promise.all([
|
||||
relations.preload(displayFields.value),
|
||||
loadFieldOptions(),
|
||||
|
||||
@@ -4,9 +4,27 @@
|
||||
*/
|
||||
import { optionLabel } from '@/api/resource-display';
|
||||
import type { ResourceRow } from '@/api/resource-page-rules';
|
||||
import type { ResourceField } from '@/api/resources';
|
||||
import type { ResourceField, ResourceUiDefinition } from '@/api/resources';
|
||||
import { isProtectedListAvatarField } from './protected-list-avatar-loader';
|
||||
|
||||
/** 返回标准列表实际渲染的业务字段,搜索提示与表格共同复用该规则。 */
|
||||
export function resourceListDisplayFields(
|
||||
definition: ResourceUiDefinition,
|
||||
): ResourceField[] {
|
||||
return definition.fields
|
||||
.filter((field) => field.key !== 'identity' && field.type !== 'password')
|
||||
.filter(
|
||||
(field) =>
|
||||
!(
|
||||
definition.name === 'gas_basic' &&
|
||||
['credit_code', 'address', 'longitude', 'latitude'].includes(
|
||||
field.key,
|
||||
)
|
||||
),
|
||||
)
|
||||
.slice(0, 6);
|
||||
}
|
||||
|
||||
/** 读取关系列表字段的完整唯一标识。 */
|
||||
export function identityFieldValue(field: ResourceField, row: ResourceRow) {
|
||||
return String(row[field.key] ?? row[`${field.key}_masked`] ?? '');
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 功能描述:管理标准资源列表的可搜索字段、关键字状态和 URL 查询参数。
|
||||
* 版本:v1.0.0
|
||||
*/
|
||||
import { computed, reactive, type Ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import type { ResourceUiDefinition } from '@/api/resources';
|
||||
import { resourceListDisplayFields } from './resource-list-field-display';
|
||||
|
||||
/**
|
||||
* 创建资源列表搜索状态。
|
||||
* 参数:definition 为当前资源定义,page 为当前页码。
|
||||
* 返回值:实际显示字段、搜索提示、请求关键字及 URL 同步方法。
|
||||
*/
|
||||
export function useResourceListSearch(
|
||||
definition: Ref<ResourceUiDefinition>,
|
||||
page: Ref<number>,
|
||||
) {
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const filters = reactive({
|
||||
keyword: typeof route.query.keyword === 'string' ? route.query.keyword : '',
|
||||
});
|
||||
const displayFields = computed(() =>
|
||||
resourceListDisplayFields(definition.value),
|
||||
);
|
||||
const searchableFields = computed(() => {
|
||||
const searchableKeys = new Set(
|
||||
definition.value.searchFields.map((field) => field.key),
|
||||
);
|
||||
return displayFields.value.filter((field) => searchableKeys.has(field.key));
|
||||
});
|
||||
const searchEnabled = computed(() => searchableFields.value.length > 0);
|
||||
const searchPlaceholder = computed(
|
||||
() =>
|
||||
`可搜索:${searchableFields.value
|
||||
.map((field) => field.listLabel ?? field.label)
|
||||
.join('、')}`,
|
||||
);
|
||||
|
||||
/** 仅在当前资源支持搜索时返回去除首尾空格的关键字。 */
|
||||
function requestKeyword() {
|
||||
return searchEnabled.value ? filters.keyword.trim() : '';
|
||||
}
|
||||
|
||||
/** 将受支持的搜索状态写入 URL,并移除无效或陈旧的 keyword。 */
|
||||
async function syncQuery() {
|
||||
const query: Record<string, string> = {};
|
||||
for (const key of [
|
||||
'owner_identity',
|
||||
'owner_name',
|
||||
'relation_key',
|
||||
'staff_type',
|
||||
'return_to',
|
||||
]) {
|
||||
const value = route.query[key];
|
||||
if (typeof value === 'string' && value) query[key] = value;
|
||||
}
|
||||
if (page.value > 1) query.page = String(page.value);
|
||||
const keyword = requestKeyword();
|
||||
if (keyword) query.keyword = keyword;
|
||||
await router.replace({ query });
|
||||
}
|
||||
|
||||
/** 首次进入不支持搜索的页面时清除地址栏遗留关键字。 */
|
||||
async function clearUnsupportedKeyword() {
|
||||
if (searchEnabled.value || !route.query.keyword) return;
|
||||
filters.keyword = '';
|
||||
await syncQuery();
|
||||
}
|
||||
|
||||
/** 切换资源时复位搜索状态,并同步移除旧资源关键字。 */
|
||||
async function resetSearchState() {
|
||||
page.value = 1;
|
||||
filters.keyword = '';
|
||||
if (route.query.keyword) await syncQuery();
|
||||
}
|
||||
|
||||
return {
|
||||
clearUnsupportedKeyword,
|
||||
displayFields,
|
||||
filters,
|
||||
requestKeyword,
|
||||
resetSearchState,
|
||||
searchEnabled,
|
||||
searchPlaceholder,
|
||||
syncQuery,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user