@jcoder-stack/registry 0.3.1 → 0.4.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.3.1",
4
+ "version": "0.4.1",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "auth",
@@ -14,7 +14,7 @@
14
14
  "postpack": "node ../scripts/restore-publish-config.mjs"
15
15
  },
16
16
  "dependencies": {
17
- "@jcoder-stack/abp-react": "^0.3.1",
17
+ "@jcoder-stack/abp-react": "^0.4.1",
18
18
  "@tanstack/react-router": "^1.0.0",
19
19
  "@tanstack/react-start": "^1.0.0",
20
20
  "zod": "^4.0.0"
@@ -16,7 +16,7 @@
16
16
  "files": [
17
17
  {
18
18
  "path": "ui/blocks/abp-sheet/use-abp-sheet.tsx",
19
- "content": "import { useLocalization, usePermissionChecker } from \"@jcoder-stack/abp-react/react\";\nimport { type FormValidateOrFn, useStore } from \"@tanstack/react-form\";\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport { type ReactNode, useCallback, useRef, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { abpFormOptions } from \"@/components/abp/crud/abp-form-options\";\nimport { useBoundComponents } from \"@/components/abp/crud/create-bound-components\";\nimport type { WritableCrudService } from \"@/components/abp/crud/crud-service\";\nimport { useAppForm } from \"@/components/form/form-hook\";\nimport { SheetForm, type SheetFormMode, type SheetFormProps } from \"@/components/form/sheet-form\";\n\n/** form 块的入参。`emptyValues` 之外三个映射都可以省:`toValues` 缺省按 `emptyValues` 的键\n * 从记录里 pick,null/undefined 回退成空值,roles 这类「表单形状 ≡ DTO 子集」的页够用了。\n * `toCreate`/`toUpdate` 只在 `TValues` 结构上可赋给对应 DTO 时才允许省略;形状不匹配\n * (比如 tenants 的 update DTO 没有 admin 字段)编译期就报错,不会到运行时才传错字段。 */\nexport type AbpSheetOptions<\n TDto extends { id?: string; concurrencyStamp?: string | null },\n TValues,\n TCreate,\n TUpdate,\n> = {\n emptyValues: TValues;\n /** record → 表单值;可异步(预取关联数据);返回 null 表示取消打开(调用方自行提示)。 */\n toValues?: (record: TDto, mode: SheetFormMode) => TValues | Promise<TValues | null>;\n schema?: (mode: SheetFormMode) => FormValidateOrFn<TValues>;\n} & (TValues extends TCreate\n ? { toCreate?: (value: TValues) => TCreate }\n : { toCreate: (value: TValues) => TCreate }) &\n (TValues extends TUpdate\n ? { toUpdate?: (value: TValues) => TUpdate }\n : { toUpdate: (value: TValues) => TUpdate });\n\n/** service → sheet 三态状态机 + create/update mutation。直接吃 `WritableCrudService`,\n * 调用方不用另建一份 mutation 自己接线。sheet 状态机、concurrencyStamp 注入、保存 toast\n * 与列表失效都收在这里。绑定成员 `Sheet` 经 `useBoundComponents` 挂出稳定身份。 */\nexport function useAbpSheet<\n TDto extends { id?: string; concurrencyStamp?: string | null },\n TValues,\n TCreate,\n TUpdate,\n>(\n service: WritableCrudService<TDto, TCreate, TUpdate>,\n opts: AbpSheetOptions<TDto, TValues, TCreate, TUpdate>,\n) {\n const L = useLocalization();\n const can = usePermissionChecker();\n const queryClient = useQueryClient();\n\n const [sheet, setSheet] = useState<{ mode: SheetFormMode; record?: TDto } | null>(null);\n // defaultValues 必须由 state 驱动:useForm 每渲染 formApi.update(opts) 会把稳定常量\n // defaultValues 拍回刚 reset 的记录值。\n const [formDefaults, setFormDefaults] = useState<TValues>(opts.emptyValues);\n\n const mode = sheet?.mode;\n const record = sheet?.record;\n\n const invalidate = () => queryClient.invalidateQueries({ queryKey: service.listKey() });\n const saved = () => {\n toast.success(L(\"Crud:Saved\"));\n void invalidate();\n };\n\n const rawCreate = service.useCreate({ mutation: { onSuccess: saved } });\n const rawUpdate = service.useUpdate({ mutation: { onSuccess: saved } });\n\n const form = useAppForm(\n abpFormOptions({\n defaultValues: formDefaults,\n schema: opts.schema?.(mode ?? \"create\"),\n // rawCreate/rawUpdate 直接调用,不包 facade。两者只在这一处读取,不像\n // use-abp-table.ts 的 deleteMutation 那样要喂一条外部 memo 链,包一层没有可观察差异。\n submit: async (value: TValues) => {\n if (sheet?.mode === \"edit\" && sheet.record?.id) {\n const stamp = sheet.record.concurrencyStamp;\n const toUpdate = opts.toUpdate ?? ((v: TValues) => v as unknown as TUpdate);\n const base = toUpdate(value);\n // 泛型上无法表达「附加可选键仍是 TUpdate」;运行时 ASP.NET 模型绑定忽略未知属性,\n // 对无该字段的 DTO(如 books)安全。\n const data = stamp != null ? ({ ...base, concurrencyStamp: stamp } as TUpdate) : base;\n await rawUpdate.mutateAsync({ id: sheet.record.id, data });\n } else {\n const toCreate = opts.toCreate ?? ((v: TValues) => v as unknown as TCreate);\n await rawCreate.mutateAsync({ data: toCreate(value) });\n }\n },\n onSuccess: () => setSheet(null),\n }),\n );\n const isSubmitting = useStore(form.store, (state) => state.isSubmitting);\n\n // 调用方的 opts 多是内联字面量、每渲染新建,钉在 ref 上 open 才能引用稳定。\n // 这是硬要求:AbpTable 的 columns memo 依赖 onOpen,它一变整张表的列模型就重建,\n // 还会触发 data-table 的 columns churn 告警去误指调用方。\n const optsRef = useRef(opts);\n optsRef.current = opts;\n\n // 默认 toValues:按 emptyValues 的键从记录 pick,null/undefined 回退成空值,够 roles 这类\n // 「表单形状 ≡ DTO 子集」的页用;密码不回显这种 mode 语义仍要显式传 toValues。\n // useCallback([]) 钉稳身份,只读 optsRef.current,ref 免疫过期闭包。\n const defaultToValues = useCallback((record: TDto): TValues => {\n const empty = optsRef.current.emptyValues as Record<string, unknown>;\n const next: Record<string, unknown> = { ...empty };\n for (const key of Object.keys(next)) {\n const value = (record as Record<string, unknown>)[key];\n if (value !== undefined && value !== null) next[key] = value;\n }\n return next as TValues;\n }, []);\n\n const open = useCallback(\n async (nextMode: SheetFormMode, nextRecord?: TDto) => {\n const o = optsRef.current;\n let values = o.emptyValues;\n if (nextRecord) {\n const resolved = o.toValues\n ? await o.toValues(nextRecord, nextMode)\n : defaultToValues(nextRecord);\n if (resolved === null) return;\n values = resolved;\n }\n setFormDefaults(values);\n form.reset(values);\n setSheet({ mode: nextMode, record: nextRecord });\n },\n [form, defaultToValues],\n );\n\n const allow = (policy: string | undefined) => (policy === undefined ? true : can(policy));\n\n const sheetProps = {\n mode: mode ?? \"create\",\n open: sheet !== null,\n onOpenChange: (nextOpen: boolean) => {\n if (!nextOpen) setSheet(null);\n },\n title: L(\n mode === \"edit\" ? \"Crud:EditTitle\" : mode === \"view\" ? \"Crud:ViewTitle\" : \"Crud:Create\",\n ),\n onSubmit: () => {\n void form.handleSubmit();\n },\n pending: isSubmitting,\n canEdit: allow(service.resolvedPolicies.update),\n onEdit: () => {\n if (sheet) setSheet({ ...sheet, mode: \"edit\" });\n },\n } satisfies Omit<SheetFormProps, \"children\">;\n\n const bound = useBoundComponents({ sheetProps }, (read) => ({\n Sheet: (p: { title?: string; children: ReactNode }) => (\n <SheetForm {...read().sheetProps} {...(p.title !== undefined ? { title: p.title } : {})}>\n {p.children}\n </SheetForm>\n ),\n }));\n\n return {\n form,\n open,\n close: () => setSheet(null),\n record,\n readOnly: mode === \"view\",\n mode,\n Sheet: bound.Sheet,\n };\n}\n",
19
+ "content": "import { useLocalization, usePermissionChecker } from \"@jcoder-stack/abp-react/react\";\nimport { type FormValidateOrFn, useStore } from \"@tanstack/react-form\";\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport { type ReactNode, useCallback, useRef, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { abpFormOptions } from \"@/components/abp/crud/abp-form-options\";\nimport { useBoundComponents } from \"@/components/abp/crud/create-bound-components\";\nimport type { WritableCrudService } from \"@/components/abp/crud/crud-service\";\nimport { useAppForm } from \"@/components/form/form-hook\";\nimport { SheetForm, type SheetFormMode, type SheetFormProps } from \"@/components/form/sheet-form\";\n\n/** form 块的入参。`emptyValues` 之外三个映射都可以省:`toValues` 缺省按 `emptyValues` 的键\n * 从记录里 pick,null/undefined 回退成空值,roles 这类「表单形状 ≡ DTO 子集」的页够用了。\n * `toCreate`/`toUpdate` 只在 `TValues` 结构上可赋给对应 DTO 时才允许省略;形状不匹配\n * (比如 tenants 的 update DTO 没有 admin 字段)编译期就报错,不会到运行时才传错字段。 */\nexport type AbpSheetOptions<\n TDto extends { id?: string; concurrencyStamp?: string | null },\n TValues,\n TCreate,\n TUpdate,\n> = {\n emptyValues: TValues;\n /** record → 表单值;可异步(预取关联数据);返回 null 表示取消打开(调用方自行提示)。 */\n toValues?: (record: TDto, mode: SheetFormMode) => TValues | Promise<TValues | null>;\n schema?: (mode: SheetFormMode) => FormValidateOrFn<TValues>;\n /** 抽屉标题。缺省是「新增 / 编辑 / 查看」这样的裸动词——没有宾语,读者不知道在新增什么,\n * 所以有实体名的页都该给一个(「新建图书」)。查看态通常直接用记录本身当标题。 */\n title?: (mode: SheetFormMode, record: TDto | undefined) => string;\n /** 标题下的记录标识,例如「刘慈欣 · 2008-01-01」。返回 undefined 则不占那一行。 */\n subtitle?: (mode: SheetFormMode, record: TDto | undefined) => string | undefined;\n} & (TValues extends TCreate\n ? { toCreate?: (value: TValues) => TCreate }\n : { toCreate: (value: TValues) => TCreate }) &\n (TValues extends TUpdate\n ? { toUpdate?: (value: TValues) => TUpdate }\n : { toUpdate: (value: TValues) => TUpdate });\n\n/** service → sheet 三态状态机 + create/update mutation。直接吃 `WritableCrudService`,\n * 调用方不用另建一份 mutation 自己接线。sheet 状态机、concurrencyStamp 注入、保存 toast\n * 与列表失效都收在这里。绑定成员 `Sheet` 经 `useBoundComponents` 挂出稳定身份。 */\nexport function useAbpSheet<\n TDto extends { id?: string; concurrencyStamp?: string | null },\n TValues,\n TCreate,\n TUpdate,\n>(\n service: WritableCrudService<TDto, TCreate, TUpdate>,\n opts: AbpSheetOptions<TDto, TValues, TCreate, TUpdate>,\n) {\n const L = useLocalization();\n const can = usePermissionChecker();\n const queryClient = useQueryClient();\n\n const [sheet, setSheet] = useState<{ mode: SheetFormMode; record?: TDto } | null>(null);\n // defaultValues 必须由 state 驱动:useForm 每渲染 formApi.update(opts) 会把稳定常量\n // defaultValues 拍回刚 reset 的记录值。\n const [formDefaults, setFormDefaults] = useState<TValues>(opts.emptyValues);\n\n const mode = sheet?.mode;\n const record = sheet?.record;\n\n const invalidate = () => queryClient.invalidateQueries({ queryKey: service.listKey() });\n const saved = () => {\n toast.success(L(\"Crud:Saved\"));\n void invalidate();\n };\n\n const rawCreate = service.useCreate({ mutation: { onSuccess: saved } });\n const rawUpdate = service.useUpdate({ mutation: { onSuccess: saved } });\n\n const form = useAppForm(\n abpFormOptions({\n defaultValues: formDefaults,\n schema: opts.schema?.(mode ?? \"create\"),\n // rawCreate/rawUpdate 直接调用,不包 facade。两者只在这一处读取,不像\n // use-abp-table.ts 的 deleteMutation 那样要喂一条外部 memo 链,包一层没有可观察差异。\n submit: async (value: TValues) => {\n if (sheet?.mode === \"edit\" && sheet.record?.id) {\n const stamp = sheet.record.concurrencyStamp;\n const toUpdate = opts.toUpdate ?? ((v: TValues) => v as unknown as TUpdate);\n const base = toUpdate(value);\n // 泛型上无法表达「附加可选键仍是 TUpdate」;运行时 ASP.NET 模型绑定忽略未知属性,\n // 对无该字段的 DTO(如 books)安全。\n const data = stamp != null ? ({ ...base, concurrencyStamp: stamp } as TUpdate) : base;\n await rawUpdate.mutateAsync({ id: sheet.record.id, data });\n } else {\n const toCreate = opts.toCreate ?? ((v: TValues) => v as unknown as TCreate);\n await rawCreate.mutateAsync({ data: toCreate(value) });\n }\n },\n onSuccess: () => setSheet(null),\n }),\n );\n const isSubmitting = useStore(form.store, (state) => state.isSubmitting);\n // 关闭前是否需要拦一道:只有真改过东西才值得打断,查看态永远不拦。\n const isDirty = useStore(form.store, (state) => state.isDirty);\n\n // 调用方的 opts 多是内联字面量、每渲染新建,钉在 ref 上 open 才能引用稳定。\n // 这是硬要求:AbpTable 的 columns memo 依赖 onOpen,它一变整张表的列模型就重建,\n // 还会触发 data-table 的 columns churn 告警去误指调用方。\n const optsRef = useRef(opts);\n optsRef.current = opts;\n\n // 默认 toValues:按 emptyValues 的键从记录 pick,null/undefined 回退成空值,够 roles 这类\n // 「表单形状 ≡ DTO 子集」的页用;密码不回显这种 mode 语义仍要显式传 toValues。\n // useCallback([]) 钉稳身份,只读 optsRef.current,ref 免疫过期闭包。\n const defaultToValues = useCallback((record: TDto): TValues => {\n const empty = optsRef.current.emptyValues as Record<string, unknown>;\n const next: Record<string, unknown> = { ...empty };\n for (const key of Object.keys(next)) {\n const value = (record as Record<string, unknown>)[key];\n if (value !== undefined && value !== null) next[key] = value;\n }\n return next as TValues;\n }, []);\n\n const open = useCallback(\n async (nextMode: SheetFormMode, nextRecord?: TDto) => {\n const o = optsRef.current;\n let values = o.emptyValues;\n if (nextRecord) {\n const resolved = o.toValues\n ? await o.toValues(nextRecord, nextMode)\n : defaultToValues(nextRecord);\n if (resolved === null) return;\n values = resolved;\n }\n setFormDefaults(values);\n form.reset(values);\n setSheet({ mode: nextMode, record: nextRecord });\n },\n [form, defaultToValues],\n );\n\n const allow = (policy: string | undefined) => (policy === undefined ? true : can(policy));\n\n const sheetProps = {\n mode: mode ?? \"create\",\n open: sheet !== null,\n onOpenChange: (nextOpen: boolean) => {\n if (!nextOpen) setSheet(null);\n },\n title:\n opts.title?.(mode ?? \"create\", sheet?.record) ??\n L(mode === \"edit\" ? \"Crud:EditTitle\" : mode === \"view\" ? \"Crud:ViewTitle\" : \"Crud:Create\"),\n subtitle: opts.subtitle?.(mode ?? \"create\", sheet?.record),\n onSubmit: () => {\n void form.handleSubmit();\n },\n pending: isSubmitting,\n dirty: isDirty && mode !== \"view\",\n canEdit: allow(service.resolvedPolicies.update),\n onEdit: () => {\n if (sheet) setSheet({ ...sheet, mode: \"edit\" });\n },\n } satisfies Omit<SheetFormProps, \"children\">;\n\n const bound = useBoundComponents({ sheetProps }, (read) => ({\n Sheet: (p: { title?: string; children: ReactNode }) => (\n <SheetForm {...read().sheetProps} {...(p.title !== undefined ? { title: p.title } : {})}>\n {p.children}\n </SheetForm>\n ),\n }));\n\n return {\n form,\n open,\n close: () => setSheet(null),\n record,\n readOnly: mode === \"view\",\n mode,\n Sheet: bound.Sheet,\n };\n}\n",
20
20
  "type": "registry:component",
21
21
  "target": "components/abp/sheet/use-abp-sheet.tsx"
22
22
  }
@@ -65,7 +65,7 @@
65
65
  },
66
66
  {
67
67
  "path": "ui/blocks/abp-table/row-actions-menu.tsx",
68
- "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { Eye, MoreHorizontal, Pencil, Trash2 } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport { useState } from \"react\";\nimport type { CellTableInstance } from \"@/components/data-table/table-core\";\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\ninterface RowActionsMenuSource {\n can: { update: boolean; delete: boolean };\n /** 只读数据源(无删除能力)时这个键不存在;删除项即使 `show.delete` 为真也不会渲染。 */\n delete?: { mutate: (id: string) => void };\n}\n\n/** `renderRowActions` 收到的内置行操作零件。公理:三个成员一律「元素 | undefined」,undefined\n * 当且仅当该能力未启用(`rowMenu.view`/`edit`/`delete` 判定为 false,或无权限/无 `onOpen`)。\n * 所以 `builtins.x ?? 兜底` 永远可用,不会有「渲染成 null 的元素」把兜底短路掉。 */\nexport interface RowActionBuiltins {\n view?: ReactNode;\n edit?: ReactNode;\n delete?: ReactNode;\n}\n\nexport interface RowActionsMenuProps<TDto extends { id?: string }> {\n record: TDto;\n table: CellTableInstance<TDto>;\n onOpen?: (mode: \"create\" | \"edit\" | \"view\", record?: TDto) => void;\n source: RowActionsMenuSource;\n rowActions?: (row: TDto, table: CellTableInstance<TDto>) => ReactNode;\n show: { view: boolean; edit: boolean; delete: boolean };\n items?: (row: TDto, table: CellTableInstance<TDto>) => ReactNode;\n render?: (row: TDto, builtins: RowActionBuiltins, table: CellTableInstance<TDto>) => ReactNode;\n}\n\n/** 行操作合并为 `···` 菜单;删除确认用受控 AlertDialog,脱出 DropdownMenu 之外。\n * 二者嵌套时 DropdownMenu 卸载会连带把内嵌 AlertDialogContent 一并卸掉。 */\nexport function RowActionsMenu<TDto extends { id?: string }>(props: RowActionsMenuProps<TDto>) {\n const L = useLocalization();\n const { record, table, onOpen, source, rowActions, show } = props;\n const [confirmOpen, setConfirmOpen] = useState(false);\n\n const viewItem = show.view ? (\n <DropdownMenuItem key=\"view\" onClick={() => onOpen?.(\"view\", record)}>\n <Eye />\n {L(\"Table:View\")}\n </DropdownMenuItem>\n ) : undefined;\n const editItem = show.edit ? (\n <DropdownMenuItem key=\"edit\" onClick={() => onOpen?.(\"edit\", record)}>\n <Pencil />\n {L(\"Form:Edit\")}\n </DropdownMenuItem>\n ) : undefined;\n // 无删除能力(source.delete undefined)时不渲染删除项。即使 show.delete 因调用方失误传了\n // true,也不会露出一个点了什么都不发生的按钮。\n const deleteItem =\n show.delete && source.delete ? (\n <DropdownMenuItem\n key=\"delete\"\n variant=\"destructive\"\n onSelect={(e) => {\n e.preventDefault();\n setConfirmOpen(true);\n }}\n >\n <Trash2 />\n {L(\"Crud:Delete\")}\n </DropdownMenuItem>\n ) : undefined;\n\n const extraItems = props.render ? undefined : props.items?.(record, table);\n const hasMenu = Boolean(viewItem || editItem || deleteItem || extraItems);\n\n const menuContent = props.render ? (\n props.render(record, { view: viewItem, edit: editItem, delete: deleteItem }, table)\n ) : hasMenu ? (\n <DropdownMenu modal={false}>\n <DropdownMenuTrigger asChild>\n <Button variant=\"ghost\" size=\"icon\" className=\"size-8\" aria-label={L(\"Table:Actions\")}>\n <MoreHorizontal />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\">\n {viewItem}\n {editItem}\n {deleteItem}\n {extraItems}\n </DropdownMenuContent>\n </DropdownMenu>\n ) : null;\n\n return (\n // biome-ignore lint/a11y/noStaticElementInteractions: 仅拦截冒泡以防触发行点击详情,非交互元素\n <div\n className=\"flex items-center gap-1\"\n onClick={(e) => e.stopPropagation()}\n onKeyDown={(e) => e.stopPropagation()}\n >\n {rowActions?.(record, table)}\n {menuContent && (\n <span className=\"opacity-0 focus-within:opacity-100 group-hover:opacity-100\">\n {menuContent}\n </span>\n )}\n <AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>\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 onClick={() => record.id && source.delete?.mutate(record.id)}>\n {L(\"Crud:Delete\")}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n </div>\n );\n}\n",
68
+ "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { Eye, MoreHorizontal, Pencil, Trash2 } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport { useState } from \"react\";\nimport type { CellTableInstance } from \"@/components/data-table/table-core\";\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\ninterface RowActionsMenuSource {\n can: { update: boolean; delete: boolean };\n /** 只读数据源(无删除能力)时这个键不存在;删除项即使 `show.delete` 为真也不会渲染。 */\n delete?: { mutate: (id: string) => void };\n}\n\n/** `renderRowActions` 收到的内置行操作零件。公理:三个成员一律「元素 | undefined」,undefined\n * 当且仅当该能力未启用(`rowMenu.view`/`edit`/`delete` 判定为 false,或无权限/无 `onOpen`)。\n * 所以 `builtins.x ?? 兜底` 永远可用,不会有「渲染成 null 的元素」把兜底短路掉。 */\nexport interface RowActionBuiltins {\n view?: ReactNode;\n edit?: ReactNode;\n delete?: ReactNode;\n}\n\nexport interface RowActionsMenuProps<TDto extends { id?: string }> {\n record: TDto;\n table: CellTableInstance<TDto>;\n onOpen?: (mode: \"create\" | \"edit\" | \"view\", record?: TDto) => void;\n source: RowActionsMenuSource;\n rowActions?: (row: TDto, table: CellTableInstance<TDto>) => ReactNode;\n show: { view: boolean; edit: boolean; delete: boolean };\n items?: (row: TDto, table: CellTableInstance<TDto>) => ReactNode;\n render?: (row: TDto, builtins: RowActionBuiltins, table: CellTableInstance<TDto>) => ReactNode;\n}\n\n/** 行操作合并为 `···` 菜单;删除确认用受控 AlertDialog,脱出 DropdownMenu 之外。\n * 二者嵌套时 DropdownMenu 卸载会连带把内嵌 AlertDialogContent 一并卸掉。 */\nexport function RowActionsMenu<TDto extends { id?: string }>(props: RowActionsMenuProps<TDto>) {\n const L = useLocalization();\n const { record, table, onOpen, source, rowActions, show } = props;\n const [confirmOpen, setConfirmOpen] = useState(false);\n\n const viewItem = show.view ? (\n <DropdownMenuItem key=\"view\" onClick={() => onOpen?.(\"view\", record)}>\n <Eye />\n {L(\"Table:View\")}\n </DropdownMenuItem>\n ) : undefined;\n const editItem = show.edit ? (\n <DropdownMenuItem key=\"edit\" onClick={() => onOpen?.(\"edit\", record)}>\n <Pencil />\n {L(\"Form:Edit\")}\n </DropdownMenuItem>\n ) : undefined;\n // 无删除能力(source.delete undefined)时不渲染删除项。即使 show.delete 因调用方失误传了\n // true,也不会露出一个点了什么都不发生的按钮。\n const deleteItem =\n show.delete && source.delete ? (\n <DropdownMenuItem\n key=\"delete\"\n variant=\"destructive\"\n onSelect={(e) => {\n e.preventDefault();\n setConfirmOpen(true);\n }}\n >\n <Trash2 />\n {L(\"Crud:Delete\")}\n </DropdownMenuItem>\n ) : undefined;\n\n const extraItems = props.render ? undefined : props.items?.(record, table);\n const hasMenu = Boolean(viewItem || editItem || deleteItem || extraItems);\n\n const menuContent = props.render ? (\n props.render(record, { view: viewItem, edit: editItem, delete: deleteItem }, table)\n ) : hasMenu ? (\n <DropdownMenu modal={false}>\n <DropdownMenuTrigger asChild>\n <Button variant=\"ghost\" size=\"icon\" className=\"size-7\" aria-label={L(\"Table:Actions\")}>\n <MoreHorizontal />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\">\n {viewItem}\n {editItem}\n {deleteItem}\n {extraItems}\n </DropdownMenuContent>\n </DropdownMenu>\n ) : null;\n\n return (\n // biome-ignore lint/a11y/noStaticElementInteractions: 仅拦截冒泡以防触发行点击详情,非交互元素\n <div\n className=\"flex items-center gap-1\"\n onClick={(e) => e.stopPropagation()}\n onKeyDown={(e) => e.stopPropagation()}\n >\n {rowActions?.(record, table)}\n {menuContent && (\n <span className=\"opacity-0 focus-within:opacity-100 group-hover:opacity-100\">\n {menuContent}\n </span>\n )}\n <AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>\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 onClick={() => record.id && source.delete?.mutate(record.id)}>\n {L(\"Crud:Delete\")}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n </div>\n );\n}\n",
69
69
  "type": "registry:component",
70
70
  "target": "components/abp/table/row-actions-menu.tsx"
71
71
  },
@@ -77,7 +77,7 @@
77
77
  },
