@jcoder-stack/registry 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -1,29 +1,29 @@
1
1
  # @jcoder-stack/registry
2
2
 
3
- 认证外壳的 copy-in 源,工厂化到极简:**`auth.config.ts` 是你唯一要看的文件**——一句 `createAbpAuthRuntime(process.env, {…覆盖项})`,默认即现行为,每个覆盖项(cookie 名/寿命、策略启停、登出回跳、身份解析器…)都带默认值,取消注释即生效。其余是薄接线:`runtime.ts`(进程单例)、`server-fns.ts` / `middleware.ts`(TanStack 接线,编译约束必留 copy-in)、`index.ts`(barrel)。
3
+ The copy-in source for the auth shell, factored down to a minimum: **`auth.config.ts` is the only file you need to look at** — a single `createAbpAuthRuntime(process.env, {…overrides})` call, where the defaults are the current behavior and every override (cookie names/lifetimes, strategy toggles, post-logout redirect, identity resolver…) ships with a default; uncomment to activate. The rest is thin wiring: `runtime.ts` (a process singleton), `server-fns.ts` / `middleware.ts` (TanStack wiring that must stay copy-in for compile-time reasons), and `index.ts` (the barrel).
4
4
 
5
- 装配与机制全部下沉包:ABP auth 运行时工厂 `createAbpAuthRuntime` 与登录/回调/登出/文化/租户 handler → `@jcoder-stack/abp-react/proxy`;路由守卫 `requireAuth`/`requirePermission` → `@jcoder-stack/abp-react/router`(TanStack Router beforeLoad,经 `@/auth` barrel 重导出);ABP 代理调用、身份读取、returnUrl/culture 纯函数 → `@jcoder-stack/abp-react/proxy` / `@jcoder-stack/abp-react/auth`。
5
+ All assembly and mechanics live down in the packages: the ABP auth runtime factory `createAbpAuthRuntime` and the login/callback/logout/culture/tenant handlers → `@jcoder-stack/abp-react/proxy`; the route guards `requireAuth`/`requirePermission` → `@jcoder-stack/abp-react/router` (TanStack Router beforeLoad, re-exported through the `@/auth` barrel); ABP proxy calls, identity reads, and the returnUrl/culture pure functions → `@jcoder-stack/abp-react/proxy` / `@jcoder-stack/abp-react/auth`.
6
6
 
7
- `jc-abp add auth` manifest 落位到应用 `src/`;属 ABP React Start 框架,总览见仓库根 README
7
+ `jc-abp add auth` distributes it into the app's `src/` per the manifest. Part of the ABP React Start framework — see the repository root README for the big picture.
8
8
 
9
9
  ## UI blocks
10
10
 
11
- `ui/blocks/` 下是可用官方 shadcn CLI 安装的块,产物在 `public/r/`:
11
+ `ui/blocks/` holds blocks installable with the official shadcn CLI; the built artifacts live in `public/r/`:
12
12
 
13
- | | 内容 |
13
+ | Block | Contents |
14
14
  | --- | --- |
15
- | `abp-layout` | 侧栏布局(AppSidebar / SiteHeader / 面包屑 / 语言与主题切换 / 品牌标识) |
16
- | `abp-login` | 密码登录卡片,含 OIDC 入口 |
17
- | `app-shell` | 应用壳胶水:`_layout`/`_authed`/首页/login 路由 + `abp-fetch.ts` + 起点 `menu.tsx` |
18
- | `data-table` | 通用服务端分页/排序/搜索表格 |
19
- | `combobox` | 单选/多选 combobox(本地过滤或防抖远程 loadOptions |
20
- | `date-picker` | 单日期 / 日期区间 / 日期时间选择器 |
21
- | `form` | 表单壳 + 字段组件 + 服务端字段错误映射 |
22
- | `abp-crud` | ABP CRUD 协议(`createCrudService` / `AbpTableSource`)与共享件 |
23
- | `abp-table` | `useAbpTable`——表格 + 筛选面板 + 批量操作 |
24
- | `abp-sheet` | `useAbpSheet`——增删改抽屉 |
25
- | `tree` | 通用树形块(展开收起 / 勾选,级联策略留给消费方) |
26
- | `abp-permission-sheet` | 权限树 Sheet(组=Accordion,组内 Tree |
27
- | `admin-pages` | users / roles / tenants / settings / profile 五个管理页 |
15
+ | `abp-layout` | The sidebar layout (AppSidebar / SiteHeader / breadcrumbs / locale & theme switchers / brand mark) |
16
+ | `abp-login` | The password sign-in card, with an OIDC entry point |
17
+ | `app-shell` | App-shell glue: the `_layout`/`_authed`/home/login routes + `abp-fetch.ts` + the starting `menu.tsx` |
18
+ | `data-table` | A general server-side paging/sorting/search table |
19
+ | `combobox` | Single/multi-select combobox (local filtering or debounced remote loadOptions) |
20
+ | `date-picker` | Single date / date range / date-time pickers |
21
+ | `form` | The form shell + field components + server-side field error mapping |
22
+ | `abp-crud` | The ABP CRUD protocol (`createCrudService` / `AbpTableSource`) and shared pieces |
23
+ | `abp-table` | `useAbpTable` — table + filter panel + bulk actions |
24
+ | `abp-sheet` | `useAbpSheet` — the create/edit/delete drawer |
25
+ | `tree` | A general tree block (expand/collapse, checking; cascade policy left to the consumer) |
26
+ | `abp-permission-sheet` | The permission tree sheet (groups as Accordion, a Tree per group) |
27
+ | `admin-pages` | The five admin pages: users / roles / tenants / settings / profile |
28
28
 
29
- 装法、依赖顺序与各块的前置条件见 [`docs/guides/install-blocks.md`](../docs/guides/install-blocks.md)。源码改动后用 `bun run build:registry` 重新生成产物。
29
+ Install steps, dependency order, and each block's prerequisites are in [`docs/guides/install-blocks.md`](../docs/guides/install-blocks.md). After changing block sources, rebuild the artifacts with `bun run build:registry`.
@@ -7,16 +7,19 @@ declare global {
7
7
  readonly env: ImportMetaEnv;
8
8
  }
9
9
 
10
+ // 与 @jcoder-stack/abp-react/proxy 的 resolveAbpAuthEnv 读取的变量一一对应。
10
11
  namespace NodeJS {
11
12
  interface ProcessEnv {
12
- readonly OIDC_ISSUER: string;
13
- readonly OIDC_CLIENT_ID: string;
14
- readonly OIDC_CLIENT_SECRET?: string;
15
- readonly OIDC_SCOPE?: string;
16
- readonly API_BASE_URL: string;
13
+ readonly AUTH_ISSUER: string;
14
+ readonly AUTH_CLIENT_ID: string;
15
+ readonly AUTH_CLIENT_SECRET?: string;
16
+ readonly AUTH_SCOPE?: string;
17
+ readonly AUTH_REDIRECT_URI: string;
18
+ readonly AUTH_POST_LOGOUT_REDIRECT_URI?: string;
17
19
  readonly AUTH_SESSION_SECRET: string;
18
- readonly OIDC_REDIRECT_URI: string;
19
- readonly OIDC_POST_LOGOUT_REDIRECT_URI?: string;
20
+ readonly AUTH_ABP_BASE_URL: string;
21
+ readonly AUTH_DEBUG?: string;
22
+ readonly AUTH_EXTRA_CA_FILE?: string;
20
23
  }
21
24
  }
22
25
  }
@@ -6,7 +6,7 @@
6
6
  # "'client_id' is invalid" → 这个 client 不存在,换名字再试
7
7
  # "'redirect_uri' is not valid" → client 对了,回调没登记,去后端补
8
8
  # 302 到 /Account/Login → 两个都对
9
- AUTH_ISSUER=https://localhost:44300
9
+ AUTH_ISSUER=https://localhost:44316
10
10
  AUTH_CLIENT_ID=WebApp
11
11
  # 机密客户端才需要:
12
12
  # AUTH_CLIENT_SECRET=
@@ -18,13 +18,11 @@ AUTH_REDIRECT_URI=http://localhost:3000/api/auth/callback
18
18
  AUTH_POST_LOGOUT_REDIRECT_URI=http://localhost:3000/
19
19
  # ≥32 字符随机串:openssl rand -base64 32
20
20
  AUTH_SESSION_SECRET=
21
- # 后端用自签证书(本地 ABP 默认如此)时,服务端到它的这一跳会失败:页面 500 且只写着 "fetch failed",
22
- # gen 同理。原因是 Node 只认自己内置的 CA 列表,不读系统钥匙串——证书 dev-certs --trust 过、浏览器和
23
- # curl 都放行,Node 依然拒。导出证书后二选一:
21
+ # 后端用自签证书(本地 ABP 默认如此)时,服务端到它的这一跳会被拒——Node 只认自己内置的 CA 列表,
22
+ # 不读系统钥匙串,证书 dev-certs --trust 过、浏览器和 curl 都放行也没用。导出证书后填到下面这行,
23
+ # dev 与 gen 就都信任它(只多信这一张,其余照常校验;需要 Node >= 22.15):
24
24
  # dotnet dev-certs https --export-path ~/.aspnet-dev.crt --format PEM
25
- # NODE_EXTRA_CA_CERTS=~/.aspnet-dev.crt bun run dev ← 只多信这一张,其余照常校验。Node 在启动时读它,
26
- # 写进本文件不生效,必须放在命令上或 shell profile 里
27
- # NODE_TLS_REJECT_UNAUTHORIZED=0 ← 写进本文件有效,但对所有主机关掉校验,只可用于开发
28
- AUTH_ABP_BASE_URL=https://localhost:44300
25
+ # AUTH_EXTRA_CA_FILE=~/.aspnet-dev.crt
26
+ AUTH_ABP_BASE_URL=https://localhost:44316
29
27
  # 开发排障:置 true 打开 auth 全链路 debug 日志(永不输出 token 原文)
30
28
  AUTH_DEBUG=false
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jcoder-stack/registry",
3
- "description": "认证外壳的 copy-in 源:登录/回调/登出 handler、callAbp 代理、路由守卫、TanStack 接线",
4
- "version": "0.1.1",
3
+ "description": "Copy-in sources for the auth shell and shadcn blocks: login/callback/logout handlers, the callAbp proxy, route guards, and TanStack wiring",
4
+ "version": "0.1.3",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "auth",
@@ -41,7 +41,7 @@
41
41
  },
42
42
  {
43
43
  "path": "ui/blocks/abp-table/abp-query-form.tsx",
44
- "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { ListFilter } from \"lucide-react\";\nimport { Children, Fragment, isValidElement, type KeyboardEvent, type ReactNode } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\n/** `Children.toArray` 不拆 fragment(`<>a b</>` 整体算一个子节点),必须先取出\n * fragment 的 `props.children` 再切;非 fragment 的单个子节点按长度 1 处理。 */\nexport function flattenFields(children: ReactNode): ReactNode[] {\n const inner =\n isValidElement<{ children?: ReactNode }>(children) && children.type === Fragment\n ? children.props.children\n : children;\n return Children.toArray(inner);\n}\n\n/** 面板不是原生 `<form>`:它住在表格卡片内部,同卡片里的「新增」等按钮会误触 submit。\n * Enter 提交改成字段区的键盘监听,只认 INPUT 上未被上游消费、也不在 IME 组字中的 Enter。\n * `defaultPrevented` 挡掉 combobox 这类「Enter 表示选中」的字段从 Portal 冒泡回来的事件,\n * `isComposing` 挡掉中文拼音上屏的那次 Enter。 */\nfunction submitOnEnter(onSubmit: () => void) {\n return (event: KeyboardEvent<HTMLElement>) => {\n if (event.key !== \"Enter\") return;\n if (event.defaultPrevented) return;\n if (event.nativeEvent.isComposing) return;\n if ((event.target as HTMLElement).tagName !== \"INPUT\") return;\n event.preventDefault();\n onSubmit();\n };\n}\n\nexport interface AbpQueryPanelToggleProps {\n expanded: boolean;\n onToggle: () => void;\n panelId: string;\n /** 当前生效的筛选项数。> 0 时图标带一个圆点,面板收起后用户否则不知道还筛着。 */\n activeCount: number;\n}\n\n/** 高级筛选面板的开合钮,住在顶部条右区的功能组。那里不随左区(搜索框 ⇄ 批量操作)变化,\n * 所以面板开着时勾选行,也仍然收得回来。不公开导出。 */\nexport function AbpQueryPanelToggle(props: AbpQueryPanelToggleProps) {\n const L = useLocalization();\n const active = props.activeCount > 0;\n return (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className={cn(\n \"relative\",\n props.expanded || active ? \"text-primary\" : \"text-muted-foreground\",\n )}\n aria-label={L(\"Table:Filter\")}\n aria-expanded={props.expanded}\n aria-controls={props.expanded ? props.panelId : undefined}\n onClick={props.onToggle}\n >\n <ListFilter />\n {active && (\n // 圆点只是\"还筛着\"的视觉提示,具体筛了什么展开面板即见;读屏另有 sr-only 文本,\n // 不靠颜色/形状单独传达状态。\n <span\n aria-hidden=\"true\"\n className=\"absolute right-1 top-1 size-1.5 rounded-full bg-primary\"\n />\n )}\n {active && <span className=\"sr-only\">{L(\"Table:FilterActive\", props.activeCount)}</span>}\n </Button>\n );\n}\n\nexport interface AbpQueryPanelViewProps {\n fields: ReactNode[];\n onSubmit: () => void;\n onReset: () => void;\n panelId: string;\n /** 面板是卡片里最后一段时(报错态没有表格托底)收窄下内边距,底下没有表格要隔开了。 */\n last?: boolean;\n}\n\n/** 高级筛选面板:带标签的字段网格 + 底部「重置 / 查询」按钮行,渲染在顶部条与表头之间,\n * 收起时整块从 DOM 卸载。查询按钮用 secondary,同卡片里的 primary 只留给「新增」。\n * 不公开导出。 */\nexport function AbpQueryPanelView(props: AbpQueryPanelViewProps) {\n const L = useLocalization();\n return (\n <fieldset\n id={props.panelId}\n // 不画下边框、改留一段白:边框紧贴表头的 muted 色带会让面板读成表格的一部分,\n // 而它是临时打开的层。留白之后表头色块自然成为表格块的起点。\n className={cn(\"px-4 pt-4\", props.last ? \"pb-4\" : \"pb-5\")}\n onKeyDown={submitOnEnter(props.onSubmit)}\n >\n <legend className=\"sr-only\">{L(\"Table:Query\")}</legend>\n <div className=\"grid gap-4 sm:grid-cols-2 lg:grid-cols-3\">{props.fields}</div>\n <div className=\"mt-4 flex items-center justify-end gap-2\">\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={props.onReset}>\n {L(\"Table:Reset\")}\n </Button>\n <Button type=\"button\" variant=\"secondary\" size=\"sm\" onClick={props.onSubmit}>\n {L(\"Table:Query\")}\n </Button>\n </div>\n </fieldset>\n );\n}\n",
44
+ "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { ListFilter } from \"lucide-react\";\nimport { Children, Fragment, isValidElement, type KeyboardEvent, type ReactNode } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\n\n/** `Children.toArray` 不拆 fragment(`<>a b</>` 整体算一个子节点),必须先取出\n * fragment 的 `props.children` 再切;非 fragment 的单个子节点按长度 1 处理。 */\nexport function flattenFields(children: ReactNode): ReactNode[] {\n const inner =\n isValidElement<{ children?: ReactNode }>(children) && children.type === Fragment\n ? children.props.children\n : children;\n return Children.toArray(inner);\n}\n\n/** 面板不是原生 `<form>`:它住在表格卡片内部,同卡片里的「新增」等按钮会误触 submit。\n * Enter 提交改成字段区的键盘监听,只认 INPUT 上未被上游消费、也不在 IME 组字中的 Enter。\n * `defaultPrevented` 挡掉 combobox 这类「Enter 表示选中」的字段从 Portal 冒泡回来的事件,\n * `isComposing` 挡掉中文拼音上屏的那次 Enter。 */\nfunction submitOnEnter(onSubmit: () => void) {\n return (event: KeyboardEvent<HTMLElement>) => {\n if (event.key !== \"Enter\") return;\n if (event.defaultPrevented) return;\n if (event.nativeEvent.isComposing) return;\n if ((event.target as HTMLElement).tagName !== \"INPUT\") return;\n event.preventDefault();\n onSubmit();\n };\n}\n\nexport interface AbpQueryPanelToggleProps {\n expanded: boolean;\n onToggle: () => void;\n panelId: string;\n /** 当前生效的筛选项数。> 0 时图标带一个圆点,面板收起后用户否则不知道还筛着。 */\n activeCount: number;\n}\n\n/** 高级筛选面板的开合钮,住在顶部条右区的功能组。那里不随左区(搜索框 ⇄ 批量操作)变化,\n * 所以面板开着时勾选行,也仍然收得回来。不公开导出。 */\nexport function AbpQueryPanelToggle(props: AbpQueryPanelToggleProps) {\n const L = useLocalization();\n const active = props.activeCount > 0;\n return (\n <Tooltip>\n <TooltipTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className={cn(\n \"relative\",\n props.expanded || active ? \"text-primary\" : \"text-muted-foreground\",\n )}\n aria-label={L(\"Table:Filter\")}\n aria-expanded={props.expanded}\n aria-controls={props.expanded ? props.panelId : undefined}\n onClick={props.onToggle}\n >\n <ListFilter />\n {active && (\n // 圆点只是\"还筛着\"的视觉提示,具体筛了什么展开面板即见;读屏另有 sr-only 文本,\n // 不靠颜色/形状单独传达状态。\n <span\n aria-hidden=\"true\"\n className=\"absolute right-1 top-1 size-1.5 rounded-full bg-primary\"\n />\n )}\n {active && <span className=\"sr-only\">{L(\"Table:FilterActive\", props.activeCount)}</span>}\n </Button>\n </TooltipTrigger>\n <TooltipContent>{L(\"Table:Filter\")}</TooltipContent>\n </Tooltip>\n );\n}\n\nexport interface AbpQueryPanelViewProps {\n fields: ReactNode[];\n onSubmit: () => void;\n onReset: () => void;\n panelId: string;\n /** 面板是卡片里最后一段时(报错态没有表格托底)收窄下内边距,底下没有表格要隔开了。 */\n last?: boolean;\n}\n\n/** 高级筛选面板:带标签的字段网格 + 底部「重置 / 查询」按钮行,渲染在顶部条与表头之间,\n * 收起时整块从 DOM 卸载。查询按钮用 secondary,同卡片里的 primary 只留给「新增」。\n * 不公开导出。 */\nexport function AbpQueryPanelView(props: AbpQueryPanelViewProps) {\n const L = useLocalization();\n return (\n <fieldset\n id={props.panelId}\n // 不画下边框、改留一段白:边框紧贴表头的 muted 色带会让面板读成表格的一部分,\n // 而它是临时打开的层。留白之后表头色块自然成为表格块的起点。\n className={cn(\"px-4 pt-4\", props.last ? \"pb-4\" : \"pb-5\")}\n onKeyDown={submitOnEnter(props.onSubmit)}\n >\n <legend className=\"sr-only\">{L(\"Table:Query\")}</legend>\n <div className=\"grid gap-4 sm:grid-cols-2 lg:grid-cols-3\">{props.fields}</div>\n <div className=\"mt-4 flex items-center justify-end gap-2\">\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={props.onReset}>\n {L(\"Table:Reset\")}\n </Button>\n <Button type=\"button\" variant=\"secondary\" size=\"sm\" onClick={props.onSubmit}>\n {L(\"Table:Query\")}\n </Button>\n </div>\n </fieldset>\n );\n}\n",
45
45
  "type": "registry:component",
46
46
  "target": "components/abp/table/abp-query-form.tsx"
47
47
  },
