Files
seclusion/plop/templates/web/table.hbs
charilezhou 3119460f13 feat(plop): 优化生成器支持 Prisma 关联关系
- 支持一对多/多对一关系定义并生成到 Prisma schema
- 简化流程:查询关联配置根据关系自动预填
- 修复 Handlebars 模板 HTML 转义导致的乱码问题
- 修复 controller 模板缺少 Prisma 导入的问题
- 新增页面模板 (page.hbs) 生成前端页面
- 添加 FindAllParams/PaginationQueryDto 索引签名修复类型兼容

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 17:30:18 +08:00

421 lines
12 KiB
Handlebars

'use client';
import type { {{pascalCase name}}Response } from '@seclusion/shared';
import { formatDate } from '@seclusion/shared';
import type { ColumnDef } from '@tanstack/react-table';
import { MoreHorizontal, Pencil, Plus{{#if softDelete}}, RotateCcw{{/if}}, Trash2 } from 'lucide-react';
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import { {{pascalCase name}}CreateDialog } from './{{pascalCase name}}CreateDialog';
import { {{pascalCase name}}EditDialog } from './{{pascalCase name}}EditDialog';
import {
DataTable,
DataTableColumnHeader,
type PaginationState,
type SortingParams,
} from '@/components/shared/DataTable';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
{{#if softDelete}}
import { Badge } from '@/components/ui/badge';
{{/if}}
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { PAGINATION } from '@/config/constants';
import {
use{{pascalCase pluralName}},
{{#if softDelete}}
useDeleted{{pascalCase pluralName}},
{{/if}}
useDelete{{pascalCase name}},
{{#if softDelete}}
useRestore{{pascalCase name}},
{{/if}}
} from '@/hooks/use{{pascalCase pluralName}}';
interface {{pascalCase name}}ActionsProps {
{{camelCase name}}: {{pascalCase name}}Response;
isDeleted?: boolean;
onDelete: (id: string) => void;
{{#if softDelete}}
onRestore: (id: string) => void;
{{/if}}
onEdit: ({{camelCase name}}: {{pascalCase name}}Response) => void;
}
function {{pascalCase name}}Actions({
{{camelCase name}},
isDeleted,
onDelete,
{{#if softDelete}}
onRestore,
{{/if}}
onEdit,
}: {{pascalCase name}}ActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">打开菜单</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>操作</DropdownMenuLabel>
<DropdownMenuSeparator />
{{#if softDelete}}
{isDeleted ? (
<DropdownMenuItem onClick={() => onRestore({{camelCase name}}.id)}>
<RotateCcw className="mr-2 h-4 w-4" />
恢复
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem onClick={() => onEdit({{camelCase name}})}>
<Pencil className="mr-2 h-4 w-4" />
编辑
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete({{camelCase name}}.id)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
删除
</DropdownMenuItem>
</>
)}
{{else}}
<DropdownMenuItem onClick={() => onEdit({{camelCase name}})}>
<Pencil className="mr-2 h-4 w-4" />
编辑
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete({{camelCase name}}.id)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
删除
</DropdownMenuItem>
{{/if}}
</DropdownMenuContent>
</DropdownMenu>
);
}
export function {{pascalCase pluralName}}Table() {
// 分页状态
const [pagination, setPagination] = useState<PaginationState>({
page: PAGINATION.DEFAULT_PAGE,
pageSize: PAGINATION.DEFAULT_PAGE_SIZE,
});
// 排序状态
const [sorting, setSorting] = useState<SortingParams>({});
// 对话框状态
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [{{camelCase name}}ToDelete, set{{pascalCase name}}ToDelete] = useState<string | null>(null);
{{#if softDelete}}
const [showDeleted, setShowDeleted] = useState(false);
{{/if}}
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [{{camelCase name}}ToEdit, set{{pascalCase name}}ToEdit] = useState<{{pascalCase name}}Response | null>(
null,
);
// 查询
const {
data: {{camelCase pluralName}}Data,
isLoading: isLoading{{pascalCase pluralName}},
refetch: refetch{{pascalCase pluralName}},
} = use{{pascalCase pluralName}}({
page: pagination.page,
pageSize: pagination.pageSize,
...sorting,
});
{{#if softDelete}}
const {
data: deleted{{pascalCase pluralName}}Data,
isLoading: isLoadingDeleted,
refetch: refetchDeleted,
} = useDeleted{{pascalCase pluralName}}({
page: pagination.page,
pageSize: pagination.pageSize,
...sorting,
});
{{/if}}
// 变更
const delete{{pascalCase name}} = useDelete{{pascalCase name}}();
{{#if softDelete}}
const restore{{pascalCase name}} = useRestore{{pascalCase name}}();
{{/if}}
const handleDelete = useCallback((id: string) => {
set{{pascalCase name}}ToDelete(id);
setDeleteDialogOpen(true);
}, []);
const confirmDelete = useCallback(async () => {
if (!{{camelCase name}}ToDelete) return;
try {
await delete{{pascalCase name}}.mutateAsync({{camelCase name}}ToDelete);
toast.success('{{chineseName}}已删除');
} catch (error) {
toast.error(error instanceof Error ? error.message : '删除失败');
} finally {
setDeleteDialogOpen(false);
set{{pascalCase name}}ToDelete(null);
}
}, [{{camelCase name}}ToDelete, delete{{pascalCase name}}]);
{{#if softDelete}}
const handleRestore = useCallback(
async (id: string) => {
try {
await restore{{pascalCase name}}.mutateAsync(id);
toast.success('{{chineseName}}已恢复');
} catch (error) {
toast.error(error instanceof Error ? error.message : '恢复失败');
}
},
[restore{{pascalCase name}}],
);
{{/if}}
const handleEdit = useCallback(({{camelCase name}}: {{pascalCase name}}Response) => {
set{{pascalCase name}}ToEdit({{camelCase name}});
setEditDialogOpen(true);
}, []);
// 分页变化处理
const handlePaginationChange = useCallback((newPagination: PaginationState) => {
setPagination(newPagination);
}, []);
// 排序变化处理
const handleSortingChange = useCallback((newSorting: SortingParams) => {
setSorting(newSorting);
setPagination((prev) => ({ ...prev, page: 1 }));
}, []);
// 列定义
const columns: ColumnDef<{{pascalCase name}}Response>[] = [
{
accessorKey: 'id',
header: 'ID',
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">
{row.original.id.slice(0, 8)}...
</span>
),
},
{{#each tableColumns}}
{
accessorKey: '{{name}}',
header: ({ column }) => (
<DataTableColumnHeader
title="{{label}}"
sortKey={column.id}
sorting={sorting}
onSortingChange={handleSortingChange}
/>
),
{{#if (cellRenderer this)}}
cell: ({ row }) => {{{cellRenderer this}}},
{{/if}}
},
{{/each}}
{
accessorKey: 'createdAt',
header: ({ column }) => (
<DataTableColumnHeader
title="创建时间"
sortKey={column.id}
sorting={sorting}
onSortingChange={handleSortingChange}
/>
),
cell: ({ row }) =>
formatDate(new Date(row.original.createdAt), 'YYYY-MM-DD HH:mm'),
},
{{#if softDelete}}
...(showDeleted
? [
{
accessorKey: 'deletedAt',
header: '删除时间',
cell: ({ row }: { row: { original: {{pascalCase name}}Response } }) =>
row.original.deletedAt
? formatDate(new Date(row.original.deletedAt), 'YYYY-MM-DD HH:mm')
: '-',
} as ColumnDef<{{pascalCase name}}Response>,
]
: []),
{{/if}}
{
id: 'actions',
header: '操作',
cell: ({ row }) => (
<{{pascalCase name}}Actions
{{camelCase name}}={row.original}
{{#if softDelete}}
isDeleted={showDeleted}
{{/if}}
onDelete={handleDelete}
{{#if softDelete}}
onRestore={handleRestore}
{{/if}}
onEdit={handleEdit}
/>
),
},
];
{{#if softDelete}}
const data = showDeleted ? deleted{{pascalCase pluralName}}Data : {{camelCase pluralName}}Data;
const isLoading = showDeleted ? isLoadingDeleted : isLoading{{pascalCase pluralName}};
{{else}}
const data = {{camelCase pluralName}}Data;
const isLoading = isLoading{{pascalCase pluralName}};
{{/if}}
return (
<div className="space-y-4">
{/* 工具栏 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{{#if softDelete}}
<Button
variant={!showDeleted ? 'default' : 'outline'}
size="sm"
onClick={() => {
setShowDeleted(false);
setPagination((prev) => ({ ...prev, page: 1 }));
}}
>
{{chineseName}}列表
{ {{camelCase pluralName}}Data && (
<Badge variant="secondary" className="ml-2">
{ {{camelCase pluralName}}Data.total}
</Badge>
)}
</Button>
<Button
variant={showDeleted ? 'default' : 'outline'}
size="sm"
onClick={() => {
setShowDeleted(true);
setPagination((prev) => ({ ...prev, page: 1 }));
}}
>
已删除
{deleted{{pascalCase pluralName}}Data && (
<Badge variant="secondary" className="ml-2">
{deleted{{pascalCase pluralName}}Data.total}
</Badge>
)}
</Button>
{{/if}}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
{{#if softDelete}}
showDeleted ? refetchDeleted() : refetch{{pascalCase pluralName}}()
{{else}}
refetch{{pascalCase pluralName}}()
{{/if}}
}
>
刷新
</Button>
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
新建{{chineseName}}
</Button>
</div>
</div>
{/* 数据表格 */}
<DataTable
columns={columns}
data={data?.items ?? []}
pagination={pagination}
paginationInfo={
data ? { total: data.total, totalPages: data.totalPages } : undefined
}
onPaginationChange={handlePaginationChange}
manualPagination
sorting={sorting}
onSortingChange={handleSortingChange}
manualSorting
isLoading={isLoading}
emptyMessage={
{{#if softDelete}}
showDeleted ? '暂无已删除{{chineseName}}' : '暂无{{chineseName}}'
{{else}}
'暂无{{chineseName}}'
{{/if}}
}
/>
{/* 删除确认弹窗 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除</AlertDialogTitle>
<AlertDialogDescription>
确定要删除该{{chineseName}}吗?{{#if softDelete}}删除后可在「已删除」列表中恢复。{{/if}}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 创建弹窗 */}
<{{pascalCase name}}CreateDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
/>
{/* 编辑弹窗 */}
<{{pascalCase name}}EditDialog
{{camelCase name}}={ {{camelCase name}}ToEdit}
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
/>
</div>
);
}