78
78
  {
79
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",
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 BoolCell(props: { value: boolean | undefined; status: Status | undefined }) {\n const L = useLocalization();\n const text = L(props.value ? \"Table:Yes\" : \"Table:No\");\n if (props.status === undefined) {\n return <span className={props.value ? undefined : \"text-muted-foreground\"}>{text}</span>;\n }\n return <StatusBadge status={props.status}>{text}</StatusBadge>;\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 /** 值为真时的徽章语义色。不传就渲染成普通文字(否为 muted)——布尔字段多数是**属性**\n * (isDefault、isPublic),徽章的含义是「这条记录当前处于某状态」,套在属性上既夸大了它,\n * 又逼着为它挑一个说不出理由的颜色。只有「启用/健康」这类真正的状态才传 `success`。 */\n trueStatus?: Status;\n /** 值为假时的徽章语义色;仅在 `trueStatus` 给出时生效,默认 `neutral`。 */\n falseStatus?: Status;\n } & ColumnOverrides<TData>,\n ): TableColumnDef<TData> => {\n const { trueStatus, 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 const status = trueStatus === undefined ? undefined : value ? trueStatus : falseStatus;\n return <BoolCell value={value} status={status} />;\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
81
  "type": "registry:component",
82
82
  "target": "components/abp/table/column-presets.tsx"
83
83
  }
@@ -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, 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",
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 title: (mode, record) =>\n mode === \"create\" ? L(\"App::UserCreateTitle\") : (record?.userName ?? \"\"),\n subtitle: (mode, record) => (mode === \"create\" ? undefined : (record?.email ?? undefined)),\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, 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",
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 // 新建态给宾语;编辑/查看态以记录本身领衔。这两页都只有 name 一个可作标识的字段,\n // 没有第二个有信息量的标识,所以不给副标题——副标题只在真有东西可说时才占那一行。\n title: (mode, record) => (mode === \"create\" ? L(\"App::RoleCreateTitle\") : (record?.name ?? \"\")),\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 { 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",
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 // 新建态给宾语;编辑/查看态以记录本身领衔。这两页都只有 name 一个可作标识的字段,\n // 没有第二个有信息量的标识,所以不给副标题——副标题只在真有东西可说时才占那一行。\n title: (mode, record) =>\n mode === \"create\" ? L(\"App::TenantCreateTitle\") : (record?.name ?? \"\"),\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
  },
@@ -33,7 +33,7 @@
33
33
  },