@@ -35,13 +35,13 @@
35
35
  "files": [
36
36
  {
37
37
  "path": "ui/blocks/admin-pages/identity/users.tsx",
38
- "content": "import { formatPersonName } from \"@jcoder-stack/abp-react/i18n\";\nimport { useCulture, useLocalization, usePermissionChecker } from \"@jcoder-stack/abp-react/react\";\nimport { createFileRoute } from \"@tanstack/react-router\";\nimport { KeyRound } from \"lucide-react\";\nimport { lazy, Suspense, useCallback, useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { z } from \"zod\";\nimport {\n getApiIdentityUsersIdRoles,\n getGetApiIdentityUsersQueryKey,\n useDeleteApiIdentityUsersId,\n useGetApiIdentityUsers,\n useGetApiIdentityUsersAssignableRoles,\n usePostApiIdentityUsers,\n usePutApiIdentityUsersId,\n} from \"@/api/endpoints/user/user\";\nimport type {\n VoloAbpIdentityIdentityUserCreateDto,\n VoloAbpIdentityIdentityUserDto,\n VoloAbpIdentityIdentityUserUpdateDto,\n} from \"@/api/models\";\nimport { requirePermission } from \"@/auth\";\nimport { createCrudService } from \"@/components/abp/crud/crud-service\";\nimport { useAbpSheet } from \"@/components/abp/sheet/use-abp-sheet\";\nimport { StatusBadge } from \"@/components/abp/table/status-badge\";\nimport { useAbpTable } from \"@/components/abp/table/use-abp-table\";\nimport type { ComboboxOption } from \"@/components/combobox/use-combobox-options\";\nimport type { TableColumnDef } from \"@/components/data-table/table-core\";\nimport { DropdownMenuItem } from \"@/components/ui/dropdown-menu\";\nimport { IdentityPermissions } from \"@/permissions\";\nimport { RouteError } from \"@/routes/shell-boundary\";\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 errorComponent: RouteError,\n component: UsersPage,\n});\n\nconst userService = createCrudService({\n useList: useGetApiIdentityUsers,\n useCreate: usePostApiIdentityUsers,\n useUpdate: usePutApiIdentityUsersId,\n useDelete: useDeleteApiIdentityUsersId,\n listKey: getGetApiIdentityUsersQueryKey,\n policy: IdentityPermissions.Users.Default,\n});\n\ninterface UserFormValues {\n userName: string;\n email: string;\n name: string;\n surname: string;\n phoneNumber: string;\n password: string;\n isActive: boolean;\n roleNames: string[];\n}\n\nconst EMPTY_VALUES: UserFormValues = {\n userName: \"\",\n email: \"\",\n name: \"\",\n surname: \"\",\n phoneNumber: \"\",\n password: \"\",\n isActive: true,\n roleNames: [],\n};\n\n/** 不能走默认 pick:roleNames 来自异步预取、password 必须回空(不回显)。 */\nfunction toRecordValues(\n record: VoloAbpIdentityIdentityUserDto,\n roleNames: string[],\n): UserFormValues {\n return {\n userName: record.userName ?? \"\",\n email: record.email ?? \"\",\n name: record.name ?? \"\",\n surname: record.surname ?? \"\",\n phoneNumber: record.phoneNumber ?? \"\",\n password: \"\",\n isActive: record.isActive ?? true,\n roleNames,\n };\n}\n\n/** 不能走默认 identity:可选字段空串归一为 undefined(不给后端发 `name: \"\"`)。 */\nfunction toCreateInput(value: UserFormValues): VoloAbpIdentityIdentityUserCreateDto {\n return {\n userName: value.userName,\n email: value.email,\n name: value.name || undefined,\n surname: value.surname || undefined,\n phoneNumber: value.phoneNumber || undefined,\n password: value.password,\n isActive: value.isActive,\n roleNames: value.roleNames,\n };\n}\n\n/** 不能走默认 identity:edit 不渲染 password 字段(留空=不改);value.password 恒为初始空串,\n * 故 update body 里永远不带 password,后端保持原密码不变;其余可选字段同 toCreateInput,\n * 空串归一为 undefined(不给后端发 `name: \"\"`)。 */\nfunction toUpdateInput(value: UserFormValues): VoloAbpIdentityIdentityUserUpdateDto {\n return {\n userName: value.userName,\n email: value.email,\n name: value.name || undefined,\n surname: value.surname || undefined,\n phoneNumber: value.phoneNumber || undefined,\n isActive: value.isActive,\n roleNames: value.roleNames,\n };\n}\n\nfunction UsersPage() {\n const L = useLocalization();\n const culture = useCulture();\n const can = usePermissionChecker();\n const canManagePermissions = can(IdentityPermissions.Users.ManagePermissions);\n const [permissionsFor, setPermissionsFor] = useState<VoloAbpIdentityIdentityUserDto | null>(null);\n\n // 引用必须稳定:进 AbpTable 的 columns memo 依赖,内联箭头会让列模型每渲染重建(DEV 有 churn 告警)。\n const permissionMenuItem = useCallback(\n (row: VoloAbpIdentityIdentityUserDto) =>\n canManagePermissions ? (\n <DropdownMenuItem onSelect={() => setPermissionsFor(row)}>\n <KeyRound />\n {L(\"Admin:Permissions\")}\n </DropdownMenuItem>\n ) : null,\n [canManagePermissions, L],\n );\n\n const columns = useMemo<TableColumnDef<VoloAbpIdentityIdentityUserDto>[]>(\n () => [\n { accessorKey: \"userName\", header: () => L(\"AbpIdentity::UserName\") },\n {\n id: \"fullName\",\n header: () => L(\"Admin:FullName\"),\n enableSorting: false,\n cell: ({ row }) =>\n formatPersonName({ name: row.original.name, surname: row.original.surname, culture }),\n },\n { accessorKey: \"email\", header: () => L(\"AbpIdentity::DisplayName:Email\") },\n {\n accessorKey: \"isActive\",\n header: () => L(\"AbpIdentity::DisplayName:IsActive\"),\n enableSorting: false,\n cell: ({ getValue }) => (\n <StatusBadge status={getValue() ? \"success\" : \"neutral\"}>\n {getValue() ? L(\"Admin:Yes\") : L(\"Admin:No\")}\n </StatusBadge>\n ),\n },\n ],\n [L, culture],\n );\n\n const sheet = useAbpSheet(userService, {\n emptyValues: EMPTY_VALUES,\n // 用户 DTO 不带 roleNames,view/edit 打开前先取一次当前角色再开 sheet;AbpTable 的 onOpen\n // 是 fire-and-forget,GET 失败必须在这里兜住提示并取消打开,否则用户只看到点击无响应。\n toValues: async (record, mode) => {\n let roleNames: string[] = [];\n if (record.id && mode !== \"create\") {\n try {\n const roles = await getApiIdentityUsersIdRoles(record.id);\n roleNames = (roles.items ?? [])\n .map((role) => role.name ?? \"\")\n .filter((name) => name.length > 0);\n } catch {\n toast.error(L(\"Crud:OperationFailed\"));\n return null;\n }\n }\n return toRecordValues(record, roleNames);\n },\n toCreate: toCreateInput,\n toUpdate: toUpdateInput,\n schema: (mode) => {\n const isCreate = mode === \"create\";\n return z.object({\n userName: z.string().trim().min(1, L(\"Form:Required\")),\n email: z\n .string()\n .trim()\n .min(1, L(\"Form:Required\"))\n .pipe(z.email(L(\"Form:InvalidEmail\"))),\n name: z.string(),\n surname: z.string(),\n phoneNumber: z.string(),\n password: isCreate ? z.string().trim().min(1, L(\"Form:Required\")) : z.string(),\n isActive: z.boolean(),\n roleNames: z.array(z.string()),\n });\n },\n });\n\n const t = useAbpTable(userService, {\n columns,\n row: { menu: permissionMenuItem },\n onOpen: sheet.open,\n });\n\n // MultiCombobox 的角色候选项只在编辑态(create/edit)且有 Update 权限时才拉,防止 view 态\n // 或无权限用户触发一次注定被后端 403 的请求。\n const assignableRolesQuery = useGetApiIdentityUsersAssignableRoles({\n query: { enabled: t.source.can.update && sheet.mode !== undefined && sheet.mode !== \"view\" },\n });\n const roleOptions: ComboboxOption[] = (assignableRolesQuery.data?.items ?? []).map((role) => ({\n value: role.name ?? \"\",\n label: role.name ?? \"\",\n }));\n\n const rolesEditable = t.source.can.update && sheet.mode !== \"view\";\n\n return (\n <section className=\"space-y-4\">\n <h1 className=\"text-2xl font-normal\">{L(\"AbpIdentity::Users\")}</h1>\n <t.Table>\n <t.BulkBar>\n <t.BulkDelete />\n </t.BulkBar>\n </t.Table>\n <sheet.Sheet>\n <sheet.form.AppForm>\n <sheet.form.FormErrors />\n </sheet.form.AppForm>\n\n <sheet.form.AppField name=\"userName\">\n {(field) => (\n <field.TextField\n label={L(\"AbpIdentity::UserName\")}\n required\n disabled={sheet.readOnly}\n />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"email\">\n {(field) => (\n <field.TextField\n label={L(\"AbpIdentity::DisplayName:Email\")}\n type=\"email\"\n required\n disabled={sheet.readOnly}\n />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"name\">\n {(field) => (\n <field.TextField label={L(\"AbpIdentity::DisplayName:Name\")} disabled={sheet.readOnly} />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"surname\">\n {(field) => (\n <field.TextField label={L(\"AbpIdentity::Surname\")} disabled={sheet.readOnly} />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"phoneNumber\">\n {(field) => (\n <field.TextField label={L(\"AbpIdentity::PhoneNumber\")} disabled={sheet.readOnly} />\n )}\n </sheet.form.AppField>\n\n {sheet.mode === \"create\" && (\n <sheet.form.AppField name=\"password\">\n {(field) => (\n <field.TextField label={L(\"AbpIdentity::Password\")} type=\"password\" required />\n )}\n </sheet.form.AppField>\n )}\n\n <sheet.form.AppField name=\"isActive\">\n {(field) => (\n <field.SwitchField\n label={L(\"AbpIdentity::DisplayName:IsActive\")}\n disabled={sheet.readOnly}\n />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"roleNames\">\n {(field) => (\n <field.MultiComboboxField\n label={L(\"AbpIdentity::Roles\")}\n options={roleOptions}\n editable={rolesEditable}\n />\n )}\n </sheet.form.AppField>\n </sheet.Sheet>\n {permissionsFor && (\n <Suspense fallback={null}>\n <PermissionSheet\n providerName=\"U\"\n providerKey={permissionsFor.id ?? \"\"}\n open={permissionsFor !== null}\n onOpenChange={(open) => !open && setPermissionsFor(null)}\n title={`${L(\"Admin:Permissions\")} · ${permissionsFor.userName ?? \"\"}`}\n />\n </Suspense>\n )}\n </section>\n );\n}\n",
38
+ "content": "import { formatPersonName } from \"@jcoder-stack/abp-react/i18n\";\nimport { useCulture, useLocalization, usePermissionChecker } from \"@jcoder-stack/abp-react/react\";\nimport { createFileRoute } from \"@tanstack/react-router\";\nimport { KeyRound } from \"lucide-react\";\nimport { lazy, Suspense, useCallback, useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { z } from \"zod\";\nimport {\n getApiIdentityUsersIdRoles,\n getGetApiIdentityUsersQueryKey,\n useDeleteApiIdentityUsersId,\n useGetApiIdentityUsers,\n useGetApiIdentityUsersAssignableRoles,\n usePostApiIdentityUsers,\n usePutApiIdentityUsersId,\n} from \"@/api/endpoints/user/user\";\nimport type {\n VoloAbpIdentityIdentityUserCreateDto,\n VoloAbpIdentityIdentityUserDto,\n VoloAbpIdentityIdentityUserUpdateDto,\n} from \"@/api/models\";\nimport { requirePermission } from \"@/auth\";\nimport { createCrudService } from \"@/components/abp/crud/crud-service\";\nimport { useAbpSheet } from \"@/components/abp/sheet/use-abp-sheet\";\nimport { StatusBadge } from \"@/components/abp/table/status-badge\";\nimport { useAbpTable } from \"@/components/abp/table/use-abp-table\";\nimport type { ComboboxOption } from \"@/components/combobox/use-combobox-options\";\nimport type { TableColumnDef } from \"@/components/data-table/table-core\";\nimport { DropdownMenuItem } from \"@/components/ui/dropdown-menu\";\nimport { IdentityPermissions } from \"@/permissions\";\n\n// 编辑/权限面板的重依赖(combobox、tree、accordion 等)按需加载:列表首屏不背这批模块。\nconst PermissionSheet = lazy(() =>\n import(\"@/components/abp/permission/permission-sheet\").then((m) => ({\n default: m.PermissionSheet,\n })),\n);\n\n/** 用户 DTO 不带 roleNames,故 open() 需先 GET .../roles 再开 sheet。 */\nexport const Route = createFileRoute(\"/_layout/_authed/identity/users\")({\n beforeLoad: requirePermission(IdentityPermissions.Users.Default),\n component: UsersPage,\n});\n\nconst userService = createCrudService({\n useList: useGetApiIdentityUsers,\n useCreate: usePostApiIdentityUsers,\n useUpdate: usePutApiIdentityUsersId,\n useDelete: useDeleteApiIdentityUsersId,\n listKey: getGetApiIdentityUsersQueryKey,\n policy: IdentityPermissions.Users.Default,\n});\n\ninterface UserFormValues {\n userName: string;\n email: string;\n name: string;\n surname: string;\n phoneNumber: string;\n password: string;\n isActive: boolean;\n roleNames: string[];\n}\n\nconst EMPTY_VALUES: UserFormValues = {\n userName: \"\",\n email: \"\",\n name: \"\",\n surname: \"\",\n phoneNumber: \"\",\n password: \"\",\n isActive: true,\n roleNames: [],\n};\n\n/** 不能走默认 pick:roleNames 来自异步预取、password 必须回空(不回显)。 */\nfunction toRecordValues(\n record: VoloAbpIdentityIdentityUserDto,\n roleNames: string[],\n): UserFormValues {\n return {\n userName: record.userName ?? \"\",\n email: record.email ?? \"\",\n name: record.name ?? \"\",\n surname: record.surname ?? \"\",\n phoneNumber: record.phoneNumber ?? \"\",\n password: \"\",\n isActive: record.isActive ?? true,\n roleNames,\n };\n}\n\n/** 不能走默认 identity:可选字段空串归一为 undefined(不给后端发 `name: \"\"`)。 */\nfunction toCreateInput(value: UserFormValues): VoloAbpIdentityIdentityUserCreateDto {\n return {\n userName: value.userName,\n email: value.email,\n name: value.name || undefined,\n surname: value.surname || undefined,\n phoneNumber: value.phoneNumber || undefined,\n password: value.password,\n isActive: value.isActive,\n roleNames: value.roleNames,\n };\n}\n\n/** 不能走默认 identity:edit 不渲染 password 字段(留空=不改);value.password 恒为初始空串,\n * 故 update body 里永远不带 password,后端保持原密码不变;其余可选字段同 toCreateInput,\n * 空串归一为 undefined(不给后端发 `name: \"\"`)。 */\nfunction toUpdateInput(value: UserFormValues): VoloAbpIdentityIdentityUserUpdateDto {\n return {\n userName: value.userName,\n email: value.email,\n name: value.name || undefined,\n surname: value.surname || undefined,\n phoneNumber: value.phoneNumber || undefined,\n isActive: value.isActive,\n roleNames: value.roleNames,\n };\n}\n\nfunction UsersPage() {\n const L = useLocalization();\n const culture = useCulture();\n const can = usePermissionChecker();\n const canManagePermissions = can(IdentityPermissions.Users.ManagePermissions);\n const [permissionsFor, setPermissionsFor] = useState<VoloAbpIdentityIdentityUserDto | null>(null);\n\n // 引用必须稳定:进 AbpTable 的 columns memo 依赖,内联箭头会让列模型每渲染重建(DEV 有 churn 告警)。\n const permissionMenuItem = useCallback(\n (row: VoloAbpIdentityIdentityUserDto) =>\n canManagePermissions ? (\n <DropdownMenuItem onSelect={() => setPermissionsFor(row)}>\n <KeyRound />\n {L(\"Admin:Permissions\")}\n </DropdownMenuItem>\n ) : null,\n [canManagePermissions, L],\n );\n\n const columns = useMemo<TableColumnDef<VoloAbpIdentityIdentityUserDto>[]>(\n () => [\n { accessorKey: \"userName\", header: () => L(\"AbpIdentity::UserName\") },\n {\n id: \"fullName\",\n header: () => L(\"Admin:FullName\"),\n enableSorting: false,\n cell: ({ row }) =>\n formatPersonName({ name: row.original.name, surname: row.original.surname, culture }),\n },\n { accessorKey: \"email\", header: () => L(\"AbpIdentity::DisplayName:Email\") },\n {\n accessorKey: \"isActive\",\n header: () => L(\"AbpIdentity::DisplayName:IsActive\"),\n enableSorting: false,\n cell: ({ getValue }) => (\n <StatusBadge status={getValue() ? \"success\" : \"neutral\"}>\n {getValue() ? L(\"Admin:Yes\") : L(\"Admin:No\")}\n </StatusBadge>\n ),\n },\n ],\n [L, culture],\n );\n\n const sheet = useAbpSheet(userService, {\n emptyValues: EMPTY_VALUES,\n // 用户 DTO 不带 roleNames,view/edit 打开前先取一次当前角色再开 sheet;AbpTable 的 onOpen\n // 是 fire-and-forget,GET 失败必须在这里兜住提示并取消打开,否则用户只看到点击无响应。\n toValues: async (record, mode) => {\n let roleNames: string[] = [];\n if (record.id && mode !== \"create\") {\n try {\n const roles = await getApiIdentityUsersIdRoles(record.id);\n roleNames = (roles.items ?? [])\n .map((role) => role.name ?? \"\")\n .filter((name) => name.length > 0);\n } catch {\n toast.error(L(\"Crud:OperationFailed\"));\n return null;\n }\n }\n return toRecordValues(record, roleNames);\n },\n toCreate: toCreateInput,\n toUpdate: toUpdateInput,\n schema: (mode) => {\n const isCreate = mode === \"create\";\n return z.object({\n userName: z.string().trim().min(1, L(\"Form:Required\")),\n email: z\n .string()\n .trim()\n .min(1, L(\"Form:Required\"))\n .pipe(z.email(L(\"Form:InvalidEmail\"))),\n name: z.string(),\n surname: z.string(),\n phoneNumber: z.string(),\n password: isCreate ? z.string().trim().min(1, L(\"Form:Required\")) : z.string(),\n isActive: z.boolean(),\n roleNames: z.array(z.string()),\n });\n },\n });\n\n const t = useAbpTable(userService, {\n columns,\n row: { menu: permissionMenuItem },\n onOpen: sheet.open,\n });\n\n // MultiCombobox 的角色候选项只在编辑态(create/edit)且有 Update 权限时才拉,防止 view 态\n // 或无权限用户触发一次注定被后端 403 的请求。\n const assignableRolesQuery = useGetApiIdentityUsersAssignableRoles({\n query: { enabled: t.source.can.update && sheet.mode !== undefined && sheet.mode !== \"view\" },\n });\n const roleOptions: ComboboxOption[] = (assignableRolesQuery.data?.items ?? []).map((role) => ({\n value: role.name ?? \"\",\n label: role.name ?? \"\",\n }));\n\n const rolesEditable = t.source.can.update && sheet.mode !== \"view\";\n\n return (\n <section className=\"space-y-4\">\n <h1 className=\"text-2xl font-normal\">{L(\"AbpIdentity::Users\")}</h1>\n <t.Table>\n <t.BulkBar>\n <t.BulkDelete />\n </t.BulkBar>\n </t.Table>\n <sheet.Sheet>\n <sheet.form.AppForm>\n <sheet.form.FormErrors />\n </sheet.form.AppForm>\n\n <sheet.form.AppField name=\"userName\">\n {(field) => (\n <field.TextField\n label={L(\"AbpIdentity::UserName\")}\n required\n disabled={sheet.readOnly}\n />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"email\">\n {(field) => (\n <field.TextField\n label={L(\"AbpIdentity::DisplayName:Email\")}\n type=\"email\"\n required\n disabled={sheet.readOnly}\n />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"name\">\n {(field) => (\n <field.TextField label={L(\"AbpIdentity::DisplayName:Name\")} disabled={sheet.readOnly} />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"surname\">\n {(field) => (\n <field.TextField label={L(\"AbpIdentity::Surname\")} disabled={sheet.readOnly} />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"phoneNumber\">\n {(field) => (\n <field.TextField label={L(\"AbpIdentity::PhoneNumber\")} disabled={sheet.readOnly} />\n )}\n </sheet.form.AppField>\n\n {sheet.mode === \"create\" && (\n <sheet.form.AppField name=\"password\">\n {(field) => (\n <field.TextField label={L(\"AbpIdentity::Password\")} type=\"password\" required />\n )}\n </sheet.form.AppField>\n )}\n\n <sheet.form.AppField name=\"isActive\">\n {(field) => (\n <field.SwitchField\n label={L(\"AbpIdentity::DisplayName:IsActive\")}\n disabled={sheet.readOnly}\n />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"roleNames\">\n {(field) => (\n <field.MultiComboboxField\n label={L(\"AbpIdentity::Roles\")}\n options={roleOptions}\n editable={rolesEditable}\n />\n )}\n </sheet.form.AppField>\n </sheet.Sheet>\n {permissionsFor && (\n <Suspense fallback={null}>\n <PermissionSheet\n providerName=\"U\"\n providerKey={permissionsFor.id ?? \"\"}\n open={permissionsFor !== null}\n onOpenChange={(open) => !open && setPermissionsFor(null)}\n title={`${L(\"Admin:Permissions\")} · ${permissionsFor.userName ?? \"\"}`}\n />\n </Suspense>\n )}\n </section>\n );\n}\n",
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, useMemo, 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 { StatusBadge } from \"@/components/abp/table/status-badge\";\nimport { useAbpTable } from \"@/components/abp/table/use-abp-table\";\nimport type { TableColumnDef } from \"@/components/data-table/table-core\";\nimport { DropdownMenuItem } from \"@/components/ui/dropdown-menu\";\nimport { IdentityPermissions } from \"@/permissions\";\nimport { buildRoleSchema } from \"@/routes/_layout/_authed/identity/-role-schema\";\nimport { RouteError } from \"@/routes/shell-boundary\";\n\n// 权限面板的重依赖(tree、accordion 等)按需加载:列表首屏不背这批模块。\nconst PermissionSheet = lazy(() =>\n import(\"@/components/abp/permission/permission-sheet\").then((m) => ({\n default: m.PermissionSheet,\n })),\n);\n\n/** 角色本身无可分配对象,故无角色 MultiCombobox。 */\nexport const Route = createFileRoute(\"/_layout/_authed/identity/roles\")({\n beforeLoad: requirePermission(IdentityPermissions.Roles.Default),\n errorComponent: RouteError,\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 columns = useMemo<TableColumnDef<VoloAbpIdentityIdentityRoleDto>[]>(\n () => [\n { accessorKey: \"name\", header: () => L(\"AbpIdentity::DisplayName:RoleName\") },\n {\n accessorKey: \"isDefault\",\n header: () => L(\"AbpIdentity::DisplayName:IsDefault\"),\n enableSorting: false,\n cell: ({ getValue }) => (\n <StatusBadge status={getValue() ? \"info\" : \"neutral\"}>\n {getValue() ? L(\"Admin:Yes\") : L(\"Admin:No\")}\n </StatusBadge>\n ),\n },\n {\n accessorKey: \"isPublic\",\n header: () => L(\"AbpIdentity::DisplayName:IsPublic\"),\n enableSorting: false,\n cell: ({ getValue }) => (\n <StatusBadge status={getValue() ? \"info\" : \"neutral\"}>\n {getValue() ? L(\"Admin:Yes\") : L(\"Admin:No\")}\n </StatusBadge>\n ),\n },\n ],\n [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, useMemo, 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 { StatusBadge } from \"@/components/abp/table/status-badge\";\nimport { useAbpTable } from \"@/components/abp/table/use-abp-table\";\nimport type { TableColumnDef } from \"@/components/data-table/table-core\";\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/** 角色本身无可分配对象,故无角色 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 columns = useMemo<TableColumnDef<VoloAbpIdentityIdentityRoleDto>[]>(\n () => [\n { accessorKey: \"name\", header: () => L(\"AbpIdentity::DisplayName:RoleName\") },\n {\n accessorKey: \"isDefault\",\n header: () => L(\"AbpIdentity::DisplayName:IsDefault\"),\n enableSorting: false,\n cell: ({ getValue }) => (\n <StatusBadge status={getValue() ? \"info\" : \"neutral\"}>\n {getValue() ? L(\"Admin:Yes\") : L(\"Admin:No\")}\n </StatusBadge>\n ),\n },\n {\n accessorKey: \"isPublic\",\n header: () => L(\"AbpIdentity::DisplayName:IsPublic\"),\n enableSorting: false,\n cell: ({ getValue }) => (\n <StatusBadge status={getValue() ? \"info\" : \"neutral\"}>\n {getValue() ? L(\"Admin:Yes\") : L(\"Admin:No\")}\n </StatusBadge>\n ),\n },\n ],\n [L],\n );\n\n const sheet = useAbpSheet(roleService, {\n emptyValues: EMPTY_VALUES,\n schema: () => roleSchema,\n });\n\n const t = useAbpTable(roleService, {\n columns,\n row: { menu: permissionMenuItem },\n onOpen: sheet.open,\n });\n\n return (\n <section className=\"space-y-4\">\n <h1 className=\"text-2xl font-normal\">{L(\"AbpIdentity::Roles\")}</h1>\n <t.Table>\n <t.BulkBar>\n <t.BulkDelete />\n </t.BulkBar>\n </t.Table>\n <sheet.Sheet>\n <sheet.form.AppForm>\n <sheet.form.FormErrors />\n </sheet.form.AppForm>\n\n <sheet.form.AppField name=\"name\">\n {(field) => (\n <field.TextField\n label={L(\"AbpIdentity::DisplayName:RoleName\")}\n required\n disabled={sheet.readOnly}\n />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"isDefault\">\n {(field) => (\n <field.SwitchField\n label={L(\"AbpIdentity::DisplayName:IsDefault\")}\n disabled={sheet.readOnly}\n />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"isPublic\">\n {(field) => (\n <field.SwitchField\n label={L(\"AbpIdentity::DisplayName:IsPublic\")}\n disabled={sheet.readOnly}\n />\n )}\n </sheet.form.AppField>\n </sheet.Sheet>\n {permissionsFor && (\n <Suspense fallback={null}>\n <PermissionSheet\n providerName=\"R\"\n providerKey={permissionsFor.name ?? \"\"}\n open={permissionsFor !== null}\n onOpenChange={(open) => !open && setPermissionsFor(null)}\n title={`${L(\"Admin:Permissions\")} · ${permissionsFor.name ?? \"\"}`}\n />\n </Suspense>\n )}\n </section>\n );\n}\n",
45
45
  "type": "registry:file",
46
46
  "target": "src/routes/_layout/_authed/identity/roles.tsx"
47
47
  },
@@ -53,19 +53,19 @@
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 { useMemo } from \"react\";\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 { useAbpTable } from \"@/components/abp/table/use-abp-table\";\nimport type { TableColumnDef } from \"@/components/data-table/table-core\";\nimport { TenantManagementPermissions } from \"@/permissions\";\nimport { RouteError } from \"@/routes/shell-boundary\";\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 errorComponent: RouteError,\n component: TenantsPage,\n});\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 columns = useMemo<TableColumnDef<VoloAbpTenantManagementTenantDto>[]>(\n () => [{ accessorKey: \"name\", header: () => L(\"AbpTenantManagement::TenantName\") }],\n [L],\n );\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 { useMemo } from \"react\";\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 { useAbpTable } from \"@/components/abp/table/use-abp-table\";\nimport type { TableColumnDef } from \"@/components/data-table/table-core\";\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\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 columns = useMemo<TableColumnDef<VoloAbpTenantManagementTenantDto>[]>(\n () => [{ accessorKey: \"name\", header: () => L(\"AbpTenantManagement::TenantName\") }],\n [L],\n );\n\n const sheet = useAbpSheet(tenantService, {\n emptyValues: EMPTY_VALUES,\n toValues: (record) => toRecordValues(record),\n toUpdate: toUpdateInput,\n // 以生成的 body schema 为基底:max64/max256/max128 这类后端约束免费继承;\n // adminEmailAddress/adminPassword 沿用既有 mode 分支(仅 create 收,edit 分支放行空串,\n // 因为 TenantUpdateDto 本就不带这两个字段),max 从生成常量取,消息在此覆盖。\n schema: (mode) => {\n const isCreate = mode === \"create\";\n return postApiMultiTenancyTenantsBody.extend({\n name: z\n .string()\n .trim()\n .min(1, L(\"Form:Required\"))\n .max(\n postApiMultiTenancyTenantsBodyNameMax,\n L(\"Form:MaxLength\", postApiMultiTenancyTenantsBodyNameMax),\n ),\n adminEmailAddress: isCreate\n ? z\n .string()\n .trim()\n .min(1, L(\"Form:Required\"))\n .max(\n postApiMultiTenancyTenantsBodyAdminEmailAddressMax,\n L(\"Form:MaxLength\", postApiMultiTenancyTenantsBodyAdminEmailAddressMax),\n )\n .pipe(z.email(L(\"Form:InvalidEmail\")))\n : z.string(),\n adminPassword: isCreate\n ? z\n .string()\n .trim()\n .min(1, L(\"Form:Required\"))\n .max(\n postApiMultiTenancyTenantsBodyAdminPasswordMax,\n L(\"Form:MaxLength\", postApiMultiTenancyTenantsBodyAdminPasswordMax),\n )\n : z.string(),\n });\n },\n });\n\n const t = useAbpTable(tenantService, { columns, onOpen: sheet.open });\n const isCreate = sheet.mode === \"create\";\n\n return (\n <section className=\"space-y-4\">\n <h1 className=\"text-2xl font-normal\">{L(\"AbpTenantManagement::Tenants\")}</h1>\n <t.Table>\n <t.BulkBar>\n <t.BulkDelete />\n </t.BulkBar>\n </t.Table>\n <sheet.Sheet>\n <sheet.form.AppForm>\n <sheet.form.FormErrors />\n </sheet.form.AppForm>\n\n <sheet.form.AppField name=\"name\">\n {(field) => (\n <field.TextField\n label={L(\"AbpTenantManagement::TenantName\")}\n required\n disabled={sheet.readOnly}\n />\n )}\n </sheet.form.AppField>\n\n {isCreate && (\n <>\n <sheet.form.AppField name=\"adminEmailAddress\">\n {(field) => (\n <field.TextField\n label={L(\"AbpTenantManagement::DisplayName:AdminEmailAddress\")}\n type=\"email\"\n required\n />\n )}\n </sheet.form.AppField>\n\n <sheet.form.AppField name=\"adminPassword\">\n {(field) => (\n <field.TextField\n label={L(\"AbpTenantManagement::DisplayName:AdminPassword\")}\n type=\"password\"\n required\n />\n )}\n </sheet.form.AppField>\n </>\n )}\n </sheet.Sheet>\n </section>\n );\n}\n",
57
57
  "type": "registry:file",
58
58
  "target": "src/routes/_layout/_authed/tenants/index.tsx"
59
59
  },
