@jcoder-stack/registry 0.1.0

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.
@@ -0,0 +1,93 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "abp-layout",
4
+ "title": "ABP Admin Layout",
5
+ "description": "sidebar-08-style ABP admin layout wired to @jcoder-stack/abp-react/react (menu, breadcrumb, user, locale, tenant, theme).",
6
+ "dependencies": [
7
+ "lucide-react",
8
+ "@jcoder-stack/abp-react"
9
+ ],
10
+ "registryDependencies": [
11
+ "sidebar",
12
+ "breadcrumb",
13
+ "separator",
14
+ "collapsible",
15
+ "command",
16
+ "tooltip",
17
+ "dropdown-menu",
18
+ "avatar",
19
+ "dialog",
20
+ "input",
21
+ "button",
22
+ "label"
23
+ ],
24
+ "files": [
25
+ {
26
+ "path": "ui/blocks/abp-layout/app-sidebar.tsx",
27
+ "content": "import type { MenuItem } from \"@jcoder-stack/abp-react/react\";\nimport { type ComponentProps, memo, type ReactNode } from \"react\";\nimport { BrandMark } from \"@/components/abp/layout/brand-mark\";\nimport { NavMain } from \"@/components/abp/layout/nav-main\";\nimport { NavSearch } from \"@/components/abp/layout/nav-search\";\nimport { NavUser } from \"@/components/abp/layout/nav-user\";\nimport {\n Sidebar,\n SidebarContent,\n SidebarFooter,\n SidebarHeader,\n SidebarMenu,\n SidebarMenuButton,\n SidebarMenuItem,\n} from \"@/components/ui/sidebar\";\n\n/** memo:布局壳每次导航都重渲染,侧栏 props 引用稳定时整棵子树跳过重执行。激活态高亮由 NavMain 内部订阅 pathname 自行更新,不依赖父级渲染。 */\nexport const AppSidebar = memo(function AppSidebar({\n items,\n title,\n logo,\n userMenuItems,\n ...props\n}: {\n items: MenuItem[];\n title: string;\n logo?: ReactNode;\n userMenuItems?: ReactNode;\n} & ComponentProps<typeof Sidebar>) {\n return (\n <Sidebar variant=\"inset\" collapsible=\"icon\" {...props}>\n <SidebarHeader>\n <SidebarMenu>\n <SidebarMenuItem>\n <SidebarMenuButton size=\"lg\" asChild>\n <a href=\"/\">\n {/* size-8! 提权:SidebarMenuButton 带 [&>svg]:size-4,会把标识压成 16px */}\n {logo ?? <BrandMark className=\"size-8! shrink-0\" />}\n <span className=\"truncate font-medium\">{title}</span>\n </a>\n </SidebarMenuButton>\n </SidebarMenuItem>\n </SidebarMenu>\n <NavSearch items={items} />\n </SidebarHeader>\n <SidebarContent>\n <NavMain items={items} />\n </SidebarContent>\n <SidebarFooter>\n <NavUser userMenuItems={userMenuItems} />\n </SidebarFooter>\n </Sidebar>\n );\n});\n",
28
+ "type": "registry:component",
29
+ "target": "components/abp/layout/app-sidebar.tsx"
30
+ },
31
+ {
32
+ "path": "ui/blocks/abp-layout/brand-mark.tsx",
33
+ "content": "import { cn } from \"@/lib/utils\";\n\n/**\n * 产品标识。换成自己的品牌时改这一个文件即可,侧栏、落地页顶导航、登录页都取它。\n *\n * 颜色走 `--brand-*` token 而非写死:暗色下轮廓翻白、播放三角换成青色(深蓝底上的\n * 蓝三角对比度不够),这两处切换由 token 自己完成,组件不需要知道当前是什么主题。\n * 尺寸由外部的 className 给(`size-*`),viewBox 保证任意尺寸都不失真。\n */\nexport function BrandMark({ className }: { className?: string }) {\n return (\n <svg\n viewBox=\"0 0 64 64\"\n fill=\"none\"\n className={cn(\"size-8\", className)}\n role=\"img\"\n aria-label=\"ABP React Start\"\n >\n <path\n d=\"M32 4.5 L54.5 17.5 V43.5 L32 56.5 L9.5 43.5 V17.5 Z\"\n className=\"stroke-brand-ink\"\n strokeWidth={5}\n strokeLinejoin=\"round\"\n />\n <path\n d=\"M27 23 L43 32 L27 41 Z\"\n className=\"fill-brand-mark stroke-brand-mark\"\n strokeWidth={3.5}\n strokeLinejoin=\"round\"\n />\n <circle\n cx={54.5}\n cy={17.5}\n r={5.5}\n className=\"fill-brand-spark stroke-brand-ink-contrast\"\n strokeWidth={2.5}\n />\n </svg>\n );\n}\n",
34
+ "type": "registry:component",
35
+ "target": "components/abp/layout/brand-mark.tsx"
36
+ },
37
+ {
38
+ "path": "ui/blocks/abp-layout/nav-main.tsx",
39
+ "content": "import { type MenuItem, useLocalization, useMenu } from \"@jcoder-stack/abp-react/react\";\nimport { Link, useRouterState } from \"@tanstack/react-router\";\nimport { ChevronRight } from \"lucide-react\";\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from \"@/components/ui/collapsible\";\nimport {\n SidebarGroup,\n SidebarMenu,\n SidebarMenuButton,\n SidebarMenuItem,\n SidebarMenuSub,\n SidebarMenuSubButton,\n SidebarMenuSubItem,\n} from \"@/components/ui/sidebar\";\n\n/** 权限剪枝后的主菜单。 */\nexport function NavMain({ items }: { items: MenuItem[] }) {\n const menu = useMenu(items);\n const L = useLocalization();\n const pathname = useRouterState({ select: (s) => s.location.pathname });\n return (\n <SidebarGroup>\n <SidebarMenu>\n {menu.map((item) =>\n item.children !== undefined && item.children.length > 0 ? (\n <Collapsible\n key={item.key}\n asChild\n defaultOpen={item.children.some((child) => child.to === pathname)}\n className=\"group/collapsible\"\n >\n <SidebarMenuItem>\n <CollapsibleTrigger asChild>\n <SidebarMenuButton tooltip={L(item.label)}>\n {item.icon}\n <span>{L(item.label)}</span>\n <ChevronRight className=\"ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90\" />\n </SidebarMenuButton>\n </CollapsibleTrigger>\n <CollapsibleContent>\n <SidebarMenuSub>\n {item.children.map((child) => (\n <SidebarMenuSubItem key={child.key}>\n <SidebarMenuSubButton asChild isActive={child.to === pathname}>\n <Link to={child.to ?? \"/\"}>\n {child.icon}\n <span>{L(child.label)}</span>\n </Link>\n </SidebarMenuSubButton>\n </SidebarMenuSubItem>\n ))}\n </SidebarMenuSub>\n </CollapsibleContent>\n </SidebarMenuItem>\n </Collapsible>\n ) : (\n <SidebarMenuItem key={item.key}>\n <SidebarMenuButton asChild isActive={item.to === pathname} tooltip={L(item.label)}>\n <Link to={item.to ?? \"/\"}>\n {item.icon}\n <span>{L(item.label)}</span>\n </Link>\n </SidebarMenuButton>\n </SidebarMenuItem>\n ),\n )}\n </SidebarMenu>\n </SidebarGroup>\n );\n}\n",
40
+ "type": "registry:component",
41
+ "target": "components/abp/layout/nav-main.tsx"
42
+ },
43
+ {
44
+ "path": "ui/blocks/abp-layout/nav-search.tsx",
45
+ "content": "import { type MenuItem, useLocalization, useMenu } from \"@jcoder-stack/abp-react/react\";\nimport { useNavigate } from \"@tanstack/react-router\";\nimport { Search } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport {\n CommandDialog,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from \"@/components/ui/command\";\nimport { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from \"@/components/ui/sidebar\";\n\ninterface SearchEntry {\n key: string;\n label: string;\n to: string;\n icon?: MenuItem[\"icon\"];\n group?: string;\n}\n\n// 上游 items 已按权限剪枝\nfunction flattenMenu(menu: MenuItem[], L: (key: string) => string): SearchEntry[] {\n const entries: SearchEntry[] = [];\n for (const item of menu) {\n if (item.children !== undefined && item.children.length > 0) {\n const group = L(item.label);\n for (const child of item.children) {\n if (child.to !== undefined) {\n entries.push({\n key: child.key,\n label: L(child.label),\n to: child.to,\n icon: child.icon,\n group,\n });\n }\n }\n } else if (item.to !== undefined) {\n entries.push({ key: item.key, label: L(item.label), to: item.to, icon: item.icon });\n }\n }\n return entries;\n}\n\n/** 侧栏菜单搜索:⌘K/Ctrl+K 或点击触发 Command 弹层,模糊过滤权限内菜单,选中即导航。 */\nexport function NavSearch({ items }: { items: MenuItem[] }) {\n const L = useLocalization();\n const menu = useMenu(items);\n const navigate = useNavigate();\n const [open, setOpen] = useState(false);\n // SSR 拿不到平台信息,挂载后再按真实平台切 ⌘/Ctrl 角标,避免水合不一致。\n const [isMac, setIsMac] = useState(false);\n\n useEffect(() => {\n setIsMac(/Mac|iPhone|iPad/i.test(`${navigator.platform} ${navigator.userAgent}`));\n }, []);\n\n useEffect(() => {\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key === \"k\" && (event.metaKey || event.ctrlKey)) {\n event.preventDefault();\n setOpen((prev) => !prev);\n }\n };\n document.addEventListener(\"keydown\", onKeyDown);\n return () => document.removeEventListener(\"keydown\", onKeyDown);\n }, []);\n\n const entries = flattenMenu(menu, L);\n const groups = new Map<string, SearchEntry[]>();\n for (const entry of entries) {\n const bucket = entry.group ?? \"\";\n groups.set(bucket, [...(groups.get(bucket) ?? []), entry]);\n }\n\n const select = (entry: SearchEntry) => {\n setOpen(false);\n void navigate({ to: entry.to });\n };\n\n return (\n <>\n <SidebarMenu>\n <SidebarMenuItem>\n <SidebarMenuButton tooltip={L(\"Layout:Search\")} onClick={() => setOpen(true)}>\n <Search />\n <span>{L(\"Layout:Search\")}</span>\n <kbd className=\"ml-auto text-xs text-muted-foreground group-data-[collapsible=icon]:hidden\">\n {isMac ? \"⌘K\" : \"Ctrl+K\"}\n </kbd>\n </SidebarMenuButton>\n </SidebarMenuItem>\n </SidebarMenu>\n <CommandDialog\n open={open}\n onOpenChange={setOpen}\n title={L(\"Layout:Search\")}\n description={L(\"Layout:SearchPlaceholder\")}\n >\n <CommandInput placeholder={L(\"Layout:SearchPlaceholder\")} />\n <CommandList>\n <CommandEmpty>{L(\"Layout:SearchEmpty\")}</CommandEmpty>\n {[...groups.entries()].map(([group, groupEntries]) => (\n <CommandGroup\n key={group === \"\" ? \"__top\" : group}\n heading={group === \"\" ? undefined : group}\n >\n {groupEntries.map((entry) => (\n <CommandItem\n key={entry.key}\n value={`${entry.group ?? \"\"} ${entry.label}`.trim()}\n onSelect={() => select(entry)}\n >\n {entry.icon}\n {entry.label}\n </CommandItem>\n ))}\n </CommandGroup>\n ))}\n </CommandList>\n </CommandDialog>\n </>\n );\n}\n",
46
+ "type": "registry:component",
47
+ "target": "components/abp/layout/nav-search.tsx"
48
+ },
49
+ {
50
+ "path": "ui/blocks/abp-layout/nav-user.tsx",
51
+ "content": "import { useLocalization, useSession } from \"@jcoder-stack/abp-react/react\";\nimport { Link } from \"@tanstack/react-router\";\nimport { ChevronsUpDown, LogIn, LogOut } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport { Avatar, AvatarFallback } from \"@/components/ui/avatar\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n SidebarMenu,\n SidebarMenuButton,\n SidebarMenuItem,\n useSidebar,\n} from \"@/components/ui/sidebar\";\n\nexport function NavUser({ userMenuItems }: { userMenuItems?: ReactNode } = {}) {\n const { identity, status } = useSession();\n const L = useLocalization();\n const { isMobile } = useSidebar();\n\n if (status !== \"authenticated\" || identity.user === null) {\n return (\n <SidebarMenu>\n <SidebarMenuItem>\n <SidebarMenuButton asChild size=\"lg\">\n <Link to=\"/login\" search={{ error: undefined }}>\n <LogIn />\n <span>{L(\"Layout:Login\")}</span>\n </Link>\n </SidebarMenuButton>\n </SidebarMenuItem>\n </SidebarMenu>\n );\n }\n\n const user = identity.user;\n const initials = user.userName.slice(0, 2).toUpperCase();\n return (\n <SidebarMenu>\n <SidebarMenuItem>\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <SidebarMenuButton\n size=\"lg\"\n className=\"data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground\"\n >\n <Avatar className=\"h-8 w-8 rounded-lg\">\n <AvatarFallback className=\"rounded-lg\">{initials}</AvatarFallback>\n </Avatar>\n <div className=\"grid flex-1 text-left text-sm leading-tight\">\n <span className=\"truncate font-medium\">{user.userName}</span>\n {user.email !== undefined && (\n <span className=\"truncate text-xs text-muted-foreground\">{user.email}</span>\n )}\n </div>\n <ChevronsUpDown className=\"ml-auto size-4\" />\n </SidebarMenuButton>\n </DropdownMenuTrigger>\n <DropdownMenuContent\n className=\"w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg\"\n side={isMobile ? \"bottom\" : \"right\"}\n align=\"end\"\n sideOffset={4}\n >\n <DropdownMenuLabel className=\"truncate font-normal\">{user.userName}</DropdownMenuLabel>\n <DropdownMenuSeparator />\n {userMenuItems}\n <DropdownMenuItem asChild>\n <a href=\"/api/auth/logout\">\n <LogOut />\n {L(\"Layout:Logout\")}\n </a>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </SidebarMenuItem>\n </SidebarMenu>\n );\n}\n",
52
+ "type": "registry:component",
53
+ "target": "components/abp/layout/nav-user.tsx"
54
+ },
55
+ {
56
+ "path": "ui/blocks/abp-layout/site-header.tsx",
57
+ "content": "import type { MenuItem } from \"@jcoder-stack/abp-react/react\";\nimport { AbpBreadcrumb } from \"@/components/abp/layout/abp-breadcrumb\";\nimport { LocaleSwitcher } from \"@/components/abp/layout/locale-switcher\";\nimport { TenantSwitcher } from \"@/components/abp/layout/tenant-switcher\";\nimport { ThemeToggle } from \"@/components/abp/layout/theme-toggle\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { SidebarTrigger } from \"@/components/ui/sidebar\";\n\nexport function SiteHeader({ items }: { items: MenuItem[] }) {\n return (\n <header className=\"sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background px-4 md:rounded-t-xl\">\n <SidebarTrigger className=\"-ml-1\" />\n <Separator orientation=\"vertical\" className=\"mr-2 data-[orientation=vertical]:h-4\" />\n <AbpBreadcrumb items={items} />\n <div className=\"ml-auto flex items-center gap-1\">\n <LocaleSwitcher />\n <TenantSwitcher />\n <ThemeToggle />\n </div>\n </header>\n );\n}\n",
58
+ "type": "registry:component",
59
+ "target": "components/abp/layout/site-header.tsx"
60
+ },
61
+ {
62
+ "path": "ui/blocks/abp-layout/abp-breadcrumb.tsx",
63
+ "content": "import { type MenuItem, useBreadcrumbs, useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { Link, useRouterState } from \"@tanstack/react-router\";\nimport { Fragment } from \"react\";\nimport {\n Breadcrumb,\n BreadcrumbItem,\n BreadcrumbLink,\n BreadcrumbList,\n BreadcrumbPage,\n BreadcrumbSeparator,\n} from \"@/components/ui/breadcrumb\";\n\n/** 面包屑:菜单树 × 当前路径 → 祖先链;末项为当前页。 */\nexport function AbpBreadcrumb({ items }: { items: MenuItem[] }) {\n const pathname = useRouterState({ select: (s) => s.location.pathname });\n const chain = useBreadcrumbs(items, pathname);\n const L = useLocalization();\n if (chain.length === 0) return null;\n return (\n <Breadcrumb>\n <BreadcrumbList>\n {chain.map((item, index) => (\n <Fragment key={item.key}>\n {index > 0 && <BreadcrumbSeparator className=\"hidden md:block\" />}\n <BreadcrumbItem className={index < chain.length - 1 ? \"hidden md:block\" : undefined}>\n {index === chain.length - 1 ? (\n <BreadcrumbPage>{L(item.label)}</BreadcrumbPage>\n ) : item.to !== undefined ? (\n <BreadcrumbLink asChild>\n <Link to={item.to}>{L(item.label)}</Link>\n </BreadcrumbLink>\n ) : (\n <span>{L(item.label)}</span>\n )}\n </BreadcrumbItem>\n </Fragment>\n ))}\n </BreadcrumbList>\n </Breadcrumb>\n );\n}\n",
64
+ "type": "registry:component",
65
+ "target": "components/abp/layout/abp-breadcrumb.tsx"
66
+ },
67
+ {
68
+ "path": "ui/blocks/abp-layout/locale-switcher.tsx",
69
+ "content": "import { useAppConfig, useCulture, useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { useRouterState } from \"@tanstack/react-router\";\nimport { Check, Languages } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@/components/ui/tooltip\";\n\n/** 语言切换:列表来自 app-config 的真实语言集;经 /api/culture 302 生效(cookie 优先机制)。 */\nexport function LocaleSwitcher() {\n const { localization } = useAppConfig();\n const current = useCulture();\n const L = useLocalization();\n const pathname = useRouterState({ select: (s) => s.location.pathname });\n if (localization.languages.length === 0) return null;\n return (\n <DropdownMenu>\n <Tooltip>\n <TooltipTrigger asChild>\n <DropdownMenuTrigger asChild>\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-8 text-muted-foreground hover:text-foreground\"\n aria-label={L(\"Layout:Language\")}\n >\n <Languages />\n </Button>\n </DropdownMenuTrigger>\n </TooltipTrigger>\n <TooltipContent>{L(\"Layout:Language\")}</TooltipContent>\n </Tooltip>\n <DropdownMenuContent align=\"end\">\n {localization.languages.map((lang) => (\n <DropdownMenuItem key={lang.cultureName} asChild>\n <a\n href={`/api/culture?culture=${encodeURIComponent(lang.cultureName)}&returnUrl=${encodeURIComponent(pathname)}`}\n >\n <span className=\"flex-1\">{lang.displayName}</span>\n {lang.cultureName === current && <Check className=\"size-4\" />}\n </a>\n </DropdownMenuItem>\n ))}\n </DropdownMenuContent>\n </DropdownMenu>\n );\n}\n",
70
+ "type": "registry:component",
71
+ "target": "components/abp/layout/locale-switcher.tsx"
72
+ },
73
+ {
74
+ "path": "ui/blocks/abp-layout/tenant-switcher.tsx",
75
+ "content": "import { useAppConfig, useLocalization, useSession } from \"@jcoder-stack/abp-react/react\";\nimport { useRouterState } from \"@tanstack/react-router\";\nimport { Building2 } from \"lucide-react\";\nimport { useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\n\n/** ABP 惯例租户切换框:输入租户名经 /api/tenant 302 落 cookie;留空切回宿主;多租户不可用时不渲染。 */\nexport function TenantSwitcher() {\n const { currentTenant } = useAppConfig();\n const { identity } = useSession();\n const L = useLocalization();\n const pathname = useRouterState({ select: (s) => s.location.pathname });\n const [name, setName] = useState(identity.tenant?.name ?? \"\");\n if (!currentTenant.isAvailable) return null;\n\n const currentLabel = identity.tenant?.name ?? L(\"Layout:Host\");\n const confirm = () => {\n const tenant = name.trim();\n const query =\n tenant === \"\"\n ? `returnUrl=${encodeURIComponent(pathname)}`\n : `tenant=${encodeURIComponent(tenant)}&returnUrl=${encodeURIComponent(pathname)}`;\n window.location.assign(`/api/tenant?${query}`);\n };\n\n // 触发器无 aria-label:可见文本(当前租户/宿主)就是可及名,覆盖它会破坏测试与读屏\n return (\n <Dialog>\n <DialogTrigger asChild>\n <Button variant=\"ghost\" size=\"sm\" className=\"text-muted-foreground hover:text-foreground\">\n <Building2 />\n <span className=\"hidden md:inline\">{currentLabel}</span>\n </Button>\n </DialogTrigger>\n <DialogContent className=\"sm:max-w-sm\">\n <DialogHeader>\n <DialogTitle>{L(\"Layout:SwitchTenant\")}</DialogTitle>\n <DialogDescription>{L(\"Layout:TenantSwitchHint\")}</DialogDescription>\n </DialogHeader>\n <div className=\"grid gap-2\">\n <Label htmlFor=\"tenant-name\">{L(\"Layout:TenantName\")}</Label>\n <Input\n id=\"tenant-name\"\n value={name}\n onChange={(event) => setName(event.target.value)}\n onKeyDown={(event) => event.key === \"Enter\" && confirm()}\n />\n </div>\n <DialogFooter>\n <DialogClose asChild>\n <Button variant=\"outline\">{L(\"Layout:Cancel\")}</Button>\n </DialogClose>\n <Button onClick={confirm}>{L(\"Layout:Confirm\")}</Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n );\n}\n",
76
+ "type": "registry:component",
77
+ "target": "components/abp/layout/tenant-switcher.tsx"
78
+ },
79
+ {
80
+ "path": "ui/blocks/abp-layout/theme-toggle.tsx",
81
+ "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { Check, Moon, Sun } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@/components/ui/tooltip\";\n\ntype Theme = \"light\" | \"dark\" | \"system\";\n\nconst THEMES: { value: Theme; labelKey: string }[] = [\n { value: \"light\", labelKey: \"Layout:ThemeLight\" },\n { value: \"dark\", labelKey: \"Layout:ThemeDark\" },\n { value: \"system\", labelKey: \"Layout:ThemeSystem\" },\n];\n\nfunction applyTheme(theme: Theme) {\n localStorage.setItem(\"theme\", theme);\n const dark =\n theme === \"dark\" ||\n (theme === \"system\" && window.matchMedia(\"(prefers-color-scheme: dark)\").matches);\n document.documentElement.classList.toggle(\"dark\", dark);\n}\n\n/** 主题三选(light/dark/system),当前项带对勾;与 __root 的首绘内联脚本共用 localStorage.theme 约定。 */\nexport function ThemeToggle() {\n const L = useLocalization();\n // SSR 读不到 localStorage,挂载后再同步已存偏好。\n const [theme, setTheme] = useState<Theme>(\"system\");\n useEffect(() => {\n const stored = localStorage.getItem(\"theme\");\n if (stored === \"light\" || stored === \"dark\" || stored === \"system\") setTheme(stored);\n }, []);\n\n const pick = (value: Theme) => {\n setTheme(value);\n applyTheme(value);\n };\n\n return (\n <DropdownMenu>\n <Tooltip>\n <TooltipTrigger asChild>\n <DropdownMenuTrigger asChild>\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-8 text-muted-foreground hover:text-foreground\"\n aria-label={L(\"Layout:Theme\")}\n >\n <Sun className=\"scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90\" />\n <Moon className=\"absolute scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0\" />\n </Button>\n </DropdownMenuTrigger>\n </TooltipTrigger>\n <TooltipContent>{L(\"Layout:Theme\")}</TooltipContent>\n </Tooltip>\n <DropdownMenuContent align=\"end\">\n {THEMES.map((item) => (\n <DropdownMenuItem key={item.value} onClick={() => pick(item.value)}>\n {L(item.labelKey)}\n {theme === item.value && <Check className=\"ml-auto size-4\" />}\n </DropdownMenuItem>\n ))}\n </DropdownMenuContent>\n </DropdownMenu>\n );\n}\n",
82
+ "type": "registry:component",
83
+ "target": "components/abp/layout/theme-toggle.tsx"
84
+ },
85
+ {
86
+ "path": "ui/blocks/abp-layout/layout-messages.json",
87
+ "content": "{\n \"en\": {\n \"\": {\n \"Layout:Login\": \"Sign in\",\n \"Layout:Logout\": \"Sign out\",\n \"Layout:Language\": \"Language\",\n \"Layout:Tenant\": \"Tenant\",\n \"Layout:SwitchTenant\": \"Switch tenant\",\n \"Layout:Host\": \"Host\",\n \"Layout:TenantName\": \"Tenant name\",\n \"Layout:TenantSwitchHint\": \"Leave empty to switch back to the host\",\n \"Layout:Confirm\": \"Confirm\",\n \"Layout:Cancel\": \"Cancel\",\n \"Layout:Theme\": \"Theme\",\n \"Layout:ThemeLight\": \"Light\",\n \"Layout:ThemeDark\": \"Dark\",\n \"Layout:ThemeSystem\": \"System\",\n \"Layout:Search\": \"Search\",\n \"Layout:SearchPlaceholder\": \"Type to search the menu...\",\n \"Layout:SearchEmpty\": \"No results found.\"\n }\n },\n \"zh-Hans\": {\n \"\": {\n \"Layout:Login\": \"登录\",\n \"Layout:Logout\": \"退出登录\",\n \"Layout:Language\": \"语言\",\n \"Layout:Tenant\": \"租户\",\n \"Layout:SwitchTenant\": \"切换租户\",\n \"Layout:Host\": \"宿主\",\n \"Layout:TenantName\": \"租户名\",\n \"Layout:TenantSwitchHint\": \"留空则切回宿主\",\n \"Layout:Confirm\": \"确定\",\n \"Layout:Cancel\": \"取消\",\n \"Layout:Theme\": \"主题\",\n \"Layout:ThemeLight\": \"浅色\",\n \"Layout:ThemeDark\": \"深色\",\n \"Layout:ThemeSystem\": \"跟随系统\",\n \"Layout:Search\": \"搜索\",\n \"Layout:SearchPlaceholder\": \"输入以搜索菜单…\",\n \"Layout:SearchEmpty\": \"未找到匹配项。\"\n }\n }\n}\n",
88
+ "type": "registry:file",
89
+ "target": "components/abp/layout/layout-messages.json"
90
+ }
91
+ ],
92
+ "type": "registry:block"
93
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "abp-login",
4
+ "title": "ABP Login Card",
5
+ "description": "Password sign-in card (ROPC) with an SSO (OIDC) entry, wired to the jc-abp auth server functions.",
6
+ "dependencies": [
7
+ "@jcoder-stack/abp-react"
8
+ ],
9
+ "registryDependencies": [
10
+ "card",
11
+ "input",
12
+ "button",
13
+ "label",
14
+ "alert"
15
+ ],
16
+ "files": [
17
+ {
18
+ "path": "ui/blocks/abp-login/login-form.tsx",
19
+ "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { type FormEvent, useState } from \"react\";\nimport { loginWithPasswordFn } from \"@/auth/server-fns\";\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent, CardFooter, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { cn } from \"@/lib/utils\";\n\n/** 密码登录卡片:成功后整页跳转首页。鉴权态变化必须作废 SSR/查询缓存,整页加载是唯一可靠时机;OIDC 入口并列。 */\nexport function LoginForm({\n initialError,\n className,\n}: {\n initialError?: string;\n className?: string;\n}) {\n const L = useLocalization();\n const [userName, setUserName] = useState(\"\");\n const [password, setPassword] = useState(\"\");\n const [error, setError] = useState<string | undefined>(initialError);\n const [pending, setPending] = useState(false);\n\n const submit = async (event: FormEvent) => {\n event.preventDefault();\n setPending(true);\n try {\n const res = await loginWithPasswordFn({ data: { userName, password } });\n if (res.ok) {\n window.location.assign(\"/\");\n } else {\n setError(res.error);\n }\n } finally {\n setPending(false);\n }\n };\n\n return (\n <Card className={cn(\"gap-4\", className)}>\n <CardHeader>\n <CardTitle>{L(\"Login:Title\")}</CardTitle>\n </CardHeader>\n <form onSubmit={(event) => void submit(event)}>\n <CardContent className=\"grid gap-4\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"login-username\">{L(\"Login:UserName\")}</Label>\n <Input\n id=\"login-username\"\n autoComplete=\"username\"\n value={userName}\n onChange={(event) => setUserName(event.target.value)}\n />\n </div>\n <div className=\"grid gap-2\">\n <Label htmlFor=\"login-password\">{L(\"Login:Password\")}</Label>\n <Input\n id=\"login-password\"\n type=\"password\"\n autoComplete=\"current-password\"\n value={password}\n onChange={(event) => setPassword(event.target.value)}\n />\n </div>\n {error !== undefined && (\n <Alert variant=\"destructive\">\n <AlertDescription>\n {L(\"Login:Failed\")}: {error}\n </AlertDescription>\n </Alert>\n )}\n </CardContent>\n <CardFooter className=\"mt-4 grid gap-2\">\n <Button type=\"submit\" disabled={pending}>\n {L(\"Login:Submit\")}\n </Button>\n <Button type=\"button\" variant=\"outline\" asChild>\n <a href=\"/api/auth/login\">{L(\"Login:WithOidc\")}</a>\n </Button>\n </CardFooter>\n </form>\n </Card>\n );\n}\n",
20
+ "type": "registry:component",
21
+ "target": "components/abp/login/login-form.tsx"
22
+ },
23
+ {
24
+ "path": "ui/blocks/abp-login/login-messages.json",
25
+ "content": "{\n \"en\": {\n \"\": {\n \"Login:Title\": \"Sign in\",\n \"Login:UserName\": \"Username\",\n \"Login:Password\": \"Password\",\n \"Login:Submit\": \"Sign in\",\n \"Login:Failed\": \"Sign-in failed\",\n \"Login:WithOidc\": \"Continue with SSO (OIDC)\"\n }\n },\n \"zh-Hans\": {\n \"\": {\n \"Login:Title\": \"登录\",\n \"Login:UserName\": \"用户名\",\n \"Login:Password\": \"密码\",\n \"Login:Submit\": \"登录\",\n \"Login:Failed\": \"登录失败\",\n \"Login:WithOidc\": \"使用统一登录(OIDC)\"\n }\n }\n}\n",
26
+ "type": "registry:file",
27
+ "target": "components/abp/login/login-messages.json"
28
+ }
29
+ ],
30
+ "type": "registry:block"
31
+ }
@@ -0,0 +1,42 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "abp-permission-sheet",
4
+ "title": "ABP Permission Sheet",
5
+ "description": "Permission-group accordion + tree sheet wired to the ABP Permission Management API, with lock/cascade rules for cross-provider grants.",
6
+ "dependencies": [
7
+ "@jcoder-stack/abp-react",
8
+ "sonner",
9
+ "@tanstack/react-query"
10
+ ],
11
+ "registryDependencies": [
12
+ "sheet",
13
+ "accordion",
14
+ "checkbox",
15
+ "button",
16
+ "skeleton",
17
+ "sonner",
18
+ "./node_modules/@jcoder-stack/registry/public/r/tree.json",
19
+ "./node_modules/@jcoder-stack/registry/public/r/form.json"
20
+ ],
21
+ "files": [
22
+ {
23
+ "path": "ui/blocks/abp-permission-sheet/permission-helpers.ts",
24
+ "content": "import type { VoloAbpPermissionManagementPermissionGrantInfoDto } from \"@/api/models\";\nimport { collectSubtreeIds, findParentChain, type TreeNode } from \"@/components/tree/tree-helpers\";\n\n/** 单个权限授予信息,直接复用生成 DTO(`voloAbpPermissionManagementPermissionGrantInfoDto`),字段名以后端为准:`name`/`displayName`/`parentName`/`isGranted`/`grantedProviders`。 */\nexport type PermissionLike = VoloAbpPermissionManagementPermissionGrantInfoDto;\n\n/** 由 `parentName` 建树(`label` 直接取后端已本地化的 `displayName`),根节点=`parentName` 为空的权限;同组内构建,不跨组。 */\nexport function buildPermissionTree(permissions: PermissionLike[]): TreeNode[] {\n const byParent = new Map<string, PermissionLike[]>();\n for (const permission of permissions) {\n const parentKey = permission.parentName ?? \"\";\n const bucket = byParent.get(parentKey) ?? [];\n bucket.push(permission);\n byParent.set(parentKey, bucket);\n }\n\n function build(parentKey: string): TreeNode[] {\n return (byParent.get(parentKey) ?? []).map((permission) => {\n const id = permission.name ?? \"\";\n const children = build(id);\n return {\n id,\n label: permission.displayName ?? id,\n ...(children.length > 0 ? { children } : {}),\n };\n });\n }\n\n return build(\"\");\n}\n\nfunction findNode(nodes: TreeNode[], id: string): TreeNode | undefined {\n for (const node of nodes) {\n if (node.id === id) {\n return node;\n }\n const found = findNode(node.children ?? [], id);\n if (found !== undefined) {\n return found;\n }\n }\n return undefined;\n}\n\n/**\n * ABP 的级联规则:勾一个权限时父链一并勾上(父是子的前置条件),取消时清掉整个子树。\n * 单独勾父节点不连带勾子节点,父只是前置条件,不代表业务上要授予所有子权限。\n *\n * `locked` 是由 `lockedNames` 算出的「他 provider 授予、本视图只读」的权限名集合。\n * 级联遍历命中它就跳过,不勾也不清,保持调用前的成员资格,勾和清两个方向对称。\n */\nexport function applyCheck(\n state: Set<string>,\n permissions: PermissionLike[],\n name: string,\n checked: boolean,\n locked?: Set<string>,\n): Set<string> {\n const tree = buildPermissionTree(permissions);\n const next = new Set(state);\n if (checked) {\n if (!locked?.has(name)) {\n next.add(name);\n }\n for (const ancestorId of findParentChain(tree, name)) {\n if (!locked?.has(ancestorId)) {\n next.add(ancestorId);\n }\n }\n return next;\n }\n const node = findNode(tree, name);\n const idsToRemove = node !== undefined ? collectSubtreeIds(node) : [name];\n for (const id of idsToRemove) {\n if (!locked?.has(id)) {\n next.delete(id);\n }\n }\n return next;\n}\n\n/**\n * 组头「全选」:勾选=组内全部权限进入 checked;取消=组内全部权限移出 checked。\n *\n * `locked`(可选,语义同 `applyCheck`):命中的权限名跳过,保持调用前的成员资格。\n */\nexport function toggleGroup(\n state: Set<string>,\n groupPermissions: PermissionLike[],\n checked: boolean,\n locked?: Set<string>,\n): Set<string> {\n const next = new Set(state);\n for (const permission of groupPermissions) {\n const name = permission.name ?? \"\";\n if (locked?.has(name)) {\n continue;\n }\n if (checked) {\n next.add(name);\n } else {\n next.delete(name);\n }\n }\n return next;\n}\n\n/** PUT 全量载荷:`all` 里每个权限都要有一条(含 `isGranted: false`),后端按整份覆盖,不是增量 diff。 */\nexport function toUpdatePayload(\n all: PermissionLike[],\n checked: Set<string>,\n): { name: string; isGranted: boolean }[] {\n return all.map((permission) => {\n const name = permission.name ?? \"\";\n return { name, isGranted: checked.has(name) };\n });\n}\n\n/** 只读判定:权限已授予,且 `grantedProviders` 里有非当前 provider 的条目,\n * 说明这条授予来自别处(比如角色),当前 provider 视图改不了。 */\nexport function lockedNames(permissions: PermissionLike[], providerName: string): Set<string> {\n const locked = new Set<string>();\n for (const permission of permissions) {\n const grantedByOther = (permission.grantedProviders ?? []).some(\n (provider) => provider.providerName !== providerName,\n );\n if (permission.isGranted === true && grantedByOther) {\n locked.add(permission.name ?? \"\");\n }\n }\n return locked;\n}\n",
25
+ "type": "registry:component",
26
+ "target": "components/abp/permission/permission-helpers.ts"
27
+ },
28
+ {
29
+ "path": "ui/blocks/abp-permission-sheet/permission-sheet.tsx",
30
+ "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport {\n useGetApiPermissionManagementPermissions,\n usePutApiPermissionManagementPermissions,\n} from \"@/api/endpoints/permissions/permissions\";\nimport type { VoloAbpPermissionManagementPermissionGroupDto } from \"@/api/models\";\nimport {\n applyCheck,\n buildPermissionTree,\n lockedNames,\n type PermissionLike,\n toggleGroup,\n toUpdatePayload,\n} from \"@/components/abp/permission/permission-helpers\";\nimport { SubmitButton } from \"@/components/form/submit-button\";\nimport { Tree } from \"@/components/tree/tree\";\nimport { deriveIndeterminate, type TreeNode } from \"@/components/tree/tree-helpers\";\nimport { TriStateCheckbox } from \"@/components/tree/tri-state-checkbox\";\nimport {\n Accordion,\n AccordionContent,\n AccordionItem,\n AccordionTrigger,\n} from \"@/components/ui/accordion\";\nimport { Button } from \"@/components/ui/button\";\nimport { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from \"@/components/ui/sheet\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\n\nexport interface PermissionSheetProps {\n providerName: \"R\" | \"U\";\n providerKey: string;\n open: boolean;\n onOpenChange: (open: boolean) => void;\n title: string;\n}\n\n/** 级联/全选/只读判定委托 permission-helpers 的纯函数。 */\nexport function PermissionSheet(props: PermissionSheetProps) {\n const { providerName, providerKey, open, onOpenChange, title } = props;\n const L = useLocalization();\n\n const query = useGetApiPermissionManagementPermissions(\n { providerName, providerKey },\n { query: { enabled: open } },\n );\n const mutation = usePutApiPermissionManagementPermissions();\n\n const groups = useMemo(() => query.data?.groups ?? [], [query.data]);\n const allPermissions = useMemo<PermissionLike[]>(\n () => groups.flatMap((group) => group.permissions ?? []),\n [groups],\n );\n const locked = useMemo(\n () => lockedNames(allPermissions, providerName),\n [allPermissions, providerName],\n );\n\n const [checked, setChecked] = useState<Set<string>>(new Set());\n\n // allPermissions 只在新数据到位(打开 sheet 触发新 fetch)时换引用,借它驱动本地勾选态回到服务端的 isGranted。\n useEffect(() => {\n setChecked(\n new Set(\n allPermissions\n .filter((permission) => permission.isGranted === true)\n .map((permission) => permission.name ?? \"\"),\n ),\n );\n }, [allPermissions]);\n\n function handleCheck(name: string, value: boolean) {\n setChecked((prev) => applyCheck(prev, allPermissions, name, value, locked));\n }\n\n function handleGroupToggle(group: VoloAbpPermissionManagementPermissionGroupDto, value: boolean) {\n setChecked((prev) => toggleGroup(prev, group.permissions ?? [], value, locked));\n }\n\n function handleSubmit() {\n mutation.mutate(\n {\n data: { permissions: toUpdatePayload(allPermissions, checked) },\n params: { providerName, providerKey },\n },\n {\n onSuccess: () => {\n toast.success(L(\"Crud:Saved\"));\n onOpenChange(false);\n },\n // 失败必须出声:没有 onError 时抽屉停在原地且零提示,用户会以为没点到而反复提交。\n onError: () => toast.error(L(\"Crud:OperationFailed\")),\n },\n );\n }\n\n return (\n <Sheet open={open} onOpenChange={onOpenChange}>\n <SheetContent className=\"flex w-full flex-col gap-0 sm:max-w-md\">\n <SheetHeader>\n <SheetTitle>{title}</SheetTitle>\n </SheetHeader>\n <form\n className=\"flex flex-1 flex-col overflow-y-auto\"\n onSubmit={(event) => {\n event.preventDefault();\n handleSubmit();\n }}\n >\n <div className=\"flex-1 space-y-2 px-4 py-2\">\n {query.isError ? (\n <p className=\"text-sm text-destructive\">{L(\"Admin:LoadFailed\")}</p>\n ) : query.isPending ? (\n <div className=\"space-y-2\" data-testid=\"permission-sheet-skeleton\">\n <Skeleton className=\"h-8 w-full\" />\n <Skeleton className=\"h-8 w-full\" />\n <Skeleton className=\"h-8 w-full\" />\n </div>\n ) : (\n <Accordion\n type=\"multiple\"\n defaultValue={groups[0]?.name != null ? [groups[0].name] : []}\n >\n {groups.map((group) => {\n const groupPermissions = group.permissions ?? [];\n const tree = withLocked(buildPermissionTree(groupPermissions), locked);\n const indeterminate = deriveIndeterminate(tree, checked);\n const groupState = groupCheckState(groupPermissions, checked);\n const groupKey = group.name ?? \"\";\n return (\n <AccordionItem key={groupKey} value={groupKey}>\n {/* Checkbox 是 <button role=\"checkbox\">,不能嵌进 AccordionTrigger 自己的 <button>(无效 HTML),所以并排放,不塞进 trigger 子节点。 */}\n <div className=\"flex items-center gap-2\">\n <TriStateCheckbox\n checked={groupState}\n aria-label={L(\"Admin:SelectAll\")}\n data-testid={`permission-group-select-all-${groupKey}`}\n onCheckedChange={(value: boolean | \"indeterminate\") =>\n handleGroupToggle(group, value === true)\n }\n />\n <AccordionTrigger className=\"py-2\">\n {group.displayName ?? groupKey}\n </AccordionTrigger>\n </div>\n <AccordionContent>\n <Tree\n nodes={tree}\n checkable\n checked={checked}\n indeterminate={indeterminate}\n defaultExpanded={collectParentIds(tree)}\n onCheckChange={handleCheck}\n />\n </AccordionContent>\n </AccordionItem>\n );\n })}\n </Accordion>\n )}\n </div>\n <SheetFooter>\n <SubmitButton pending={mutation.isPending} />\n <Button type=\"button\" variant=\"outline\" onClick={() => onOpenChange(false)}>\n {L(\"Form:Cancel\")}\n </Button>\n </SheetFooter>\n </form>\n </SheetContent>\n </Sheet>\n );\n}\n\nfunction groupCheckState(\n permissions: PermissionLike[],\n checked: Set<string>,\n): boolean | \"indeterminate\" {\n if (permissions.length === 0) return false;\n const checkedCount = permissions.filter((permission) =>\n checked.has(permission.name ?? \"\"),\n ).length;\n if (checkedCount === 0) return false;\n if (checkedCount === permissions.length) return true;\n return \"indeterminate\";\n}\n\n/** 把 lockedNames 的判定结果落到树节点的 `disabled`(他 provider 授予的权限在本视图只读)。 */\nfunction withLocked(nodes: TreeNode[], locked: Set<string>): TreeNode[] {\n return nodes.map((node) => ({\n ...node,\n disabled: locked.has(node.id),\n ...(node.children !== undefined ? { children: withLocked(node.children, locked) } : {}),\n }));\n}\n\n/** 用作 Tree 的 defaultExpanded,权限树默认全展开。 */\nfunction collectParentIds(nodes: TreeNode[]): string[] {\n const ids: string[] = [];\n for (const node of nodes) {\n if (node.children !== undefined && node.children.length > 0) {\n ids.push(node.id, ...collectParentIds(node.children));\n }\n }\n return ids;\n}\n",
31
+ "type": "registry:component",
32
+ "target": "components/abp/permission/permission-sheet.tsx"
33
+ },
34
+ {
35
+ "path": "ui/blocks/abp-permission-sheet/admin-messages.json",
36
+ "content": "{\n \"en\": {\n \"\": {\n \"Admin:SelectAll\": \"Select all\",\n \"Admin:Permissions\": \"Permissions\",\n \"Admin:FullName\": \"Full name\",\n \"Admin:Yes\": \"Yes\",\n \"Admin:No\": \"No\",\n \"Admin:Profile\": \"Profile\",\n \"Admin:ProfileInfo\": \"Profile information\",\n \"Admin:SmtpSection\": \"SMTP server\",\n \"Admin:SmtpSectionDesc\": \"Outgoing mail is delivered through this SMTP server.\",\n \"Admin:SenderSection\": \"Sender\",\n \"Admin:SenderSectionDesc\": \"Default from address, display name, and credential options.\",\n \"Admin:LoadFailed\": \"Failed to load\"\n }\n },\n \"zh-Hans\": {\n \"\": {\n \"Admin:SelectAll\": \"全选\",\n \"Admin:Permissions\": \"权限\",\n \"Admin:FullName\": \"姓名\",\n \"Admin:Yes\": \"是\",\n \"Admin:No\": \"否\",\n \"Admin:Profile\": \"个人中心\",\n \"Admin:ProfileInfo\": \"个人资料\",\n \"Admin:SmtpSection\": \"SMTP 服务器\",\n \"Admin:SmtpSectionDesc\": \"外发邮件通过此 SMTP 服务器投递。\",\n \"Admin:SenderSection\": \"发件人\",\n \"Admin:SenderSectionDesc\": \"默认发件地址、显示名称与凭据选项。\",\n \"Admin:LoadFailed\": \"加载失败\"\n }\n }\n}\n",
37
+ "type": "registry:file",
38
+ "target": "components/abp/permission/admin-messages.json"
39
+ }
40
+ ],
41
+ "type": "registry:block"
42
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "abp-sheet",
4
+ "title": "ABP CRUD Sheet",
5
+ "description": "useAbpSheet: create/update drawer state machine wired to concurrencyStamp handling and abpSubmitValidator, on top of the form and abp-crud blocks.",
6
+ "dependencies": [
7
+ "@jcoder-stack/abp-react",
8
+ "@tanstack/react-form",
9
+ "@tanstack/react-query",
10
+ "sonner"
11
+ ],
12
+ "registryDependencies": [
13
+ "./node_modules/@jcoder-stack/registry/public/r/form.json",
14
+ "./node_modules/@jcoder-stack/registry/public/r/abp-crud.json"
15
+ ],
16
+ "files": [
17
+ {
18
+ "path": "ui/blocks/abp-sheet/use-abp-sheet.tsx",
19
+ "content": "import { useLocalization, usePermissionChecker } from \"@jcoder-stack/abp-react/react\";\nimport { type FormValidateOrFn, useStore } from \"@tanstack/react-form\";\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport { type ReactNode, useCallback, useRef, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { abpFormOptions } from \"@/components/abp/crud/abp-form-options\";\nimport { useBoundComponents } from \"@/components/abp/crud/create-bound-components\";\nimport type { WritableCrudService } from \"@/components/abp/crud/crud-service\";\nimport { useAppForm } from \"@/components/form/form-hook\";\nimport { SheetForm, type SheetFormMode, type SheetFormProps } from \"@/components/form/sheet-form\";\n\n/** form 块的入参。`emptyValues` 之外三个映射都可以省:`toValues` 缺省按 `emptyValues` 的键\n * 从记录里 pick,null/undefined 回退成空值,roles 这类「表单形状 ≡ DTO 子集」的页够用了。\n * `toCreate`/`toUpdate` 只在 `TValues` 结构上可赋给对应 DTO 时才允许省略;形状不匹配\n * (比如 tenants 的 update DTO 没有 admin 字段)编译期就报错,不会到运行时才传错字段。 */\nexport type AbpSheetOptions<\n TDto extends { id?: string; concurrencyStamp?: string | null },\n TValues,\n TCreate,\n TUpdate,\n> = {\n emptyValues: TValues;\n /** record → 表单值;可异步(预取关联数据);返回 null 表示取消打开(调用方自行提示)。 */\n toValues?: (record: TDto, mode: SheetFormMode) => TValues | Promise<TValues | null>;\n schema?: (mode: SheetFormMode) => FormValidateOrFn<TValues>;\n} & (TValues extends TCreate\n ? { toCreate?: (value: TValues) => TCreate }\n : { toCreate: (value: TValues) => TCreate }) &\n (TValues extends TUpdate\n ? { toUpdate?: (value: TValues) => TUpdate }\n : { toUpdate: (value: TValues) => TUpdate });\n\n/** service → sheet 三态状态机 + create/update mutation。直接吃 `WritableCrudService`,\n * 调用方不用另建一份 mutation 自己接线。sheet 状态机、concurrencyStamp 注入、保存 toast\n * 与列表失效都收在这里。绑定成员 `Sheet` 经 `useBoundComponents` 挂出稳定身份。 */\nexport function useAbpSheet<\n TDto extends { id?: string; concurrencyStamp?: string | null },\n TValues,\n TCreate,\n TUpdate,\n>(\n service: WritableCrudService<TDto, TCreate, TUpdate>,\n opts: AbpSheetOptions<TDto, TValues, TCreate, TUpdate>,\n) {\n const L = useLocalization();\n const can = usePermissionChecker();\n const queryClient = useQueryClient();\n\n const [sheet, setSheet] = useState<{ mode: SheetFormMode; record?: TDto } | null>(null);\n // defaultValues 必须由 state 驱动:useForm 每渲染 formApi.update(opts) 会把稳定常量\n // defaultValues 拍回刚 reset 的记录值。\n const [formDefaults, setFormDefaults] = useState<TValues>(opts.emptyValues);\n\n const mode = sheet?.mode;\n const record = sheet?.record;\n\n const invalidate = () => queryClient.invalidateQueries({ queryKey: service.listKey() });\n const saved = () => {\n toast.success(L(\"Crud:Saved\"));\n void invalidate();\n };\n\n const rawCreate = service.useCreate({ mutation: { onSuccess: saved } });\n const rawUpdate = service.useUpdate({ mutation: { onSuccess: saved } });\n\n const form = useAppForm(\n abpFormOptions({\n defaultValues: formDefaults,\n schema: opts.schema?.(mode ?? \"create\"),\n // rawCreate/rawUpdate 直接调用,不包 facade。两者只在这一处读取,不像\n // use-abp-table.ts 的 deleteMutation 那样要喂一条外部 memo 链,包一层没有可观察差异。\n submit: async (value: TValues) => {\n if (sheet?.mode === \"edit\" && sheet.record?.id) {\n const stamp = sheet.record.concurrencyStamp;\n const toUpdate = opts.toUpdate ?? ((v: TValues) => v as unknown as TUpdate);\n const base = toUpdate(value);\n // 泛型上无法表达「附加可选键仍是 TUpdate」;运行时 ASP.NET 模型绑定忽略未知属性,\n // 对无该字段的 DTO(如 books)安全。\n const data = stamp != null ? ({ ...base, concurrencyStamp: stamp } as TUpdate) : base;\n await rawUpdate.mutateAsync({ id: sheet.record.id, data });\n } else {\n const toCreate = opts.toCreate ?? ((v: TValues) => v as unknown as TCreate);\n await rawCreate.mutateAsync({ data: toCreate(value) });\n }\n },\n onSuccess: () => setSheet(null),\n }),\n );\n const isSubmitting = useStore(form.store, (state) => state.isSubmitting);\n\n // 调用方的 opts 多是内联字面量、每渲染新建,钉在 ref 上 open 才能引用稳定。\n // 这是硬要求:AbpTable 的 columns memo 依赖 onOpen,它一变整张表的列模型就重建,\n // 还会触发 data-table 的 columns churn 告警去误指调用方。\n const optsRef = useRef(opts);\n optsRef.current = opts;\n\n // 默认 toValues:按 emptyValues 的键从记录 pick,null/undefined 回退成空值,够 roles 这类\n // 「表单形状 ≡ DTO 子集」的页用;密码不回显这种 mode 语义仍要显式传 toValues。\n // useCallback([]) 钉稳身份,只读 optsRef.current,ref 免疫过期闭包。\n const defaultToValues = useCallback((record: TDto): TValues => {\n const empty = optsRef.current.emptyValues as Record<string, unknown>;\n const next: Record<string, unknown> = { ...empty };\n for (const key of Object.keys(next)) {\n const value = (record as Record<string, unknown>)[key];\n if (value !== undefined && value !== null) next[key] = value;\n }\n return next as TValues;\n }, []);\n\n const open = useCallback(\n async (nextMode: SheetFormMode, nextRecord?: TDto) => {\n const o = optsRef.current;\n let values = o.emptyValues;\n if (nextRecord) {\n const resolved = o.toValues\n ? await o.toValues(nextRecord, nextMode)\n : defaultToValues(nextRecord);\n if (resolved === null) return;\n values = resolved;\n }\n setFormDefaults(values);\n form.reset(values);\n setSheet({ mode: nextMode, record: nextRecord });\n },\n [form, defaultToValues],\n );\n\n const allow = (policy: string | undefined) => (policy === undefined ? true : can(policy));\n\n const sheetProps = {\n mode: mode ?? \"create\",\n open: sheet !== null,\n onOpenChange: (nextOpen: boolean) => {\n if (!nextOpen) setSheet(null);\n },\n title: L(\n mode === \"edit\" ? \"Crud:EditTitle\" : mode === \"view\" ? \"Crud:ViewTitle\" : \"Crud:Create\",\n ),\n onSubmit: () => {\n void form.handleSubmit();\n },\n pending: isSubmitting,\n canEdit: allow(service.resolvedPolicies.update),\n onEdit: () => {\n if (sheet) setSheet({ ...sheet, mode: \"edit\" });\n },\n } satisfies Omit<SheetFormProps, \"children\">;\n\n const bound = useBoundComponents({ sheetProps }, (read) => ({\n Sheet: (p: { title?: string; children: ReactNode }) => (\n <SheetForm {...read().sheetProps} {...(p.title !== undefined ? { title: p.title } : {})}>\n {p.children}\n </SheetForm>\n ),\n }));\n\n return {\n form,\n open,\n close: () => setSheet(null),\n record,\n readOnly: mode === \"view\",\n mode,\n Sheet: bound.Sheet,\n };\n}\n",
20
+ "type": "registry:component",
21
+ "target": "components/abp/sheet/use-abp-sheet.tsx"
22
+ }
23
+ ],
24
+ "type": "registry:block"
25
+ }
@@ -0,0 +1,80 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "abp-table",
4
+ "title": "ABP CRUD Table",
5
+ "description": "useAbpTable and AbpTable composition (query form, bulk bar, row actions, status badge) on top of the data-table, form, and abp-crud blocks.",
6
+ "dependencies": [
7
+ "@jcoder-stack/abp-react",
8
+ "@tanstack/react-query",
9
+ "@tanstack/react-table@9.0.0-beta.59",
10
+ "@tanstack/react-form",
11
+ "sonner",
12
+ "lucide-react"
13
+ ],
14
+ "registryDependencies": [
15
+ "alert-dialog",
16
+ "badge",
17
+ "button",
18
+ "tooltip",
19
+ "sonner",
20
+ "dropdown-menu",
21
+ "separator",
22
+ "field",
23
+ "skeleton",
24
+ "./node_modules/@jcoder-stack/registry/public/r/data-table.json",
25
+ "./node_modules/@jcoder-stack/registry/public/r/form.json",
26
+ "./node_modules/@jcoder-stack/registry/public/r/abp-crud.json",
27
+ "./node_modules/@jcoder-stack/registry/public/r/date-picker.json"
28
+ ],
29
+ "files": [
30
+ {
31
+ "path": "ui/blocks/abp-table/use-abp-table.ts",
32
+ "content": "import { toAbpListParams, toPagedResult } from \"@jcoder-stack/abp-react/core\";\nimport { useLocalization, usePermissionChecker } from \"@jcoder-stack/abp-react/react\";\nimport {\n type FormAsyncValidateOrFn,\n type FormValidateOrFn,\n revalidateLogic,\n} from \"@tanstack/react-form\";\nimport { keepPreviousData, useQueryClient } from \"@tanstack/react-query\";\nimport {\n Children,\n createElement,\n Fragment,\n isValidElement,\n type ReactNode,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport type { AbpTableSource } from \"@/components/abp/crud/abp-table-source\";\nimport { useBoundComponents } from \"@/components/abp/crud/create-bound-components\";\nimport {\n ABP_RESERVED_LIST_PARAM_NAMES,\n type CrudDeleteHook,\n type CrudService,\n type CrudWriteHooks,\n type ListParams,\n} from \"@/components/abp/crud/crud-service\";\nimport { AbpBulkDeleteView } from \"@/components/abp/table/abp-bulk-delete\";\nimport { AbpTableView, type AbpTableViewOwnProps } from \"@/components/abp/table/abp-table\";\nimport { RowActionsMenu } from \"@/components/abp/table/row-actions-menu\";\nimport { devWarn } from \"@/components/data-table/dev-warn\";\nimport type { CellTableInstance, TableColumnDef } from \"@/components/data-table/table-core\";\nimport { type UseDataTableOptions, useDataTable } from \"@/components/data-table/use-data-table\";\nimport { useDataTableState } from \"@/components/data-table/use-data-table-state\";\nimport { useAppForm } from \"@/components/form/form-hook\";\n\n/** 留白字段不该变成 `Name=` 空串发给后端,`\"\"` 和 `undefined` 都不进请求。 */\nfunction pruneEmpty(value: Record<string, unknown>): Record<string, unknown> {\n return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== \"\" && v !== undefined));\n}\n\n/** 跨字段 zod 校验没给 issue 挂 `path` 时,错误落在 `errorMap.onDynamic` 的 `\"\"` 桶。\n * 走 unknown + 收窄而不是 `state.errorMap` 的具体类型,是为了绕开 TOnDynamic 那串泛型形参。 */\nfunction hasUnroutedCrossFieldError(errorMap: unknown): boolean {\n if (typeof errorMap !== \"object\" || errorMap === null) return false;\n const onDynamic = (errorMap as { onDynamic?: unknown }).onDynamic;\n if (typeof onDynamic !== \"object\" || onDynamic === null) return false;\n const bucket = (onDynamic as Record<string, unknown>)[\"\"];\n return Array.isArray(bucket) && bucket.length > 0;\n}\n\n/** 空操作删除 hook,顶替只读 service 缺失的 `useDelete`。\n * 这样 `useServiceSource` 每次渲染都无条件调用一个 hook,\n * 而不是 `service.useDelete?.(...)` 那种真正的条件调用。 */\nconst useNoopDelete: CrudDeleteHook[\"useDelete\"] = () => ({\n mutate: () => {},\n mutateAsync: async () => undefined,\n isPending: false,\n});\n\n/** 在 `CrudService` 上补回可选的 create/update/delete 键。`CrudService` 本身不含这三个\n * (只读 service 上它们不存在),但这里要判断它们在不在才能派生 `can`/`delete`,\n * `Partial<...>` 让\"有\"和\"没有\"两种形状都赋得进来。 */\ntype AbpTableSourceService<\n TDto extends { id?: string },\n TCreate,\n TUpdate,\n TListParams extends Record<string, unknown>,\n> = CrudService<TDto, TCreate, TUpdate, TListParams> &\n Partial<CrudWriteHooks<TCreate, TUpdate>> &\n Partial<CrudDeleteHook>;\n\nexport interface AbpTableRowConfig<TDto extends { id?: string }> {\n /** 追加在内置「···」菜单三项之后的自定义菜单项。引用必须稳定(模块级函数或 `useCallback`),\n * 它在操作列的 `useMemo` 依赖里,每渲染换新引用会让整张表的列模型重建。 */\n menu?: (row: TDto, table: CellTableInstance<TDto>) => ReactNode;\n /** 插入内置「···」菜单**左侧**的行内常驻操作。同上,引用必须稳定。 */\n actions?: (row: TDto, table: CellTableInstance<TDto>) => ReactNode;\n /** 覆盖对应内置项的默认出现条件;省略时按 can/onOpen/click 推导。 */\n view?: boolean;\n edit?: boolean;\n delete?: boolean;\n /** 默认:`onOpen` 存在时点行开详情;`false` 关闭;也可给自定义回调。 */\n click?: false | ((row: TDto) => void);\n}\n\nexport interface UseAbpTableOptions<\n TDto extends { id?: string },\n TQueryDefaults extends Record<string, unknown>,\n> {\n /** 列定义,见 `useDataTable` 同名 prop 的引用稳定性要求。 */\n columns: TableColumnDef<TDto>[];\n /** 查询表单。`defaults` 只在这里生效(字段上不带 `default`),首帧就用它发请求,\n * 免得先空筛选请求一次、挂载后再请求一次,中间闪一下未筛数据。\n * 省略则不建查询表单,`queryForm` 的 values 退化成空对象。 */\n query?: {\n defaults: TQueryDefaults;\n /** 额外校验器的逃生舱。形状照抄 `AbpFormConfig[\"validators\"]`,别换成\n * `Parameters<typeof useAppForm>[0][\"validators\"]`:那是个自引用类型,和 `defaultValues`\n * 塞进同一个 `useAppForm` 调用,会让 `TFormData` 的推断整体塌成 unknown,\n * `t.queryForm` 的字段名收窄跟着失效。\n * `onDynamic` 在这里不是保留位,查询表单没走 `abpFormOptions()` 预设,调用方可以自己传。 */\n validators?: {\n onMount?: FormValidateOrFn<TQueryDefaults>;\n onChange?: FormValidateOrFn<TQueryDefaults>;\n onChangeAsync?: FormAsyncValidateOrFn<TQueryDefaults>;\n onBlur?: FormValidateOrFn<TQueryDefaults>;\n onBlurAsync?: FormAsyncValidateOrFn<TQueryDefaults>;\n onDynamic?: FormValidateOrFn<TQueryDefaults>;\n onDynamicAsync?: FormAsyncValidateOrFn<TQueryDefaults>;\n };\n };\n row?: AbpTableRowConfig<TDto>;\n onOpen?: (mode: \"create\" | \"edit\" | \"view\", record?: TDto) => void;\n selectable?: boolean;\n defaultPageSize?: number;\n features?: UseDataTableOptions<TDto>[\"features\"];\n tableOptions?: UseDataTableOptions<TDto>[\"tableOptions\"];\n /** 顶部条「导出」图标的回调插槽;缺席不渲染导出按钮。组件库不内置导出实现。 */\n onExport?: () => void;\n}\n\n/**\n * service → source 归一:列表、删除、权限收在这里,create/update 归 `useAbpSheet`。\n * 只读 service 没有 `useDelete`,用 `useNoopDelete` 顶替,调用点才不是条件 hook。\n *\n * `listParams` 收到的已经是合并后的最终请求参数,看不到查询表单的原始键,\n * 所以 ABP 保留名的 devWarn 剔除不在这里做,在 `useAbpTable` 的合并处。\n */\nfunction useServiceSource<\n TDto extends { id?: string },\n TCreate,\n TUpdate,\n TListParams extends Record<string, unknown>,\n>(\n service: AbpTableSourceService<TDto, TCreate, TUpdate, TListParams>,\n listParams: ListParams,\n): AbpTableSource<TDto> {\n const L = useLocalization();\n const can = usePermissionChecker();\n const queryClient = useQueryClient();\n\n const listQuery = service.useList(listParams, {\n query: { placeholderData: keepPreviousData, select: toPagedResult },\n });\n const totalCount = listQuery.data?.totalCount ?? 0;\n const pageCount = Math.max(Math.ceil(totalCount / listParams.MaxResultCount), 1);\n\n const invalidate = () => queryClient.invalidateQueries({ queryKey: service.listKey() });\n const deleted = () => {\n toast.success(L(\"Crud:Deleted\"));\n void invalidate();\n };\n const deleteFailed = () => toast.error(L(\"Crud:OperationFailed\"));\n\n const useDeleteHook = service.useDelete ?? useNoopDelete;\n const rawDelete = useDeleteHook({ mutation: { onSuccess: deleted, onError: deleteFailed } });\n // 批量删除另起一个 mutation 实例,不带回调。单条删除的 onSuccess/onError 是逐条触发的,\n // 批量复用它删 N 条就会弹 N 个 toast、发 N 次重取。整批的汇总和失效由 deleteMany 负责。\n const rawBulkDelete = useDeleteHook();\n\n const bulkMutateAsync = rawBulkDelete.mutateAsync;\n const deleteMany = useCallback(\n async (ids: string[]) => {\n const failed: string[] = [];\n // 顺序而非并发:ABP 的删除常连带关联清理,并发提交容易撞上后端的并发/死锁保护,\n // 把「后端拒绝」误算成「这条删不掉」。\n for (const id of ids) {\n try {\n await bulkMutateAsync({ id });\n } catch {\n failed.push(id);\n }\n }\n await queryClient.invalidateQueries({ queryKey: service.listKey() });\n return { failed };\n },\n [bulkMutateAsync, queryClient, service.listKey],\n );\n\n // 把 orval 的 {id} 变量形状拆回 mutate(id)/mutateAsync(id)。useMemo 的依赖挂在\n // mutate/mutateAsync/isPending 上,不能挂 rawDelete:useMutation 每渲染都返回新的包装对象,\n // 挂它会让这个 memo 每渲染都失效,连累下面 rowActionsSrc → columns 整条链。deleteMany 同理。\n const deleteMutation = useMemo(\n () => ({\n mutate: (id: string) => rawDelete.mutate({ id }),\n mutateAsync: (id: string) => rawDelete.mutateAsync({ id }),\n isPending: rawDelete.isPending,\n many: deleteMany,\n }),\n [rawDelete.mutate, rawDelete.mutateAsync, rawDelete.isPending, deleteMany],\n );\n\n const allow = (policy: string | undefined) => (policy === undefined ? true : can(policy));\n\n return {\n listQuery,\n pageCount,\n totalCount,\n delete: service.useDelete !== undefined ? deleteMutation : undefined,\n can: {\n create: service.useCreate !== undefined && allow(service.resolvedPolicies.create),\n update: service.useUpdate !== undefined && allow(service.resolvedPolicies.update),\n delete: service.useDelete !== undefined && allow(service.resolvedPolicies.delete),\n },\n supportsFilter: service.supportsFilter !== false,\n };\n}\n\n/**\n * 一次调用给出页面要用的全部东西:查询表单、归一后的 `AbpTableSource`、TanStack 表实例。\n * 调用方不用再把 `table`/`crud`/`queryForm` 分别接到 `AbpTable` 的多个 props 上。\n *\n * `query.defaults` 只在这里生效,首帧就用它发请求。计算型默认值(比如\"最近一周\")要在\n * route loader 里算好再传进来,渲染期调 `new Date()` 会导致水合不一致。\n *\n * 跨字段 zod 校验(比如\"起始日期须早于结束\")记得给 issue 一个具体 `path`,例如\n * `.refine(fn, { path: [\"MinPublishDate\"] })`,它才会落进字段级的内联错误。不带 `path` 的\n * 表单级错误落在 `errorMap.onDynamic` 的 `{\"\": issue[]}` 里,`FormErrors` 只订阅 `onSubmit`,\n * 渲染不出来,这里也不代为兜底。\n *\n * 第一个参数传 service 或回调都行,但同一次 `useAbpTable` 调用里不能中途换种类。\n * 下面的分叉因此只是类型上的\"条件\",运行时 lifetime 稳定,不是条件 hook。\n */\nexport function useAbpTable<\n TDto extends { id?: string },\n TCreate = unknown,\n TUpdate = unknown,\n TListParams extends Record<string, unknown> = Record<string, unknown>,\n TQueryDefaults extends Record<string, unknown> = Record<string, never>,\n>(\n source:\n | AbpTableSourceService<TDto, TCreate, TUpdate, TListParams>\n | ((params: ListParams) => AbpTableSource<TDto>),\n opts: UseAbpTableOptions<TDto, TQueryDefaults>,\n) {\n const L = useLocalization();\n const state = useDataTableState({ defaultPageSize: opts.defaultPageSize });\n\n const queryDefaults: TQueryDefaults = opts.query?.defaults ?? ({} as TQueryDefaults);\n const [params, setParams] = useState<Record<string, unknown>>(() => pruneEmpty(queryDefaults));\n\n const queryForm = useAppForm({\n defaultValues: queryDefaults,\n validationLogic: revalidateLogic({ mode: \"submit\", modeAfterSubmission: \"change\" }),\n validators: opts.query?.validators,\n onSubmit: ({ value }) => {\n setParams(pruneEmpty(value as Record<string, unknown>));\n // 筛选变化必回第 1 页并清选择,这条不变式只在 useDataTableState.resetPaging 里实现。\n state.resetPaging();\n },\n });\n\n // 4 个 ABP 分页协议字段归表格状态机独占:查询表单里的同名键无条件剔除,不管当前有没有值。\n // 按值剔除会变成\"随排序/搜索状态时灵时不灵\"的间歇故障,比一直不工作更难查。\n // 也只有这里同时看得到查询表单的原始键和 toAbpListParams 的输出,useServiceSource 分不清。\n const reserved = Object.keys(params).filter((key) => ABP_RESERVED_LIST_PARAM_NAMES.has(key));\n if (reserved.length > 0) {\n devWarn(\n `use-abp-table:reserved-param:${reserved.join(\",\")}`,\n `useAbpTable: 查询表单里的 ${reserved.join(\"、\")} 属 ABP 分页协议保留名,已被剔除。` +\n \"分页/排序/搜索归表格状态机独占,请走各自的内建控件,不要用查询表单字段传。\",\n );\n }\n const safeParams = Object.fromEntries(\n Object.entries(params).filter(([key]) => !ABP_RESERVED_LIST_PARAM_NAMES.has(key)),\n );\n const listParams: ListParams = { ...safeParams, ...toAbpListParams(state.params) };\n\n // source 的种类跨渲染不变,分支 lifetime 稳定,不是条件 hook。见函数 TSDoc。\n const src =\n typeof source === \"function\"\n ? source(listParams)\n : // biome-ignore lint/correctness/useHookAtTopLevel: 见上,source 的种类跨渲染不变,分支 lifetime 稳定\n useServiceSource(source, listParams);\n\n // 检查收进自己的包装函数,不改写表单实例的 handleSubmit,那依赖 TanStack Form 未承诺的\n // 「实例引用跨渲染稳定」。AbpTable 内建的「查询」按钮走这里。\n const queryFormRef = useRef(queryForm);\n queryFormRef.current = queryForm;\n const submitQuery = useCallback(async () => {\n await queryFormRef.current.handleSubmit();\n if (hasUnroutedCrossFieldError(queryFormRef.current.state.errorMap)) {\n devWarn(\n \"use-abp-table:cross-field-error-no-path\",\n \"useAbpTable: 查询表单的跨字段校验错误没有挂 path,落在 errorMap.onDynamic 的空字符串桶里,\" +\n \"现有 FormErrors 只订阅 onSubmit 渲染不出来——给 .refine 的 issue 加一个具体字段的 path。\",\n );\n }\n }, []);\n\n // 重置后要立即重新发请求,所以收进 hook 而不是把 reset() 丢给调用方。\n // 后者只要忘了跟一句 handleSubmit(),字段看着回到默认值,请求参数却纹丝不动。\n const resetQuery = useCallback(() => {\n queryFormRef.current.reset();\n void submitQuery();\n }, [submitQuery]);\n\n // 只读出口,导出「全部匹配行」这类需求要拿当前已提交的完整请求参数。\n // 返回的是提交结果的快照而不是表单草稿,「表单是唯一取值源」的不变式不破。\n const paramsRef = useRef(params);\n paramsRef.current = params;\n const listStateRef = useRef(state.params);\n listStateRef.current = state.params;\n const getListParams = useCallback(\n () => ({ ...paramsRef.current, ...toAbpListParams(listStateRef.current) }),\n [],\n );\n\n const row: AbpTableRowConfig<TDto> = opts.row ?? {};\n const showView = row.view ?? (row.click === false && opts.onOpen !== undefined);\n const showEdit = row.edit ?? (src.can.update && opts.onOpen !== undefined);\n // source.delete 缺席时强制不显示删除项。自定义 source 把 can.delete 手误置成 true,\n // 也不会渲染出一个点了没反应的删除按钮。\n const showDelete = src.delete === undefined ? false : (row.delete ?? src.can.delete);\n\n // 窄化 src 的引用面并单独 memo,只随 can.update/can.delete/delete 变,\n // 免得每渲染新建的 source 字面量让 columns 跟着重算(同 deleteMutation 那处)。\n // `delete` 直接透传 `src.delete`,两边形状本就一致,不用再包一层。\n const rowActionsSrc = useMemo(\n () => ({\n can: { update: src.can.update, delete: src.can.delete },\n delete: src.delete,\n }),\n [src.can.update, src.can.delete, src.delete],\n );\n\n const finalColumns = useMemo<TableColumnDef<TDto>[]>(() => {\n if (\n !showView &&\n !showEdit &&\n !showDelete &&\n row.actions === undefined &&\n row.menu === undefined\n ) {\n return opts.columns;\n }\n return [\n ...opts.columns,\n {\n id: \"actions\",\n header: () => L(\"Table:Actions\"),\n enableSorting: false,\n // 同理:藏掉操作列等于让行操作入口消失,不该出现在 Columns 菜单里\n enableHiding: false,\n cell: ({ row: cellRow, table: rowTable }) =>\n // 显式实例化泛型组件(TS 4.7 instantiation expression)。不钉死的话 createElement 会让\n // RowActionsMenu 重新推断自己的 TDto,跟外层的撞车:onOpen 的回调参数在逆变位,\n // 两个推断路径不同的 TDto 在那里对不上,报 \"assignable to constraint 但不是 TDto 本身\"。\n createElement(RowActionsMenu<TDto>, {\n record: cellRow.original,\n table: rowTable,\n onOpen: opts.onOpen,\n source: rowActionsSrc,\n rowActions: row.actions,\n show: { view: showView, edit: showEdit, delete: showDelete },\n items: row.menu,\n }),\n },\n ];\n }, [\n opts.columns,\n opts.onOpen,\n row.actions,\n row.menu,\n showView,\n showEdit,\n showDelete,\n rowActionsSrc,\n L,\n ]);\n\n // 末页被删空后 totalCount 和 pageCount 都缩了,但没人钳制 pageIndex,用户会停在一个\n // 「暂无数据」的越界空页上。setter 在 useDataTableState、pageCount 在 source,只在本 hook\n // 汇合,钳制只能放这里。三个取数状态的门控缺一不可:isPending/isFetching 在途时 totalCount\n // 还是旧值,isError 时它退化成 0、pageCount 恒 1,不门控就会把用户从报错的第 N 页拽回第 1 页。\n const { pageIndex } = state.pagination;\n const { isPending, isFetching, isError } = src.listQuery;\n useEffect(() => {\n if (isPending || isFetching || isError) return;\n if (pageIndex >= src.pageCount) {\n state.onPaginationChange((p) => ({ ...p, pageIndex: Math.max(src.pageCount - 1, 0) }));\n }\n }, [isPending, isFetching, isError, pageIndex, src.pageCount, state.onPaginationChange]);\n\n const dt = useDataTable({\n state,\n columns: finalColumns,\n data: src.listQuery.data?.items ?? [],\n pageCount: src.pageCount,\n rowCount: src.totalCount,\n selectable: opts.selectable,\n getRowId: (r, i) => r.id ?? String(i),\n features: opts.features,\n tableOptions: opts.tableOptions,\n });\n\n const instanceWithoutComponents = {\n ...dt,\n source: src,\n queryForm,\n submitQuery,\n resetQuery,\n getListParams,\n rowConfig: opts.row,\n onOpen: opts.onOpen,\n searchEnabled: src.supportsFilter,\n // 已提交的筛选项数,不是表单草稿。驱动筛选钮上的\"还筛着\"圆点,面板收起后唯一的状态提示。\n activeQueryCount: Object.keys(safeParams).length,\n onExport: opts.onExport,\n };\n\n // 绑定成员挂载。五个成员的身份跨渲染稳定(见 useBoundComponents),接线值经 read() 读活。\n // Toolbar/BulkBar 只是透传 Fragment,真正的容器由 AbpTableView 在对应槽位包裹,\n // 扫描(这里)和摆放(AbpTableView)解耦。\n const bound = useBoundComponents({ self: instanceWithoutComponents }, (read) => {\n /** 标记组件,自身渲染 null,只把字段 children 交给 t.Table 的槽位扫描。\n * 筛选面板的真实渲染由 AbpTableView 编排,这里就地渲染不到那个位置。 */\n const QueryForm = (_p: { children: ReactNode }) => null;\n const Toolbar = (p: { children: ReactNode }) => createElement(Fragment, null, p.children);\n const BulkBar = (p: { children: ReactNode }) => createElement(Fragment, null, p.children);\n // 内建批量删除。删除 mutation 和列表失效都在 source 上,页面再写一遍就是把同一份样板\n // 抄进每个 CRUD 页。摆在 t.BulkBar 里用。\n const BulkDelete = () =>\n createElement(AbpBulkDeleteView<TDto>, {\n source: read().self.source,\n selectedRows: read().self.selectedRows,\n keepSelected: read().self.keepSelected,\n });\n const Table = (p: AbpTableViewOwnProps<TDto> & { children?: ReactNode }) => {\n // 槽位扫描靠引用相等识别自家绑定成员(组件身份在 useBoundComponents 内跨渲染不变,\n // child.type 的比较因此稳定)。未知的直接子元素只 devWarn 不渲染,\n // t.Table 不是通用容器,只认这三种。\n const slots: { query?: { children: ReactNode }; toolbar?: ReactNode; bulkBar?: ReactNode } =\n {};\n Children.forEach(p.children, (child) => {\n if (!isValidElement(child)) return;\n if (child.type === QueryForm) slots.query = child.props as { children: ReactNode };\n else if (child.type === Toolbar) slots.toolbar = child;\n else if (child.type === BulkBar) slots.bulkBar = child;\n else\n devWarn(\n \"abp-table:unknown-child\",\n \"useAbpTable: t.Table 只认 t.QueryForm/t.Toolbar/t.BulkBar 直接子元素,其余直接子元素会被忽略。\",\n );\n });\n const { empty, skeletonRows, rowProps, pageSizes, searchPlaceholder } = p;\n return createElement(AbpTableView<TDto>, {\n t: read().self,\n slots,\n empty,\n skeletonRows,\n rowProps,\n pageSizes,\n searchPlaceholder,\n });\n };\n return { Table, QueryForm, Toolbar, BulkBar, BulkDelete };\n });\n\n return { ...instanceWithoutComponents, ...bound };\n}\n\n/** `useAbpTable` 的完整返回类型:`DataTableInstance` 全成员 + 数据侧接线 + 五个绑定成员。\n * 派生自 hook 自身的返回类型而不是手写,省得手拼 TanStack Form 那十几个校验器泛型形参。\n *\n * `abp-table.tsx` 的 `AbpTableViewInstance` 不是它的扩展,是更窄的另一个类型,不含绑定成员。\n * 那些成员正是靠 `AbpTableView` 渲染出来的,反过来要求它消费的实例已经带着,会是循环依赖。 */\nexport type AbpTableInstance<\n TDto extends { id?: string },\n TQueryDefaults extends Record<string, unknown> = Record<string, never>,\n> = ReturnType<typeof useAbpTable<TDto, unknown, unknown, Record<string, unknown>, TQueryDefaults>>;\n",
33
+ "type": "registry:component",
34
+ "target": "components/abp/table/use-abp-table.ts"
35
+ },
36
+ {
37
+ "path": "ui/blocks/abp-table/abp-table.tsx",
38
+ "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { Plus } from \"lucide-react\";\nimport { type ReactNode, useId, useState } from \"react\";\nimport type { AbpTableSource } from \"@/components/abp/crud/abp-table-source\";\nimport { AbpBulkBarView } from \"@/components/abp/table/abp-bulk-bar\";\nimport {\n AbpQueryPanelToggle,\n AbpQueryPanelView,\n flattenFields,\n} from \"@/components/abp/table/abp-query-form\";\nimport type { AbpTableRowConfig } from \"@/components/abp/table/use-abp-table\";\nimport { DataTable } from \"@/components/data-table/data-table\";\nimport { DataTableColumnsMenu } from \"@/components/data-table/data-table-columns-menu\";\nimport { DataTableToolbar } from \"@/components/data-table/data-table-toolbar\";\nimport type { DataTableInstance } from \"@/components/data-table/use-data-table\";\nimport { FormErrorSummary } from \"@/components/form/form-error-summary\";\nimport { Button } from \"@/components/ui/button\";\n\n/** `t.Table` 的表现层 props:不受管的 `DataTable` 透传项,缺省全部走内建默认。 */\nexport interface AbpTableViewOwnProps<TDto> {\n empty?: ReactNode;\n skeletonRows?: number | \"pageSize\";\n rowProps?: (row: TDto) => { className?: string };\n pageSizes?: number[];\n searchPlaceholder?: string;\n}\n\n/** `AbpTableView` 消费的实例面:`useDataTable` 全量 + `useAbpTable` 的数据侧接线。\n * 不含五个绑定成员,那些正是靠本组件渲染出来的,反过来要求实例已经带着会是循环依赖。 */\nexport interface AbpTableViewInstance<TDto extends { id?: string }>\n extends DataTableInstance<TDto> {\n source: AbpTableSource<TDto>;\n rowConfig?: AbpTableRowConfig<TDto>;\n onOpen?: (mode: \"create\" | \"edit\" | \"view\", record?: TDto) => void;\n searchEnabled: boolean;\n submitQuery: () => Promise<void>;\n resetQuery: () => void;\n /** 当前已提交的筛选项数,驱动筛选钮上的\"还筛着\"圆点。 */\n activeQueryCount: number;\n onExport?: () => void;\n}\n\nexport interface AbpTableViewSlots {\n /** `t.QueryForm` 捕获的 props:高级筛选的字段 children,整体渲染进筛选面板\n * (顶部条右区的筛选钮开合它)。缺席则不出现筛选钮。 */\n query?: { children: ReactNode };\n /** `t.Toolbar` 的内容,追加在内建「新建」按钮右侧。 */\n toolbar?: ReactNode;\n /** `t.BulkBar` 的内容,经顶部条批量态渲染;仅有选中行时出现。 */\n bulkBar?: ReactNode;\n}\n\n// 必须导出。`useAbpTable` 的 `Table` 绑定成员把 `AbpTableView<TDto>` 的元素类型带进了自己的\n// 推断返回类型,声明发出时要能给这个类型具名(tsconfig.base.json 开着 declaration + composite),\n// 不导出会报 TS4058「cannot be named」。\nexport interface AbpTableViewProps<TDto extends { id?: string }>\n extends AbpTableViewOwnProps<TDto> {\n t: AbpTableViewInstance<TDto>;\n slots: AbpTableViewSlots;\n}\n\n/** `t.Table` 的渲染实现:在单张卡片里编排顶部条(搜索框 ⇄ 批量态 + 动作组)、\n * 高级筛选面板和表格。不公开导出,调用方只应经 `t.Table` 用它。 */\nexport function AbpTableView<TDto extends { id?: string }>(props: AbpTableViewProps<TDto>) {\n const L = useLocalization();\n const { t, slots, empty, skeletonRows, rowProps, pageSizes, searchPlaceholder } = props;\n\n const [expanded, setExpanded] = useState(false);\n const reactId = useId();\n const panelId = `${reactId}-query-panel`;\n\n const queryFields = slots.query ? flattenFields(slots.query.children) : [];\n const hasQuery = queryFields.length > 0;\n\n const toolbar = (\n <DataTableToolbar\n table={t}\n // 面板展开时让出搜索框:面板里是更精确的字段筛选,同屏再摆一个模糊搜索是重复入口。\n search={t.searchEnabled && !expanded}\n searchPlaceholder={searchPlaceholder}\n bulk={\n <AbpBulkBarView\n selectedCount={t.selectedRows.length}\n onClear={() => t.state.clearSelection()}\n >\n {slots.bulkBar}\n </AbpBulkBarView>\n }\n onRefresh={t.source.listQuery.refetch ? () => void t.source.listQuery.refetch?.() : undefined}\n refreshing={t.source.listQuery.isFetching}\n onExport={t.onExport}\n utilityLeading={\n hasQuery ? (\n <AbpQueryPanelToggle\n expanded={expanded}\n onToggle={() => setExpanded((value) => !value)}\n panelId={panelId}\n activeCount={t.activeQueryCount}\n />\n ) : undefined\n }\n actions={\n <>\n {t.source.can.create && t.onOpen && (\n <Button type=\"button\" size=\"sm\" onClick={() => t.onOpen?.(\"create\")}>\n <Plus />\n {L(\"Crud:Create\")}\n </Button>\n )}\n {slots.toolbar}\n </>\n }\n >\n <DataTableColumnsMenu table={t} />\n </DataTableToolbar>\n );\n\n const renderQueryPanel = (last: boolean) =>\n hasQuery && expanded ? (\n <AbpQueryPanelView\n fields={queryFields}\n onSubmit={() => void t.submitQuery()}\n onReset={() => t.resetQuery()}\n panelId={panelId}\n last={last}\n />\n ) : null;\n\n if (t.source.listQuery.isError) {\n // 查询区和搜索框要留在报错态里。筛选值和搜索词是用户自己输入的,也最容易触发后端 400,\n // 一起吞掉就等于把用户锁死在自己的输入里,只能整页刷新。顶部条跟非报错态共用同一个\n // `toolbar`,报错态没有表格托底,包一层卡片容器撑住外观。\n return (\n <div className=\"flex flex-col gap-3\">\n <div className=\"overflow-hidden rounded-md border bg-card\">\n {toolbar}\n {renderQueryPanel(true)}\n </div>\n <FormErrorSummary errors={[L(\"Crud:OperationFailed\")]} />\n {t.source.listQuery.refetch && (\n <Button\n variant=\"outline\"\n size=\"sm\"\n className=\"self-start\"\n onClick={() => t.source.listQuery.refetch?.()}\n >\n {L(\"Crud:Retry\")}\n </Button>\n )}\n </div>\n );\n }\n\n // 默认 onOpen 存在时点行开详情;false 关闭;也可给自定义回调。\n const click = t.rowConfig?.click;\n const rowClickHandler =\n click === false\n ? undefined\n : (click ?? (t.onOpen ? (row: TDto) => t.onOpen?.(\"view\", row) : undefined));\n\n return (\n <DataTable\n table={t}\n loading={t.source.listQuery.isPending}\n fetching={t.source.listQuery.isFetching && !t.source.listQuery.isPending}\n empty={empty}\n skeletonRows={skeletonRows}\n rowProps={rowProps}\n pageSizes={pageSizes}\n onRowClick={rowClickHandler}\n >\n {toolbar}\n {renderQueryPanel(false)}\n </DataTable>\n );\n}\n",
39
+ "type": "registry:component",
40
+ "target": "components/abp/table/abp-table.tsx"
41
+ },
42
+ {
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",
45
+ "type": "registry:component",
46
+ "target": "components/abp/table/abp-query-form.tsx"
47
+ },
48
+ {
49
+ "path": "ui/blocks/abp-table/abp-bulk-bar.tsx",
50
+ "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport type { ReactNode } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\n\nexport interface AbpBulkBarViewProps {\n selectedCount: number;\n onClear: () => void;\n children?: ReactNode;\n}\n\n/** 装配层内部构件:t.BulkBar 内容的内联容器:计数 + 插槽内容 + 清除,经 DataTableToolbar 的 bulk 槽渲染进顶部条左区。不公开导出。 */\nexport function AbpBulkBarView(props: AbpBulkBarViewProps) {\n const L = useLocalization();\n // 数字高亮但不拆词条:按数字在【译文】里的实际位置切分,中英语序自动各就各位\n // (\"2 selected\" / \"已选 2 项\"),避免为了着色把整句拆成两个键。\n const countText = L(\"Table:NSelected\", props.selectedCount);\n const countNum = String(props.selectedCount);\n const numAt = countText.indexOf(countNum);\n const count = (\n <span className=\"text-foreground\">\n {numAt === -1 ? (\n countText\n ) : (\n <>\n {countText.slice(0, numAt)}\n <span className=\"font-semibold text-primary\">{countNum}</span>\n {countText.slice(numAt + countNum.length)}\n </>\n )}\n </span>\n );\n\n return (\n <div className=\"flex min-h-8 flex-wrap items-center gap-3 text-sm\">\n {count}\n <Separator orientation=\"vertical\" className=\"!h-4\" />\n {props.children}\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={props.onClear}>\n {L(\"Table:Clear\")}\n </Button>\n </div>\n );\n}\n",
51
+ "type": "registry:component",
52
+ "target": "components/abp/table/abp-bulk-bar.tsx"
53
+ },
54
+ {
55
+ "path": "ui/blocks/abp-table/abp-bulk-delete.tsx",
56
+ "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { Trash2 } from \"lucide-react\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport type { AbpTableSource } from \"@/components/abp/crud/abp-table-source\";\nimport { devWarn } from \"@/components/data-table/dev-warn\";\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n AlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\";\nimport { Button } from \"@/components/ui/button\";\n\nexport interface AbpBulkDeleteViewProps<TDto extends { id?: string }> {\n source: AbpTableSource<TDto>;\n selectedRows: TDto[];\n keepSelected: (ids: string[]) => void;\n}\n\n/**\n * 装配组件:`t.BulkDelete` 绑定成员的渲染实现:删除按钮 + 二次确认 + 整批成败汇总。\n * 模块级、不公开导出,调用方只应经 `t.BulkDelete` 使用。\n *\n * 删了几条、哪几条失败都由 `source.delete.many` 汇总回来,这里只负责把三种结局翻成一条 toast,\n * 并把失败的 id 交还给选择态。成功的行随列表刷新离场,失败的行留在勾选里供重试。\n */\nexport function AbpBulkDeleteView<TDto extends { id?: string }>(\n props: AbpBulkDeleteViewProps<TDto>,\n) {\n const L = useLocalization();\n const [open, setOpen] = useState(false);\n const many = props.source.delete?.many;\n\n if (!props.source.can.delete) return null;\n if (many === undefined) {\n devWarn(\n \"abp-table:bulk-delete-unsupported\",\n \"useAbpTable: t.BulkDelete 需要数据源提供 delete.many,当前 source 没有,按钮不渲染。\",\n );\n return null;\n }\n\n return (\n <AlertDialog open={open} onOpenChange={setOpen}>\n <AlertDialogTrigger asChild>\n <Button variant=\"ghost\" size=\"sm\" className=\"text-destructive hover:text-destructive\">\n <Trash2 />\n {L(\"Crud:Delete\")}\n </Button>\n </AlertDialogTrigger>\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>{L(\"Crud:DeleteConfirmTitle\")}</AlertDialogTitle>\n <AlertDialogDescription>{L(\"Crud:DeleteConfirmBody\")}</AlertDialogDescription>\n </AlertDialogHeader>\n <AlertDialogFooter>\n <AlertDialogCancel>{L(\"Form:Cancel\")}</AlertDialogCancel>\n <AlertDialogAction\n onClick={async () => {\n // 无 id 的行进不了删除端点,先剔除,否则会被算进「成功」的分母,\n // 让一次什么都没删的操作报成功。\n const ids = props.selectedRows\n .map((row) => row.id)\n .filter((id): id is string => id !== undefined);\n try {\n const { failed } = await many(ids);\n if (failed.length === 0) toast.success(L(\"Crud:Deleted\"));\n else if (failed.length === ids.length) toast.error(L(\"Crud:OperationFailed\"));\n else\n toast.warning(\n L(\"Crud:BulkDeletePartialFailure\", ids.length - failed.length, failed.length),\n );\n props.keepSelected(failed);\n } finally {\n setOpen(false);\n }\n }}\n >\n {L(\"Crud:Delete\")}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n );\n}\n",
57
+ "type": "registry:component",
58
+ "target": "components/abp/table/abp-bulk-delete.tsx"
59
+ },
60
+ {
61
+ "path": "ui/blocks/abp-table/query-date-range.tsx",
62
+ "content": "import { lazy, type ReactNode, Suspense, useId } from \"react\";\nimport { formatIso, ISO_DATE, parseIso } from \"@/components/date-picker/date-io\";\nimport { Field, FieldLabel } from \"@/components/ui/field\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\n\nconst DateRangePicker = lazy(() =>\n import(\"@/components/date-picker/date-range-picker\").then((m) => ({\n default: m.DateRangePicker,\n })),\n);\n\ninterface RangeFieldApi {\n state: { value: string };\n handleChange: (value: string) => void;\n}\n\n/** 查询表单的最小结构面:能按名订阅/写回字符串字段即可,不绑定 TanStack Form 完整泛型\n * (参照 form-hook 的 ServerErrorForm)。返回类型必须是 `ReactNode | Promise<ReactNode>`:\n * React 19 类型下 TanStack Form 真实的 `Field` 返回这个联合类型(Suspense 场景),窄化成单纯\n * `ReactNode` 会让真实 `useAppForm` 实例在 `form` prop 处报 TS2322。 */\nexport interface QueryDateRangeForm<TName extends string> {\n Field: (props: {\n name: TName;\n children: (field: RangeFieldApi) => ReactNode;\n }) => ReactNode | Promise<ReactNode>;\n}\n\nexport interface QueryDateRangeProps<TName extends string> {\n form: QueryDateRangeForm<TName>;\n from: TName;\n to: TName;\n label: string;\n disabled?: boolean;\n}\n\n/** 查询区日期区间:一个 DateRangePicker 同时读写两个扁平查询参数(Min/Max 仍与后端 DTO\n * 一一对应),取代旧的双字段拼接写法。 */\nexport function QueryDateRange<TName extends string>(props: QueryDateRangeProps<TName>) {\n const id = useId();\n const F = props.form.Field;\n return (\n <F name={props.from}>\n {(fromField) => (\n <F name={props.to}>\n {(toField) => (\n <Field>\n <FieldLabel htmlFor={id}>{props.label}</FieldLabel>\n <Suspense fallback={<Skeleton className=\"h-9 w-full\" />}>\n <DateRangePicker\n id={id}\n disabled={props.disabled}\n value={{\n from: parseIso(fromField.state.value, ISO_DATE),\n to: parseIso(toField.state.value, ISO_DATE),\n }}\n onChange={(range) => {\n fromField.handleChange(formatIso(range?.from, ISO_DATE));\n toField.handleChange(formatIso(range?.to, ISO_DATE));\n }}\n />\n </Suspense>\n </Field>\n )}\n </F>\n )}\n </F>\n );\n}\n",
63
+ "type": "registry:component",
64
+ "target": "components/abp/table/query-date-range.tsx"
65
+ },
66
+ {
67
+ "path": "ui/blocks/abp-table/row-actions-menu.tsx",
68
+ "content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { Eye, MoreHorizontal, Pencil, Trash2 } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport { useState } from \"react\";\nimport type { CellTableInstance } from \"@/components/data-table/table-core\";\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\ninterface RowActionsMenuSource {\n can: { update: boolean; delete: boolean };\n /** 只读数据源(无删除能力)时这个键不存在;删除项即使 `show.delete` 为真也不会渲染。 */\n delete?: { mutate: (id: string) => void };\n}\n\n/** `renderRowActions` 收到的内置行操作零件。公理:三个成员一律「元素 | undefined」,undefined\n * 当且仅当该能力未启用(`rowMenu.view`/`edit`/`delete` 判定为 false,或无权限/无 `onOpen`)。\n * 所以 `builtins.x ?? 兜底` 永远可用,不会有「渲染成 null 的元素」把兜底短路掉。 */\nexport interface RowActionBuiltins {\n view?: ReactNode;\n edit?: ReactNode;\n delete?: ReactNode;\n}\n\nexport interface RowActionsMenuProps<TDto extends { id?: string }> {\n record: TDto;\n table: CellTableInstance<TDto>;\n onOpen?: (mode: \"create\" | \"edit\" | \"view\", record?: TDto) => void;\n source: RowActionsMenuSource;\n rowActions?: (row: TDto, table: CellTableInstance<TDto>) => ReactNode;\n show: { view: boolean; edit: boolean; delete: boolean };\n items?: (row: TDto, table: CellTableInstance<TDto>) => ReactNode;\n render?: (row: TDto, builtins: RowActionBuiltins, table: CellTableInstance<TDto>) => ReactNode;\n}\n\n/** 行操作合并为 `···` 菜单;删除确认用受控 AlertDialog,脱出 DropdownMenu 之外。\n * 二者嵌套时 DropdownMenu 卸载会连带把内嵌 AlertDialogContent 一并卸掉。 */\nexport function RowActionsMenu<TDto extends { id?: string }>(props: RowActionsMenuProps<TDto>) {\n const L = useLocalization();\n const { record, table, onOpen, source, rowActions, show } = props;\n const [confirmOpen, setConfirmOpen] = useState(false);\n\n const viewItem = show.view ? (\n <DropdownMenuItem key=\"view\" onClick={() => onOpen?.(\"view\", record)}>\n <Eye />\n {L(\"Table:View\")}\n </DropdownMenuItem>\n ) : undefined;\n const editItem = show.edit ? (\n <DropdownMenuItem key=\"edit\" onClick={() => onOpen?.(\"edit\", record)}>\n <Pencil />\n {L(\"Form:Edit\")}\n </DropdownMenuItem>\n ) : undefined;\n // 无删除能力(source.delete undefined)时不渲染删除项。即使 show.delete 因调用方失误传了\n // true,也不会露出一个点了什么都不发生的按钮。\n const deleteItem =\n show.delete && source.delete ? (\n <DropdownMenuItem\n key=\"delete\"\n variant=\"destructive\"\n onSelect={(e) => {\n e.preventDefault();\n setConfirmOpen(true);\n }}\n >\n <Trash2 />\n {L(\"Crud:Delete\")}\n </DropdownMenuItem>\n ) : undefined;\n\n const extraItems = props.render ? undefined : props.items?.(record, table);\n const hasMenu = Boolean(viewItem || editItem || deleteItem || extraItems);\n\n const menuContent = props.render ? (\n props.render(record, { view: viewItem, edit: editItem, delete: deleteItem }, table)\n ) : hasMenu ? (\n <DropdownMenu modal={false}>\n <DropdownMenuTrigger asChild>\n <Button variant=\"ghost\" size=\"icon\" className=\"size-8\" aria-label={L(\"Table:Actions\")}>\n <MoreHorizontal />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\">\n {viewItem}\n {editItem}\n {deleteItem}\n {extraItems}\n </DropdownMenuContent>\n </DropdownMenu>\n ) : null;\n\n return (\n // biome-ignore lint/a11y/noStaticElementInteractions: 仅拦截冒泡以防触发行点击详情,非交互元素\n <div\n className=\"flex items-center gap-1\"\n onClick={(e) => e.stopPropagation()}\n onKeyDown={(e) => e.stopPropagation()}\n >\n {rowActions?.(record, table)}\n {menuContent && (\n <span className=\"opacity-0 focus-within:opacity-100 group-hover:opacity-100\">\n {menuContent}\n </span>\n )}\n <AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>{L(\"Crud:DeleteConfirmTitle\")}</AlertDialogTitle>\n <AlertDialogDescription>{L(\"Crud:DeleteConfirmBody\")}</AlertDialogDescription>\n </AlertDialogHeader>\n <AlertDialogFooter>\n <AlertDialogCancel>{L(\"Form:Cancel\")}</AlertDialogCancel>\n <AlertDialogAction onClick={() => record.id && source.delete?.mutate(record.id)}>\n {L(\"Crud:Delete\")}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n </div>\n );\n}\n",
69
+ "type": "registry:component",
70
+ "target": "components/abp/table/row-actions-menu.tsx"
71
+ },
72
+ {
73
+ "path": "ui/blocks/abp-table/status-badge.tsx",
74
+ "content": "import type { ReactNode } from \"react\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { cn } from \"@/lib/utils\";\n\nconst STATUS_CLASSES = {\n success: \"bg-status-success/15 text-status-success\",\n info: \"bg-status-info/15 text-status-info\",\n warning: \"bg-status-warning/15 text-status-warning\",\n error: \"bg-status-error/15 text-status-error\",\n neutral: \"bg-status-neutral/15 text-status-neutral\",\n} as const;\n\nexport type Status = keyof typeof STATUS_CLASSES;\n\n/**\n * 语义状态徽章:status-* 色点 + 同色文字 + 半透明底的胶囊。行/记录的状态一律用它,\n * 不用 primary/destructive 实心 Badge:accent 只表示「操作」,实心红只表示「危险动作」。\n */\nexport function StatusBadge(props: { status: Status; className?: string; children: ReactNode }) {\n return (\n <Badge\n variant=\"outline\"\n className={cn(\n \"gap-1.5 rounded-full border-transparent font-medium\",\n STATUS_CLASSES[props.status],\n props.className,\n )}\n >\n <span aria-hidden className=\"size-1.5 rounded-full bg-current\" />\n {props.children}\n </Badge>\n );\n}\n",
75
+ "type": "registry:component",
76
+ "target": "components/abp/table/status-badge.tsx"
77
+ }
78
+ ],
79
+ "type": "registry:block"
80
+ }