diff --git a/backend/api/cmd/cli/main.go b/backend/api/cmd/cli/main.go index 744db68..910df77 100644 --- a/backend/api/cmd/cli/main.go +++ b/backend/api/cmd/cli/main.go @@ -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}) diff --git a/backend/api/internal/logic/common/base.go b/backend/api/internal/logic/common/base.go index 6cc666c..1e663ce 100644 --- a/backend/api/internal/logic/common/base.go +++ b/backend/api/internal/logic/common/base.go @@ -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 diff --git a/backend/api/internal/logic/common/keyword_search.go b/backend/api/internal/logic/common/keyword_search.go new file mode 100644 index 0000000..910c9e0 --- /dev/null +++ b/backend/api/internal/logic/common/keyword_search.go @@ -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 +} diff --git a/backend/api/internal/logic/common/keyword_search_test.go b/backend/api/internal/logic/common/keyword_search_test.go new file mode 100644 index 0000000..1d9d90e --- /dev/null +++ b/backend/api/internal/logic/common/keyword_search_test.go @@ -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("搜索契约必须返回副本,避免调用方修改全局策略") + } +} diff --git a/backend/api/internal/logic/platform/resource_contract.go b/backend/api/internal/logic/platform/resource_contract.go index 11bd759..f1f375c 100644 --- a/backend/api/internal/logic/platform/resource_contract.go +++ b/backend/api/internal/logic/platform/resource_contract.go @@ -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 { diff --git a/backend/api/internal/logic/platform/resource_search.go b/backend/api/internal/logic/platform/resource_search.go new file mode 100644 index 0000000..111551c --- /dev/null +++ b/backend/api/internal/logic/platform/resource_search.go @@ -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} +} diff --git a/backend/api/internal/logic/platform/resource_search_test.go b/backend/api/internal/logic/platform/resource_search_test.go new file mode 100644 index 0000000..3aa8143 --- /dev/null +++ b/backend/api/internal/logic/platform/resource_search_test.go @@ -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 +} diff --git a/backend/api/internal/routers/platform.go b/backend/api/internal/routers/platform.go index c63a125..4df553f 100644 --- a/backend/api/internal/routers/platform.go +++ b/backend/api/internal/routers/platform.go @@ -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) diff --git a/docs/操作日志_平台资源搜索配置_20260811.md b/docs/操作日志_平台资源搜索配置_20260811.md new file mode 100644 index 0000000..0349d29 --- /dev/null +++ b/docs/操作日志_平台资源搜索配置_20260811.md @@ -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 列名和编码均来自静态策略。 +- 当前未开放动态关系名称搜索;若后续开放,需要额外权限审计和数据库索引评估。 diff --git a/docs/项目文档_平台资源搜索配置_v1.0.md b/docs/项目文档_平台资源搜索配置_v1.0.md new file mode 100644 index 0000000..618ff2c --- /dev/null +++ b/docs/项目文档_平台资源搜索配置_v1.0.md @@ -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 清理。 diff --git a/frontend/platform_admin/package.json b/frontend/platform_admin/package.json index 761210e..805460f 100644 --- a/frontend/platform_admin/package.json +++ b/frontend/platform_admin/package.json @@ -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 .", diff --git a/frontend/platform_admin/scripts/check-backend-contract.mjs b/frontend/platform_admin/scripts/check-backend-contract.mjs index 9b9237a..c1874bc 100644 --- a/frontend/platform_admin/scripts/check-backend-contract.mjs +++ b/frontend/platform_admin/scripts/check-backend-contract.mjs @@ -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_', diff --git a/frontend/platform_admin/scripts/check-resource-search.mjs b/frontend/platform_admin/scripts/check-resource-search.mjs new file mode 100644 index 0000000..cbb94b5 --- /dev/null +++ b/frontend/platform_admin/scripts/check-resource-search.mjs @@ -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 清理和中文枚举均已覆盖'); diff --git a/frontend/platform_admin/src/api/resource-search-contract.ts b/frontend/platform_admin/src/api/resource-search-contract.ts new file mode 100644 index 0000000..32b6242 --- /dev/null +++ b/frontend/platform_admin/src/api/resource-search-contract.ts @@ -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; + +/** 返回后端已确认可用的资源搜索字段副本。 */ +export function resourceSearchFields(name: string): ResourceSearchField[] { + return (searchFieldsByResource[name] ?? []).map((field) => ({ + ...field, + values: field.values?.map((value) => ({ ...value })), + })); +} + +/** 返回搜索枚举的中文展示选项,确保页面显示与后端匹配规则同源。 */ +export function resourceSearchEnumOptions(resource: string, key: string) { + const field = (searchFieldsByResource[resource] ?? []).find( + (item) => item.key === key && item.kind === 'enum', + ); + if (!field?.values?.length) { + throw new Error(`资源搜索枚举缺失:${resource}.${key}`); + } + return field.values.map((value) => ({ ...value })); +} diff --git a/frontend/platform_admin/src/api/resources.ts b/frontend/platform_admin/src/api/resources.ts index d5545d3..730f545 100644 --- a/frontend/platform_admin/src/api/resources.ts +++ b/frontend/platform_admin/src/api/resources.ts @@ -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'), diff --git a/frontend/platform_admin/src/contracts/platform-resources.json b/frontend/platform_admin/src/contracts/platform-resources.json index 88040a6..b854e77 100644 --- a/frontend/platform_admin/src/contracts/platform-resources.json +++ b/frontend/platform_admin/src/contracts/platform-resources.json @@ -1 +1 @@ -{"resources":[{"domain":"gas","name":"gas_basic","path":"/gas_basic","pageKind":"list","mode":"writable"},{"domain":"gas","name":"gas_account","path":"/gas_account","pageKind":"list","mode":"writable"},{"domain":"delivery","name":"delivery_basic","path":"/delivery_basic","pageKind":"list","mode":"writable"},{"domain":"delivery","name":"delivery_account","path":"/delivery_account","pageKind":"list","mode":"writable"},{"domain":"staff","name":"staff_account","path":"/staff_account","pageKind":"list","mode":"writable"},{"domain":"staff","name":"staff_credential","path":"/staff_credential","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_account","path":"/user_account","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_address","path":"/user_address","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_service_relation","path":"/user_service_relation","pageKind":"list","mode":"writable"},{"domain":"product","name":"producer_account","path":"/producer_account","pageKind":"list","mode":"writable"},{"domain":"product","name":"product_type","path":"/product_type","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_warehouse","path":"/product_warehouse","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_info","path":"/product_info","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_repair","path":"/product_repair","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_owner","path":"/product_owner","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_category","path":"/ec_category","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_product","path":"/ec_product","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_product_attribute","path":"/ec_product_attribute","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_product_image","path":"/ec_product_image","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_cart","path":"/ec_cart","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_order","path":"/ec_order","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_order_item","path":"/ec_order_item","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_review","path":"/ec_review","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_contract","path":"/gasorder_contract","pageKind":"list","mode":"managed"},{"domain":"gasorder","name":"gasorder_contract_product","path":"/gasorder_contract_product","pageKind":"list","mode":"append_only"},{"domain":"gasorder","name":"gasorder_contract_revision","path":"/gasorder_contract_revision","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_basic","path":"/gasorder_basic","pageKind":"list","mode":"append_only"},{"domain":"gasorder","name":"gasorder_item","path":"/gasorder_item","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_assign","path":"/gasorder_assign","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_status","path":"/gasorder_status","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_track","path":"/gasorder_track","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_track_point","path":"/gasorder_track_point","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_confirm","path":"/gasorder_confirm","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_payment","path":"/gasorder_payment","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"fin_payment","path":"/fin_payment","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"fin_settlement","path":"/fin_settlement","pageKind":"list","mode":"writable"},{"domain":"finance","name":"fin_reconciliation","path":"/fin_reconciliation","pageKind":"list","mode":"readonly"},{"domain":"content","name":"cms_content","path":"/cms_content","pageKind":"list","mode":"writable"},{"domain":"customer_service","name":"cs_ticket","path":"/cs_ticket","pageKind":"list","mode":"writable"},{"domain":"platform","name":"platform_account","path":"/platform_account","pageKind":"list","mode":"writable"},{"domain":"platform","name":"platform_role","path":"/platform_role","pageKind":"list","mode":"writable"},{"domain":"platform","name":"platform_menu","path":"/platform_menu","pageKind":"tree","mode":"readonly"},{"domain":"wallet","name":"wallet_basic","path":"/wallet_basic","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_bank","path":"/wallet_bank","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"payment_order","path":"/payment_order","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_record","path":"/wallet_record","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"payment_refund","path":"/payment_refund","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_apply_cash","path":"/wallet_apply_cash","pageKind":"list","mode":"readonly"}],"routes":[{"method":"GET","path":"/gasorder_contract"},{"method":"GET","path":"/gasorder_contract_product"},{"method":"GET","path":"/gasorder_contract_product/:identity"},{"method":"GET","path":"/gasorder_contract_revision"},{"method":"GET","path":"/gasorder_contract_revision/:identity"},{"method":"GET","path":"/gasorder_contract/:identity"},{"method":"GET","path":"/gasorder_confirm"},{"method":"GET","path":"/gasorder_confirm/:identity"},{"method":"GET","path":"/gasorder_track"},{"method":"GET","path":"/gasorder_track_point"},{"method":"GET","path":"/gasorder_track_point/:identity"},{"method":"GET","path":"/gasorder_track/:identity"},{"method":"GET","path":"/gasorder_basic"},{"method":"GET","path":"/gasorder_basic/:identity"},{"method":"GET","path":"/gasorder_item"},{"method":"GET","path":"/gasorder_item/:identity"},{"method":"GET","path":"/gasorder_assign"},{"method":"GET","path":"/gasorder_assign/:identity"},{"method":"GET","path":"/gasorder_status"},{"method":"GET","path":"/gasorder_status/:identity"},{"method":"GET","path":"/gasorder_payment"},{"method":"GET","path":"/gasorder_payment/:identity"},{"method":"GET","path":"/gas_basic"},{"method":"GET","path":"/gas_basic/:identity"},{"method":"GET","path":"/gas_account"},{"method":"GET","path":"/gas_account/:identity"},{"method":"GET","path":"/product_type"},{"method":"GET","path":"/product_type/:identity"},{"method":"GET","path":"/product_warehouse"},{"method":"GET","path":"/product_warehouse/:identity"},{"method":"GET","path":"/product_info"},{"method":"GET","path":"/product_info/:identity"},{"method":"GET","path":"/product_repair"},{"method":"GET","path":"/product_repair/:identity"},{"method":"GET","path":"/product_owner"},{"method":"GET","path":"/product_owner/:identity"},{"method":"GET","path":"/producer_account"},{"method":"GET","path":"/producer_account/:identity"},{"method":"GET","path":"/platform_role"},{"method":"GET","path":"/platform_role/:identity"},{"method":"GET","path":"/platform_role/:identity/menu"},{"method":"GET","path":"/platform_account"},{"method":"GET","path":"/platform_account/:identity"},{"method":"GET","path":"/platform_menu"},{"method":"GET","path":"/platform_menu/:identity"},{"method":"GET","path":"/payment_order"},{"method":"GET","path":"/payment_order/:identity"},{"method":"GET","path":"/payment_refund"},{"method":"GET","path":"/payment_refund/:identity"},{"method":"GET","path":"/ping/hello"},{"method":"GET","path":"/ec_product"},{"method":"GET","path":"/ec_product_attribute"},{"method":"GET","path":"/ec_product_attribute/:identity"},{"method":"GET","path":"/ec_product_image"},{"method":"GET","path":"/ec_product_image/:identity"},{"method":"GET","path":"/ec_product/:identity"},{"method":"GET","path":"/ec_category"},{"method":"GET","path":"/ec_category/:identity"},{"method":"GET","path":"/ec_cart"},{"method":"GET","path":"/ec_cart/:identity"},{"method":"GET","path":"/ec_order"},{"method":"GET","path":"/ec_order_item"},{"method":"GET","path":"/ec_order_item/:identity"},{"method":"GET","path":"/ec_order/:identity"},{"method":"GET","path":"/ec_review"},{"method":"GET","path":"/ec_review/:identity"},{"method":"GET","path":"/wallet_basic"},{"method":"GET","path":"/wallet_basic/:identity"},{"method":"GET","path":"/wallet_bank"},{"method":"GET","path":"/wallet_bank/:identity"},{"method":"GET","path":"/wallet_record"},{"method":"GET","path":"/wallet_record/:identity"},{"method":"GET","path":"/wallet_apply_cash"},{"method":"GET","path":"/wallet_apply_cash/:identity"},{"method":"GET","path":"/user_account"},{"method":"GET","path":"/user_account/:identity"},{"method":"GET","path":"/user_address"},{"method":"GET","path":"/user_address/:identity"},{"method":"GET","path":"/user_service_relation"},{"method":"GET","path":"/user_service_relation/:identity"},{"method":"GET","path":"/fin_payment"},{"method":"GET","path":"/fin_payment/:identity"},{"method":"GET","path":"/fin_settlement"},{"method":"GET","path":"/fin_settlement/:identity"},{"method":"GET","path":"/fin_reconciliation"},{"method":"GET","path":"/fin_reconciliation/:identity"},{"method":"GET","path":"/delivery_basic"},{"method":"GET","path":"/delivery_basic/:identity"},{"method":"GET","path":"/delivery_account"},{"method":"GET","path":"/delivery_account/:identity"},{"method":"GET","path":"/dashboard/overview"},{"method":"GET","path":"/staff_account"},{"method":"GET","path":"/staff_account/:identity"},{"method":"GET","path":"/staff_credential"},{"method":"GET","path":"/staff_credential/:identity"},{"method":"GET","path":"/cms_content"},{"method":"GET","path":"/cms_content/:identity"},{"method":"GET","path":"/cs_ticket"},{"method":"GET","path":"/cs_ticket/:identity"},{"method":"GET","path":"/auth/profile"},{"method":"POST","path":"/gasorder_basic"},{"method":"POST","path":"/gasorder_basic/:identity/assign"},{"method":"POST","path":"/gasorder_basic/:identity/awaiting-confirmation"},{"method":"POST","path":"/gasorder_basic/:identity/ready"},{"method":"POST","path":"/gasorder_basic/:identity/recover"},{"method":"POST","path":"/gasorder_basic/:identity/complete"},{"method":"POST","path":"/gasorder_basic/:identity/cancel"},{"method":"POST","path":"/gasorder_basic/:identity/filling"},{"method":"POST","path":"/gasorder_basic/:identity/delivering"},{"method":"POST","path":"/gasorder_basic/:identity/exception"},{"method":"POST","path":"/gasorder_contract"},{"method":"POST","path":"/gasorder_contract/:identity/activate"},{"method":"POST","path":"/gasorder_contract/:identity/renew"},{"method":"POST","path":"/gasorder_contract/:identity/terminate"},{"method":"POST","path":"/gasorder_contract_product"},{"method":"POST","path":"/gasorder_contract_product/:identity/unbind"},{"method":"POST","path":"/gas_basic"},{"method":"POST","path":"/gas_basic/:identity/review"},{"method":"POST","path":"/gas_account"},{"method":"POST","path":"/product_type"},{"method":"POST","path":"/product_warehouse"},{"method":"POST","path":"/product_info"},{"method":"POST","path":"/product_repair"},{"method":"POST","path":"/producer_account"},{"method":"POST","path":"/payment_refund/:identity/approve"},{"method":"POST","path":"/payment_refund/:identity/reject"},{"method":"POST","path":"/platform_account"},{"method":"POST","path":"/platform_role"},{"method":"POST","path":"/wallet_apply_cash/:identity/approve"},{"method":"POST","path":"/wallet_apply_cash/:identity/reject"},{"method":"POST","path":"/wallet_apply_cash/:identity/complete"},{"method":"POST","path":"/wallet_basic/owner/:owner_type/:owner_identity"},{"method":"POST","path":"/wallet_basic/:identity/recharge"},{"method":"POST","path":"/ec_product"},{"method":"POST","path":"/ec_product_attribute"},{"method":"POST","path":"/ec_product_image"},{"method":"POST","path":"/ec_category"},{"method":"POST","path":"/user_account"},{"method":"POST","path":"/user_address"},{"method":"POST","path":"/user_service_relation"},{"method":"POST","path":"/delivery_basic"},{"method":"POST","path":"/delivery_account"},{"method":"POST","path":"/staff_account"},{"method":"POST","path":"/staff_credential"},{"method":"POST","path":"/cms_content"},{"method":"POST","path":"/cs_ticket"},{"method":"POST","path":"/auth/login"},{"method":"POST","path":"/fin_settlement"},{"method":"PUT","path":"/product_type/:identity"},{"method":"PUT","path":"/product_warehouse/:identity"},{"method":"PUT","path":"/product_info/:identity"},{"method":"PUT","path":"/product_repair/:identity"},{"method":"PUT","path":"/producer_account/:identity"},{"method":"PUT","path":"/platform_role/:identity"},{"method":"PUT","path":"/platform_role/:identity/menu"},{"method":"PUT","path":"/platform_account/:identity"},{"method":"PUT","path":"/ec_product_attribute/:identity"},{"method":"PUT","path":"/ec_product_image/:identity"},{"method":"PUT","path":"/ec_product/:identity"},{"method":"PUT","path":"/ec_category/:identity"},{"method":"PUT","path":"/gas_basic/:identity"},{"method":"PUT","path":"/gas_account/:identity"},{"method":"PUT","path":"/gasorder_contract/:identity"},{"method":"PUT","path":"/user_account/:identity"},{"method":"PUT","path":"/user_address/:identity"},{"method":"PUT","path":"/user_service_relation/:identity"},{"method":"PUT","path":"/delivery_basic/:identity"},{"method":"PUT","path":"/delivery_account/:identity"},{"method":"PUT","path":"/staff_account/:identity"},{"method":"PUT","path":"/staff_credential/:identity"},{"method":"PUT","path":"/cms_content/:identity"},{"method":"PUT","path":"/cs_ticket/:identity"},{"method":"PUT","path":"/auth/password"},{"method":"PUT","path":"/fin_settlement/:identity"},{"method":"PATCH","path":"/product_info/:identity/status"},{"method":"PATCH","path":"/product_info/:identity/lifecycle"},{"method":"PATCH","path":"/product_type/:identity/status"},{"method":"PATCH","path":"/product_warehouse/:identity/status"},{"method":"PATCH","path":"/product_repair/:identity/status"},{"method":"PATCH","path":"/producer_account/:identity/status"},{"method":"PATCH","path":"/platform_account/:identity/status"},{"method":"PATCH","path":"/platform_role/:identity/status"},{"method":"PATCH","path":"/ec_product_attribute/:identity/status"},{"method":"PATCH","path":"/ec_product_image/:identity/status"},{"method":"PATCH","path":"/ec_product/:identity/status"},{"method":"PATCH","path":"/ec_category/:identity/status"},{"method":"PATCH","path":"/user_account/:identity/status"},{"method":"PATCH","path":"/user_address/:identity/status"},{"method":"PATCH","path":"/user_service_relation/:identity/status"},{"method":"PATCH","path":"/gas_basic/:identity/status"},{"method":"PATCH","path":"/gas_account/:identity/status"},{"method":"PATCH","path":"/delivery_basic/:identity/status"},{"method":"PATCH","path":"/delivery_account/:identity/status"},{"method":"PATCH","path":"/staff_account/:identity/status"},{"method":"PATCH","path":"/staff_credential/:identity/status"},{"method":"PATCH","path":"/cms_content/:identity/status"},{"method":"PATCH","path":"/cs_ticket/:identity/status"},{"method":"PATCH","path":"/wallet_basic/:identity/status"},{"method":"PATCH","path":"/fin_settlement/:identity/status"},{"method":"DELETE","path":"/ec_product_attribute/:identity"},{"method":"DELETE","path":"/ec_product_image/:identity"},{"method":"DELETE","path":"/ec_product/:identity"},{"method":"DELETE","path":"/ec_category/:identity"},{"method":"DELETE","path":"/user_account/:identity"},{"method":"DELETE","path":"/user_address/:identity"},{"method":"DELETE","path":"/user_service_relation/:identity"},{"method":"DELETE","path":"/platform_account/:identity"},{"method":"DELETE","path":"/platform_role/:identity"},{"method":"DELETE","path":"/producer_account/:identity"},{"method":"DELETE","path":"/gas_basic/:identity"},{"method":"DELETE","path":"/gas_account/:identity"},{"method":"DELETE","path":"/delivery_basic/:identity"},{"method":"DELETE","path":"/delivery_account/:identity"},{"method":"DELETE","path":"/staff_account/:identity"},{"method":"DELETE","path":"/staff_credential/:identity"},{"method":"DELETE","path":"/cms_content/:identity"},{"method":"DELETE","path":"/cs_ticket/:identity"},{"method":"DELETE","path":"/fin_settlement/:identity"}]} +{"resources":[{"domain":"gas","name":"gas_basic","path":"/gas_basic","pageKind":"list","mode":"writable","searchFields":[{"key":"code","kind":"text"},{"key":"name","kind":"text"}]},{"domain":"gas","name":"gas_account","path":"/gas_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"},{"key":"display_name","kind":"text"},{"key":"role_code","kind":"enum","values":[{"value":"admin","label":"气站管理员"}]}]},{"domain":"delivery","name":"delivery_basic","path":"/delivery_basic","pageKind":"list","mode":"writable","searchFields":[{"key":"delivery_code","kind":"text"},{"key":"name","kind":"text"}]},{"domain":"delivery","name":"delivery_account","path":"/delivery_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"},{"key":"display_name","kind":"text"},{"key":"role_code","kind":"enum","values":[{"value":"admin","label":"配送点管理员"}]}]},{"domain":"staff","name":"staff_account","path":"/staff_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"},{"key":"role_code","kind":"enum","values":[{"value":"installer","label":"安装人员"},{"value":"delivery","label":"配送人员"},{"value":"operations","label":"运维人员"}]}]},{"domain":"staff","name":"staff_credential","path":"/staff_credential","pageKind":"list","mode":"writable","searchFields":[{"key":"credential_type","kind":"text"}]},{"domain":"user","name":"user_account","path":"/user_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"}]},{"domain":"user","name":"user_address","path":"/user_address","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_service_relation","path":"/user_service_relation","pageKind":"list","mode":"writable"},{"domain":"product","name":"producer_account","path":"/producer_account","pageKind":"list","mode":"writable","searchFields":[{"key":"name","kind":"text"}]},{"domain":"product","name":"product_type","path":"/product_type","pageKind":"list","mode":"editable","searchFields":[{"key":"code","kind":"text"},{"key":"name","kind":"text"}]},{"domain":"product","name":"product_warehouse","path":"/product_warehouse","pageKind":"list","mode":"editable","searchFields":[{"key":"code","kind":"text"},{"key":"name","kind":"text"}]},{"domain":"product","name":"product_info","path":"/product_info","pageKind":"list","mode":"editable","searchFields":[{"key":"code","kind":"text"},{"key":"name","kind":"text"}]},{"domain":"product","name":"product_repair","path":"/product_repair","pageKind":"list","mode":"editable","searchFields":[{"key":"result","kind":"enum","values":[{"value":"pending","label":"待处理"},{"value":"passed","label":"通过"},{"value":"failed","label":"未通过"}]}]},{"domain":"product","name":"product_owner","path":"/product_owner","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_category","path":"/ec_category","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_product","path":"/ec_product","pageKind":"list","mode":"writable","searchFields":[{"key":"product_code","kind":"text"},{"key":"name","kind":"text"}]},{"domain":"ec","name":"ec_product_attribute","path":"/ec_product_attribute","pageKind":"list","mode":"writable","searchFields":[{"key":"name","kind":"text"},{"key":"value","kind":"text"}]},{"domain":"ec","name":"ec_product_image","path":"/ec_product_image","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_cart","path":"/ec_cart","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_order","path":"/ec_order","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_order_item","path":"/ec_order_item","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_review","path":"/ec_review","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_contract","path":"/gasorder_contract","pageKind":"list","mode":"managed","searchFields":[{"key":"contract_no","kind":"text"},{"key":"title","kind":"text"}]},{"domain":"gasorder","name":"gasorder_contract_product","path":"/gasorder_contract_product","pageKind":"list","mode":"append_only"},{"domain":"gasorder","name":"gasorder_contract_revision","path":"/gasorder_contract_revision","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_basic","path":"/gasorder_basic","pageKind":"list","mode":"append_only","searchFields":[{"key":"request_no","kind":"text"},{"key":"creator_type","kind":"enum","values":[{"value":"user","label":"用户"},{"value":"staff","label":"工作人员"},{"value":"delivery","label":"配送站"},{"value":"gas","label":"气站"}]}]},{"domain":"gasorder","name":"gasorder_item","path":"/gasorder_item","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_assign","path":"/gasorder_assign","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_status","path":"/gasorder_status","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_track","path":"/gasorder_track","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_track_point","path":"/gasorder_track_point","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_confirm","path":"/gasorder_confirm","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_payment","path":"/gasorder_payment","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"fin_payment","path":"/fin_payment","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"fin_settlement","path":"/fin_settlement","pageKind":"list","mode":"writable","searchFields":[{"key":"settlement_no","kind":"text"},{"key":"subject_type","kind":"text"}]},{"domain":"finance","name":"fin_reconciliation","path":"/fin_reconciliation","pageKind":"list","mode":"readonly"},{"domain":"content","name":"cms_content","path":"/cms_content","pageKind":"list","mode":"writable","searchFields":[{"key":"content_type","kind":"text"},{"key":"title","kind":"text"},{"key":"publish_status","kind":"text"}]},{"domain":"customer_service","name":"cs_ticket","path":"/cs_ticket","pageKind":"list","mode":"writable","searchFields":[{"key":"ticket_no","kind":"text"},{"key":"category","kind":"text"},{"key":"priority","kind":"text"}]},{"domain":"platform","name":"platform_account","path":"/platform_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"},{"key":"display_name","kind":"text"}]},{"domain":"platform","name":"platform_role","path":"/platform_role","pageKind":"list","mode":"writable","searchFields":[{"key":"role_code","kind":"text"},{"key":"name","kind":"text"},{"key":"location_scope","kind":"enum","values":[{"value":"standard","label":"脱敏坐标"},{"value":"precise","label":"精确坐标"}]}]},{"domain":"platform","name":"platform_menu","path":"/platform_menu","pageKind":"tree","mode":"readonly"},{"domain":"wallet","name":"wallet_basic","path":"/wallet_basic","pageKind":"list","mode":"readonly","searchFields":[{"key":"owner_type","kind":"text"}]},{"domain":"wallet","name":"wallet_bank","path":"/wallet_bank","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"payment_order","path":"/payment_order","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_record","path":"/wallet_record","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"payment_refund","path":"/payment_refund","pageKind":"list","mode":"readonly","searchFields":[{"key":"refund_no","kind":"text"}]},{"domain":"wallet","name":"wallet_apply_cash","path":"/wallet_apply_cash","pageKind":"list","mode":"readonly","searchFields":[{"key":"cash_no","kind":"text"}]}],"routes":[{"method":"GET","path":"/gasorder_contract"},{"method":"GET","path":"/gasorder_contract_product"},{"method":"GET","path":"/gasorder_contract_product/:identity"},{"method":"GET","path":"/gasorder_contract_revision"},{"method":"GET","path":"/gasorder_contract_revision/:identity"},{"method":"GET","path":"/gasorder_contract/:identity"},{"method":"GET","path":"/gasorder_confirm"},{"method":"GET","path":"/gasorder_confirm/:identity"},{"method":"GET","path":"/gasorder_track"},{"method":"GET","path":"/gasorder_track_point"},{"method":"GET","path":"/gasorder_track_point/:identity"},{"method":"GET","path":"/gasorder_track/:identity"},{"method":"GET","path":"/gasorder_basic"},{"method":"GET","path":"/gasorder_basic/:identity"},{"method":"GET","path":"/gasorder_item"},{"method":"GET","path":"/gasorder_item/:identity"},{"method":"GET","path":"/gasorder_assign"},{"method":"GET","path":"/gasorder_assign/:identity"},{"method":"GET","path":"/gasorder_status"},{"method":"GET","path":"/gasorder_status/:identity"},{"method":"GET","path":"/gasorder_payment"},{"method":"GET","path":"/gasorder_payment/:identity"},{"method":"GET","path":"/gas_basic"},{"method":"GET","path":"/gas_basic/:identity"},{"method":"GET","path":"/gas_account"},{"method":"GET","path":"/gas_account/:identity"},{"method":"GET","path":"/product_type"},{"method":"GET","path":"/product_type/:identity"},{"method":"GET","path":"/product_warehouse"},{"method":"GET","path":"/product_warehouse/:identity"},{"method":"GET","path":"/product_info"},{"method":"GET","path":"/product_info/:identity"},{"method":"GET","path":"/product_repair"},{"method":"GET","path":"/product_repair/:identity"},{"method":"GET","path":"/product_owner"},{"method":"GET","path":"/product_owner/:identity"},{"method":"GET","path":"/producer_account"},{"method":"GET","path":"/producer_account/:identity"},{"method":"GET","path":"/platform_account"},{"method":"GET","path":"/platform_account/:identity"},{"method":"GET","path":"/platform_account/:identity/avatar"},{"method":"GET","path":"/platform_role"},{"method":"GET","path":"/platform_role/:identity"},{"method":"GET","path":"/platform_role/:identity/menu"},{"method":"GET","path":"/platform_menu"},{"method":"GET","path":"/platform_menu/:identity"},{"method":"GET","path":"/payment_order"},{"method":"GET","path":"/payment_order/:identity"},{"method":"GET","path":"/payment_refund"},{"method":"GET","path":"/payment_refund/:identity"},{"method":"GET","path":"/ping/hello"},{"method":"GET","path":"/ec_product"},{"method":"GET","path":"/ec_product_attribute"},{"method":"GET","path":"/ec_product_attribute/:identity"},{"method":"GET","path":"/ec_product_image"},{"method":"GET","path":"/ec_product_image/:identity"},{"method":"GET","path":"/ec_product/:identity"},{"method":"GET","path":"/ec_category"},{"method":"GET","path":"/ec_category/:identity"},{"method":"GET","path":"/ec_cart"},{"method":"GET","path":"/ec_cart/:identity"},{"method":"GET","path":"/ec_order"},{"method":"GET","path":"/ec_order_item"},{"method":"GET","path":"/ec_order_item/:identity"},{"method":"GET","path":"/ec_order/:identity"},{"method":"GET","path":"/ec_review"},{"method":"GET","path":"/ec_review/:identity"},{"method":"GET","path":"/wallet_basic"},{"method":"GET","path":"/wallet_basic/:identity"},{"method":"GET","path":"/wallet_bank"},{"method":"GET","path":"/wallet_bank/:identity"},{"method":"GET","path":"/wallet_record"},{"method":"GET","path":"/wallet_record/:identity"},{"method":"GET","path":"/wallet_apply_cash"},{"method":"GET","path":"/wallet_apply_cash/:identity"},{"method":"GET","path":"/user_account"},{"method":"GET","path":"/user_account/:identity"},{"method":"GET","path":"/user_account/:identity/avatar"},{"method":"GET","path":"/user_address"},{"method":"GET","path":"/user_address/:identity"},{"method":"GET","path":"/user_service_relation"},{"method":"GET","path":"/user_service_relation/:identity"},{"method":"GET","path":"/fin_payment"},{"method":"GET","path":"/fin_payment/:identity"},{"method":"GET","path":"/fin_settlement"},{"method":"GET","path":"/fin_settlement/:identity"},{"method":"GET","path":"/fin_reconciliation"},{"method":"GET","path":"/fin_reconciliation/:identity"},{"method":"GET","path":"/delivery_basic"},{"method":"GET","path":"/delivery_basic/:identity"},{"method":"GET","path":"/delivery_account"},{"method":"GET","path":"/delivery_account/:identity"},{"method":"GET","path":"/dashboard/overview"},{"method":"GET","path":"/staff_account"},{"method":"GET","path":"/staff_account/:identity"},{"method":"GET","path":"/staff_account/:identity/avatar"},{"method":"GET","path":"/staff_credential"},{"method":"GET","path":"/staff_credential/:identity"},{"method":"GET","path":"/cms_content"},{"method":"GET","path":"/cms_content/:identity"},{"method":"GET","path":"/cs_ticket"},{"method":"GET","path":"/cs_ticket/:identity"},{"method":"GET","path":"/auth/profile"},{"method":"POST","path":"/gasorder_basic"},{"method":"POST","path":"/gasorder_basic/:identity/assign"},{"method":"POST","path":"/gasorder_basic/:identity/awaiting-confirmation"},{"method":"POST","path":"/gasorder_basic/:identity/ready"},{"method":"POST","path":"/gasorder_basic/:identity/recover"},{"method":"POST","path":"/gasorder_basic/:identity/complete"},{"method":"POST","path":"/gasorder_basic/:identity/cancel"},{"method":"POST","path":"/gasorder_basic/:identity/filling"},{"method":"POST","path":"/gasorder_basic/:identity/delivering"},{"method":"POST","path":"/gasorder_basic/:identity/exception"},{"method":"POST","path":"/gasorder_contract"},{"method":"POST","path":"/gasorder_contract/:identity/activate"},{"method":"POST","path":"/gasorder_contract/:identity/renew"},{"method":"POST","path":"/gasorder_contract/:identity/terminate"},{"method":"POST","path":"/gasorder_contract_product"},{"method":"POST","path":"/gasorder_contract_product/:identity/unbind"},{"method":"POST","path":"/gas_basic"},{"method":"POST","path":"/gas_account"},{"method":"POST","path":"/product_type"},{"method":"POST","path":"/product_warehouse"},{"method":"POST","path":"/product_info"},{"method":"POST","path":"/product_repair"},{"method":"POST","path":"/producer_account"},{"method":"POST","path":"/payment_refund/:identity/approve"},{"method":"POST","path":"/payment_refund/:identity/reject"},{"method":"POST","path":"/platform_account"},{"method":"POST","path":"/platform_role"},{"method":"POST","path":"/wallet_apply_cash/:identity/approve"},{"method":"POST","path":"/wallet_apply_cash/:identity/reject"},{"method":"POST","path":"/wallet_apply_cash/:identity/complete"},{"method":"POST","path":"/wallet_basic/owner/:owner_type/:owner_identity"},{"method":"POST","path":"/wallet_basic/:identity/recharge"},{"method":"POST","path":"/ec_product"},{"method":"POST","path":"/ec_product_attribute"},{"method":"POST","path":"/ec_product_image"},{"method":"POST","path":"/ec_category"},{"method":"POST","path":"/user_account"},{"method":"POST","path":"/user_address"},{"method":"POST","path":"/user_service_relation"},{"method":"POST","path":"/delivery_basic"},{"method":"POST","path":"/delivery_account"},{"method":"POST","path":"/staff_account"},{"method":"POST","path":"/staff_credential"},{"method":"POST","path":"/cms_content"},{"method":"POST","path":"/cs_ticket"},{"method":"POST","path":"/auth/login"},{"method":"POST","path":"/fin_settlement"},{"method":"PUT","path":"/product_type/:identity"},{"method":"PUT","path":"/product_warehouse/:identity"},{"method":"PUT","path":"/product_info/:identity"},{"method":"PUT","path":"/product_repair/:identity"},{"method":"PUT","path":"/producer_account/:identity"},{"method":"PUT","path":"/platform_role/:identity"},{"method":"PUT","path":"/platform_role/:identity/menu"},{"method":"PUT","path":"/platform_account/:identity"},{"method":"PUT","path":"/ec_product_attribute/:identity"},{"method":"PUT","path":"/ec_product_image/:identity"},{"method":"PUT","path":"/ec_product/:identity"},{"method":"PUT","path":"/ec_category/:identity"},{"method":"PUT","path":"/gas_basic/:identity"},{"method":"PUT","path":"/gas_account/:identity"},{"method":"PUT","path":"/gasorder_contract/:identity"},{"method":"PUT","path":"/user_account/:identity"},{"method":"PUT","path":"/user_address/:identity"},{"method":"PUT","path":"/user_service_relation/:identity"},{"method":"PUT","path":"/delivery_basic/:identity"},{"method":"PUT","path":"/delivery_account/:identity"},{"method":"PUT","path":"/staff_account/:identity"},{"method":"PUT","path":"/staff_credential/:identity"},{"method":"PUT","path":"/cms_content/:identity"},{"method":"PUT","path":"/cs_ticket/:identity"},{"method":"PUT","path":"/auth/password"},{"method":"PUT","path":"/fin_settlement/:identity"},{"method":"PATCH","path":"/product_info/:identity/status"},{"method":"PATCH","path":"/product_info/:identity/lifecycle"},{"method":"PATCH","path":"/product_type/:identity/status"},{"method":"PATCH","path":"/product_warehouse/:identity/status"},{"method":"PATCH","path":"/product_repair/:identity/status"},{"method":"PATCH","path":"/producer_account/:identity/status"},{"method":"PATCH","path":"/platform_account/:identity/status"},{"method":"PATCH","path":"/platform_role/:identity/status"},{"method":"PATCH","path":"/ec_product_attribute/:identity/status"},{"method":"PATCH","path":"/ec_product_image/:identity/status"},{"method":"PATCH","path":"/ec_product/:identity/status"},{"method":"PATCH","path":"/ec_category/:identity/status"},{"method":"PATCH","path":"/user_account/:identity/status"},{"method":"PATCH","path":"/user_address/:identity/status"},{"method":"PATCH","path":"/user_service_relation/:identity/status"},{"method":"PATCH","path":"/gas_basic/:identity/status"},{"method":"PATCH","path":"/gas_account/:identity/status"},{"method":"PATCH","path":"/delivery_basic/:identity/status"},{"method":"PATCH","path":"/delivery_account/:identity/status"},{"method":"PATCH","path":"/staff_account/:identity/status"},{"method":"PATCH","path":"/staff_credential/:identity/status"},{"method":"PATCH","path":"/cms_content/:identity/status"},{"method":"PATCH","path":"/cs_ticket/:identity/status"},{"method":"PATCH","path":"/wallet_basic/:identity/status"},{"method":"PATCH","path":"/fin_settlement/:identity/status"},{"method":"DELETE","path":"/ec_product_attribute/:identity"},{"method":"DELETE","path":"/ec_product_image/:identity"},{"method":"DELETE","path":"/ec_product/:identity"},{"method":"DELETE","path":"/ec_category/:identity"},{"method":"DELETE","path":"/user_account/:identity"},{"method":"DELETE","path":"/user_address/:identity"},{"method":"DELETE","path":"/user_service_relation/:identity"},{"method":"DELETE","path":"/platform_account/:identity"},{"method":"DELETE","path":"/platform_role/:identity"},{"method":"DELETE","path":"/producer_account/:identity"},{"method":"DELETE","path":"/gas_basic/:identity"},{"method":"DELETE","path":"/gas_account/:identity"},{"method":"DELETE","path":"/delivery_basic/:identity"},{"method":"DELETE","path":"/delivery_account/:identity"},{"method":"DELETE","path":"/staff_account/:identity"},{"method":"DELETE","path":"/staff_credential/:identity"},{"method":"DELETE","path":"/cms_content/:identity"},{"method":"DELETE","path":"/cs_ticket/:identity"},{"method":"DELETE","path":"/fin_settlement/:identity"}]} diff --git a/frontend/platform_admin/src/views/shared/CrudListPage.vue b/frontend/platform_admin/src/views/shared/CrudListPage.vue index 54affe8..a3a0b3d 100644 --- a/frontend/platform_admin/src/views/shared/CrudListPage.vue +++ b/frontend/platform_admin/src/views/shared/CrudListPage.vue @@ -14,9 +14,9 @@ 请从详情页中的专用操作推进流程,操作结果会按服务端状态校验并保留审计记录。
- - - + + + 查询 重置 @@ -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([]); -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( props.definition.resource, page.value, @@ -423,23 +418,6 @@ function confirmArchive(row: ResourceRow) { }); } -async function syncQuery() { - const query: Record = {}; - 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(), diff --git a/frontend/platform_admin/src/views/shared/resource-list-field-display.ts b/frontend/platform_admin/src/views/shared/resource-list-field-display.ts index 33d4465..0433016 100644 --- a/frontend/platform_admin/src/views/shared/resource-list-field-display.ts +++ b/frontend/platform_admin/src/views/shared/resource-list-field-display.ts @@ -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`] ?? ''); diff --git a/frontend/platform_admin/src/views/shared/use-resource-list-search.ts b/frontend/platform_admin/src/views/shared/use-resource-list-search.ts new file mode 100644 index 0000000..8b446d4 --- /dev/null +++ b/frontend/platform_admin/src/views/shared/use-resource-list-search.ts @@ -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, + page: Ref, +) { + 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 = {}; + 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, + }; +}