60
60
  {
61
61
  "path": "ui/blocks/admin-pages/settings/index.tsx",
62
- "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { createFileRoute } from \"@tanstack/react-router\";\nimport { useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { z } from \"zod\";\nimport {\n postApiSettingManagementEmailing,\n postApiSettingManagementEmailingSendTestEmail,\n useGetApiSettingManagementEmailing,\n} from \"@/api/endpoints/email-settings/email-settings\";\nimport {\n postApiSettingManagementTimezone,\n useGetApiSettingManagementTimezone,\n useGetApiSettingManagementTimezoneTimezones,\n} from \"@/api/endpoints/time-zone-settings/time-zone-settings\";\nimport type {\n VoloAbpNameValue,\n VoloAbpSettingManagementEmailSettingsDto,\n VoloAbpSettingManagementSendTestEmailInput,\n VoloAbpSettingManagementUpdateEmailSettingsDto,\n} from \"@/api/models\";\nimport { requirePermission } from \"@/auth\";\nimport { abpFormOptions } from \"@/components/abp/crud/abp-form-options\";\nimport { Combobox } from \"@/components/combobox/combobox\";\nimport { FormErrorSummary } from \"@/components/form/form-error-summary\";\nimport { useAppForm } from \"@/components/form/form-hook\";\nimport { FormSection } from \"@/components/form/form-section\";\nimport { SubmitButton } from \"@/components/form/submit-button\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Field, FieldLabel } from \"@/components/ui/field\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { SettingManagementPermissions } from \"@/permissions\";\nimport { RouteError } from \"@/routes/shell-boundary\";\n\n/** /settings:SettingManagement 模块的邮件/时区两页签。requiredPolicy 用\n * SettingManagement.Emailing。demo 后端两页签共用这一策略,无独立的 TimeZone 权限点。 */\nexport const Route = createFileRoute(\"/_layout/_authed/settings/\")({\n beforeLoad: requirePermission(SettingManagementPermissions.Emailing),\n errorComponent: RouteError,\n component: SettingsPage,\n});\n\nfunction SettingsPage() {\n const L = useLocalization();\n return (\n <section className=\"space-y-4\">\n <h1 className=\"text-2xl font-normal\">{L(\"AbpSettingManagement::Menu:Settings\")}</h1>\n <Tabs defaultValue=\"emailing\">\n <TabsList variant=\"line\">\n <TabsTrigger value=\"emailing\">{L(\"AbpSettingManagement::Menu:Emailing\")}</TabsTrigger>\n <TabsTrigger value=\"timezone\">{L(\"AbpSettingManagement::Menu:TimeZone\")}</TabsTrigger>\n </TabsList>\n <TabsContent value=\"emailing\" className=\"space-y-4\">\n <EmailingTab />\n </TabsContent>\n <TabsContent value=\"timezone\">\n <TimeZoneTab />\n </TabsContent>\n </Tabs>\n </section>\n );\n}\n\ninterface EmailingFormValues {\n smtpHost: string;\n smtpPort: number;\n smtpUserName: string;\n smtpPassword: string;\n smtpDomain: string;\n smtpEnableSsl: boolean;\n smtpUseDefaultCredentials: boolean;\n defaultFromAddress: string;\n defaultFromDisplayName: string;\n}\n\nfunction toEmailingValues(dto: VoloAbpSettingManagementEmailSettingsDto): EmailingFormValues {\n return {\n smtpHost: dto.smtpHost ?? \"\",\n smtpPort: dto.smtpPort ?? 25,\n smtpUserName: dto.smtpUserName ?? \"\",\n smtpPassword: dto.smtpPassword ?? \"\",\n smtpDomain: dto.smtpDomain ?? \"\",\n smtpEnableSsl: dto.smtpEnableSsl ?? false,\n smtpUseDefaultCredentials: dto.smtpUseDefaultCredentials ?? false,\n defaultFromAddress: dto.defaultFromAddress ?? \"\",\n defaultFromDisplayName: dto.defaultFromDisplayName ?? \"\",\n };\n}\n\nfunction toUpdateEmailingInput(\n value: EmailingFormValues,\n): VoloAbpSettingManagementUpdateEmailSettingsDto {\n return {\n smtpHost: value.smtpHost || undefined,\n smtpPort: value.smtpPort,\n smtpUserName: value.smtpUserName || undefined,\n smtpPassword: value.smtpPassword || undefined,\n smtpDomain: value.smtpDomain || undefined,\n smtpEnableSsl: value.smtpEnableSsl,\n smtpUseDefaultCredentials: value.smtpUseDefaultCredentials,\n defaultFromAddress: value.defaultFromAddress,\n defaultFromDisplayName: value.defaultFromDisplayName,\n };\n}\n\nfunction EmailingTab() {\n const L = useLocalization();\n const emailingQuery = useGetApiSettingManagementEmailing();\n\n if (emailingQuery.isError) {\n return <FormErrorSummary errors={[L(\"Crud:OperationFailed\")]} />;\n }\n if (!emailingQuery.data) {\n return (\n <Card>\n <CardHeader>\n <CardTitle>{L(\"AbpSettingManagement::Menu:Emailing\")}</CardTitle>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <Skeleton className=\"h-8 w-full\" />\n <Skeleton className=\"h-8 w-full\" />\n <Skeleton className=\"h-8 w-full\" />\n </CardContent>\n </Card>\n );\n }\n\n return <EmailingFormCard emailing={emailingQuery.data} />;\n}\n\nfunction EmailingFormCard({ emailing }: { emailing: VoloAbpSettingManagementEmailSettingsDto }) {\n const L = useLocalization();\n const [testEmailOpen, setTestEmailOpen] = useState(false);\n\n const emailingSchema = z.object({\n smtpHost: z.string(),\n smtpPort: z.number(L(\"Form:Required\")),\n smtpUserName: z.string(),\n smtpPassword: z.string(),\n smtpDomain: z.string(),\n smtpEnableSsl: z.boolean(),\n smtpUseDefaultCredentials: z.boolean(),\n defaultFromAddress: z\n .string()\n .trim()\n .min(1, L(\"Form:Required\"))\n .pipe(z.email(L(\"Form:InvalidEmail\"))),\n defaultFromDisplayName: z.string().trim().min(1, L(\"Form:Required\")),\n });\n\n const form = useAppForm(\n abpFormOptions({\n defaultValues: toEmailingValues(emailing),\n schema: emailingSchema,\n submit: async (value: EmailingFormValues) => {\n await postApiSettingManagementEmailing(toUpdateEmailingInput(value));\n form.reset(value);\n },\n onSuccess: () => toast.success(L(\"Crud:Saved\")),\n }),\n );\n\n return (\n <form\n className=\"space-y-4\"\n onSubmit={(event) => {\n event.preventDefault();\n void form.handleSubmit();\n }}\n >\n <form.AppForm>\n <form.FormErrors />\n </form.AppForm>\n\n <div className=\"divide-y rounded-lg border bg-card\">\n <FormSection title={L(\"Admin:SmtpSection\")} description={L(\"Admin:SmtpSectionDesc\")}>\n <div className=\"grid gap-4 sm:grid-cols-2\">\n <form.AppField name=\"smtpHost\">\n {(field) => <field.TextField label={L(\"AbpSettingManagement::SmtpHost\")} />}\n </form.AppField>\n\n <form.AppField name=\"smtpPort\">\n {(field) => (\n <field.NumberField label={L(\"AbpSettingManagement::SmtpPort\")} required />\n )}\n </form.AppField>\n </div>\n\n <div className=\"grid gap-4 sm:grid-cols-2\">\n <form.AppField name=\"smtpUserName\">\n {(field) => <field.TextField label={L(\"AbpSettingManagement::SmtpUserName\")} />}\n </form.AppField>\n\n <form.AppField name=\"smtpPassword\">\n {(field) => (\n <field.TextField\n label={L(\"AbpSettingManagement::SmtpPassword\")}\n type=\"password\"\n autoComplete=\"off\"\n />\n )}\n </form.AppField>\n </div>\n\n <form.AppField name=\"smtpDomain\">\n {(field) => <field.TextField label={L(\"AbpSettingManagement::SmtpDomain\")} />}\n </form.AppField>\n\n <form.AppField name=\"smtpEnableSsl\">\n {(field) => <field.SwitchField label={L(\"AbpSettingManagement::SmtpEnableSsl\")} />}\n </form.AppField>\n\n <form.AppField name=\"smtpUseDefaultCredentials\">\n {(field) => (\n <field.SwitchField label={L(\"AbpSettingManagement::SmtpUseDefaultCredentials\")} />\n )}\n </form.AppField>\n </FormSection>\n\n <FormSection title={L(\"Admin:SenderSection\")} description={L(\"Admin:SenderSectionDesc\")}>\n <div className=\"grid gap-4 sm:grid-cols-2\">\n <form.AppField name=\"defaultFromAddress\">\n {(field) => (\n <field.TextField\n label={L(\"AbpSettingManagement::DefaultFromAddress\")}\n type=\"email\"\n required\n />\n )}\n </form.AppField>\n\n <form.AppField name=\"defaultFromDisplayName\">\n {(field) => (\n <field.TextField\n label={L(\"AbpSettingManagement::DefaultFromDisplayName\")}\n required\n />\n )}\n </form.AppField>\n </div>\n </FormSection>\n\n <div className=\"flex items-center justify-end gap-2 px-6 py-4\">\n <Dialog open={testEmailOpen} onOpenChange={setTestEmailOpen}>\n <DialogTrigger asChild>\n <Button type=\"button\" variant=\"outline\">\n {L(\"AbpSettingManagement::SendTestEmail\")}\n </Button>\n </DialogTrigger>\n <SendTestEmailDialogContent\n defaultSenderEmailAddress={emailing.defaultFromAddress ?? \"\"}\n onClose={() => setTestEmailOpen(false)}\n />\n </Dialog>\n <form.Subscribe selector={(state) => state.isSubmitting}>\n {(isSubmitting) => <SubmitButton pending={isSubmitting} />}\n </form.Subscribe>\n </div>\n </div>\n </form>\n );\n}\n\ninterface SendTestEmailFormValues {\n senderEmailAddress: string;\n targetEmailAddress: string;\n subject: string;\n body: string;\n}\n\nfunction toSendTestEmailInput(\n value: SendTestEmailFormValues,\n): VoloAbpSettingManagementSendTestEmailInput {\n return {\n senderEmailAddress: value.senderEmailAddress,\n targetEmailAddress: value.targetEmailAddress,\n subject: value.subject,\n body: value.body || undefined,\n };\n}\n\n/** 「发送测试邮件」Dialog:成败走 toast(不进 FormErrorSummary)。demo SMTP 未配时后端抛\n * MailSendingFailed 是环境性错误而非表单校验错误,字段级映射无意义。 */\nfunction SendTestEmailDialogContent({\n defaultSenderEmailAddress,\n onClose,\n}: {\n defaultSenderEmailAddress: string;\n onClose: () => void;\n}) {\n const L = useLocalization();\n\n const emptyValues: SendTestEmailFormValues = {\n senderEmailAddress: defaultSenderEmailAddress,\n targetEmailAddress: \"\",\n subject: \"\",\n body: \"\",\n };\n\n const sendTestEmailSchema = z.object({\n senderEmailAddress: z\n .string()\n .trim()\n .min(1, L(\"Form:Required\"))\n .pipe(z.email(L(\"Form:InvalidEmail\"))),\n targetEmailAddress: z\n .string()\n .trim()\n .min(1, L(\"Form:Required\"))\n .pipe(z.email(L(\"Form:InvalidEmail\"))),\n subject: z.string().trim().min(1, L(\"Form:Required\")),\n body: z.string(),\n });\n\n const form = useAppForm(\n abpFormOptions({\n defaultValues: emptyValues,\n schema: sendTestEmailSchema,\n submit: async (value: SendTestEmailFormValues) => {\n try {\n await postApiSettingManagementEmailingSendTestEmail(toSendTestEmailInput(value));\n } catch {\n // 重新 throw 使本次提交判定失败,避免误触发成功回调关闭弹窗/重置表单;错误已由 toast 展示。\n toast.error(L(\"AbpSettingManagement::MailSendingFailed\"));\n throw new Error(L(\"AbpSettingManagement::MailSendingFailed\"));\n }\n },\n onSuccess: () => {\n toast.success(L(\"AbpSettingManagement::SentSuccessfully\"));\n form.reset(emptyValues);\n onClose();\n },\n }),\n );\n\n return (\n <DialogContent>\n <form\n onSubmit={(event) => {\n event.preventDefault();\n void form.handleSubmit();\n }}\n >\n <DialogHeader>\n <DialogTitle>{L(\"AbpSettingManagement::SendTestEmail\")}</DialogTitle>\n <DialogDescription>\n {L(\"AbpSettingManagement::Permission:EmailingTest\")}\n </DialogDescription>\n </DialogHeader>\n\n <div className=\"space-y-4 py-4\">\n <form.AppField name=\"senderEmailAddress\">\n {(field) => (\n <field.TextField\n label={L(\"AbpSettingManagement::SenderEmailAddress\")}\n type=\"email\"\n required\n />\n )}\n </form.AppField>\n\n <form.AppField name=\"targetEmailAddress\">\n {(field) => (\n <field.TextField\n label={L(\"AbpSettingManagement::TargetEmailAddress\")}\n type=\"email\"\n required\n />\n )}\n </form.AppField>\n\n <form.AppField name=\"subject\">\n {(field) => <field.TextField label={L(\"AbpSettingManagement::Subject\")} required />}\n </form.AppField>\n\n <form.AppField name=\"body\">\n {(field) => <field.TextField label={L(\"AbpSettingManagement::Body\")} />}\n </form.AppField>\n </div>\n\n <DialogFooter>\n <form.Subscribe selector={(state) => state.isSubmitting}>\n {(isSubmitting) => (\n <SubmitButton pending={isSubmitting}>{L(\"AbpSettingManagement::Send\")}</SubmitButton>\n )}\n </form.Subscribe>\n </DialogFooter>\n </form>\n </DialogContent>\n );\n}\n\nfunction TimeZoneTab() {\n const L = useLocalization();\n const timezonesQuery = useGetApiSettingManagementTimezoneTimezones();\n const currentQuery = useGetApiSettingManagementTimezone();\n\n if (timezonesQuery.isError || currentQuery.isError) {\n return <FormErrorSummary errors={[L(\"Crud:OperationFailed\")]} />;\n }\n if (!timezonesQuery.data || currentQuery.data === undefined) {\n return (\n <Card>\n <CardHeader>\n <CardTitle>{L(\"AbpSettingManagement::Menu:TimeZone\")}</CardTitle>\n </CardHeader>\n <CardContent>\n <Skeleton className=\"h-8 w-full\" />\n </CardContent>\n </Card>\n );\n }\n\n return <TimeZoneFormCard timezones={timezonesQuery.data} currentTimezone={currentQuery.data} />;\n}\n\nfunction TimeZoneFormCard({\n timezones,\n currentTimezone,\n}: {\n timezones: VoloAbpNameValue[];\n currentTimezone: string;\n}) {\n const L = useLocalization();\n const [value, setValue] = useState(currentTimezone);\n const [saving, setSaving] = useState(false);\n const timezoneOptions = useMemo(\n () =>\n timezones.map((timezone) => ({\n value: timezone.value ?? \"\",\n label: timezone.name ?? timezone.value ?? \"\",\n })),\n [timezones],\n );\n\n const handleSave = async () => {\n setSaving(true);\n try {\n await postApiSettingManagementTimezone({ timezone: value });\n toast.success(L(\"Crud:Saved\"));\n } catch {\n toast.error(L(\"Crud:OperationFailed\"));\n } finally {\n setSaving(false);\n }\n };\n\n return (\n <form\n onSubmit={(event) => {\n event.preventDefault();\n void handleSave();\n }}\n >\n <div className=\"divide-y rounded-lg border bg-card\">\n <FormSection title={L(\"AbpSettingManagement::Menu:TimeZone\")}>\n <Field>\n <FieldLabel htmlFor=\"timezone\">\n {L(\"AbpSettingManagement::DisplayName:Timezone\")}\n </FieldLabel>\n {/* 时区约 500 项,Radix Select 逐项挂载实测有 500ms+ 长任务,改用可搜索 Combobox。 */}\n <Combobox\n value={value}\n onChange={(next) => setValue(next ?? \"\")}\n options={timezoneOptions}\n />\n </Field>\n </FormSection>\n\n <div className=\"flex items-center justify-end gap-2 px-6 py-4\">\n <SubmitButton pending={saving} />\n </div>\n </div>\n </form>\n );\n}\n",
62
+ "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { createFileRoute } from \"@tanstack/react-router\";\nimport { useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { z } from \"zod\";\nimport {\n postApiSettingManagementEmailing,\n postApiSettingManagementEmailingSendTestEmail,\n useGetApiSettingManagementEmailing,\n} from \"@/api/endpoints/email-settings/email-settings\";\nimport {\n postApiSettingManagementTimezone,\n useGetApiSettingManagementTimezone,\n useGetApiSettingManagementTimezoneTimezones,\n} from \"@/api/endpoints/time-zone-settings/time-zone-settings\";\nimport type {\n VoloAbpNameValue,\n VoloAbpSettingManagementEmailSettingsDto,\n VoloAbpSettingManagementSendTestEmailInput,\n VoloAbpSettingManagementUpdateEmailSettingsDto,\n} from \"@/api/models\";\nimport { requirePermission } from \"@/auth\";\nimport { abpFormOptions } from \"@/components/abp/crud/abp-form-options\";\nimport { Combobox } from \"@/components/combobox/combobox\";\nimport { FormErrorSummary } from \"@/components/form/form-error-summary\";\nimport { useAppForm } from \"@/components/form/form-hook\";\nimport { FormSection } from \"@/components/form/form-section\";\nimport { SubmitButton } from \"@/components/form/submit-button\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Field, FieldLabel } from \"@/components/ui/field\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { SettingManagementPermissions } from \"@/permissions\";\n\n/** /settings:SettingManagement 模块的邮件/时区两页签。requiredPolicy 用\n * SettingManagement.Emailing。demo 后端两页签共用这一策略,无独立的 TimeZone 权限点。 */\nexport const Route = createFileRoute(\"/_layout/_authed/settings/\")({\n beforeLoad: requirePermission(SettingManagementPermissions.Emailing),\n component: SettingsPage,\n});\n\nfunction SettingsPage() {\n const L = useLocalization();\n return (\n <section className=\"space-y-4\">\n <h1 className=\"text-2xl font-normal\">{L(\"AbpSettingManagement::Menu:Settings\")}</h1>\n <Tabs defaultValue=\"emailing\">\n <TabsList variant=\"line\">\n <TabsTrigger value=\"emailing\">{L(\"AbpSettingManagement::Menu:Emailing\")}</TabsTrigger>\n <TabsTrigger value=\"timezone\">{L(\"AbpSettingManagement::Menu:TimeZone\")}</TabsTrigger>\n </TabsList>\n <TabsContent value=\"emailing\" className=\"space-y-4\">\n <EmailingTab />\n </TabsContent>\n <TabsContent value=\"timezone\">\n <TimeZoneTab />\n </TabsContent>\n </Tabs>\n </section>\n );\n}\n\ninterface EmailingFormValues {\n smtpHost: string;\n smtpPort: number;\n smtpUserName: string;\n smtpPassword: string;\n smtpDomain: string;\n smtpEnableSsl: boolean;\n smtpUseDefaultCredentials: boolean;\n defaultFromAddress: string;\n defaultFromDisplayName: string;\n}\n\nfunction toEmailingValues(dto: VoloAbpSettingManagementEmailSettingsDto): EmailingFormValues {\n return {\n smtpHost: dto.smtpHost ?? \"\",\n smtpPort: dto.smtpPort ?? 25,\n smtpUserName: dto.smtpUserName ?? \"\",\n smtpPassword: dto.smtpPassword ?? \"\",\n smtpDomain: dto.smtpDomain ?? \"\",\n smtpEnableSsl: dto.smtpEnableSsl ?? false,\n smtpUseDefaultCredentials: dto.smtpUseDefaultCredentials ?? false,\n defaultFromAddress: dto.defaultFromAddress ?? \"\",\n defaultFromDisplayName: dto.defaultFromDisplayName ?? \"\",\n };\n}\n\nfunction toUpdateEmailingInput(\n value: EmailingFormValues,\n): VoloAbpSettingManagementUpdateEmailSettingsDto {\n return {\n smtpHost: value.smtpHost || undefined,\n smtpPort: value.smtpPort,\n smtpUserName: value.smtpUserName || undefined,\n smtpPassword: value.smtpPassword || undefined,\n smtpDomain: value.smtpDomain || undefined,\n smtpEnableSsl: value.smtpEnableSsl,\n smtpUseDefaultCredentials: value.smtpUseDefaultCredentials,\n defaultFromAddress: value.defaultFromAddress,\n defaultFromDisplayName: value.defaultFromDisplayName,\n };\n}\n\nfunction EmailingTab() {\n const L = useLocalization();\n const emailingQuery = useGetApiSettingManagementEmailing();\n\n if (emailingQuery.isError) {\n return <FormErrorSummary errors={[L(\"Crud:OperationFailed\")]} />;\n }\n if (!emailingQuery.data) {\n return (\n <Card>\n <CardHeader>\n <CardTitle>{L(\"AbpSettingManagement::Menu:Emailing\")}</CardTitle>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <Skeleton className=\"h-8 w-full\" />\n <Skeleton className=\"h-8 w-full\" />\n <Skeleton className=\"h-8 w-full\" />\n </CardContent>\n </Card>\n );\n }\n\n return <EmailingFormCard emailing={emailingQuery.data} />;\n}\n\nfunction EmailingFormCard({ emailing }: { emailing: VoloAbpSettingManagementEmailSettingsDto }) {\n const L = useLocalization();\n const [testEmailOpen, setTestEmailOpen] = useState(false);\n\n const emailingSchema = z.object({\n smtpHost: z.string(),\n smtpPort: z.number(L(\"Form:Required\")),\n smtpUserName: z.string(),\n smtpPassword: z.string(),\n smtpDomain: z.string(),\n smtpEnableSsl: z.boolean(),\n smtpUseDefaultCredentials: z.boolean(),\n defaultFromAddress: z\n .string()\n .trim()\n .min(1, L(\"Form:Required\"))\n .pipe(z.email(L(\"Form:InvalidEmail\"))),\n defaultFromDisplayName: z.string().trim().min(1, L(\"Form:Required\")),\n });\n\n const form = useAppForm(\n abpFormOptions({\n defaultValues: toEmailingValues(emailing),\n schema: emailingSchema,\n submit: async (value: EmailingFormValues) => {\n await postApiSettingManagementEmailing(toUpdateEmailingInput(value));\n form.reset(value);\n },\n onSuccess: () => toast.success(L(\"Crud:Saved\")),\n }),\n );\n\n return (\n <form\n className=\"space-y-4\"\n onSubmit={(event) => {\n event.preventDefault();\n void form.handleSubmit();\n }}\n >\n <form.AppForm>\n <form.FormErrors />\n </form.AppForm>\n\n <div className=\"divide-y rounded-lg border bg-card\">\n <FormSection title={L(\"Admin:SmtpSection\")} description={L(\"Admin:SmtpSectionDesc\")}>\n <div className=\"grid gap-4 sm:grid-cols-2\">\n <form.AppField name=\"smtpHost\">\n {(field) => <field.TextField label={L(\"AbpSettingManagement::SmtpHost\")} />}\n </form.AppField>\n\n <form.AppField name=\"smtpPort\">\n {(field) => (\n <field.NumberField label={L(\"AbpSettingManagement::SmtpPort\")} required />\n )}\n </form.AppField>\n </div>\n\n <div className=\"grid gap-4 sm:grid-cols-2\">\n <form.AppField name=\"smtpUserName\">\n {(field) => <field.TextField label={L(\"AbpSettingManagement::SmtpUserName\")} />}\n </form.AppField>\n\n <form.AppField name=\"smtpPassword\">\n {(field) => (\n <field.TextField\n label={L(\"AbpSettingManagement::SmtpPassword\")}\n type=\"password\"\n autoComplete=\"off\"\n />\n )}\n </form.AppField>\n </div>\n\n <form.AppField name=\"smtpDomain\">\n {(field) => <field.TextField label={L(\"AbpSettingManagement::SmtpDomain\")} />}\n </form.AppField>\n\n <form.AppField name=\"smtpEnableSsl\">\n {(field) => <field.SwitchField label={L(\"AbpSettingManagement::SmtpEnableSsl\")} />}\n </form.AppField>\n\n <form.AppField name=\"smtpUseDefaultCredentials\">\n {(field) => (\n <field.SwitchField label={L(\"AbpSettingManagement::SmtpUseDefaultCredentials\")} />\n )}\n </form.AppField>\n </FormSection>\n\n <FormSection title={L(\"Admin:SenderSection\")} description={L(\"Admin:SenderSectionDesc\")}>\n <div className=\"grid gap-4 sm:grid-cols-2\">\n <form.AppField name=\"defaultFromAddress\">\n {(field) => (\n <field.TextField\n label={L(\"AbpSettingManagement::DefaultFromAddress\")}\n type=\"email\"\n required\n />\n )}\n </form.AppField>\n\n <form.AppField name=\"defaultFromDisplayName\">\n {(field) => (\n <field.TextField\n label={L(\"AbpSettingManagement::DefaultFromDisplayName\")}\n required\n />\n )}\n </form.AppField>\n </div>\n </FormSection>\n\n <div className=\"flex items-center justify-end gap-2 px-6 py-4\">\n <Dialog open={testEmailOpen} onOpenChange={setTestEmailOpen}>\n <DialogTrigger asChild>\n <Button type=\"button\" variant=\"outline\">\n {L(\"AbpSettingManagement::SendTestEmail\")}\n </Button>\n </DialogTrigger>\n <SendTestEmailDialogContent\n defaultSenderEmailAddress={emailing.defaultFromAddress ?? \"\"}\n onClose={() => setTestEmailOpen(false)}\n />\n </Dialog>\n <form.Subscribe selector={(state) => state.isSubmitting}>\n {(isSubmitting) => <SubmitButton pending={isSubmitting} />}\n </form.Subscribe>\n </div>\n </div>\n </form>\n );\n}\n\ninterface SendTestEmailFormValues {\n senderEmailAddress: string;\n targetEmailAddress: string;\n subject: string;\n body: string;\n}\n\nfunction toSendTestEmailInput(\n value: SendTestEmailFormValues,\n): VoloAbpSettingManagementSendTestEmailInput {\n return {\n senderEmailAddress: value.senderEmailAddress,\n targetEmailAddress: value.targetEmailAddress,\n subject: value.subject,\n body: value.body || undefined,\n };\n}\n\n/** 「发送测试邮件」Dialog:成败走 toast(不进 FormErrorSummary)。demo SMTP 未配时后端抛\n * MailSendingFailed 是环境性错误而非表单校验错误,字段级映射无意义。 */\nfunction SendTestEmailDialogContent({\n defaultSenderEmailAddress,\n onClose,\n}: {\n defaultSenderEmailAddress: string;\n onClose: () => void;\n}) {\n const L = useLocalization();\n\n const emptyValues: SendTestEmailFormValues = {\n senderEmailAddress: defaultSenderEmailAddress,\n targetEmailAddress: \"\",\n subject: \"\",\n body: \"\",\n };\n\n const sendTestEmailSchema = z.object({\n senderEmailAddress: z\n .string()\n .trim()\n .min(1, L(\"Form:Required\"))\n .pipe(z.email(L(\"Form:InvalidEmail\"))),\n targetEmailAddress: z\n .string()\n .trim()\n .min(1, L(\"Form:Required\"))\n .pipe(z.email(L(\"Form:InvalidEmail\"))),\n subject: z.string().trim().min(1, L(\"Form:Required\")),\n body: z.string(),\n });\n\n const form = useAppForm(\n abpFormOptions({\n defaultValues: emptyValues,\n schema: sendTestEmailSchema,\n submit: async (value: SendTestEmailFormValues) => {\n try {\n await postApiSettingManagementEmailingSendTestEmail(toSendTestEmailInput(value));\n } catch {\n // 重新 throw 使本次提交判定失败,避免误触发成功回调关闭弹窗/重置表单;错误已由 toast 展示。\n toast.error(L(\"AbpSettingManagement::MailSendingFailed\"));\n throw new Error(L(\"AbpSettingManagement::MailSendingFailed\"));\n }\n },\n onSuccess: () => {\n toast.success(L(\"AbpSettingManagement::SentSuccessfully\"));\n form.reset(emptyValues);\n onClose();\n },\n }),\n );\n\n return (\n <DialogContent>\n <form\n onSubmit={(event) => {\n event.preventDefault();\n void form.handleSubmit();\n }}\n >\n <DialogHeader>\n <DialogTitle>{L(\"AbpSettingManagement::SendTestEmail\")}</DialogTitle>\n <DialogDescription>\n {L(\"AbpSettingManagement::Permission:EmailingTest\")}\n </DialogDescription>\n </DialogHeader>\n\n <div className=\"space-y-4 py-4\">\n <form.AppField name=\"senderEmailAddress\">\n {(field) => (\n <field.TextField\n label={L(\"AbpSettingManagement::SenderEmailAddress\")}\n type=\"email\"\n required\n />\n )}\n </form.AppField>\n\n <form.AppField name=\"targetEmailAddress\">\n {(field) => (\n <field.TextField\n label={L(\"AbpSettingManagement::TargetEmailAddress\")}\n type=\"email\"\n required\n />\n )}\n </form.AppField>\n\n <form.AppField name=\"subject\">\n {(field) => <field.TextField label={L(\"AbpSettingManagement::Subject\")} required />}\n </form.AppField>\n\n <form.AppField name=\"body\">\n {(field) => <field.TextField label={L(\"AbpSettingManagement::Body\")} />}\n </form.AppField>\n </div>\n\n <DialogFooter>\n <form.Subscribe selector={(state) => state.isSubmitting}>\n {(isSubmitting) => (\n <SubmitButton pending={isSubmitting}>{L(\"AbpSettingManagement::Send\")}</SubmitButton>\n )}\n </form.Subscribe>\n </DialogFooter>\n </form>\n </DialogContent>\n );\n}\n\nfunction TimeZoneTab() {\n const L = useLocalization();\n const timezonesQuery = useGetApiSettingManagementTimezoneTimezones();\n const currentQuery = useGetApiSettingManagementTimezone();\n\n if (timezonesQuery.isError || currentQuery.isError) {\n return <FormErrorSummary errors={[L(\"Crud:OperationFailed\")]} />;\n }\n if (!timezonesQuery.data || currentQuery.data === undefined) {\n return (\n <Card>\n <CardHeader>\n <CardTitle>{L(\"AbpSettingManagement::Menu:TimeZone\")}</CardTitle>\n </CardHeader>\n <CardContent>\n <Skeleton className=\"h-8 w-full\" />\n </CardContent>\n </Card>\n );\n }\n\n return <TimeZoneFormCard timezones={timezonesQuery.data} currentTimezone={currentQuery.data} />;\n}\n\nfunction TimeZoneFormCard({\n timezones,\n currentTimezone,\n}: {\n timezones: VoloAbpNameValue[];\n currentTimezone: string;\n}) {\n const L = useLocalization();\n const [value, setValue] = useState(currentTimezone);\n const [saving, setSaving] = useState(false);\n const timezoneOptions = useMemo(\n () =>\n timezones.map((timezone) => ({\n value: timezone.value ?? \"\",\n label: timezone.name ?? timezone.value ?? \"\",\n })),\n [timezones],\n );\n\n const handleSave = async () => {\n setSaving(true);\n try {\n await postApiSettingManagementTimezone({ timezone: value });\n toast.success(L(\"Crud:Saved\"));\n } catch {\n toast.error(L(\"Crud:OperationFailed\"));\n } finally {\n setSaving(false);\n }\n };\n\n return (\n <form\n onSubmit={(event) => {\n event.preventDefault();\n void handleSave();\n }}\n >\n <div className=\"divide-y rounded-lg border bg-card\">\n <FormSection title={L(\"AbpSettingManagement::Menu:TimeZone\")}>\n <Field>\n <FieldLabel htmlFor=\"timezone\">\n {L(\"AbpSettingManagement::DisplayName:Timezone\")}\n </FieldLabel>\n {/* 时区约 500 项,Radix Select 逐项挂载实测有 500ms+ 长任务,改用可搜索 Combobox。 */}\n <Combobox\n value={value}\n onChange={(next) => setValue(next ?? \"\")}\n options={timezoneOptions}\n />\n </Field>\n </FormSection>\n\n <div className=\"flex items-center justify-end gap-2 px-6 py-4\">\n <SubmitButton pending={saving} />\n </div>\n </div>\n </form>\n );\n}\n",
63
63
  "type": "registry:file",
64
64
  "target": "src/routes/_layout/_authed/settings/index.tsx"
65
65
  },