34
34
  {
35
35
  "path": "ui/blocks/app-shell/routes/index.tsx",
36
- "content": "import { useLocalization, useSession } from \"@jcoder-stack/abp-react/react\";\nimport { createFileRoute } from \"@tanstack/react-router\";\nimport {\n ArrowRight,\n Building2,\n Check,\n Code2,\n Copy,\n KeyRound,\n Languages,\n LayoutDashboard,\n type LucideIcon,\n ShieldCheck,\n Terminal,\n} from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport { useState } from \"react\";\nimport { BrandMark } from \"@/components/abp/layout/brand-mark\";\nimport { LocaleSwitcher } from \"@/components/abp/layout/locale-switcher\";\nimport { ThemeToggle } from \"@/components/abp/layout/theme-toggle\";\nimport { Button } from \"@/components/ui/button\";\nimport { TooltipProvider } from \"@/components/ui/tooltip\";\nimport { clientEnv } from \"@/env\";\nimport { cn } from \"@/lib/utils\";\nimport { AbpTableDemo } from \"./-showcase/abp-table-demo\";\nimport { ComboboxDemo } from \"./-showcase/combobox-demo\";\nimport { DataTableDemo } from \"./-showcase/data-table-demo\";\nimport { DatePickerDemo } from \"./-showcase/date-picker-demo\";\nimport { FeatureRow, UsageRow } from \"./-showcase/feature-row\";\nimport { FormDemo } from \"./-showcase/form-demo\";\nimport { TreeDemo } from \"./-showcase/tree-demo\";\n\nexport const Route = createFileRoute(\"/\")({ component: Landing });\n\nconst INSTALL_COMMAND = \"npx jc-abp init\";\n\nconst FEATURES: { icon: LucideIcon; titleKey: string; descKey: string }[] = [\n { icon: KeyRound, titleKey: \"Landing:FeatureAuthTitle\", descKey: \"Landing:FeatureAuthDesc\" },\n { icon: ShieldCheck, titleKey: \"Landing:FeaturePermTitle\", descKey: \"Landing:FeaturePermDesc\" },\n { icon: Languages, titleKey: \"Landing:FeatureI18nTitle\", descKey: \"Landing:FeatureI18nDesc\" },\n { icon: Code2, titleKey: \"Landing:FeatureApiTitle\", descKey: \"Landing:FeatureApiDesc\" },\n {\n icon: LayoutDashboard,\n titleKey: \"Landing:FeatureAdminTitle\",\n descKey: \"Landing:FeatureAdminDesc\",\n },\n { icon: Building2, titleKey: \"Landing:FeatureTenantTitle\", descKey: \"Landing:FeatureTenantDesc\" },\n];\n\nconst USAGE_ROWS = [\n {\n titleKey: \"Landing:UsageI18nTitle\",\n descKey: \"Landing:UsageI18nDesc\",\n bulletKeys: [\"Landing:UsageI18nB1\", \"Landing:UsageI18nB2\", \"Landing:UsageI18nB3\"],\n label: \"useLocalization()\",\n code: 'const L = useLocalization()\\n\\nL(\"AbpIdentity::DisplayName:UserName\")\\nL(\"Table:PageOf\", page, total)\\n\\nconst culture = useCulture()',\n },\n {\n titleKey: \"Landing:UsagePermTitle\",\n descKey: \"Landing:UsagePermDesc\",\n bulletKeys: [\"Landing:UsagePermB1\", \"Landing:UsagePermB2\", \"Landing:UsagePermB3\"],\n label: \"permissions\",\n code: 'import { IdentityPermissions } from \"@/permissions\"\\n\\nbeforeLoad: requirePermission(IdentityPermissions.Users.Default)\\n\\nconst can = usePermissionChecker()\\ncan.any([IdentityPermissions.Users.Create, IdentityPermissions.Users.Update])\\n\\n<PermissionGuard policy={IdentityPermissions.Users.Delete}>…</PermissionGuard>',\n },\n {\n titleKey: \"Landing:UsageCrudTitle\",\n descKey: \"Landing:UsageCrudDesc\",\n bulletKeys: [\"Landing:UsageCrudB1\", \"Landing:UsageCrudB2\", \"Landing:UsageCrudB3\"],\n label: \"identity/roles.tsx\",\n code: 'const roleService = createCrudService({\\n useList: useGetApiIdentityRoles,\\n useCreate: usePostApiIdentityRoles,\\n useUpdate: usePutApiIdentityRolesId,\\n useDelete: useDeleteApiIdentityRolesId,\\n listKey: getGetApiIdentityRolesQueryKey,\\n policy: IdentityPermissions.Roles.Default,\\n})\\n\\nconst sheet = useAbpSheet(roleService, { emptyValues, schema: () => roleSchema })\\nconst t = useAbpTable(roleService, { columns, onOpen: sheet.open })\\n\\n<t.Table>\\n <t.BulkBar>\\n <t.BulkDelete />\\n </t.BulkBar>\\n</t.Table>\\n\\n<sheet.Sheet>\\n <sheet.form.AppField name=\"name\">\\n {(field) => <field.TextField label={L(\"...:RoleName\")} required />}\\n </sheet.form.AppField>\\n</sheet.Sheet>',\n },\n {\n titleKey: \"Landing:UsageMenuTitle\",\n descKey: \"Landing:UsageMenuDesc\",\n bulletKeys: [\"Landing:UsageMenuB1\", \"Landing:UsageMenuB2\", \"Landing:UsageMenuB3\"],\n label: \"menu.tsx\",\n code: 'export const menuItems: MenuItem<FileRouteTypes[\"to\"]>[] = [\\n { key: \"home\", label: \"App::Home\", to: \"/\" },\\n {\\n key: \"identity\",\\n label: \"AbpIdentity::Menu:IdentityManagement\",\\n children: [\\n { key: \"users\", label: \"AbpIdentity::Users\", to: \"/identity/users\",\\n requiredPolicy: IdentityPermissions.Users.Default },\\n ],\\n },\\n { key: \"tenants\", label: \"...Tenants\", to: \"/tenants\",\\n requiredPolicy: TenantManagementPermissions.Tenants.Default,\\n requiredFeature: \"AbpTenantManagement.Enable\" },\\n]',\n },\n] as const;\n\nconst STACK = [\n { name: \"React 19\", roleKey: \"Landing:StackReact\" },\n { name: \"TanStack Start\", roleKey: \"Landing:StackStart\" },\n { name: \"TanStack Query\", roleKey: \"Landing:StackQuery\" },\n { name: \"TanStack Table\", roleKey: \"Landing:StackTable\" },\n { name: \"TanStack Form\", roleKey: \"Landing:StackForm\" },\n { name: \"shadcn/ui\", roleKey: \"Landing:StackShadcn\" },\n { name: \"Tailwind v4\", roleKey: \"Landing:StackTailwind\" },\n { name: \"Orval\", roleKey: \"Landing:StackOrval\" },\n { name: \"ABP\", roleKey: \"Landing:StackAbp\" },\n] as const;\n\nconst IN_ACTION_ROWS = [\n {\n name: \"data-table\",\n snippet:\n \"const dt = useDataTable({ state, columns, data, pageCount });\\n<DataTable table={dt} />\",\n titleKey: \"Showcase:DataTableTitle\",\n descKey: \"Showcase:DataTableDesc\",\n bulletKeys: [\"Showcase:DataTableB1\", \"Showcase:DataTableB2\", \"Showcase:DataTableB3\"],\n Demo: DataTableDemo,\n },\n {\n name: \"form\",\n snippet: '<SheetForm mode=\"create\" onSubmit={submit}>…</SheetForm>',\n titleKey: \"Showcase:FormTitle\",\n descKey: \"Showcase:FormDesc\",\n bulletKeys: [\"Showcase:FormB1\", \"Showcase:FormB2\", \"Showcase:FormB3\"],\n Demo: FormDemo,\n },\n {\n name: \"tree\",\n snippet: \"<Tree nodes={nodes} checkable checked={checked} />\",\n titleKey: \"Showcase:TreeTitle\",\n descKey: \"Showcase:TreeDesc\",\n bulletKeys: [\"Showcase:TreeB1\", \"Showcase:TreeB2\", \"Showcase:TreeB3\"],\n Demo: TreeDemo,\n },\n {\n name: \"combobox\",\n snippet: \"<Combobox value={value} onChange={setValue} options={options} />\",\n titleKey: \"Showcase:ComboboxTitle\",\n descKey: \"Showcase:ComboboxDesc\",\n bulletKeys: [\"Showcase:ComboboxB1\", \"Showcase:ComboboxB2\", \"Showcase:ComboboxB3\"],\n Demo: ComboboxDemo,\n },\n {\n name: \"date-picker\",\n snippet: \"<DatePicker value={date} onChange={setDate} />\",\n titleKey: \"Showcase:DatePickerTitle\",\n descKey: \"Showcase:DatePickerDesc\",\n bulletKeys: [\"Showcase:DatePickerB1\", \"Showcase:DatePickerB2\", \"Showcase:DatePickerB3\"],\n Demo: DatePickerDemo,\n },\n {\n name: \"abp-table\",\n snippet: \"const t = useAbpTable(bookService, { columns, onOpen: sheet.open });\\n<t.Table />\",\n titleKey: \"Showcase:AbpTableTitle\",\n descKey: \"Showcase:AbpTableDesc\",\n bulletKeys: [\n \"Showcase:AbpTableCrud\",\n \"Showcase:AbpTablePermission\",\n \"Showcase:AbpTableConcurrency\",\n ],\n Demo: AbpTableDemo,\n },\n] as const;\n\ntype Localizer = (key: string, ...args: (string | number)[]) => string;\n\n/**\n * 匿名态给登录入口,认证态给「进入控制台」(整页跳转进 shell)。换成你自己的官网即可;文案在\n * _layout/shell-messages.json 的 Landing:* / Showcase:* 桶。\n */\nfunction Landing() {\n const L = useLocalization();\n const { status, identity } = useSession();\n const authed = status === \"authenticated\";\n\n return (\n // 落地页在 _layout 侧边栏壳之外,拿不到 SidebarProvider 自带的 TooltipProvider;\n // 顶栏的 ThemeToggle/LocaleSwitcher 用了 Tooltip,这里自备一个。\n <TooltipProvider delayDuration={0}>\n <div className=\"flex min-h-svh flex-col bg-background\">\n <SiteNav L={L} authed={authed} />\n <main className=\"flex-1\">\n <Hero L={L} authed={authed} userName={identity.user?.userName ?? \"\"} />\n <Features L={L} />\n <InAction L={L} />\n <Usage L={L} />\n <StackSection L={L} />\n <CtaBand L={L} authed={authed} />\n </main>\n <SiteFooter L={L} />\n </div>\n </TooltipProvider>\n );\n}\n\nfunction Shell({ children, className }: { children: ReactNode; className?: string }) {\n return <div className={cn(\"mx-auto w-full max-w-6xl px-6\", className)}>{children}</div>;\n}\n\nfunction Brand() {\n return (\n <span className=\"flex items-center gap-2.5\">\n <BrandMark className=\"size-8 shrink-0\" />\n <span className=\"text-lg font-semibold\">{clientEnv.VITE_APP_TITLE}</span>\n </span>\n );\n}\n\n/** 认证态跳控制台用整页跳转(`<a href>`)而非 typed Link:进 shell 是上下文切换。落点是零权限\n * 要求的控制台首页 /home——指向某个权限页会让没有该权限的用户一进门就 403。 */\nfunction ConsoleLink({ L, size }: { L: Localizer; size?: \"sm\" | \"lg\" }) {\n return (\n <Button asChild size={size}>\n <a href=\"/home\">\n {L(\"Landing:NavConsole\")}\n {size === \"lg\" && <ArrowRight />}\n </a>\n </Button>\n );\n}\n\nfunction SignInLink({ L, size, label }: { L: Localizer; size?: \"sm\" | \"lg\"; label: string }) {\n return (\n <Button asChild size={size}>\n <a href=\"/api/auth/login\">\n {L(label)}\n {size === \"lg\" && <ArrowRight />}\n </a>\n </Button>\n );\n}\n\nfunction SiteNav({ L, authed }: { L: Localizer; authed: boolean }) {\n return (\n <header className=\"sticky top-0 z-20 border-b bg-background/80 backdrop-blur\">\n <Shell className=\"flex h-16 items-center gap-6\">\n <Brand />\n <nav className=\"ml-4 hidden items-center gap-6 text-sm text-muted-foreground md:flex\">\n <a href=\"#features\" className=\"transition-colors hover:text-foreground\">\n {L(\"Landing:NavFeatures\")}\n </a>\n <a href=\"#in-action\" className=\"transition-colors hover:text-foreground\">\n {L(\"Landing:NavComponents\")}\n </a>\n </nav>\n <div className=\"ml-auto flex items-center gap-1\">\n <LocaleSwitcher />\n <ThemeToggle />\n <span className=\"ml-1\">\n {authed ? (\n <ConsoleLink L={L} size=\"sm\" />\n ) : (\n <SignInLink L={L} size=\"sm\" label=\"Landing:NavSignIn\" />\n )}\n </span>\n </div>\n </Shell>\n </header>\n );\n}\n\nfunction Hero({ L, authed, userName }: { L: Localizer; authed: boolean; userName: string }) {\n return (\n <section className=\"relative overflow-hidden border-b\">\n <div\n aria-hidden\n className=\"pointer-events-none absolute inset-x-0 -top-40 flex justify-center\"\n >\n <div className=\"size-[38rem] rounded-full bg-primary/15 blur-[120px]\" />\n </div>\n <Shell className=\"relative flex flex-col items-center py-24 text-center\">\n <span className=\"inline-flex items-center gap-2 rounded-full border bg-card/60 px-3 py-1 text-2xs font-semibold uppercase tracking-wider text-muted-foreground\">\n <span className=\"size-1.5 rounded-full bg-primary\" />\n {L(\"Landing:HeroEyebrow\")}\n </span>\n <h1 className=\"mt-6 max-w-3xl text-4xl font-medium sm:text-5xl\">\n {L(\"Landing:HeroTitleLead\")}{\" \"}\n <span className=\"bg-gradient-to-r from-primary to-sidebar-primary bg-clip-text text-transparent\">\n {L(\"Landing:HeroTitleAccent\")}\n </span>\n </h1>\n <p className=\"mt-5 max-w-xl text-base leading-relaxed text-muted-foreground sm:text-lg\">\n {authed ? L(\"Landing:HeroWelcomeBack\", userName) : L(\"Landing:HeroSubtitle\")}\n </p>\n <div className=\"mt-8 flex flex-wrap items-center justify-center gap-3\">\n {authed ? (\n <ConsoleLink L={L} size=\"lg\" />\n ) : (\n <SignInLink L={L} size=\"lg\" label=\"Landing:HeroPrimary\" />\n )}\n <Button asChild size=\"lg\" variant=\"outline\">\n <a href=\"#in-action\">{L(\"Landing:HeroSecondary\")}</a>\n </Button>\n </div>\n <InstallBlock L={L} />\n </Shell>\n </section>\n );\n}\n\nfunction InstallBlock({ L }: { L: Localizer }) {\n const [copied, setCopied] = useState(false);\n const copy = () => {\n navigator.clipboard?.writeText(INSTALL_COMMAND).then(() => {\n setCopied(true);\n setTimeout(() => setCopied(false), 1500);\n });\n };\n return (\n <div className=\"mt-10 w-full max-w-md\">\n <p className=\"mb-2 text-xs text-muted-foreground\">{L(\"Landing:InstallHint\")}</p>\n <div className=\"flex items-center gap-3 rounded-xl border bg-card px-4 py-3 font-mono text-sm\">\n <Terminal className=\"size-4 shrink-0 text-muted-foreground\" />\n <code className=\"flex-1 text-left text-foreground\">\n <span className=\"text-muted-foreground\">$ </span>\n {INSTALL_COMMAND}\n </code>\n <button\n type=\"button\"\n onClick={copy}\n aria-label={L(copied ? \"Landing:Copied\" : \"Landing:Copy\")}\n className=\"flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n >\n {copied ? <Check className=\"size-4 text-primary\" /> : <Copy className=\"size-4\" />}\n </button>\n </div>\n </div>\n );\n}\n\nfunction SectionEyebrow({ eyebrow, title }: { eyebrow: string; title: string }) {\n return (\n <div className=\"flex flex-col items-center text-center\">\n <span className=\"text-2xs font-semibold uppercase tracking-wider text-primary\">\n {eyebrow}\n </span>\n <h2 className=\"mt-2 max-w-2xl text-2xl font-normal sm:text-3xl\">{title}</h2>\n </div>\n );\n}\n\nfunction Features({ L }: { L: Localizer }) {\n return (\n <section id=\"features\" className=\"border-b py-20\">\n <Shell>\n <SectionEyebrow eyebrow={L(\"Landing:FeaturesEyebrow\")} title={L(\"Landing:FeaturesTitle\")} />\n <div className=\"mt-12 grid gap-4 sm:grid-cols-2 lg:grid-cols-3\">\n {FEATURES.map(({ icon: Icon, titleKey, descKey }) => (\n <div key={titleKey} className=\"rounded-xl border bg-card p-6\">\n <div className=\"flex size-10 items-center justify-center rounded-lg border bg-muted/40 text-muted-foreground\">\n <Icon className=\"size-5\" />\n </div>\n <h3 className=\"mt-4 text-base font-semibold\">{L(titleKey)}</h3>\n <p className=\"mt-1.5 text-sm leading-relaxed text-muted-foreground\">{L(descKey)}</p>\n </div>\n ))}\n </div>\n </Shell>\n </section>\n );\n}\n\nfunction InAction({ L }: { L: Localizer }) {\n return (\n <section id=\"in-action\" className=\"border-b bg-muted/20 py-24\">\n <Shell>\n <SectionEyebrow eyebrow={L(\"Landing:InActionEyebrow\")} title={L(\"Landing:InActionTitle\")} />\n <p className=\"mx-auto mt-3 max-w-2xl text-center text-sm leading-relaxed text-muted-foreground\">\n {L(\"Showcase:Intro\")}\n </p>\n <div className=\"mt-16 space-y-20 sm:space-y-28\">\n {IN_ACTION_ROWS.map((row, i) => {\n const { Demo } = row;\n return (\n <FeatureRow\n key={row.name}\n index={String(i + 1).padStart(2, \"0\")}\n name={row.name}\n snippet={row.snippet}\n title={L(row.titleKey)}\n description={L(row.descKey)}\n bullets={row.bulletKeys.map((key) => L(key))}\n reverse={i % 2 === 1}\n >\n <Demo />\n </FeatureRow>\n );\n })}\n </div>\n </Shell>\n </section>\n );\n}\n\nfunction Usage({ L }: { L: Localizer }) {\n return (\n <section className=\"border-b py-24\">\n <Shell>\n <SectionEyebrow eyebrow={L(\"Landing:UsageEyebrow\")} title={L(\"Landing:UsageTitle\")} />\n <div className=\"mt-16 space-y-20 sm:space-y-24\">\n {USAGE_ROWS.map((row, i) => (\n <UsageRow\n key={row.label}\n title={L(row.titleKey)}\n description={L(row.descKey)}\n bullets={row.bulletKeys.map((key) => L(key))}\n label={row.label}\n code={row.code}\n reverse={i % 2 === 1}\n />\n ))}\n </div>\n </Shell>\n </section>\n );\n}\n\nfunction StackSection({ L }: { L: Localizer }) {\n return (\n <section className=\"border-b bg-muted/20 py-20\">\n <Shell>\n <SectionEyebrow eyebrow={L(\"Landing:StackEyebrow\")} title={L(\"Landing:StackTitle\")} />\n <div className=\"mx-auto mt-12 grid max-w-4xl gap-x-10 gap-y-6 sm:grid-cols-2 lg:grid-cols-3\">\n {STACK.map((item) => (\n <div key={item.name} className=\"flex flex-col gap-1 border-l border-border pl-4\">\n <span className=\"font-mono text-sm text-foreground\">{item.name}</span>\n <span className=\"text-xs leading-relaxed text-muted-foreground\">\n {L(item.roleKey)}\n </span>\n </div>\n ))}\n </div>\n </Shell>\n </section>\n );\n}\n\nfunction CtaBand({ L, authed }: { L: Localizer; authed: boolean }) {\n return (\n <section className=\"border-b py-20\">\n <Shell className=\"flex flex-col items-center text-center\">\n <h2 className=\"max-w-2xl text-2xl font-normal sm:text-3xl\">{L(\"Landing:CtaTitle\")}</h2>\n <p className=\"mt-3 max-w-xl text-sm leading-relaxed text-muted-foreground\">\n {L(\"Landing:CtaSubtitle\")}\n </p>\n <div className=\"mt-8\">\n {authed ? (\n <ConsoleLink L={L} size=\"lg\" />\n ) : (\n <SignInLink L={L} size=\"lg\" label=\"Landing:HeroPrimary\" />\n )}\n </div>\n </Shell>\n </section>\n );\n}\n\nfunction SiteFooter({ L }: { L: Localizer }) {\n return (\n <footer className=\"py-10\">\n <Shell className=\"flex flex-col items-center justify-between gap-4 sm:flex-row\">\n <Brand />\n <p className=\"text-xs text-muted-foreground\">{L(\"Landing:FooterTagline\")}</p>\n </Shell>\n </footer>\n );\n}\n",
36
+ "content": "import { useLocalization, useSession } from \"@jcoder-stack/abp-react/react\";\nimport { createFileRoute } from \"@tanstack/react-router\";\nimport {\n ArrowRight,\n Building2,\n Check,\n Code2,\n Copy,\n KeyRound,\n Languages,\n LayoutDashboard,\n type LucideIcon,\n ShieldCheck,\n Terminal,\n} from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport { useState } from \"react\";\nimport { BrandMark } from \"@/components/abp/layout/brand-mark\";\nimport { LocaleSwitcher } from \"@/components/abp/layout/locale-switcher\";\nimport { ThemeToggle } from \"@/components/abp/layout/theme-toggle\";\nimport { Button } from \"@/components/ui/button\";\nimport { TooltipProvider } from \"@/components/ui/tooltip\";\nimport { clientEnv } from \"@/env\";\nimport { cn } from \"@/lib/utils\";\nimport { AbpTableDemo } from \"./-showcase/abp-table-demo\";\nimport { ComboboxDemo } from \"./-showcase/combobox-demo\";\nimport { DataTableDemo } from \"./-showcase/data-table-demo\";\nimport { DatePickerDemo } from \"./-showcase/date-picker-demo\";\nimport { FeatureRow, UsageRow } from \"./-showcase/feature-row\";\nimport { FormDemo } from \"./-showcase/form-demo\";\nimport { TreeDemo } from \"./-showcase/tree-demo\";\n\nexport const Route = createFileRoute(\"/\")({ component: Landing });\n\nconst INSTALL_COMMAND = \"npx jc-abp init\";\n\nconst FEATURES: { icon: LucideIcon; titleKey: string; descKey: string }[] = [\n { icon: KeyRound, titleKey: \"Landing:FeatureAuthTitle\", descKey: \"Landing:FeatureAuthDesc\" },\n { icon: ShieldCheck, titleKey: \"Landing:FeaturePermTitle\", descKey: \"Landing:FeaturePermDesc\" },\n { icon: Languages, titleKey: \"Landing:FeatureI18nTitle\", descKey: \"Landing:FeatureI18nDesc\" },\n { icon: Code2, titleKey: \"Landing:FeatureApiTitle\", descKey: \"Landing:FeatureApiDesc\" },\n {\n icon: LayoutDashboard,\n titleKey: \"Landing:FeatureAdminTitle\",\n descKey: \"Landing:FeatureAdminDesc\",\n },\n { icon: Building2, titleKey: \"Landing:FeatureTenantTitle\", descKey: \"Landing:FeatureTenantDesc\" },\n];\n\nconst USAGE_ROWS = [\n {\n titleKey: \"Landing:UsageI18nTitle\",\n descKey: \"Landing:UsageI18nDesc\",\n bulletKeys: [\"Landing:UsageI18nB1\", \"Landing:UsageI18nB2\", \"Landing:UsageI18nB3\"],\n label: \"useLocalization()\",\n code: 'const L = useLocalization()\\n\\nL(\"AbpIdentity::DisplayName:UserName\")\\nL(\"Table:PageOf\", page, total)\\n\\nconst culture = useCulture()',\n },\n {\n titleKey: \"Landing:UsagePermTitle\",\n descKey: \"Landing:UsagePermDesc\",\n bulletKeys: [\"Landing:UsagePermB1\", \"Landing:UsagePermB2\", \"Landing:UsagePermB3\"],\n label: \"permissions\",\n code: 'import { IdentityPermissions } from \"@/permissions\"\\n\\nbeforeLoad: requirePermission(IdentityPermissions.Users.Default)\\n\\nconst can = usePermissionChecker()\\ncan.any([IdentityPermissions.Users.Create, IdentityPermissions.Users.Update])\\n\\n<PermissionGuard policy={IdentityPermissions.Users.Delete}>…</PermissionGuard>',\n },\n {\n titleKey: \"Landing:UsageCrudTitle\",\n descKey: \"Landing:UsageCrudDesc\",\n bulletKeys: [\"Landing:UsageCrudB1\", \"Landing:UsageCrudB2\", \"Landing:UsageCrudB3\"],\n label: \"identity/roles.tsx\",\n code: 'const roleService = createCrudService({\\n useList: useGetApiIdentityRoles,\\n useCreate: usePostApiIdentityRoles,\\n useUpdate: usePutApiIdentityRolesId,\\n useDelete: useDeleteApiIdentityRolesId,\\n listKey: getGetApiIdentityRolesQueryKey,\\n policy: IdentityPermissions.Roles.Default,\\n})\\n\\nconst sheet = useAbpSheet(roleService, { emptyValues, schema: () => roleSchema })\\nconst t = useAbpTable(roleService, { columns, onOpen: sheet.open })\\n\\n<t.Table>\\n <t.BulkBar>\\n <t.BulkDelete />\\n </t.BulkBar>\\n</t.Table>\\n\\n<sheet.Sheet>\\n <sheet.form.AppField name=\"name\">\\n {(field) => <field.TextField label={L(\"...:RoleName\")} required />}\\n </sheet.form.AppField>\\n</sheet.Sheet>',\n },\n {\n titleKey: \"Landing:UsageMenuTitle\",\n descKey: \"Landing:UsageMenuDesc\",\n bulletKeys: [\"Landing:UsageMenuB1\", \"Landing:UsageMenuB2\", \"Landing:UsageMenuB3\"],\n label: \"menu.tsx\",\n code: 'export const menuItems: MenuItem<FileRouteTypes[\"to\"]>[] = [\\n { key: \"home\", label: \"App::Home\", to: \"/\" },\\n {\\n key: \"identity\",\\n label: \"AbpIdentity::Menu:IdentityManagement\",\\n children: [\\n { key: \"users\", label: \"AbpIdentity::Users\", to: \"/identity/users\",\\n requiredPolicy: IdentityPermissions.Users.Default },\\n ],\\n },\\n { key: \"tenants\", label: \"...Tenants\", to: \"/tenants\",\\n requiredPolicy: TenantManagementPermissions.Tenants.Default,\\n requiredFeature: \"AbpTenantManagement.Enable\" },\\n]',\n },\n] as const;\n\nconst STACK = [\n { name: \"React 19\", roleKey: \"Landing:StackReact\" },\n { name: \"TanStack Start\", roleKey: \"Landing:StackStart\" },\n { name: \"TanStack Query\", roleKey: \"Landing:StackQuery\" },\n { name: \"TanStack Table\", roleKey: \"Landing:StackTable\" },\n { name: \"TanStack Form\", roleKey: \"Landing:StackForm\" },\n { name: \"shadcn/ui\", roleKey: \"Landing:StackShadcn\" },\n { name: \"Tailwind v4\", roleKey: \"Landing:StackTailwind\" },\n { name: \"Orval\", roleKey: \"Landing:StackOrval\" },\n { name: \"ABP\", roleKey: \"Landing:StackAbp\" },\n] as const;\n\nconst IN_ACTION_ROWS = [\n {\n name: \"data-table\",\n snippet:\n \"const dt = useDataTable({ state, columns, data, pageCount });\\n<DataTable table={dt} />\",\n titleKey: \"Showcase:DataTableTitle\",\n descKey: \"Showcase:DataTableDesc\",\n bulletKeys: [\"Showcase:DataTableB1\", \"Showcase:DataTableB2\", \"Showcase:DataTableB3\"],\n Demo: DataTableDemo,\n },\n {\n name: \"form\",\n snippet: '<SheetForm mode=\"create\" onSubmit={submit}>…</SheetForm>',\n titleKey: \"Showcase:FormTitle\",\n descKey: \"Showcase:FormDesc\",\n bulletKeys: [\"Showcase:FormB1\", \"Showcase:FormB2\", \"Showcase:FormB3\"],\n Demo: FormDemo,\n },\n {\n name: \"tree\",\n snippet: \"<Tree nodes={nodes} checkable checked={checked} />\",\n titleKey: \"Showcase:TreeTitle\",\n descKey: \"Showcase:TreeDesc\",\n bulletKeys: [\"Showcase:TreeB1\", \"Showcase:TreeB2\", \"Showcase:TreeB3\"],\n Demo: TreeDemo,\n },\n {\n name: \"combobox\",\n snippet: \"<Combobox value={value} onChange={setValue} options={options} />\",\n titleKey: \"Showcase:ComboboxTitle\",\n descKey: \"Showcase:ComboboxDesc\",\n bulletKeys: [\"Showcase:ComboboxB1\", \"Showcase:ComboboxB2\", \"Showcase:ComboboxB3\"],\n Demo: ComboboxDemo,\n },\n {\n name: \"date-picker\",\n snippet: \"<DatePicker value={date} onChange={setDate} />\",\n titleKey: \"Showcase:DatePickerTitle\",\n descKey: \"Showcase:DatePickerDesc\",\n bulletKeys: [\"Showcase:DatePickerB1\", \"Showcase:DatePickerB2\", \"Showcase:DatePickerB3\"],\n Demo: DatePickerDemo,\n },\n {\n name: \"abp-table\",\n snippet: \"const t = useAbpTable(bookService, { columns, onOpen: sheet.open });\\n<t.Table />\",\n titleKey: \"Showcase:AbpTableTitle\",\n descKey: \"Showcase:AbpTableDesc\",\n bulletKeys: [\n \"Showcase:AbpTableCrud\",\n \"Showcase:AbpTablePermission\",\n \"Showcase:AbpTableConcurrency\",\n ],\n Demo: AbpTableDemo,\n },\n] as const;\n\ntype Localizer = (key: string, ...args: (string | number)[]) => string;\n\n/**\n * 匿名态给登录入口,认证态给「进入控制台」(整页跳转进 shell)。换成你自己的官网即可;文案在\n * _layout/shell-messages.json 的 Landing:* / Showcase:* 桶。\n */\nfunction Landing() {\n const L = useLocalization();\n const { status, identity } = useSession();\n const authed = status === \"authenticated\";\n\n return (\n // 落地页在 _layout 侧边栏壳之外,拿不到 SidebarProvider 自带的 TooltipProvider;\n // 顶栏的 ThemeToggle/LocaleSwitcher 用了 Tooltip,这里自备一个。\n <TooltipProvider delayDuration={0}>\n <div className=\"flex min-h-svh flex-col bg-background\">\n <SiteNav L={L} authed={authed} />\n <main className=\"flex-1\">\n <Hero L={L} authed={authed} userName={identity.user?.userName ?? \"\"} />\n <Features L={L} />\n <InAction L={L} />\n <Usage L={L} />\n <StackSection L={L} />\n <CtaBand L={L} authed={authed} />\n </main>\n <SiteFooter L={L} />\n </div>\n </TooltipProvider>\n );\n}\n\nfunction Shell({ children, className }: { children: ReactNode; className?: string }) {\n return <div className={cn(\"mx-auto w-full max-w-6xl px-6\", className)}>{children}</div>;\n}\n\nfunction Brand() {\n return (\n <span className=\"flex items-center gap-2.5\">\n <BrandMark className=\"size-8 shrink-0\" />\n <span className=\"text-lg font-semibold\">{clientEnv.VITE_APP_TITLE}</span>\n </span>\n );\n}\n\n/** 认证态跳控制台用整页跳转(`<a href>`)而非 typed Link:进 shell 是上下文切换。落点是零权限\n * 要求的控制台首页 /home——指向某个权限页会让没有该权限的用户一进门就 403。 */\nfunction ConsoleLink({ L, size }: { L: Localizer; size?: \"sm\" | \"lg\" }) {\n return (\n <Button asChild size={size}>\n <a href=\"/home\">\n {L(\"Landing:NavConsole\")}\n {size === \"lg\" && <ArrowRight />}\n </a>\n </Button>\n );\n}\n\nfunction SignInLink({ L, size, label }: { L: Localizer; size?: \"sm\" | \"lg\"; label: string }) {\n return (\n <Button asChild size={size}>\n <a href=\"/api/auth/login\">\n {L(label)}\n {size === \"lg\" && <ArrowRight />}\n </a>\n </Button>\n );\n}\n\nfunction SiteNav({ L, authed }: { L: Localizer; authed: boolean }) {\n return (\n <header className=\"sticky top-0 z-20 border-b bg-background/80 backdrop-blur\">\n <Shell className=\"flex h-16 items-center gap-6\">\n <Brand />\n <nav className=\"ml-4 hidden items-center gap-6 text-sm text-muted-foreground md:flex\">\n <a href=\"#features\" className=\"transition-colors hover:text-foreground\">\n {L(\"Landing:NavFeatures\")}\n </a>\n <a href=\"#in-action\" className=\"transition-colors hover:text-foreground\">\n {L(\"Landing:NavComponents\")}\n </a>\n </nav>\n <div className=\"ml-auto flex items-center gap-1\">\n <LocaleSwitcher />\n <ThemeToggle />\n <span className=\"ml-1\">\n {authed ? (\n <ConsoleLink L={L} size=\"sm\" />\n ) : (\n <SignInLink L={L} size=\"sm\" label=\"Landing:NavSignIn\" />\n )}\n </span>\n </div>\n </Shell>\n </header>\n );\n}\n\nfunction Hero({ L, authed, userName }: { L: Localizer; authed: boolean; userName: string }) {\n return (\n <section className=\"relative overflow-hidden\">\n <div\n aria-hidden\n className=\"pointer-events-none absolute inset-x-0 -top-40 flex justify-center\"\n >\n <div className=\"size-[38rem] rounded-full bg-primary/15 blur-[120px]\" />\n </div>\n <Shell className=\"relative flex flex-col items-center py-12 text-center md:py-24\">\n <span className=\"inline-flex items-center gap-2 rounded-full border bg-card/60 px-3 py-1 text-2xs font-semibold uppercase tracking-wider text-muted-foreground\">\n <span className=\"size-1.5 rounded-full bg-primary\" />\n {L(\"Landing:HeroEyebrow\")}\n </span>\n <h1 className=\"mt-6 max-w-3xl text-4xl font-medium sm:text-5xl\">\n {L(\"Landing:HeroTitleLead\")}{\" \"}\n <span className=\"bg-gradient-to-r from-primary to-sidebar-primary bg-clip-text text-transparent\">\n {L(\"Landing:HeroTitleAccent\")}\n </span>\n </h1>\n <p className=\"mt-5 max-w-xl text-base leading-relaxed text-muted-foreground sm:text-lg\">\n {authed ? L(\"Landing:HeroWelcomeBack\", userName) : L(\"Landing:HeroSubtitle\")}\n </p>\n <div className=\"mt-8 flex flex-wrap items-center justify-center gap-3\">\n {authed ? (\n <ConsoleLink L={L} size=\"lg\" />\n ) : (\n <SignInLink L={L} size=\"lg\" label=\"Landing:HeroPrimary\" />\n )}\n <Button asChild size=\"lg\" variant=\"outline\">\n <a href=\"#in-action\">{L(\"Landing:HeroSecondary\")}</a>\n </Button>\n </div>\n <InstallBlock L={L} />\n </Shell>\n </section>\n );\n}\n\nfunction InstallBlock({ L }: { L: Localizer }) {\n const [copied, setCopied] = useState(false);\n const copy = () => {\n navigator.clipboard?.writeText(INSTALL_COMMAND).then(() => {\n setCopied(true);\n setTimeout(() => setCopied(false), 1500);\n });\n };\n return (\n <div className=\"mt-10 w-full max-w-md\">\n <p className=\"mb-2 text-xs text-muted-foreground\">{L(\"Landing:InstallHint\")}</p>\n <div className=\"flex items-center gap-3 rounded-xl border bg-card px-4 py-3 font-mono text-sm\">\n <Terminal className=\"size-4 shrink-0 text-muted-foreground\" />\n <code className=\"flex-1 text-left text-foreground\">\n <span className=\"text-muted-foreground\">$ </span>\n {INSTALL_COMMAND}\n </code>\n <button\n type=\"button\"\n onClick={copy}\n aria-label={L(copied ? \"Landing:Copied\" : \"Landing:Copy\")}\n className=\"flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n >\n {copied ? <Check className=\"size-4 text-primary\" /> : <Copy className=\"size-4\" />}\n </button>\n </div>\n </div>\n );\n}\n\nfunction SectionEyebrow({ eyebrow, title }: { eyebrow: string; title: string }) {\n return (\n <div className=\"flex flex-col items-center text-center\">\n <span className=\"text-2xs font-semibold uppercase tracking-wider text-primary\">\n {eyebrow}\n </span>\n <h2 className=\"mt-2 max-w-2xl text-2xl font-normal sm:text-3xl\">{title}</h2>\n </div>\n );\n}\n\nfunction Features({ L }: { L: Localizer }) {\n return (\n <section id=\"features\" className=\"py-12 md:py-24\">\n <Shell>\n <SectionEyebrow eyebrow={L(\"Landing:FeaturesEyebrow\")} title={L(\"Landing:FeaturesTitle\")} />\n <div className=\"mt-12 grid gap-4 sm:grid-cols-2 lg:grid-cols-3\">\n {FEATURES.map(({ icon: Icon, titleKey, descKey }) => (\n <div key={titleKey} className=\"rounded-xl border bg-card p-6\">\n <div className=\"flex size-10 items-center justify-center rounded-lg border bg-muted/40 text-muted-foreground\">\n <Icon className=\"size-5\" />\n </div>\n <h3 className=\"mt-4 text-base font-semibold\">{L(titleKey)}</h3>\n <p className=\"mt-1.5 text-sm leading-relaxed text-muted-foreground\">{L(descKey)}</p>\n </div>\n ))}\n </div>\n </Shell>\n </section>\n );\n}\n\nfunction InAction({ L }: { L: Localizer }) {\n return (\n <section id=\"in-action\" className=\"bg-muted/20 py-12 md:py-24\">\n <Shell>\n <SectionEyebrow eyebrow={L(\"Landing:InActionEyebrow\")} title={L(\"Landing:InActionTitle\")} />\n <p className=\"mx-auto mt-3 max-w-2xl text-center text-sm leading-relaxed text-muted-foreground\">\n {L(\"Showcase:Intro\")}\n </p>\n <div className=\"mt-16 space-y-20 sm:space-y-28\">\n {IN_ACTION_ROWS.map((row, i) => {\n const { Demo } = row;\n return (\n <FeatureRow\n key={row.name}\n index={String(i + 1).padStart(2, \"0\")}\n name={row.name}\n snippet={row.snippet}\n title={L(row.titleKey)}\n description={L(row.descKey)}\n bullets={row.bulletKeys.map((key) => L(key))}\n reverse={i % 2 === 1}\n >\n <Demo />\n </FeatureRow>\n );\n })}\n </div>\n </Shell>\n </section>\n );\n}\n\nfunction Usage({ L }: { L: Localizer }) {\n return (\n <section className=\"py-12 md:py-24\">\n <Shell>\n <SectionEyebrow eyebrow={L(\"Landing:UsageEyebrow\")} title={L(\"Landing:UsageTitle\")} />\n <div className=\"mt-16 space-y-20 sm:space-y-24\">\n {USAGE_ROWS.map((row, i) => (\n <UsageRow\n key={row.label}\n title={L(row.titleKey)}\n description={L(row.descKey)}\n bullets={row.bulletKeys.map((key) => L(key))}\n label={row.label}\n code={row.code}\n reverse={i % 2 === 1}\n />\n ))}\n </div>\n </Shell>\n </section>\n );\n}\n\nfunction StackSection({ L }: { L: Localizer }) {\n return (\n <section className=\"bg-muted/20 py-12 md:py-24\">\n <Shell>\n <SectionEyebrow eyebrow={L(\"Landing:StackEyebrow\")} title={L(\"Landing:StackTitle\")} />\n <div className=\"mx-auto mt-12 grid max-w-4xl gap-x-10 gap-y-6 sm:grid-cols-2 lg:grid-cols-3\">\n {STACK.map((item) => (\n <div key={item.name} className=\"flex flex-col gap-1 border-l border-border pl-4\">\n <span className=\"font-mono text-sm text-foreground\">{item.name}</span>\n <span className=\"text-xs leading-relaxed text-muted-foreground\">\n {L(item.roleKey)}\n </span>\n </div>\n ))}\n </div>\n </Shell>\n </section>\n );\n}\n\nfunction CtaBand({ L, authed }: { L: Localizer; authed: boolean }) {\n return (\n <section className=\"py-12 md:py-24\">\n <Shell className=\"flex flex-col items-center text-center\">\n <h2 className=\"max-w-2xl text-2xl font-normal sm:text-3xl\">{L(\"Landing:CtaTitle\")}</h2>\n <p className=\"mt-3 max-w-xl text-sm leading-relaxed text-muted-foreground\">\n {L(\"Landing:CtaSubtitle\")}\n </p>\n <div className=\"mt-8\">\n {authed ? (\n <ConsoleLink L={L} size=\"lg\" />\n ) : (\n <SignInLink L={L} size=\"lg\" label=\"Landing:HeroPrimary\" />\n )}\n </div>\n </Shell>\n </section>\n );\n}\n\nfunction SiteFooter({ L }: { L: Localizer }) {\n return (\n <footer className=\"py-16\">\n <Shell className=\"flex flex-col items-center justify-between gap-4 sm:flex-row\">\n <Brand />\n <p className=\"text-xs text-muted-foreground\">{L(\"Landing:FooterTagline\")}</p>\n </Shell>\n </footer>\n );\n}\n",
37
37
  "type": "registry:file",
38
38
  "target": "src/routes/index.tsx"
39
39
  },
