Files
shrimp-server/src/Shrimp/ShrimpTableModule.ts
T
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

440 lines
18 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(给人看,可有损)
resLabel: (value, field) => (field.format ?? JSON.stringify)(value),
},
}
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)
}
}
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)
}
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)
}
return this.processResItem(item)
}
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" })
}
}