66
66
  {
67
67
  "path": "ui/blocks/admin-pages/profile/index.tsx",
68
- "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport { createFileRoute } from \"@tanstack/react-router\";\nimport { toast } from \"sonner\";\nimport { z } from \"zod\";\nimport {\n getGetApiAccountMyProfileQueryKey,\n postApiAccountMyProfileChangePassword,\n putApiAccountMyProfile,\n useGetApiAccountMyProfile,\n} from \"@/api/endpoints/profile/profile\";\nimport type {\n VoloAbpAccountChangePasswordInput,\n VoloAbpAccountProfileDto,\n VoloAbpAccountUpdateProfileDto,\n} from \"@/api/models\";\nimport { abpFormOptions } from \"@/components/abp/crud/abp-form-options\";\nimport { FormErrorSummary } from \"@/components/form/form-error-summary\";\nimport { useAppForm } from \"@/components/form/form-hook\";\nimport { SubmitButton } from \"@/components/form/submit-button\";\nimport { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { RouteError } from \"@/routes/shell-boundary\";\n\n/** /profile 个人中心。无 requiredPolicy(认证即可访问,非租户/角色资源)。两张独立 Card:\n * 资料卡直到 getApiAccountMyProfile 结算才挂载表单(用查到的值当 useForm 的 defaultValues,\n * 而非先挂空表单再靠 effect 回填,省一次 reset 竞态);改密码卡是纯空表单,成功后自己清空。 */\nexport const Route = createFileRoute(\"/_layout/_authed/profile/\")({\n errorComponent: RouteError,\n component: ProfilePage,\n});\n\nfunction ProfilePage() {\n const L = useLocalization();\n const profileQuery = useGetApiAccountMyProfile();\n\n return (\n <section className=\"mx-auto w-full max-w-xl space-y-4\">\n <h1 className=\"text-2xl font-normal\">{L(\"Admin:Profile\")}</h1>\n {profileQuery.isError ? (\n <FormErrorSummary errors={[L(\"Crud:OperationFailed\")]} />\n ) : profileQuery.data ? (\n <ProfileInfoCard profile={profileQuery.data} />\n ) : (\n <Card>\n <CardHeader>\n <CardTitle>{L(\"Admin:ProfileInfo\")}</CardTitle>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <Skeleton className=\"h-8 w-full\" />\n <Skeleton className=\"h-8 w-full\" />\n <Skeleton className=\"h-8 w-full\" />\n </CardContent>\n </Card>\n )}\n <ChangePasswordCard />\n </section>\n );\n}\n\ninterface ProfileFormValues {\n userName: string;\n email: string;\n name: string;\n surname: string;\n phoneNumber: string;\n concurrencyStamp: string;\n}\n\nfunction toProfileValues(profile: VoloAbpAccountProfileDto): ProfileFormValues {\n return {\n userName: profile.userName ?? \"\",\n email: profile.email ?? \"\",\n name: profile.name ?? \"\",\n surname: profile.surname ?? \"\",\n phoneNumber: profile.phoneNumber ?? \"\",\n concurrencyStamp: profile.concurrencyStamp ?? \"\",\n };\n}\n\n/** ABP 乐观并发:UpdateAsync 把 concurrencyStamp 赋给实体当 EF 拦截器 UPDATE 的 WHERE 条件,\n * 不带(空串→undefined)就匹配不到行,单人保存也会抛 AbpDbConcurrencyException。 */\nfunction toUpdateProfileInput(value: ProfileFormValues): VoloAbpAccountUpdateProfileDto {\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 concurrencyStamp: value.concurrencyStamp || undefined,\n };\n}\n\nfunction ProfileInfoCard({ profile }: { profile: VoloAbpAccountProfileDto }) {\n const L = useLocalization();\n const queryClient = useQueryClient();\n\n const profileSchema = 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 concurrencyStamp: z.string(),\n });\n\n const form = useAppForm(\n abpFormOptions({\n defaultValues: toProfileValues(profile),\n schema: profileSchema,\n submit: async (value: ProfileFormValues) => {\n const updated = await putApiAccountMyProfile(toUpdateProfileInput(value));\n queryClient.setQueryData(getGetApiAccountMyProfileQueryKey(), updated);\n form.reset(toProfileValues(updated));\n },\n onSuccess: () => toast.success(L(\"Crud:Saved\")),\n }),\n );\n\n return (\n <Card>\n <CardHeader>\n <CardTitle>{L(\"Admin:ProfileInfo\")}</CardTitle>\n </CardHeader>\n <CardContent>\n <form\n className=\"space-y-4\"\n onSubmit={(event) => {\n event.preventDefault();\n void form.handleSubmit();\n }}\n >\n <form.AppForm>\n <form.FormErrors />\n </form.AppForm>\n\n <form.AppField name=\"userName\">\n {(field) => <field.TextField label={L(\"AbpAccount::DisplayName:UserName\")} required />}\n </form.AppField>\n\n <form.AppField name=\"email\">\n {(field) => (\n <field.TextField label={L(\"AbpAccount::DisplayName:Email\")} type=\"email\" required />\n )}\n </form.AppField>\n\n <form.AppField name=\"name\">\n {(field) => <field.TextField label={L(\"AbpAccount::DisplayName:Name\")} />}\n </form.AppField>\n\n <form.AppField name=\"surname\">\n {(field) => <field.TextField label={L(\"AbpAccount::DisplayName:Surname\")} />}\n </form.AppField>\n\n <form.AppField name=\"phoneNumber\">\n {(field) => <field.TextField label={L(\"AbpAccount::DisplayName:PhoneNumber\")} />}\n </form.AppField>\n\n <div className=\"flex justify-end\">\n <form.Subscribe selector={(state) => state.isSubmitting}>\n {(isSubmitting) => <SubmitButton pending={isSubmitting} />}\n </form.Subscribe>\n </div>\n </form>\n </CardContent>\n </Card>\n );\n}\n\ninterface ChangePasswordFormValues {\n currentPassword: string;\n newPassword: string;\n}\n\nconst EMPTY_PASSWORD_VALUES: ChangePasswordFormValues = { currentPassword: \"\", newPassword: \"\" };\n\nfunction toChangePasswordInput(value: ChangePasswordFormValues): VoloAbpAccountChangePasswordInput {\n return { currentPassword: value.currentPassword, newPassword: value.newPassword };\n}\n\nfunction ChangePasswordCard() {\n const L = useLocalization();\n\n const passwordSchema = z.object({\n currentPassword: z.string().trim().min(1, L(\"Form:Required\")),\n newPassword: z.string().trim().min(1, L(\"Form:Required\")),\n });\n\n const form = useAppForm(\n abpFormOptions({\n defaultValues: EMPTY_PASSWORD_VALUES,\n schema: passwordSchema,\n submit: async (value: ChangePasswordFormValues) => {\n await postApiAccountMyProfileChangePassword(toChangePasswordInput(value));\n },\n onSuccess: () => {\n toast.success(L(\"Crud:Saved\"));\n form.reset(EMPTY_PASSWORD_VALUES);\n },\n }),\n );\n\n return (\n <Card>\n <CardHeader>\n <CardTitle>{L(\"AbpAccount::ChangePassword\")}</CardTitle>\n </CardHeader>\n <CardContent>\n <form\n className=\"space-y-4\"\n onSubmit={(event) => {\n event.preventDefault();\n void form.handleSubmit();\n }}\n >\n <form.AppForm>\n <form.FormErrors />\n </form.AppForm>\n\n <form.AppField name=\"currentPassword\">\n {(field) => (\n <field.TextField\n label={L(\"AbpAccount::DisplayName:CurrentPassword\")}\n type=\"password\"\n autoComplete=\"current-password\"\n required\n />\n )}\n </form.AppField>\n\n <form.AppField name=\"newPassword\">\n {(field) => (\n <field.TextField\n label={L(\"AbpAccount::DisplayName:NewPassword\")}\n type=\"password\"\n autoComplete=\"new-password\"\n required\n />\n )}\n </form.AppField>\n\n <div className=\"flex justify-end\">\n <form.Subscribe selector={(state) => state.isSubmitting}>\n {(isSubmitting) => <SubmitButton pending={isSubmitting} />}\n </form.Subscribe>\n </div>\n </form>\n </CardContent>\n </Card>\n );\n}\n",
68
+ "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport { createFileRoute } from \"@tanstack/react-router\";\nimport { toast } from \"sonner\";\nimport { z } from \"zod\";\nimport {\n getGetApiAccountMyProfileQueryKey,\n postApiAccountMyProfileChangePassword,\n putApiAccountMyProfile,\n useGetApiAccountMyProfile,\n} from \"@/api/endpoints/profile/profile\";\nimport type {\n VoloAbpAccountChangePasswordInput,\n VoloAbpAccountProfileDto,\n VoloAbpAccountUpdateProfileDto,\n} from \"@/api/models\";\nimport { abpFormOptions } from \"@/components/abp/crud/abp-form-options\";\nimport { FormErrorSummary } from \"@/components/form/form-error-summary\";\nimport { useAppForm } from \"@/components/form/form-hook\";\nimport { SubmitButton } from \"@/components/form/submit-button\";\nimport { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\n\n/** /profile 个人中心。无 requiredPolicy(认证即可访问,非租户/角色资源)。两张独立 Card:\n * 资料卡直到 getApiAccountMyProfile 结算才挂载表单(用查到的值当 useForm 的 defaultValues,\n * 而非先挂空表单再靠 effect 回填,省一次 reset 竞态);改密码卡是纯空表单,成功后自己清空。 */\nexport const Route = createFileRoute(\"/_layout/_authed/profile/\")({\n component: ProfilePage,\n});\n\nfunction ProfilePage() {\n const L = useLocalization();\n const profileQuery = useGetApiAccountMyProfile();\n\n return (\n <section className=\"mx-auto w-full max-w-xl space-y-4\">\n <h1 className=\"text-2xl font-normal\">{L(\"Admin:Profile\")}</h1>\n {profileQuery.isError ? (\n <FormErrorSummary errors={[L(\"Crud:OperationFailed\")]} />\n ) : profileQuery.data ? (\n <ProfileInfoCard profile={profileQuery.data} />\n ) : (\n <Card>\n <CardHeader>\n <CardTitle>{L(\"Admin:ProfileInfo\")}</CardTitle>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <Skeleton className=\"h-8 w-full\" />\n <Skeleton className=\"h-8 w-full\" />\n <Skeleton className=\"h-8 w-full\" />\n </CardContent>\n </Card>\n )}\n <ChangePasswordCard />\n </section>\n );\n}\n\ninterface ProfileFormValues {\n userName: string;\n email: string;\n name: string;\n surname: string;\n phoneNumber: string;\n concurrencyStamp: string;\n}\n\nfunction toProfileValues(profile: VoloAbpAccountProfileDto): ProfileFormValues {\n return {\n userName: profile.userName ?? \"\",\n email: profile.email ?? \"\",\n name: profile.name ?? \"\",\n surname: profile.surname ?? \"\",\n phoneNumber: profile.phoneNumber ?? \"\",\n concurrencyStamp: profile.concurrencyStamp ?? \"\",\n };\n}\n\n/** ABP 乐观并发:UpdateAsync 把 concurrencyStamp 赋给实体当 EF 拦截器 UPDATE 的 WHERE 条件,\n * 不带(空串→undefined)就匹配不到行,单人保存也会抛 AbpDbConcurrencyException。 */\nfunction toUpdateProfileInput(value: ProfileFormValues): VoloAbpAccountUpdateProfileDto {\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 concurrencyStamp: value.concurrencyStamp || undefined,\n };\n}\n\nfunction ProfileInfoCard({ profile }: { profile: VoloAbpAccountProfileDto }) {\n const L = useLocalization();\n const queryClient = useQueryClient();\n\n const profileSchema = 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 concurrencyStamp: z.string(),\n });\n\n const form = useAppForm(\n abpFormOptions({\n defaultValues: toProfileValues(profile),\n schema: profileSchema,\n submit: async (value: ProfileFormValues) => {\n const updated = await putApiAccountMyProfile(toUpdateProfileInput(value));\n queryClient.setQueryData(getGetApiAccountMyProfileQueryKey(), updated);\n form.reset(toProfileValues(updated));\n },\n onSuccess: () => toast.success(L(\"Crud:Saved\")),\n }),\n );\n\n return (\n <Card>\n <CardHeader>\n <CardTitle>{L(\"Admin:ProfileInfo\")}</CardTitle>\n </CardHeader>\n <CardContent>\n <form\n className=\"space-y-4\"\n onSubmit={(event) => {\n event.preventDefault();\n void form.handleSubmit();\n }}\n >\n <form.AppForm>\n <form.FormErrors />\n </form.AppForm>\n\n <form.AppField name=\"userName\">\n {(field) => <field.TextField label={L(\"AbpAccount::DisplayName:UserName\")} required />}\n </form.AppField>\n\n <form.AppField name=\"email\">\n {(field) => (\n <field.TextField label={L(\"AbpAccount::DisplayName:Email\")} type=\"email\" required />\n )}\n </form.AppField>\n\n <form.AppField name=\"name\">\n {(field) => <field.TextField label={L(\"AbpAccount::DisplayName:Name\")} />}\n </form.AppField>\n\n <form.AppField name=\"surname\">\n {(field) => <field.TextField label={L(\"AbpAccount::DisplayName:Surname\")} />}\n </form.AppField>\n\n <form.AppField name=\"phoneNumber\">\n {(field) => <field.TextField label={L(\"AbpAccount::DisplayName:PhoneNumber\")} />}\n </form.AppField>\n\n <div className=\"flex justify-end\">\n <form.Subscribe selector={(state) => state.isSubmitting}>\n {(isSubmitting) => <SubmitButton pending={isSubmitting} />}\n </form.Subscribe>\n </div>\n </form>\n </CardContent>\n </Card>\n );\n}\n\ninterface ChangePasswordFormValues {\n currentPassword: string;\n newPassword: string;\n}\n\nconst EMPTY_PASSWORD_VALUES: ChangePasswordFormValues = { currentPassword: \"\", newPassword: \"\" };\n\nfunction toChangePasswordInput(value: ChangePasswordFormValues): VoloAbpAccountChangePasswordInput {\n return { currentPassword: value.currentPassword, newPassword: value.newPassword };\n}\n\nfunction ChangePasswordCard() {\n const L = useLocalization();\n\n const passwordSchema = z.object({\n currentPassword: z.string().trim().min(1, L(\"Form:Required\")),\n newPassword: z.string().trim().min(1, L(\"Form:Required\")),\n });\n\n const form = useAppForm(\n abpFormOptions({\n defaultValues: EMPTY_PASSWORD_VALUES,\n schema: passwordSchema,\n submit: async (value: ChangePasswordFormValues) => {\n await postApiAccountMyProfileChangePassword(toChangePasswordInput(value));\n },\n onSuccess: () => {\n toast.success(L(\"Crud:Saved\"));\n form.reset(EMPTY_PASSWORD_VALUES);\n },\n }),\n );\n\n return (\n <Card>\n <CardHeader>\n <CardTitle>{L(\"AbpAccount::ChangePassword\")}</CardTitle>\n </CardHeader>\n <CardContent>\n <form\n className=\"space-y-4\"\n onSubmit={(event) => {\n event.preventDefault();\n void form.handleSubmit();\n }}\n >\n <form.AppForm>\n <form.FormErrors />\n </form.AppForm>\n\n <form.AppField name=\"currentPassword\">\n {(field) => (\n <field.TextField\n label={L(\"AbpAccount::DisplayName:CurrentPassword\")}\n type=\"password\"\n autoComplete=\"current-password\"\n required\n />\n )}\n </form.AppField>\n\n <form.AppField name=\"newPassword\">\n {(field) => (\n <field.TextField\n label={L(\"AbpAccount::DisplayName:NewPassword\")}\n type=\"password\"\n autoComplete=\"new-password\"\n required\n />\n )}\n </form.AppField>\n\n <div className=\"flex justify-end\">\n <form.Subscribe selector={(state) => state.isSubmitting}>\n {(isSubmitting) => <SubmitButton pending={isSubmitting} />}\n </form.Subscribe>\n </div>\n </form>\n </CardContent>\n </Card>\n );\n}\n",
69
69
  "type": "registry:file",
70
70
  "target": "src/routes/_layout/_authed/profile/index.tsx"
71
71
  }