@@ -87,7 +87,7 @@
87
87
  },
88
88
  {
89
89
  "path": "ui/blocks/app-shell/routes/shell-boundary.tsx",
90
- "content": "import { parseCookieHeader, parseCultureCookie } from \"@jcoder-stack/abp-react/auth\";\nimport type { FrontendCatalog } from \"@jcoder-stack/abp-react/i18n\";\nimport { CULTURE_COOKIE } from \"@jcoder-stack/abp-react/proxy\";\nimport { type ErrorComponentProps, Link } from \"@tanstack/react-router\";\nimport { Component, type ReactNode, useCallback, useEffect, useState } from \"react\";\nimport shellMessages from \"./_layout/shell-messages.json\";\n\n/**\n * 根路由的错误/404 边界。**刻意不经 AppConfigProvider**:TanStack Router 给根路由的 CatchBoundary\n * 包在 `component`(RootComponent,Provider 在它内部)外层。出错时整棵 Provider 子树被 fallback 替换,\n * useLocalization 在这里必然炸(无 Provider)。故自建一个只读 shell-messages.json 的最小 translator;\n * culture 只在客户端挂载后从 culture cookie 解析(不把 server-only 的请求头读取拉进客户端 bundle),\n * 挂载前统一渲染 fallbackCulture 文案,SSR 与客户端首屏因此一致,不产生 hydration 警告。\n */\nfunction useShellLocalization(): (key: string) => string {\n const [culture, setCulture] = useState(\"en\");\n useEffect(() => {\n const detected = parseCultureCookie(parseCookieHeader(document.cookie)[CULTURE_COOKIE]);\n if (detected && detected in shellMessages) setCulture(detected);\n }, []);\n return useCallback(\n (key: string) => {\n const catalog = shellMessages as FrontendCatalog;\n const value = catalog[culture]?.[\"\"]?.[key] ?? catalog.en?.[\"\"]?.[key];\n return typeof value === \"string\" ? value : key;\n },\n [culture],\n );\n}\n\n/** 路由错误边界:结构化错误都带 `status`(hook 的 AbpApiError 或 app-state/refresh 的 HttpError),故用鸭子类型把 403/401 渲染成权限/登录提示,而非 instanceof 单一类。 */\nfunction RouteErrorBody({ error }: ErrorComponentProps) {\n const L = useShellLocalization();\n const status =\n typeof error === \"object\" && error !== null && \"status\" in error\n ? (error as { status?: number }).status\n : undefined;\n if (status === 403) {\n return (\n <section className=\"space-y-2\">\n <h1 className=\"text-2xl font-normal\">{L(\"Shell:Forbidden\")}</h1>\n <p className=\"text-sm text-muted-foreground\">{L(\"Shell:ForbiddenBody\")}</p>\n <p className=\"space-x-4 text-sm\">\n <Link to=\"/\" className=\"text-primary underline-offset-4 hover:underline\">\n {L(\"Shell:BackHome\")}\n </Link>\n {/* 权限不够的正解常常是换个账号;注销是鉴权态变化,必须整页跳转 */}\n <a href=\"/api/auth/logout\" className=\"text-primary underline-offset-4 hover:underline\">\n {L(\"Shell:SignOut\")}\n </a>\n </p>\n </section>\n );\n }\n if (status === 401) {\n return (\n <section className=\"space-y-2\">\n <h1 className=\"text-2xl font-normal\">{L(\"Shell:Unauthorized\")}</h1>\n <p className=\"text-sm text-muted-foreground\">{L(\"Shell:SessionExpired\")}</p>\n <a\n href=\"/api/auth/login\"\n className=\"text-sm text-primary underline-offset-4 hover:underline\"\n >\n {L(\"Shell:SignIn\")}\n </a>\n </section>\n );\n }\n return (\n <section className=\"space-y-2\">\n <h1 className=\"text-2xl font-normal\">{L(\"Shell:UnexpectedError\")}</h1>\n <p className=\"text-sm text-muted-foreground\">\n {error instanceof Error ? error.message : L(\"Shell:UnknownError\")}\n </p>\n <Link to=\"/\" className=\"text-sm text-primary underline-offset-4 hover:underline\">\n {L(\"Shell:BackHome\")}\n </Link>\n </section>\n );\n}\n\ninterface LastResortState {\n failed: boolean;\n}\n\n/**\n * 错误页自身的保险丝。RouteError 是全应用的最后一道可见边界,它若在渲染中抛错,root 之上再无\n * React 边界,结果是整树卸载白屏。这里包一层零依赖的 class 边界(渲染错误只有 componentDidCatch\n * 能接),fallback 为硬编码英文的静态 HTML——刻意不碰词条、路由、任何可能跟着一起坏的东西。\n * 正常运行永远不可见;只有 shell-boundary 自己被改坏时才出场,把白屏降级为可操作的提示。\n */\nclass LastResortBoundary extends Component<{ children: ReactNode }, LastResortState> {\n state: LastResortState = { failed: false };\n\n static getDerivedStateFromError(): LastResortState {\n return { failed: true };\n }\n\n render() {\n if (!this.state.failed) return this.props.children;\n return (\n <section className=\"space-y-2 p-6\">\n <h1 className=\"text-2xl font-normal\">Something went wrong</h1>\n <p className=\"text-sm text-muted-foreground\">\n The error page itself failed to render. Reload the page, or go back home.\n </p>\n <p className=\"space-x-4 text-sm\">\n <button\n type=\"button\"\n className=\"underline underline-offset-4\"\n onClick={() => window.location.reload()}\n >\n Reload\n </button>\n <a href=\"/\" className=\"underline underline-offset-4\">\n Home\n </a>\n </p>\n </section>\n );\n }\n}\n\n/** 路由错误边界(见 RouteErrorBody 的分支说明),外层套 LastResortBoundary 防错误页自炸成白屏。 */\nexport function RouteError(props: ErrorComponentProps) {\n return (\n <LastResortBoundary>\n <RouteErrorBody {...props} />\n </LastResortBoundary>\n );\n}\n\nexport function RouteNotFound() {\n const L = useShellLocalization();\n return (\n <section className=\"space-y-2\">\n <h1 className=\"text-2xl font-normal\">{L(\"Shell:NotFound\")}</h1>\n <p className=\"space-x-4 text-sm\">\n <Link to=\"/\" className=\"text-primary underline-offset-4 hover:underline\">\n {L(\"Shell:BackHome\")}\n </Link>\n <a href=\"/api/auth/logout\" className=\"text-primary underline-offset-4 hover:underline\">\n {L(\"Shell:SignOut\")}\n </a>\n </p>\n </section>\n );\n}\n",
90
+ "content": "import {\n CULTURE_COOKIE,\n parseCookieHeader,\n parseCultureCookie,\n} from \"@jcoder-stack/abp-react/auth\";\nimport type { FrontendCatalog } from \"@jcoder-stack/abp-react/i18n\";\nimport { type ErrorComponentProps, Link } from \"@tanstack/react-router\";\nimport { Component, type ReactNode, useCallback, useEffect, useState } from \"react\";\nimport shellMessages from \"./_layout/shell-messages.json\";\n\n/**\n * 根路由的错误/404 边界。**刻意不经 AppConfigProvider**:TanStack Router 给根路由的 CatchBoundary\n * 包在 `component`(RootComponent,Provider 在它内部)外层。出错时整棵 Provider 子树被 fallback 替换,\n * useLocalization 在这里必然炸(无 Provider)。故自建一个只读 shell-messages.json 的最小 translator;\n * culture 只在客户端挂载后从 culture cookie 解析(不把 server-only 的请求头读取拉进客户端 bundle),\n * 挂载前统一渲染 fallbackCulture 文案,SSR 与客户端首屏因此一致,不产生 hydration 警告。\n */\nfunction useShellLocalization(): (key: string) => string {\n const [culture, setCulture] = useState(\"en\");\n useEffect(() => {\n const detected = parseCultureCookie(parseCookieHeader(document.cookie)[CULTURE_COOKIE]);\n if (detected && detected in shellMessages) setCulture(detected);\n }, []);\n return useCallback(\n (key: string) => {\n const catalog = shellMessages as FrontendCatalog;\n const value = catalog[culture]?.[\"\"]?.[key] ?? catalog.en?.[\"\"]?.[key];\n return typeof value === \"string\" ? value : key;\n },\n [culture],\n );\n}\n\n/** 路由错误边界:结构化错误都带 `status`(hook 的 AbpApiError 或 app-state/refresh 的 HttpError),故用鸭子类型把 403/401 渲染成权限/登录提示,而非 instanceof 单一类。 */\nfunction RouteErrorBody({ error }: ErrorComponentProps) {\n const L = useShellLocalization();\n const status =\n typeof error === \"object\" && error !== null && \"status\" in error\n ? (error as { status?: number }).status\n : undefined;\n if (status === 403) {\n return (\n <section className=\"space-y-2\">\n <h1 className=\"text-2xl font-normal\">{L(\"Shell:Forbidden\")}</h1>\n <p className=\"text-sm text-muted-foreground\">{L(\"Shell:ForbiddenBody\")}</p>\n <p className=\"space-x-4 text-sm\">\n <Link to=\"/\" className=\"text-primary underline-offset-4 hover:underline\">\n {L(\"Shell:BackHome\")}\n </Link>\n {/* 权限不够的正解常常是换个账号;注销是鉴权态变化,必须整页跳转 */}\n <a href=\"/api/auth/logout\" className=\"text-primary underline-offset-4 hover:underline\">\n {L(\"Shell:SignOut\")}\n </a>\n </p>\n </section>\n );\n }\n if (status === 401) {\n return (\n <section className=\"space-y-2\">\n <h1 className=\"text-2xl font-normal\">{L(\"Shell:Unauthorized\")}</h1>\n <p className=\"text-sm text-muted-foreground\">{L(\"Shell:SessionExpired\")}</p>\n <a\n href=\"/api/auth/login\"\n className=\"text-sm text-primary underline-offset-4 hover:underline\"\n >\n {L(\"Shell:SignIn\")}\n </a>\n </section>\n );\n }\n return (\n <section className=\"space-y-2\">\n <h1 className=\"text-2xl font-normal\">{L(\"Shell:UnexpectedError\")}</h1>\n <p className=\"text-sm text-muted-foreground\">\n {error instanceof Error ? error.message : L(\"Shell:UnknownError\")}\n </p>\n <Link to=\"/\" className=\"text-sm text-primary underline-offset-4 hover:underline\">\n {L(\"Shell:BackHome\")}\n </Link>\n </section>\n );\n}\n\ninterface LastResortState {\n failed: boolean;\n}\n\n/**\n * 错误页自身的保险丝。RouteError 是全应用的最后一道可见边界,它若在渲染中抛错,root 之上再无\n * React 边界,结果是整树卸载白屏。这里包一层零依赖的 class 边界(渲染错误只有 componentDidCatch\n * 能接),fallback 为硬编码英文的静态 HTML——刻意不碰词条、路由、任何可能跟着一起坏的东西。\n * 正常运行永远不可见;只有 shell-boundary 自己被改坏时才出场,把白屏降级为可操作的提示。\n */\nclass LastResortBoundary extends Component<{ children: ReactNode }, LastResortState> {\n state: LastResortState = { failed: false };\n\n static getDerivedStateFromError(): LastResortState {\n return { failed: true };\n }\n\n render() {\n if (!this.state.failed) return this.props.children;\n return (\n <section className=\"space-y-2 p-6\">\n <h1 className=\"text-2xl font-normal\">Something went wrong</h1>\n <p className=\"text-sm text-muted-foreground\">\n The error page itself failed to render. Reload the page, or go back home.\n </p>\n <p className=\"space-x-4 text-sm\">\n <button\n type=\"button\"\n className=\"underline underline-offset-4\"\n onClick={() => window.location.reload()}\n >\n Reload\n </button>\n <a href=\"/\" className=\"underline underline-offset-4\">\n Home\n </a>\n </p>\n </section>\n );\n }\n}\n\n/** 路由错误边界(见 RouteErrorBody 的分支说明),外层套 LastResortBoundary 防错误页自炸成白屏。 */\nexport function RouteError(props: ErrorComponentProps) {\n return (\n <LastResortBoundary>\n <RouteErrorBody {...props} />\n </LastResortBoundary>\n );\n}\n\nexport function RouteNotFound() {\n const L = useShellLocalization();\n return (\n <section className=\"space-y-2\">\n <h1 className=\"text-2xl font-normal\">{L(\"Shell:NotFound\")}</h1>\n <p className=\"space-x-4 text-sm\">\n <Link to=\"/\" className=\"text-primary underline-offset-4 hover:underline\">\n {L(\"Shell:BackHome\")}\n </Link>\n <a href=\"/api/auth/logout\" className=\"text-primary underline-offset-4 hover:underline\">\n {L(\"Shell:SignOut\")}\n </a>\n </p>\n </section>\n );\n}\n",
91
91
  "type": "registry:file",
92
92
  "target": "src/routes/shell-boundary.tsx"
93
93
  },
