@jcoder-stack/registry 0.1.3 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jcoder-stack/registry",
|
|
3
3
|
"description": "Copy-in sources for the auth shell and shadcn blocks: login/callback/logout handlers, the callAbp proxy, route guards, and TanStack wiring",
|
|
4
|
-
"version": "0.1
|
|
4
|
+
"version": "0.2.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
7
7
|
"auth",
|
|
@@ -14,14 +14,14 @@
|
|
|
14
14
|
"postpack": "node ../scripts/restore-publish-config.mjs"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@jcoder-stack/abp-react": "^0.1
|
|
17
|
+
"@jcoder-stack/abp-react": "^0.2.1",
|
|
18
18
|
"@tanstack/react-router": "^1.0.0",
|
|
19
19
|
"@tanstack/react-start": "^1.0.0",
|
|
20
20
|
"zod": "^4.0.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@tanstack/react-form": "^1.33.
|
|
24
|
-
"@tanstack/react-table": "9.
|
|
23
|
+
"@tanstack/react-form": "^1.33.5",
|
|
24
|
+
"@tanstack/react-table": "9.2.4",
|
|
25
25
|
"date-fns": "^4.4.0",
|
|
26
26
|
"lucide-react": "^0.525.0",
|
|
27
27
|
"radix-ui": "^1.6.4",
|
package/public/r/abp-table.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"dependencies": [
|
|
7
7
|
"@jcoder-stack/abp-react",
|
|
8
8
|
"@tanstack/react-query",
|
|
9
|
-
"@tanstack/react-table@9.
|
|
9
|
+
"@tanstack/react-table@9.2.4",
|
|
10
10
|
"@tanstack/react-form",
|
|
11
11
|
"sonner",
|
|
12
12
|
"lucide-react"
|
|
@@ -29,13 +29,13 @@
|
|
|
29
29
|
"files": [
|
|
30
30
|
{
|
|
31
31
|
"path": "ui/blocks/abp-table/use-abp-table.ts",
|
|
32
|
-
"content": "import { toAbpListParams, toPagedResult } from \"@jcoder-stack/abp-react/core\";\nimport { useLocalization, usePermissionChecker } from \"@jcoder-stack/abp-react/react\";\nimport {\n type FormAsyncValidateOrFn,\n type FormValidateOrFn,\n revalidateLogic,\n} from \"@tanstack/react-form\";\nimport { keepPreviousData, useQueryClient } from \"@tanstack/react-query\";\nimport {\n Children,\n createElement,\n Fragment,\n isValidElement,\n type ReactNode,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport type { AbpTableSource } from \"@/components/abp/crud/abp-table-source\";\nimport { useBoundComponents } from \"@/components/abp/crud/create-bound-components\";\nimport {\n ABP_RESERVED_LIST_PARAM_NAMES,\n type CrudDeleteHook,\n type CrudService,\n type CrudWriteHooks,\n type ListParams,\n} from \"@/components/abp/crud/crud-service\";\nimport { AbpBulkDeleteView } from \"@/components/abp/table/abp-bulk-delete\";\nimport { AbpTableView, type AbpTableViewOwnProps } from \"@/components/abp/table/abp-table\";\nimport { RowActionsMenu } from \"@/components/abp/table/row-actions-menu\";\nimport { devWarn } from \"@/components/data-table/dev-warn\";\nimport type { CellTableInstance, TableColumnDef } from \"@/components/data-table/table-core\";\nimport { type UseDataTableOptions, useDataTable } from \"@/components/data-table/use-data-table\";\nimport { useDataTableState } from \"@/components/data-table/use-data-table-state\";\nimport { useAppForm } from \"@/components/form/form-hook\";\n\n/** 留白字段不该变成 `Name=` 空串发给后端,`\"\"` 和 `undefined` 都不进请求。 */\nfunction pruneEmpty(value: Record<string, unknown>): Record<string, unknown> {\n return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== \"\" && v !== undefined));\n}\n\n/** 跨字段 zod 校验没给 issue 挂 `path` 时,错误落在 `errorMap.onDynamic` 的 `\"\"` 桶。\n * 走 unknown + 收窄而不是 `state.errorMap` 的具体类型,是为了绕开 TOnDynamic 那串泛型形参。 */\nfunction hasUnroutedCrossFieldError(errorMap: unknown): boolean {\n if (typeof errorMap !== \"object\" || errorMap === null) return false;\n const onDynamic = (errorMap as { onDynamic?: unknown }).onDynamic;\n if (typeof onDynamic !== \"object\" || onDynamic === null) return false;\n const bucket = (onDynamic as Record<string, unknown>)[\"\"];\n return Array.isArray(bucket) && bucket.length > 0;\n}\n\n/** 空操作删除 hook,顶替只读 service 缺失的 `useDelete`。\n * 这样 `useServiceSource` 每次渲染都无条件调用一个 hook,\n * 而不是 `service.useDelete?.(...)` 那种真正的条件调用。 */\nconst useNoopDelete: CrudDeleteHook[\"useDelete\"] = () => ({\n mutate: () => {},\n mutateAsync: async () => undefined,\n isPending: false,\n});\n\n/** 在 `CrudService` 上补回可选的 create/update/delete 键。`CrudService` 本身不含这三个\n * (只读 service 上它们不存在),但这里要判断它们在不在才能派生 `can`/`delete`,\n * `Partial<...>` 让\"有\"和\"没有\"两种形状都赋得进来。 */\ntype AbpTableSourceService<\n TDto extends { id?: string },\n TCreate,\n TUpdate,\n TListParams extends Record<string, unknown>,\n> = CrudService<TDto, TCreate, TUpdate, TListParams> &\n Partial<CrudWriteHooks<TCreate, TUpdate>> &\n Partial<CrudDeleteHook>;\n\nexport interface AbpTableRowConfig<TDto extends { id?: string }> {\n /** 追加在内置「···」菜单三项之后的自定义菜单项。引用必须稳定(模块级函数或 `useCallback`),\n * 它在操作列的 `useMemo` 依赖里,每渲染换新引用会让整张表的列模型重建。 */\n menu?: (row: TDto, table: CellTableInstance<TDto>) => ReactNode;\n /** 插入内置「···」菜单**左侧**的行内常驻操作。同上,引用必须稳定。 */\n actions?: (row: TDto, table: CellTableInstance<TDto>) => ReactNode;\n /** 覆盖对应内置项的默认出现条件;省略时按 can/onOpen/click 推导。 */\n view?: boolean;\n edit?: boolean;\n delete?: boolean;\n /** 默认:`onOpen` 存在时点行开详情;`false` 关闭;也可给自定义回调。 */\n click?: false | ((row: TDto) => void);\n}\n\nexport interface UseAbpTableOptions<\n TDto extends { id?: string },\n TQueryDefaults extends Record<string, unknown>,\n> {\n /** 列定义,见 `useDataTable` 同名 prop 的引用稳定性要求。 */\n columns: TableColumnDef<TDto>[];\n /** 查询表单。`defaults` 只在这里生效(字段上不带 `default`),首帧就用它发请求,\n * 免得先空筛选请求一次、挂载后再请求一次,中间闪一下未筛数据。\n * 省略则不建查询表单,`queryForm` 的 values 退化成空对象。 */\n query?: {\n defaults: TQueryDefaults;\n /** 额外校验器的逃生舱。形状照抄 `AbpFormConfig[\"validators\"]`,别换成\n * `Parameters<typeof useAppForm>[0][\"validators\"]`:那是个自引用类型,和 `defaultValues`\n * 塞进同一个 `useAppForm` 调用,会让 `TFormData` 的推断整体塌成 unknown,\n * `t.queryForm` 的字段名收窄跟着失效。\n * `onDynamic` 在这里不是保留位,查询表单没走 `abpFormOptions()` 预设,调用方可以自己传。 */\n validators?: {\n onMount?: FormValidateOrFn<TQueryDefaults>;\n onChange?: FormValidateOrFn<TQueryDefaults>;\n onChangeAsync?: FormAsyncValidateOrFn<TQueryDefaults>;\n onBlur?: FormValidateOrFn<TQueryDefaults>;\n onBlurAsync?: FormAsyncValidateOrFn<TQueryDefaults>;\n onDynamic?: FormValidateOrFn<TQueryDefaults>;\n onDynamicAsync?: FormAsyncValidateOrFn<TQueryDefaults>;\n };\n };\n row?: AbpTableRowConfig<TDto>;\n onOpen?: (mode: \"create\" | \"edit\" | \"view\", record?: TDto) => void;\n selectable?: boolean;\n defaultPageSize?: number;\n features?: UseDataTableOptions<TDto>[\"features\"];\n tableOptions?: UseDataTableOptions<TDto>[\"tableOptions\"];\n /** 顶部条「导出」图标的回调插槽;缺席不渲染导出按钮。组件库不内置导出实现。 */\n onExport?: () => void;\n}\n\n/**\n * service → source 归一:列表、删除、权限收在这里,create/update 归 `useAbpSheet`。\n * 只读 service 没有 `useDelete`,用 `useNoopDelete` 顶替,调用点才不是条件 hook。\n *\n * `listParams` 收到的已经是合并后的最终请求参数,看不到查询表单的原始键,\n * 所以 ABP 保留名的 devWarn 剔除不在这里做,在 `useAbpTable` 的合并处。\n */\nfunction useServiceSource<\n TDto extends { id?: string },\n TCreate,\n TUpdate,\n TListParams extends Record<string, unknown>,\n>(\n service: AbpTableSourceService<TDto, TCreate, TUpdate, TListParams>,\n listParams: ListParams,\n): AbpTableSource<TDto> {\n const L = useLocalization();\n const can = usePermissionChecker();\n const queryClient = useQueryClient();\n\n const listQuery = service.useList(listParams, {\n query: { placeholderData: keepPreviousData, select: toPagedResult },\n });\n const totalCount = listQuery.data?.totalCount ?? 0;\n const pageCount = Math.max(Math.ceil(totalCount / listParams.MaxResultCount), 1);\n\n const invalidate = () => queryClient.invalidateQueries({ queryKey: service.listKey() });\n const deleted = () => {\n toast.success(L(\"Crud:Deleted\"));\n void invalidate();\n };\n const deleteFailed = () => toast.error(L(\"Crud:OperationFailed\"));\n\n const useDeleteHook = service.useDelete ?? useNoopDelete;\n const rawDelete = useDeleteHook({ mutation: { onSuccess: deleted, onError: deleteFailed } });\n // 批量删除另起一个 mutation 实例,不带回调。单条删除的 onSuccess/onError 是逐条触发的,\n // 批量复用它删 N 条就会弹 N 个 toast、发 N 次重取。整批的汇总和失效由 deleteMany 负责。\n const rawBulkDelete = useDeleteHook();\n\n const bulkMutateAsync = rawBulkDelete.mutateAsync;\n const deleteMany = useCallback(\n async (ids: string[]) => {\n const failed: string[] = [];\n // 顺序而非并发:ABP 的删除常连带关联清理,并发提交容易撞上后端的并发/死锁保护,\n // 把「后端拒绝」误算成「这条删不掉」。\n for (const id of ids) {\n try {\n await bulkMutateAsync({ id });\n } catch {\n failed.push(id);\n }\n }\n await queryClient.invalidateQueries({ queryKey: service.listKey() });\n return { failed };\n },\n [bulkMutateAsync, queryClient, service.listKey],\n );\n\n // 把 orval 的 {id} 变量形状拆回 mutate(id)/mutateAsync(id)。useMemo 的依赖挂在\n // mutate/mutateAsync/isPending 上,不能挂 rawDelete:useMutation 每渲染都返回新的包装对象,\n // 挂它会让这个 memo 每渲染都失效,连累下面 rowActionsSrc → columns 整条链。deleteMany 同理。\n const deleteMutation = useMemo(\n () => ({\n mutate: (id: string) => rawDelete.mutate({ id }),\n mutateAsync: (id: string) => rawDelete.mutateAsync({ id }),\n isPending: rawDelete.isPending,\n many: deleteMany,\n }),\n [rawDelete.mutate, rawDelete.mutateAsync, rawDelete.isPending, deleteMany],\n );\n\n const allow = (policy: string | undefined) => (policy === undefined ? true : can(policy));\n\n return {\n listQuery,\n pageCount,\n totalCount,\n delete: service.useDelete !== undefined ? deleteMutation : undefined,\n can: {\n create: service.useCreate !== undefined && allow(service.resolvedPolicies.create),\n update: service.useUpdate !== undefined && allow(service.resolvedPolicies.update),\n delete: service.useDelete !== undefined && allow(service.resolvedPolicies.delete),\n },\n supportsFilter: service.supportsFilter !== false,\n };\n}\n\n/**\n * 一次调用给出页面要用的全部东西:查询表单、归一后的 `AbpTableSource`、TanStack 表实例。\n * 调用方不用再把 `table`/`crud`/`queryForm` 分别接到 `AbpTable` 的多个 props 上。\n *\n * `query.defaults` 只在这里生效,首帧就用它发请求。计算型默认值(比如\"最近一周\")要在\n * route loader 里算好再传进来,渲染期调 `new Date()` 会导致水合不一致。\n *\n * 跨字段 zod 校验(比如\"起始日期须早于结束\")记得给 issue 一个具体 `path`,例如\n * `.refine(fn, { path: [\"MinPublishDate\"] })`,它才会落进字段级的内联错误。不带 `path` 的\n * 表单级错误落在 `errorMap.onDynamic` 的 `{\"\": issue[]}` 里,`FormErrors` 只订阅 `onSubmit`,\n * 渲染不出来,这里也不代为兜底。\n *\n * 第一个参数传 service 或回调都行,但同一次 `useAbpTable` 调用里不能中途换种类。\n * 下面的分叉因此只是类型上的\"条件\",运行时 lifetime 稳定,不是条件 hook。\n */\nexport function useAbpTable<\n TDto extends { id?: string },\n TCreate = unknown,\n TUpdate = unknown,\n TListParams extends Record<string, unknown> = Record<string, unknown>,\n TQueryDefaults extends Record<string, unknown> = Record<string, never>,\n>(\n source:\n | AbpTableSourceService<TDto, TCreate, TUpdate, TListParams>\n | ((params: ListParams) => AbpTableSource<TDto>),\n opts: UseAbpTableOptions<TDto, TQueryDefaults>,\n) {\n const L = useLocalization();\n const state = useDataTableState({ defaultPageSize: opts.defaultPageSize });\n\n const queryDefaults: TQueryDefaults = opts.query?.defaults ?? ({} as TQueryDefaults);\n const [params, setParams] = useState<Record<string, unknown>>(() => pruneEmpty(queryDefaults));\n\n const queryForm = useAppForm({\n defaultValues: queryDefaults,\n validationLogic: revalidateLogic({ mode: \"submit\", modeAfterSubmission: \"change\" }),\n validators: opts.query?.validators,\n onSubmit: ({ value }) => {\n setParams(pruneEmpty(value as Record<string, unknown>));\n // 筛选变化必回第 1 页并清选择,这条不变式只在 useDataTableState.resetPaging 里实现。\n state.resetPaging();\n },\n });\n\n // 4 个 ABP 分页协议字段归表格状态机独占:查询表单里的同名键无条件剔除,不管当前有没有值。\n // 按值剔除会变成\"随排序/搜索状态时灵时不灵\"的间歇故障,比一直不工作更难查。\n // 也只有这里同时看得到查询表单的原始键和 toAbpListParams 的输出,useServiceSource 分不清。\n const reserved = Object.keys(params).filter((key) => ABP_RESERVED_LIST_PARAM_NAMES.has(key));\n if (reserved.length > 0) {\n devWarn(\n `use-abp-table:reserved-param:${reserved.join(\",\")}`,\n `useAbpTable: 查询表单里的 ${reserved.join(\"、\")} 属 ABP 分页协议保留名,已被剔除。` +\n \"分页/排序/搜索归表格状态机独占,请走各自的内建控件,不要用查询表单字段传。\",\n );\n }\n const safeParams = Object.fromEntries(\n Object.entries(params).filter(([key]) => !ABP_RESERVED_LIST_PARAM_NAMES.has(key)),\n );\n const listParams: ListParams = { ...safeParams, ...toAbpListParams(state.params) };\n\n // source 的种类跨渲染不变,分支 lifetime 稳定,不是条件 hook。见函数 TSDoc。\n const src =\n typeof source === \"function\"\n ? source(listParams)\n : // biome-ignore lint/correctness/useHookAtTopLevel: 见上,source 的种类跨渲染不变,分支 lifetime 稳定\n useServiceSource(source, listParams);\n\n // 检查收进自己的包装函数,不改写表单实例的 handleSubmit,那依赖 TanStack Form 未承诺的\n // 「实例引用跨渲染稳定」。AbpTable 内建的「查询」按钮走这里。\n const queryFormRef = useRef(queryForm);\n queryFormRef.current = queryForm;\n const submitQuery = useCallback(async () => {\n await queryFormRef.current.handleSubmit();\n if (hasUnroutedCrossFieldError(queryFormRef.current.state.errorMap)) {\n devWarn(\n \"use-abp-table:cross-field-error-no-path\",\n \"useAbpTable: 查询表单的跨字段校验错误没有挂 path,落在 errorMap.onDynamic 的空字符串桶里,\" +\n \"现有 FormErrors 只订阅 onSubmit 渲染不出来——给 .refine 的 issue 加一个具体字段的 path。\",\n );\n }\n }, []);\n\n // 重置后要立即重新发请求,所以收进 hook 而不是把 reset() 丢给调用方。\n // 后者只要忘了跟一句 handleSubmit(),字段看着回到默认值,请求参数却纹丝不动。\n const resetQuery = useCallback(() => {\n queryFormRef.current.reset();\n void submitQuery();\n }, [submitQuery]);\n\n // 只读出口,导出「全部匹配行」这类需求要拿当前已提交的完整请求参数。\n // 返回的是提交结果的快照而不是表单草稿,「表单是唯一取值源」的不变式不破。\n const paramsRef = useRef(params);\n paramsRef.current = params;\n const listStateRef = useRef(state.params);\n listStateRef.current = state.params;\n const getListParams = useCallback(\n () => ({ ...paramsRef.current, ...toAbpListParams(listStateRef.current) }),\n [],\n );\n\n const row: AbpTableRowConfig<TDto> = opts.row ?? {};\n const showView = row.view ?? (row.click === false && opts.onOpen !== undefined);\n const showEdit = row.edit ?? (src.can.update && opts.onOpen !== undefined);\n // source.delete 缺席时强制不显示删除项。自定义 source 把 can.delete 手误置成 true,\n // 也不会渲染出一个点了没反应的删除按钮。\n const showDelete = src.delete === undefined ? false : (row.delete ?? src.can.delete);\n\n // 窄化 src 的引用面并单独 memo,只随 can.update/can.delete/delete 变,\n // 免得每渲染新建的 source 字面量让 columns 跟着重算(同 deleteMutation 那处)。\n // `delete` 直接透传 `src.delete`,两边形状本就一致,不用再包一层。\n const rowActionsSrc = useMemo(\n () => ({\n can: { update: src.can.update, delete: src.can.delete },\n delete: src.delete,\n }),\n [src.can.update, src.can.delete, src.delete],\n );\n\n const finalColumns = useMemo<TableColumnDef<TDto>[]>(() => {\n if (\n !showView &&\n !showEdit &&\n !showDelete &&\n row.actions === undefined &&\n row.menu === undefined\n ) {\n return opts.columns;\n }\n return [\n ...opts.columns,\n {\n id: \"actions\",\n header: () => L(\"Table:Actions\"),\n enableSorting: false,\n // 同理:藏掉操作列等于让行操作入口消失,不该出现在 Columns 菜单里\n enableHiding: false,\n cell: ({ row: cellRow, table: rowTable }) =>\n // 显式实例化泛型组件(TS 4.7 instantiation expression)。不钉死的话 createElement 会让\n // RowActionsMenu 重新推断自己的 TDto,跟外层的撞车:onOpen 的回调参数在逆变位,\n // 两个推断路径不同的 TDto 在那里对不上,报 \"assignable to constraint 但不是 TDto 本身\"。\n createElement(RowActionsMenu<TDto>, {\n record: cellRow.original,\n table: rowTable,\n onOpen: opts.onOpen,\n source: rowActionsSrc,\n rowActions: row.actions,\n show: { view: showView, edit: showEdit, delete: showDelete },\n items: row.menu,\n }),\n },\n ];\n }, [\n opts.columns,\n opts.onOpen,\n row.actions,\n row.menu,\n showView,\n showEdit,\n showDelete,\n rowActionsSrc,\n L,\n ]);\n\n // 末页被删空后 totalCount 和 pageCount 都缩了,但没人钳制 pageIndex,用户会停在一个\n // 「暂无数据」的越界空页上。setter 在 useDataTableState、pageCount 在 source,只在本 hook\n // 汇合,钳制只能放这里。三个取数状态的门控缺一不可:isPending/isFetching 在途时 totalCount\n // 还是旧值,isError 时它退化成 0、pageCount 恒 1,不门控就会把用户从报错的第 N 页拽回第 1 页。\n const { pageIndex } = state.pagination;\n const { isPending, isFetching, isError } = src.listQuery;\n useEffect(() => {\n if (isPending || isFetching || isError) return;\n if (pageIndex >= src.pageCount) {\n state.onPaginationChange((p) => ({ ...p, pageIndex: Math.max(src.pageCount - 1, 0) }));\n }\n }, [isPending, isFetching, isError, pageIndex, src.pageCount, state.onPaginationChange]);\n\n const dt = useDataTable({\n state,\n columns: finalColumns,\n data: src.listQuery.data?.items ?? [],\n pageCount: src.pageCount,\n rowCount: src.totalCount,\n selectable: opts.selectable,\n getRowId: (r, i) => r.id ?? String(i),\n features: opts.features,\n tableOptions: opts.tableOptions,\n });\n\n const instanceWithoutComponents = {\n ...dt,\n source: src,\n queryForm,\n submitQuery,\n resetQuery,\n getListParams,\n rowConfig: opts.row,\n onOpen: opts.onOpen,\n searchEnabled: src.supportsFilter,\n // 已提交的筛选项数,不是表单草稿。驱动筛选钮上的\"还筛着\"圆点,面板收起后唯一的状态提示。\n activeQueryCount: Object.keys(safeParams).length,\n onExport: opts.onExport,\n };\n\n // 绑定成员挂载。五个成员的身份跨渲染稳定(见 useBoundComponents),接线值经 read() 读活。\n // Toolbar/BulkBar 只是透传 Fragment,真正的容器由 AbpTableView 在对应槽位包裹,\n // 扫描(这里)和摆放(AbpTableView)解耦。\n const bound = useBoundComponents({ self: instanceWithoutComponents }, (read) => {\n /** 标记组件,自身渲染 null,只把字段 children 交给 t.Table 的槽位扫描。\n * 筛选面板的真实渲染由 AbpTableView 编排,这里就地渲染不到那个位置。 */\n const QueryForm = (_p: { children: ReactNode }) => null;\n const Toolbar = (p: { children: ReactNode }) => createElement(Fragment, null, p.children);\n const BulkBar = (p: { children: ReactNode }) => createElement(Fragment, null, p.children);\n // 内建批量删除。删除 mutation 和列表失效都在 source 上,页面再写一遍就是把同一份样板\n // 抄进每个 CRUD 页。摆在 t.BulkBar 里用。\n const BulkDelete = () =>\n createElement(AbpBulkDeleteView<TDto>, {\n source: read().self.source,\n selectedRows: read().self.selectedRows,\n keepSelected: read().self.keepSelected,\n });\n const Table = (p: AbpTableViewOwnProps<TDto> & { children?: ReactNode }) => {\n // 槽位扫描靠引用相等识别自家绑定成员(组件身份在 useBoundComponents 内跨渲染不变,\n // child.type 的比较因此稳定)。未知的直接子元素只 devWarn 不渲染,\n // t.Table 不是通用容器,只认这三种。\n const slots: { query?: { children: ReactNode }; toolbar?: ReactNode; bulkBar?: ReactNode } =\n {};\n Children.forEach(p.children, (child) => {\n if (!isValidElement(child)) return;\n if (child.type === QueryForm) slots.query = child.props as { children: ReactNode };\n else if (child.type === Toolbar) slots.toolbar = child;\n else if (child.type === BulkBar) slots.bulkBar = child;\n else\n devWarn(\n \"abp-table:unknown-child\",\n \"useAbpTable: t.Table 只认 t.QueryForm/t.Toolbar/t.BulkBar 直接子元素,其余直接子元素会被忽略。\",\n );\n });\n const { empty, skeletonRows, rowProps, pageSizes, searchPlaceholder } = p;\n return createElement(AbpTableView<TDto>, {\n t: read().self,\n slots,\n empty,\n skeletonRows,\n rowProps,\n pageSizes,\n searchPlaceholder,\n });\n };\n return { Table, QueryForm, Toolbar, BulkBar, BulkDelete };\n });\n\n return { ...instanceWithoutComponents, ...bound };\n}\n\n/** `useAbpTable` 的完整返回类型:`DataTableInstance` 全成员 + 数据侧接线 + 五个绑定成员。\n * 派生自 hook 自身的返回类型而不是手写,省得手拼 TanStack Form 那十几个校验器泛型形参。\n *\n * `abp-table.tsx` 的 `AbpTableViewInstance` 不是它的扩展,是更窄的另一个类型,不含绑定成员。\n * 那些成员正是靠 `AbpTableView` 渲染出来的,反过来要求它消费的实例已经带着,会是循环依赖。 */\nexport type AbpTableInstance<\n TDto extends { id?: string },\n TQueryDefaults extends Record<string, unknown> = Record<string, never>,\n> = ReturnType<typeof useAbpTable<TDto, unknown, unknown, Record<string, unknown>, TQueryDefaults>>;\n",
|
|
32
|
+
"content": "import { toAbpListParams, toPagedResult } from \"@jcoder-stack/abp-react/core\";\nimport { useLocalization, usePermissionChecker } from \"@jcoder-stack/abp-react/react\";\nimport {\n type FormAsyncValidateOrFn,\n type FormValidateOrFn,\n revalidateLogic,\n} from \"@tanstack/react-form\";\nimport { keepPreviousData, useQueryClient } from \"@tanstack/react-query\";\nimport {\n Children,\n createElement,\n Fragment,\n isValidElement,\n type ReactNode,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport type { AbpTableSource } from \"@/components/abp/crud/abp-table-source\";\nimport { useBoundComponents } from \"@/components/abp/crud/create-bound-components\";\nimport {\n ABP_RESERVED_LIST_PARAM_NAMES,\n type CrudDeleteHook,\n type CrudService,\n type CrudWriteHooks,\n type ListParams,\n} from \"@/components/abp/crud/crud-service\";\nimport { AbpBulkDeleteView } from \"@/components/abp/table/abp-bulk-delete\";\nimport { AbpTableView, type AbpTableViewOwnProps } from \"@/components/abp/table/abp-table\";\nimport { RowActionsMenu } from \"@/components/abp/table/row-actions-menu\";\nimport { devWarn } from \"@/components/data-table/dev-warn\";\nimport type { CellTableInstance, TableColumnDef } from \"@/components/data-table/table-core\";\nimport { type UseDataTableOptions, useDataTable } from \"@/components/data-table/use-data-table\";\nimport { useDataTableState } from \"@/components/data-table/use-data-table-state\";\nimport { useAppForm } from \"@/components/form/form-hook\";\n\n/** 留白字段不该变成 `Name=` 空串发给后端,`\"\"` 和 `undefined` 都不进请求。 */\nfunction pruneEmpty(value: Record<string, unknown>): Record<string, unknown> {\n return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== \"\" && v !== undefined));\n}\n\n/** 跨字段 zod 校验没给 issue 挂 `path` 时,错误落在 `errorMap.onDynamic` 的 `\"\"` 桶。\n * 走 unknown + 收窄而不是 `state.errorMap` 的具体类型,是为了绕开 TOnDynamic 那串泛型形参。 */\nfunction hasUnroutedCrossFieldError(errorMap: unknown): boolean {\n if (typeof errorMap !== \"object\" || errorMap === null) return false;\n const onDynamic = (errorMap as { onDynamic?: unknown }).onDynamic;\n if (typeof onDynamic !== \"object\" || onDynamic === null) return false;\n const bucket = (onDynamic as Record<string, unknown>)[\"\"];\n return Array.isArray(bucket) && bucket.length > 0;\n}\n\n/** 空操作删除 hook,顶替只读 service 缺失的 `useDelete`。\n * 这样 `useServiceSource` 每次渲染都无条件调用一个 hook,\n * 而不是 `service.useDelete?.(...)` 那种真正的条件调用。 */\nconst useNoopDelete: CrudDeleteHook[\"useDelete\"] = () => ({\n mutate: () => {},\n mutateAsync: async () => undefined,\n isPending: false,\n});\n\n/** 在 `CrudService` 上补回可选的 create/update/delete 键。`CrudService` 本身不含这三个\n * (只读 service 上它们不存在),但这里要判断它们在不在才能派生 `can`/`delete`,\n * `Partial<...>` 让\"有\"和\"没有\"两种形状都赋得进来。 */\ntype AbpTableSourceService<\n TDto extends { id?: string },\n TCreate,\n TUpdate,\n TListParams extends Record<string, unknown>,\n> = CrudService<TDto, TCreate, TUpdate, TListParams> &\n Partial<CrudWriteHooks<TCreate, TUpdate>> &\n Partial<CrudDeleteHook>;\n\nexport interface AbpTableRowConfig<TDto extends { id?: string }> {\n /** 追加在内置「···」菜单三项之后的自定义菜单项。引用必须稳定(模块级函数或 `useCallback`),\n * 它在操作列的 `useMemo` 依赖里,每渲染换新引用会让整张表的列模型重建。 */\n menu?: (row: TDto, table: CellTableInstance<TDto>) => ReactNode;\n /** 插入内置「···」菜单**左侧**的行内常驻操作。同上,引用必须稳定。 */\n actions?: (row: TDto, table: CellTableInstance<TDto>) => ReactNode;\n /** 覆盖对应内置项的默认出现条件;省略时按 can/onOpen/click 推导。 */\n view?: boolean;\n edit?: boolean;\n delete?: boolean;\n /** 默认:`onOpen` 存在时点行开详情;`false` 关闭;也可给自定义回调。 */\n click?: false | ((row: TDto) => void);\n}\n\nexport interface UseAbpTableOptions<\n TDto extends { id?: string },\n TQueryDefaults extends Record<string, unknown>,\n> {\n /** 列定义,见 `useDataTable` 同名 prop 的引用稳定性要求。 */\n columns: TableColumnDef<TDto>[];\n /** 查询表单。`defaults` 只在这里生效(字段上不带 `default`),首帧就用它发请求,\n * 免得先空筛选请求一次、挂载后再请求一次,中间闪一下未筛数据。\n * 省略则不建查询表单,`queryForm` 的 values 退化成空对象。 */\n query?: {\n defaults: TQueryDefaults;\n /** 额外校验器的逃生舱。形状照抄 `AbpFormConfig[\"validators\"]`,别换成\n * `Parameters<typeof useAppForm>[0][\"validators\"]`:那是个自引用类型,和 `defaultValues`\n * 塞进同一个 `useAppForm` 调用,会让 `TFormData` 的推断整体塌成 unknown,\n * `t.queryForm` 的字段名收窄跟着失效。\n * `onDynamic` 在这里不是保留位,查询表单没走 `abpFormOptions()` 预设,调用方可以自己传。 */\n validators?: {\n onMount?: FormValidateOrFn<TQueryDefaults>;\n onChange?: FormValidateOrFn<TQueryDefaults>;\n onChangeAsync?: FormAsyncValidateOrFn<TQueryDefaults>;\n onBlur?: FormValidateOrFn<TQueryDefaults>;\n onBlurAsync?: FormAsyncValidateOrFn<TQueryDefaults>;\n onDynamic?: FormValidateOrFn<TQueryDefaults>;\n onDynamicAsync?: FormAsyncValidateOrFn<TQueryDefaults>;\n };\n };\n row?: AbpTableRowConfig<TDto>;\n onOpen?: (mode: \"create\" | \"edit\" | \"view\", record?: TDto) => void;\n selectable?: boolean;\n defaultPageSize?: number;\n features?: UseDataTableOptions<TDto>[\"features\"];\n tableOptions?: UseDataTableOptions<TDto>[\"tableOptions\"];\n /** 顶部条「导出」图标的回调插槽;缺席不渲染导出按钮。组件库不内置导出实现。 */\n onExport?: () => void;\n}\n\n/**\n * service → source 归一:列表、删除、权限收在这里,create/update 归 `useAbpSheet`。\n * 只读 service 没有 `useDelete`,用 `useNoopDelete` 顶替,调用点才不是条件 hook。\n *\n * `listParams` 收到的已经是合并后的最终请求参数,看不到查询表单的原始键,\n * 所以 ABP 保留名的 devWarn 剔除不在这里做,在 `useAbpTable` 的合并处。\n */\nfunction useServiceSource<\n TDto extends { id?: string },\n TCreate,\n TUpdate,\n TListParams extends Record<string, unknown>,\n>(\n service: AbpTableSourceService<TDto, TCreate, TUpdate, TListParams>,\n listParams: ListParams,\n): AbpTableSource<TDto> {\n const L = useLocalization();\n const can = usePermissionChecker();\n const queryClient = useQueryClient();\n\n const listQuery = service.useList(listParams, {\n query: { placeholderData: keepPreviousData, select: toPagedResult },\n });\n const totalCount = listQuery.data?.totalCount ?? 0;\n const pageCount = Math.max(Math.ceil(totalCount / listParams.MaxResultCount), 1);\n\n const invalidate = () => queryClient.invalidateQueries({ queryKey: service.listKey() });\n const deleted = () => {\n toast.success(L(\"Crud:Deleted\"));\n void invalidate();\n };\n const deleteFailed = () => toast.error(L(\"Crud:OperationFailed\"));\n\n const useDeleteHook = service.useDelete ?? useNoopDelete;\n const rawDelete = useDeleteHook({ mutation: { onSuccess: deleted, onError: deleteFailed } });\n // 批量删除另起一个 mutation 实例,不带回调。单条删除的 onSuccess/onError 是逐条触发的,\n // 批量复用它删 N 条就会弹 N 个 toast、发 N 次重取。整批的汇总和失效由 deleteMany 负责。\n const rawBulkDelete = useDeleteHook();\n\n const bulkMutateAsync = rawBulkDelete.mutateAsync;\n const deleteMany = useCallback(\n async (ids: string[]) => {\n const failed: string[] = [];\n // 顺序而非并发:ABP 的删除常连带关联清理,并发提交容易撞上后端的并发/死锁保护,\n // 把「后端拒绝」误算成「这条删不掉」。\n for (const id of ids) {\n try {\n await bulkMutateAsync({ id });\n } catch {\n failed.push(id);\n }\n }\n await queryClient.invalidateQueries({ queryKey: service.listKey() });\n return { failed };\n },\n [bulkMutateAsync, queryClient, service.listKey],\n );\n\n // 把 orval 的 {id} 变量形状拆回 mutate(id)/mutateAsync(id)。useMemo 的依赖挂在\n // mutate/mutateAsync/isPending 上,不能挂 rawDelete:useMutation 每渲染都返回新的包装对象,\n // 挂它会让这个 memo 每渲染都失效,连累下面 rowActionsSrc → columns 整条链。deleteMany 同理。\n const deleteMutation = useMemo(\n () => ({\n mutate: (id: string) => rawDelete.mutate({ id }),\n mutateAsync: (id: string) => rawDelete.mutateAsync({ id }),\n isPending: rawDelete.isPending,\n many: deleteMany,\n }),\n [rawDelete.mutate, rawDelete.mutateAsync, rawDelete.isPending, deleteMany],\n );\n\n const allow = (policy: string | undefined) => (policy === undefined ? true : can(policy));\n\n return {\n listQuery,\n pageCount,\n totalCount,\n delete: service.useDelete !== undefined ? deleteMutation : undefined,\n can: {\n create: service.useCreate !== undefined && allow(service.resolvedPolicies.create),\n update: service.useUpdate !== undefined && allow(service.resolvedPolicies.update),\n delete: service.useDelete !== undefined && allow(service.resolvedPolicies.delete),\n },\n supportsFilter: service.supportsFilter !== false,\n };\n}\n\n/**\n * 一次调用给出页面要用的全部东西:查询表单、归一后的 `AbpTableSource`、TanStack 表实例。\n * 调用方不用再把 `table`/`crud`/`queryForm` 分别接到 `AbpTable` 的多个 props 上。\n *\n * `query.defaults` 只在这里生效,首帧就用它发请求。计算型默认值(比如\"最近一周\")要在\n * route loader 里算好再传进来,渲染期调 `new Date()` 会导致水合不一致。\n *\n * 跨字段 zod 校验(比如\"起始日期须早于结束\")记得给 issue 一个具体 `path`,例如\n * `.refine(fn, { path: [\"MinPublishDate\"] })`,它才会落进字段级的内联错误。不带 `path` 的\n * 表单级错误落在 `errorMap.onDynamic` 的 `{\"\": issue[]}` 里,`FormErrors` 只订阅 `onSubmit`,\n * 渲染不出来,这里也不代为兜底。\n *\n * 第一个参数传 service 或回调都行,但同一次 `useAbpTable` 调用里不能中途换种类。\n * 下面的分叉因此只是类型上的\"条件\",运行时 lifetime 稳定,不是条件 hook。\n */\nexport function useAbpTable<\n TDto extends { id?: string },\n TCreate = unknown,\n TUpdate = unknown,\n TListParams extends Record<string, unknown> = Record<string, unknown>,\n TQueryDefaults extends Record<string, unknown> = Record<string, never>,\n>(\n source:\n | AbpTableSourceService<TDto, TCreate, TUpdate, TListParams>\n | ((params: ListParams) => AbpTableSource<TDto>),\n opts: UseAbpTableOptions<TDto, TQueryDefaults>,\n) {\n const L = useLocalization();\n const state = useDataTableState({ defaultPageSize: opts.defaultPageSize });\n\n const queryDefaults: TQueryDefaults = opts.query?.defaults ?? ({} as TQueryDefaults);\n const [params, setParams] = useState<Record<string, unknown>>(() => pruneEmpty(queryDefaults));\n\n const queryForm = useAppForm({\n defaultValues: queryDefaults,\n validationLogic: revalidateLogic({ mode: \"submit\", modeAfterSubmission: \"change\" }),\n validators: opts.query?.validators,\n onSubmit: ({ value }) => {\n setParams(pruneEmpty(value as Record<string, unknown>));\n // 筛选变化必回第 1 页并清选择。回第 1 页由 resetPaging 自己做;清选择由它递增的\n // scopeEpoch 触发,落在 useDataTable 的 scopeKey effect 里(选中态归表所有,\n // 状态机够不着)。用户本来就在第 1 页时 pageIndex 写回同值,全靠那个版本号发信号。\n state.resetPaging();\n },\n });\n\n // 4 个 ABP 分页协议字段归表格状态机独占:查询表单里的同名键无条件剔除,不管当前有没有值。\n // 按值剔除会变成\"随排序/搜索状态时灵时不灵\"的间歇故障,比一直不工作更难查。\n // 也只有这里同时看得到查询表单的原始键和 toAbpListParams 的输出,useServiceSource 分不清。\n const reserved = Object.keys(params).filter((key) => ABP_RESERVED_LIST_PARAM_NAMES.has(key));\n if (reserved.length > 0) {\n devWarn(\n `use-abp-table:reserved-param:${reserved.join(\",\")}`,\n `useAbpTable: 查询表单里的 ${reserved.join(\"、\")} 属 ABP 分页协议保留名,已被剔除。` +\n \"分页/排序/搜索归表格状态机独占,请走各自的内建控件,不要用查询表单字段传。\",\n );\n }\n const safeParams = Object.fromEntries(\n Object.entries(params).filter(([key]) => !ABP_RESERVED_LIST_PARAM_NAMES.has(key)),\n );\n const listParams: ListParams = { ...safeParams, ...toAbpListParams(state.params) };\n\n // source 的种类跨渲染不变,分支 lifetime 稳定,不是条件 hook。见函数 TSDoc。\n const src =\n typeof source === \"function\"\n ? source(listParams)\n : // biome-ignore lint/correctness/useHookAtTopLevel: 见上,source 的种类跨渲染不变,分支 lifetime 稳定\n useServiceSource(source, listParams);\n\n // 检查收进自己的包装函数,不改写表单实例的 handleSubmit,那依赖 TanStack Form 未承诺的\n // 「实例引用跨渲染稳定」。AbpTable 内建的「查询」按钮走这里。\n const queryFormRef = useRef(queryForm);\n queryFormRef.current = queryForm;\n const submitQuery = useCallback(async () => {\n await queryFormRef.current.handleSubmit();\n if (hasUnroutedCrossFieldError(queryFormRef.current.state.errorMap)) {\n devWarn(\n \"use-abp-table:cross-field-error-no-path\",\n \"useAbpTable: 查询表单的跨字段校验错误没有挂 path,落在 errorMap.onDynamic 的空字符串桶里,\" +\n \"现有 FormErrors 只订阅 onSubmit 渲染不出来——给 .refine 的 issue 加一个具体字段的 path。\",\n );\n }\n }, []);\n\n // 重置后要立即重新发请求,所以收进 hook 而不是把 reset() 丢给调用方。\n // 后者只要忘了跟一句 handleSubmit(),字段看着回到默认值,请求参数却纹丝不动。\n const resetQuery = useCallback(() => {\n queryFormRef.current.reset();\n void submitQuery();\n }, [submitQuery]);\n\n // 只读出口,导出「全部匹配行」这类需求要拿当前已提交的完整请求参数。\n // 返回的是提交结果的快照而不是表单草稿,「表单是唯一取值源」的不变式不破。\n const paramsRef = useRef(params);\n paramsRef.current = params;\n const listStateRef = useRef(state.params);\n listStateRef.current = state.params;\n const getListParams = useCallback(\n () => ({ ...paramsRef.current, ...toAbpListParams(listStateRef.current) }),\n [],\n );\n\n const row: AbpTableRowConfig<TDto> = opts.row ?? {};\n const showView = row.view ?? (row.click === false && opts.onOpen !== undefined);\n const showEdit = row.edit ?? (src.can.update && opts.onOpen !== undefined);\n // source.delete 缺席时强制不显示删除项。自定义 source 把 can.delete 手误置成 true,\n // 也不会渲染出一个点了没反应的删除按钮。\n const showDelete = src.delete === undefined ? false : (row.delete ?? src.can.delete);\n\n // 窄化 src 的引用面并单独 memo,只随 can.update/can.delete/delete 变,\n // 免得每渲染新建的 source 字面量让 columns 跟着重算(同 deleteMutation 那处)。\n // `delete` 直接透传 `src.delete`,两边形状本就一致,不用再包一层。\n const rowActionsSrc = useMemo(\n () => ({\n can: { update: src.can.update, delete: src.can.delete },\n delete: src.delete,\n }),\n [src.can.update, src.can.delete, src.delete],\n );\n\n const finalColumns = useMemo<TableColumnDef<TDto>[]>(() => {\n if (\n !showView &&\n !showEdit &&\n !showDelete &&\n row.actions === undefined &&\n row.menu === undefined\n ) {\n return opts.columns;\n }\n return [\n ...opts.columns,\n {\n id: \"actions\",\n header: () => L(\"Table:Actions\"),\n enableSorting: false,\n // 同理:藏掉操作列等于让行操作入口消失,不该出现在 Columns 菜单里\n enableHiding: false,\n cell: ({ row: cellRow, table: rowTable }) =>\n // 显式实例化泛型组件(TS 4.7 instantiation expression)。不钉死的话 createElement 会让\n // RowActionsMenu 重新推断自己的 TDto,跟外层的撞车:onOpen 的回调参数在逆变位,\n // 两个推断路径不同的 TDto 在那里对不上,报 \"assignable to constraint 但不是 TDto 本身\"。\n createElement(RowActionsMenu<TDto>, {\n record: cellRow.original,\n table: rowTable,\n onOpen: opts.onOpen,\n source: rowActionsSrc,\n rowActions: row.actions,\n show: { view: showView, edit: showEdit, delete: showDelete },\n items: row.menu,\n }),\n },\n ];\n }, [\n opts.columns,\n opts.onOpen,\n row.actions,\n row.menu,\n showView,\n showEdit,\n showDelete,\n rowActionsSrc,\n L,\n ]);\n\n // 末页被删空后 totalCount 和 pageCount 都缩了,但没人钳制 pageIndex,用户会停在一个\n // 「暂无数据」的越界空页上。setter 在 useDataTableState、pageCount 在 source,只在本 hook\n // 汇合,钳制只能放这里。三个取数状态的门控缺一不可:isPending/isFetching 在途时 totalCount\n // 还是旧值,isError 时它退化成 0、pageCount 恒 1,不门控就会把用户从报错的第 N 页拽回第 1 页。\n const { pageIndex } = state.pagination;\n const { isPending, isFetching, isError } = src.listQuery;\n useEffect(() => {\n if (isPending || isFetching || isError) return;\n if (pageIndex >= src.pageCount) {\n state.onPaginationChange((p) => ({ ...p, pageIndex: Math.max(src.pageCount - 1, 0) }));\n }\n }, [isPending, isFetching, isError, pageIndex, src.pageCount, state.onPaginationChange]);\n\n const dt = useDataTable({\n state,\n columns: finalColumns,\n data: src.listQuery.data?.items ?? [],\n pageCount: src.pageCount,\n rowCount: src.totalCount,\n selectable: opts.selectable,\n getRowId: (r, i) => r.id ?? String(i),\n features: opts.features,\n tableOptions: opts.tableOptions,\n });\n\n const instanceWithoutComponents = {\n ...dt,\n source: src,\n queryForm,\n submitQuery,\n resetQuery,\n getListParams,\n rowConfig: opts.row,\n onOpen: opts.onOpen,\n searchEnabled: src.supportsFilter,\n // 已提交的筛选项数,不是表单草稿。驱动筛选钮上的\"还筛着\"圆点,面板收起后唯一的状态提示。\n activeQueryCount: Object.keys(safeParams).length,\n onExport: opts.onExport,\n };\n\n // 绑定成员挂载。五个成员的身份跨渲染稳定(见 useBoundComponents),接线值经 read() 读活。\n // Toolbar/BulkBar 只是透传 Fragment,真正的容器由 AbpTableView 在对应槽位包裹,\n // 扫描(这里)和摆放(AbpTableView)解耦。\n const bound = useBoundComponents({ self: instanceWithoutComponents }, (read) => {\n /** 标记组件,自身渲染 null,只把字段 children 交给 t.Table 的槽位扫描。\n * 筛选面板的真实渲染由 AbpTableView 编排,这里就地渲染不到那个位置。 */\n const QueryForm = (_p: { children: ReactNode }) => null;\n const Toolbar = (p: { children: ReactNode }) => createElement(Fragment, null, p.children);\n const BulkBar = (p: { children: ReactNode }) => createElement(Fragment, null, p.children);\n // 内建批量删除。删除 mutation 和列表失效都在 source 上,页面再写一遍就是把同一份样板\n // 抄进每个 CRUD 页。摆在 t.BulkBar 里用。\n const BulkDelete = () =>\n createElement(AbpBulkDeleteView<TDto>, {\n source: read().self.source,\n getSelectedRows: read().self.getSelectedRows,\n keepSelected: read().self.keepSelected,\n });\n const Table = (p: AbpTableViewOwnProps<TDto> & { children?: ReactNode }) => {\n // 槽位扫描靠引用相等识别自家绑定成员(组件身份在 useBoundComponents 内跨渲染不变,\n // child.type 的比较因此稳定)。未知的直接子元素只 devWarn 不渲染,\n // t.Table 不是通用容器,只认这三种。\n const slots: { query?: { children: ReactNode }; toolbar?: ReactNode; bulkBar?: ReactNode } =\n {};\n Children.forEach(p.children, (child) => {\n if (!isValidElement(child)) return;\n if (child.type === QueryForm) slots.query = child.props as { children: ReactNode };\n else if (child.type === Toolbar) slots.toolbar = child;\n else if (child.type === BulkBar) slots.bulkBar = child;\n else\n devWarn(\n \"abp-table:unknown-child\",\n \"useAbpTable: t.Table 只认 t.QueryForm/t.Toolbar/t.BulkBar 直接子元素,其余直接子元素会被忽略。\",\n );\n });\n const { empty, skeletonRows, rowProps, pageSizes, searchPlaceholder } = p;\n return createElement(AbpTableView<TDto>, {\n t: read().self,\n slots,\n empty,\n skeletonRows,\n rowProps,\n pageSizes,\n searchPlaceholder,\n });\n };\n return { Table, QueryForm, Toolbar, BulkBar, BulkDelete };\n });\n\n return { ...instanceWithoutComponents, ...bound };\n}\n\n/** `useAbpTable` 的完整返回类型:`DataTableInstance` 全成员 + 数据侧接线 + 五个绑定成员。\n * 派生自 hook 自身的返回类型而不是手写,省得手拼 TanStack Form 那十几个校验器泛型形参。\n *\n * `abp-table.tsx` 的 `AbpTableViewInstance` 不是它的扩展,是更窄的另一个类型,不含绑定成员。\n * 那些成员正是靠 `AbpTableView` 渲染出来的,反过来要求它消费的实例已经带着,会是循环依赖。 */\nexport type AbpTableInstance<\n TDto extends { id?: string },\n TQueryDefaults extends Record<string, unknown> = Record<string, never>,\n> = ReturnType<typeof useAbpTable<TDto, unknown, unknown, Record<string, unknown>, TQueryDefaults>>;\n",
|
|
33
33
|
"type": "registry:component",
|
|
34
34
|
"target": "components/abp/table/use-abp-table.ts"
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
"path": "ui/blocks/abp-table/abp-table.tsx",
|
|
38
|
-
"content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { Plus } from \"lucide-react\";\nimport { type ReactNode, useId, useState } from \"react\";\nimport type { AbpTableSource } from \"@/components/abp/crud/abp-table-source\";\nimport { AbpBulkBarView } from \"@/components/abp/table/abp-bulk-bar\";\nimport {\n AbpQueryPanelToggle,\n AbpQueryPanelView,\n flattenFields,\n} from \"@/components/abp/table/abp-query-form\";\nimport type { AbpTableRowConfig } from \"@/components/abp/table/use-abp-table\";\nimport { DataTable } from \"@/components/data-table/data-table\";\nimport { DataTableColumnsMenu } from \"@/components/data-table/data-table-columns-menu\";\nimport { DataTableToolbar } from \"@/components/data-table/data-table-toolbar\";\nimport type { DataTableInstance } from \"@/components/data-table/use-data-table\";\nimport { FormErrorSummary } from \"@/components/form/form-error-summary\";\nimport { Button } from \"@/components/ui/button\";\n\n/** `t.Table` 的表现层 props:不受管的 `DataTable` 透传项,缺省全部走内建默认。 */\nexport interface AbpTableViewOwnProps<TDto> {\n empty?: ReactNode;\n skeletonRows?: number | \"pageSize\";\n rowProps?: (row: TDto) => { className?: string };\n pageSizes?: number[];\n searchPlaceholder?: string;\n}\n\n/** `AbpTableView` 消费的实例面:`useDataTable` 全量 + `useAbpTable` 的数据侧接线。\n * 不含五个绑定成员,那些正是靠本组件渲染出来的,反过来要求实例已经带着会是循环依赖。 */\nexport interface AbpTableViewInstance<TDto extends { id?: string }>\n extends DataTableInstance<TDto> {\n source: AbpTableSource<TDto>;\n rowConfig?: AbpTableRowConfig<TDto>;\n onOpen?: (mode: \"create\" | \"edit\" | \"view\", record?: TDto) => void;\n searchEnabled: boolean;\n submitQuery: () => Promise<void>;\n resetQuery: () => void;\n /** 当前已提交的筛选项数,驱动筛选钮上的\"还筛着\"圆点。 */\n activeQueryCount: number;\n onExport?: () => void;\n}\n\nexport interface AbpTableViewSlots {\n /** `t.QueryForm` 捕获的 props:高级筛选的字段 children,整体渲染进筛选面板\n * (顶部条右区的筛选钮开合它)。缺席则不出现筛选钮。 */\n query?: { children: ReactNode };\n /** `t.Toolbar` 的内容,追加在内建「新建」按钮右侧。 */\n toolbar?: ReactNode;\n /** `t.BulkBar` 的内容,经顶部条批量态渲染;仅有选中行时出现。 */\n bulkBar?: ReactNode;\n}\n\n// 必须导出。`useAbpTable` 的 `Table` 绑定成员把 `AbpTableView<TDto>` 的元素类型带进了自己的\n// 推断返回类型,声明发出时要能给这个类型具名(tsconfig.base.json 开着 declaration + composite),\n// 不导出会报 TS4058「cannot be named」。\nexport interface AbpTableViewProps<TDto extends { id?: string }>\n extends AbpTableViewOwnProps<TDto> {\n t: AbpTableViewInstance<TDto>;\n slots: AbpTableViewSlots;\n}\n\n/** `t.Table` 的渲染实现:在单张卡片里编排顶部条(搜索框 ⇄ 批量态 + 动作组)、\n * 高级筛选面板和表格。不公开导出,调用方只应经 `t.Table` 用它。 */\nexport function AbpTableView<TDto extends { id?: string }>(props: AbpTableViewProps<TDto>) {\n const L = useLocalization();\n const { t, slots, empty, skeletonRows, rowProps, pageSizes, searchPlaceholder } = props;\n\n const [expanded, setExpanded] = useState(false);\n const reactId = useId();\n const panelId = `${reactId}-query-panel`;\n\n const queryFields = slots.query ? flattenFields(slots.query.children) : [];\n const hasQuery = queryFields.length > 0;\n\n const toolbar = (\n <DataTableToolbar\n table={t}\n // 面板展开时让出搜索框:面板里是更精确的字段筛选,同屏再摆一个模糊搜索是重复入口。\n search={t.searchEnabled && !expanded}\n searchPlaceholder={searchPlaceholder}\n bulk={\n <
|
|
38
|
+
"content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { Plus } from \"lucide-react\";\nimport { type ReactNode, useId, useState } from \"react\";\nimport type { AbpTableSource } from \"@/components/abp/crud/abp-table-source\";\nimport { AbpBulkBarView } from \"@/components/abp/table/abp-bulk-bar\";\nimport {\n AbpQueryPanelToggle,\n AbpQueryPanelView,\n flattenFields,\n} from \"@/components/abp/table/abp-query-form\";\nimport type { AbpTableRowConfig } from \"@/components/abp/table/use-abp-table\";\nimport { DataTable } from \"@/components/data-table/data-table\";\nimport { DataTableColumnsMenu } from \"@/components/data-table/data-table-columns-menu\";\nimport { DataTableToolbar } from \"@/components/data-table/data-table-toolbar\";\nimport type { DataTableInstance } from \"@/components/data-table/use-data-table\";\nimport { FormErrorSummary } from \"@/components/form/form-error-summary\";\nimport { Button } from \"@/components/ui/button\";\n\n/** `t.Table` 的表现层 props:不受管的 `DataTable` 透传项,缺省全部走内建默认。 */\nexport interface AbpTableViewOwnProps<TDto> {\n empty?: ReactNode;\n skeletonRows?: number | \"pageSize\";\n rowProps?: (row: TDto) => { className?: string };\n pageSizes?: number[];\n searchPlaceholder?: string;\n}\n\n/** `AbpTableView` 消费的实例面:`useDataTable` 全量 + `useAbpTable` 的数据侧接线。\n * 不含五个绑定成员,那些正是靠本组件渲染出来的,反过来要求实例已经带着会是循环依赖。 */\nexport interface AbpTableViewInstance<TDto extends { id?: string }>\n extends DataTableInstance<TDto> {\n source: AbpTableSource<TDto>;\n rowConfig?: AbpTableRowConfig<TDto>;\n onOpen?: (mode: \"create\" | \"edit\" | \"view\", record?: TDto) => void;\n searchEnabled: boolean;\n submitQuery: () => Promise<void>;\n resetQuery: () => void;\n /** 当前已提交的筛选项数,驱动筛选钮上的\"还筛着\"圆点。 */\n activeQueryCount: number;\n onExport?: () => void;\n}\n\nexport interface AbpTableViewSlots {\n /** `t.QueryForm` 捕获的 props:高级筛选的字段 children,整体渲染进筛选面板\n * (顶部条右区的筛选钮开合它)。缺席则不出现筛选钮。 */\n query?: { children: ReactNode };\n /** `t.Toolbar` 的内容,追加在内建「新建」按钮右侧。 */\n toolbar?: ReactNode;\n /** `t.BulkBar` 的内容,经顶部条批量态渲染;仅有选中行时出现。 */\n bulkBar?: ReactNode;\n}\n\n// 必须导出。`useAbpTable` 的 `Table` 绑定成员把 `AbpTableView<TDto>` 的元素类型带进了自己的\n// 推断返回类型,声明发出时要能给这个类型具名(tsconfig.base.json 开着 declaration + composite),\n// 不导出会报 TS4058「cannot be named」。\nexport interface AbpTableViewProps<TDto extends { id?: string }>\n extends AbpTableViewOwnProps<TDto> {\n t: AbpTableViewInstance<TDto>;\n slots: AbpTableViewSlots;\n}\n\n/** `t.Table` 的渲染实现:在单张卡片里编排顶部条(搜索框 ⇄ 批量态 + 动作组)、\n * 高级筛选面板和表格。不公开导出,调用方只应经 `t.Table` 用它。 */\nexport function AbpTableView<TDto extends { id?: string }>(props: AbpTableViewProps<TDto>) {\n const L = useLocalization();\n const { t, slots, empty, skeletonRows, rowProps, pageSizes, searchPlaceholder } = props;\n\n const [expanded, setExpanded] = useState(false);\n const reactId = useId();\n const panelId = `${reactId}-query-panel`;\n\n const queryFields = slots.query ? flattenFields(slots.query.children) : [];\n const hasQuery = queryFields.length > 0;\n\n const toolbar = (\n <DataTableToolbar\n table={t}\n // 面板展开时让出搜索框:面板里是更精确的字段筛选,同屏再摆一个模糊搜索是重复入口。\n search={t.searchEnabled && !expanded}\n searchPlaceholder={searchPlaceholder}\n // 这里再订阅一次是刻意的:顶部条那层 SelectedCount 只负责「左区该显示什么」,\n // 计数是批量条自己的数据。把数字从顶部条传下来会让「计数从哪来」重新耦合进\n // DataTableToolbar 的 props,而这一层的立意正是谁要计数谁自己订阅。\n bulk={\n <t.SelectedCount>\n {(count) => (\n <AbpBulkBarView selectedCount={count} onClear={t.clearSelection}>\n {slots.bulkBar}\n </AbpBulkBarView>\n )}\n </t.SelectedCount>\n }\n onRefresh={t.source.listQuery.refetch ? () => void t.source.listQuery.refetch?.() : undefined}\n refreshing={t.source.listQuery.isFetching}\n onExport={t.onExport}\n utilityLeading={\n hasQuery ? (\n <AbpQueryPanelToggle\n expanded={expanded}\n onToggle={() => setExpanded((value) => !value)}\n panelId={panelId}\n activeCount={t.activeQueryCount}\n />\n ) : undefined\n }\n actions={\n <>\n {t.source.can.create && t.onOpen && (\n <Button type=\"button\" size=\"sm\" onClick={() => t.onOpen?.(\"create\")}>\n <Plus />\n {L(\"Crud:Create\")}\n </Button>\n )}\n {slots.toolbar}\n </>\n }\n >\n <DataTableColumnsMenu table={t} />\n </DataTableToolbar>\n );\n\n const renderQueryPanel = (last: boolean) =>\n hasQuery && expanded ? (\n <AbpQueryPanelView\n fields={queryFields}\n onSubmit={() => void t.submitQuery()}\n onReset={() => t.resetQuery()}\n panelId={panelId}\n last={last}\n />\n ) : null;\n\n if (t.source.listQuery.isError) {\n // 查询区和搜索框要留在报错态里。筛选值和搜索词是用户自己输入的,也最容易触发后端 400,\n // 一起吞掉就等于把用户锁死在自己的输入里,只能整页刷新。顶部条跟非报错态共用同一个\n // `toolbar`,报错态没有表格托底,包一层卡片容器撑住外观。\n return (\n <div className=\"flex flex-col gap-3\">\n <div className=\"overflow-hidden rounded-md border bg-card\">\n {toolbar}\n {renderQueryPanel(true)}\n </div>\n <FormErrorSummary errors={[L(\"Crud:OperationFailed\")]} />\n {t.source.listQuery.refetch && (\n <Button\n variant=\"outline\"\n size=\"sm\"\n className=\"self-start\"\n onClick={() => t.source.listQuery.refetch?.()}\n >\n {L(\"Crud:Retry\")}\n </Button>\n )}\n </div>\n );\n }\n\n // 默认 onOpen 存在时点行开详情;false 关闭;也可给自定义回调。\n const click = t.rowConfig?.click;\n const rowClickHandler =\n click === false\n ? undefined\n : (click ?? (t.onOpen ? (row: TDto) => t.onOpen?.(\"view\", row) : undefined));\n\n return (\n <DataTable\n table={t}\n loading={t.source.listQuery.isPending}\n fetching={t.source.listQuery.isFetching && !t.source.listQuery.isPending}\n empty={empty}\n skeletonRows={skeletonRows}\n rowProps={rowProps}\n pageSizes={pageSizes}\n onRowClick={rowClickHandler}\n >\n {toolbar}\n {renderQueryPanel(false)}\n </DataTable>\n );\n}\n",
|
|
39
39
|
"type": "registry:component",
|
|
40
40
|
"target": "components/abp/table/abp-table.tsx"
|
|
41
41
|
},
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
"path": "ui/blocks/abp-table/abp-bulk-delete.tsx",
|
|
56
|
-
"content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { Trash2 } from \"lucide-react\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport type { AbpTableSource } from \"@/components/abp/crud/abp-table-source\";\nimport { devWarn } from \"@/components/data-table/dev-warn\";\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n AlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\";\nimport { Button } from \"@/components/ui/button\";\n\nexport interface AbpBulkDeleteViewProps<TDto extends { id?: string }> {\n source: AbpTableSource<TDto>;\n
|
|
56
|
+
"content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { Trash2 } from \"lucide-react\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport type { AbpTableSource } from \"@/components/abp/crud/abp-table-source\";\nimport { devWarn } from \"@/components/data-table/dev-warn\";\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n AlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\";\nimport { Button } from \"@/components/ui/button\";\n\nexport interface AbpBulkDeleteViewProps<TDto extends { id?: string }> {\n source: AbpTableSource<TDto>;\n /** 确认时才取选中行:选中态归表所有,渲染期读到的快照会陈旧。 */\n getSelectedRows: () => TDto[];\n keepSelected: (ids: string[]) => void;\n}\n\n/**\n * 装配组件:`t.BulkDelete` 绑定成员的渲染实现:删除按钮 + 二次确认 + 整批成败汇总。\n * 模块级、不公开导出,调用方只应经 `t.BulkDelete` 使用。\n *\n * 删了几条、哪几条失败都由 `source.delete.many` 汇总回来,这里只负责把三种结局翻成一条 toast,\n * 并把失败的 id 交还给选择态。成功的行随列表刷新离场,失败的行留在勾选里供重试。\n */\nexport function AbpBulkDeleteView<TDto extends { id?: string }>(\n props: AbpBulkDeleteViewProps<TDto>,\n) {\n const L = useLocalization();\n const [open, setOpen] = useState(false);\n const many = props.source.delete?.many;\n\n if (!props.source.can.delete) return null;\n if (many === undefined) {\n devWarn(\n \"abp-table:bulk-delete-unsupported\",\n \"useAbpTable: t.BulkDelete 需要数据源提供 delete.many,当前 source 没有,按钮不渲染。\",\n );\n return null;\n }\n\n return (\n <AlertDialog open={open} onOpenChange={setOpen}>\n <AlertDialogTrigger asChild>\n <Button variant=\"ghost\" size=\"sm\" className=\"text-destructive hover:text-destructive\">\n <Trash2 />\n {L(\"Crud:Delete\")}\n </Button>\n </AlertDialogTrigger>\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>{L(\"Crud:DeleteConfirmTitle\")}</AlertDialogTitle>\n <AlertDialogDescription>{L(\"Crud:DeleteConfirmBody\")}</AlertDialogDescription>\n </AlertDialogHeader>\n <AlertDialogFooter>\n <AlertDialogCancel>{L(\"Form:Cancel\")}</AlertDialogCancel>\n <AlertDialogAction\n onClick={async () => {\n // 无 id 的行进不了删除端点,先剔除,否则会被算进「成功」的分母,\n // 让一次什么都没删的操作报成功。\n const ids = props\n .getSelectedRows()\n .map((row) => row.id)\n .filter((id): id is string => id !== undefined);\n try {\n const { failed } = await many(ids);\n if (failed.length === 0) toast.success(L(\"Crud:Deleted\"));\n else if (failed.length === ids.length) toast.error(L(\"Crud:OperationFailed\"));\n else\n toast.warning(\n L(\"Crud:BulkDeletePartialFailure\", ids.length - failed.length, failed.length),\n );\n props.keepSelected(failed);\n } finally {\n setOpen(false);\n }\n }}\n >\n {L(\"Crud:Delete\")}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n );\n}\n",
|
|
57
57
|
"type": "registry:component",
|
|
58
58
|
"target": "components/abp/table/abp-bulk-delete.tsx"
|
|
59
59
|
},
|
|
@@ -74,6 +74,12 @@
|
|
|
74
74
|
"content": "import type { ReactNode } from \"react\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { cn } from \"@/lib/utils\";\n\nconst STATUS_CLASSES = {\n success: \"bg-status-success/15 text-status-success\",\n info: \"bg-status-info/15 text-status-info\",\n warning: \"bg-status-warning/15 text-status-warning\",\n error: \"bg-status-error/15 text-status-error\",\n neutral: \"bg-status-neutral/15 text-status-neutral\",\n} as const;\n\nexport type Status = keyof typeof STATUS_CLASSES;\n\n/**\n * 语义状态徽章:status-* 色点 + 同色文字 + 半透明底的胶囊。行/记录的状态一律用它,\n * 不用 primary/destructive 实心 Badge:accent 只表示「操作」,实心红只表示「危险动作」。\n */\nexport function StatusBadge(props: { status: Status; className?: string; children: ReactNode }) {\n return (\n <Badge\n variant=\"outline\"\n className={cn(\n \"gap-1.5 rounded-full border-transparent font-medium\",\n STATUS_CLASSES[props.status],\n props.className,\n )}\n >\n <span aria-hidden className=\"size-1.5 rounded-full bg-current\" />\n {props.children}\n </Badge>\n );\n}\n",
|
|
75
75
|
"type": "registry:component",
|
|
76
76
|
"target": "components/abp/table/status-badge.tsx"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"path": "ui/blocks/abp-table/column-presets.tsx",
|
|
80
|
+
"content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport type { RowData } from \"@tanstack/react-table\";\nimport type { ReactNode } from \"react\";\nimport { type Status, StatusBadge } from \"@/components/abp/table/status-badge\";\nimport { createTableColumnHelper, type TableColumnDef } from \"@/components/data-table/table-core\";\n\n/** 列标签:词条 key,或自渲染函数。给 key 时 `L()` 在渲染期由标签组件内部调用,列定义因此\n * 不依赖 `L`,可以降成模块级常量——`useDataTable` 对 columns 的引用稳定性要求随之自动满足。\n * 计算型表头(拼接、带图标等)走函数形态。 */\nexport type ColumnLabel = string | (() => ReactNode);\n\n/** 预设列的可覆盖面。预设产出的是普通 `ColumnDef`,这里的键整键覆盖预设的默认值,\n * 包括 `cell`/`header`/`meta`——预设是加法糖,任何时候都能退回 TanStack 原生写法。 */\ntype ColumnOverrides<TData extends RowData> = Partial<TableColumnDef<TData>>;\n\n/** `TData` 上值类型可赋给 `TValue` 的字段名。DTO 字段普遍可选,故按 `NonNullable` 判定。 */\ntype FieldsOf<TData, TValue> = {\n [K in Extract<keyof TData, string>]-?: NonNullable<TData[K]> extends TValue ? K : never;\n}[Extract<keyof TData, string>];\n\nfunction LocalizedText(props: { entry: string }) {\n const L = useLocalization();\n return <>{L(props.entry)}</>;\n}\n\nfunction BoolBadge(props: { value: boolean | undefined; status: Status }) {\n const L = useLocalization();\n return (\n <StatusBadge status={props.status}>{L(props.value ? \"Table:Yes\" : \"Table:No\")}</StatusBadge>\n );\n}\n\n/** 把列标签转成 `ColumnDef[\"header\"]` 渲染器。预设内部用它;手写 `accessor`/`display` 列时\n * 也该用它而不是直接调 `L()`——那会把列定义重新绑回 hook,失去模块级常量的资格。 */\nexport function columnHeader(label: ColumnLabel): () => ReactNode {\n return typeof label === \"string\" ? () => <LocalizedText entry={label} /> : label;\n}\n\n/** ABP 的 date-only 字段回来的是完整 ISO datetime,表格里只展示日期段。\n * 刻意不做时区换算:值本身就没有时刻语义,换算只会把日期挪错一天。 */\nfunction dateOnly(value: string | undefined): string {\n return value ? value.slice(0, 10) : \"\";\n}\n\n/**\n * 列构造器:v9 原生的 `accessor`/`display`/`group` 原样透出,另加五个 ABP 后台常见列型的预设。\n *\n * 预设省掉的是重复的 cell 渲染与对齐,不是表达力:每个预设都接 `opts` 整键覆盖,\n * 覆盖不了的场景直接用 `accessor`/`display` 手写。\n *\n * @example\n * ```tsx\n * const col = createAbpColumns<BookDto>();\n * const columns = [\n * col.text(\"name\", \"App::BookName\"),\n * col.money(\"price\", \"App::BookPrice\", { digits: 2 }),\n * col.enum(\"type\", \"App::BookType\", { map: BOOK_TYPE_KEYS, enableSorting: false }),\n * ];\n * ```\n */\nexport function createAbpColumns<TData extends RowData>() {\n const helper = createTableColumnHelper<TData>();\n\n const text = (\n key: FieldsOf<TData, string>,\n label: ColumnLabel,\n opts?: ColumnOverrides<TData>,\n ): TableColumnDef<TData> => ({\n accessorKey: key,\n header: columnHeader(label),\n ...opts,\n });\n\n const date = (\n key: FieldsOf<TData, string>,\n label: ColumnLabel,\n opts?: ColumnOverrides<TData>,\n ): TableColumnDef<TData> => ({\n accessorKey: key,\n header: columnHeader(label),\n cell: ({ row }) => dateOnly(row.original[key] as string | undefined),\n ...opts,\n });\n\n const money = (\n key: FieldsOf<TData, number>,\n label: ColumnLabel,\n opts?: { digits?: number } & ColumnOverrides<TData>,\n ): TableColumnDef<TData> => {\n const { digits = 2, ...overrides } = opts ?? {};\n return {\n accessorKey: key,\n header: columnHeader(label),\n meta: { align: \"right\" },\n cell: ({ row }) => {\n const value = row.original[key] as number | undefined;\n return typeof value === \"number\" ? value.toFixed(digits) : \"\";\n },\n ...overrides,\n };\n };\n\n const enumColumn = (\n key: FieldsOf<TData, number | string>,\n label: ColumnLabel,\n opts: {\n /** 值 → 词条 key。查不到的值按原样渲染,不吞掉。 */\n map: Record<string | number, string>;\n /** 值为空时用的词条 key;省略则渲染空串。 */\n fallback?: string;\n } & ColumnOverrides<TData>,\n ): TableColumnDef<TData> => {\n const { map, fallback, ...overrides } = opts;\n return {\n accessorKey: key,\n header: columnHeader(label),\n cell: ({ row }) => {\n const value = row.original[key] as string | number | undefined;\n if (value === undefined || value === null) {\n return fallback ? <LocalizedText entry={fallback} /> : \"\";\n }\n const entry = map[value];\n return entry ? <LocalizedText entry={entry} /> : String(value);\n },\n ...overrides,\n };\n };\n\n const bool = (\n key: FieldsOf<TData, boolean>,\n label: ColumnLabel,\n opts?: {\n /** 值为真时的徽章语义色,默认 `info`。用 `success` 表示「启用/健康」这类正向状态。 */\n trueStatus?: Status;\n falseStatus?: Status;\n } & ColumnOverrides<TData>,\n ): TableColumnDef<TData> => {\n const { trueStatus = \"info\", falseStatus = \"neutral\", ...overrides } = opts ?? {};\n return {\n accessorKey: key,\n header: columnHeader(label),\n cell: ({ row }) => {\n const value = row.original[key] as boolean | undefined;\n return <BoolBadge value={value} status={value ? trueStatus : falseStatus} />;\n },\n ...overrides,\n };\n };\n\n return {\n accessor: helper.accessor,\n display: helper.display,\n group: helper.group,\n text,\n date,\n money,\n enum: enumColumn,\n bool,\n };\n}\n",
|
|
81
|
+
"type": "registry:component",
|
|
82
|
+
"target": "components/abp/table/column-presets.tsx"
|
|
77
83
|
}
|
|
78
84
|
],
|
|
79
85
|
"type": "registry:block"
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"@jcoder-stack/abp-react",
|
|
8
8
|
"@tanstack/react-form",
|
|
9
9
|
"@tanstack/react-query",
|
|
10
|
-
"@tanstack/react-table@9.
|
|
10
|
+
"@tanstack/react-table@9.2.4",
|
|
11
11
|
"sonner",
|
|
12
12
|
"lucide-react",
|
|
13
13
|
"zod"
|
|
@@ -35,13 +35,13 @@
|
|
|
35
35
|
"files": [
|
|
36
36
|
{
|
|
37
37
|
"path": "ui/blocks/admin-pages/identity/users.tsx",
|
|
38
|
-
"content": "import { formatPersonName } from \"@jcoder-stack/abp-react/i18n\";\nimport { useCulture, useLocalization, usePermissionChecker } from \"@jcoder-stack/abp-react/react\";\nimport { createFileRoute } from \"@tanstack/react-router\";\nimport { KeyRound } from \"lucide-react\";\nimport { lazy, Suspense, useCallback, useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { z } from \"zod\";\nimport {\n getApiIdentityUsersIdRoles,\n getGetApiIdentityUsersQueryKey,\n useDeleteApiIdentityUsersId,\n useGetApiIdentityUsers,\n useGetApiIdentityUsersAssignableRoles,\n usePostApiIdentityUsers,\n usePutApiIdentityUsersId,\n} from \"@/api/endpoints/user/user\";\nimport type {\n VoloAbpIdentityIdentityUserCreateDto,\n VoloAbpIdentityIdentityUserDto,\n VoloAbpIdentityIdentityUserUpdateDto,\n} from \"@/api/models\";\nimport { requirePermission } from \"@/auth\";\nimport { createCrudService } from \"@/components/abp/crud/crud-service\";\nimport { useAbpSheet } from \"@/components/abp/sheet/use-abp-sheet\";\nimport { StatusBadge } from \"@/components/abp/table/status-badge\";\nimport { useAbpTable } from \"@/components/abp/table/use-abp-table\";\nimport type { ComboboxOption } from \"@/components/combobox/use-combobox-options\";\nimport type { TableColumnDef } from \"@/components/data-table/table-core\";\nimport { DropdownMenuItem } from \"@/components/ui/dropdown-menu\";\nimport { IdentityPermissions } from \"@/permissions\";\n\n// 编辑/权限面板的重依赖(combobox、tree、accordion 等)按需加载:列表首屏不背这批模块。\nconst PermissionSheet = lazy(() =>\n import(\"@/components/abp/permission/permission-sheet\").then((m) => ({\n default: m.PermissionSheet,\n })),\n);\n\n/** 用户 DTO 不带 roleNames,故 open() 需先 GET .../roles 再开 sheet。 */\nexport const Route = createFileRoute(\"/_layout/_authed/identity/users\")({\n beforeLoad: requirePermission(IdentityPermissions.Users.Default),\n component: UsersPage,\n});\n\nconst userService = createCrudService({\n useList: useGetApiIdentityUsers,\n useCreate: usePostApiIdentityUsers,\n useUpdate: usePutApiIdentityUsersId,\n useDelete: useDeleteApiIdentityUsersId,\n listKey: getGetApiIdentityUsersQueryKey,\n policy: IdentityPermissions.Users.Default,\n});\n\ninterface UserFormValues {\n userName: string;\n email: string;\n name: string;\n surname: string;\n phoneNumber: string;\n password: string;\n isActive: boolean;\n roleNames: string[];\n}\n\nconst EMPTY_VALUES: UserFormValues = {\n userName: \"\",\n email: \"\",\n name: \"\",\n surname: \"\",\n phoneNumber: \"\",\n password: \"\",\n isActive: true,\n roleNames: [],\n};\n\n/** 不能走默认 pick:roleNames 来自异步预取、password 必须回空(不回显)。 */\nfunction toRecordValues(\n record: VoloAbpIdentityIdentityUserDto,\n roleNames: string[],\n): UserFormValues {\n return {\n userName: record.userName ?? \"\",\n email: record.email ?? \"\",\n name: record.name ?? \"\",\n surname: record.surname ?? \"\",\n phoneNumber: record.phoneNumber ?? \"\",\n password: \"\",\n isActive: record.isActive ?? true,\n roleNames,\n };\n}\n\n/** 不能走默认 identity:可选字段空串归一为 undefined(不给后端发 `name: \"\"`)。 */\nfunction toCreateInput(value: UserFormValues): VoloAbpIdentityIdentityUserCreateDto {\n return {\n userName: value.userName,\n email: value.email,\n name: value.name || undefined,\n surname: value.surname || undefined,\n phoneNumber: value.phoneNumber || undefined,\n password: value.password,\n isActive: value.isActive,\n roleNames: value.roleNames,\n };\n}\n\n/** 不能走默认 identity:edit 不渲染 password 字段(留空=不改);value.password 恒为初始空串,\n * 故 update body 里永远不带 password,后端保持原密码不变;其余可选字段同 toCreateInput,\n * 空串归一为 undefined(不给后端发 `name: \"\"`)。 */\nfunction toUpdateInput(value: UserFormValues): VoloAbpIdentityIdentityUserUpdateDto {\n return {\n userName: value.userName,\n email: value.email,\n name: value.name || undefined,\n surname: value.surname || undefined,\n phoneNumber: value.phoneNumber || undefined,\n isActive: value.isActive,\n roleNames: value.roleNames,\n };\n}\n\nfunction UsersPage() {\n const L = useLocalization();\n const culture = useCulture();\n const can = usePermissionChecker();\n const canManagePermissions = can(IdentityPermissions.Users.ManagePermissions);\n const [permissionsFor, setPermissionsFor] = useState<VoloAbpIdentityIdentityUserDto | null>(null);\n\n // 引用必须稳定:进 AbpTable 的 columns memo 依赖,内联箭头会让列模型每渲染重建(DEV 有 churn 告警)。\n const permissionMenuItem = useCallback(\n (row: VoloAbpIdentityIdentityUserDto) =>\n canManagePermissions ? (\n <DropdownMenuItem onSelect={() => setPermissionsFor(row)}>\n <KeyRound />\n {L(\"Admin:Permissions\")}\n </DropdownMenuItem>\n ) : null,\n [canManagePermissions, L],\n );\n\n const columns = useMemo<TableColumnDef<VoloAbpIdentityIdentityUserDto>[]>(\n () => [\n { accessorKey: \"userName\", header: () => L(\"AbpIdentity::UserName\") },\n {\n id: \"fullName\",\n header: () => L(\"Admin:FullName\"),\n enableSorting: false,\n cell: ({ row }) =>\n formatPersonName({ name: row.original.name, surname: row.original.surname, culture }),\n },\n { accessorKey: \"email\", header: () => L(\"AbpIdentity::DisplayName:Email\") },\n {\n accessorKey: \"isActive\",\n header: () => L(\"AbpIdentity::DisplayName:IsActive\"),\n enableSorting: false,\n cell: ({ getValue }) => (\n <StatusBadge status={getValue() ? \"success\" : \"neutral\"}>\n {getValue() ? L(\"Admin:Yes\") : L(\"Admin:No\")}\n </StatusBadge>\n ),\n },\n ],\n [L, culture],\n );\n\n const sheet = useAbpSheet(userService, {\n emptyValues: EMPTY_VALUES,\n // 用户 DTO 不带 roleNames,view/edit 打开前先取一次当前角色再开 sheet;AbpTable 的 onOpen\n // 是 fire-and-forget,GET 失败必须在这里兜住提示并取消打开,否则用户只看到点击无响应。\n toValues: async (record, mode) => {\n let roleNames: string[] = [];\n if (record.id && mode !== \"create\") {\n try {\n const roles = await getApiIdentityUsersIdRoles(record.id);\n roleNames = (roles.items ?? [])\n .map((role) => role.name ?? \"\")\n .filter((name) => name.length > 0);\n } catch {\n toast.error(L(\"Crud:OperationFailed\"));\n return null;\n }\n }\n return toRecordValues(record, roleNames);\n },\n toCreate: toCreateInput,\n toUpdate: toUpdateInput,\n schema: (mode) => {\n const isCreate = mode === \"create\";\n return z.object({\n userName: z.string().trim().min(1, L(\"Form:Required\")),\n email: z\n .string()\n .trim()\n .min(1, L(\"Form:Required\"))\n .pipe(z.email(L(\"Form:InvalidEmail\"))),\n name: z.string(),\n surname: z.string(),\n phoneNumber: z.string(),\n password: isCreate ? z.string().trim().min(1, L(\"Form:Required\")) : z.string(),\n isActive: z.boolean(),\n roleNames: z.array(z.string()),\n });\n },\n });\n\n const t = useAbpTable(userService, {\n columns,\n row: { menu: permissionMenuItem },\n onOpen: sheet.open,\n });\n\n // MultiCombobox 的角色候选项只在编辑态(create/edit)且有 Update 权限时才拉,防止 view 态\n // 或无权限用户触发一次注定被后端 403 的请求。\n const assignableRolesQuery = useGetApiIdentityUsersAssignableRoles({\n query: { enabled: t.source.can.update && sheet.mode !== undefined && sheet.mode !== \"view\" },\n });\n const roleOptions: ComboboxOption[] = (assignableRolesQuery.data?.items ?? []).map((role) => ({\n value: role.name ?? \"\",\n label: role.name ?? \"\",\n }));\n\n const rolesEditable = t.source.can.update && sheet.mode !== \"view\";\n\n return (\n <section className=\"space-y-4\">\n <h1 className=\"text-2xl font-normal\">{L(\"AbpIdentity::Users\")}</h1>\n <t.Table>\n <t.BulkBar>\n <t.BulkDelete />\n </t.BulkBar>\n </t.Table>\n <sheet.Sheet>\n <sheet.form.AppForm>\n <sheet.form.FormErrors />\n </sheet.form.AppForm>\n\n <sheet.form.AppField name=\"userName\">\n {(field) => (\n <field.TextField\n label={L(\"AbpIdentity::UserName\")}\n required\n disabled={sheet.readOnly}\n />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"email\">\n {(field) => (\n <field.TextField\n label={L(\"AbpIdentity::DisplayName:Email\")}\n type=\"email\"\n required\n disabled={sheet.readOnly}\n />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"name\">\n {(field) => (\n <field.TextField label={L(\"AbpIdentity::DisplayName:Name\")} disabled={sheet.readOnly} />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"surname\">\n {(field) => (\n <field.TextField label={L(\"AbpIdentity::Surname\")} disabled={sheet.readOnly} />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"phoneNumber\">\n {(field) => (\n <field.TextField label={L(\"AbpIdentity::PhoneNumber\")} disabled={sheet.readOnly} />\n )}\n </sheet.form.AppField>\n\n {sheet.mode === \"create\" && (\n <sheet.form.AppField name=\"password\">\n {(field) => (\n <field.TextField label={L(\"AbpIdentity::Password\")} type=\"password\" required />\n )}\n </sheet.form.AppField>\n )}\n\n <sheet.form.AppField name=\"isActive\">\n {(field) => (\n <field.SwitchField\n label={L(\"AbpIdentity::DisplayName:IsActive\")}\n disabled={sheet.readOnly}\n />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"roleNames\">\n {(field) => (\n <field.MultiComboboxField\n label={L(\"AbpIdentity::Roles\")}\n options={roleOptions}\n editable={rolesEditable}\n />\n )}\n </sheet.form.AppField>\n </sheet.Sheet>\n {permissionsFor && (\n <Suspense fallback={null}>\n <PermissionSheet\n providerName=\"U\"\n providerKey={permissionsFor.id ?? \"\"}\n open={permissionsFor !== null}\n onOpenChange={(open) => !open && setPermissionsFor(null)}\n title={`${L(\"Admin:Permissions\")} · ${permissionsFor.userName ?? \"\"}`}\n />\n </Suspense>\n )}\n </section>\n );\n}\n",
|
|
38
|
+
"content": "import { formatPersonName } from \"@jcoder-stack/abp-react/i18n\";\nimport { useCulture, useLocalization, usePermissionChecker } from \"@jcoder-stack/abp-react/react\";\nimport { createFileRoute } from \"@tanstack/react-router\";\nimport { KeyRound } from \"lucide-react\";\nimport { lazy, Suspense, useCallback, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { z } from \"zod\";\nimport {\n getApiIdentityUsersIdRoles,\n getGetApiIdentityUsersQueryKey,\n useDeleteApiIdentityUsersId,\n useGetApiIdentityUsers,\n useGetApiIdentityUsersAssignableRoles,\n usePostApiIdentityUsers,\n usePutApiIdentityUsersId,\n} from \"@/api/endpoints/user/user\";\nimport type {\n VoloAbpIdentityIdentityUserCreateDto,\n VoloAbpIdentityIdentityUserDto,\n VoloAbpIdentityIdentityUserUpdateDto,\n} from \"@/api/models\";\nimport { requirePermission } from \"@/auth\";\nimport { createCrudService } from \"@/components/abp/crud/crud-service\";\nimport { useAbpSheet } from \"@/components/abp/sheet/use-abp-sheet\";\nimport { columnHeader, createAbpColumns } from \"@/components/abp/table/column-presets\";\nimport { useAbpTable } from \"@/components/abp/table/use-abp-table\";\nimport type { ComboboxOption } from \"@/components/combobox/use-combobox-options\";\nimport { DropdownMenuItem } from \"@/components/ui/dropdown-menu\";\nimport { IdentityPermissions } from \"@/permissions\";\n\n// 编辑/权限面板的重依赖(combobox、tree、accordion 等)按需加载:列表首屏不背这批模块。\nconst PermissionSheet = lazy(() =>\n import(\"@/components/abp/permission/permission-sheet\").then((m) => ({\n default: m.PermissionSheet,\n })),\n);\n\n/** 用户 DTO 不带 roleNames,故 open() 需先 GET .../roles 再开 sheet。 */\nexport const Route = createFileRoute(\"/_layout/_authed/identity/users\")({\n beforeLoad: requirePermission(IdentityPermissions.Users.Default),\n component: UsersPage,\n});\n\nconst userService = createCrudService({\n useList: useGetApiIdentityUsers,\n useCreate: usePostApiIdentityUsers,\n useUpdate: usePutApiIdentityUsersId,\n useDelete: useDeleteApiIdentityUsersId,\n listKey: getGetApiIdentityUsersQueryKey,\n policy: IdentityPermissions.Users.Default,\n});\n\ninterface UserFormValues {\n userName: string;\n email: string;\n name: string;\n surname: string;\n phoneNumber: string;\n password: string;\n isActive: boolean;\n roleNames: string[];\n}\n\nconst EMPTY_VALUES: UserFormValues = {\n userName: \"\",\n email: \"\",\n name: \"\",\n surname: \"\",\n phoneNumber: \"\",\n password: \"\",\n isActive: true,\n roleNames: [],\n};\n\n/** 不能走默认 pick:roleNames 来自异步预取、password 必须回空(不回显)。 */\nfunction toRecordValues(\n record: VoloAbpIdentityIdentityUserDto,\n roleNames: string[],\n): UserFormValues {\n return {\n userName: record.userName ?? \"\",\n email: record.email ?? \"\",\n name: record.name ?? \"\",\n surname: record.surname ?? \"\",\n phoneNumber: record.phoneNumber ?? \"\",\n password: \"\",\n isActive: record.isActive ?? true,\n roleNames,\n };\n}\n\n/** 不能走默认 identity:可选字段空串归一为 undefined(不给后端发 `name: \"\"`)。 */\nfunction toCreateInput(value: UserFormValues): VoloAbpIdentityIdentityUserCreateDto {\n return {\n userName: value.userName,\n email: value.email,\n name: value.name || undefined,\n surname: value.surname || undefined,\n phoneNumber: value.phoneNumber || undefined,\n password: value.password,\n isActive: value.isActive,\n roleNames: value.roleNames,\n };\n}\n\n/** 不能走默认 identity:edit 不渲染 password 字段(留空=不改);value.password 恒为初始空串,\n * 故 update body 里永远不带 password,后端保持原密码不变;其余可选字段同 toCreateInput,\n * 空串归一为 undefined(不给后端发 `name: \"\"`)。 */\nfunction toUpdateInput(value: UserFormValues): VoloAbpIdentityIdentityUserUpdateDto {\n return {\n userName: value.userName,\n email: value.email,\n name: value.name || undefined,\n surname: value.surname || undefined,\n phoneNumber: value.phoneNumber || undefined,\n isActive: value.isActive,\n roleNames: value.roleNames,\n };\n}\n\n/** 姓名顺序随文化而变(中日韩姓在前),`useCulture` 是 hook,只能在渲染期读。\n * 收进单元格组件后,列定义就不再依赖任何 hook,可以留在模块级。 */\nfunction FullNameCell(props: { name?: string | null; surname?: string | null }) {\n const culture = useCulture();\n return <>{formatPersonName({ name: props.name, surname: props.surname, culture })}</>;\n}\n\nconst col = createAbpColumns<VoloAbpIdentityIdentityUserDto>();\nconst columns = [\n col.text(\"userName\", \"AbpIdentity::UserName\"),\n col.display({\n id: \"fullName\",\n header: columnHeader(\"Admin:FullName\"),\n enableSorting: false,\n cell: ({ row }) => <FullNameCell name={row.original.name} surname={row.original.surname} />,\n }),\n col.text(\"email\", \"AbpIdentity::DisplayName:Email\"),\n col.bool(\"isActive\", \"AbpIdentity::DisplayName:IsActive\", {\n trueStatus: \"success\",\n enableSorting: false,\n }),\n];\n\nfunction UsersPage() {\n const L = useLocalization();\n const can = usePermissionChecker();\n const canManagePermissions = can(IdentityPermissions.Users.ManagePermissions);\n const [permissionsFor, setPermissionsFor] = useState<VoloAbpIdentityIdentityUserDto | null>(null);\n\n // 引用必须稳定:进 AbpTable 的 columns memo 依赖,内联箭头会让列模型每渲染重建(DEV 有 churn 告警)。\n const permissionMenuItem = useCallback(\n (row: VoloAbpIdentityIdentityUserDto) =>\n canManagePermissions ? (\n <DropdownMenuItem onSelect={() => setPermissionsFor(row)}>\n <KeyRound />\n {L(\"Admin:Permissions\")}\n </DropdownMenuItem>\n ) : null,\n [canManagePermissions, L],\n );\n\n const sheet = useAbpSheet(userService, {\n emptyValues: EMPTY_VALUES,\n // 用户 DTO 不带 roleNames,view/edit 打开前先取一次当前角色再开 sheet;AbpTable 的 onOpen\n // 是 fire-and-forget,GET 失败必须在这里兜住提示并取消打开,否则用户只看到点击无响应。\n toValues: async (record, mode) => {\n let roleNames: string[] = [];\n if (record.id && mode !== \"create\") {\n try {\n const roles = await getApiIdentityUsersIdRoles(record.id);\n roleNames = (roles.items ?? [])\n .map((role) => role.name ?? \"\")\n .filter((name) => name.length > 0);\n } catch {\n toast.error(L(\"Crud:OperationFailed\"));\n return null;\n }\n }\n return toRecordValues(record, roleNames);\n },\n toCreate: toCreateInput,\n toUpdate: toUpdateInput,\n schema: (mode) => {\n const isCreate = mode === \"create\";\n return z.object({\n userName: z.string().trim().min(1, L(\"Form:Required\")),\n email: z\n .string()\n .trim()\n .min(1, L(\"Form:Required\"))\n .pipe(z.email(L(\"Form:InvalidEmail\"))),\n name: z.string(),\n surname: z.string(),\n phoneNumber: z.string(),\n password: isCreate ? z.string().trim().min(1, L(\"Form:Required\")) : z.string(),\n isActive: z.boolean(),\n roleNames: z.array(z.string()),\n });\n },\n });\n\n const t = useAbpTable(userService, {\n columns,\n row: { menu: permissionMenuItem },\n onOpen: sheet.open,\n });\n\n // MultiCombobox 的角色候选项只在编辑态(create/edit)且有 Update 权限时才拉,防止 view 态\n // 或无权限用户触发一次注定被后端 403 的请求。\n const assignableRolesQuery = useGetApiIdentityUsersAssignableRoles({\n query: { enabled: t.source.can.update && sheet.mode !== undefined && sheet.mode !== \"view\" },\n });\n const roleOptions: ComboboxOption[] = (assignableRolesQuery.data?.items ?? []).map((role) => ({\n value: role.name ?? \"\",\n label: role.name ?? \"\",\n }));\n\n const rolesEditable = t.source.can.update && sheet.mode !== \"view\";\n\n return (\n <section className=\"space-y-4\">\n <h1 className=\"text-2xl font-normal\">{L(\"AbpIdentity::Users\")}</h1>\n <t.Table>\n <t.BulkBar>\n <t.BulkDelete />\n </t.BulkBar>\n </t.Table>\n <sheet.Sheet>\n <sheet.form.AppForm>\n <sheet.form.FormErrors />\n </sheet.form.AppForm>\n\n <sheet.form.AppField name=\"userName\">\n {(field) => (\n <field.TextField\n label={L(\"AbpIdentity::UserName\")}\n required\n disabled={sheet.readOnly}\n />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"email\">\n {(field) => (\n <field.TextField\n label={L(\"AbpIdentity::DisplayName:Email\")}\n type=\"email\"\n required\n disabled={sheet.readOnly}\n />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"name\">\n {(field) => (\n <field.TextField label={L(\"AbpIdentity::DisplayName:Name\")} disabled={sheet.readOnly} />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"surname\">\n {(field) => (\n <field.TextField label={L(\"AbpIdentity::Surname\")} disabled={sheet.readOnly} />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"phoneNumber\">\n {(field) => (\n <field.TextField label={L(\"AbpIdentity::PhoneNumber\")} disabled={sheet.readOnly} />\n )}\n </sheet.form.AppField>\n\n {sheet.mode === \"create\" && (\n <sheet.form.AppField name=\"password\">\n {(field) => (\n <field.TextField label={L(\"AbpIdentity::Password\")} type=\"password\" required />\n )}\n </sheet.form.AppField>\n )}\n\n <sheet.form.AppField name=\"isActive\">\n {(field) => (\n <field.SwitchField\n label={L(\"AbpIdentity::DisplayName:IsActive\")}\n disabled={sheet.readOnly}\n />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"roleNames\">\n {(field) => (\n <field.MultiComboboxField\n label={L(\"AbpIdentity::Roles\")}\n options={roleOptions}\n editable={rolesEditable}\n />\n )}\n </sheet.form.AppField>\n </sheet.Sheet>\n {permissionsFor && (\n <Suspense fallback={null}>\n <PermissionSheet\n providerName=\"U\"\n providerKey={permissionsFor.id ?? \"\"}\n open={permissionsFor !== null}\n onOpenChange={(open) => !open && setPermissionsFor(null)}\n title={`${L(\"Admin:Permissions\")} · ${permissionsFor.userName ?? \"\"}`}\n />\n </Suspense>\n )}\n </section>\n );\n}\n",
|
|
39
39
|
"type": "registry:file",
|
|
40
40
|
"target": "src/routes/_layout/_authed/identity/users.tsx"
|
|
41
41
|
},
|
|
42
42
|
{
|
|
43
43
|
"path": "ui/blocks/admin-pages/identity/roles.tsx",
|
|
44
|
-
"content": "import { useLocalization, usePermissionChecker } from \"@jcoder-stack/abp-react/react\";\nimport { createFileRoute } from \"@tanstack/react-router\";\nimport { KeyRound } from \"lucide-react\";\nimport { lazy, Suspense, useCallback,
|
|
44
|
+
"content": "import { useLocalization, usePermissionChecker } from \"@jcoder-stack/abp-react/react\";\nimport { createFileRoute } from \"@tanstack/react-router\";\nimport { KeyRound } from \"lucide-react\";\nimport { lazy, Suspense, useCallback, useState } from \"react\";\nimport {\n getGetApiIdentityRolesQueryKey,\n useDeleteApiIdentityRolesId,\n useGetApiIdentityRoles,\n usePostApiIdentityRoles,\n usePutApiIdentityRolesId,\n} from \"@/api/endpoints/role/role\";\nimport type { VoloAbpIdentityIdentityRoleDto } from \"@/api/models\";\nimport { requirePermission } from \"@/auth\";\nimport { createCrudService } from \"@/components/abp/crud/crud-service\";\nimport { useAbpSheet } from \"@/components/abp/sheet/use-abp-sheet\";\nimport { createAbpColumns } from \"@/components/abp/table/column-presets\";\nimport { useAbpTable } from \"@/components/abp/table/use-abp-table\";\nimport { DropdownMenuItem } from \"@/components/ui/dropdown-menu\";\nimport { IdentityPermissions } from \"@/permissions\";\nimport { buildRoleSchema } from \"@/routes/_layout/_authed/identity/-role-schema\";\n\n// 权限面板的重依赖(tree、accordion 等)按需加载:列表首屏不背这批模块。\nconst PermissionSheet = lazy(() =>\n import(\"@/components/abp/permission/permission-sheet\").then((m) => ({\n default: m.PermissionSheet,\n })),\n);\n\n// 列走词条 key,不依赖 L,故留在模块级:引用天然稳定,不必包 useMemo。\nconst col = createAbpColumns<VoloAbpIdentityIdentityRoleDto>();\nconst columns = [\n col.text(\"name\", \"AbpIdentity::DisplayName:RoleName\"),\n col.bool(\"isDefault\", \"AbpIdentity::DisplayName:IsDefault\", { enableSorting: false }),\n col.bool(\"isPublic\", \"AbpIdentity::DisplayName:IsPublic\", { enableSorting: false }),\n];\n\n/** 角色本身无可分配对象,故无角色 MultiCombobox。 */\nexport const Route = createFileRoute(\"/_layout/_authed/identity/roles\")({\n beforeLoad: requirePermission(IdentityPermissions.Roles.Default),\n component: RolesPage,\n});\n\nconst roleService = createCrudService({\n useList: useGetApiIdentityRoles,\n useCreate: usePostApiIdentityRoles,\n useUpdate: usePutApiIdentityRolesId,\n useDelete: useDeleteApiIdentityRolesId,\n listKey: getGetApiIdentityRolesQueryKey,\n policy: IdentityPermissions.Roles.Default,\n});\n\ninterface RoleFormValues {\n name: string;\n isDefault: boolean;\n isPublic: boolean;\n}\n\nconst EMPTY_VALUES: RoleFormValues = { name: \"\", isDefault: false, isPublic: false };\n\nfunction RolesPage() {\n const L = useLocalization();\n const can = usePermissionChecker();\n const canManagePermissions = can(IdentityPermissions.Roles.ManagePermissions);\n const [permissionsFor, setPermissionsFor] = useState<VoloAbpIdentityIdentityRoleDto | null>(null);\n\n const roleSchema = buildRoleSchema(L);\n\n // 引用必须稳定:进 AbpTable 的 columns memo 依赖,内联箭头会让列模型每渲染重建(DEV 有 churn 告警)。\n const permissionMenuItem = useCallback(\n (row: VoloAbpIdentityIdentityRoleDto) =>\n canManagePermissions ? (\n <DropdownMenuItem onSelect={() => setPermissionsFor(row)}>\n <KeyRound />\n {L(\"Admin:Permissions\")}\n </DropdownMenuItem>\n ) : null,\n [canManagePermissions, L],\n );\n\n const sheet = useAbpSheet(roleService, {\n emptyValues: EMPTY_VALUES,\n schema: () => roleSchema,\n });\n\n const t = useAbpTable(roleService, {\n columns,\n row: { menu: permissionMenuItem },\n onOpen: sheet.open,\n });\n\n return (\n <section className=\"space-y-4\">\n <h1 className=\"text-2xl font-normal\">{L(\"AbpIdentity::Roles\")}</h1>\n <t.Table>\n <t.BulkBar>\n <t.BulkDelete />\n </t.BulkBar>\n </t.Table>\n <sheet.Sheet>\n <sheet.form.AppForm>\n <sheet.form.FormErrors />\n </sheet.form.AppForm>\n\n <sheet.form.AppField name=\"name\">\n {(field) => (\n <field.TextField\n label={L(\"AbpIdentity::DisplayName:RoleName\")}\n required\n disabled={sheet.readOnly}\n />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"isDefault\">\n {(field) => (\n <field.SwitchField\n label={L(\"AbpIdentity::DisplayName:IsDefault\")}\n disabled={sheet.readOnly}\n />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"isPublic\">\n {(field) => (\n <field.SwitchField\n label={L(\"AbpIdentity::DisplayName:IsPublic\")}\n disabled={sheet.readOnly}\n />\n )}\n </sheet.form.AppField>\n </sheet.Sheet>\n {permissionsFor && (\n <Suspense fallback={null}>\n <PermissionSheet\n providerName=\"R\"\n providerKey={permissionsFor.name ?? \"\"}\n open={permissionsFor !== null}\n onOpenChange={(open) => !open && setPermissionsFor(null)}\n title={`${L(\"Admin:Permissions\")} · ${permissionsFor.name ?? \"\"}`}\n />\n </Suspense>\n )}\n </section>\n );\n}\n",
|
|
45
45
|
"type": "registry:file",
|
|
46
46
|
"target": "src/routes/_layout/_authed/identity/roles.tsx"
|
|
47
47
|
},
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
"path": "ui/blocks/admin-pages/tenants/index.tsx",
|
|
56
|
-
"content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { createFileRoute } from \"@tanstack/react-router\";\nimport {
|
|
56
|
+
"content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { createFileRoute } from \"@tanstack/react-router\";\nimport { z } from \"zod\";\nimport {\n getGetApiMultiTenancyTenantsQueryKey,\n useDeleteApiMultiTenancyTenantsId,\n useGetApiMultiTenancyTenants,\n usePostApiMultiTenancyTenants,\n usePutApiMultiTenancyTenantsId,\n} from \"@/api/endpoints/tenant/tenant\";\nimport type {\n VoloAbpTenantManagementTenantDto,\n VoloAbpTenantManagementTenantUpdateDto,\n} from \"@/api/models\";\nimport {\n postApiMultiTenancyTenantsBody,\n postApiMultiTenancyTenantsBodyAdminEmailAddressMax,\n postApiMultiTenancyTenantsBodyAdminPasswordMax,\n postApiMultiTenancyTenantsBodyNameMax,\n} from \"@/api/schemas/tenant/tenant\";\nimport { requirePermission } from \"@/auth\";\nimport { createCrudService } from \"@/components/abp/crud/crud-service\";\nimport { useAbpSheet } from \"@/components/abp/sheet/use-abp-sheet\";\nimport { createAbpColumns } from \"@/components/abp/table/column-presets\";\nimport { useAbpTable } from \"@/components/abp/table/use-abp-table\";\nimport { TenantManagementPermissions } from \"@/permissions\";\n\n/** /tenants:TenantManagement 模块的租户 CRUD;create/edit 字段集不同:create 额外收\n * adminEmailAddress/adminPassword(开租户时建种子管理员),edit 只改 name(后端 TenantUpdateDto\n * 本就不收管理员字段,修改管理员是另一条 Identity.Users 链路)。 */\nexport const Route = createFileRoute(\"/_layout/_authed/tenants/\")({\n beforeLoad: requirePermission(TenantManagementPermissions.Tenants.Default),\n component: TenantsPage,\n});\n\n// 列走词条 key,不依赖 L,故留在模块级:引用天然稳定,不必包 useMemo。\nconst col = createAbpColumns<VoloAbpTenantManagementTenantDto>();\nconst columns = [col.text(\"name\", \"AbpTenantManagement::TenantName\")];\n\nconst tenantService = createCrudService({\n useList: useGetApiMultiTenancyTenants,\n useCreate: usePostApiMultiTenancyTenants,\n useUpdate: usePutApiMultiTenancyTenantsId,\n useDelete: useDeleteApiMultiTenancyTenantsId,\n listKey: getGetApiMultiTenancyTenantsQueryKey,\n policy: TenantManagementPermissions.Tenants.Default,\n});\n\ninterface TenantFormValues {\n name: string;\n adminEmailAddress: string;\n adminPassword: string;\n}\n\nconst EMPTY_VALUES: TenantFormValues = { name: \"\", adminEmailAddress: \"\", adminPassword: \"\" };\n\n// toValues 显式保留(不用默认的 pick-and-coalesce):密码是业务上刻意的单向字段,\n// 记录里从没有它,回填必须固定为空串,不能让默认实现的「按 emptyValues 键从记录 pick」\n// 悄悄从别的同名字段带出值。\nfunction toRecordValues(record: VoloAbpTenantManagementTenantDto): TenantFormValues {\n return { name: record.name ?? \"\", adminEmailAddress: \"\", adminPassword: \"\" };\n}\n\n// toUpdate 显式保留:update DTO 没有 admin 字段,若省略、退化到默认 identity 映射,会把\n// adminEmailAddress/adminPassword 两个空串也塞进 PUT 请求体(依赖后端「忽略未知属性」的隐式\n// 契约,而不是让接口形状本身说话)。这里没有触发类型层的条件必填:TenantUpdateDto 的必填\n// 字段只有 name,TenantFormValues 结构上仍满足 `extends` 关系,toUpdate 类型层其实可省。\n// 真正会被强制显式传的是「目标 DTO 存在 TValues 没有的必填字段」,见\n// abp-sheet-contract.test-d.ts 的负例。\nfunction toUpdateInput(value: TenantFormValues): VoloAbpTenantManagementTenantUpdateDto {\n return { name: value.name };\n}\n\nfunction TenantsPage() {\n const L = useLocalization();\n\n const sheet = useAbpSheet(tenantService, {\n emptyValues: EMPTY_VALUES,\n toValues: (record) => toRecordValues(record),\n toUpdate: toUpdateInput,\n // 以生成的 body schema 为基底:max64/max256/max128 这类后端约束免费继承;\n // adminEmailAddress/adminPassword 沿用既有 mode 分支(仅 create 收,edit 分支放行空串,\n // 因为 TenantUpdateDto 本就不带这两个字段),max 从生成常量取,消息在此覆盖。\n schema: (mode) => {\n const isCreate = mode === \"create\";\n return postApiMultiTenancyTenantsBody.extend({\n name: z\n .string()\n .trim()\n .min(1, L(\"Form:Required\"))\n .max(\n postApiMultiTenancyTenantsBodyNameMax,\n L(\"Form:MaxLength\", postApiMultiTenancyTenantsBodyNameMax),\n ),\n adminEmailAddress: isCreate\n ? z\n .string()\n .trim()\n .min(1, L(\"Form:Required\"))\n .max(\n postApiMultiTenancyTenantsBodyAdminEmailAddressMax,\n L(\"Form:MaxLength\", postApiMultiTenancyTenantsBodyAdminEmailAddressMax),\n )\n .pipe(z.email(L(\"Form:InvalidEmail\")))\n : z.string(),\n adminPassword: isCreate\n ? z\n .string()\n .trim()\n .min(1, L(\"Form:Required\"))\n .max(\n postApiMultiTenancyTenantsBodyAdminPasswordMax,\n L(\"Form:MaxLength\", postApiMultiTenancyTenantsBodyAdminPasswordMax),\n )\n : z.string(),\n });\n },\n });\n\n const t = useAbpTable(tenantService, { columns, onOpen: sheet.open });\n const isCreate = sheet.mode === \"create\";\n\n return (\n <section className=\"space-y-4\">\n <h1 className=\"text-2xl font-normal\">{L(\"AbpTenantManagement::Tenants\")}</h1>\n <t.Table>\n <t.BulkBar>\n <t.BulkDelete />\n </t.BulkBar>\n </t.Table>\n <sheet.Sheet>\n <sheet.form.AppForm>\n <sheet.form.FormErrors />\n </sheet.form.AppForm>\n\n <sheet.form.AppField name=\"name\">\n {(field) => (\n <field.TextField\n label={L(\"AbpTenantManagement::TenantName\")}\n required\n disabled={sheet.readOnly}\n />\n )}\n </sheet.form.AppField>\n\n {isCreate && (\n <>\n <sheet.form.AppField name=\"adminEmailAddress\">\n {(field) => (\n <field.TextField\n label={L(\"AbpTenantManagement::DisplayName:AdminEmailAddress\")}\n type=\"email\"\n required\n />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"adminPassword\">\n {(field) => (\n <field.TextField\n label={L(\"AbpTenantManagement::DisplayName:AdminPassword\")}\n type=\"password\"\n required\n />\n )}\n </sheet.form.AppField>\n </>\n )}\n </sheet.Sheet>\n </section>\n );\n}\n",
|
|
57
57
|
"type": "registry:file",
|
|
58
58
|
"target": "src/routes/_layout/_authed/tenants/index.tsx"
|
|
59
59
|
},
|
package/public/r/app-shell.json
CHANGED
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
{
|
|
53
53
|
"path": "ui/blocks/app-shell/routes/-showcase/data-table-demo.tsx",
|
|
54
|
-
"content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { useMemo } from \"react\";\nimport { toast } from \"sonner\";\nimport { StatusBadge } from \"@/components/abp/table/status-badge\";\nimport { DataTable } from \"@/components/data-table/data-table\";\nimport { DataTableToolbar } from \"@/components/data-table/data-table-toolbar\";\nimport type { TableColumnDef } from \"@/components/data-table/table-core\";\nimport { useDataTable } from \"@/components/data-table/use-data-table\";\nimport { useDataTableState } from \"@/components/data-table/use-data-table-state\";\nimport { Button } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\n\ninterface BlockRow {\n name: string;\n files: number;\n status: \"stable\" | \"beta\";\n}\n\n// 拿脚手架自己的 block 当示例数据:块名是专有名词(照写),文件数演示排序,状态演示词条化枚举。\nconst BLOCKS: BlockRow[] = [\n { name: \"app-shell\", files: 8, status: \"stable\" },\n { name: \"abp-layout\", files: 6, status: \"stable\" },\n { name: \"abp-login\", files: 5, status: \"stable\" },\n { name: \"data-table\", files: 4, status: \"stable\" },\n { name: \"abp-table\", files: 6, status: \"stable\" },\n { name: \"form\", files: 8, status: \"stable\" },\n { name: \"tree\", files: 3, status: \"stable\" },\n { name: \"combobox\", files: 4, status: \"beta\" },\n { name: \"admin-pages\", files: 12, status: \"stable\" },\n { name: \"abp-permission-sheet\", files: 3, status: \"beta\" },\n { name: \"showcase\", files: 6, status: \"beta\" },\n { name: \"menu\", files: 1, status: \"stable\" },\n];\n\n/** data-table 展示:DataTable 是服务端分页语义(manualSorting/manualPagination),过滤/排序/切片都由外部算,这里用本地数组模拟一个「服务端」。 */\nexport function DataTableDemo() {\n const L = useLocalization();\n const state = useDataTableState();\n const filter = state.params.filter.trim().toLowerCase();\n\n const rows = useMemo(() => {\n const matched = BLOCKS.filter((r) => r.name.toLowerCase().includes(filter));\n const sort = state.params.sorting[0];\n if (sort?.id === \"files\") {\n matched.sort((a, b) => (sort.desc ? b.files - a.files : a.files - b.files));\n }\n return matched;\n }, [filter, state.params.sorting]);\n\n const start = state.params.pageIndex * state.params.pageSize;\n const page = rows.slice(start, start + state.params.pageSize);\n const pageCount = Math.max(Math.ceil(rows.length / state.params.pageSize), 1);\n\n const columns = useMemo<TableColumnDef<BlockRow>[]>(\n () => [\n { accessorKey: \"name\", header: () => L(\"Showcase:ColBlock\"), enableSorting: false },\n { accessorKey: \"files\", header: () => L(\"Showcase:ColFiles\") },\n {\n accessorKey: \"status\",\n header: () => L(\"Showcase:ColStatus\"),\n enableSorting: false,\n cell: ({ getValue }) => {\n const stable = getValue() === \"stable\";\n return (\n <StatusBadge status={stable ? \"success\" : \"info\"}>\n {stable ? L(\"Showcase:StatusStable\") : L(\"Showcase:StatusBeta\")}\n </StatusBadge>\n );\n },\n },\n ],\n [L],\n );\n\n const dt = useDataTable({\n state,\n columns,\n data: page,\n pageCount,\n rowCount: rows.length,\n selectable: true,\n getRowId: (row) => row.name,\n });\n\n const bulk = (\n <div className=\"flex min-h-8 flex-wrap items-center gap-3 text-sm\">\n
|
|
54
|
+
"content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { useMemo } from \"react\";\nimport { toast } from \"sonner\";\nimport { StatusBadge } from \"@/components/abp/table/status-badge\";\nimport { DataTable } from \"@/components/data-table/data-table\";\nimport { DataTableToolbar } from \"@/components/data-table/data-table-toolbar\";\nimport type { TableColumnDef } from \"@/components/data-table/table-core\";\nimport { useDataTable } from \"@/components/data-table/use-data-table\";\nimport { useDataTableState } from \"@/components/data-table/use-data-table-state\";\nimport { Button } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\n\ninterface BlockRow {\n name: string;\n files: number;\n status: \"stable\" | \"beta\";\n}\n\n// 拿脚手架自己的 block 当示例数据:块名是专有名词(照写),文件数演示排序,状态演示词条化枚举。\nconst BLOCKS: BlockRow[] = [\n { name: \"app-shell\", files: 8, status: \"stable\" },\n { name: \"abp-layout\", files: 6, status: \"stable\" },\n { name: \"abp-login\", files: 5, status: \"stable\" },\n { name: \"data-table\", files: 4, status: \"stable\" },\n { name: \"abp-table\", files: 6, status: \"stable\" },\n { name: \"form\", files: 8, status: \"stable\" },\n { name: \"tree\", files: 3, status: \"stable\" },\n { name: \"combobox\", files: 4, status: \"beta\" },\n { name: \"admin-pages\", files: 12, status: \"stable\" },\n { name: \"abp-permission-sheet\", files: 3, status: \"beta\" },\n { name: \"showcase\", files: 6, status: \"beta\" },\n { name: \"menu\", files: 1, status: \"stable\" },\n];\n\n/** data-table 展示:DataTable 是服务端分页语义(manualSorting/manualPagination),过滤/排序/切片都由外部算,这里用本地数组模拟一个「服务端」。 */\nexport function DataTableDemo() {\n const L = useLocalization();\n const state = useDataTableState();\n const filter = state.params.filter.trim().toLowerCase();\n\n const rows = useMemo(() => {\n const matched = BLOCKS.filter((r) => r.name.toLowerCase().includes(filter));\n const sort = state.params.sorting[0];\n if (sort?.id === \"files\") {\n matched.sort((a, b) => (sort.desc ? b.files - a.files : a.files - b.files));\n }\n return matched;\n }, [filter, state.params.sorting]);\n\n const start = state.params.pageIndex * state.params.pageSize;\n const page = rows.slice(start, start + state.params.pageSize);\n const pageCount = Math.max(Math.ceil(rows.length / state.params.pageSize), 1);\n\n const columns = useMemo<TableColumnDef<BlockRow>[]>(\n () => [\n { accessorKey: \"name\", header: () => L(\"Showcase:ColBlock\"), enableSorting: false },\n { accessorKey: \"files\", header: () => L(\"Showcase:ColFiles\") },\n {\n accessorKey: \"status\",\n header: () => L(\"Showcase:ColStatus\"),\n enableSorting: false,\n cell: ({ getValue }) => {\n const stable = getValue() === \"stable\";\n return (\n <StatusBadge status={stable ? \"success\" : \"info\"}>\n {stable ? L(\"Showcase:StatusStable\") : L(\"Showcase:StatusBeta\")}\n </StatusBadge>\n );\n },\n },\n ],\n [L],\n );\n\n const dt = useDataTable({\n state,\n columns,\n data: page,\n pageCount,\n rowCount: rows.length,\n selectable: true,\n getRowId: (row) => row.name,\n });\n\n const bulk = (\n <dt.SelectedCount>\n {(count) => (\n <div className=\"flex min-h-8 flex-wrap items-center gap-3 text-sm\">\n <span className=\"text-muted-foreground\">{L(\"Table:NSelected\", count)}</span>\n <Separator orientation=\"vertical\" className=\"!h-4\" />\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => toast.success(L(\"Showcase:BulkExport\", count))}\n >\n {L(\"Showcase:BulkExport\", count)}\n </Button>\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={dt.clearSelection}>\n {L(\"Table:Clear\")}\n </Button>\n </div>\n )}\n </dt.SelectedCount>\n );\n\n return (\n <DataTable table={dt}>\n <DataTableToolbar table={dt} searchPlaceholder={L(\"Showcase:SearchBlocks\")} bulk={bulk} />\n </DataTable>\n );\n}\n",
|
|
55
55
|
"type": "registry:file",
|
|
56
56
|
"target": "src/routes/-showcase/data-table-demo.tsx"
|
|
57
57
|
},
|
package/public/r/data-table.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"title": "Server-side Data Table",
|
|
5
5
|
"description": "Generic paginated/sortable/searchable data table with debounced search, driven by @tanstack/react-table state.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@tanstack/react-table@9.
|
|
7
|
+
"@tanstack/react-table@9.2.4",
|
|
8
8
|
"@jcoder-stack/abp-react",
|
|
9
9
|
"lucide-react"
|
|
10
10
|
],
|
|
@@ -30,25 +30,25 @@
|
|
|
30
30
|
},
|
|
31
31
|
{
|
|
32
32
|
"path": "ui/blocks/data-table/table-core.ts",
|
|
33
|
-
"content": "import {\n type ColumnDef,\n columnVisibilityFeature,\n createColumnHelper,\n type ReactTable,\n type RowData,\n rowPaginationFeature,\n rowSelectionFeature,\n rowSortingFeature,\n type Table,\n tableFeatures,\n} from \"@tanstack/react-table\";\nimport type { ReactNode } from \"react\";\n\n/** 基础特性映射;供 DataTable
|
|
33
|
+
"content": "import {\n type ColumnDef,\n columnVisibilityFeature,\n createColumnHelper,\n type ReactTable,\n type RowData,\n rowPaginationFeature,\n rowSelectionFeature,\n rowSortingFeature,\n type Table,\n type TableState,\n tableFeatures,\n} from \"@tanstack/react-table\";\nimport type { ReactNode } from \"react\";\n\n/** 列 `meta` 的形状。经 `baseFeatureMap` 的 `columnMeta` 槽按表声明,不走全局 `declare module`:\n * 本文件是 copy-in 分发的,全局增广会污染下游项目的类型空间,下游再增广同一接口即冲突。 */\nexport interface TableColumnMeta {\n align?: \"left\" | \"right\" | \"center\";\n className?: string;\n label?: ReactNode;\n}\n\n/** 基础特性映射;供 DataTable 与调用方追加特性时做加法合并。\n *\n * `columnMeta` 必须挂在这里而不是 `tableFeatures()` 调用处:`useDataTable` 用\n * `tableFeatures({ ...baseFeatureMap, ...opts.features })` 做加法合并,槽写在调用处的话,\n * 调用方一传 `features` 就把它挤掉了。挂在 map 上则天然幸存,并顺带给下游一个扩展点——\n * 传 `columnMeta: {} as TableColumnMeta & { mine: X }` 即可加自己的键。 */\nexport const baseFeatureMap = {\n rowSortingFeature,\n rowPaginationFeature,\n columnVisibilityFeature,\n rowSelectionFeature,\n // 幻影值:运行时被剥离,只有类型参与推断。\n columnMeta: {} as TableColumnMeta,\n};\n\n/** 全表共用的 v9 特性集;少注册一个对应方法即编译期消失。 */\nexport const features = tableFeatures(baseFeatureMap);\n\nexport type TableFeatures = typeof features;\n\n/** `useDataTable` 让宿主订阅的状态投影。少了 `rowSelection`:那一片由表头、行、`SelectedCount`\n * 各自定点订阅,宿主不为勾选重渲染,所以 `table.state` 里也读不到它——要读走\n * `table.atoms.rowSelection.get()`(快照)或 `Subscribe`(订阅)。 */\nexport type HostTableState = Omit<TableState<TableFeatures>, \"rowSelection\">;\n\n/** DataTable 内部构造的表实例类型(按基础特性定型)。`useDataTable()` 的调用方在自己的组件\n * 作用域里直接拿到它(`dt.table`);`DataTable` 的 `footer` 回调另外单独给一份。 */\nexport type TableInstance<TData extends RowData> = ReactTable<TableFeatures, TData, HostTableState>;\n\n/** 列 `cell` 上下文给出的表对象类型,比 `TableInstance` 窄。`ReactTable` 是\n * `Omit<Table, \"store\"> & { store, state, Subscribe, FlexRender }`,而 `state`/`Subscribe`/\n * `FlexRender` 只挂在 `useTable()` 另行 memo 出的返回对象上,cell 上下文永远拿不到。故行内插槽\n * (`AbpTableRowConfig` 的 `actions`/`menu`,喂给 `RowActionsMenu` 的 `rowActions`/`items` 属性)\n * 一律用这个类型,宁可少承诺也不给说谎的类型;\n * 要读当前状态走 `table.atoms.<slice>.get()`(快照),要订阅用从 `@tanstack/react-table`\n * 直接导入的 `Subscribe` 配 `source={table.atoms.<slice>}`——`table.state` / `table.Subscribe`\n * 只挂在 `useTable()` 的返回对象上,这里没有;`table.store` 在 v9 已废弃,勿用。要完整\n * 实例走调用方自己持有的 `dt.table` 或 `DataTable` 的 `footer` 回调。 */\nexport type CellTableInstance<TData extends RowData> = Table<TableFeatures, TData>;\n\n/** 绑定好 TFeatures 的列定义别名,消费方只写这个,不感知泛型。 */\nexport type TableColumnDef<TData extends RowData> = ColumnDef<TableFeatures, TData, unknown>;\n\nexport const createTableColumnHelper = <TData extends RowData>() =>\n createColumnHelper<TableFeatures, TData>();\n",
|
|
34
34
|
"type": "registry:component",
|
|
35
35
|
"target": "components/data-table/table-core.ts"
|
|
36
36
|
},
|
|
37
37
|
{
|
|
38
38
|
"path": "ui/blocks/data-table/use-data-table-state.ts",
|
|
39
|
-
"content": "import type {
|
|
39
|
+
"content": "import type { PaginationState, SortingState, Updater } from \"@tanstack/react-table\";\nimport { useCallback, useState } from \"react\";\n\nexport type TableDensity = \"comfortable\" | \"compact\";\n\n/**\n * 服务端分页表格的状态机:分页/排序/已提交搜索值;filter 为已提交的搜索值,\n * 输入框即时值与防抖由工具条自己持有(见 DataTableToolbar),状态机只接收提交结果。\n * 行选择不在这里——所有权在表实例上(`table.atoms.rowSelection`),经 `useDataTable` 暴露。\n * 结构化查询参数不归这里管,那是 useAbpTable 的表单实例自己持有的东西。\n */\nexport function useDataTableState(opts: { defaultPageSize?: number } = {}) {\n const [pagination, setPagination] = useState<PaginationState>({\n pageIndex: 0,\n pageSize: opts.defaultPageSize ?? 10,\n });\n const [sorting, setSorting] = useState<SortingState>([]);\n const [filter, setFilter] = useState(\"\");\n const [density, setDensity] = useState<TableDensity>(\"comfortable\");\n\n // 「当前这一页的语境」的版本号。resetPaging 每次都递增,哪怕 pageIndex 写回同值:\n // 用户本来就在第 1 页时分页/排序/搜索三者可以一起纹丝不动(查询面板提交只改结构化参数),\n // 光比这三样看不出语境换过,页内选择就会跨语境残留。选中态归表所有,清空动作在\n // useDataTable 里按这个版本号触发(见那边的 scopeKey effect)。\n const [scopeEpoch, setScopeEpoch] = useState(0);\n\n const resetPaging = useCallback(() => {\n // 新查询从第一页开始,否则会停留在越界页\n setPagination((p) => ({ ...p, pageIndex: 0 }));\n setScopeEpoch((n) => n + 1);\n }, []);\n\n /** 提交搜索值:写入已提交的 filter 并回到第 1 页。防抖由调用方负责——\n * 输入节奏是输入框自己的事,放进状态机就必须把即时值也留在页面级,\n * 那正是「敲一个字母整表重画」的来源。 */\n const commitSearch = useCallback(\n (value: string) => {\n setFilter(value);\n resetPaging();\n },\n [resetPaging],\n );\n\n const onPaginationChange = useCallback((updater: Updater<PaginationState>) => {\n setPagination(updater);\n }, []);\n const onSortingChange = useCallback((updater: Updater<SortingState>) => {\n setSorting(updater);\n }, []);\n\n return {\n params: { pageIndex: pagination.pageIndex, pageSize: pagination.pageSize, sorting, filter },\n pagination,\n sorting,\n scopeEpoch,\n commitSearch,\n resetPaging,\n onPaginationChange,\n onSortingChange,\n density,\n setDensity,\n };\n}\n\nexport type DataTableState = ReturnType<typeof useDataTableState>;\n",
|
|
40
40
|
"type": "registry:component",
|
|
41
41
|
"target": "components/data-table/use-data-table-state.ts"
|
|
42
42
|
},
|
|
43
43
|
{
|
|
44
44
|
"path": "ui/blocks/data-table/use-data-table.ts",
|
|
45
|
-
"content": "import { type Localize, useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport {\n type RowData,\n type RowSelectionState,\n type TableOptions,\n tableFeatures,\n useTable,\n} from \"@tanstack/react-table\";\nimport { createElement, type MouseEvent, useEffect, useMemo, useRef } from \"react\";\nimport { devWarn } from \"@/components/data-table/dev-warn\";\nimport {\n baseFeatureMap,\n features,\n type TableColumnDef,\n type TableFeatures,\n type TableInstance,\n} from \"@/components/data-table/table-core\";\nimport {\n type DataTableState,\n useDataTableState,\n} from \"@/components/data-table/use-data-table-state\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\n\n/** 共享的空选择态。写成 `?? {}` 每渲染都新建对象,TanStack 按身份比对会当成受控 state 变了,\n * 在「未受控 rowSelection + 其它受控 prop 变化」的组合下自持重渲染。 */\nconst EMPTY_ROW_SELECTION: RowSelectionState = {};\n\n/** 页内选择列:表头全选(含 indeterminate),行内单选;checked→事件由调用方 handler 消费 `event.target.checked`。 */\nfunction selectionColumn<TData extends RowData>(L: Localize): TableColumnDef<TData> {\n return {\n id: \"select\",\n enableSorting: false,\n // 组件注入的列不进 Columns 菜单:藏掉它只会让「已选 N 项」无从取消,且菜单里会出现无标签项\n enableHiding: false,\n meta: { className: \"w-10\" },\n header: ({ table }) =>\n createElement(Checkbox, {\n checked: table.getIsAllPageRowsSelected()\n ? true\n : table.getIsSomePageRowsSelected()\n ? \"indeterminate\"\n : false,\n onCheckedChange: (value: boolean | \"indeterminate\") =>\n table.getToggleAllPageRowsSelectedHandler()({ target: { checked: value === true } }),\n \"aria-label\": L(\"Table:SelectAll\"),\n // 半选只隐藏对勾,保留 indeterminate。对勾表示「全选」,拿它表示「部分」会误导;\n // aria-checked=\"mixed\" 得留着,否则读屏会把「已选 2 行」播报成「未选中」。\n // 选了几行由紧邻上方的批量条给出。\n // tree 块的 TriStateCheckbox 半选画横线,跟这里不一样,别当不一致来「修」:\n // 那边把「部分授予」误读成「全部授予」是授权问题,这里只影响感知,不值得为它加依赖。\n className: \"data-[state=indeterminate]:[&_[data-slot=checkbox-indicator]]:opacity-0\",\n }),\n cell: ({ row }) =>\n createElement(Checkbox, {\n checked: row.getIsSelected(),\n onCheckedChange: (value: boolean | \"indeterminate\") =>\n row.getToggleSelectedHandler()({ target: { checked: value === true } }),\n onClick: (e: MouseEvent) => e.stopPropagation(),\n \"aria-label\": L(\"Table:SelectRow\"),\n }),\n };\n}\n\nexport interface UseDataTableOptions<TData extends RowData> {\n /** 分页/排序/选择状态机;省略则内部自建(纯客户端数据场景,不需要与外部查询参数联动)。 */\n state?: DataTableState;\n /** 列定义,引用必须稳定。`useTable` 每渲染收到新数组会重建列模型,上层所有以 columns\n * 为依赖的 memo 也跟着失效。`header` 里要用 `L()` 就包一层 `useMemo(() => [...], [L])`,\n * `L` 的引用是稳的,等价于永久 memo。违反时 DEV 期会告警。 */\n columns: TableColumnDef<TData>[];\n data: TData[];\n pageCount?: number;\n rowCount?: number;\n selectable?: boolean;\n getRowId?: (row: TData, index: number) => string;\n /** 追加 TanStack 特性,与基础集加法合并。引用必须稳定,建议用模块级常量:`useTable` 只在\n * 挂载时构造一次表实例,特性在那时注册完,之后再变会被忽略,也不会触发重建。\n *\n * 注册不等于渲染。注册只让特性的状态和 API 可用,`DataTable` 的表现层不会自动跟上:\n * 注册 `columnResizingFeature` 后表头里没有 resize handle,注册 `rowExpandingFeature`\n * 后表体也只有 `row.getVisibleCells()`。要让它有画面就得改 `data-table.tsx`,\n * 这正是 copy-in 分发的用法。 */\n features?: Parameters<typeof tableFeatures>[0];\n /** 透传给 useTable,展开在受管选项之后,同名键调用方赢,接管即自负。\n * `features` 走专门的 prop,类型层禁止在这里传,免得打破加法合并的不变式。 */\n tableOptions?: Omit<Partial<TableOptions<TableFeatures, TData>>, \"features\">;\n}\n\nexport interface DataTableInstance<TData extends RowData> {\n table: TableInstance<TData>;\n state: DataTableState;\n selectedRows: TData[];\n /** 只保留这些行选中;部分失败时回填用。ids 是 `getRowId` 产出的行 ID。 */\n keepSelected: (ids: string[]) => void;\n pageCount: number;\n rowCount?: number;\n selectable: boolean;\n}\n\n/** 建 TanStack 表实例:合成勾选列、注册特性、绑定状态机、剪枝离场行的选中态。 */\nexport function useDataTable<TData extends RowData>(\n opts: UseDataTableOptions<TData>,\n): DataTableInstance<TData> {\n const L = useLocalization();\n // hooks 必须无条件调用:外部没给 state 也要建内部状态机,只是外部给了就不用\n const internalState = useDataTableState();\n const state = opts.state ?? internalState;\n\n const columns = useMemo(\n () => (opts.selectable ? [selectionColumn<TData>(L), ...opts.columns] : opts.columns),\n [opts.selectable, opts.columns, L],\n );\n const tableFeatureSet = useMemo(\n () => (opts.features ? tableFeatures({ ...baseFeatureMap, ...opts.features }) : features),\n [opts.features],\n );\n // 引用比较:features 的契约本就是「必须引用稳定」,引用变本身即误用,无需比较内容。\n const mountedFeatures = useRef(opts.features);\n useEffect(() => {\n if (mountedFeatures.current !== opts.features) {\n devWarn(\n \"data-table:features-changed\",\n \"data-table: features prop 在挂载后改变了引用,新值已被忽略。useTable 只在挂载时构造\" +\n \"表实例、特性注册在构造时完成,挂载后的变更不会触发重建。请把它提成模块级常量。\",\n );\n }\n }, [opts.features]);\n const prevColumns = useRef(opts.columns);\n const columnsChurn = useRef(0);\n useEffect(() => {\n if (!import.meta.env.DEV) return;\n columnsChurn.current = prevColumns.current === opts.columns ? 0 : columnsChurn.current + 1;\n prevColumns.current = opts.columns;\n if (columnsChurn.current >= 3) {\n devWarn(\n \"data-table:columns-churn\",\n \"data-table: columns 每次渲染都是新数组,表实例的列模型与上层所有以 columns 为依赖的 \" +\n \"memo 会全部失效。用 useMemo(() => [...], [L]) 包住——useLocalization 的 L 引用稳定,\" +\n \"等价于永久 memo。\",\n );\n }\n });\n const table = useTable({\n features: tableFeatureSet,\n data: opts.data,\n columns,\n pageCount: opts.pageCount ?? 1,\n onPaginationChange: state.onPaginationChange,\n onSortingChange: state.onSortingChange,\n onRowSelectionChange: state.onRowSelectionChange,\n enableRowSelection: opts.selectable ?? false,\n manualPagination: true,\n manualSorting: true,\n getRowId: opts.getRowId,\n ...opts.tableOptions,\n // 一层合并而非整体覆盖:tableOptions.state 只能按键覆盖/追加受管切片,\n // 不会把 pagination/sorting/rowSelection 整体挤掉造成组件渲染与表状态失步。\n state: {\n pagination: state.pagination,\n sorting: state.sorting,\n rowSelection: state.rowSelection ?? EMPTY_ROW_SELECTION,\n ...opts.tableOptions?.state,\n },\n });\n\n // 行离场(删除后 refetch、外部 invalidate)时剔除对应的选中 id。选中数有两个口径:\n // 批量条显隐看 state 键数,内容看在场行数,不剪枝就会分裂出「已选 0 项」的幽灵批量条。\n // 剪枝只看当前 data,跟 keepPreviousData 天然兼容,fetching 期间旧行还在场,不会误清。\n const { data, getRowId } = opts;\n const { rowSelection, onRowSelectionChange } = state;\n useEffect(() => {\n const selected = Object.keys(rowSelection);\n if (selected.length === 0) return;\n const present = new Set(data.map((row, index) => getRowId?.(row, index) ?? String(index)));\n if (selected.every((id) => present.has(id))) return;\n onRowSelectionChange(\n Object.fromEntries(Object.entries(rowSelection).filter(([id]) => present.has(id))),\n );\n }, [data, rowSelection, onRowSelectionChange, getRowId]);\n\n const selectedRows = table.getSelectedRowModel().rows.map((r) => r.original);\n\n return {\n table,\n state,\n selectedRows,\n keepSelected: state.keepSelected,\n pageCount: opts.pageCount ?? 1,\n rowCount: opts.rowCount,\n selectable: opts.selectable ?? false,\n };\n}\n",
|
|
45
|
+
"content": "import { type Localize, useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport {\n type RowData,\n Subscribe,\n type TableOptions,\n tableFeatures,\n useTable,\n} from \"@tanstack/react-table\";\nimport {\n createElement,\n type MouseEvent,\n type ReactNode,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n} from \"react\";\nimport { devWarn } from \"@/components/data-table/dev-warn\";\nimport {\n baseFeatureMap,\n features,\n type TableColumnDef,\n type TableFeatures,\n type TableInstance,\n} from \"@/components/data-table/table-core\";\nimport {\n type DataTableState,\n useDataTableState,\n} from \"@/components/data-table/use-data-table-state\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\n\ntype SelectionAtom = TableInstance<RowData>[\"atoms\"][\"rowSelection\"];\ntype SelectionState = ReturnType<SelectionAtom[\"get\"]>;\n\n/** `Subscribe` 的「单一 source + selector」那条重载对应的 props 形状。\n * 本文件是 .ts(文件名进了 registry 清单),写不了 JSX,只能 createElement;而\n * createElement 会把重载塌成最后一条(全 store,不收 source),显式给出 props 才选得对。 */\ninterface SelectionSubscribeProps<TSelected> {\n source: SelectionAtom;\n selector: (state: SelectionState) => TSelected;\n children: (selected: TSelected) => ReactNode;\n}\n\n/** 同上,对应「单一 source、无 selector」那条重载:整片选中映射按浅比较驱动重渲染。 */\ninterface SelectionIdentitySubscribeProps {\n source: SelectionAtom;\n selector?: undefined;\n children: (state: SelectionState) => ReactNode;\n}\n\n/** 页内选择列:表头全选(含 indeterminate),行内单选;checked→事件由调用方 handler 消费 `event.target.checked`。\n *\n * 两个复选框都包在 `Subscribe` 里而不是渲染期直接读:宿主的 `useTable` selector 已把\n * rowSelection 摘出订阅,勾选不再重渲染 DataTable,渲染期读到的是永远停在初始态的快照。\n * 用独立导出的 `Subscribe` 而非 `table.Subscribe`——列的 header/cell 上下文给的是核心\n * `Table`,`Subscribe` 只挂在 `useTable` 另行 memo 出的返回对象上,这里类型上拿不到。\n * 本文件统一用它(含下方的 `SelectedCount`):`table.Subscribe` 只是同一组件补了个默认\n * source,我们每处都显式给 source,同名两份反而只会在阅读时混淆。\n *\n * cell 这份订阅与 `data-table.tsx` 的 `DataTableRow` 那份在当前组合下是重叠的——整行已被\n * 包在同样的订阅里,删掉这一份测试也全绿。重叠是有意的:本列定义要能独立成立,契约不该是\n * 「只有被 `DataTableRow` 包着才会刷新」,而 `UseDataTableOptions.features` 的 TSDoc 正教着\n * 下游去改 `data-table.tsx` 的表体。代价只是每行多一个订阅。 */\nfunction selectionColumn<TData extends RowData>(L: Localize): TableColumnDef<TData> {\n return {\n id: \"select\",\n enableSorting: false,\n // 组件注入的列不进 Columns 菜单:藏掉它只会让「已选 N 项」无从取消,且菜单里会出现无标签项\n enableHiding: false,\n meta: { className: \"w-10\" },\n header: ({ table }) =>\n createElement<SelectionIdentitySubscribeProps>(Subscribe, {\n source: table.atoms.rowSelection,\n // 整片订阅而非投影:全选框的三态还取决于当前页在场哪些行,selector 只拿得到选中映射,\n // 算不出来;三态在回调里现算,那两个 getter 都直接读原子,读到的是最新值。\n // biome-ignore lint/correctness/noChildrenProp: Subscribe 的 children 是 render prop,createElement 的可变子参数只收 ReactNode,塞不进去\n children: () =>\n createElement(Checkbox, {\n checked: table.getIsAllPageRowsSelected()\n ? true\n : table.getIsSomePageRowsSelected()\n ? \"indeterminate\"\n : false,\n onCheckedChange: (value: boolean | \"indeterminate\") =>\n table.getToggleAllPageRowsSelectedHandler()({ target: { checked: value === true } }),\n \"aria-label\": L(\"Table:SelectAll\"),\n // 半选只隐藏对勾,保留 indeterminate。对勾表示「全选」,拿它表示「部分」会误导;\n // aria-checked=\"mixed\" 得留着,否则读屏会把「已选 2 行」播报成「未选中」。\n // 选了几行由紧邻上方的批量条给出。\n // tree 块的 TriStateCheckbox 半选画横线,跟这里不一样,别当不一致来「修」:\n // 那边把「部分授予」误读成「全部授予」是授权问题,这里只影响感知,不值得为它加依赖。\n className: \"data-[state=indeterminate]:[&_[data-slot=checkbox-indicator]]:opacity-0\",\n }),\n }),\n cell: ({ row, table }) =>\n createElement<SelectionSubscribeProps<boolean>>(Subscribe, {\n source: table.atoms.rowSelection,\n selector: (s) => Boolean(s?.[row.id]),\n // biome-ignore lint/correctness/noChildrenProp: 同上\n children: (selected) =>\n createElement(Checkbox, {\n checked: selected,\n onCheckedChange: (value: boolean | \"indeterminate\") =>\n row.getToggleSelectedHandler()({ target: { checked: value === true } }),\n onClick: (e: MouseEvent) => e.stopPropagation(),\n \"aria-label\": L(\"Table:SelectRow\"),\n }),\n }),\n };\n}\n\nexport interface UseDataTableOptions<TData extends RowData> {\n /** 分页/排序/选择状态机;省略则内部自建(纯客户端数据场景,不需要与外部查询参数联动)。 */\n state?: DataTableState;\n /** 列定义,引用必须稳定。`useTable` 每渲染收到新数组会重建列模型,上层所有以 columns\n * 为依赖的 memo 也跟着失效。`header` 里要用 `L()` 就包一层 `useMemo(() => [...], [L])`,\n * `L` 的引用是稳的,等价于永久 memo。违反时 DEV 期会告警。 */\n columns: TableColumnDef<TData>[];\n data: TData[];\n pageCount?: number;\n rowCount?: number;\n selectable?: boolean;\n getRowId?: (row: TData, index: number) => string;\n /** 追加 TanStack 特性,与基础集加法合并。引用必须稳定,建议用模块级常量:`useTable` 只在\n * 挂载时构造一次表实例,特性在那时注册完,之后再变会被忽略,也不会触发重建。\n *\n * 注册不等于渲染。注册只让特性的状态和 API 可用,`DataTable` 的表现层不会自动跟上:\n * 注册 `columnResizingFeature` 后表头里没有 resize handle,注册 `rowExpandingFeature`\n * 后表体也只有 `row.getVisibleCells()`。要让它有画面就得改 `data-table.tsx`,\n * 这正是 copy-in 分发的用法。 */\n features?: Parameters<typeof tableFeatures>[0];\n /** 透传给 useTable,展开在受管选项之后,同名键调用方赢,接管即自负。\n * `features` 走专门的 prop,类型层禁止在这里传,免得打破加法合并的不变式。 */\n tableOptions?: Omit<Partial<TableOptions<TableFeatures, TData>>, \"features\">;\n}\n\nexport interface DataTableInstance<TData extends RowData> {\n table: TableInstance<TData>;\n state: DataTableState;\n /** 当前选中行,调用时取值。选中态归表所有,页面不再随它重渲染,\n * 渲染期读到的快照会陈旧——只在动作处理函数里调它。 */\n getSelectedRows: () => TData[];\n clearSelection: () => void;\n /** 只保留这些行选中;部分失败时回填用。ids 是 `getRowId` 产出的行 ID。 */\n keepSelected: (ids: string[]) => void;\n /** 订阅选中态并把计数交给 children,用于渲染期显示计数。 */\n SelectedCount: (props: { children: (count: number) => ReactNode }) => ReactNode;\n pageCount: number;\n rowCount?: number;\n selectable: boolean;\n}\n\n/** 建 TanStack 表实例:合成勾选列、注册特性、绑定状态机、剪枝离场行的选中态。 */\nexport function useDataTable<TData extends RowData>(\n opts: UseDataTableOptions<TData>,\n): DataTableInstance<TData> {\n const L = useLocalization();\n // hooks 必须无条件调用:外部没给 state 也要建内部状态机,只是外部给了就不用\n const internalState = useDataTableState();\n const state = opts.state ?? internalState;\n\n const columns = useMemo(\n () => (opts.selectable ? [selectionColumn<TData>(L), ...opts.columns] : opts.columns),\n [opts.selectable, opts.columns, L],\n );\n const tableFeatureSet = useMemo(\n () => (opts.features ? tableFeatures({ ...baseFeatureMap, ...opts.features }) : features),\n [opts.features],\n );\n // 引用比较:features 的契约本就是「必须引用稳定」,引用变本身即误用,无需比较内容。\n const mountedFeatures = useRef(opts.features);\n useEffect(() => {\n if (mountedFeatures.current !== opts.features) {\n devWarn(\n \"data-table:features-changed\",\n \"data-table: features prop 在挂载后改变了引用,新值已被忽略。useTable 只在挂载时构造\" +\n \"表实例、特性注册在构造时完成,挂载后的变更不会触发重建。请把它提成模块级常量。\",\n );\n }\n }, [opts.features]);\n const prevColumns = useRef(opts.columns);\n const columnsChurn = useRef(0);\n useEffect(() => {\n if (!import.meta.env.DEV) return;\n columnsChurn.current = prevColumns.current === opts.columns ? 0 : columnsChurn.current + 1;\n prevColumns.current = opts.columns;\n if (columnsChurn.current >= 3) {\n devWarn(\n \"data-table:columns-churn\",\n \"data-table: columns 每次渲染都是新数组,表实例的列模型与上层所有以 columns 为依赖的 \" +\n \"memo 会全部失效。用 useMemo(() => [...], [L]) 包住——useLocalization 的 L 引用稳定,\" +\n \"等价于永久 memo。\",\n );\n }\n });\n const table = useTable(\n {\n features: tableFeatureSet,\n data: opts.data,\n columns,\n pageCount: opts.pageCount ?? 1,\n onPaginationChange: state.onPaginationChange,\n onSortingChange: state.onSortingChange,\n enableRowSelection: opts.selectable ?? false,\n manualPagination: true,\n manualSorting: true,\n getRowId: opts.getRowId,\n ...opts.tableOptions,\n // 一层合并而非整体覆盖:tableOptions.state 只能按键覆盖/追加受管切片,\n // 不会把 pagination/sorting 整体挤掉造成组件渲染与表状态失步。\n // rowSelection 刻意缺席:那一片归表自己所有,见下方的 SelectedCount / getSelectedRows。\n state: {\n pagination: state.pagination,\n sorting: state.sorting,\n ...opts.tableOptions?.state,\n },\n },\n // 省略 selector 等于订阅全部切片,勾一个复选框就要重渲染整张表,成本随行数放大。\n // 摘掉 rowSelection 之后它由表头/行内/SelectedCount 各自定点订阅,宿主不再为它重渲染。\n // 用排除法而非列举需要的切片:features 是加法合并的,调用方追加特性带来的切片\n // (如 columnFilteringFeature 的 columnFilters)不在列举里就永远不触发重渲染,\n // 他们的筛选 UI 会静默失灵。返回新对象不会每渲染都重渲染——结果走浅比较。\n (tableState) => {\n const { rowSelection: _rowSelection, ...rest } = tableState;\n return rest;\n },\n );\n\n // 全部取自表实例但绕开 table 本身:useTable 每渲染都返回一份新的表对象(options 是渲染期\n // 新建的),拿 table 当依赖等于没 memo;而这些方法与原子建表时就定下,引用恒定。\n const { getSelectedRowModel, resetRowSelection, setRowSelection } = table;\n const rowSelectionAtom = table.atoms.rowSelection;\n\n // 翻页/排序/换查询语境会换一批在场行,页内选择随之作废。这条不变式原先在状态机里,\n // 但那里拿不到表实例;所有权交给表之后只能在这里承接。\n // scopeEpoch 不可省:搜索与查询面板提交都走 resetPaging,用户本来就在第 1 页时\n // 分页与排序可以一起纹丝不动,只比这两样会让选中态跨查询语境残留。\n const { pageIndex, pageSize } = state.pagination;\n const scopeKey = `${pageIndex}:${pageSize}:${state.scopeEpoch}:${state.sorting.map((s) => `${s.id}:${s.desc}`).join(\",\")}`;\n const lastScope = useRef(scopeKey);\n useEffect(() => {\n // 挂载帧不算「翻页/排序变了」:不跳过就会在首次 effect 里清一次选择,把挂载前经\n // keepSelected 回填的结果立刻抹掉。用「上次作用域」而非「是不是首帧」记这件事,\n // 顺带扛住依赖抖动——resetRowSelection 每次写入的都是新对象,多跑一次就再触发一次\n // 渲染,真被无谓地重跑就是死循环。\n if (lastScope.current === scopeKey) return;\n lastScope.current = scopeKey;\n resetRowSelection();\n }, [scopeKey, resetRowSelection]);\n\n // 行离场(删除后 refetch、外部 invalidate)时剔除对应的选中 id。选中数有两个口径:\n // 批量条显隐看 state 键数,内容看在场行数,不剪枝就会分裂出「已选 0 项」的幽灵批量条。\n // 剪枝只看当前 data,跟 keepPreviousData 天然兼容,fetching 期间旧行还在场,不会误清。\n useEffect(() => {\n // 从原子读而非从渲染期快照读:上面那条清空 effect 先跑,翻页同时换数据时快照已经过期。\n // table.atoms 的键是必需的(Atoms<TFeatures> 用了 -?),rowSelectionFeature 已在\n // baseFeatureMap 里注册,所以这里不需要可选链;?? {} 只兜住切片值本身的 undefined。\n const current = rowSelectionAtom.get() ?? {};\n const selected = Object.keys(current);\n if (selected.length === 0) return;\n const present = new Set(\n opts.data.map((row, index) => opts.getRowId?.(row, index) ?? String(index)),\n );\n if (selected.every((id) => present.has(id))) return;\n setRowSelection(Object.fromEntries(Object.entries(current).filter(([id]) => present.has(id))));\n }, [opts.data, opts.getRowId, rowSelectionAtom, setRowSelection]);\n\n const getSelectedRows = useCallback(\n () => getSelectedRowModel().rows.map((r) => r.original),\n [getSelectedRowModel],\n );\n const clearSelection = useCallback(() => resetRowSelection(), [resetRowSelection]);\n const keepSelected = useCallback(\n (ids: string[]) => setRowSelection(Object.fromEntries(ids.map((id) => [id, true]))),\n [setRowSelection],\n );\n\n /** 订阅选中态并把计数交给 children。用它而不是在渲染期读快照——\n * 所有权在表里之后,页面不再因选中变化重渲染,快照会陈旧。\n * 组件身份必须稳定,否则每渲染都是一个新组件类型,React 会把它整棵子树卸载重挂\n * (工具条要把搜索框放进它的 children,重挂就丢焦点和已输入内容)。 */\n const SelectedCount = useMemo(\n () =>\n function SelectedCount(p: { children: (count: number) => ReactNode }) {\n return createElement<SelectionSubscribeProps<number>>(Subscribe, {\n source: rowSelectionAtom,\n selector: (s) => Object.keys(s ?? {}).length,\n // biome-ignore lint/correctness/noChildrenProp: Subscribe 的 children 是 render prop(收计数返回节点),createElement 的可变子参数只收 ReactNode,塞不进去\n children: p.children,\n });\n },\n [rowSelectionAtom],\n );\n\n return {\n table,\n state,\n getSelectedRows,\n clearSelection,\n keepSelected,\n SelectedCount,\n pageCount: opts.pageCount ?? 1,\n rowCount: opts.rowCount,\n selectable: opts.selectable ?? false,\n };\n}\n",
|
|
46
46
|
"type": "registry:component",
|
|
47
47
|
"target": "components/data-table/use-data-table.ts"
|
|
48
48
|
},
|
|
49
49
|
{
|
|
50
50
|
"path": "ui/blocks/data-table/data-table.tsx",
|
|
51
|
-
"content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { flexRender, type PaginationState, type RowData } from \"@tanstack/react-table\";\nimport { ChevronDown, ChevronsUpDown, ChevronUp } from \"lucide-react\";\nimport { type ReactNode, useId } from \"react\";\nimport { DataTableFooter } from \"@/components/data-table/data-table-footer\";\nimport type { TableInstance } from \"@/components/data-table/table-core\";\nimport type { DataTableInstance } from \"@/components/data-table/use-data-table\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from \"@/components/ui/table\";\nimport { cn } from \"@/lib/utils\";\n\nconst DEFAULT_SKELETON_ROWS = 5;\n\nfunction alignClass(align?: \"left\" | \"right\" | \"center\") {\n // 右对齐即数字列(DESIGN.md):一并给等宽数字,否则 Inter 的比例数字宽窄不一,\n // 同列上下对不齐、跨组件(分页/页码)看着像换了字体。\n if (align === \"right\") return \"text-right tabular-nums\";\n if (align === \"center\") return \"text-center\";\n return \"\";\n}\n\nfunction justifyClass(align?: \"left\" | \"right\" | \"center\") {\n if (align === \"right\") return \"justify-end\";\n if (align === \"center\") return \"justify-center\";\n return \"justify-start\";\n}\n\nexport interface DataTableProps<TData extends RowData> {\n /** 由 `useDataTable` 构造的表实例;列/数据/分页/排序/选择全部经它接入。 */\n table: DataTableInstance<TData>;\n /** 无数据可显示、正在首次取数 → 渲染骨架行。 */\n loading?: boolean;\n /** 已有数据在位、正在取新数据 → 表体降透明度。与 `loading` 互斥,react-query 下传\n * `isFetching && !isPending`。表头不降透明度,排序仍可点,它也是用户定位刚点了什么的锚点。 */\n fetching?: boolean;\n /** 空状态。缺省是 `L(\"Table:Empty\")` 的居中单行。传了只替换 `<td>` 内的内容,外层\n * `<tr><td colSpan>` 仍由组件提供,不用自己算 colSpan。注意那个 `<td>` 带着\n * `h-24 text-center text-muted-foreground`,插图 + 标题 + 按钮这类空状态会被强制居中、\n * 标题被染成 muted 色,要退出得自己在内容上加覆盖类。 */\n empty?: ReactNode;\n /** 骨架行数;缺省 5。传 `\"pageSize\"` 跟随当前 `pagination.pageSize`,\n * 避免 `pageSize=50` 时 5 行骨架突变成 50 行数据的跳变。 */\n skeletonRows?: number | \"pageSize\";\n /** 行级属性,只开 `className`。事件走 `onRowClick`,万能透传会让调用方覆盖掉\n * `key`/`data-state` 这些组件自管的东西。收 `row.original` 而不是 `Row`,要判断的是业务\n * 状态而非表格行状态。不需要引用稳定,它不进任何 memo 依赖,只在渲染时调用。\n *\n * 要覆盖悬停态或选中态得自带对应变体,例如\n * `{ className: \"bg-red-50 hover:bg-red-50 data-[state=selected]:bg-red-50\" }`。\n * 只给 `bg-red-50` 的话,组件自带的 `hover:bg-muted/50` 和\n * `data-[state=selected]:bg-row-selected` 修饰符不同,tailwind-merge 不去重,\n * 悬停和选中时又特异性更高,会把你的颜色盖回去。 */\n rowProps?: (row: TData) => { className?: string };\n onRowClick?: (row: TData) => void;\n /** 每页条数选项,缺省 `[10, 20, 50]`。当前 `pagination.pageSize` 总会被并入去重升序,\n * 所以传了 `useDataTable({ defaultPageSize })` 忘了同步这里,触发器也不会空白。\n * 传了 `footer` 则本项不生效。 */\n pageSizes?: number[];\n /** 接管整个页脚区:总条数文字、每页行数下拉、分页器三者全部。缺省渲染内建页脚。\n * 做「加载更多」或无限滚动的调用方本就不要页码和每页行数,拆开接管只会让他们分别关\n * 三个开关;要保留哪部分自己从 ctx 拼,分页动作走 `ctx.table.nextPage()` 这些。\n * 传了它之后 `pageSizes` 不生效,内建的每页行数下拉不再渲染。 */\n footer?: (ctx: {\n table: TableInstance<TData>;\n pagination: PaginationState;\n pageCount: number;\n rowCount?: number;\n }) => ReactNode;\n /** 工具栏等散件,渲染在卡片内部、表格上方,顺序即摆放顺序。 */\n children?: ReactNode;\n}\n\n/** 服务端分页的通用表格。实例由 `useDataTable` 构造后传入,本组件只管表现层:\n * 骨架、空态、表头表体和内建页脚。行操作列由调用方当普通 column 传进来。 */\nexport function DataTable<TData extends RowData>(props: DataTableProps<TData>) {\n const L = useLocalization();\n const tableId = useId();\n const { table: dt } = props;\n const table = dt.table;\n const skeletonCount =\n props.skeletonRows === \"pageSize\"\n ? dt.state.pagination.pageSize\n : (props.skeletonRows ?? DEFAULT_SKELETON_ROWS);\n\n const footerContent = props.footer ? (\n props.footer({\n table,\n pagination: dt.state.pagination,\n pageCount: dt.pageCount,\n rowCount: dt.rowCount,\n })\n ) : (\n <DataTableFooter\n table={table}\n pagination={dt.state.pagination}\n pageCount={dt.pageCount}\n rowCount={dt.rowCount}\n pageSizes={props.pageSizes}\n />\n );\n\n return (\n <div className=\"overflow-hidden rounded-md border bg-card\">\n {props.children}\n <Table\n className={cn(dt.state.density === \"compact\" && \"[&_td]:py-1\")}\n aria-busy={props.loading || props.fetching ? true : undefined}\n >\n <TableHeader>\n {table.getHeaderGroups().map((headerGroup) => (\n <TableRow key={headerGroup.id}>\n {headerGroup.headers.map((header) => {\n const showSortIndex =\n table.state.sorting.length > 1 && Boolean(header.column.getIsSorted());\n const sortPriorityId = `${tableId}-sortpri-${header.id}`;\n return (\n <TableHead\n key={header.id}\n aria-sort={\n header.column.getIsSorted() === \"asc\"\n ? \"ascending\"\n : header.column.getIsSorted() === \"desc\"\n ? \"descending\"\n : header.column.getCanSort()\n ? \"none\"\n : undefined\n }\n className={cn(\n // label-caps:正字距的大写小字把表头标记为\"分类\"而非内容,\n // 是这套系统里唯一用正字距的地方(DESIGN.md Typography)。\n \"bg-muted/50 text-xs font-medium uppercase tracking-wide text-muted-foreground\",\n alignClass(header.column.columnDef.meta?.align),\n header.column.columnDef.meta?.className,\n )}\n >\n {header.isPlaceholder ? null : header.column.getCanSort() ? (\n <button\n type=\"button\"\n onClick={header.column.getToggleSortingHandler()}\n aria-describedby={showSortIndex ? sortPriorityId : undefined}\n className={cn(\n \"inline-flex w-full items-center gap-1.5 select-none hover:text-foreground\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n justifyClass(header.column.columnDef.meta?.align),\n )}\n >\n {flexRender(header.column.columnDef.header, header.getContext())}\n {header.column.getIsSorted() === \"asc\" ? (\n <ChevronUp className=\"size-3 text-primary\" />\n ) : header.column.getIsSorted() === \"desc\" ? (\n <ChevronDown className=\"size-3 text-primary\" />\n ) : (\n <ChevronsUpDown className=\"size-3 opacity-50\" />\n )}\n {showSortIndex && (\n // 优先级本就不可用 ARIA 表达(aria-sort 只有方向),裸数字留在可访问名里\n // 会污染读屏播报与语音控制的匹配目标(按钮会变成「Name1」而非「Name」)。\n <span\n aria-hidden=\"true\"\n className=\"text-2xs font-normal tabular-nums text-muted-foreground\"\n >\n {header.column.getSortIndex() + 1}\n </span>\n )}\n </button>\n ) : (\n flexRender(header.column.columnDef.header, header.getContext())\n )}\n {showSortIndex && (\n // 描述得放 button 外面,进 button 会并入可访问名、污染语音控制的匹配目标,\n // 而 aria-describedby 只影响描述不影响名称。但 th 自己也是 \"name from\n // content\" 的角色,不加 aria-hidden 这段文字照样会并进表头的可访问名。\n // aria-hidden 把它逐出常规内容遍历,aria-describedby 的直接引用不受影响,\n // 读屏仍读得到。\n <span id={sortPriorityId} aria-hidden=\"true\" className=\"sr-only\">\n {L(\"Table:SortPriority\", header.column.getSortIndex() + 1)}\n </span>\n )}\n </TableHead>\n );\n })}\n </TableRow>\n ))}\n </TableHeader>\n <TableBody\n className={cn(\n // delay-100 吃掉快请求的闪烁,100ms 内返回的几乎看不出变化,且不用 JS 定时器。\n // 没加 pointer-events-none:它只挡鼠标不挡键盘,两类用户会拿到不同能力;\n // keepPreviousData 期间在位的又正是上一次的有效数据,点它并不危险(行操作打的是\n // id,行真没了服务端会 404)。「正在刷新」靠降透明度 + aria-busy 表达,不禁用交互。\n props.fetching && \"opacity-60 transition-opacity duration-150 delay-100\",\n )}\n >\n {props.loading ? (\n Array.from({ length: skeletonCount }, (_, i) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: 骨架占位行数固定且从不重排\n <TableRow key={`skeleton-${i}`}>\n <TableCell colSpan={table.getVisibleLeafColumns().length}>\n <Skeleton className=\"h-5 w-full\" />\n </TableCell>\n </TableRow>\n ))\n ) : table.getRowModel().rows.length === 0 ? (\n <TableRow>\n <TableCell\n colSpan={table.getVisibleLeafColumns().length}\n className=\"h-24 text-center text-muted-foreground\"\n >\n {props.empty ?? L(\"Table:Empty\")}\n </TableCell>\n </TableRow>\n ) : (\n table.getRowModel().rows.map((row) => (\n <TableRow\n key={row.id}\n data-state={row.getIsSelected() ? \"selected\" : undefined}\n className={cn(\n \"group hover:bg-muted/50 data-[state=selected]:bg-row-selected\",\n props.onRowClick &&\n \"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\",\n props.rowProps?.(row.original)?.className,\n )}\n tabIndex={props.onRowClick ? 0 : undefined}\n onClick={() => props.onRowClick?.(row.original)}\n onKeyDown={\n props.onRowClick &&\n ((e) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n // 两个都要 preventDefault。Space 默认滚动页面;Enter 会在本次按键继续合成\n // 一次 click,落到激活后新获得焦点的元素上(详情抽屉里的「编辑」按钮),\n // 键盘激活就越过详情态直接进了编辑态。\n // 行操作菜单已在自己的 onKeyDown 里 stopPropagation,不会误触发行激活。\n e.preventDefault();\n props.onRowClick?.(row.original);\n }\n })\n }\n >\n {row.getVisibleCells().map((cell) => (\n <TableCell\n key={cell.id}\n className={cn(\n alignClass(cell.column.columnDef.meta?.align),\n cell.column.columnDef.meta?.className,\n )}\n >\n {flexRender(cell.column.columnDef.cell, cell.getContext())}\n </TableCell>\n ))}\n </TableRow>\n ))\n )}\n </TableBody>\n </Table>\n {/* footer 返回 falsy(无限滚动这类不要页脚的接管)时连外壳一起不渲染,\n 否则卡片底部会留一条 1px 上边框加一段空白。 */}\n {footerContent ? <div className=\"border-t px-3 py-2\">{footerContent}</div> : null}\n </div>\n );\n}\n",
|
|
51
|
+
"content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport {\n flexRender,\n type PaginationState,\n type Row,\n type RowData,\n Subscribe,\n} from \"@tanstack/react-table\";\nimport { ChevronDown, ChevronsUpDown, ChevronUp } from \"lucide-react\";\nimport { type ReactNode, useId } from \"react\";\nimport { DataTableFooter } from \"@/components/data-table/data-table-footer\";\nimport type { TableFeatures, TableInstance } from \"@/components/data-table/table-core\";\nimport type { DataTableInstance } from \"@/components/data-table/use-data-table\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from \"@/components/ui/table\";\nimport { cn } from \"@/lib/utils\";\n\nconst DEFAULT_SKELETON_ROWS = 5;\n\nfunction alignClass(align?: \"left\" | \"right\" | \"center\") {\n // 右对齐即数字列(DESIGN.md):一并给等宽数字,否则 Inter 的比例数字宽窄不一,\n // 同列上下对不齐、跨组件(分页/页码)看着像换了字体。\n if (align === \"right\") return \"text-right tabular-nums\";\n if (align === \"center\") return \"text-center\";\n return \"\";\n}\n\nfunction justifyClass(align?: \"left\" | \"right\" | \"center\") {\n if (align === \"right\") return \"justify-end\";\n if (align === \"center\") return \"justify-center\";\n return \"justify-start\";\n}\n\ninterface DataTableRowProps<TData extends RowData>\n extends Pick<DataTableProps<TData>, \"rowProps\" | \"onRowClick\"> {\n row: Row<TableFeatures, TData>;\n selectionAtom: TableInstance<TData>[\"atoms\"][\"rowSelection\"];\n}\n\n/** 表体的一行。整行包在自己那一行的选中订阅里:`data-state` 是 `<TableRow>` 上的属性,\n * 而宿主的 `useTable` selector 已把 rowSelection 摘出去、不再因勾选重渲染,只有把整行\n * 放进订阅,选中底色才跟得上。刻意不加 `React.memo`:订阅本就没有比较面,加了反而把\n * `rowProps` / `onRowClick` 的「不需要引用稳定」契约拖下水。\n *\n * 这份订阅与选择列 cell 里那份(见 `use-data-table.ts` 的 `selectionColumn`)在当前组合下\n * 是重叠的——本组件把整行连同各 cell 一起包住,删掉任意一份,测试都还全绿。重叠是有意的,\n * 两份都不能删:本组件负责 `<tr>` 的 `data-state`,是列定义碰不到的;`selectionColumn`\n * 则要能独立成立,它的契约不该是「只有被 DataTableRow 包着才会刷新」——`UseDataTableOptions`\n * 的 `features` TSDoc 正教着下游改本文件的表体。 */\nfunction DataTableRow<TData extends RowData>(props: DataTableRowProps<TData>) {\n const { row, selectionAtom, rowProps, onRowClick } = props;\n return (\n <Subscribe source={selectionAtom} selector={(s) => Boolean(s?.[row.id])}>\n {(selected) => (\n <TableRow\n data-state={selected ? \"selected\" : undefined}\n className={cn(\n \"group hover:bg-muted/50 data-[state=selected]:bg-row-selected\",\n onRowClick &&\n \"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\",\n rowProps?.(row.original)?.className,\n )}\n tabIndex={onRowClick ? 0 : undefined}\n onClick={() => onRowClick?.(row.original)}\n onKeyDown={\n onRowClick &&\n ((e) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n // 两个都要 preventDefault。Space 默认滚动页面;Enter 会在本次按键继续合成\n // 一次 click,落到激活后新获得焦点的元素上(详情抽屉里的「编辑」按钮),\n // 键盘激活就越过详情态直接进了编辑态。\n // 行操作菜单已在自己的 onKeyDown 里 stopPropagation,不会误触发行激活。\n e.preventDefault();\n onRowClick(row.original);\n }\n })\n }\n >\n {row.getVisibleCells().map((cell) => (\n <TableCell\n key={cell.id}\n className={cn(\n alignClass(cell.column.columnDef.meta?.align),\n cell.column.columnDef.meta?.className,\n )}\n >\n {flexRender(cell.column.columnDef.cell, cell.getContext())}\n </TableCell>\n ))}\n </TableRow>\n )}\n </Subscribe>\n );\n}\n\nexport interface DataTableProps<TData extends RowData> {\n /** 由 `useDataTable` 构造的表实例;列/数据/分页/排序/选择全部经它接入。 */\n table: DataTableInstance<TData>;\n /** 无数据可显示、正在首次取数 → 渲染骨架行。 */\n loading?: boolean;\n /** 已有数据在位、正在取新数据 → 表体降透明度。与 `loading` 互斥,react-query 下传\n * `isFetching && !isPending`。表头不降透明度,排序仍可点,它也是用户定位刚点了什么的锚点。 */\n fetching?: boolean;\n /** 空状态。缺省是 `L(\"Table:Empty\")` 的居中单行。传了只替换 `<td>` 内的内容,外层\n * `<tr><td colSpan>` 仍由组件提供,不用自己算 colSpan。注意那个 `<td>` 带着\n * `h-24 text-center text-muted-foreground`,插图 + 标题 + 按钮这类空状态会被强制居中、\n * 标题被染成 muted 色,要退出得自己在内容上加覆盖类。 */\n empty?: ReactNode;\n /** 骨架行数;缺省 5。传 `\"pageSize\"` 跟随当前 `pagination.pageSize`,\n * 避免 `pageSize=50` 时 5 行骨架突变成 50 行数据的跳变。 */\n skeletonRows?: number | \"pageSize\";\n /** 行级属性,只开 `className`。事件走 `onRowClick`,万能透传会让调用方覆盖掉\n * `key`/`data-state` 这些组件自管的东西。收 `row.original` 而不是 `Row`,要判断的是业务\n * 状态而非表格行状态。不需要引用稳定,它不进任何 memo 依赖,只在渲染时调用。\n *\n * 要覆盖悬停态或选中态得自带对应变体,例如\n * `{ className: \"bg-red-50 hover:bg-red-50 data-[state=selected]:bg-red-50\" }`。\n * 只给 `bg-red-50` 的话,组件自带的 `hover:bg-muted/50` 和\n * `data-[state=selected]:bg-row-selected` 修饰符不同,tailwind-merge 不去重,\n * 悬停和选中时又特异性更高,会把你的颜色盖回去。 */\n rowProps?: (row: TData) => { className?: string };\n onRowClick?: (row: TData) => void;\n /** 每页条数选项,缺省 `[10, 20, 50]`。当前 `pagination.pageSize` 总会被并入去重升序,\n * 所以传了 `useDataTable({ defaultPageSize })` 忘了同步这里,触发器也不会空白。\n * 传了 `footer` 则本项不生效。 */\n pageSizes?: number[];\n /** 接管整个页脚区:总条数文字、每页行数下拉、分页器三者全部。缺省渲染内建页脚。\n * 做「加载更多」或无限滚动的调用方本就不要页码和每页行数,拆开接管只会让他们分别关\n * 三个开关;要保留哪部分自己从 ctx 拼,分页动作走 `ctx.table.nextPage()` 这些。\n * 传了它之后 `pageSizes` 不生效,内建的每页行数下拉不再渲染。 */\n footer?: (ctx: {\n table: TableInstance<TData>;\n pagination: PaginationState;\n pageCount: number;\n rowCount?: number;\n }) => ReactNode;\n /** 工具栏等散件,渲染在卡片内部、表格上方,顺序即摆放顺序。 */\n children?: ReactNode;\n}\n\n/** 服务端分页的通用表格。实例由 `useDataTable` 构造后传入,本组件只管表现层:\n * 骨架、空态、表头表体和内建页脚。行操作列由调用方当普通 column 传进来。 */\nexport function DataTable<TData extends RowData>(props: DataTableProps<TData>) {\n const L = useLocalization();\n const tableId = useId();\n const { table: dt } = props;\n const table = dt.table;\n const skeletonCount =\n props.skeletonRows === \"pageSize\"\n ? dt.state.pagination.pageSize\n : (props.skeletonRows ?? DEFAULT_SKELETON_ROWS);\n\n const footerContent = props.footer ? (\n props.footer({\n table,\n pagination: dt.state.pagination,\n pageCount: dt.pageCount,\n rowCount: dt.rowCount,\n })\n ) : (\n <DataTableFooter\n table={table}\n pagination={dt.state.pagination}\n pageCount={dt.pageCount}\n rowCount={dt.rowCount}\n pageSizes={props.pageSizes}\n />\n );\n\n return (\n <div className=\"overflow-hidden rounded-md border bg-card\">\n {props.children}\n <Table\n className={cn(dt.state.density === \"compact\" && \"[&_td]:py-1\")}\n aria-busy={props.loading || props.fetching ? true : undefined}\n >\n <TableHeader>\n {table.getHeaderGroups().map((headerGroup) => (\n <TableRow key={headerGroup.id}>\n {headerGroup.headers.map((header) => {\n const showSortIndex =\n table.state.sorting.length > 1 && Boolean(header.column.getIsSorted());\n const sortPriorityId = `${tableId}-sortpri-${header.id}`;\n return (\n <TableHead\n key={header.id}\n aria-sort={\n header.column.getIsSorted() === \"asc\"\n ? \"ascending\"\n : header.column.getIsSorted() === \"desc\"\n ? \"descending\"\n : header.column.getCanSort()\n ? \"none\"\n : undefined\n }\n className={cn(\n // label-caps:正字距的大写小字把表头标记为\"分类\"而非内容,\n // 是这套系统里唯一用正字距的地方(DESIGN.md Typography)。\n \"bg-muted/50 text-xs font-medium uppercase tracking-wide text-muted-foreground\",\n alignClass(header.column.columnDef.meta?.align),\n header.column.columnDef.meta?.className,\n )}\n >\n {header.isPlaceholder ? null : header.column.getCanSort() ? (\n <button\n type=\"button\"\n onClick={header.column.getToggleSortingHandler()}\n aria-describedby={showSortIndex ? sortPriorityId : undefined}\n className={cn(\n \"inline-flex w-full items-center gap-1.5 select-none hover:text-foreground\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n justifyClass(header.column.columnDef.meta?.align),\n )}\n >\n {flexRender(header.column.columnDef.header, header.getContext())}\n {header.column.getIsSorted() === \"asc\" ? (\n <ChevronUp className=\"size-3 text-primary\" />\n ) : header.column.getIsSorted() === \"desc\" ? (\n <ChevronDown className=\"size-3 text-primary\" />\n ) : (\n <ChevronsUpDown className=\"size-3 opacity-50\" />\n )}\n {showSortIndex && (\n // 优先级本就不可用 ARIA 表达(aria-sort 只有方向),裸数字留在可访问名里\n // 会污染读屏播报与语音控制的匹配目标(按钮会变成「Name1」而非「Name」)。\n <span\n aria-hidden=\"true\"\n className=\"text-2xs font-normal tabular-nums text-muted-foreground\"\n >\n {header.column.getSortIndex() + 1}\n </span>\n )}\n </button>\n ) : (\n flexRender(header.column.columnDef.header, header.getContext())\n )}\n {showSortIndex && (\n // 描述得放 button 外面,进 button 会并入可访问名、污染语音控制的匹配目标,\n // 而 aria-describedby 只影响描述不影响名称。但 th 自己也是 \"name from\n // content\" 的角色,不加 aria-hidden 这段文字照样会并进表头的可访问名。\n // aria-hidden 把它逐出常规内容遍历,aria-describedby 的直接引用不受影响,\n // 读屏仍读得到。\n <span id={sortPriorityId} aria-hidden=\"true\" className=\"sr-only\">\n {L(\"Table:SortPriority\", header.column.getSortIndex() + 1)}\n </span>\n )}\n </TableHead>\n );\n })}\n </TableRow>\n ))}\n </TableHeader>\n <TableBody\n className={cn(\n // delay-100 吃掉快请求的闪烁,100ms 内返回的几乎看不出变化,且不用 JS 定时器。\n // 没加 pointer-events-none:它只挡鼠标不挡键盘,两类用户会拿到不同能力;\n // keepPreviousData 期间在位的又正是上一次的有效数据,点它并不危险(行操作打的是\n // id,行真没了服务端会 404)。「正在刷新」靠降透明度 + aria-busy 表达,不禁用交互。\n props.fetching && \"opacity-60 transition-opacity duration-150 delay-100\",\n )}\n >\n {props.loading ? (\n Array.from({ length: skeletonCount }, (_, i) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: 骨架占位行数固定且从不重排\n <TableRow key={`skeleton-${i}`}>\n <TableCell colSpan={table.getVisibleLeafColumns().length}>\n <Skeleton className=\"h-5 w-full\" />\n </TableCell>\n </TableRow>\n ))\n ) : table.getRowModel().rows.length === 0 ? (\n <TableRow>\n <TableCell\n colSpan={table.getVisibleLeafColumns().length}\n className=\"h-24 text-center text-muted-foreground\"\n >\n {props.empty ?? L(\"Table:Empty\")}\n </TableCell>\n </TableRow>\n ) : (\n table\n .getRowModel()\n .rows.map((row) => (\n <DataTableRow\n key={row.id}\n row={row}\n selectionAtom={table.atoms.rowSelection}\n rowProps={props.rowProps}\n onRowClick={props.onRowClick}\n />\n ))\n )}\n </TableBody>\n </Table>\n {/* footer 返回 falsy(无限滚动这类不要页脚的接管)时连外壳一起不渲染,\n 否则卡片底部会留一条 1px 上边框加一段空白。 */}\n {footerContent ? <div className=\"border-t px-3 py-2\">{footerContent}</div> : null}\n </div>\n );\n}\n",
|
|
52
52
|
"type": "registry:component",
|
|
53
53
|
"target": "components/data-table/data-table.tsx"
|
|
54
54
|
},
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
},
|
|
61
61
|
{
|
|
62
62
|
"path": "ui/blocks/data-table/data-table-toolbar.tsx",
|
|
63
|
-
"content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport type { RowData } from \"@tanstack/react-table\";\nimport { Download, RefreshCw, Rows3 } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport type { DataTableInstance } from \"@/components/data-table/use-data-table\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Input } from \"@/components/ui/input\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\n\n/** 顶部条功能图标钮的共同外观:ghost 32px 方钮、静态色比正文更轻,hover 才提亮。 */\nconst UTILITY_ICON_CLASS = \"size-8 text-muted-foreground\";\n\nexport interface DataTableToolbarProps<TData extends RowData> {\n table: DataTableInstance<TData>;\n /** 是否渲染内建搜索框;缺省 true。传了 `left` 时搜索框让位。 */\n search?: boolean;\n searchPlaceholder?: string;\n /** 左区自定义内容(如内联查询字段);提供时替代内建搜索框。 */\n left?: ReactNode;\n /** 有选中行时替换整个左区的批量操作内容;缺席则选中时左区维持原样。 */\n bulk?: ReactNode;\n /** 刷新回调;缺席不渲染刷新按钮。`refreshing` 为 true 时图标旋转。 */\n onRefresh?: () => void;\n refreshing?: boolean;\n /** 导出插槽;缺席不渲染导出按钮,组件库不内置导出实现。 */\n onExport?: () => void;\n /** 页面级动作(如「新增」),primary 归它;与右侧功能图标组之间以竖线分隔。 */\n actions?: ReactNode;\n /** 功能区最前的散件(如查询面板开合钮),排在刷新之前,与「改表格外观」的那几个分开。 */\n utilityLeading?: ReactNode;\n /** trailing 区:列显隐菜单等散件,并入右侧功能图标组。 */\n children?: ReactNode;\n}\n\n/** 表格卡片顶部条:左区三态(自定义内容/搜索框 ⇄ 批量操作),右区恒定\n * (actions │ utilityLeading · 刷新 · 导出 · 密度 · trailing)。Enter 立即提交搜索。 */\nexport function DataTableToolbar<TData extends RowData>(props: DataTableToolbarProps<TData>) {\n const L = useLocalization();\n const { state } = props.table;\n const showSearch = props.search ?? true;\n
|
|
63
|
+
"content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport type { RowData } from \"@tanstack/react-table\";\nimport { Download, RefreshCw, Rows3 } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport type { DataTableInstance } from \"@/components/data-table/use-data-table\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Input } from \"@/components/ui/input\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\n\n/** 顶部条功能图标钮的共同外观:ghost 32px 方钮、静态色比正文更轻,hover 才提亮。 */\nconst UTILITY_ICON_CLASS = \"size-8 text-muted-foreground\";\nconst SEARCH_DEBOUNCE_MS = 400;\n\nexport interface DataTableToolbarProps<TData extends RowData> {\n table: DataTableInstance<TData>;\n /** 是否渲染内建搜索框;缺省 true。传了 `left` 时搜索框让位。 */\n search?: boolean;\n searchPlaceholder?: string;\n /** 左区自定义内容(如内联查询字段);提供时替代内建搜索框。 */\n left?: ReactNode;\n /** 有选中行时替换整个左区的批量操作内容;缺席则选中时左区维持原样。 */\n bulk?: ReactNode;\n /** 刷新回调;缺席不渲染刷新按钮。`refreshing` 为 true 时图标旋转。 */\n onRefresh?: () => void;\n refreshing?: boolean;\n /** 导出插槽;缺席不渲染导出按钮,组件库不内置导出实现。 */\n onExport?: () => void;\n /** 页面级动作(如「新增」),primary 归它;与右侧功能图标组之间以竖线分隔。 */\n actions?: ReactNode;\n /** 功能区最前的散件(如查询面板开合钮),排在刷新之前,与「改表格外观」的那几个分开。 */\n utilityLeading?: ReactNode;\n /** trailing 区:列显隐菜单等散件,并入右侧功能图标组。 */\n children?: ReactNode;\n}\n\n/** 表格卡片顶部条:左区三态(自定义内容/搜索框 ⇄ 批量操作),右区恒定\n * (actions │ utilityLeading · 刷新 · 导出 · 密度 · trailing)。Enter 立即提交搜索。 */\nexport function DataTableToolbar<TData extends RowData>(props: DataTableToolbarProps<TData>) {\n const L = useLocalization();\n const { state, SelectedCount } = props.table;\n const showSearch = props.search ?? true;\n\n // 即时值持有在组件本体而非条件子树:批量态会把整个左区换成 props.bulk,\n // 若把这份 state 放进搜索框所在的三元分支,批量态一进一出输入内容就丢了。\n const [searchInput, setSearchInput] = useState(\"\");\n const timer = useRef<ReturnType<typeof setTimeout>>(undefined);\n const { commitSearch } = state;\n\n useEffect(() => () => clearTimeout(timer.current), []);\n\n const onSearchChange = (value: string) => {\n setSearchInput(value);\n clearTimeout(timer.current);\n timer.current = setTimeout(() => commitSearch(value), SEARCH_DEBOUNCE_MS);\n };\n\n const flushSearch = () => {\n clearTimeout(timer.current);\n commitSearch(searchInput);\n };\n\n const idle =\n props.left ??\n (showSearch ? (\n <Input\n className=\"h-8 w-56\"\n value={searchInput}\n placeholder={props.searchPlaceholder ?? L(\"Table:Search\")}\n onChange={(e) => onSearchChange(e.target.value)}\n onKeyDown={(e) => e.key === \"Enter\" && flushSearch()}\n />\n ) : null);\n\n // 只有在给了 bulk 内容时才订阅选中态:没有批量内容的表,左区恒定,多挂一个订阅者没有意义。\n const left =\n props.bulk === undefined ? (\n idle\n ) : (\n <SelectedCount>{(count) => (count > 0 ? props.bulk : idle)}</SelectedCount>\n );\n\n return (\n <div className=\"flex flex-wrap items-center gap-2 border-b px-3 py-2\">\n {left}\n <div className=\"ml-auto flex items-center gap-2\">\n {props.actions}\n {props.actions ? <Separator orientation=\"vertical\" className=\"mx-1 !h-5\" /> : null}\n <div className=\"flex items-center gap-1\">\n {props.utilityLeading}\n {props.onRefresh && (\n <Tooltip>\n <TooltipTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className={UTILITY_ICON_CLASS}\n aria-label={L(\"Table:Refresh\")}\n onClick={props.onRefresh}\n >\n <RefreshCw className={cn(props.refreshing && \"animate-spin\")} />\n </Button>\n </TooltipTrigger>\n <TooltipContent>{L(\"Table:Refresh\")}</TooltipContent>\n </Tooltip>\n )}\n {props.onExport && (\n <Tooltip>\n <TooltipTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className={UTILITY_ICON_CLASS}\n aria-label={L(\"Table:Export\")}\n onClick={props.onExport}\n >\n <Download />\n </Button>\n </TooltipTrigger>\n <TooltipContent>{L(\"Table:Export\")}</TooltipContent>\n </Tooltip>\n )}\n <DropdownMenu>\n <Tooltip>\n <TooltipTrigger asChild>\n <DropdownMenuTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className={UTILITY_ICON_CLASS}\n aria-label={L(\"Table:Density\")}\n >\n <Rows3 />\n </Button>\n </DropdownMenuTrigger>\n </TooltipTrigger>\n <TooltipContent>{L(\"Table:Density\")}</TooltipContent>\n </Tooltip>\n <DropdownMenuContent align=\"end\">\n <DropdownMenuRadioGroup\n value={state.density}\n onValueChange={(value) => {\n // 收窄而非断言:菜单项之外的值(将来加档位忘了同步这里)宁可无声忽略,\n // 也不要把非法字符串塞进状态机。\n if (value === \"comfortable\" || value === \"compact\") state.setDensity(value);\n }}\n >\n <DropdownMenuRadioItem value=\"comfortable\">\n {L(\"Table:DensityComfortable\")}\n </DropdownMenuRadioItem>\n <DropdownMenuRadioItem value=\"compact\">\n {L(\"Table:DensityCompact\")}\n </DropdownMenuRadioItem>\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n {props.children}\n </div>\n </div>\n </div>\n );\n}\n",
|
|
64
64
|
"type": "registry:component",
|
|
65
65
|
"target": "components/data-table/data-table-toolbar.tsx"
|
|
66
66
|
},
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
},
|
|
85
85
|
{
|
|
86
86
|
"path": "ui/blocks/data-table/table-messages.json",
|
|
87
|
-
"content": "{\n \"en\": {\n \"\": {\n \"Table:Search\": \"Search…\",\n \"Table:Empty\": \"No data\",\n \"Table:Query\": \"Query\",\n \"Table:Reset\": \"Reset\",\n \"Table:RowsPerPage\": \"Rows per page\",\n \"Table:PageOf\": \"Page {0} of {1}\",\n \"Table:Total\": \"{0} items\",\n \"Table:Previous\": \"Previous\",\n \"Table:Next\": \"Next\",\n \"Table:Actions\": \"Actions\",\n \"Table:View\": \"View\",\n \"Table:SelectAll\": \"Select all rows\",\n \"Table:SelectRow\": \"Select row\",\n \"Table:NSelected\": \"{0} selected\",\n \"Table:Clear\": \"Clear\",\n \"Table:Columns\": \"Columns\",\n \"Table:Sort\": \"Sort\",\n \"Table:SortClear\": \"Clear sort\",\n \"Table:SortPriority\": \"Sort priority {0}\",\n \"Table:Expand\": \"More\",\n \"Table:Collapse\": \"Less\",\n \"Table:Refresh\": \"Refresh\",\n \"Table:Export\": \"Export\",\n \"Table:Density\": \"Density\",\n \"Table:DensityComfortable\": \"Comfortable\",\n \"Table:DensityCompact\": \"Compact\",\n \"Table:Filter\": \"Filters\",\n \"Table:FilterActive\": \"{0} filters applied\"\n }\n },\n \"zh-Hans\": {\n \"\": {\n \"Table:Search\": \"搜索…\",\n \"Table:Empty\": \"暂无数据\",\n \"Table:Query\": \"查询\",\n \"Table:Reset\": \"重置\",\n \"Table:RowsPerPage\": \"每页行数\",\n \"Table:PageOf\": \"第 {0} / {1} 页\",\n \"Table:Total\": \"共 {0} 条\",\n \"Table:Previous\": \"上一页\",\n \"Table:Next\": \"下一页\",\n \"Table:Actions\": \"操作\",\n \"Table:View\": \"查看\",\n \"Table:SelectAll\": \"全选\",\n \"Table:SelectRow\": \"选择该行\",\n \"Table:NSelected\": \"已选 {0} 项\",\n \"Table:Clear\": \"清除\",\n \"Table:Columns\": \"列\",\n \"Table:Sort\": \"排序\",\n \"Table:SortClear\": \"清除排序\",\n \"Table:SortPriority\": \"排序优先级 {0}\",\n \"Table:Expand\": \"展开\",\n \"Table:Collapse\": \"收起\",\n \"Table:Refresh\": \"刷新\",\n \"Table:Export\": \"导出\",\n \"Table:Density\": \"密度\",\n \"Table:DensityComfortable\": \"舒适\",\n \"Table:DensityCompact\": \"紧凑\",\n \"Table:Filter\": \"筛选\",\n \"Table:FilterActive\": \"已应用 {0} 个筛选条件\"\n }\n }\n}\n",
|
|
87
|
+
"content": "{\n \"en\": {\n \"\": {\n \"Table:Search\": \"Search…\",\n \"Table:Empty\": \"No data\",\n \"Table:Query\": \"Query\",\n \"Table:Reset\": \"Reset\",\n \"Table:RowsPerPage\": \"Rows per page\",\n \"Table:PageOf\": \"Page {0} of {1}\",\n \"Table:Total\": \"{0} items\",\n \"Table:Previous\": \"Previous\",\n \"Table:Next\": \"Next\",\n \"Table:Actions\": \"Actions\",\n \"Table:View\": \"View\",\n \"Table:SelectAll\": \"Select all rows\",\n \"Table:SelectRow\": \"Select row\",\n \"Table:NSelected\": \"{0} selected\",\n \"Table:Clear\": \"Clear\",\n \"Table:Columns\": \"Columns\",\n \"Table:Sort\": \"Sort\",\n \"Table:SortClear\": \"Clear sort\",\n \"Table:SortPriority\": \"Sort priority {0}\",\n \"Table:Expand\": \"More\",\n \"Table:Collapse\": \"Less\",\n \"Table:Refresh\": \"Refresh\",\n \"Table:Export\": \"Export\",\n \"Table:Density\": \"Density\",\n \"Table:DensityComfortable\": \"Comfortable\",\n \"Table:DensityCompact\": \"Compact\",\n \"Table:Filter\": \"Filters\",\n \"Table:FilterActive\": \"{0} filters applied\",\n \"Table:Yes\": \"Yes\",\n \"Table:No\": \"No\"\n }\n },\n \"zh-Hans\": {\n \"\": {\n \"Table:Search\": \"搜索…\",\n \"Table:Empty\": \"暂无数据\",\n \"Table:Query\": \"查询\",\n \"Table:Reset\": \"重置\",\n \"Table:RowsPerPage\": \"每页行数\",\n \"Table:PageOf\": \"第 {0} / {1} 页\",\n \"Table:Total\": \"共 {0} 条\",\n \"Table:Previous\": \"上一页\",\n \"Table:Next\": \"下一页\",\n \"Table:Actions\": \"操作\",\n \"Table:View\": \"查看\",\n \"Table:SelectAll\": \"全选\",\n \"Table:SelectRow\": \"选择该行\",\n \"Table:NSelected\": \"已选 {0} 项\",\n \"Table:Clear\": \"清除\",\n \"Table:Columns\": \"列\",\n \"Table:Sort\": \"排序\",\n \"Table:SortClear\": \"清除排序\",\n \"Table:SortPriority\": \"排序优先级 {0}\",\n \"Table:Expand\": \"展开\",\n \"Table:Collapse\": \"收起\",\n \"Table:Refresh\": \"刷新\",\n \"Table:Export\": \"导出\",\n \"Table:Density\": \"密度\",\n \"Table:DensityComfortable\": \"舒适\",\n \"Table:DensityCompact\": \"紧凑\",\n \"Table:Filter\": \"筛选\",\n \"Table:FilterActive\": \"已应用 {0} 个筛选条件\",\n \"Table:Yes\": \"是\",\n \"Table:No\": \"否\"\n }\n }\n}\n",
|
|
88
88
|
"type": "registry:file",
|
|
89
89
|
"target": "components/data-table/table-messages.json"
|
|
90
90
|
}
|
package/public/r/registry.json
CHANGED
|
@@ -249,7 +249,7 @@
|
|
|
249
249
|
"separator"
|
|
250
250
|
],
|
|
251
251
|
"dependencies": [
|
|
252
|
-
"@tanstack/react-table@9.
|
|
252
|
+
"@tanstack/react-table@9.2.4",
|
|
253
253
|
"@jcoder-stack/abp-react",
|
|
254
254
|
"lucide-react"
|
|
255
255
|
],
|
|
@@ -444,7 +444,7 @@
|
|
|
444
444
|
"dependencies": [
|
|
445
445
|
"@jcoder-stack/abp-react",
|
|
446
446
|
"@tanstack/react-query",
|
|
447
|
-
"@tanstack/react-table@9.
|
|
447
|
+
"@tanstack/react-table@9.2.4",
|
|
448
448
|
"@tanstack/react-form",
|
|
449
449
|
"sonner",
|
|
450
450
|
"lucide-react"
|
|
@@ -489,6 +489,11 @@
|
|
|
489
489
|
"path": "ui/blocks/abp-table/status-badge.tsx",
|
|
490
490
|
"type": "registry:component",
|
|
491
491
|
"target": "components/abp/table/status-badge.tsx"
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
"path": "ui/blocks/abp-table/column-presets.tsx",
|
|
495
|
+
"type": "registry:component",
|
|
496
|
+
"target": "components/abp/table/column-presets.tsx"
|
|
492
497
|
}
|
|
493
498
|
]
|
|
494
499
|
},
|
|
@@ -697,7 +702,7 @@
|
|
|
697
702
|
"@jcoder-stack/abp-react",
|
|
698
703
|
"@tanstack/react-form",
|
|
699
704
|
"@tanstack/react-query",
|
|
700
|
-
"@tanstack/react-table@9.
|
|
705
|
+
"@tanstack/react-table@9.2.4",
|
|
701
706
|
"sonner",
|
|
702
707
|
"lucide-react",
|
|
703
708
|
"zod"
|