@@ -33,13 +33,13 @@
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 是上下文切换,且 --no-admin 项目没有该路由时不会编译报错。 */\nfunction ConsoleLink({ L, size }: { L: Localizer; size?: \"sm\" | \"lg\" }) {\n return (\n <Button asChild size={size}>\n <a href=\"/identity/users\">\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 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",
37
37
  "type": "registry:file",
38
38
  "target": "src/routes/index.tsx"
39
39
  },
40
40
  {
41
41
  "path": "ui/blocks/app-shell/routes/_layout/shell-messages.json",
42
- "content": "{\n \"en\": {\n \"\": {\n \"Shell:Loading\": \"Loading…\",\n \"Shell:Forbidden\": \"403 · Forbidden\",\n \"Shell:ForbiddenHint\": \"You're signed in, but you don't have permission to view this page.\",\n \"Shell:ForbiddenBody\": \"You don't have permission to access this resource.\",\n \"Shell:BackHome\": \"Back to home\",\n \"Shell:Unauthorized\": \"401 · Unauthorized\",\n \"Shell:SessionExpired\": \"Your session has expired. Please sign in again.\",\n \"Shell:SignIn\": \"Sign in\",\n \"Shell:NotFound\": \"404 · Page not found\",\n \"Shell:UnexpectedError\": \"Something went wrong\",\n \"Shell:UnknownError\": \"Unknown error\",\n \"Home:Welcome\": \"Welcome\",\n \"Home:WelcomeBack\": \"Welcome back, {0}\",\n \"Home:Tagline\": \"A modern React admin scaffold for your ABP backend — authentication, permissions, multi-tenancy, and localization, all wired up out of the box.\",\n \"Home:SignIn\": \"Sign in to get started\",\n \"Home:FeatureAuthTitle\": \"Identity & Permissions\",\n \"Home:FeatureAuthDesc\": \"OpenIddict sign-in, role management, and fine-grained permission checks kept consistent from backend to UI.\",\n \"Home:FeatureTenantTitle\": \"Multi-tenancy\",\n \"Home:FeatureTenantDesc\": \"Per-tenant data and settings isolation, with tenant switching available from the top bar.\",\n \"Home:FeatureI18nTitle\": \"Localization\",\n \"Home:FeatureI18nDesc\": \"Text and culture synced from the ABP backend, so every screen stays free of hardcoded copy.\",\n \"Home:EditHint\": \"Edit src/routes/_layout/index.tsx to make this home page your own.\",\n \"Showcase:Heading\": \"Built-in components\",\n \"Showcase:Intro\": \"Core interactive components the scaffold ships with. Each section below is a live, clickable example running on local sample data — no backend required.\",\n \"Showcase:DataTableDesc\": \"A server-paginated table primitive: sortable headers, debounced search, localized empty state, and pagination. Click a header to sort, type to search, and page through the rows.\",\n \"Showcase:ColBlock\": \"Block\",\n \"Showcase:ColFiles\": \"Files\",\n \"Showcase:ColStatus\": \"Status\",\n \"Showcase:ColBookName\": \"Title\",\n \"Showcase:ColBookAuthor\": \"Author\",\n \"Showcase:ColBookPrice\": \"Price\",\n \"Showcase:StatusStable\": \"Stable\",\n \"Showcase:StatusBeta\": \"Preview\",\n \"Showcase:SearchBlocks\": \"Search blocks…\",\n \"Showcase:BulkExport\": \"Export {0}\",\n \"Showcase:AbpTableDesc\": \"Everything data-table gives you, wired to an ABP backend: list query, create/edit/delete drawer forms, permission-gated actions, and optimistic refresh — you describe the service, the page comes with it. The table on the right is the real thing; try creating and deleting a row.\",\n \"Showcase:AbpTableCrud\": \"Create/edit/delete drawer forms that refresh the list on success.\",\n \"Showcase:AbpTablePermission\": \"Every action button gated by its ABP permission policy.\",\n \"Showcase:AbpTableConcurrency\": \"Optimistic concurrency: the concurrencyStamp is round-tripped end to end.\",\n \"Showcase:FormDesc\": \"A drawer form container plus the required-field trio: the asterisk mark, inline errors, and blur/submit validation — sharing one error channel with server-side field errors.\",\n \"Showcase:FormOpen\": \"New item\",\n \"Showcase:FormNameLabel\": \"Name\",\n \"Showcase:FormEmailLabel\": \"Email\",\n \"Showcase:FormSuccess\": \"Validated and submitted: {0}\",\n \"Showcase:TreeDesc\": \"A controlled tree: expand/collapse and tri-state checkboxes (including indeterminate). Cascade behavior is composed from the pure tree-helpers functions. Check a parent to try it.\",\n \"Showcase:TreeIdentity\": \"Identity management\",\n \"Showcase:TreeUsers\": \"Users\",\n \"Showcase:TreeRoles\": \"Roles\",\n \"Showcase:PermView\": \"View\",\n \"Showcase:PermEdit\": \"Edit\",\n \"Showcase:TreeSelected\": \"{0} selected\",\n \"Showcase:ComboboxDesc\": \"Single- and multi-select dropdowns: local filtering or remote loadOptions (debounced), with the selected label rendered back automatically. This is the local-filter mode — type to filter.\",\n \"Showcase:ComboboxSingle\": \"Single select\",\n \"Showcase:ComboboxMulti\": \"Multi select\",\n \"Showcase:OptAdmin\": \"Administrator\",\n \"Showcase:OptEditor\": \"Editor\",\n \"Showcase:OptViewer\": \"Viewer\",\n \"Showcase:OptAuditor\": \"Auditor\",\n \"Showcase:OptDeveloper\": \"Developer\",\n \"Showcase:OptOperator\": \"Operator\",\n \"Showcase:DatePickerDesc\": \"Popover-based date pickers built on react-day-picker: a single date, a from–to range, and a date + time combo, all formatted via date-fns and locale-aware. Click any field to open the calendar.\",\n \"Showcase:DatePickerSingle\": \"Pick a date\",\n \"Showcase:DatePickerRange\": \"Pick a range\",\n \"Showcase:DatePickerDateTime\": \"Pick date & time\",\n \"Showcase:DataTableTitle\": \"Server-side tables, out of the box\",\n \"Showcase:DataTableB1\": \"manualPagination / manualSorting semantics\",\n \"Showcase:DataTableB2\": \"Debounced search, submit on Enter\",\n \"Showcase:DataTableB3\": \"Localized empty state and pagination\",\n \"Showcase:FormTitle\": \"Forms, one error channel\",\n \"Showcase:FormB1\": \"Required mark + aria-required\",\n \"Showcase:FormB2\": \"Validates on blur and on submit\",\n \"Showcase:FormB3\": \"Server field errors on the same channel\",\n \"Showcase:TreeTitle\": \"Trees and cascading, composable\",\n \"Showcase:TreeB1\": \"Tri-state checkboxes, including indeterminate\",\n \"Showcase:TreeB2\": \"Pure helpers: collectSubtreeIds / deriveIndeterminate\",\n \"Showcase:TreeB3\": \"Zero business coupling — you own the policy\",\n \"Showcase:ComboboxTitle\": \"Comboboxes, local or remote\",\n \"Showcase:ComboboxB1\": \"Single and multi select\",\n \"Showcase:ComboboxB2\": \"Local filter or debounced remote loadOptions\",\n \"Showcase:ComboboxB3\": \"Selected labels rendered back automatically\",\n \"Showcase:DatePickerTitle\": \"Date pickers, locale-aware\",\n \"Showcase:DatePickerB1\": \"Single, range, and date + time variants\",\n \"Showcase:DatePickerB2\": \"date-fns formatting synced to the current culture\",\n \"Showcase:DatePickerB3\": \"DateField binds straight to an ISO date string in forms\",\n \"Showcase:AbpTableTitle\": \"ABP CRUD, wired by descriptor\",\n \"Landing:NavFeatures\": \"Features\",\n \"Landing:NavComponents\": \"Components\",\n \"Landing:NavConsole\": \"Open console\",\n \"Landing:NavSignIn\": \"Sign in\",\n \"Landing:HeroEyebrow\": \"Pure React · ABP\",\n \"Landing:HeroTitleLead\": \"A modern React admin for your\",\n \"Landing:HeroTitleAccent\": \"ABP backend.\",\n \"Landing:HeroSubtitle\": \"A hand-built, pure-React framework for your ABP backend — authentication, permissions, multi-tenancy, and localization out of the box, with data-table, form, tree, and combobox components ready to use.\",\n \"Landing:HeroWelcomeBack\": \"Welcome back, {0}. Your console is one click away.\",\n \"Landing:HeroPrimary\": \"Get started\",\n \"Landing:HeroSecondary\": \"Explore components\",\n \"Landing:InstallHint\": \"Scaffold a project with one command\",\n \"Landing:Copy\": \"Copy\",\n \"Landing:Copied\": \"Copied\",\n \"Landing:FeaturesEyebrow\": \"Features\",\n \"Landing:FeaturesTitle\": \"Everything an admin needs, already wired up\",\n \"Landing:FeatureAuthTitle\": \"OIDC sign-in\",\n \"Landing:FeatureAuthDesc\": \"Wired to OpenIddict out of the box, and switchable to another identity provider like Keycloak.\",\n \"Landing:FeaturePermTitle\": \"Fine-grained permissions\",\n \"Landing:FeaturePermDesc\": \"Permission checks kept consistent from backend to UI — gate any action by its policy.\",\n \"Landing:FeatureI18nTitle\": \"Front-and-back localization\",\n \"Landing:FeatureI18nDesc\": \"Reuses your ABP backend's localization resources, so front and back share one set of translations; switch culture and it applies instantly.\",\n \"Landing:FeatureApiTitle\": \"Type-safe API\",\n \"Landing:FeatureApiDesc\": \"Orval generates endpoints and models from your ABP Swagger — regenerate with jc-abp gen, and every call stays type-safe.\",\n \"Landing:FeatureAdminTitle\": \"Admin pages, ready\",\n \"Landing:FeatureAdminDesc\": \"Users, roles, tenants, settings, and profile CRUD pages already built on the bundled components.\",\n \"Landing:FeatureTenantTitle\": \"Multi-tenancy\",\n \"Landing:FeatureTenantDesc\": \"Per-tenant data and settings isolation, with tenant switching from the top bar.\",\n \"Landing:InActionEyebrow\": \"In action\",\n \"Landing:InActionTitle\": \"Built-in components, live\",\n \"Landing:UsageEyebrow\": \"Usage\",\n \"Landing:UsageTitle\": \"Wire it in, like this\",\n \"Landing:UsageI18nTitle\": \"Localization · L()\",\n \"Landing:UsageI18nDesc\": \"One L reads every translation shared with the backend, namespaced Resource::Key, with argument interpolation.\",\n \"Landing:UsageI18nB1\": \"useLocalization() gives you L; keys are Resource::Key\",\n \"Landing:UsageI18nB2\": \"Argument interpolation supported (page, total, …)\",\n \"Landing:UsageI18nB3\": \"useCulture() reads the current culture; switch it from the top bar\",\n \"Landing:UsagePermTitle\": \"Permissions · three ways\",\n \"Landing:UsagePermDesc\": \"One policy set, consistent front to back — pick route, imperative, or declarative.\",\n \"Landing:UsagePermB1\": \"requirePermission() guards a route in beforeLoad\",\n \"Landing:UsagePermB2\": \"usePermissionChecker() — imperative can / can.any / can.all\",\n \"Landing:UsagePermB3\": \"PermissionGuard renders children only when granted\",\n \"Landing:UsageCrudTitle\": \"abp-table · full CRUD\",\n \"Landing:UsageCrudDesc\": \"Declare the list/create/update/delete calls and a policy once; useAbpTable / useAbpSheet wire the rest, and t.Table / sheet.Sheet give you paging, search, drawer forms, and optimistic refresh.\",\n \"Landing:UsageCrudB1\": \"createCrudService: list / create / update / delete + policy\",\n \"Landing:UsageCrudB2\": \"useAbpTable + useAbpSheet: two hooks return the table, query form, and drawer, fully wired\",\n \"Landing:UsageCrudB3\": \"t.Table: permission-gated actions, optimistic refresh, concurrency\",\n \"Landing:UsageMenuTitle\": \"Navigation · configured once\",\n \"Landing:UsageMenuDesc\": \"All navigation lives in one menuItems array; the framework prunes it by permission, feature flag, and auth — hiding items and empty parents the user shouldn't see, ordered by order.\",\n \"Landing:UsageMenuB1\": \"One typed MenuItem<FileRouteTypes[\\\"to\\\"]>[] declares everything, with nested children\",\n \"Landing:UsageMenuB2\": \"requiredPolicy filters by permission; requiredFeature by feature flag\",\n \"Landing:UsageMenuB3\": \"Empty parents auto-hidden; items sorted by order\",\n \"Landing:StackEyebrow\": \"Built on\",\n \"Landing:StackTitle\": \"Standing on tools you already know\",\n \"Landing:StackReact\": \"UI runtime.\",\n \"Landing:StackStart\": \"Full-stack framework — SSR, file routing, server functions.\",\n \"Landing:StackQuery\": \"Server state and caching, with optimistic refresh on writes.\",\n \"Landing:StackTable\": \"Headless table engine — sorting and pagination.\",\n \"Landing:StackForm\": \"Form state and per-field validation.\",\n \"Landing:StackShadcn\": \"Radix-based component primitives.\",\n \"Landing:StackTailwind\": \"Utility-first, token-driven theming.\",\n \"Landing:StackOrval\": \"Type-safe API client generated from your ABP Swagger.\",\n \"Landing:StackAbp\": \".NET backend — auth, permissions, and multi-tenancy.\",\n \"Landing:CtaTitle\": \"Ready to build?\",\n \"Landing:CtaSubtitle\": \"Initialize a project and you're looking at a working ABP admin in minutes.\",\n \"Landing:FooterTagline\": \"Built with React, TanStack Start, and shadcn/ui.\"\n }\n },\n \"zh-Hans\": {\n \"\": {\n \"Shell:Loading\": \"加载中…\",\n \"Shell:Forbidden\": \"403 · 无权限\",\n \"Shell:ForbiddenHint\": \"你已登录,但没有访问该页面的权限。\",\n \"Shell:ForbiddenBody\": \"你没有访问该资源的权限。\",\n \"Shell:BackHome\": \"返回首页\",\n \"Shell:Unauthorized\": \"401 · 未登录\",\n \"Shell:SessionExpired\": \"会话已过期,请重新登录。\",\n \"Shell:SignIn\": \"登录\",\n \"Shell:NotFound\": \"404 · 页面不存在\",\n \"Shell:UnexpectedError\": \"出错了\",\n \"Shell:UnknownError\": \"未知错误\",\n \"Home:Welcome\": \"欢迎\",\n \"Home:WelcomeBack\": \"欢迎回来,{0}\",\n \"Home:Tagline\": \"面向 ABP 后端的现代化 React 管理后台脚手架——认证、权限、多租户、国际化开箱即用。\",\n \"Home:SignIn\": \"登录以开始\",\n \"Home:FeatureAuthTitle\": \"身份与权限\",\n \"Home:FeatureAuthDesc\": \"OpenIddict 登录、角色管理与细粒度权限校验,前后端保持一致。\",\n \"Home:FeatureTenantTitle\": \"多租户\",\n \"Home:FeatureTenantDesc\": \"按租户隔离数据与配置,顶栏即可切换租户。\",\n \"Home:FeatureI18nTitle\": \"国际化\",\n \"Home:FeatureI18nDesc\": \"词条与文化设置随 ABP 后端同步,每个页面都无硬编码文案。\",\n \"Home:EditHint\": \"编辑 src/routes/_layout/index.tsx,把这个首页改成你自己的。\",\n \"Showcase:Heading\": \"封装组件\",\n \"Showcase:Intro\": \"脚手架封装好的核心交互组件。下面每一节都是可以直接上手点的实时示例,用本地示例数据、不连后端。\",\n \"Showcase:DataTableDesc\": \"服务端分页语义的通用表格:排序表头、防抖搜索、词条化空态与分页控件。点表头排序、输入搜索、翻页试试。\",\n \"Showcase:ColBlock\": \"块\",\n \"Showcase:ColFiles\": \"文件数\",\n \"Showcase:ColStatus\": \"状态\",\n \"Showcase:ColBookName\": \"书名\",\n \"Showcase:ColBookAuthor\": \"作者\",\n \"Showcase:ColBookPrice\": \"价格\",\n \"Showcase:StatusStable\": \"稳定\",\n \"Showcase:StatusBeta\": \"预览\",\n \"Showcase:SearchBlocks\": \"搜索块…\",\n \"Showcase:BulkExport\": \"导出 {0} 项\",\n \"Showcase:AbpTableDesc\": \"在 data-table 之上接好 ABP 后端:列表查询、增删改侧滑表单、按权限门控的操作、乐观刷新——描述清楚 service,整页就跟着来了。右边这张表就是真货,新建、编辑、删一行试试。\",\n \"Showcase:AbpTableCrud\": \"增删改侧滑表单,成功后自动刷新列表。\",\n \"Showcase:AbpTablePermission\": \"每个操作按钮都按其 ABP 权限策略门控。\",\n \"Showcase:AbpTableConcurrency\": \"乐观并发:concurrencyStamp 全链路回传。\",\n \"Showcase:FormDesc\": \"侧滑表单容器 + 必填三件套:必填星号、内联错误、失焦/提交校验——与服务端字段错误共用同一条错误链路。\",\n \"Showcase:FormOpen\": \"新建条目\",\n \"Showcase:FormNameLabel\": \"名称\",\n \"Showcase:FormEmailLabel\": \"邮箱\",\n \"Showcase:FormSuccess\": \"校验通过,已提交:{0}\",\n \"Showcase:TreeDesc\": \"受控树形组件:展开/收起、三态勾选(含半选)。级联策略用 tree-helpers 纯函数自由组合。勾选父节点试试。\",\n \"Showcase:TreeIdentity\": \"身份管理\",\n \"Showcase:TreeUsers\": \"用户\",\n \"Showcase:TreeRoles\": \"角色\",\n \"Showcase:PermView\": \"查看\",\n \"Showcase:PermEdit\": \"编辑\",\n \"Showcase:TreeSelected\": \"已选 {0} 项\",\n \"Showcase:ComboboxDesc\": \"单选/多选下拉:本地过滤或远程 loadOptions(防抖),选中项 label 自动回显。这里是本地过滤示例,输入即筛选。\",\n \"Showcase:ComboboxSingle\": \"单选\",\n \"Showcase:ComboboxMulti\": \"多选\",\n \"Showcase:OptAdmin\": \"管理员\",\n \"Showcase:OptEditor\": \"编辑\",\n \"Showcase:OptViewer\": \"访客\",\n \"Showcase:OptAuditor\": \"审计员\",\n \"Showcase:OptDeveloper\": \"开发者\",\n \"Showcase:OptOperator\": \"运维\",\n \"Showcase:DatePickerDesc\": \"基于 react-day-picker 的弹层日期选择器:单选、区间与日期+时间三种形态,格式经 date-fns 处理并随文化切换。点击任意输入框打开日历试试。\",\n \"Showcase:DatePickerSingle\": \"选择日期\",\n \"Showcase:DatePickerRange\": \"选择日期范围\",\n \"Showcase:DatePickerDateTime\": \"选择日期时间\",\n \"Showcase:DataTableTitle\": \"服务端分页,拿来即用\",\n \"Showcase:DataTableB1\": \"manualPagination / manualSorting 语义\",\n \"Showcase:DataTableB2\": \"防抖搜索,Enter 立即提交\",\n \"Showcase:DataTableB3\": \"词条化空态与分页控件\",\n \"Showcase:FormTitle\": \"表单校验,同一条链路\",\n \"Showcase:FormB1\": \"必填星号 + aria-required\",\n \"Showcase:FormB2\": \"失焦、提交即校验\",\n \"Showcase:FormB3\": \"服务端字段错误同链回填\",\n \"Showcase:TreeTitle\": \"树形与级联,自由组合\",\n \"Showcase:TreeB1\": \"三态勾选,含半选态\",\n \"Showcase:TreeB2\": \"纯函数 collectSubtreeIds / deriveIndeterminate\",\n \"Showcase:TreeB3\": \"零业务耦合,策略你定\",\n \"Showcase:ComboboxTitle\": \"下拉搜索,本地或远程\",\n \"Showcase:ComboboxB1\": \"单选与多选\",\n \"Showcase:ComboboxB2\": \"本地过滤或远程 loadOptions(防抖)\",\n \"Showcase:ComboboxB3\": \"选中项 label 自动回显\",\n \"Showcase:DatePickerTitle\": \"日期选择器,随文化切换\",\n \"Showcase:DatePickerB1\": \"单选、区间、日期+时间三种形态\",\n \"Showcase:DatePickerB2\": \"date-fns 格式化,随当前文化切换\",\n \"Showcase:DatePickerB3\": \"DateField 直接绑定表单里的 ISO 日期字符串\",\n \"Showcase:AbpTableTitle\": \"ABP CRUD,描述符接线\",\n \"Landing:NavFeatures\": \"特性\",\n \"Landing:NavComponents\": \"组件\",\n \"Landing:NavConsole\": \"进入控制台\",\n \"Landing:NavSignIn\": \"登录\",\n \"Landing:HeroEyebrow\": \"纯 React · ABP\",\n \"Landing:HeroTitleLead\": \"为你的 ABP 后端,备好一套\",\n \"Landing:HeroTitleAccent\": \"现代 React 管理后台\",\n \"Landing:HeroSubtitle\": \"自研的纯 React ABP 框架:认证、权限、多租户、国际化开箱即用,配套 data-table、form、tree、combobox 等封装组件,拿来即用。\",\n \"Landing:HeroWelcomeBack\": \"欢迎回来,{0}。控制台一键即达。\",\n \"Landing:HeroPrimary\": \"开始使用\",\n \"Landing:HeroSecondary\": \"查看组件\",\n \"Landing:InstallHint\": \"一条命令初始化项目\",\n \"Landing:Copy\": \"复制\",\n \"Landing:Copied\": \"已复制\",\n \"Landing:FeaturesEyebrow\": \"特性\",\n \"Landing:FeaturesTitle\": \"后台该有的,都替你接好了\",\n \"Landing:FeatureAuthTitle\": \"OIDC 登录\",\n \"Landing:FeatureAuthDesc\": \"已对接 OpenIddict,开箱即用;也可切换到 Keycloak 等其它身份提供方。\",\n \"Landing:FeaturePermTitle\": \"细粒度权限\",\n \"Landing:FeaturePermDesc\": \"权限校验前后端一致,按策略门控每一个操作。\",\n \"Landing:FeatureI18nTitle\": \"前后端多语言\",\n \"Landing:FeatureI18nDesc\": \"复用 ABP 后端的本地化资源,前后端共享同一套词条;切换文化即时生效。\",\n \"Landing:FeatureApiTitle\": \"类型安全 API\",\n \"Landing:FeatureApiDesc\": \"Orval 从 ABP Swagger 生成端点与模型,jc-abp gen 一键更新,调用处全程类型安全。\",\n \"Landing:FeatureAdminTitle\": \"管理页已接好\",\n \"Landing:FeatureAdminDesc\": \"用户、角色、租户、设置、个人资料等 CRUD 页面,已用封装组件接好。\",\n \"Landing:FeatureTenantTitle\": \"多租户\",\n \"Landing:FeatureTenantDesc\": \"按租户隔离数据与配置,顶栏一键切换租户。\",\n \"Landing:InActionEyebrow\": \"实时演示\",\n \"Landing:InActionTitle\": \"封装组件,点点看\",\n \"Landing:UsageEyebrow\": \"用法\",\n \"Landing:UsageTitle\": \"接进来,就这么写\",\n \"Landing:UsageI18nTitle\": \"多语言 · L()\",\n \"Landing:UsageI18nDesc\": \"一个 L 取遍与后端共享的词条,按 资源::键 命名,支持带参插值。\",\n \"Landing:UsageI18nB1\": \"useLocalization() 拿到 L,资源::键 取词条\",\n \"Landing:UsageI18nB2\": \"支持带参插值(page、total 等)\",\n \"Landing:UsageI18nB3\": \"useCulture() 读当前文化,顶栏 LocaleSwitcher 切换\",\n \"Landing:UsagePermTitle\": \"权限 · 三种写法\",\n \"Landing:UsagePermDesc\": \"同一套策略,前后端一致;路由、命令式、声明式任你选。\",\n \"Landing:UsagePermB1\": \"requirePermission() 在 beforeLoad 做路由守卫\",\n \"Landing:UsagePermB2\": \"usePermissionChecker() 命令式:can / can.any / can.all\",\n \"Landing:UsagePermB3\": \"PermissionGuard 声明式:授权才渲染 children\",\n \"Landing:UsageCrudTitle\": \"abp-table · 完整 CRUD\",\n \"Landing:UsageCrudDesc\": \"createCrudService 声明列表/增删改与权限策略,useAbpTable / useAbpSheet 把接线分别收进两个 hook,t.Table / sheet.Sheet 一挂就有分页、搜索、增删改侧滑与乐观刷新。\",\n \"Landing:UsageCrudB1\": \"createCrudService:list / create / update / delete + policy\",\n \"Landing:UsageCrudB2\": \"useAbpTable + useAbpSheet:两个 hook 返回接好线的表格、查询区与抽屉\",\n \"Landing:UsageCrudB3\": \"t.Table:按权限门控、乐观刷新、并发校验\",\n \"Landing:UsageMenuTitle\": \"菜单导航 · 统一配置\",\n \"Landing:UsageMenuDesc\": \"所有导航在一份 menuItems 数组里声明;框架按权限、功能开关与登录态自动裁剪,无权限的项连同空父级一并隐藏,按 order 排序。\",\n \"Landing:UsageMenuB1\": \"一份类型化 MenuItem<FileRouteTypes[\\\"to\\\"]>[] 声明全部导航,可嵌套 children\",\n \"Landing:UsageMenuB2\": \"requiredPolicy 按权限过滤,requiredFeature 按功能开关过滤\",\n \"Landing:UsageMenuB3\": \"无链接且子项被裁空的父级自动隐藏,按 order 排序\",\n \"Landing:StackEyebrow\": \"技术栈\",\n \"Landing:StackTitle\": \"站在你熟悉的成熟工具之上\",\n \"Landing:StackReact\": \"UI 运行时。\",\n \"Landing:StackStart\": \"全栈框架——SSR、文件路由、server functions。\",\n \"Landing:StackQuery\": \"服务端状态与缓存,写操作乐观刷新。\",\n \"Landing:StackTable\": \"无头表格引擎——排序与分页。\",\n \"Landing:StackForm\": \"表单状态与逐字段校验。\",\n \"Landing:StackShadcn\": \"基于 Radix 的组件原语。\",\n \"Landing:StackTailwind\": \"原子化 + 语义 token 主题。\",\n \"Landing:StackOrval\": \"从 ABP Swagger 生成类型安全 API 客户端。\",\n \"Landing:StackAbp\": \".NET 后端——认证、权限、多租户。\",\n \"Landing:CtaTitle\": \"准备好开始了吗?\",\n \"Landing:CtaSubtitle\": \"初始化一个项目,几分钟后你面对的就是一套能跑的 ABP 后台。\",\n \"Landing:FooterTagline\": \"基于 React、TanStack Start 与 shadcn/ui 构建。\"\n }\n }\n}\n",
42
+ "content": "{\n \"en\": {\n \"\": {\n \"Shell:Loading\": \"Loading…\",\n \"Shell:Forbidden\": \"403 · Forbidden\",\n \"Shell:ForbiddenHint\": \"You're signed in, but you don't have permission to view this page.\",\n \"Shell:ForbiddenBody\": \"You don't have permission to access this resource.\",\n \"Shell:BackHome\": \"Back to home\",\n \"Shell:Unauthorized\": \"401 · Unauthorized\",\n \"Shell:SessionExpired\": \"Your session has expired. Please sign in again.\",\n \"Shell:SignIn\": \"Sign in\",\n \"Shell:NotFound\": \"404 · Page not found\",\n \"Shell:UnexpectedError\": \"Something went wrong\",\n \"Shell:UnknownError\": \"Unknown error\",\n \"Home:Welcome\": \"Welcome\",\n \"Home:WelcomeBack\": \"Welcome back, {0}\",\n \"Home:Tagline\": \"A modern React admin scaffold for your ABP backend — authentication, permissions, multi-tenancy, and localization, all wired up out of the box.\",\n \"Home:SignIn\": \"Sign in to get started\",\n \"Home:FeatureAuthTitle\": \"Identity & Permissions\",\n \"Home:FeatureAuthDesc\": \"OpenIddict sign-in, role management, and fine-grained permission checks kept consistent from backend to UI.\",\n \"Home:FeatureTenantTitle\": \"Multi-tenancy\",\n \"Home:FeatureTenantDesc\": \"Per-tenant data and settings isolation, with tenant switching available from the top bar.\",\n \"Home:FeatureI18nTitle\": \"Localization\",\n \"Home:FeatureI18nDesc\": \"Text and culture synced from the ABP backend, so every screen stays free of hardcoded copy.\",\n \"Home:EditHint\": \"Edit src/routes/_layout/_authed/home.tsx to make this home page your own.\",\n \"Showcase:Heading\": \"Built-in components\",\n \"Showcase:Intro\": \"Core interactive components the scaffold ships with. Each section below is a live, clickable example running on local sample data — no backend required.\",\n \"Showcase:DataTableDesc\": \"A server-paginated table primitive: sortable headers, debounced search, localized empty state, and pagination. Click a header to sort, type to search, and page through the rows.\",\n \"Showcase:ColBlock\": \"Block\",\n \"Showcase:ColFiles\": \"Files\",\n \"Showcase:ColStatus\": \"Status\",\n \"Showcase:ColBookName\": \"Title\",\n \"Showcase:ColBookAuthor\": \"Author\",\n \"Showcase:ColBookPrice\": \"Price\",\n \"Showcase:StatusStable\": \"Stable\",\n \"Showcase:StatusBeta\": \"Preview\",\n \"Showcase:SearchBlocks\": \"Search blocks…\",\n \"Showcase:BulkExport\": \"Export {0}\",\n \"Showcase:AbpTableDesc\": \"Everything data-table gives you, wired to an ABP backend: list query, create/edit/delete drawer forms, permission-gated actions, and optimistic refresh — you describe the service, the page comes with it. The table on the right is the real thing; try creating and deleting a row.\",\n \"Showcase:AbpTableCrud\": \"Create/edit/delete drawer forms that refresh the list on success.\",\n \"Showcase:AbpTablePermission\": \"Every action button gated by its ABP permission policy.\",\n \"Showcase:AbpTableConcurrency\": \"Optimistic concurrency: the concurrencyStamp is round-tripped end to end.\",\n \"Showcase:FormDesc\": \"A drawer form container plus the required-field trio: the asterisk mark, inline errors, and blur/submit validation — sharing one error channel with server-side field errors.\",\n \"Showcase:FormOpen\": \"New item\",\n \"Showcase:FormNameLabel\": \"Name\",\n \"Showcase:FormEmailLabel\": \"Email\",\n \"Showcase:FormSuccess\": \"Validated and submitted: {0}\",\n \"Showcase:TreeDesc\": \"A controlled tree: expand/collapse and tri-state checkboxes (including indeterminate). Cascade behavior is composed from the pure tree-helpers functions. Check a parent to try it.\",\n \"Showcase:TreeIdentity\": \"Identity management\",\n \"Showcase:TreeUsers\": \"Users\",\n \"Showcase:TreeRoles\": \"Roles\",\n \"Showcase:PermView\": \"View\",\n \"Showcase:PermEdit\": \"Edit\",\n \"Showcase:TreeSelected\": \"{0} selected\",\n \"Showcase:ComboboxDesc\": \"Single- and multi-select dropdowns: local filtering or remote loadOptions (debounced), with the selected label rendered back automatically. This is the local-filter mode — type to filter.\",\n \"Showcase:ComboboxSingle\": \"Single select\",\n \"Showcase:ComboboxMulti\": \"Multi select\",\n \"Showcase:OptAdmin\": \"Administrator\",\n \"Showcase:OptEditor\": \"Editor\",\n \"Showcase:OptViewer\": \"Viewer\",\n \"Showcase:OptAuditor\": \"Auditor\",\n \"Showcase:OptDeveloper\": \"Developer\",\n \"Showcase:OptOperator\": \"Operator\",\n \"Showcase:DatePickerDesc\": \"Popover-based date pickers built on react-day-picker: a single date, a from–to range, and a date + time combo, all formatted via date-fns and locale-aware. Click any field to open the calendar.\",\n \"Showcase:DatePickerSingle\": \"Pick a date\",\n \"Showcase:DatePickerRange\": \"Pick a range\",\n \"Showcase:DatePickerDateTime\": \"Pick date & time\",\n \"Showcase:DataTableTitle\": \"Server-side tables, out of the box\",\n \"Showcase:DataTableB1\": \"manualPagination / manualSorting semantics\",\n \"Showcase:DataTableB2\": \"Debounced search, submit on Enter\",\n \"Showcase:DataTableB3\": \"Localized empty state and pagination\",\n \"Showcase:FormTitle\": \"Forms, one error channel\",\n \"Showcase:FormB1\": \"Required mark + aria-required\",\n \"Showcase:FormB2\": \"Validates on blur and on submit\",\n \"Showcase:FormB3\": \"Server field errors on the same channel\",\n \"Showcase:TreeTitle\": \"Trees and cascading, composable\",\n \"Showcase:TreeB1\": \"Tri-state checkboxes, including indeterminate\",\n \"Showcase:TreeB2\": \"Pure helpers: collectSubtreeIds / deriveIndeterminate\",\n \"Showcase:TreeB3\": \"Zero business coupling — you own the policy\",\n \"Showcase:ComboboxTitle\": \"Comboboxes, local or remote\",\n \"Showcase:ComboboxB1\": \"Single and multi select\",\n \"Showcase:ComboboxB2\": \"Local filter or debounced remote loadOptions\",\n \"Showcase:ComboboxB3\": \"Selected labels rendered back automatically\",\n \"Showcase:DatePickerTitle\": \"Date pickers, locale-aware\",\n \"Showcase:DatePickerB1\": \"Single, range, and date + time variants\",\n \"Showcase:DatePickerB2\": \"date-fns formatting synced to the current culture\",\n \"Showcase:DatePickerB3\": \"DateField binds straight to an ISO date string in forms\",\n \"Showcase:AbpTableTitle\": \"ABP CRUD, wired by descriptor\",\n \"Landing:NavFeatures\": \"Features\",\n \"Landing:NavComponents\": \"Components\",\n \"Landing:NavConsole\": \"Open console\",\n \"Landing:NavSignIn\": \"Sign in\",\n \"Landing:HeroEyebrow\": \"Pure React · ABP\",\n \"Landing:HeroTitleLead\": \"A modern React admin for your\",\n \"Landing:HeroTitleAccent\": \"ABP backend.\",\n \"Landing:HeroSubtitle\": \"A pure-React framework for your ABP backend — authentication, permissions, multi-tenancy, and localization out of the box, with data-table, form, tree, and combobox components ready to use.\",\n \"Landing:HeroWelcomeBack\": \"Welcome back, {0}. Your console is one click away.\",\n \"Landing:HeroPrimary\": \"Get started\",\n \"Landing:HeroSecondary\": \"Explore components\",\n \"Landing:InstallHint\": \"Scaffold a project with one command\",\n \"Landing:Copy\": \"Copy\",\n \"Landing:Copied\": \"Copied\",\n \"Landing:FeaturesEyebrow\": \"Features\",\n \"Landing:FeaturesTitle\": \"Everything an admin needs, already wired up\",\n \"Landing:FeatureAuthTitle\": \"OIDC sign-in\",\n \"Landing:FeatureAuthDesc\": \"Wired to OpenIddict out of the box, and switchable to another identity provider like Keycloak.\",\n \"Landing:FeaturePermTitle\": \"Fine-grained permissions\",\n \"Landing:FeaturePermDesc\": \"Permission checks kept consistent from backend to UI — gate any action by its policy.\",\n \"Landing:FeatureI18nTitle\": \"Front-and-back localization\",\n \"Landing:FeatureI18nDesc\": \"Reuses your ABP backend's localization resources, so front and back share one set of translations; switch culture and it applies instantly.\",\n \"Landing:FeatureApiTitle\": \"Type-safe API\",\n \"Landing:FeatureApiDesc\": \"Orval generates endpoints and models from your ABP Swagger — regenerate with jc-abp gen, and every call stays type-safe.\",\n \"Landing:FeatureAdminTitle\": \"Admin pages, ready\",\n \"Landing:FeatureAdminDesc\": \"Users, roles, tenants, settings, and profile CRUD pages already built on the bundled components.\",\n \"Landing:FeatureTenantTitle\": \"Multi-tenancy\",\n \"Landing:FeatureTenantDesc\": \"Per-tenant data and settings isolation, with tenant switching from the top bar.\",\n \"Landing:InActionEyebrow\": \"In action\",\n \"Landing:InActionTitle\": \"Built-in components, live\",\n \"Landing:UsageEyebrow\": \"Usage\",\n \"Landing:UsageTitle\": \"Wire it in, like this\",\n \"Landing:UsageI18nTitle\": \"Localization · L()\",\n \"Landing:UsageI18nDesc\": \"One L reads every translation shared with the backend, namespaced Resource::Key, with argument interpolation.\",\n \"Landing:UsageI18nB1\": \"useLocalization() gives you L; keys are Resource::Key\",\n \"Landing:UsageI18nB2\": \"Argument interpolation supported (page, total, …)\",\n \"Landing:UsageI18nB3\": \"useCulture() reads the current culture; switch it from the top bar\",\n \"Landing:UsagePermTitle\": \"Permissions · three ways\",\n \"Landing:UsagePermDesc\": \"One policy set, consistent front to back — pick route, imperative, or declarative.\",\n \"Landing:UsagePermB1\": \"requirePermission() guards a route in beforeLoad\",\n \"Landing:UsagePermB2\": \"usePermissionChecker() — imperative can / can.any / can.all\",\n \"Landing:UsagePermB3\": \"PermissionGuard renders children only when granted\",\n \"Landing:UsageCrudTitle\": \"abp-table · full CRUD\",\n \"Landing:UsageCrudDesc\": \"Declare the list/create/update/delete calls and a policy once; useAbpTable / useAbpSheet wire the rest, and t.Table / sheet.Sheet give you paging, search, drawer forms, and optimistic refresh.\",\n \"Landing:UsageCrudB1\": \"createCrudService: list / create / update / delete + policy\",\n \"Landing:UsageCrudB2\": \"useAbpTable + useAbpSheet: two hooks return the table, query form, and drawer, fully wired\",\n \"Landing:UsageCrudB3\": \"t.Table: permission-gated actions, optimistic refresh, concurrency\",\n \"Landing:UsageMenuTitle\": \"Navigation · configured once\",\n \"Landing:UsageMenuDesc\": \"All navigation lives in one menuItems array; the framework prunes it by permission, feature flag, and auth — hiding items and empty parents the user shouldn't see, ordered by order.\",\n \"Landing:UsageMenuB1\": \"One typed MenuItem<FileRouteTypes[\\\"to\\\"]>[] declares everything, with nested children\",\n \"Landing:UsageMenuB2\": \"requiredPolicy filters by permission; requiredFeature by feature flag\",\n \"Landing:UsageMenuB3\": \"Empty parents auto-hidden; items sorted by order\",\n \"Landing:StackEyebrow\": \"Built on\",\n \"Landing:StackTitle\": \"Standing on tools you already know\",\n \"Landing:StackReact\": \"UI runtime.\",\n \"Landing:StackStart\": \"Full-stack framework — SSR, file routing, server functions.\",\n \"Landing:StackQuery\": \"Server state and caching, with optimistic refresh on writes.\",\n \"Landing:StackTable\": \"Headless table engine — sorting and pagination.\",\n \"Landing:StackForm\": \"Form state and per-field validation.\",\n \"Landing:StackShadcn\": \"Radix-based component primitives.\",\n \"Landing:StackTailwind\": \"Utility-first, token-driven theming.\",\n \"Landing:StackOrval\": \"Type-safe API client generated from your ABP Swagger.\",\n \"Landing:StackAbp\": \".NET backend — auth, permissions, and multi-tenancy.\",\n \"Landing:CtaTitle\": \"Ready to build?\",\n \"Landing:CtaSubtitle\": \"Initialize a project and you're looking at a working ABP admin in minutes.\",\n \"Landing:FooterTagline\": \"Built with React, TanStack Start, and shadcn/ui.\",\n \"Shell:Retry\": \"Retry\",\n \"Shell:SignOut\": \"Sign out\"\n }\n },\n \"zh-Hans\": {\n \"\": {\n \"Shell:Loading\": \"加载中…\",\n \"Shell:Forbidden\": \"403 · 无权限\",\n \"Shell:ForbiddenHint\": \"你已登录,但没有访问该页面的权限。\",\n \"Shell:ForbiddenBody\": \"你没有访问该资源的权限。\",\n \"Shell:BackHome\": \"返回首页\",\n \"Shell:Unauthorized\": \"401 · 未登录\",\n \"Shell:SessionExpired\": \"会话已过期,请重新登录。\",\n \"Shell:SignIn\": \"登录\",\n \"Shell:NotFound\": \"404 · 页面不存在\",\n \"Shell:UnexpectedError\": \"出错了\",\n \"Shell:UnknownError\": \"未知错误\",\n \"Home:Welcome\": \"欢迎\",\n \"Home:WelcomeBack\": \"欢迎回来,{0}\",\n \"Home:Tagline\": \"面向 ABP 后端的现代化 React 管理后台脚手架——认证、权限、多租户、国际化开箱即用。\",\n \"Home:SignIn\": \"登录以开始\",\n \"Home:FeatureAuthTitle\": \"身份与权限\",\n \"Home:FeatureAuthDesc\": \"OpenIddict 登录、角色管理与细粒度权限校验,前后端保持一致。\",\n \"Home:FeatureTenantTitle\": \"多租户\",\n \"Home:FeatureTenantDesc\": \"按租户隔离数据与配置,顶栏即可切换租户。\",\n \"Home:FeatureI18nTitle\": \"国际化\",\n \"Home:FeatureI18nDesc\": \"词条与文化设置随 ABP 后端同步,每个页面都无硬编码文案。\",\n \"Home:EditHint\": \"编辑 src/routes/_layout/_authed/home.tsx,把这个首页改成你自己的。\",\n \"Showcase:Heading\": \"封装组件\",\n \"Showcase:Intro\": \"脚手架封装好的核心交互组件。下面每一节都是可以直接上手点的实时示例,用本地示例数据、不连后端。\",\n \"Showcase:DataTableDesc\": \"服务端分页语义的通用表格:排序表头、防抖搜索、词条化空态与分页控件。点表头排序、输入搜索、翻页试试。\",\n \"Showcase:ColBlock\": \"块\",\n \"Showcase:ColFiles\": \"文件数\",\n \"Showcase:ColStatus\": \"状态\",\n \"Showcase:ColBookName\": \"书名\",\n \"Showcase:ColBookAuthor\": \"作者\",\n \"Showcase:ColBookPrice\": \"价格\",\n \"Showcase:StatusStable\": \"稳定\",\n \"Showcase:StatusBeta\": \"预览\",\n \"Showcase:SearchBlocks\": \"搜索块…\",\n \"Showcase:BulkExport\": \"导出 {0} 项\",\n \"Showcase:AbpTableDesc\": \"在 data-table 之上接好 ABP 后端:列表查询、增删改侧滑表单、按权限门控的操作、乐观刷新——描述清楚 service,整页就跟着来了。右边这张表就是真货,新建、编辑、删一行试试。\",\n \"Showcase:AbpTableCrud\": \"增删改侧滑表单,成功后自动刷新列表。\",\n \"Showcase:AbpTablePermission\": \"每个操作按钮都按其 ABP 权限策略门控。\",\n \"Showcase:AbpTableConcurrency\": \"乐观并发:concurrencyStamp 全链路回传。\",\n \"Showcase:FormDesc\": \"侧滑表单容器 + 必填三件套:必填星号、内联错误、失焦/提交校验——与服务端字段错误共用同一条错误链路。\",\n \"Showcase:FormOpen\": \"新建条目\",\n \"Showcase:FormNameLabel\": \"名称\",\n \"Showcase:FormEmailLabel\": \"邮箱\",\n \"Showcase:FormSuccess\": \"校验通过,已提交:{0}\",\n \"Showcase:TreeDesc\": \"受控树形组件:展开/收起、三态勾选(含半选)。级联策略用 tree-helpers 纯函数自由组合。勾选父节点试试。\",\n \"Showcase:TreeIdentity\": \"身份管理\",\n \"Showcase:TreeUsers\": \"用户\",\n \"Showcase:TreeRoles\": \"角色\",\n \"Showcase:PermView\": \"查看\",\n \"Showcase:PermEdit\": \"编辑\",\n \"Showcase:TreeSelected\": \"已选 {0} 项\",\n \"Showcase:ComboboxDesc\": \"单选/多选下拉:本地过滤或远程 loadOptions(防抖),选中项 label 自动回显。这里是本地过滤示例,输入即筛选。\",\n \"Showcase:ComboboxSingle\": \"单选\",\n \"Showcase:ComboboxMulti\": \"多选\",\n \"Showcase:OptAdmin\": \"管理员\",\n \"Showcase:OptEditor\": \"编辑\",\n \"Showcase:OptViewer\": \"访客\",\n \"Showcase:OptAuditor\": \"审计员\",\n \"Showcase:OptDeveloper\": \"开发者\",\n \"Showcase:OptOperator\": \"运维\",\n \"Showcase:DatePickerDesc\": \"基于 react-day-picker 的弹层日期选择器:单选、区间与日期+时间三种形态,格式经 date-fns 处理并随文化切换。点击任意输入框打开日历试试。\",\n \"Showcase:DatePickerSingle\": \"选择日期\",\n \"Showcase:DatePickerRange\": \"选择日期范围\",\n \"Showcase:DatePickerDateTime\": \"选择日期时间\",\n \"Showcase:DataTableTitle\": \"服务端分页,拿来即用\",\n \"Showcase:DataTableB1\": \"manualPagination / manualSorting 语义\",\n \"Showcase:DataTableB2\": \"防抖搜索,Enter 立即提交\",\n \"Showcase:DataTableB3\": \"词条化空态与分页控件\",\n \"Showcase:FormTitle\": \"表单校验,同一条链路\",\n \"Showcase:FormB1\": \"必填星号 + aria-required\",\n \"Showcase:FormB2\": \"失焦、提交即校验\",\n \"Showcase:FormB3\": \"服务端字段错误同链回填\",\n \"Showcase:TreeTitle\": \"树形与级联,自由组合\",\n \"Showcase:TreeB1\": \"三态勾选,含半选态\",\n \"Showcase:TreeB2\": \"纯函数 collectSubtreeIds / deriveIndeterminate\",\n \"Showcase:TreeB3\": \"零业务耦合,策略你定\",\n \"Showcase:ComboboxTitle\": \"下拉搜索,本地或远程\",\n \"Showcase:ComboboxB1\": \"单选与多选\",\n \"Showcase:ComboboxB2\": \"本地过滤或远程 loadOptions(防抖)\",\n \"Showcase:ComboboxB3\": \"选中项 label 自动回显\",\n \"Showcase:DatePickerTitle\": \"日期选择器,随文化切换\",\n \"Showcase:DatePickerB1\": \"单选、区间、日期+时间三种形态\",\n \"Showcase:DatePickerB2\": \"date-fns 格式化,随当前文化切换\",\n \"Showcase:DatePickerB3\": \"DateField 直接绑定表单里的 ISO 日期字符串\",\n \"Showcase:AbpTableTitle\": \"ABP CRUD,描述符接线\",\n \"Landing:NavFeatures\": \"特性\",\n \"Landing:NavComponents\": \"组件\",\n \"Landing:NavConsole\": \"进入控制台\",\n \"Landing:NavSignIn\": \"登录\",\n \"Landing:HeroEyebrow\": \"纯 React · ABP\",\n \"Landing:HeroTitleLead\": \"为你的 ABP 后端,备好一套\",\n \"Landing:HeroTitleAccent\": \"现代 React 管理后台\",\n \"Landing:HeroSubtitle\": \"面向 ABP 后端的纯 React 框架:认证、权限、多租户、国际化开箱即用,配套 data-table、form、tree、combobox 等封装组件,拿来即用。\",\n \"Landing:HeroWelcomeBack\": \"欢迎回来,{0}。控制台一键即达。\",\n \"Landing:HeroPrimary\": \"开始使用\",\n \"Landing:HeroSecondary\": \"查看组件\",\n \"Landing:InstallHint\": \"一条命令初始化项目\",\n \"Landing:Copy\": \"复制\",\n \"Landing:Copied\": \"已复制\",\n \"Landing:FeaturesEyebrow\": \"特性\",\n \"Landing:FeaturesTitle\": \"后台该有的,都替你接好了\",\n \"Landing:FeatureAuthTitle\": \"OIDC 登录\",\n \"Landing:FeatureAuthDesc\": \"已对接 OpenIddict,开箱即用;也可切换到 Keycloak 等其它身份提供方。\",\n \"Landing:FeaturePermTitle\": \"细粒度权限\",\n \"Landing:FeaturePermDesc\": \"权限校验前后端一致,按策略门控每一个操作。\",\n \"Landing:FeatureI18nTitle\": \"前后端多语言\",\n \"Landing:FeatureI18nDesc\": \"复用 ABP 后端的本地化资源,前后端共享同一套词条;切换文化即时生效。\",\n \"Landing:FeatureApiTitle\": \"类型安全 API\",\n \"Landing:FeatureApiDesc\": \"Orval 从 ABP Swagger 生成端点与模型,jc-abp gen 一键更新,调用处全程类型安全。\",\n \"Landing:FeatureAdminTitle\": \"管理页已接好\",\n \"Landing:FeatureAdminDesc\": \"用户、角色、租户、设置、个人资料等 CRUD 页面,已用封装组件接好。\",\n \"Landing:FeatureTenantTitle\": \"多租户\",\n \"Landing:FeatureTenantDesc\": \"按租户隔离数据与配置,顶栏一键切换租户。\",\n \"Landing:InActionEyebrow\": \"实时演示\",\n \"Landing:InActionTitle\": \"封装组件,点点看\",\n \"Landing:UsageEyebrow\": \"用法\",\n \"Landing:UsageTitle\": \"接进来,就这么写\",\n \"Landing:UsageI18nTitle\": \"多语言 · L()\",\n \"Landing:UsageI18nDesc\": \"一个 L 取遍与后端共享的词条,按 资源::键 命名,支持带参插值。\",\n \"Landing:UsageI18nB1\": \"useLocalization() 拿到 L,资源::键 取词条\",\n \"Landing:UsageI18nB2\": \"支持带参插值(page、total 等)\",\n \"Landing:UsageI18nB3\": \"useCulture() 读当前文化,顶栏 LocaleSwitcher 切换\",\n \"Landing:UsagePermTitle\": \"权限 · 三种写法\",\n \"Landing:UsagePermDesc\": \"同一套策略,前后端一致;路由、命令式、声明式任你选。\",\n \"Landing:UsagePermB1\": \"requirePermission() 在 beforeLoad 做路由守卫\",\n \"Landing:UsagePermB2\": \"usePermissionChecker() 命令式:can / can.any / can.all\",\n \"Landing:UsagePermB3\": \"PermissionGuard 声明式:授权才渲染 children\",\n \"Landing:UsageCrudTitle\": \"abp-table · 完整 CRUD\",\n \"Landing:UsageCrudDesc\": \"createCrudService 声明列表/增删改与权限策略,useAbpTable / useAbpSheet 把接线分别收进两个 hook,t.Table / sheet.Sheet 一挂就有分页、搜索、增删改侧滑与乐观刷新。\",\n \"Landing:UsageCrudB1\": \"createCrudService:list / create / update / delete + policy\",\n \"Landing:UsageCrudB2\": \"useAbpTable + useAbpSheet:两个 hook 返回接好线的表格、查询区与抽屉\",\n \"Landing:UsageCrudB3\": \"t.Table:按权限门控、乐观刷新、并发校验\",\n \"Landing:UsageMenuTitle\": \"菜单导航 · 统一配置\",\n \"Landing:UsageMenuDesc\": \"所有导航在一份 menuItems 数组里声明;框架按权限、功能开关与登录态自动裁剪,无权限的项连同空父级一并隐藏,按 order 排序。\",\n \"Landing:UsageMenuB1\": \"一份类型化 MenuItem<FileRouteTypes[\\\"to\\\"]>[] 声明全部导航,可嵌套 children\",\n \"Landing:UsageMenuB2\": \"requiredPolicy 按权限过滤,requiredFeature 按功能开关过滤\",\n \"Landing:UsageMenuB3\": \"无链接且子项被裁空的父级自动隐藏,按 order 排序\",\n \"Landing:StackEyebrow\": \"技术栈\",\n \"Landing:StackTitle\": \"站在你熟悉的成熟工具之上\",\n \"Landing:StackReact\": \"UI 运行时。\",\n \"Landing:StackStart\": \"全栈框架——SSR、文件路由、server functions。\",\n \"Landing:StackQuery\": \"服务端状态与缓存,写操作乐观刷新。\",\n \"Landing:StackTable\": \"无头表格引擎——排序与分页。\",\n \"Landing:StackForm\": \"表单状态与逐字段校验。\",\n \"Landing:StackShadcn\": \"基于 Radix 的组件原语。\",\n \"Landing:StackTailwind\": \"原子化 + 语义 token 主题。\",\n \"Landing:StackOrval\": \"从 ABP Swagger 生成类型安全 API 客户端。\",\n \"Landing:StackAbp\": \".NET 后端——认证、权限、多租户。\",\n \"Landing:CtaTitle\": \"准备好开始了吗?\",\n \"Landing:CtaSubtitle\": \"初始化一个项目,几分钟后你面对的就是一套能跑的 ABP 后台。\",\n \"Landing:FooterTagline\": \"基于 React、TanStack Start 与 shadcn/ui 构建。\",\n \"Shell:Retry\": \"重试\",\n \"Shell:SignOut\": \"注销\"\n }\n }\n}\n",
43
43
  "type": "registry:file",
44
44
  "target": "src/routes/_layout/shell-messages.json"
45
45
  },
@@ -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 { 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 单一类。 */\nexport function RouteError({ 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 <Link to=\"/\" className=\"text-sm text-primary underline-offset-4 hover:underline\">\n {L(\"Shell:BackHome\")}\n </Link>\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\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 <Link to=\"/\" className=\"text-sm text-primary underline-offset-4 hover:underline\">\n {L(\"Shell:BackHome\")}\n </Link>\n </section>\n );\n}\n",
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",
91
91
  "type": "registry:file",
92
92
  "target": "src/routes/shell-boundary.tsx"
93
93
  },
@@ -105,7 +105,7 @@
105
105
  },
106
106
  {
107
107
  "path": "ui/blocks/app-shell/menu.tsx",
108
- "content": "import type { MenuItem } from \"@jcoder-stack/abp-react/react\";\nimport { Building2, Home, IdCard, Settings, Shield, Users, Wrench } from \"lucide-react\";\nimport {\n IdentityPermissions,\n SettingManagementPermissions,\n TenantManagementPermissions,\n} from \"@/permissions\";\nimport type { FileRouteTypes } from \"@/routeTree.gen\";\n\n/** `to` 类型化自生成路由树,路由改名/删除在此 typecheck 失败而非运行时死链;extend 时照 requiredPolicy 格式补。 */\nexport const menuItems: MenuItem<FileRouteTypes[\"to\"]>[] = [\n { key: \"home\", label: \"App::Home\", to: \"/\", icon: <Home /> },\n {\n key: \"identity\",\n label: \"AbpIdentity::Menu:IdentityManagement\",\n icon: <IdCard />,\n children: [\n {\n key: \"identity-users\",\n label: \"AbpIdentity::Users\",\n to: \"/identity/users\",\n icon: <Users />,\n requiredPolicy: IdentityPermissions.Users.Default,\n },\n {\n key: \"identity-roles\",\n label: \"AbpIdentity::Roles\",\n to: \"/identity/roles\",\n icon: <Shield />,\n requiredPolicy: IdentityPermissions.Roles.Default,\n },\n ],\n },\n {\n key: \"tenants\",\n label: \"AbpTenantManagement::Menu:TenantManagement\",\n to: \"/tenants\",\n icon: <Building2 />,\n requiredPolicy: TenantManagementPermissions.Tenants.Default,\n },\n {\n key: \"system\",\n label: \"App::System\",\n icon: <Settings />,\n children: [\n {\n key: \"settings\",\n label: \"App::Settings\",\n to: \"/settings\",\n icon: <Wrench />,\n requiredPolicy: SettingManagementPermissions.Emailing,\n },\n ],\n },\n];\n",
108
+ "content": "import type { MenuItem } from \"@jcoder-stack/abp-react/react\";\nimport { Building2, Home, IdCard, Settings, Shield, Users, Wrench } from \"lucide-react\";\nimport {\n IdentityPermissions,\n SettingManagementPermissions,\n TenantManagementPermissions,\n} from \"@/permissions\";\nimport type { FileRouteTypes } from \"@/routeTree.gen\";\n\n/** `to` 类型化自生成路由树,路由改名/删除在此 typecheck 失败而非运行时死链;extend 时照 requiredPolicy 格式补。 */\nexport const menuItems: MenuItem<FileRouteTypes[\"to\"]>[] = [\n { key: \"home\", label: \"App::Home\", to: \"/home\", icon: <Home /> },\n {\n key: \"identity\",\n label: \"AbpIdentity::Menu:IdentityManagement\",\n icon: <IdCard />,\n children: [\n {\n key: \"identity-users\",\n label: \"AbpIdentity::Users\",\n to: \"/identity/users\",\n icon: <Users />,\n requiredPolicy: IdentityPermissions.Users.Default,\n },\n {\n key: \"identity-roles\",\n label: \"AbpIdentity::Roles\",\n to: \"/identity/roles\",\n icon: <Shield />,\n requiredPolicy: IdentityPermissions.Roles.Default,\n },\n ],\n },\n {\n key: \"tenants\",\n label: \"AbpTenantManagement::Menu:TenantManagement\",\n to: \"/tenants\",\n icon: <Building2 />,\n requiredPolicy: TenantManagementPermissions.Tenants.Default,\n },\n {\n key: \"system\",\n label: \"App::System\",\n icon: <Settings />,\n children: [\n {\n key: \"settings\",\n label: \"App::Settings\",\n to: \"/settings\",\n icon: <Wrench />,\n requiredPolicy: SettingManagementPermissions.Emailing,\n },\n ],\n },\n];\n",
109
109
  "type": "registry:file",
110
110
  "target": "src/menu.tsx"
111
111
  },
@@ -114,6 +114,24 @@
114
114
  "content": "/** ABP 内置模块权限名常量,与后端 *Permissions 定义类逐一对应;业务模块权限照此格式在本文件追加。 */\nexport const IdentityPermissions = {\n Users: {\n Default: \"AbpIdentity.Users\",\n Create: \"AbpIdentity.Users.Create\",\n Update: \"AbpIdentity.Users.Update\",\n Delete: \"AbpIdentity.Users.Delete\",\n ManagePermissions: \"AbpIdentity.Users.ManagePermissions\",\n },\n Roles: {\n Default: \"AbpIdentity.Roles\",\n Create: \"AbpIdentity.Roles.Create\",\n Update: \"AbpIdentity.Roles.Update\",\n Delete: \"AbpIdentity.Roles.Delete\",\n ManagePermissions: \"AbpIdentity.Roles.ManagePermissions\",\n },\n} as const;\n\nexport const TenantManagementPermissions = {\n Tenants: {\n Default: \"AbpTenantManagement.Tenants\",\n Create: \"AbpTenantManagement.Tenants.Create\",\n Update: \"AbpTenantManagement.Tenants.Update\",\n Delete: \"AbpTenantManagement.Tenants.Delete\",\n ManageFeatures: \"AbpTenantManagement.Tenants.ManageFeatures\",\n ManageConnectionStrings: \"AbpTenantManagement.Tenants.ManageConnectionStrings\",\n },\n} as const;\n\nexport const SettingManagementPermissions = {\n Emailing: \"SettingManagement.Emailing\",\n EmailingTest: \"SettingManagement.Emailing.Test\",\n TimeZone: \"SettingManagement.TimeZone\",\n} as const;\n",
115
115
  "type": "registry:file",
116
116
  "target": "src/permissions.ts"
117
+ },
118
+ {
119
+ "path": "ui/blocks/app-shell/components/section-boundary.tsx",
120
+ "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { Component, type ReactNode } from \"react\";\nimport { Button } from \"@/components/ui/button\";\n\ninterface CatchProps {\n children: ReactNode;\n renderFallback: (error: Error, reset: () => void) => ReactNode;\n}\n\ninterface CatchState {\n error: Error | null;\n}\n\n// 渲染错误只有 class 的 getDerivedStateFromError 能接,函数组件做不到;对外只暴露下面的函数组件。\nclass Catch extends Component<CatchProps, CatchState> {\n state: CatchState = { error: null };\n\n static getDerivedStateFromError(error: unknown): CatchState {\n return { error: error instanceof Error ? error : new Error(String(error)) };\n }\n\n render() {\n if (this.state.error === null) return this.props.children;\n return this.props.renderFallback(this.state.error, () => this.setState({ error: null }));\n }\n}\n\nexport interface SectionBoundaryProps {\n children: ReactNode;\n /** 替换内置 fallback;接到错误对象与 reset(调用后清除错误、重渲染 children)。 */\n fallback?: (error: Error, reset: () => void) => ReactNode;\n}\n\n/**\n * 区块级错误边界:包住页面里的一个区块,渲染抛错时只有该区块被 fallback 替换,兄弟区块与\n * 页面骨架照常;错误在这里被接住,不再冒泡到路由层的 errorComponent。默认 fallback 是\n * 词条化的紧凑错误条 + 重试按钮(重试即重渲染 children,适合瞬时性数据错误)。\n */\nexport function SectionBoundary({ children, fallback }: SectionBoundaryProps) {\n const L = useLocalization();\n return (\n <Catch\n renderFallback={(error, reset) =>\n fallback ? (\n fallback(error, reset)\n ) : (\n <div\n role=\"alert\"\n className=\"rounded-md border border-destructive/50 bg-destructive/5 p-4 text-sm\"\n >\n <p className=\"font-medium text-destructive\">{L(\"Shell:UnexpectedError\")}</p>\n <p className=\"mt-1 text-muted-foreground\">{error.message}</p>\n <Button type=\"button\" variant=\"outline\" size=\"sm\" className=\"mt-3\" onClick={reset}>\n {L(\"Shell:Retry\")}\n </Button>\n </div>\n )\n }\n >\n {children}\n </Catch>\n );\n}\n",
121
+ "type": "registry:file",
122
+ "target": "src/components/section-boundary.tsx"
123
+ },
124
+ {
125
+ "path": "ui/blocks/app-shell/routes/_layout/forbidden.tsx",
126
+ "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { createFileRoute, Link } from \"@tanstack/react-router\";\n\n/** 403 page. Permission guards redirect an authenticated but unauthorized user here. */\nexport const Route = createFileRoute(\"/_layout/forbidden\")({\n component: Forbidden,\n});\n\nfunction Forbidden() {\n const L = useLocalization();\n return (\n <section className=\"flex flex-1 flex-col items-center justify-center gap-2\">\n <h1 className=\"text-2xl font-normal\">{L(\"Shell:Forbidden\")}</h1>\n <p className=\"text-sm text-muted-foreground\">{L(\"Shell:ForbiddenHint\")}</p>\n <p className=\"space-x-4 text-sm\">\n <Link to=\"/home\" className=\"text-primary underline-offset-4 hover:underline\">\n {L(\"Shell:BackHome\")}\n </Link>\n {/* 权限不够的正解常常是换个账号:注销是鉴权态变化,必须整页跳转(a href),不走 SPA navigate */}\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",
127
+ "type": "registry:file",
128
+ "target": "src/routes/_layout/forbidden.tsx"
129
+ },
130
+ {
131
+ "path": "ui/blocks/app-shell/routes/_layout/_authed/home.tsx",
132
+ "content": "import { useCurrentUser, useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { createFileRoute } from \"@tanstack/react-router\";\n\n/** 控制台首页:登录即可见,零权限要求——「进入控制台」与侧栏 Home 的落点,\n * 保证没有任何管理权限的用户也有地方可站,而不是砸在某个权限守卫的 403 上。 */\nexport const Route = createFileRoute(\"/_layout/_authed/home\")({\n component: HomePage,\n});\n\nconst FEATURES = [\n [\"Home:FeatureAuthTitle\", \"Home:FeatureAuthDesc\"],\n [\"Home:FeatureTenantTitle\", \"Home:FeatureTenantDesc\"],\n [\"Home:FeatureI18nTitle\", \"Home:FeatureI18nDesc\"],\n] as const;\n\nfunction HomePage() {\n const L = useLocalization();\n const user = useCurrentUser();\n return (\n <section className=\"space-y-4\">\n <h1 className=\"text-2xl font-normal\">\n {user?.userName ? L(\"Home:WelcomeBack\", user.userName) : L(\"Home:Welcome\")}\n </h1>\n <p className=\"text-sm text-muted-foreground\">{L(\"Home:Tagline\")}</p>\n <div className=\"grid gap-4 sm:grid-cols-3\">\n {FEATURES.map(([title, desc]) => (\n <div key={title} className=\"rounded-lg border bg-card p-4\">\n <h2 className=\"text-sm font-medium\">{L(title)}</h2>\n <p className=\"mt-1 text-sm text-muted-foreground\">{L(desc)}</p>\n </div>\n ))}\n </div>\n <p className=\"text-xs text-muted-foreground\">{L(\"Home:EditHint\")}</p>\n </section>\n );\n}\n",
133
+ "type": "registry:file",
134
+ "target": "src/routes/_layout/_authed/home.tsx"
117
135
  }
118
136
  ],