@@ -48,13 +48,13 @@
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 {\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",
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 // 行高是声明出来的,不是内容撑出来的:此前两档都靠单元格内距,于是同一档里\n // \"有行尾菜单的行\"比\"纯文本行\"高 12px,密度实际由那个按钮决定。\n className={cn(dt.state.density === \"compact\" ? \"[&_td]:h-8\" : \"[&_td]:h-10\")}\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
  },
55
55
  {
56
56
  "path": "ui/blocks/data-table/data-table-footer.tsx",
57
- "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport type { PaginationState, RowData } from \"@tanstack/react-table\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { type ReactNode, useMemo } from \"react\";\nimport type { TableInstance } from \"@/components/data-table/table-core\";\nimport {\n Pagination,\n PaginationContent,\n PaginationEllipsis,\n PaginationItem,\n PaginationLink,\n} from \"@/components/ui/pagination\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport { cn } from \"@/lib/utils\";\n\nconst DEFAULT_PAGE_SIZES = [10, 20, 50];\nconst PAGE_WINDOW_THRESHOLD = 7;\n\n/** 0-based 页码窗口:总页 ≤7 全显;否则首页/末页恒在,加当前页及左右各一页,其余折叠为省略号。 */\nexport function getPageItems(pageIndex: number, pageCount: number): (number | \"ellipsis\")[] {\n const totalPages = Math.max(pageCount, 1);\n if (totalPages <= PAGE_WINDOW_THRESHOLD) {\n return Array.from({ length: totalPages }, (_, i) => i);\n }\n\n const current = Math.min(Math.max(pageIndex, 0), totalPages - 1);\n const keep = new Set<number>([0, totalPages - 1, current]);\n if (current - 1 >= 0) keep.add(current - 1);\n if (current + 1 <= totalPages - 1) keep.add(current + 1);\n const sorted = [...keep].sort((a, b) => a - b);\n\n const items: (number | \"ellipsis\")[] = [];\n let previous: number | undefined;\n for (const page of sorted) {\n if (previous !== undefined && page - previous > 1) items.push(\"ellipsis\");\n items.push(page);\n previous = page;\n }\n return items;\n}\n\n/** 内建页脚:总条数 + 每页行数下拉 + 分页器。与表体正交,单独成文件降低 fork 进入成本。 */\nexport function DataTableFooter<TData extends RowData>(props: {\n table: TableInstance<TData>;\n pagination: PaginationState;\n pageCount: number;\n rowCount?: number;\n pageSizes?: number[];\n}): ReactNode {\n const L = useLocalization();\n const { table } = props;\n const canPrev = table.getCanPreviousPage();\n const canNext = table.getCanNextPage();\n const pageItems = getPageItems(props.pagination.pageIndex, props.pageCount);\n\n const sizeOptions = useMemo(() => {\n const merged = new Set([...(props.pageSizes ?? DEFAULT_PAGE_SIZES), props.pagination.pageSize]);\n return [...merged].sort((a, b) => a - b);\n }, [props.pageSizes, props.pagination.pageSize]);\n\n const prevItem = (\n <PaginationItem>\n <PaginationLink\n size=\"icon\"\n className={cn(\"size-8\", !canPrev && \"pointer-events-none opacity-50\")}\n aria-label={L(\"Table:Previous\")}\n aria-disabled={!canPrev}\n href={canPrev ? \"#\" : undefined}\n onClick={(e) => {\n e.preventDefault();\n if (canPrev) table.previousPage();\n }}\n >\n <ChevronLeft />\n </PaginationLink>\n </PaginationItem>\n );\n\n const nextItem = (\n <PaginationItem>\n <PaginationLink\n size=\"icon\"\n className={cn(\"size-8\", !canNext && \"pointer-events-none opacity-50\")}\n aria-label={L(\"Table:Next\")}\n aria-disabled={!canNext}\n href={canNext ? \"#\" : undefined}\n onClick={(e) => {\n e.preventDefault();\n if (canNext) table.nextPage();\n }}\n >\n <ChevronRight />\n </PaginationLink>\n </PaginationItem>\n );\n\n return (\n <div className=\"flex items-center justify-between gap-2\">\n <div className=\"flex items-center gap-2 text-sm tabular-nums text-muted-foreground\">\n {props.rowCount !== undefined && props.rowCount > 0 && (\n <span>{L(\"Table:Total\", props.rowCount)}</span>\n )}\n <span>{L(\"Table:RowsPerPage\")}</span>\n <Select\n value={String(props.pagination.pageSize)}\n onValueChange={(v: string) => table.setPagination({ pageIndex: 0, pageSize: Number(v) })}\n >\n <SelectTrigger size=\"sm\" className=\"tabular-nums\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent className=\"tabular-nums\">\n {sizeOptions.map((size) => (\n <SelectItem key={size} value={String(size)}>\n {size}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n <div className=\"flex items-center gap-2\">\n <span className=\"text-sm tabular-nums text-muted-foreground md:hidden\">\n {L(\"Table:PageOf\", props.pagination.pageIndex + 1, Math.max(props.pageCount, 1))}\n </span>\n <Pagination className=\"mx-0 w-auto md:hidden\">\n <PaginationContent>\n {prevItem}\n {nextItem}\n </PaginationContent>\n </Pagination>\n <Pagination className=\"mx-0 hidden w-auto md:flex\">\n <PaginationContent>\n {prevItem}\n {pageItems.map((item, i) =>\n item === \"ellipsis\" ? (\n // biome-ignore lint/suspicious/noArrayIndexKey: 省略号在窗口内位置固定,不参与重排\n <PaginationItem key={`ellipsis-${i}`}>\n <PaginationEllipsis />\n </PaginationItem>\n ) : (\n <PaginationItem key={item}>\n <PaginationLink\n size=\"icon\"\n className={cn(\n \"size-8 tabular-nums\",\n item === props.pagination.pageIndex &&\n \"border-primary bg-primary text-primary-foreground hover:bg-primary-hover hover:text-primary-foreground dark:border-primary dark:bg-primary dark:hover:bg-primary-hover\",\n )}\n isActive={item === props.pagination.pageIndex}\n href=\"#\"\n onClick={(e) => {\n e.preventDefault();\n table.setPageIndex(item);\n }}\n >\n {item + 1}\n </PaginationLink>\n </PaginationItem>\n ),\n )}\n {nextItem}\n </PaginationContent>\n </Pagination>\n </div>\n </div>\n );\n}\n",
57
+ "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport type { PaginationState, RowData } from \"@tanstack/react-table\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { type ReactNode, useMemo } from \"react\";\nimport type { TableInstance } from \"@/components/data-table/table-core\";\nimport {\n Pagination,\n PaginationContent,\n PaginationEllipsis,\n PaginationItem,\n PaginationLink,\n} from \"@/components/ui/pagination\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport { cn } from \"@/lib/utils\";\n\nconst DEFAULT_PAGE_SIZES = [10, 20, 50];\nconst PAGE_WINDOW_THRESHOLD = 7;\n\n/**\n * 0-based 页码窗口:总页 ≤7 全显;否则**恒定 7 个槽位**,首页与末页占两端,中间五槽随当前页滑动。\n *\n * 槽位数必须恒定:数量随当前页变化时,翻一页整排页码就会左右平移,按钮跑到光标底下换了张脸,\n * 连点下一页看起来像在闪烁。宁可多显示一个省略号,也不让已经画出来的页码挪位置。\n */\nexport function getPageItems(pageIndex: number, pageCount: number): (number | \"ellipsis\")[] {\n const totalPages = Math.max(pageCount, 1);\n if (totalPages <= PAGE_WINDOW_THRESHOLD) {\n return Array.from({ length: totalPages }, (_, i) => i);\n }\n\n const last = totalPages - 1;\n const current = Math.min(Math.max(pageIndex, 0), last);\n\n if (current <= 3) return [0, 1, 2, 3, 4, \"ellipsis\", last];\n if (current >= last - 3) return [0, \"ellipsis\", last - 4, last - 3, last - 2, last - 1, last];\n return [0, \"ellipsis\", current - 1, current, current + 1, \"ellipsis\", last];\n}\n\n/** 内建页脚:总条数 + 每页行数下拉 + 分页器。与表体正交,单独成文件降低 fork 进入成本。 */\nexport function DataTableFooter<TData extends RowData>(props: {\n table: TableInstance<TData>;\n pagination: PaginationState;\n pageCount: number;\n rowCount?: number;\n pageSizes?: number[];\n}): ReactNode {\n const L = useLocalization();\n const { table } = props;\n const canPrev = table.getCanPreviousPage();\n const canNext = table.getCanNextPage();\n const pageItems = getPageItems(props.pagination.pageIndex, props.pageCount);\n\n const sizeOptions = useMemo(() => {\n const merged = new Set([...(props.pageSizes ?? DEFAULT_PAGE_SIZES), props.pagination.pageSize]);\n return [...merged].sort((a, b) => a - b);\n }, [props.pageSizes, props.pagination.pageSize]);\n\n const prevItem = (\n <PaginationItem>\n <PaginationLink\n size=\"icon\"\n className={cn(\"size-8\", !canPrev && \"pointer-events-none opacity-50\")}\n aria-label={L(\"Table:Previous\")}\n aria-disabled={!canPrev}\n href={canPrev ? \"#\" : undefined}\n onClick={(e) => {\n e.preventDefault();\n if (canPrev) table.previousPage();\n }}\n >\n <ChevronLeft />\n </PaginationLink>\n </PaginationItem>\n );\n\n const nextItem = (\n <PaginationItem>\n <PaginationLink\n size=\"icon\"\n className={cn(\"size-8\", !canNext && \"pointer-events-none opacity-50\")}\n aria-label={L(\"Table:Next\")}\n aria-disabled={!canNext}\n href={canNext ? \"#\" : undefined}\n onClick={(e) => {\n e.preventDefault();\n if (canNext) table.nextPage();\n }}\n >\n <ChevronRight />\n </PaginationLink>\n </PaginationItem>\n );\n\n return (\n <div className=\"flex items-center justify-between gap-2\">\n <div className=\"flex items-center gap-2 text-sm tabular-nums text-muted-foreground\">\n {props.rowCount !== undefined && props.rowCount > 0 && (\n <span>{L(\"Table:Total\", props.rowCount)}</span>\n )}\n <span>{L(\"Table:RowsPerPage\")}</span>\n <Select\n value={String(props.pagination.pageSize)}\n onValueChange={(v: string) => table.setPagination({ pageIndex: 0, pageSize: Number(v) })}\n >\n <SelectTrigger size=\"sm\" className=\"tabular-nums\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent className=\"tabular-nums\">\n {sizeOptions.map((size) => (\n <SelectItem key={size} value={String(size)}>\n {size}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n <div className=\"flex items-center gap-2\">\n <span className=\"text-sm tabular-nums text-muted-foreground md:hidden\">\n {L(\"Table:PageOf\", props.pagination.pageIndex + 1, Math.max(props.pageCount, 1))}\n </span>\n <Pagination className=\"mx-0 w-auto md:hidden\">\n <PaginationContent>\n {prevItem}\n {nextItem}\n </PaginationContent>\n </Pagination>\n <Pagination className=\"mx-0 hidden w-auto md:flex\">\n <PaginationContent>\n {prevItem}\n {pageItems.map((item, i) =>\n item === \"ellipsis\" ? (\n // biome-ignore lint/suspicious/noArrayIndexKey: 省略号在窗口内位置固定,不参与重排\n <PaginationItem key={`ellipsis-${i}`}>\n <PaginationEllipsis />\n </PaginationItem>\n ) : (\n <PaginationItem key={item}>\n <PaginationLink\n size=\"icon\"\n className={cn(\n \"size-8 tabular-nums\",\n item === props.pagination.pageIndex &&\n \"border-primary bg-primary text-primary-foreground hover:bg-primary-hover hover:text-primary-foreground dark:border-primary dark:bg-primary dark:hover:bg-primary-hover\",\n )}\n isActive={item === props.pagination.pageIndex}\n href=\"#\"\n onClick={(e) => {\n e.preventDefault();\n table.setPageIndex(item);\n }}\n >\n {item + 1}\n </PaginationLink>\n </PaginationItem>\n ),\n )}\n {nextItem}\n </PaginationContent>\n </Pagination>\n </div>\n </div>\n );\n}\n",
58
58
  "type": "registry:component",
59
59
  "target": "components/data-table/data-table-footer.tsx"
60
60
  },
