Files
shrimp-server/src/Shrimp/ShrimpTableModule.ts
T
wc24 bc54b475cf feat(tab): format 可拿到子 relation 解析名(批量,无 N+1)
tab 字段的 relation 子字段(如 sellType.goods)原只有 id,format 显示不了关联名。
新增 populateTabRelations:批量(每个关联模块一次 $in via crud.list)解析子 relation 的
labelKey 名,挂到 item.__tabRel[fieldKey][childKey],作为 format 第二参 relLabels 传入;
不污染 tab 本体(避免回灌进库),响应末尾删除 __tabRel。
2026-06-29 16:56:45 +08:00

496 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
IFieldConfig, IFieldTypeHandler, IShrimpQuery, IShrimpCondition, IShrimpPopulate,
IPageQuery, IPageResult, ITableCrudProto, ILabel, ShrimpConfig, ShrimpError, ShrimpFieldTypes,
} from "../IShrimpType"
import { Shrimp } from "./Shrimp"
import { ShrimpModule, IShrimpModuleOptions } from "./ShrimpModule"
import type { ShrimpCrud } from "./ShrimpCrud"
import type { RootShrimpPower } from "./ShrimpPower"
/** 内置字段类型处理器 */
export const defaultTypeHandlers: Record<string, IFieldTypeHandler> = {
[ShrimpFieldTypes.Password]: {
reqFilter: async (value) => {
if (typeof value === 'string' && value.length > 0) {
return Shrimp.passwordHasher.hash(value)
}
return value
},
resFilter: () => "***",
},
[ShrimpFieldTypes.Date]: {
defaultValue: () => Date.now(),
},
[ShrimpFieldTypes.Number]: {
defaultValue: 0,
},
[ShrimpFieldTypes.Boolean]: {
defaultValue: false,
},
[ShrimpFieldTypes.Json]: {
reqFilter: (value) => {
if (value == null || value === "") return undefined
if (typeof value === "string") {
try {
return JSON.parse(value)
} catch {
throw new ShrimpError({ t: 'shrimp.invalidJson' })
}
}
return value
},
resFilter: (value) => {
if (value == null) return value
if (typeof value === "object") {
return JSON.stringify(value)
}
return value
},
},
[ShrimpFieldTypes.Tab]: {
// tab 是结构化字段:库里/响应/编辑全程是对象,只在"给人看"的边界(import/export/label)走字符串。
reqFilter: (value, field) => {
if (value == null || value === "") return undefined
if (typeof value === "string") {
try {
return (field.parse ?? JSON.parse)(value)
} catch {
throw new ShrimpError({ t: 'shrimp.invalidJson' })
}
}
return value
},
// 本体原样下发对象(不再 stringify);展示文本走 _label,用 format(给人看,可有损)
// 第二参是已批量解析的子 relation 名(item.__tabRel[field.key]),让 format 能显示关联名而非 id
resLabel: (value, field, item) => (field.format ?? JSON.stringify)(value, item?.__tabRel?.[field.key]),
},
}
export class ShrimpTableModule<T, P extends ITableCrudProto<T> = ITableCrudProto<T>> extends ShrimpModule<P> implements ITableCrudProto<T> {
fields: IFieldConfig[] = []
constructor(public parent: Shrimp | ShrimpModule, moduleKey: string, public dataSource: ShrimpCrud<T>, fields?: IFieldConfig[], label?: ILabel, power?: RootShrimpPower, options?: IShrimpModuleOptions) {
super(parent, moduleKey, label || { t: moduleKey }, power, options)
if (fields) {
this.fields = fields
}
this.root.crudRegistry.set(moduleKey, dataSource)
}
getFields(): IFieldConfig[] {
return this.fields
}
static typeHandlers: Map<string, IFieldTypeHandler> = new Map(Object.entries(defaultTypeHandlers))
static registerTypeHandler(type: string, handler: IFieldTypeHandler) {
this.typeHandlers.set(type, handler)
}
private getTypeHandler(field: IFieldConfig): IFieldTypeHandler | undefined {
return ShrimpTableModule.typeHandlers.get(field.type)
}
/** 前置验证 */
private async validateField(field: IFieldConfig, value: any, item: Record<string, any>): Promise<ILabel | null> {
const isEmpty = value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0)
if (field.required && isEmpty) {
return field.rules?.find(r => r.required)?.message || { t: 'shrimp.fieldRequired', params: { field: field.label } }
}
if (isEmpty) return null
if (Array.isArray(field.options)) {
const validValues = new Set(field.options.map(o => o.value))
const values = field.multiple && Array.isArray(value) ? value : [value]
for (const v of values) {
if (!validValues.has(v)) {
return field.rules?.find(r => r.message)?.message || { t: 'shrimp.fieldInvalid', params: { field: field.label } }
}
}
}
// multiple 字段值是数组,类型校验需逐元素进行(与上面 options 校验保持一致);
// 非 multiple 则整体校验单值。否则数组会被标量类型分支整体判为类型错误。
const checkType = (v: any): boolean => {
switch (field.type) {
case ShrimpFieldTypes.String: return typeof v === 'string'
case ShrimpFieldTypes.Number: return typeof v === 'number' && !Number.isNaN(v)
case ShrimpFieldTypes.Boolean: return typeof v === 'boolean'
case ShrimpFieldTypes.Date: return typeof v === 'number' || v instanceof Date || !isNaN(Date.parse(v))
default: return true // 非内置标量类型(relation/json/自定义等)不在此校验
}
}
const typeTargets = field.multiple && Array.isArray(value) ? value : [value]
for (const v of typeTargets) {
if (!checkType(v)) return { t: 'shrimp.fieldTypeError', params: { field: field.label } }
}
if (field.rules) {
for (const rule of field.rules) {
const msg = rule.message || { t: 'shrimp.fieldInvalid', params: { field: field.label } }
if (rule.min !== undefined) {
if (typeof value === 'number' && value < rule.min) return { t: 'shrimp.fieldMin', params: { field: field.label, min: rule.min } }
if (typeof value === 'string' && value.length < rule.min) return { t: 'shrimp.fieldMinLength', params: { field: field.label, min: rule.min } }
if (Array.isArray(value) && value.length < rule.min) return { t: 'shrimp.fieldMinLength', params: { field: field.label, min: rule.min } }
}
if (rule.max !== undefined) {
if (typeof value === 'number' && value > rule.max) return { t: 'shrimp.fieldMax', params: { field: field.label, max: rule.max } }
if (typeof value === 'string' && value.length > rule.max) return { t: 'shrimp.fieldMaxLength', params: { field: field.label, max: rule.max } }
if (Array.isArray(value) && value.length > rule.max) return { t: 'shrimp.fieldMaxLength', params: { field: field.label, max: rule.max } }
}
if (rule.pattern) {
const regex = new RegExp(rule.pattern)
if (!regex.test(String(value))) return msg
}
}
}
return null
}
private async prepareAddItem(item: Record<string, any>): Promise<Record<string, any>> {
const result = { ...item }
for (const field of this.fields) {
if (field.editable === "readonly") {
delete result[field.key]
continue
}
const handler = this.getTypeHandler(field)
if (result[field.key] === undefined) {
const dv = field.defaultValue !== undefined ? field.defaultValue : handler?.defaultValue
if (dv !== undefined) {
result[field.key] = typeof dv === 'function' ? (dv as () => any)() : dv
}
}
if (handler?.reqFilter && result[field.key] !== undefined) {
result[field.key] = await handler.reqFilter(result[field.key], field, result)
}
const fieldErr = await this.validateField(field, result[field.key], result)
if (fieldErr) throw new ShrimpError(fieldErr)
if (handler?.validate && result[field.key] !== undefined) {
const err = await handler.validate(result[field.key], field, result)
if (err) throw new ShrimpError(err)
}
}
return result
}
private async prepareEditItem(item: Record<string, any>): Promise<Record<string, any>> {
const result = { ...item }
for (const field of this.fields) {
if (field.editable === "readonly" || field.editable === "addOnly") {
delete result[field.key]
continue
}
if (result[field.key] === undefined) {
delete result[field.key]
continue
}
const handler = this.getTypeHandler(field)
if (handler?.reqFilter) {
result[field.key] = await handler.reqFilter(result[field.key], field, result)
}
const fieldErr = await this.validateField(field, result[field.key], result)
if (fieldErr) throw new ShrimpError(fieldErr)
if (handler?.validate) {
const err = await handler.validate(result[field.key], field, result)
if (err) throw new ShrimpError(err)
}
}
return result
}
private async processResItem(item: Record<string, any>): Promise<Record<string, any>> {
const result = { ...item }
for (const field of this.fields) {
const handler = this.getTypeHandler(field)
if (handler?.resFilter && result[field.key] !== undefined) {
result[field.key] = await handler.resFilter(result[field.key], field, result)
}
if (handler?.resLabel && result[field.key] !== undefined) {
result[`${field.key}_label`] = handler.resLabel(result[field.key], field, result)
}
}
delete result.__tabRel // 临时挂的子 relation 解析结果,不下发
return result
}
private async prepareResponseItems(items: any[]): Promise<any[]> {
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)))
}
private async prepareResponseItem(item: any): Promise<any> {
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)
}
/**
* 解析 tab 字段里 relation 子字段的名字,批量(每个关联模块一次 $in),
* 挂到 item.__tabRel[fieldKey][childKey],供该字段的 format 第二参使用。
* 不污染 tab 本体对象(避免回灌进库),__tabRel 在 processResItem 末尾删除。
*/
private async populateTabRelations(items: any[]): Promise<void> {
const tabFields = this.fields.filter(f => f.type === ShrimpFieldTypes.Tab && Array.isArray(f.tabs))
if (tabFields.length === 0 || items.length === 0) return
const need = new Map<string, { labelKey: string; ids: Set<string> }>()
const refs: { item: any; fieldKey: string; childKey: string; moduleKey: string; multiple: boolean }[] = []
for (const item of items) {
for (const tf of tabFields) {
const val = item[tf.key]
if (!val || typeof val !== "object") continue
const tab = tf.tabs!.find(t => t.key === val.type)
if (!tab) continue
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 labelMaps = new Map<string, Map<string, any>>()
for (const [moduleKey, b] of need) {
const m = new Map<string, any>()
const crud = this.root.crudRegistry.get(moduleKey)
if (crud && b.ids.size > 0) {
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])
}
labelMaps.set(moduleKey, m)
}
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
}
}
private excludeKeys(item: any, keys: string[]): any {
if (keys.length === 0) return item
const result = { ...item }
for (const key of keys) {
delete result[key]
}
return result
}
private filterHidden(item: any): any {
return this.excludeKeys(item, this.fields.filter(f => f.hidden).map(f => f.key))
}
private getRelationFields(): IFieldConfig[] {
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 {
const { keyword, page, pageSize, sortBy, sortOrder, filter, ownerId } = query || {}
const p = page || 1
const ps = pageSize || ShrimpConfig.defaultPageSize
const shrimpQuery: IShrimpQuery = {
offset: (p - 1) * ps,
limit: ps,
}
if (filter && typeof filter === 'object') {
const filterableFields = new Map(this.fields.filter(f => f.filterable).map(f => [f.key, f]))
const stringLikeTypes = new Set(['string', 'textarea'])
const where: Record<string, IShrimpCondition> = {}
for (const [key, value] of Object.entries(filter)) {
const field = filterableFields.get(key)
if (!field) continue
if (value == null) continue
if (Array.isArray(value) && value.length === 0) continue
if (typeof value === 'string' && value === '') continue
const obj = value as any
if (typeof obj === 'object' && !Array.isArray(obj) && obj.min === undefined && obj.max === undefined) continue
if (Array.isArray(value)) {
where[key] = { in: value }
} else if (typeof value === 'object') {
const cond: IShrimpCondition = {}
if (obj.min !== undefined) cond.gte = obj.min
if (obj.max !== undefined) cond.lte = obj.max
where[key] = cond
} else if (typeof value === 'boolean') {
where[key] = { eq: value }
} else if (typeof value === 'string' && stringLikeTypes.has(field.type)) {
where[key] = { like: value }
} else {
where[key] = { eq: value }
}
}
if (Object.keys(where).length > 0) shrimpQuery.where = where
}
// 子表场景: ownerId + ownerKey 强制注入过滤
if (ownerId != null && ownerId !== '' && this.ownerKey) {
shrimpQuery.where = shrimpQuery.where || {}
shrimpQuery.where[this.ownerKey] = { eq: ownerId }
}
if (keyword) {
const stringLikeTypes = new Set(['string', 'textarea'])
const searchFields = this.fields
.filter(f => f.searchable && stringLikeTypes.has(f.type))
.map(f => f.key)
if (searchFields.length > 0) {
shrimpQuery.orWhere = searchFields.map(key => ({ [key]: { like: keyword } }))
}
}
if (sortBy) {
shrimpQuery.order = [[sortBy, sortOrder === 'desc' ? 'desc' : 'asc']]
}
return shrimpQuery
}
async list(query?: IPageQuery & any): Promise<IPageResult<T>> {
const shrimpQuery = this.buildQuery(query)
const result = await this.dataSource.list(shrimpQuery)
result.list = await this.prepareResponseItems(result.list)
result.list = result.list.map(item => this.filterHidden(item))
return result
}
async get(id: string): Promise<T | null> {
const item = await this.dataSource.get(id)
if (item) {
const processed = await this.prepareResponseItem(item)
return this.filterHidden(processed) as T
}
return item
}
async add(item: Omit<T, '_id'>, ownerId?: string): Promise<T> {
if (ownerId && this.ownerKey) {
(item as any)[this.ownerKey] = ownerId
}
const prepared = await this.prepareAddItem(item as any)
const result = await this.dataSource.add(prepared as Omit<T, '_id'>)
const processed = await this.prepareResponseItem(result)
return this.filterHidden(processed) as T
}
async edit(id: string, item: Partial<T>): Promise<T | null> {
const prepared = await this.prepareEditItem(item as any)
const result = await this.dataSource.edit(id, prepared as Partial<T>)
if (result) {
const processed = await this.prepareResponseItem(result)
return this.filterHidden(processed) as T
}
return result
}
async delete(id: string): Promise<boolean> {
return await this.dataSource.delete(id)
}
async clear(query?: IPageQuery & any): Promise<boolean> {
const hasFilter = query && (
(query.keyword) ||
(query.filter && typeof query.filter === 'object' && Object.keys(query.filter).length > 0)
)
if (hasFilter) {
const shrimpQuery = this.buildQuery(query)
delete shrimpQuery.offset
delete shrimpQuery.limit
const result = await this.dataSource.list(shrimpQuery)
for (const item of result.list as any[]) {
await this.dataSource.delete(item._id)
}
return true
}
return await this.dataSource.clear()
}
async export(query?: IPageQuery & any): Promise<T[]> {
const shrimpQuery = this.buildQuery(query)
delete shrimpQuery.offset
delete shrimpQuery.limit
const result = await this.dataSource.list(shrimpQuery)
result.list = await this.prepareResponseItems(result.list)
// 导出是机器往返的边界:结构化字段(如 tab)用可逆的 stringify 序列化(默认 JSON),
// import 端的 reqFilter(parse) 能原样还原;展示用的 _label 不进导出,去掉。
const labelFields = this.fields.filter(f => this.getTypeHandler(f)?.resLabel)
return result.list.map(item => {
const row = this.filterHidden(item)
for (const f of labelFields) {
if (row[f.key] !== undefined && typeof row[f.key] === "object") {
row[f.key] = (f.stringify ?? JSON.stringify)(row[f.key])
}
delete row[`${f.key}_label`]
}
return row
})
}
async import(data?: T[], ownerId?: string): Promise<void> {
if (!data || data.length === 0) return
for (const raw of data) {
const item = { ...raw } as Record<string, any>
if (ownerId && this.ownerKey) item[this.ownerKey] = ownerId
const id = item._id ? String(item._id) : undefined
if (id) {
const existing = await this.dataSource.get(id)
if (existing) {
const prepared = await this.prepareEditItem(item)
await this.dataSource.edit(id, prepared as Partial<T>)
continue
}
}
const prepared = await this.prepareAddItem(item)
await this.dataSource.add(prepared as Omit<T, '_id'>)
}
}
initCrud() {
return this
.addEvent("list", this.power)
.addEvent("get", this.power)
.addEvent("add", { t: "power.add" })
.addEvent("edit", { t: "power.edit" })
.addEvent("delete", { t: "power.delete" })
}
}