119
137
  "type": "registry:block"
@@ -16,6 +16,7 @@
16
16
  "skeleton",
17
17
  "pagination",
18
18
  "checkbox",
19
+ "tooltip",
19
20
  "dropdown-menu",
20
21
  "label",
21
22
  "separator"
@@ -59,7 +60,7 @@
59
60
  },
60
61
  {
61
62
  "path": "ui/blocks/data-table/data-table-toolbar.tsx",
62
- "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport type { RowData } from \"@tanstack/react-table\";\nimport { Download, RefreshCw, Rows3 } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport type { DataTableInstance } from \"@/components/data-table/use-data-table\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Input } from \"@/components/ui/input\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { cn } from \"@/lib/utils\";\n\n/** 顶部条功能图标钮的共同外观:ghost 32px 方钮、静态色比正文更轻,hover 才提亮。 */\nconst UTILITY_ICON_CLASS = \"size-8 text-muted-foreground\";\n\nexport interface DataTableToolbarProps<TData extends RowData> {\n table: DataTableInstance<TData>;\n /** 是否渲染内建搜索框;缺省 true。传了 `left` 时搜索框让位。 */\n search?: boolean;\n searchPlaceholder?: string;\n /** 左区自定义内容(如内联查询字段);提供时替代内建搜索框。 */\n left?: ReactNode;\n /** 有选中行时替换整个左区的批量操作内容;缺席则选中时左区维持原样。 */\n bulk?: ReactNode;\n /** 刷新回调;缺席不渲染刷新按钮。`refreshing` 为 true 时图标旋转。 */\n onRefresh?: () => void;\n refreshing?: boolean;\n /** 导出插槽;缺席不渲染导出按钮,组件库不内置导出实现。 */\n onExport?: () => void;\n /** 页面级动作(如「新增」),primary 归它;与右侧功能图标组之间以竖线分隔。 */\n actions?: ReactNode;\n /** 功能区最前的散件(如查询面板开合钮),排在刷新之前,与「改表格外观」的那几个分开。 */\n utilityLeading?: ReactNode;\n /** trailing 区:列显隐菜单等散件,并入右侧功能图标组。 */\n children?: ReactNode;\n}\n\n/** 表格卡片顶部条:左区三态(自定义内容/搜索框 ⇄ 批量操作),右区恒定\n * (actions │ utilityLeading · 刷新 · 导出 · 密度 · trailing)。Enter 立即提交搜索。 */\nexport function DataTableToolbar<TData extends RowData>(props: DataTableToolbarProps<TData>) {\n const L = useLocalization();\n const { state } = props.table;\n const showSearch = props.search ?? true;\n const bulkActive = props.bulk !== undefined && props.table.selectedRows.length > 0;\n\n const left = bulkActive\n ? props.bulk\n : (props.left ??\n (showSearch ? (\n <Input\n className=\"h-8 w-56\"\n value={state.searchInput}\n placeholder={props.searchPlaceholder ?? L(\"Table:Search\")}\n onChange={(e) => state.setSearch(e.target.value)}\n onKeyDown={(e) => e.key === \"Enter\" && state.flushSearch()}\n />\n ) : null));\n\n return (\n <div className=\"flex flex-wrap items-center gap-2 border-b px-3 py-2\">\n {left}\n <div className=\"ml-auto flex items-center gap-2\">\n {props.actions}\n {props.actions ? <Separator orientation=\"vertical\" className=\"mx-1 !h-5\" /> : null}\n <div className=\"flex items-center gap-1\">\n {props.utilityLeading}\n {props.onRefresh && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className={UTILITY_ICON_CLASS}\n aria-label={L(\"Table:Refresh\")}\n onClick={props.onRefresh}\n >\n <RefreshCw className={cn(props.refreshing && \"animate-spin\")} />\n </Button>\n )}\n {props.onExport && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className={UTILITY_ICON_CLASS}\n aria-label={L(\"Table:Export\")}\n onClick={props.onExport}\n >\n <Download />\n </Button>\n )}\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className={UTILITY_ICON_CLASS}\n aria-label={L(\"Table:Density\")}\n >\n <Rows3 />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\">\n <DropdownMenuRadioGroup\n value={state.density}\n onValueChange={(value) => {\n // 收窄而非断言:菜单项之外的值(将来加档位忘了同步这里)宁可无声忽略,\n // 也不要把非法字符串塞进状态机。\n if (value === \"comfortable\" || value === \"compact\") state.setDensity(value);\n }}\n >\n <DropdownMenuRadioItem value=\"comfortable\">\n {L(\"Table:DensityComfortable\")}\n </DropdownMenuRadioItem>\n <DropdownMenuRadioItem value=\"compact\">\n {L(\"Table:DensityCompact\")}\n </DropdownMenuRadioItem>\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n {props.children}\n </div>\n </div>\n </div>\n );\n}\n",
63
+ "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport type { RowData } from \"@tanstack/react-table\";\nimport { Download, RefreshCw, Rows3 } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport type { DataTableInstance } from \"@/components/data-table/use-data-table\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Input } from \"@/components/ui/input\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\n\n/** 顶部条功能图标钮的共同外观:ghost 32px 方钮、静态色比正文更轻,hover 才提亮。 */\nconst UTILITY_ICON_CLASS = \"size-8 text-muted-foreground\";\n\nexport interface DataTableToolbarProps<TData extends RowData> {\n table: DataTableInstance<TData>;\n /** 是否渲染内建搜索框;缺省 true。传了 `left` 时搜索框让位。 */\n search?: boolean;\n searchPlaceholder?: string;\n /** 左区自定义内容(如内联查询字段);提供时替代内建搜索框。 */\n left?: ReactNode;\n /** 有选中行时替换整个左区的批量操作内容;缺席则选中时左区维持原样。 */\n bulk?: ReactNode;\n /** 刷新回调;缺席不渲染刷新按钮。`refreshing` 为 true 时图标旋转。 */\n onRefresh?: () => void;\n refreshing?: boolean;\n /** 导出插槽;缺席不渲染导出按钮,组件库不内置导出实现。 */\n onExport?: () => void;\n /** 页面级动作(如「新增」),primary 归它;与右侧功能图标组之间以竖线分隔。 */\n actions?: ReactNode;\n /** 功能区最前的散件(如查询面板开合钮),排在刷新之前,与「改表格外观」的那几个分开。 */\n utilityLeading?: ReactNode;\n /** trailing 区:列显隐菜单等散件,并入右侧功能图标组。 */\n children?: ReactNode;\n}\n\n/** 表格卡片顶部条:左区三态(自定义内容/搜索框 ⇄ 批量操作),右区恒定\n * (actions │ utilityLeading · 刷新 · 导出 · 密度 · trailing)。Enter 立即提交搜索。 */\nexport function DataTableToolbar<TData extends RowData>(props: DataTableToolbarProps<TData>) {\n const L = useLocalization();\n const { state } = props.table;\n const showSearch = props.search ?? true;\n const bulkActive = props.bulk !== undefined && props.table.selectedRows.length > 0;\n\n const left = bulkActive\n ? props.bulk\n : (props.left ??\n (showSearch ? (\n <Input\n className=\"h-8 w-56\"\n value={state.searchInput}\n placeholder={props.searchPlaceholder ?? L(\"Table:Search\")}\n onChange={(e) => state.setSearch(e.target.value)}\n onKeyDown={(e) => e.key === \"Enter\" && state.flushSearch()}\n />\n ) : null));\n\n return (\n <div className=\"flex flex-wrap items-center gap-2 border-b px-3 py-2\">\n {left}\n <div className=\"ml-auto flex items-center gap-2\">\n {props.actions}\n {props.actions ? <Separator orientation=\"vertical\" className=\"mx-1 !h-5\" /> : null}\n <div className=\"flex items-center gap-1\">\n {props.utilityLeading}\n {props.onRefresh && (\n <Tooltip>\n <TooltipTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className={UTILITY_ICON_CLASS}\n aria-label={L(\"Table:Refresh\")}\n onClick={props.onRefresh}\n >\n <RefreshCw className={cn(props.refreshing && \"animate-spin\")} />\n </Button>\n </TooltipTrigger>\n <TooltipContent>{L(\"Table:Refresh\")}</TooltipContent>\n </Tooltip>\n )}\n {props.onExport && (\n <Tooltip>\n <TooltipTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className={UTILITY_ICON_CLASS}\n aria-label={L(\"Table:Export\")}\n onClick={props.onExport}\n >\n <Download />\n </Button>\n </TooltipTrigger>\n <TooltipContent>{L(\"Table:Export\")}</TooltipContent>\n </Tooltip>\n )}\n <DropdownMenu>\n <Tooltip>\n <TooltipTrigger asChild>\n <DropdownMenuTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className={UTILITY_ICON_CLASS}\n aria-label={L(\"Table:Density\")}\n >\n <Rows3 />\n </Button>\n </DropdownMenuTrigger>\n </TooltipTrigger>\n <TooltipContent>{L(\"Table:Density\")}</TooltipContent>\n </Tooltip>\n <DropdownMenuContent align=\"end\">\n <DropdownMenuRadioGroup\n value={state.density}\n onValueChange={(value) => {\n // 收窄而非断言:菜单项之外的值(将来加档位忘了同步这里)宁可无声忽略,\n // 也不要把非法字符串塞进状态机。\n if (value === \"comfortable\" || value === \"compact\") state.setDensity(value);\n }}\n >\n <DropdownMenuRadioItem value=\"comfortable\">\n {L(\"Table:DensityComfortable\")}\n </DropdownMenuRadioItem>\n <DropdownMenuRadioItem value=\"compact\">\n {L(\"Table:DensityCompact\")}\n </DropdownMenuRadioItem>\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n {props.children}\n </div>\n </div>\n </div>\n );\n}\n",
63
64
  "type": "registry:component",
64
65
  "target": "components/data-table/data-table-toolbar.tsx"
65
66
  },
