Compare commits

..
3 Commits
Author SHA1 Message Date
wc24 eb339ccc49 feat(filter): buildQuery 放行 _id 精确过滤(单项查看/relation 跳转)
filter 原本只认 filterable 字段,_id 会被丢弃。新增: key==='_id' 时始终以 eq 放行,
让 ?filter={_id} 能过滤到单条。_id 是主键精确匹配、用户已有列表权限,无安全顾虑。
2026-06-29 20:40:28 +08:00
wc24 8f9dfd40e0 feat(relation): 用整页 refs 字典替代逐行 _label
list 响应新增 refs: { moduleKey: { id: 显示名 } },整页一次性按模块批量查、去重;
去掉 relation 的 populateItems→${key}_label 逐行注入。前端显示时按 id 查 refs。
tab 的 resLabel/_label 不动(留待复合那步)。这是嵌套引用的地基:collectRefs 以后加
tab 的 children() 下钻即可覆盖任意深度。
2026-06-29 20:17:26 +08:00
wc24 ee35b47f69 revert(tab): 撤回 v0.6.5 嵌套 relation 解析,回到 format 纯展示
populateTabRelations / format 第二参是 tab 专属特例,太狭隘(object/array 同类问题未覆盖),
通用复合解析另当专门事做。嵌套关联名(如商品名)的展示需求改放业务侧(模块重写 list/get)。
框架只保留: format(同步纯展示) + stringify/parse(可逆往返)。
2026-06-29 17:33:52 +08:00
3 changed files with 31 additions and 66 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "shrimp-server", "name": "shrimp-server",
"version": "0.6.5", "version": "0.6.8",
"description": "Shrimp 框架后端 — 声明式字段驱动的 Node.js 管理后台框架(Express + Mongoose + JWT)", "description": "Shrimp 框架后端 — 声明式字段驱动的 Node.js 管理后台框架(Express + Mongoose + JWT)",
"main": "dist/main/index.js", "main": "dist/main/index.js",
"types": "dist/main/index.d.ts", "types": "dist/main/index.d.ts",
+3 -3
View File
@@ -278,9 +278,8 @@ export interface IFieldConfig {
rules?: IFieldRule[] // 通用验证规则(前后端共用) rules?: IFieldRule[] // 通用验证规则(前后端共用)
/** tab 类型专用:每个 type 对应的子字段配置 */ /** tab 类型专用:每个 type 对应的子字段配置 */
tabs?: { key: any; label: ILabel; fields: IFieldConfig[] }[] tabs?: { key: any; label: ILabel; fields: IFieldConfig[] }[]
/** tab 类型专用:展示文本(表格列 `${key}_label`),给人看、可有损好看,默认 JSON.stringify。 /** tab 类型专用:展示文本(表格列 `${key}_label`),给人看、可有损好看,默认 JSON.stringify */
* relLabels 是已解析的子 relation 名(`{childKey: 名 | 名[]}`),单选为名、multiple 为名数组 */ format?: (value: any) => string
format?: (value: any, relLabels?: Record<string, any>) => string
/** tab 类型专用:export 序列化,须与 parse 可逆(机器往返),默认 JSON.stringify */ /** tab 类型专用:export 序列化,须与 parse 可逆(机器往返),默认 JSON.stringify */
stringify?: (value: any) => string stringify?: (value: any) => string
/** tab 类型专用:import 反序列化,须与 stringify 可逆,默认 JSON.parse */ /** tab 类型专用:import 反序列化,须与 stringify 可逆,默认 JSON.parse */
@@ -321,6 +320,7 @@ export interface IPageResult<T> {
page: number // 当前页码 page: number // 当前页码
pageSize: number // 每页数量 pageSize: number // 每页数量
totalPages: number // 总页数 totalPages: number // 总页数
refs?: Record<string, Record<string, any>> // relation 显示名字典: { moduleKey: { id: 显示名 } },前端显示时查名,替代逐行 _label
} }
// ============== 数据源查询协议 ============== // ============== 数据源查询协议 ==============
+27 -62
View File
@@ -61,8 +61,7 @@ export const defaultTypeHandlers: Record<string, IFieldTypeHandler> = {
return value return value
}, },
// 本体原样下发对象(不再 stringify);展示文本走 _label,用 format(给人看,可有损) // 本体原样下发对象(不再 stringify);展示文本走 _label,用 format(给人看,可有损)
// 第二参是已批量解析的子 relation 名(item.__tabRel[field.key]),让 format 能显示关联名而非 id resLabel: (value, field) => (field.format ?? JSON.stringify)(value),
resLabel: (value, field, item) => (field.format ?? JSON.stringify)(value, item?.__tabRel?.[field.key]),
}, },
} }
@@ -226,80 +225,48 @@ export class ShrimpTableModule<T, P extends ITableCrudProto<T> = ITableCrudProto
result[`${field.key}_label`] = handler.resLabel(result[field.key], field, result) result[`${field.key}_label`] = handler.resLabel(result[field.key], field, result)
} }
} }
delete result.__tabRel // 临时挂的子 relation 解析结果,不下发
return result return result
} }
private async prepareResponseItems(items: any[]): Promise<any[]> { private async prepareResponseItems(items: any[]): Promise<any[]> {
if (items.length === 0) return items if (items.length === 0) return items
const populates = this.getRelationPopulates()
if (populates.length > 0 && this.dataSource.populateItems) {
await this.dataSource.populateItems(items, populates, this.root.crudRegistry)
}
await this.populateTabRelations(items)
return Promise.all(items.map(item => this.processResItem(item))) return Promise.all(items.map(item => this.processResItem(item)))
} }
private async prepareResponseItem(item: any): Promise<any> { private async prepareResponseItem(item: any): Promise<any> {
if (!item) return item if (!item) return item
const populates = this.getRelationPopulates()
if (populates.length > 0 && this.dataSource.populateItems) {
await this.dataSource.populateItems([item], populates, this.root.crudRegistry)
}
await this.populateTabRelations([item])
return this.processResItem(item) return this.processResItem(item)
} }
/** /**
* 解析 tab 字段里 relation 子字段的名字,批量(每个关联模块一次 $in), * 顶层 relation 字段批量解析成 refs 字典: { moduleKey: { id: 显示名 } }。
* 挂到 item.__tabRel[fieldKey][childKey],供该字段的 format 第二参使用。 * 整页一次性、按模块各查一次 $in,去重;替代逐行注入 ${key}_label。前端显示时按 id 查名。
* 不污染 tab 本体对象(避免回灌进库),__tabRel 在 processResItem 末尾删除。
*/ */
private async populateTabRelations(items: any[]): Promise<void> { private async buildRefs(rows: any[]): Promise<Record<string, Record<string, any>>> {
const tabFields = this.fields.filter(f => f.type === ShrimpFieldTypes.Tab && Array.isArray(f.tabs)) const relFields = this.getRelationFields()
if (tabFields.length === 0 || items.length === 0) return if (relFields.length === 0 || rows.length === 0) return {}
const buckets = new Map<string, { labelKey: string; ids: Set<string> }>()
const need = new Map<string, { labelKey: string; ids: Set<string> }>() for (const row of rows) {
const refs: { item: any; fieldKey: string; childKey: string; moduleKey: string; multiple: boolean }[] = [] for (const f of relFields) {
for (const item of items) { const v = row[f.key]
for (const tf of tabFields) { if (v == null) continue
const val = item[tf.key] const { moduleKey, labelKey } = f.relation!
if (!val || typeof val !== "object") continue let b = buckets.get(moduleKey)
const tab = tf.tabs!.find(t => t.key === val.type) if (!b) buckets.set(moduleKey, (b = { labelKey, ids: new Set() }))
if (!tab) continue ;(Array.isArray(v) ? v : [v]).forEach((id: any) => id != null && b!.ids.add(String(id)))
for (const child of tab.fields) {
if (child.type !== ShrimpFieldTypes.Relation || !child.relation || val[child.key] == null) continue
const { moduleKey, labelKey } = child.relation
let bucket = need.get(moduleKey)
if (!bucket) need.set(moduleKey, (bucket = { labelKey, ids: new Set() }))
const cv = val[child.key]
;(Array.isArray(cv) ? cv : [cv]).forEach((id: any) => id != null && bucket!.ids.add(String(id)))
refs.push({ item, fieldKey: tf.key, childKey: child.key, moduleKey, multiple: !!child.multiple })
}
} }
} }
if (refs.length === 0) return const refs: Record<string, Record<string, any>> = {}
for (const [moduleKey, b] of buckets) {
const labelMaps = new Map<string, Map<string, any>>() const dict: Record<string, any> = {}
for (const [moduleKey, b] of need) {
const m = new Map<string, any>()
const crud = this.root.crudRegistry.get(moduleKey) const crud = this.root.crudRegistry.get(moduleKey)
if (crud && b.ids.size > 0) { if (crud && b.ids.size > 0) {
const res = await crud.list({ where: { _id: { in: Array.from(b.ids) } }, limit: b.ids.size }) const res = await crud.list({ where: { _id: { in: Array.from(b.ids) } }, limit: b.ids.size })
for (const doc of res.list as any[]) m.set(String(doc._id), doc[b.labelKey]) for (const d of res.list as any[]) dict[String(d._id)] = d[b.labelKey]
} }
labelMaps.set(moduleKey, m) refs[moduleKey] = dict
}
for (const ref of refs) {
const m = labelMaps.get(ref.moduleKey)!
const cv = ref.item[ref.fieldKey][ref.childKey]
const label = ref.multiple
? (Array.isArray(cv) ? cv.map((id: any) => m.get(String(id)) ?? null) : [])
: m.get(String(cv)) ?? null
const bag = (ref.item.__tabRel ||= {})
;(bag[ref.fieldKey] ||= {})[ref.childKey] = label
} }
return refs
} }
private excludeKeys(item: any, keys: string[]): any { private excludeKeys(item: any, keys: string[]): any {
@@ -319,14 +286,6 @@ export class ShrimpTableModule<T, P extends ITableCrudProto<T> = ITableCrudProto
return this.fields.filter(f => f.type === "relation" && f.relation) return this.fields.filter(f => f.type === "relation" && f.relation)
} }
private getRelationPopulates(): IShrimpPopulate[] {
return this.getRelationFields().map(f => ({
key: f.key,
moduleKey: f.relation!.moduleKey,
labelKey: f.relation!.labelKey,
}))
}
private buildQuery(query?: IPageQuery & any): IShrimpQuery { private buildQuery(query?: IPageQuery & any): IShrimpQuery {
const { keyword, page, pageSize, sortBy, sortOrder, filter, ownerId } = query || {} const { keyword, page, pageSize, sortBy, sortOrder, filter, ownerId } = query || {}
const p = page || 1 const p = page || 1
@@ -341,6 +300,11 @@ export class ShrimpTableModule<T, P extends ITableCrudProto<T> = ITableCrudProto
const stringLikeTypes = new Set(['string', 'textarea']) const stringLikeTypes = new Set(['string', 'textarea'])
const where: Record<string, IShrimpCondition> = {} const where: Record<string, IShrimpCondition> = {}
for (const [key, value] of Object.entries(filter)) { for (const [key, value] of Object.entries(filter)) {
// _id 始终放行精确过滤(单项查看/relation 跳转用),无需配 filterable
if (key === '_id') {
if (value != null && value !== '') where['_id'] = { eq: value }
continue
}
const field = filterableFields.get(key) const field = filterableFields.get(key)
if (!field) continue if (!field) continue
if (value == null) continue if (value == null) continue
@@ -395,6 +359,7 @@ export class ShrimpTableModule<T, P extends ITableCrudProto<T> = ITableCrudProto
const result = await this.dataSource.list(shrimpQuery) const result = await this.dataSource.list(shrimpQuery)
result.list = await this.prepareResponseItems(result.list) result.list = await this.prepareResponseItems(result.list)
result.list = result.list.map(item => this.filterHidden(item)) result.list = result.list.map(item => this.filterHidden(item))
result.refs = await this.buildRefs(result.list)
return result return result
} }