@@ -10,6 +10,7 @@
10
10
  ],
11
11
  "registryDependencies": [
12
12
  "sheet",
13
+ "alert-dialog",
13
14
  "button",
14
15
  "alert",
15
16
  "field",
@@ -24,13 +25,13 @@
24
25
  "files": [
25
26
  {
26
27
  "path": "ui/blocks/form/form-hook.tsx",
27
- "content": "import { createFormHook, createFormHookContexts } from \"@tanstack/react-form\";\nimport type { ReactNode } from \"react\";\nimport { lazy, Suspense } from \"react\";\nimport { Combobox } from \"@/components/combobox/combobox\";\nimport type { ComboboxOption } from \"@/components/combobox/use-combobox-options\";\nimport { formatIso, ISO_DATE, ISO_DATE_TIME, parseIso } from \"@/components/date-picker/date-io\";\nimport { FormErrorSummary } from \"@/components/form/form-error-summary\";\nimport { RequiredMark } from \"@/components/form/required-mark\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Field, FieldError, FieldLabel } from \"@/components/ui/field\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { Switch } from \"@/components/ui/switch\";\n\nconst MultiCombobox = lazy(() =>\n import(\"@/components/combobox/multi-combobox\").then((m) => ({ default: m.MultiCombobox })),\n);\nconst DatePicker = lazy(() =>\n import(\"@/components/date-picker/date-picker\").then((m) => ({ default: m.DatePicker })),\n);\nconst DateTimePicker = lazy(() =>\n import(\"@/components/date-picker/date-time-picker\").then((m) => ({\n default: m.DateTimePicker,\n })),\n);\nconst DateRangePicker = lazy(() =>\n import(\"@/components/date-picker/date-range-picker\").then((m) => ({\n default: m.DateRangePicker,\n })),\n);\n\nexport const { fieldContext, formContext, useFieldContext, useFormContext } =\n createFormHookContexts();\n\n/** 错误元素可能是服务端原始字符串或 Standard Schema issue;FieldError 只认 {message}。 */\nfunction toFieldErrors(errors: unknown[]): { message?: string }[] {\n return errors.map((e) => (typeof e === \"string\" ? { message: e } : (e as { message?: string })));\n}\n\ninterface ServerErrorFieldMeta {\n errorMap?: Record<string, unknown>;\n}\n\n/** `clearServerSubmitErrors` 需要的最小结构面,避免依赖 TanStack Form 的完整泛型形态。 */\ninterface ServerErrorForm {\n state: { fieldMeta: Record<string, ServerErrorFieldMeta | undefined> };\n setFieldMeta: (\n name: string,\n updater: (meta: ServerErrorFieldMeta) => ServerErrorFieldMeta,\n ) => void;\n setErrorMap: (map: { onSubmit?: undefined }) => void;\n}\n\n/**\n * 绕过 @tanstack/react-form 1.33.2 死锁:onSubmitAsync 注入的字段级错误不会在重提交时自清,\n * 不清则提交被永久短路。约束:onSubmit 槽位只放服务端错误,勿给 field 另挂 onSubmit 校验器。\n */\nfunction clearServerSubmitErrors(form: ServerErrorForm): void {\n for (const name of Object.keys(form.state.fieldMeta)) {\n form.setFieldMeta(name, (meta) => ({\n ...meta,\n errorMap: { ...meta.errorMap, onSubmit: undefined },\n }));\n }\n form.setErrorMap({ onSubmit: undefined });\n}\n\nfunction FieldShell(props: {\n label: string;\n required?: boolean;\n orientation?: \"vertical\" | \"horizontal\";\n children: ReactNode;\n}) {\n const field = useFieldContext();\n const invalid = field.state.meta.errors.length > 0;\n return (\n <Field orientation={props.orientation} data-invalid={invalid ? true : undefined}>\n <FieldLabel htmlFor={field.name}>\n {props.label}\n {props.required === true && <RequiredMark />}\n </FieldLabel>\n {props.children}\n <FieldError errors={toFieldErrors(field.state.meta.errors)} />\n </Field>\n );\n}\n\nexport function TextField(props: {\n label: string;\n required?: boolean;\n type?: \"text\" | \"email\" | \"password\" | \"date\";\n disabled?: boolean;\n placeholder?: string;\n autoComplete?: string;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell label={props.label} required={props.required}>\n <Input\n id={field.name}\n name={field.name}\n type={props.type ?? \"text\"}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n value={field.state.value}\n disabled={props.disabled}\n placeholder={props.placeholder}\n autoComplete={props.autoComplete}\n onBlur={field.handleBlur}\n onChange={(event) => field.handleChange(event.target.value)}\n />\n </FieldShell>\n );\n}\n\nexport function NumberField(props: {\n label: string;\n required?: boolean;\n disabled?: boolean;\n step?: string;\n}) {\n const field = useFieldContext<number>();\n return (\n <FieldShell label={props.label} required={props.required}>\n <Input\n id={field.name}\n name={field.name}\n type=\"number\"\n step={props.step}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n value={Number.isNaN(field.state.value) ? \"\" : String(field.state.value)}\n disabled={props.disabled}\n onBlur={field.handleBlur}\n onChange={(event) => {\n // 空串裸转 Number 是 0,会把「清空了」写成「改成 0」;映射 NaN 让 z.number() 判失败\n const raw = event.target.value;\n field.handleChange(raw.trim() === \"\" ? Number.NaN : Number(raw));\n }}\n />\n </FieldShell>\n );\n}\n\nexport function SwitchField(props: { label: string; disabled?: boolean }) {\n const field = useFieldContext<boolean>();\n return (\n <FieldShell label={props.label} orientation=\"horizontal\">\n <Switch\n id={field.name}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n checked={field.state.value}\n disabled={props.disabled}\n onCheckedChange={(checked: boolean) => field.handleChange(checked)}\n />\n </FieldShell>\n );\n}\n\nexport function SelectField(props: {\n label: string;\n options: { value: string; label: string }[];\n required?: boolean;\n disabled?: boolean;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell label={props.label} required={props.required}>\n <Select\n value={field.state.value}\n onValueChange={(value) => field.handleChange(value)}\n disabled={props.disabled}\n >\n <SelectTrigger\n id={field.name}\n className=\"w-full\"\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n >\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {props.options.map((option) => (\n <SelectItem key={option.value} value={option.value}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </FieldShell>\n );\n}\n\nexport function DateField(props: {\n label: string;\n required?: boolean;\n disabled?: boolean;\n placeholder?: string;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell label={props.label} required={props.required}>\n <Suspense fallback={<Skeleton className=\"h-9 w-full\" />}>\n <DatePicker\n id={field.name}\n value={parseIso(field.state.value, ISO_DATE)}\n disabled={props.disabled}\n placeholder={props.placeholder}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n onChange={(date) => field.handleChange(formatIso(date, ISO_DATE))}\n />\n </Suspense>\n </FieldShell>\n );\n}\n\nexport function DateTimeField(props: {\n label: string;\n required?: boolean;\n disabled?: boolean;\n placeholder?: string;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell label={props.label} required={props.required}>\n <Suspense fallback={<Skeleton className=\"h-9 w-full\" />}>\n <DateTimePicker\n id={field.name}\n value={parseIso(field.state.value, ISO_DATE_TIME)}\n disabled={props.disabled}\n placeholder={props.placeholder}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n onChange={(date) => field.handleChange(formatIso(date, ISO_DATE_TIME))}\n />\n </Suspense>\n </FieldShell>\n );\n}\n\nexport function DateRangeField(props: { label: string; required?: boolean; disabled?: boolean }) {\n const field = useFieldContext<{ from: string; to: string }>();\n return (\n <FieldShell label={props.label} required={props.required}>\n <Suspense fallback={<Skeleton className=\"h-9 w-full\" />}>\n <DateRangePicker\n id={field.name}\n value={{\n from: parseIso(field.state.value.from, ISO_DATE),\n to: parseIso(field.state.value.to, ISO_DATE),\n }}\n disabled={props.disabled}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n onChange={(range) =>\n field.handleChange({\n from: formatIso(range?.from, ISO_DATE),\n to: formatIso(range?.to, ISO_DATE),\n })\n }\n />\n </Suspense>\n </FieldShell>\n );\n}\n\nexport function ComboboxField(props: {\n label: string;\n options?: ComboboxOption[];\n loadOptions?: (search: string) => Promise<ComboboxOption[]>;\n placeholder?: string;\n required?: boolean;\n disabled?: boolean;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell label={props.label} required={props.required}>\n <Combobox\n value={field.state.value || undefined}\n onChange={(value) => field.handleChange(value ?? \"\")}\n options={props.options ?? []}\n loadOptions={props.loadOptions}\n placeholder={props.placeholder}\n disabled={props.disabled}\n />\n </FieldShell>\n );\n}\n\nexport function MultiComboboxField(props: {\n label: string;\n options: ComboboxOption[];\n editable: boolean;\n}) {\n const field = useFieldContext<string[]>();\n return (\n <FieldShell label={props.label}>\n {props.editable ? (\n <Suspense fallback={<Skeleton className=\"h-9 w-full\" />}>\n <MultiCombobox\n values={field.state.value}\n onChange={(values) => field.handleChange(values)}\n options={props.options}\n />\n </Suspense>\n ) : (\n <div className=\"flex flex-wrap gap-1\">\n {field.state.value.length === 0 ? (\n <span className=\"text-sm text-muted-foreground\">—</span>\n ) : (\n field.state.value.map((name) => (\n <Badge key={name} variant=\"secondary\">\n {name}\n </Badge>\n ))\n )}\n </div>\n )}\n </FieldShell>\n );\n}\n\n/** onSubmitAsync 返回的 form 键落 state.errorMap.onSubmit;\\n 拆多条渲染。 */\nfunction FormErrors() {\n const form = useFormContext();\n return (\n <form.Subscribe selector={(state) => state.errorMap.onSubmit}>\n {(onSubmit) => {\n const message = typeof onSubmit === \"string\" ? onSubmit : undefined;\n return <FormErrorSummary errors={message ? message.split(\"\\n\") : []} />;\n }}\n </form.Subscribe>\n );\n}\n\nconst { useAppForm: useAppFormBase, withForm } = createFormHook({\n fieldContext,\n formContext,\n fieldComponents: {\n TextField,\n NumberField,\n SwitchField,\n SelectField,\n DateField,\n DateTimeField,\n DateRangeField,\n ComboboxField,\n MultiComboboxField,\n },\n formComponents: { FormErrors },\n});\n\n/** 提交结束后把焦点移到第一个仍有错误的字段;取不到 DOM 元素(如自定义组件未转发 id)则顺延到\n * 下一个可聚焦的错误字段。字段输入的 id 约定为 field.name(内置字段组件已遵守)。「第一个」按\n * fieldMeta 的字段注册序(≈挂载序/tab 序);条件渲染或重排字段的极端场景下未必是视觉最上者。 */\nfunction focusFirstInvalidField(form: {\n state: { fieldMeta: Record<string, { errors?: unknown[] } | undefined> };\n}): void {\n for (const [name, meta] of Object.entries(form.state.fieldMeta)) {\n if (!meta?.errors?.length) continue;\n const el = document.getElementById(name);\n if (el) {\n el.focus();\n return;\n }\n }\n}\n\nconst patchedForms = new WeakSet<object>();\n\n/**\n * 包装 useAppFormBase:提交前注入服务端错误清理(见 clearServerSubmitErrors),提交结束后聚焦\n * 首错字段(见 focusFirstInvalidField)。WeakSet 守卫而不是 useState/useRef,后者初始化器在\n * StrictMode 下双调用,保证不了只 patch 一次。\n */\nexport const useAppForm: typeof useAppFormBase = (opts) => {\n const form = useAppFormBase(opts);\n if (!patchedForms.has(form)) {\n patchedForms.add(form);\n const original = form.handleSubmit;\n form.handleSubmit = ((submitMeta?: unknown) => {\n clearServerSubmitErrors(form as unknown as ServerErrorForm);\n const result = submitMeta === undefined ? original() : original(submitMeta as never);\n return result.then(() => {\n focusFirstInvalidField(form as unknown as Parameters<typeof focusFirstInvalidField>[0]);\n });\n }) as typeof form.handleSubmit;\n }\n return form;\n};\n\nexport { withForm };\n",
28
+ "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { createFormHook, createFormHookContexts } from \"@tanstack/react-form\";\nimport type { ReactNode } from \"react\";\nimport { createContext, lazy, Suspense, useContext } from \"react\";\nimport { Combobox } from \"@/components/combobox/combobox\";\nimport type { ComboboxOption } from \"@/components/combobox/use-combobox-options\";\nimport { formatIso, ISO_DATE, ISO_DATE_TIME, parseIso } from \"@/components/date-picker/date-io\";\nimport { FormErrorSummary } from \"@/components/form/form-error-summary\";\nimport { OptionalMark } from \"@/components/form/optional-mark\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Field, FieldDescription, FieldError, FieldLabel } from \"@/components/ui/field\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { cn } from \"@/lib/utils\";\n\nconst MultiCombobox = lazy(() =>\n import(\"@/components/combobox/multi-combobox\").then((m) => ({ default: m.MultiCombobox })),\n);\nconst DatePicker = lazy(() =>\n import(\"@/components/date-picker/date-picker\").then((m) => ({ default: m.DatePicker })),\n);\nconst DateTimePicker = lazy(() =>\n import(\"@/components/date-picker/date-time-picker\").then((m) => ({\n default: m.DateTimePicker,\n })),\n);\nconst DateRangePicker = lazy(() =>\n import(\"@/components/date-picker/date-range-picker\").then((m) => ({\n default: m.DateRangePicker,\n })),\n);\n\nexport const { fieldContext, formContext, useFieldContext, useFormContext } =\n createFormHookContexts();\n\n/** 错误元素可能是服务端原始字符串或 Standard Schema issue;FieldError 只认 {message}。 */\nfunction toFieldErrors(errors: unknown[]): { message?: string }[] {\n return errors.map((e) => (typeof e === \"string\" ? { message: e } : (e as { message?: string })));\n}\n\ninterface ServerErrorFieldMeta {\n errorMap?: Record<string, unknown>;\n}\n\n/** `clearServerSubmitErrors` 需要的最小结构面,避免依赖 TanStack Form 的完整泛型形态。 */\ninterface ServerErrorForm {\n state: { fieldMeta: Record<string, ServerErrorFieldMeta | undefined> };\n setFieldMeta: (\n name: string,\n updater: (meta: ServerErrorFieldMeta) => ServerErrorFieldMeta,\n ) => void;\n setErrorMap: (map: { onSubmit?: undefined }) => void;\n}\n\n/**\n * 绕过 @tanstack/react-form 1.33.2 死锁:onSubmitAsync 注入的字段级错误不会在重提交时自清,\n * 不清则提交被永久短路。约束:onSubmit 槽位只放服务端错误,勿给 field 另挂 onSubmit 校验器。\n */\nfunction clearServerSubmitErrors(form: ServerErrorForm): void {\n for (const name of Object.keys(form.state.fieldMeta)) {\n form.setFieldMeta(name, (meta) => ({\n ...meta,\n errorMap: { ...meta.errorMap, onSubmit: undefined },\n }));\n }\n form.setErrorMap({ onSubmit: undefined });\n}\n\n/**\n * 「选填」标记开关。\n *\n * 标少数派才有意义,而少数派是谁取决于**整张表单的构成**,不是单个字段:录入表单里多数字段\n * 必填,标出那一两个选填的很有用;查询表单里字段本来就全是选填,逐个标等于每个标签都挂一句\n * 废话。所以这个决定放在表单层——SheetForm 打开它,查询表单不打开。\n */\nconst OptionalMarksContext = createContext(false);\n\n/**\n * 只读渲染开关。打开后字段不再渲染「禁用的控件」,而是渲染一行「键 / 值」。\n *\n * 禁用控件的对比度是 WCAG 明确豁免的——豁免的前提正是它**不承载内容**。用一排灰掉的输入框\n * 展示一条记录,既难读又像坏了,用户还会去点那些框。\n *\n * 渲染归字段管(只有它知道该把枚举值、日期、布尔显示成什么),容器归表单管,调用方的写法\n * 一个字都不用改。\n */\nconst ReadOnlyContext = createContext(false);\n\nexport function ReadOnlyFields(props: { children: ReactNode }) {\n return <ReadOnlyContext.Provider value={true}>{props.children}</ReadOnlyContext.Provider>;\n}\n\n/** 只读态的一行:左键右值,行高与表格同源(40px),值右对齐排成一条竖线。 */\nfunction FieldRow(props: { label: string; display: ReactNode }) {\n const L = useLocalization();\n const empty =\n props.display === undefined ||\n props.display === null ||\n props.display === \"\" ||\n (typeof props.display === \"number\" && Number.isNaN(props.display));\n return (\n <div className=\"flex min-h-10 items-center justify-between gap-4 px-3 py-2\">\n <dt className=\"shrink-0 text-sm text-muted-foreground\">{props.label}</dt>\n <dd className={cn(\"text-right text-sm\", empty && \"text-muted-foreground\")}>\n {empty ? L(\"Form:Empty\") : props.display}\n </dd>\n </div>\n );\n}\n\nexport function OptionalMarks(props: { children: ReactNode }) {\n return (\n <OptionalMarksContext.Provider value={true}>{props.children}</OptionalMarksContext.Provider>\n );\n}\n\n/**\n * 字段外壳:标签 → 说明 → 控件 → 错误,顺序恒定。\n *\n * 说明刻意放在控件**之上**:控件下方是错误信息的位置,两者挤在一起时,出错那一刻要么说明\n * 被顶走、要么错误被推到看不见的地方。\n */\nfunction FieldShell(props: {\n label: string;\n required?: boolean;\n description?: string;\n /**\n * 只读态下这个字段该显示成什么。\n *\n * `null` 表示「接了只读渲染,但此刻没有值」——渲染成「未填写」那一行。\n * `undefined` 表示「这个字段还没接只读渲染」,回退到禁用控件。两者不能混用:空值若传\n * undefined,就会在只读的键值卡里冒出一个控件,把整张卡的排版撑坏。\n */\n display?: ReactNode;\n orientation?: \"vertical\" | \"horizontal\";\n children: ReactNode;\n}) {\n const field = useFieldContext();\n const invalid = field.state.meta.errors.length > 0;\n const marksOptional = useContext(OptionalMarksContext);\n const readOnly = useContext(ReadOnlyContext);\n if (readOnly && props.display !== undefined) {\n return <FieldRow label={props.label} display={props.display} />;\n }\n return (\n <Field orientation={props.orientation} data-invalid={invalid ? true : undefined}>\n <FieldLabel htmlFor={field.name}>\n {props.label}\n {marksOptional && props.required !== true && <OptionalMark />}\n </FieldLabel>\n {props.description !== undefined && <FieldDescription>{props.description}</FieldDescription>}\n {props.children}\n <FieldError errors={toFieldErrors(field.state.meta.errors)} />\n </Field>\n );\n}\n\nexport function TextField(props: {\n label: string;\n required?: boolean;\n description?: string;\n type?: \"text\" | \"email\" | \"password\" | \"date\";\n disabled?: boolean;\n placeholder?: string;\n autoComplete?: string;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell\n label={props.label}\n required={props.required}\n description={props.description}\n display={field.state.value}\n >\n <Input\n id={field.name}\n name={field.name}\n type={props.type ?? \"text\"}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n value={field.state.value}\n disabled={props.disabled}\n placeholder={props.placeholder}\n autoComplete={props.autoComplete}\n onBlur={field.handleBlur}\n onChange={(event) => field.handleChange(event.target.value)}\n />\n </FieldShell>\n );\n}\n\nexport function NumberField(props: {\n label: string;\n required?: boolean;\n placeholder?: string;\n description?: string;\n disabled?: boolean;\n step?: string;\n}) {\n const field = useFieldContext<number>();\n return (\n <FieldShell\n label={props.label}\n required={props.required}\n description={props.description}\n display={\n Number.isNaN(field.state.value) ? null : (\n <span className=\"tabular-nums\">{String(field.state.value)}</span>\n )\n }\n >\n <Input\n id={field.name}\n name={field.name}\n type=\"number\"\n step={props.step}\n placeholder={props.placeholder}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n value={Number.isNaN(field.state.value) ? \"\" : String(field.state.value)}\n disabled={props.disabled}\n onBlur={field.handleBlur}\n onChange={(event) => {\n // 空串裸转 Number 是 0,会把「清空了」写成「改成 0」;映射 NaN 让 z.number() 判失败\n const raw = event.target.value;\n field.handleChange(raw.trim() === \"\" ? Number.NaN : Number(raw));\n }}\n />\n </FieldShell>\n );\n}\n\nexport function SwitchField(props: { label: string; disabled?: boolean }) {\n const L = useLocalization();\n const field = useFieldContext<boolean>();\n return (\n <FieldShell\n label={props.label}\n orientation=\"horizontal\"\n display={L(field.state.value ? \"Form:Yes\" : \"Form:No\")}\n >\n <Switch\n id={field.name}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n checked={field.state.value}\n disabled={props.disabled}\n onCheckedChange={(checked: boolean) => field.handleChange(checked)}\n />\n </FieldShell>\n );\n}\n\nexport function SelectField(props: {\n label: string;\n options: { value: string; label: string }[];\n required?: boolean;\n description?: string;\n disabled?: boolean;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell\n label={props.label}\n required={props.required}\n description={props.description}\n display={props.options.find((option) => option.value === field.state.value)?.label ?? null}\n >\n <Select\n value={field.state.value}\n onValueChange={(value) => field.handleChange(value)}\n disabled={props.disabled}\n >\n <SelectTrigger\n id={field.name}\n className=\"w-full\"\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n >\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {props.options.map((option) => (\n <SelectItem key={option.value} value={option.value}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </FieldShell>\n );\n}\n\nexport function DateField(props: {\n label: string;\n required?: boolean;\n description?: string;\n disabled?: boolean;\n placeholder?: string;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell\n label={props.label}\n required={props.required}\n description={props.description}\n display={field.state.value ? <span className=\"tabular-nums\">{field.state.value}</span> : null}\n >\n <Suspense fallback={<Skeleton className=\"h-9 w-full\" />}>\n <DatePicker\n id={field.name}\n value={parseIso(field.state.value, ISO_DATE)}\n disabled={props.disabled}\n placeholder={props.placeholder}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n onChange={(date) => field.handleChange(formatIso(date, ISO_DATE))}\n />\n </Suspense>\n </FieldShell>\n );\n}\n\nexport function DateTimeField(props: {\n label: string;\n description?: string;\n required?: boolean;\n disabled?: boolean;\n placeholder?: string;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell\n label={props.label}\n required={props.required}\n description={props.description}\n display={field.state.value ? <span className=\"tabular-nums\">{field.state.value}</span> : null}\n >\n <Suspense fallback={<Skeleton className=\"h-9 w-full\" />}>\n <DateTimePicker\n id={field.name}\n value={parseIso(field.state.value, ISO_DATE_TIME)}\n disabled={props.disabled}\n placeholder={props.placeholder}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n onChange={(date) => field.handleChange(formatIso(date, ISO_DATE_TIME))}\n />\n </Suspense>\n </FieldShell>\n );\n}\n\nexport function DateRangeField(props: {\n label: string;\n description?: string;\n required?: boolean;\n disabled?: boolean;\n}) {\n const field = useFieldContext<{ from: string; to: string }>();\n return (\n <FieldShell\n label={props.label}\n required={props.required}\n description={props.description}\n display={\n field.state.value.from || field.state.value.to ? (\n <span className=\"tabular-nums\">\n {field.state.value.from} – {field.state.value.to}\n </span>\n ) : null\n }\n >\n <Suspense fallback={<Skeleton className=\"h-9 w-full\" />}>\n <DateRangePicker\n id={field.name}\n value={{\n from: parseIso(field.state.value.from, ISO_DATE),\n to: parseIso(field.state.value.to, ISO_DATE),\n }}\n disabled={props.disabled}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n onChange={(range) =>\n field.handleChange({\n from: formatIso(range?.from, ISO_DATE),\n to: formatIso(range?.to, ISO_DATE),\n })\n }\n />\n </Suspense>\n </FieldShell>\n );\n}\n\nexport function ComboboxField(props: {\n label: string;\n description?: string;\n options?: ComboboxOption[];\n loadOptions?: (search: string) => Promise<ComboboxOption[]>;\n placeholder?: string;\n required?: boolean;\n disabled?: boolean;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell\n label={props.label}\n required={props.required}\n description={props.description}\n display={\n props.options?.find((option) => option.value === field.state.value)?.label ??\n field.state.value\n }\n >\n <Combobox\n value={field.state.value || undefined}\n onChange={(value) => field.handleChange(value ?? \"\")}\n options={props.options ?? []}\n loadOptions={props.loadOptions}\n placeholder={props.placeholder}\n disabled={props.disabled}\n />\n </FieldShell>\n );\n}\n\nexport function MultiComboboxField(props: {\n label: string;\n options: ComboboxOption[];\n editable: boolean;\n}) {\n const field = useFieldContext<string[]>();\n return (\n <FieldShell\n label={props.label}\n display={\n field.state.value.length === 0 ? null : (\n <div className=\"flex flex-wrap justify-end gap-1\">\n {field.state.value.map((name) => (\n <Badge key={name} variant=\"secondary\">\n {name}\n </Badge>\n ))}\n </div>\n )\n }\n >\n {props.editable ? (\n <Suspense fallback={<Skeleton className=\"h-9 w-full\" />}>\n <MultiCombobox\n values={field.state.value}\n onChange={(values) => field.handleChange(values)}\n options={props.options}\n />\n </Suspense>\n ) : (\n <div className=\"flex flex-wrap gap-1\">\n {field.state.value.length === 0 ? (\n <span className=\"text-sm text-muted-foreground\">—</span>\n ) : (\n field.state.value.map((name) => (\n <Badge key={name} variant=\"secondary\">\n {name}\n </Badge>\n ))\n )}\n </div>\n )}\n </FieldShell>\n );\n}\n\n/** onSubmitAsync 返回的 form 键落 state.errorMap.onSubmit;\\n 拆多条渲染。 */\nfunction FormErrors() {\n const form = useFormContext();\n return (\n <form.Subscribe selector={(state) => state.errorMap.onSubmit}>\n {(onSubmit) => {\n const message = typeof onSubmit === \"string\" ? onSubmit : undefined;\n return <FormErrorSummary errors={message ? message.split(\"\\n\") : []} />;\n }}\n </form.Subscribe>\n );\n}\n\nconst { useAppForm: useAppFormBase, withForm } = createFormHook({\n fieldContext,\n formContext,\n fieldComponents: {\n TextField,\n NumberField,\n SwitchField,\n SelectField,\n DateField,\n DateTimeField,\n DateRangeField,\n ComboboxField,\n MultiComboboxField,\n },\n formComponents: { FormErrors },\n});\n\n/** 提交结束后把焦点移到第一个仍有错误的字段;取不到 DOM 元素(如自定义组件未转发 id)则顺延到\n * 下一个可聚焦的错误字段。字段输入的 id 约定为 field.name(内置字段组件已遵守)。「第一个」按\n * fieldMeta 的字段注册序(≈挂载序/tab 序);条件渲染或重排字段的极端场景下未必是视觉最上者。 */\nfunction focusFirstInvalidField(form: {\n state: { fieldMeta: Record<string, { errors?: unknown[] } | undefined> };\n}): void {\n for (const [name, meta] of Object.entries(form.state.fieldMeta)) {\n if (!meta?.errors?.length) continue;\n const el = document.getElementById(name);\n if (el) {\n el.focus();\n return;\n }\n }\n}\n\nconst patchedForms = new WeakSet<object>();\n\n/**\n * 包装 useAppFormBase:提交前注入服务端错误清理(见 clearServerSubmitErrors),提交结束后聚焦\n * 首错字段(见 focusFirstInvalidField)。WeakSet 守卫而不是 useState/useRef,后者初始化器在\n * StrictMode 下双调用,保证不了只 patch 一次。\n */\nexport const useAppForm: typeof useAppFormBase = (opts) => {\n const form = useAppFormBase(opts);\n if (!patchedForms.has(form)) {\n patchedForms.add(form);\n const original = form.handleSubmit;\n form.handleSubmit = ((submitMeta?: unknown) => {\n clearServerSubmitErrors(form as unknown as ServerErrorForm);\n const result = submitMeta === undefined ? original() : original(submitMeta as never);\n return result.then(() => {\n focusFirstInvalidField(form as unknown as Parameters<typeof focusFirstInvalidField>[0]);\n });\n }) as typeof form.handleSubmit;\n }\n return form;\n};\n\nexport { withForm };\n",
28
29
  "type": "registry:component",
29
30
  "target": "components/form/form-hook.tsx"
30
31
  },
31
32
  {
32
33
  "path": "ui/blocks/form/sheet-form.tsx",
33
- "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { Pencil } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport { SubmitButton } from \"@/components/form/submit-button\";\nimport { Button } from \"@/components/ui/button\";\nimport { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from \"@/components/ui/sheet\";\n\nexport type SheetFormMode = \"create\" | \"edit\" | \"view\";\n\nexport interface SheetFormProps {\n mode: SheetFormMode;\n open: boolean;\n onOpenChange: (open: boolean) => void;\n title: string;\n onSubmit?: () => void;\n canEdit?: boolean;\n onEdit?: () => void;\n pending?: boolean;\n children: ReactNode;\n}\n\n/** 三态侧滑表单容器(对标 DrawerForm)。 */\nexport function SheetForm(props: SheetFormProps) {\n const L = useLocalization();\n const editable = props.mode !== \"view\";\n return (\n <Sheet open={props.open} onOpenChange={props.onOpenChange}>\n <SheetContent className=\"flex w-full flex-col gap-0 sm:max-w-md\">\n <SheetHeader className=\"flex-row items-center justify-between gap-2 pr-12\">\n <SheetTitle>{props.title}</SheetTitle>\n {!editable && props.canEdit === true && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-8\"\n aria-label={L(\"Form:Edit\")}\n onClick={props.onEdit}\n >\n <Pencil />\n </Button>\n )}\n </SheetHeader>\n <form\n className=\"flex flex-1 flex-col overflow-y-auto\"\n onSubmit={(event) => {\n event.preventDefault();\n props.onSubmit?.();\n }}\n >\n <div className=\"flex-1 space-y-4 px-4 py-2\">{props.children}</div>\n {editable && (\n <SheetFooter>\n <SubmitButton pending={props.pending} />\n <Button type=\"button\" variant=\"outline\" onClick={() => props.onOpenChange(false)}>\n {L(\"Form:Cancel\")}\n </Button>\n </SheetFooter>\n )}\n </form>\n </SheetContent>\n </Sheet>\n );\n}\n",
34
+ "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { Pencil } from \"lucide-react\";\nimport { type ReactNode, useState } from \"react\";\nimport { OptionalMarks, ReadOnlyFields } from \"@/components/form/form-hook\";\nimport { SubmitButton } from \"@/components/form/submit-button\";\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Sheet,\n SheetContent,\n SheetDescription,\n SheetHeader,\n SheetTitle,\n} from \"@/components/ui/sheet\";\n\nexport type SheetFormMode = \"create\" | \"edit\" | \"view\";\n\nexport interface SheetFormProps {\n mode: SheetFormMode;\n open: boolean;\n onOpenChange: (open: boolean) => void;\n title: string;\n /** 记录标识或状态,例如「刘慈欣 · 2008-01-01」。只放能帮人确认「我在改哪一条」的信息——\n * 写「保存后可继续编辑」这类通用说明不如不写,它占一行而读者只看一次。 */\n subtitle?: string;\n onSubmit?: () => void;\n canEdit?: boolean;\n onEdit?: () => void;\n pending?: boolean;\n /** 表单是否有未保存的改动。为真时,任何关闭动作都先过一道确认。 */\n dirty?: boolean;\n children: ReactNode;\n}\n\n/** 三态侧滑表单容器(对标 DrawerForm)。 */\nexport function SheetForm(props: SheetFormProps) {\n const L = useLocalization();\n const editable = props.mode !== \"view\";\n const [confirming, setConfirming] = useState(false);\n\n // 所有关闭路径都收敛到这里:Esc、右上角 ×、点遮罩、取消按钮。分散判断迟早漏掉一条,\n // 而「哪一条漏了」只有用户丢了数据才会发现。\n const requestClose = () => {\n if (props.dirty === true) {\n setConfirming(true);\n return;\n }\n props.onOpenChange(false);\n };\n\n return (\n <Sheet\n open={props.open}\n onOpenChange={(next) => (next ? props.onOpenChange(true) : requestClose())}\n >\n <SheetContent\n className=\"flex w-full flex-col gap-0 p-0 sm:max-w-md\"\n // Esc 自己兜一层,不依赖 Radix 的默认路径:焦点落在文本框里时浏览器会先消费掉这个键\n // (事件到达 document 时已 defaultPrevented),而表单一打开就自动聚焦第一个字段——\n // 于是模态最标准的退出键在实际使用中从来没生效过。\n onKeyDown={(event) => {\n if (event.key !== \"Escape\") return;\n event.stopPropagation();\n requestClose();\n }}\n >\n <SheetHeader className=\"gap-0 px-5 pt-5 pb-4 pr-12\">\n <div className=\"flex items-start justify-between gap-2\">\n <SheetTitle className=\"truncate\">{props.title}</SheetTitle>\n {!editable && props.canEdit === true && (\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={props.onEdit}\n className=\"shrink-0\"\n >\n <Pencil />\n {L(\"Form:Edit\")}\n </Button>\n )}\n </div>\n {props.subtitle !== undefined && (\n <SheetDescription className=\"mt-1\">{props.subtitle}</SheetDescription>\n )}\n </SheetHeader>\n {/* form 自身不滚:滚动下移一层,否则底部操作栏会跟着字段一起滚出屏幕。 */}\n <form\n className=\"flex flex-1 flex-col overflow-hidden\"\n onSubmit={(event) => {\n event.preventDefault();\n props.onSubmit?.();\n }}\n >\n <div className=\"flex flex-1 flex-col gap-5 overflow-y-auto px-5 pb-5\">\n {editable ? (\n // 录入表单里多数字段必填,所以标出选填的那一两个;查询表单反之,不开这个开关。\n <OptionalMarks>{props.children}</OptionalMarks>\n ) : (\n // 查看态借整页表单的容器语汇:一行一个字段、发丝线分隔、值右对齐排成一条竖线。\n // 一条记录于是读起来像「把一行表格竖过来」,而不是一排灰掉的输入框。\n <dl className=\"divide-y rounded-lg border bg-card\">\n <ReadOnlyFields>{props.children}</ReadOnlyFields>\n </dl>\n )}\n </div>\n {editable && (\n // 单行右对齐、primary 最右,与整页表单同一套语法;取消用 ghost——它是退路,\n // 不是与保存并列的选项,不该也带一圈边框去争视觉重量。\n <div className=\"flex items-center justify-end gap-2 border-t px-5 py-3\">\n <Button type=\"button\" variant=\"ghost\" onClick={requestClose}>\n {L(\"Form:Cancel\")}\n </Button>\n <SubmitButton pending={props.pending} />\n </div>\n )}\n </form>\n </SheetContent>\n <AlertDialog open={confirming} onOpenChange={setConfirming}>\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>{L(\"Form:DiscardTitle\")}</AlertDialogTitle>\n <AlertDialogDescription>{L(\"Form:DiscardBody\")}</AlertDialogDescription>\n </AlertDialogHeader>\n <AlertDialogFooter>\n <AlertDialogCancel>{L(\"Form:KeepEditing\")}</AlertDialogCancel>\n {/* 丢弃已填内容是不可逆的,而红色在这套系统里正是留给不可逆动作的。 */}\n <AlertDialogAction\n variant=\"destructive\"\n onClick={() => {\n setConfirming(false);\n props.onOpenChange(false);\n }}\n >\n {L(\"Form:Discard\")}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n </Sheet>\n );\n}\n",
34
35
  "type": "registry:component",
35
36
  "target": "components/form/sheet-form.tsx"
36
37
  },
@@ -41,10 +42,10 @@
41
42
  "target": "components/form/form-section.tsx"
42
43
  },
43
44
  {
44
- "path": "ui/blocks/form/required-mark.tsx",
45
- "content": "import { cn } from \"@/lib/utils\";\n\n/**\n * 必填字段 label 后的星号标记。仅视觉提示(`aria-hidden`),真正的必填语义由 input 自身的\n * `required`/`aria-required` 承载,标记不再对读屏播报,避免「星号 + required」重复念两遍。\n */\nexport function RequiredMark({ className }: { className?: string }) {\n return (\n <span aria-hidden className={cn(\"ml-0.5 select-none text-destructive\", className)}>\n *\n </span>\n );\n}\n",
45
+ "path": "ui/blocks/form/optional-mark.tsx",
46
+ "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * 选填字段 label 后的「选填」标记。\n *\n * 标的是**少数派**:后台表单里多数字段必填,逐个挂红星等于让一张还没被碰过的表单布满\n * 错误信号,而红色在本系统里只表示出错与危险,这么用会把它的分量花掉。改为只标选填,\n * 一张表上通常只剩一两个标记。\n *\n * 与 RequiredMark 一样只是视觉提示(`aria-hidden`):必填语义由控件自身的\n * `required`/`aria-required` 承载,不让读屏把标记和属性念两遍。\n */\nexport function OptionalMark({ className }: { className?: string }) {\n const L = useLocalization();\n return (\n <span\n aria-hidden\n className={cn(\"ml-1 font-normal text-muted-foreground select-none\", className)}\n >\n {L(\"Form:Optional\")}\n </span>\n );\n}\n",
46
47
  "type": "registry:component",
47
- "target": "components/form/required-mark.tsx"
48
+ "target": "components/form/optional-mark.tsx"
48
49
  },
49
50
  {
50
51
  "path": "ui/blocks/form/server-errors.ts",
@@ -66,7 +67,7 @@
66
67
  },
67
68
  {
68
69
  "path": "ui/blocks/form/form-messages.json",
69
- "content": "{\n \"en\": {\n \"\": {\n \"Form:Save\": \"Save\",\n \"Form:Cancel\": \"Cancel\",\n \"Form:Edit\": \"Edit\",\n \"Form:Close\": \"Close\",\n \"Form:SubmitFailed\": \"Submission failed\",\n \"Form:Required\": \"This field is required\",\n \"Form:InvalidEmail\": \"Enter a valid email address\",\n \"Form:MaxLength\": \"Up to {0} characters\"\n }\n },\n \"zh-Hans\": {\n \"\": {\n \"Form:Save\": \"保存\",\n \"Form:Cancel\": \"取消\",\n \"Form:Edit\": \"编辑\",\n \"Form:Close\": \"关闭\",\n \"Form:SubmitFailed\": \"提交失败\",\n \"Form:Required\": \"此项为必填\",\n \"Form:InvalidEmail\": \"请输入有效的邮箱地址\",\n \"Form:MaxLength\": \"最多 {0} 个字符\"\n }\n }\n}\n",
70
+ "content": "{\n \"en\": {\n \"\": {\n \"Form:Save\": \"Save\",\n \"Form:Cancel\": \"Cancel\",\n \"Form:Edit\": \"Edit\",\n \"Form:Close\": \"Close\",\n \"Form:SubmitFailed\": \"Submission failed\",\n \"Form:Required\": \"This field is required\",\n \"Form:InvalidEmail\": \"Enter a valid email address\",\n \"Form:MaxLength\": \"Up to {0} characters\",\n \"Form:Optional\": \"Optional\",\n \"Form:Empty\": \"Not set\",\n \"Form:Yes\": \"Yes\",\n \"Form:No\": \"No\",\n \"Form:DiscardTitle\": \"Discard your changes?\",\n \"Form:DiscardBody\": \"What you have filled in has not been saved. Closing now loses it.\",\n \"Form:Discard\": \"Discard\",\n \"Form:KeepEditing\": \"Keep editing\"\n }\n },\n \"zh-Hans\": {\n \"\": {\n \"Form:Save\": \"保存\",\n \"Form:Cancel\": \"取消\",\n \"Form:Edit\": \"编辑\",\n \"Form:Close\": \"关闭\",\n \"Form:SubmitFailed\": \"提交失败\",\n \"Form:Required\": \"此项为必填\",\n \"Form:InvalidEmail\": \"请输入有效的邮箱地址\",\n \"Form:MaxLength\": \"最多 {0} 个字符\",\n \"Form:Optional\": \"选填\",\n \"Form:Empty\": \"未填写\",\n \"Form:Yes\": \"是\",\n \"Form:No\": \"否\",\n \"Form:DiscardTitle\": \"放弃未保存的修改?\",\n \"Form:DiscardBody\": \"已填写的内容尚未保存,现在关闭会丢失。\",\n \"Form:Discard\": \"放弃\",\n \"Form:KeepEditing\": \"继续编辑\"\n }\n }\n}\n",
70
71
  "type": "registry:file",
71
72
  "target": "components/form/form-messages.json"
72
73
  }
@@ -318,6 +318,7 @@
318
318
  "description": "Sheet-based form shell with server field-error mapping, wired to @tanstack/react-form.",
319
319
  "registryDependencies": [
320
320
  "sheet",
321
+ "alert-dialog",
321
322
  "button",
322
323
  "alert",
323
324
  "field",
@@ -351,9 +352,9 @@
351
352
  "target": "components/form/form-section.tsx"
352
353
  },
353
354
  {
354
- "path": "ui/blocks/form/required-mark.tsx",
355
+ "path": "ui/blocks/form/optional-mark.tsx",
355
356
  "type": "registry:component",
356
- "target": "components/form/required-mark.tsx"
357
+ "target": "components/form/optional-mark.tsx"
357
358
  },
358
359
  {
359
360
  "path": "ui/blocks/form/server-errors.ts",