@@ -71,13 +72,13 @@
71
72
  },
72
73
  {
73
74
  "path": "ui/blocks/data-table/data-table-columns-menu.tsx",
74
- "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport type { Column, RowData } from \"@tanstack/react-table\";\nimport { TableProperties } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport { resolveColumnLabel } from \"@/components/data-table/column-label\";\nimport type { TableFeatures, TableInstance } from \"@/components/data-table/table-core\";\nimport type { DataTableInstance } from \"@/components/data-table/use-data-table\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuCheckboxItem,\n DropdownMenuContent,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface DataTableColumnsMenuProps<TData extends RowData> {\n table: DataTableInstance<TData>;\n /** 菜单末尾追加自定义项;`items` 输入是完整表实例,非单列。 */\n items?: (table: TableInstance<TData>) => ReactNode;\n /** 覆盖单列标签;缺省走 resolveColumnLabel 的三级阶梯。 */\n label?: (column: Column<TableFeatures, TData, unknown>) => ReactNode;\n className?: string;\n}\n\n/** 列显隐菜单;出现哪些列由列定义的 `enableHiding` 原生控制,本组件不做二次过滤。 */\nexport function DataTableColumnsMenu<TData extends RowData>(\n props: DataTableColumnsMenuProps<TData>,\n) {\n const L = useLocalization();\n const table = props.table.table;\n const columns = table.getAllLeafColumns().filter((c) => c.getCanHide());\n\n return (\n <DropdownMenu modal={false}>\n <DropdownMenuTrigger asChild>\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className={cn(\"size-8 text-muted-foreground\", props.className)}\n aria-label={L(\"Table:Columns\")}\n title={L(\"Table:Columns\")}\n >\n <TableProperties />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\">\n {columns.map((column) => (\n <DropdownMenuCheckboxItem\n key={column.id}\n checked={column.getIsVisible()}\n onCheckedChange={(value) => column.toggleVisibility(value === true)}\n >\n {props.label?.(column) ?? resolveColumnLabel(table, column.id)}\n </DropdownMenuCheckboxItem>\n ))}\n {props.items?.(table)}\n </DropdownMenuContent>\n </DropdownMenu>\n );\n}\n",
75
+ "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport type { Column, RowData } from \"@tanstack/react-table\";\nimport { TableProperties } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport { resolveColumnLabel } from \"@/components/data-table/column-label\";\nimport type { TableFeatures, TableInstance } from \"@/components/data-table/table-core\";\nimport type { DataTableInstance } from \"@/components/data-table/use-data-table\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuCheckboxItem,\n DropdownMenuContent,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface DataTableColumnsMenuProps<TData extends RowData> {\n table: DataTableInstance<TData>;\n /** 菜单末尾追加自定义项;`items` 输入是完整表实例,非单列。 */\n items?: (table: TableInstance<TData>) => ReactNode;\n /** 覆盖单列标签;缺省走 resolveColumnLabel 的三级阶梯。 */\n label?: (column: Column<TableFeatures, TData, unknown>) => ReactNode;\n className?: string;\n}\n\n/** 列显隐菜单;出现哪些列由列定义的 `enableHiding` 原生控制,本组件不做二次过滤。 */\nexport function DataTableColumnsMenu<TData extends RowData>(\n props: DataTableColumnsMenuProps<TData>,\n) {\n const L = useLocalization();\n const table = props.table.table;\n const columns = table.getAllLeafColumns().filter((c) => c.getCanHide());\n\n return (\n <DropdownMenu modal={false}>\n <Tooltip>\n <TooltipTrigger asChild>\n <DropdownMenuTrigger asChild>\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className={cn(\"size-8 text-muted-foreground\", props.className)}\n aria-label={L(\"Table:Columns\")}\n >\n <TableProperties />\n </Button>\n </DropdownMenuTrigger>\n </TooltipTrigger>\n <TooltipContent>{L(\"Table:Columns\")}</TooltipContent>\n </Tooltip>\n <DropdownMenuContent align=\"end\">\n {columns.map((column) => (\n <DropdownMenuCheckboxItem\n key={column.id}\n checked={column.getIsVisible()}\n onCheckedChange={(value) => column.toggleVisibility(value === true)}\n >\n {props.label?.(column) ?? resolveColumnLabel(table, column.id)}\n </DropdownMenuCheckboxItem>\n ))}\n {props.items?.(table)}\n </DropdownMenuContent>\n </DropdownMenu>\n );\n}\n",
75
76
  "type": "registry:component",
76
77
  "target": "components/data-table/data-table-columns-menu.tsx"
77
78
  },
