功能:按资源配置平台列表搜索

This commit is contained in:
czl231
2026-08-11 22:22:36 +08:00
parent 8d6ffca1e2
commit 95d0f7dc54
19 changed files with 741 additions and 62 deletions

View File

@@ -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})

View File

@@ -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

View 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
}

View 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("搜索契约必须返回副本,避免调用方修改全局策略")
}
}

View File

@@ -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 {

View 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}
}

View 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
}

View File

@@ -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)

View 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 列名和编码均来自静态策略。
- 当前未开放动态关系名称搜索;若后续开放,需要额外权限审计和数据库索引评估。

View 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 清理。

View File

@@ -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 .",

View File

@@ -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_',

View 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 清理和中文枚举均已覆盖');

View 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 }));
}

View File

@@ -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

View File

@@ -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(),

View File

@@ -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`] ?? '');

View File

@@ -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,
};
}