78
79
  {
79
80
  "path": "ui/blocks/data-table/data-table-sort-menu.tsx",
80
- "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport type { Column, RowData } from \"@tanstack/react-table\";\nimport { ArrowUpDown } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport { resolveColumnLabel } from \"@/components/data-table/column-label\";\nimport type { TableFeatures, TableInstance } from \"@/components/data-table/table-core\";\nimport type { DataTableInstance } from \"@/components/data-table/use-data-table\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface DataTableSortMenuProps<TData extends RowData> {\n table: DataTableInstance<TData>;\n /** 菜单末尾追加自定义项;`items` 输入是完整表实例,非单列。 */\n items?: (table: TableInstance<TData>) => ReactNode;\n /** 覆盖单列标签;缺省走 resolveColumnLabel 的三级阶梯。 */\n label?: (column: Column<TableFeatures, TData, unknown>) => ReactNode;\n className?: string;\n}\n\n/** 排序菜单:每次点选**叠加**到现有排序,不替换。已在排序中的列点了是翻方向,不在的追加到末尾。\n * 退出多列态走「清除排序」项。与表头点击的心智模型不同:表头默认替换、按住 shift 才叠加,\n * 菜单里没有 shift 手势可用,所以默认就是叠加。出现哪些列仍由列定义的 `enableSorting` 原生控制。 */\nexport function DataTableSortMenu<TData extends RowData>(props: DataTableSortMenuProps<TData>) {\n const L = useLocalization();\n const table = props.table.table;\n const columns = table.getAllLeafColumns().filter((c) => c.getCanSort());\n\n return (\n <DropdownMenu modal={false}>\n <DropdownMenuTrigger asChild>\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className={cn(\"size-8 text-muted-foreground\", props.className)}\n aria-label={L(\"Table:Sort\")}\n title={L(\"Table:Sort\")}\n >\n <ArrowUpDown />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\">\n {columns.map((column) => (\n <DropdownMenuItem\n key={column.id}\n // 第二参 multi=true:菜单里点第二列不再把第一列压掉。没有它,菜单会\n // 破坏用户刚用 shift+click 建立的多列排序状态。\n onClick={() => column.toggleSorting(column.getIsSorted() === \"asc\", true)}\n >\n {props.label?.(column) ?? resolveColumnLabel(table, column.id)}\n </DropdownMenuItem>\n ))}\n <DropdownMenuSeparator />\n <DropdownMenuItem onClick={() => table.setSorting([])}>\n {L(\"Table:SortClear\")}\n </DropdownMenuItem>\n {props.items?.(table)}\n </DropdownMenuContent>\n </DropdownMenu>\n );\n}\n\nexport interface DataTableClearSortButtonProps<TData extends RowData> {\n table: DataTableInstance<TData>;\n className?: string;\n}\n\n/** 多列排序的退出口:仅在两列及以上排序时出现,点击一次清空全部排序。\n * 可见性判据留在组件内部,调用方无条件渲染即可。「何时需要退出口」是表现层知识,\n * 不该外泄给组装层。单列排序不给出口:表头三态循环(asc→desc→无)两次点击即可退出,\n * 为它多摆一颗常驻按钮是噪声。 */\nexport function DataTableClearSortButton<TData extends RowData>(\n props: DataTableClearSortButtonProps<TData>,\n) {\n const L = useLocalization();\n const table = props.table.table;\n if (table.state.sorting.length <= 1) return null;\n\n return (\n <Button\n variant=\"ghost\"\n size=\"sm\"\n className={cn(\"text-muted-foreground\", props.className)}\n onClick={() => table.setSorting([])}\n >\n {L(\"Table:SortClear\")}\n </Button>\n );\n}\n",
81
+ "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport type { Column, RowData } from \"@tanstack/react-table\";\nimport { ArrowUpDown } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport { resolveColumnLabel } from \"@/components/data-table/column-label\";\nimport type { TableFeatures, TableInstance } from \"@/components/data-table/table-core\";\nimport type { DataTableInstance } from \"@/components/data-table/use-data-table\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface DataTableSortMenuProps<TData extends RowData> {\n table: DataTableInstance<TData>;\n /** 菜单末尾追加自定义项;`items` 输入是完整表实例,非单列。 */\n items?: (table: TableInstance<TData>) => ReactNode;\n /** 覆盖单列标签;缺省走 resolveColumnLabel 的三级阶梯。 */\n label?: (column: Column<TableFeatures, TData, unknown>) => ReactNode;\n className?: string;\n}\n\n/** 排序菜单:每次点选**叠加**到现有排序,不替换。已在排序中的列点了是翻方向,不在的追加到末尾。\n * 退出多列态走「清除排序」项。与表头点击的心智模型不同:表头默认替换、按住 shift 才叠加,\n * 菜单里没有 shift 手势可用,所以默认就是叠加。出现哪些列仍由列定义的 `enableSorting` 原生控制。 */\nexport function DataTableSortMenu<TData extends RowData>(props: DataTableSortMenuProps<TData>) {\n const L = useLocalization();\n const table = props.table.table;\n const columns = table.getAllLeafColumns().filter((c) => c.getCanSort());\n\n return (\n <DropdownMenu modal={false}>\n <Tooltip>\n <TooltipTrigger asChild>\n <DropdownMenuTrigger asChild>\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className={cn(\"size-8 text-muted-foreground\", props.className)}\n aria-label={L(\"Table:Sort\")}\n >\n <ArrowUpDown />\n </Button>\n </DropdownMenuTrigger>\n </TooltipTrigger>\n <TooltipContent>{L(\"Table:Sort\")}</TooltipContent>\n </Tooltip>\n <DropdownMenuContent align=\"end\">\n {columns.map((column) => (\n <DropdownMenuItem\n key={column.id}\n // 第二参 multi=true:菜单里点第二列不再把第一列压掉。没有它,菜单会\n // 破坏用户刚用 shift+click 建立的多列排序状态。\n onClick={() => column.toggleSorting(column.getIsSorted() === \"asc\", true)}\n >\n {props.label?.(column) ?? resolveColumnLabel(table, column.id)}\n </DropdownMenuItem>\n ))}\n <DropdownMenuSeparator />\n <DropdownMenuItem onClick={() => table.setSorting([])}>\n {L(\"Table:SortClear\")}\n </DropdownMenuItem>\n {props.items?.(table)}\n </DropdownMenuContent>\n </DropdownMenu>\n );\n}\n\nexport interface DataTableClearSortButtonProps<TData extends RowData> {\n table: DataTableInstance<TData>;\n className?: string;\n}\n\n/** 多列排序的退出口:仅在两列及以上排序时出现,点击一次清空全部排序。\n * 可见性判据留在组件内部,调用方无条件渲染即可。「何时需要退出口」是表现层知识,\n * 不该外泄给组装层。单列排序不给出口:表头三态循环(asc→desc→无)两次点击即可退出,\n * 为它多摆一颗常驻按钮是噪声。 */\nexport function DataTableClearSortButton<TData extends RowData>(\n props: DataTableClearSortButtonProps<TData>,\n) {\n const L = useLocalization();\n const table = props.table.table;\n if (table.state.sorting.length <= 1) return null;\n\n return (\n <Button\n variant=\"ghost\"\n size=\"sm\"\n className={cn(\"text-muted-foreground\", props.className)}\n onClick={() => table.setSorting([])}\n >\n {L(\"Table:SortClear\")}\n </Button>\n );\n}\n",
81
82
  "type": "registry:component",
82
83
  "target": "components/data-table/data-table-sort-menu.tsx"
83
84
  },
@@ -212,6 +212,21 @@
212
212
  "path": "ui/blocks/app-shell/permissions.ts",
213
213
  "type": "registry:file",
214
214
  "target": "src/permissions.ts"
215
+ },
216
+ {
217
+ "path": "ui/blocks/app-shell/components/section-boundary.tsx",
218
+ "type": "registry:file",
219
+ "target": "src/components/section-boundary.tsx"
220
+ },
221
+ {
222
+ "path": "ui/blocks/app-shell/routes/_layout/forbidden.tsx",
223
+ "type": "registry:file",
224
+ "target": "src/routes/_layout/forbidden.tsx"
225
+ },
226
+ {
227
+ "path": "ui/blocks/app-shell/routes/_layout/_authed/home.tsx",
228
+ "type": "registry:file",
229
+ "target": "src/routes/_layout/_authed/home.tsx"
215
230
  }
216
231
  ]
217
232
  },
@@ -228,6 +243,7 @@
228
243
  "skeleton",
229
244
  "pagination",
230
245
  "checkbox",
246
+ "tooltip",
231
247
  "dropdown-menu",
232
248
  "label",
233
249
  "separator"