表单CRUD

This commit is contained in:
dengqichen 2025-10-24 13:10:02 +08:00
parent 951fcbfebb
commit d22285bc95
12 changed files with 1959 additions and 0 deletions

View File

@ -0,0 +1,199 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@ -0,0 +1,46 @@
import request from '@/utils/request';
import type { Page } from '@/types/base';
import type {
FormCategoryQuery,
FormCategoryResponse,
FormCategoryRequest,
} from './types';
const BASE_URL = '/api/v1/forms/categories';
/**
*
*/
export const getCategories = (params?: FormCategoryQuery) =>
request.get<Page<FormCategoryResponse>>(`${BASE_URL}/page`, { params });
/**
*
*/
export const getCategoryById = (id: number) =>
request.get<FormCategoryResponse>(`${BASE_URL}/${id}`);
/**
*
*/
export const getEnabledCategories = () =>
request.get<FormCategoryResponse[]>(`${BASE_URL}/enabled`);
/**
*
*/
export const createCategory = (data: FormCategoryRequest) =>
request.post<FormCategoryResponse>(BASE_URL, data);
/**
*
*/
export const updateCategory = (id: number, data: FormCategoryRequest) =>
request.put<FormCategoryResponse>(`${BASE_URL}/${id}`, data);
/**
*
*/
export const deleteCategory = (id: number) =>
request.delete(`${BASE_URL}/${id}`);

View File

@ -0,0 +1,40 @@
import { BaseQuery } from '@/types/base';
/**
*
*/
export interface FormCategoryQuery extends BaseQuery {
name?: string;
code?: string;
enabled?: boolean;
}
/**
*
*/
export interface FormCategoryResponse {
id: number;
name: string;
code: string;
description?: string;
icon?: string;
sort: number;
enabled: boolean;
createBy?: string;
createTime?: string;
updateBy?: string;
updateTime?: string;
}
/**
* /
*/
export interface FormCategoryRequest {
name: string;
code: string;
description?: string;
icon?: string;
sort?: number;
enabled?: boolean;
}

View File

@ -0,0 +1,150 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { FormRenderer } from '@/components/FormDesigner';
import { ArrowLeft, Loader2 } from 'lucide-react';
import { getFormDataById } from './service';
import type { FormDataResponse, FormDataStatus, FormDataBusinessType } from './types';
/**
*
*/
const FormDataDetail: React.FC = () => {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<FormDataResponse | null>(null);
// 加载数据
useEffect(() => {
if (id) {
loadData(Number(id));
}
}, [id]);
const loadData = async (dataId: number) => {
setLoading(true);
try {
const result = await getFormDataById(dataId);
setData(result);
} catch (error) {
console.error('加载表单数据失败:', error);
} finally {
setLoading(false);
}
};
// 返回列表
const handleBack = () => {
navigate('/form/data');
};
// 状态徽章
const getStatusBadge = (status: FormDataStatus) => {
const statusMap: Record<FormDataStatus, { variant: 'default' | 'secondary' | 'destructive' | 'success' | 'outline'; text: string }> = {
DRAFT: { variant: 'outline', text: '草稿' },
SUBMITTED: { variant: 'success', text: '已提交' },
COMPLETED: { variant: 'default', text: '已完成' },
};
const statusInfo = statusMap[status];
return <Badge variant={statusInfo.variant}>{statusInfo.text}</Badge>;
};
// 业务类型徽章
const getBusinessTypeBadge = (type: FormDataBusinessType) => {
const typeMap: Record<FormDataBusinessType, { variant: 'default' | 'secondary' | 'outline'; text: string }> = {
STANDALONE: { variant: 'outline', text: '独立' },
WORKFLOW: { variant: 'default', text: '工作流' },
ORDER: { variant: 'secondary', text: '订单' },
};
const typeInfo = typeMap[type];
return <Badge variant={typeInfo.variant}>{typeInfo.text}</Badge>;
};
// 描述项组件
const DescriptionItem: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
<div className="flex py-3 border-b last:border-b-0">
<div className="w-32 text-muted-foreground flex-shrink-0">{label}</div>
<div className="flex-1 font-medium">{value}</div>
</div>
);
if (loading) {
return (
<div className="p-6">
<Card>
<CardContent className="py-12">
<div className="flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin mr-2" />
<span>...</span>
</div>
</CardContent>
</Card>
</div>
);
}
if (!data) {
return (
<div className="p-6">
<Card>
<CardContent className="py-12">
<div className="text-center text-muted-foreground"></div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="p-6">
<Card className="mb-4">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle></CardTitle>
<Button variant="outline" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-0">
<DescriptionItem label="表单标识" value={data.formKey} />
<DescriptionItem label="表单版本" value={`v${data.formVersion}`} />
<DescriptionItem label="业务类型" value={getBusinessTypeBadge(data.businessType)} />
<DescriptionItem label="业务标识" value={data.businessKey || '-'} />
<DescriptionItem label="提交人" value={data.submitter || '-'} />
<DescriptionItem label="提交时间" value={data.submitTime || '-'} />
<DescriptionItem label="状态" value={getStatusBadge(data.status)} />
<DescriptionItem label="创建时间" value={data.createTime || '-'} />
<DescriptionItem label="更新时间" value={data.updateTime || '-'} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{/* 使用 FormRenderer 以只读模式展示数据 */}
<FormRenderer
schema={data.schemaSnapshot}
value={data.data}
readonly={true}
showSubmit={false}
showCancel={true}
cancelText="返回"
onCancel={handleBack}
/>
</CardContent>
</Card>
</div>
);
};
export default FormDataDetail;

View File

@ -0,0 +1,408 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { DataTablePagination } from '@/components/ui/pagination';
import {
Loader2, Search, Eye, Trash2, Download, Folder,
Activity, Clock, CheckCircle2, FileCheck, Database
} from 'lucide-react';
import { getFormDataList, deleteFormData, exportFormData } from './service';
import { getEnabledCategories } from '../Category/service';
import type { FormDataResponse, FormDataStatus, FormDataBusinessType } from './types';
import type { FormCategoryResponse } from '../Category/types';
import type { Page } from '@/types/base';
import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
import dayjs from 'dayjs';
/**
*
*/
const FormDataList: React.FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<Page<FormDataResponse> | null>(null);
const [categories, setCategories] = useState<FormCategoryResponse[]>([]);
const [query, setQuery] = useState({
pageNum: DEFAULT_CURRENT - 1,
pageSize: DEFAULT_PAGE_SIZE,
formDefinitionId: searchParams.get('formDefinitionId') ? Number(searchParams.get('formDefinitionId')) : undefined,
businessKey: '',
categoryId: undefined as number | undefined,
status: undefined as FormDataStatus | undefined,
businessType: undefined as FormDataBusinessType | undefined,
});
// 加载分类列表
const loadCategories = async () => {
try {
const result = await getEnabledCategories();
setCategories(result || []);
} catch (error) {
console.error('加载分类失败:', error);
}
};
// 加载数据
const loadData = async () => {
setLoading(true);
try {
const result = await getFormDataList(query);
setData(result);
} catch (error) {
console.error('加载表单数据失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadCategories();
}, []);
useEffect(() => {
loadData();
}, [query]);
// 搜索
const handleSearch = () => {
setQuery(prev => ({
...prev,
pageNum: 0,
}));
};
// 重置搜索
const handleReset = () => {
setQuery({
pageNum: 0,
pageSize: DEFAULT_PAGE_SIZE,
formDefinitionId: undefined,
businessKey: '',
categoryId: undefined,
status: undefined,
businessType: undefined,
});
};
// 根据分类 ID 获取分类信息
const getCategoryInfo = (categoryId?: number) => {
return categories.find(cat => cat.id === categoryId);
};
// 查看详情
const handleView = (record: FormDataResponse) => {
navigate(`/form/data/${record.id}`);
};
// 删除
const handleDelete = async (record: FormDataResponse) => {
if (!confirm('确定要删除该数据吗?')) return;
try {
await deleteFormData(record.id);
loadData();
} catch (error) {
console.error('删除数据失败:', error);
}
};
// 导出
const handleExport = async () => {
try {
await exportFormData(query);
} catch (error) {
console.error('导出数据失败:', error);
}
};
// 状态徽章
const getStatusBadge = (status: FormDataStatus) => {
const statusMap: Record<FormDataStatus, {
variant: 'default' | 'secondary' | 'destructive' | 'success' | 'outline';
text: string;
icon: React.ElementType
}> = {
DRAFT: { variant: 'outline', text: '草稿', icon: Clock },
SUBMITTED: { variant: 'success', text: '已提交', icon: CheckCircle2 },
COMPLETED: { variant: 'default', text: '已完成', icon: FileCheck },
};
const statusInfo = statusMap[status];
const Icon = statusInfo.icon;
return (
<Badge variant={statusInfo.variant} className="flex items-center gap-1">
<Icon className="h-3 w-3" />
{statusInfo.text}
</Badge>
);
};
// 业务类型徽章
const getBusinessTypeBadge = (type: FormDataBusinessType) => {
const typeMap: Record<FormDataBusinessType, { variant: 'default' | 'secondary' | 'outline'; text: string }> = {
STANDALONE: { variant: 'outline', text: '独立' },
WORKFLOW: { variant: 'default', text: '工作流' },
ORDER: { variant: 'secondary', text: '订单' },
};
const typeInfo = typeMap[type];
return <Badge variant={typeInfo.variant}>{typeInfo.text}</Badge>;
};
// 统计数据
const stats = useMemo(() => {
const total = data?.totalElements || 0;
const draftCount = data?.content?.filter(d => d.status === 'DRAFT').length || 0;
const submittedCount = data?.content?.filter(d => d.status === 'SUBMITTED').length || 0;
const completedCount = data?.content?.filter(d => d.status === 'COMPLETED').length || 0;
return { total, draftCount, submittedCount, completedCount };
}, [data]);
const pageCount = data?.totalElements ? Math.ceil(data.totalElements / query.pageSize) : 0;
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="text-muted-foreground mt-2">
</p>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<Card className="bg-gradient-to-br from-blue-500/10 to-blue-500/5 border-blue-500/20">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-blue-700"></CardTitle>
<Activity className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-yellow-500/10 to-yellow-500/5 border-yellow-500/20">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-yellow-700">稿</CardTitle>
<Clock className="h-4 w-4 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.draftCount}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-green-500/10 to-green-500/5 border-green-500/20">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-green-700"></CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.submittedCount}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-purple-500/10 to-purple-500/5 border-purple-500/20">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-purple-700"></CardTitle>
<FileCheck className="h-4 w-4 text-purple-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.completedCount}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{/* 搜索栏 */}
<div className="flex flex-wrap items-center gap-4 mb-4">
<div className="flex-1 max-w-md">
<Input
placeholder="搜索业务标识"
value={query.businessKey}
onChange={(e) => setQuery(prev => ({ ...prev, businessKey: e.target.value }))}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="h-9"
/>
</div>
<Select
value={query.categoryId?.toString() || undefined}
onValueChange={(value) => setQuery(prev => ({ ...prev, categoryId: Number(value) }))}
>
<SelectTrigger className="w-[160px] h-9">
<SelectValue placeholder="全部分类" />
</SelectTrigger>
<SelectContent>
{categories.map(cat => (
<SelectItem key={cat.id} value={cat.id.toString()}>
<div className="flex items-center gap-2">
{cat.icon && <Folder className="h-3.5 w-3.5" />}
{cat.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={query.businessType || undefined}
onValueChange={(value) => setQuery(prev => ({ ...prev, businessType: value as FormDataBusinessType }))}
>
<SelectTrigger className="w-[140px] h-9">
<SelectValue placeholder="全部类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="STANDALONE"></SelectItem>
<SelectItem value="WORKFLOW"></SelectItem>
<SelectItem value="ORDER"></SelectItem>
</SelectContent>
</Select>
<Select
value={query.status || undefined}
onValueChange={(value) => setQuery(prev => ({ ...prev, status: value as FormDataStatus }))}
>
<SelectTrigger className="w-[130px] h-9">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="DRAFT">稿</SelectItem>
<SelectItem value="SUBMITTED"></SelectItem>
<SelectItem value="COMPLETED"></SelectItem>
</SelectContent>
</Select>
<Button onClick={handleSearch} className="h-9">
<Search className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleReset} className="h-9">
</Button>
<Button variant="outline" onClick={handleExport} className="h-9">
<Download className="h-4 w-4 mr-2" />
</Button>
</div>
{/* 表格 */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[180px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center">
<div className="flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
<span className="text-sm text-muted-foreground">...</span>
</div>
</TableCell>
</TableRow>
) : data?.content && data.content.length > 0 ? (
data.content.map((record) => {
const categoryInfo = getCategoryInfo(record.categoryId);
return (
<TableRow key={record.id} className="hover:bg-muted/50">
<TableCell>
<code className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">
{record.formKey}
</code>
</TableCell>
<TableCell>
{categoryInfo ? (
<Badge variant="outline" className="flex items-center gap-1 w-fit">
{categoryInfo.icon && <Folder className="h-3 w-3" />}
{categoryInfo.name}
</Badge>
) : (
<Badge variant="outline"></Badge>
)}
</TableCell>
<TableCell>{getBusinessTypeBadge(record.businessType)}</TableCell>
<TableCell>
<span className="text-sm">{record.businessKey || '-'}</span>
</TableCell>
<TableCell>
<span className="text-sm">{record.submitter || '匿名'}</span>
</TableCell>
<TableCell>
<span className="text-sm">
{record.submitTime ? dayjs(record.submitTime).format('YYYY-MM-DD HH:mm:ss') : '-'}
</span>
</TableCell>
<TableCell>{getStatusBadge(record.status)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleView(record)}
title="查看详情"
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(record)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
title="删除"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center">
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Database className="w-16 h-16 mb-4 text-muted-foreground/50" />
<div className="text-lg font-semibold mb-2"></div>
<div className="text-sm"></div>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* 分页 */}
{pageCount > 1 && (
<DataTablePagination
pageIndex={query.pageNum + 1}
pageSize={query.pageSize}
pageCount={pageCount}
onPageChange={(page) => setQuery(prev => ({
...prev,
pageNum: page - 1
}))}
/>
)}
</CardContent>
</Card>
</div>
);
};
export default FormDataList;

View File

@ -0,0 +1,53 @@
import request from '@/utils/request';
import type { Page } from '@/types/base';
import type {
FormDataQuery,
FormDataResponse,
FormDataSubmitRequest,
FormDataUpdateRequest,
} from './types';
const BASE_URL = '/api/v1/forms/data';
/**
*
*/
export const getFormDataList = (params?: FormDataQuery) =>
request.get<Page<FormDataResponse>>(`${BASE_URL}/page`, { params });
/**
*
*/
export const getFormDataById = (id: number) =>
request.get<FormDataResponse>(`${BASE_URL}/${id}`);
/**
*
*/
export const submitFormData = (data: FormDataSubmitRequest) =>
request.post<FormDataResponse>(BASE_URL, data);
/**
*
*/
export const updateFormData = (id: number, data: FormDataUpdateRequest) =>
request.put<FormDataResponse>(`${BASE_URL}/${id}`, data);
/**
*
*/
export const deleteFormData = (id: number) =>
request.delete(`${BASE_URL}/${id}`);
/**
*
*/
export const batchDeleteFormData = (ids: number[]) =>
request.post(`${BASE_URL}/batch-delete`, { ids });
/**
*
*/
export const exportFormData = (params?: FormDataQuery) =>
request.download(`${BASE_URL}/export`, undefined, { params });

View File

@ -0,0 +1,70 @@
import { BaseQuery } from '@/types/base';
import type { FormSchema } from '@/components/FormDesigner';
/**
*
*/
export type FormDataBusinessType = 'STANDALONE' | 'WORKFLOW' | 'ORDER';
/**
*
*/
export type FormDataStatus = 'DRAFT' | 'SUBMITTED' | 'COMPLETED';
/**
*
*/
export interface FormDataQuery extends BaseQuery {
formDefinitionId?: number;
formKey?: string;
categoryId?: number; // 分类ID筛选
businessType?: FormDataBusinessType;
businessKey?: string;
submitter?: string;
status?: FormDataStatus;
submitTimeStart?: string;
submitTimeEnd?: string;
}
/**
*
*/
export interface FormDataResponse {
id: number;
formDefinitionId: number;
formKey: string;
formVersion: number;
categoryId?: number; // 分类ID
businessKey?: string;
businessType: FormDataBusinessType;
data: Record<string, any>;
schemaSnapshot: FormSchema;
submitter?: string;
submitTime?: string;
status: FormDataStatus;
createBy?: string;
createTime?: string;
updateBy?: string;
updateTime?: string;
}
/**
*
*/
export interface FormDataSubmitRequest {
formDefinitionId: number;
formKey: string;
businessType?: FormDataBusinessType;
businessKey?: string;
data: Record<string, any>;
status?: FormDataStatus;
}
/**
*
*/
export interface FormDataUpdateRequest {
data: Record<string, any>;
status?: FormDataStatus;
}

View File

@ -0,0 +1,263 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { FormDesigner } from '@/components/FormDesigner';
import type { FormSchema } from '@/components/FormDesigner';
import { ArrowLeft, FileText, Tag, Folder, AlignLeft, Info } from 'lucide-react';
import { Separator } from '@/components/ui/separator';
import { getDefinitionById, createDefinition, updateDefinition } from './service';
import { getEnabledCategories } from '../Category/service';
import type { FormDefinitionRequest } from './types';
import type { FormCategoryResponse } from '../Category/types';
/**
*
*/
const FormDesignerPage: React.FC = () => {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const isEdit = !!id;
const [categories, setCategories] = useState<FormCategoryResponse[]>([]);
const [formMeta, setFormMeta] = useState({
name: '',
key: '',
categoryId: undefined as number | undefined,
description: '',
});
const [formSchema, setFormSchema] = useState<FormSchema | null>(null);
// 加载分类列表
const loadCategories = async () => {
try {
const result = await getEnabledCategories();
setCategories(result || []);
} catch (error) {
console.error('加载分类失败:', error);
}
};
// 加载表单定义
useEffect(() => {
loadCategories();
if (isEdit && id) {
loadFormDefinition(Number(id));
}
}, [id, isEdit]);
const loadFormDefinition = async (definitionId: number) => {
try {
const result = await getDefinitionById(definitionId);
setFormMeta({
name: result.name,
key: result.key,
categoryId: result.categoryId,
description: result.description || '',
});
setFormSchema(result.schema);
} catch (error) {
console.error('加载表单定义失败:', error);
}
};
// 保存表单
const handleSave = async (schema: FormSchema) => {
if (!formMeta.name.trim()) {
alert('请输入表单名称');
return;
}
if (!formMeta.key.trim()) {
alert('请输入表单标识');
return;
}
const request: FormDefinitionRequest = {
name: formMeta.name,
key: formMeta.key,
categoryId: formMeta.categoryId,
description: formMeta.description,
schema,
status: 'PUBLISHED',
};
try {
if (isEdit && id) {
await updateDefinition(Number(id), request);
} else {
await createDefinition(request);
}
navigate('/form/definitions');
} catch (error) {
console.error('保存表单失败:', error);
}
};
// 返回列表
const handleBack = () => {
navigate('/form/definitions');
};
return (
<div className="flex flex-col gap-6 p-6">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
{isEdit ? '编辑表单定义' : '创建表单定义'}
</h1>
<p className="text-sm text-muted-foreground mt-2">
{isEdit ? '修改表单的基本信息和字段配置' : '设计您的自定义表单,添加字段并配置验证规则'}
</p>
</div>
<Button variant="outline" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
</div>
{/* 基本信息卡片 */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<div className="p-2 rounded-lg bg-primary/10">
<FileText className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle></CardTitle>
<CardDescription className="mt-1">
</CardDescription>
</div>
</div>
</CardHeader>
<Separator />
<CardContent className="pt-6">
<div className="space-y-6">
{/* 第一行:表单名称 + 表单标识 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="name" className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-destructive">*</span>
</Label>
<Input
id="name"
placeholder="例如:员工请假申请表"
value={formMeta.name}
onChange={(e) => setFormMeta(prev => ({ ...prev, name: e.target.value }))}
className="h-10"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<Label htmlFor="key" className="flex items-center gap-2">
<Tag className="h-4 w-4 text-muted-foreground" />
<span className="text-destructive">*</span>
{isEdit && <span className="text-xs text-muted-foreground">()</span>}
</Label>
<Input
id="key"
placeholder="例如employee-leave-form"
value={formMeta.key}
onChange={(e) => setFormMeta(prev => ({ ...prev, key: e.target.value }))}
disabled={isEdit}
className="h-10 font-mono"
/>
<p className="text-xs text-muted-foreground">
线 API
</p>
</div>
</div>
{/* 第二行:分类 */}
<div className="space-y-2">
<Label htmlFor="category" className="flex items-center gap-2">
<Folder className="h-4 w-4 text-muted-foreground" />
</Label>
<Select
value={formMeta.categoryId?.toString() || undefined}
onValueChange={(value) => setFormMeta(prev => ({ ...prev, categoryId: Number(value) }))}
>
<SelectTrigger className="h-10">
<SelectValue placeholder="选择表单所属分类" />
</SelectTrigger>
<SelectContent>
{categories.map(cat => (
<SelectItem key={cat.id} value={cat.id.toString()}>
<div className="flex items-center gap-2">
<Folder className="h-3.5 w-3.5" />
{cat.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 第三行:描述(跨整行) */}
<div className="space-y-2">
<Label htmlFor="description" className="flex items-center gap-2">
<AlignLeft className="h-4 w-4 text-muted-foreground" />
</Label>
<Textarea
id="description"
placeholder="简要说明此表单的用途和填写注意事项..."
value={formMeta.description}
onChange={(e) => setFormMeta(prev => ({ ...prev, description: e.target.value }))}
className="min-h-[100px] resize-none"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
</CardContent>
</Card>
{/* 表单设计器区域 */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<div className="p-2 rounded-lg bg-blue-500/10">
<Info className="h-5 w-5 text-blue-600" />
</div>
<div>
<CardTitle></CardTitle>
<CardDescription className="mt-1">
</CardDescription>
</div>
</div>
</CardHeader>
<Separator />
<CardContent className="p-0">
{/* 表单设计器 */}
<div className="rounded-lg">
<FormDesigner
value={formSchema || undefined}
onChange={setFormSchema}
onSave={handleSave}
/>
</div>
</CardContent>
</Card>
</div>
);
};
export default FormDesignerPage;

View File

@ -0,0 +1,571 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from '@/components/ui/card';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
import { DataTablePagination } from '@/components/ui/pagination';
import { FormRenderer } from '@/components/FormDesigner';
import {
Loader2, Plus, Search, Eye, Edit, FileText, Ban, Trash2,
Database, MoreHorizontal, CheckCircle2, XCircle, Clock, AlertCircle,
Folder, Activity
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Separator } from '@/components/ui/separator';
import { getDefinitions, publishDefinition, disableDefinition, deleteDefinition } from './service';
import { getEnabledCategories } from '../Category/service';
import type { FormDefinitionResponse, FormDefinitionStatus } from './types';
import type { FormCategoryResponse } from '../Category/types';
import type { Page } from '@/types/base';
import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
/**
*
*/
const FormDefinitionList: React.FC = () => {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<Page<FormDefinitionResponse> | null>(null);
const [categories, setCategories] = useState<FormCategoryResponse[]>([]);
const [query, setQuery] = useState({
pageNum: DEFAULT_CURRENT - 1,
pageSize: DEFAULT_PAGE_SIZE,
name: '',
categoryId: undefined as number | undefined,
status: undefined as FormDefinitionStatus | undefined,
});
// 预览弹窗
const [previewVisible, setPreviewVisible] = useState(false);
const [previewForm, setPreviewForm] = useState<FormDefinitionResponse | null>(null);
// 删除确认弹窗
const [deleteVisible, setDeleteVisible] = useState(false);
const [deleteForm, setDeleteForm] = useState<FormDefinitionResponse | null>(null);
const [deleting, setDeleting] = useState(false);
// 加载分类列表
const loadCategories = async () => {
try {
const result = await getEnabledCategories();
setCategories(result || []);
} catch (error) {
console.error('加载分类失败:', error);
}
};
// 加载数据
const loadData = async () => {
setLoading(true);
try {
const result = await getDefinitions(query);
setData(result);
} catch (error) {
console.error('加载表单定义失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadCategories();
}, []);
useEffect(() => {
loadData();
}, [query]);
// 统计数据
const stats = useMemo(() => {
const total = data?.totalElements || 0;
const draftCount = data?.content?.filter(item => item.status === 'DRAFT').length || 0;
const publishedCount = data?.content?.filter(item => item.status === 'PUBLISHED').length || 0;
const disabledCount = data?.content?.filter(item => item.status === 'DISABLED').length || 0;
return { total, draftCount, publishedCount, disabledCount };
}, [data]);
// 搜索
const handleSearch = () => {
setQuery(prev => ({
...prev,
pageNum: 0,
}));
};
// 重置搜索
const handleReset = () => {
setQuery({
pageNum: 0,
pageSize: DEFAULT_PAGE_SIZE,
name: '',
categoryId: undefined,
status: undefined,
});
};
// 创建表单
const handleCreate = () => {
navigate('/form/definitions/create');
};
// 编辑表单
const handleEdit = (record: FormDefinitionResponse) => {
navigate(`/form/definitions/${record.id}/edit`);
};
// 预览表单
const handlePreview = (record: FormDefinitionResponse) => {
setPreviewForm(record);
setPreviewVisible(true);
};
// 发布表单
const handlePublish = async (record: FormDefinitionResponse) => {
try {
await publishDefinition(record.id);
loadData();
} catch (error) {
console.error('发布表单失败:', error);
}
};
// 禁用表单
const handleDisable = async (record: FormDefinitionResponse) => {
try {
await disableDefinition(record.id);
loadData();
} catch (error) {
console.error('禁用表单失败:', error);
}
};
// 打开删除确认弹窗
const handleDeleteClick = (record: FormDefinitionResponse) => {
setDeleteForm(record);
setDeleteVisible(true);
};
// 确认删除
const handleDeleteConfirm = async () => {
if (!deleteForm) return;
setDeleting(true);
try {
await deleteDefinition(deleteForm.id);
setDeleteVisible(false);
setDeleteForm(null);
loadData();
} catch (error) {
console.error('删除表单失败:', error);
} finally {
setDeleting(false);
}
};
// 查看数据
const handleViewData = (record: FormDefinitionResponse) => {
navigate(`/form/data?formDefinitionId=${record.id}`);
};
// 根据分类 ID 获取分类信息
const getCategoryInfo = (categoryId?: number) => {
return categories.find(cat => cat.id === categoryId);
};
// 状态徽章(带图标)
const getStatusBadge = (status: FormDefinitionStatus) => {
const statusMap: Record<FormDefinitionStatus, {
variant: 'default' | 'secondary' | 'destructive' | 'success' | 'outline';
text: string;
icon: React.ReactNode;
}> = {
DRAFT: {
variant: 'outline',
text: '草稿',
icon: <Clock className="h-3 w-3 mr-1" />
},
PUBLISHED: {
variant: 'success',
text: '已发布',
icon: <CheckCircle2 className="h-3 w-3 mr-1" />
},
DISABLED: {
variant: 'secondary',
text: '已禁用',
icon: <XCircle className="h-3 w-3 mr-1" />
},
};
const statusInfo = statusMap[status];
return (
<Badge variant={statusInfo.variant} className="flex items-center w-fit">
{statusInfo.icon}
{statusInfo.text}
</Badge>
);
};
const pageCount = data?.totalElements ? Math.ceil(data.totalElements / query.pageSize) : 0;
return (
<div className="flex flex-col gap-6 p-6">
{/* 统计卡片 */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total}</div>
<p className="text-xs text-muted-foreground mt-1">
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">稿</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.draftCount}</div>
<p className="text-xs text-muted-foreground mt-1">
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.publishedCount}</div>
<p className="text-xs text-muted-foreground mt-1">
使
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<XCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.disabledCount}</div>
<p className="text-xs text-muted-foreground mt-1">
</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription className="mt-2">
</CardDescription>
</div>
<Button onClick={handleCreate} size="default">
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</CardHeader>
<Separator />
<CardContent className="pt-6">
{/* 搜索栏 */}
<div className="flex items-center gap-4 mb-6">
<div className="flex-1 max-w-md">
<Input
placeholder="搜索表单名称或标识..."
value={query.name}
onChange={(e) => setQuery(prev => ({ ...prev, name: e.target.value }))}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="h-9"
/>
</div>
<Select
value={query.categoryId?.toString() || undefined}
onValueChange={(value) => setQuery(prev => ({ ...prev, categoryId: Number(value) }))}
>
<SelectTrigger className="w-[160px] h-9">
<SelectValue placeholder="全部分类" />
</SelectTrigger>
<SelectContent>
{categories.map(cat => (
<SelectItem key={cat.id} value={cat.id.toString()}>
<div className="flex items-center gap-2">
{cat.icon && <Folder className="h-3.5 w-3.5" />}
{cat.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={query.status || undefined}
onValueChange={(value) => setQuery(prev => ({ ...prev, status: value as FormDefinitionStatus }))}
>
<SelectTrigger className="w-[140px] h-9">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="DRAFT">稿</SelectItem>
<SelectItem value="PUBLISHED"></SelectItem>
<SelectItem value="DISABLED"></SelectItem>
</SelectContent>
</Select>
<Button onClick={handleSearch} size="sm">
<Search className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleReset} size="sm">
</Button>
</div>
{/* 表格 */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[250px]"></TableHead>
<TableHead className="w-[200px]"></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[180px]"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} className="h-32 text-center">
<div className="flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
<span className="text-sm text-muted-foreground">...</span>
</div>
</TableCell>
</TableRow>
) : data?.content && data.content.length > 0 ? (
data.content.map((record) => {
const categoryInfo = getCategoryInfo(record.categoryId);
return (
<TableRow key={record.id} className="hover:bg-muted/50">
<TableCell>
<div>
<div className="font-medium">{record.name}</div>
{record.description && (
<div className="text-xs text-muted-foreground mt-1 line-clamp-1">
{record.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<code className="text-xs bg-muted px-2 py-1 rounded">{record.key}</code>
</TableCell>
<TableCell>
{categoryInfo ? (
<div className="flex items-center gap-2">
<Folder className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{categoryInfo.name}</span>
</div>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<Badge variant="outline" className="font-mono">
v{record.formVersion}
</Badge>
</TableCell>
<TableCell>{getStatusBadge(record.status)}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{record.updateTime || record.createTime}
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handlePreview(record)}
className="h-8 w-8 p-0"
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(record)}
className="h-8 w-8 p-0"
>
<Edit className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[160px]">
<DropdownMenuItem onClick={() => handleViewData(record)}>
<Database className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuSeparator />
{record.status === 'DRAFT' && (
<DropdownMenuItem onClick={() => handlePublish(record)}>
<FileText className="h-4 w-4 mr-2" />
</DropdownMenuItem>
)}
{record.status === 'PUBLISHED' && (
<DropdownMenuItem onClick={() => handleDisable(record)}>
<Ban className="h-4 w-4 mr-2" />
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleDeleteClick(record)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={7} className="h-32 text-center">
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<FileText className="w-12 h-12 mb-4 opacity-20" />
<div className="text-sm font-medium"></div>
<div className="text-xs mt-1">"创建表单"</div>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* 分页 */}
{pageCount > 1 && (
<div className="mt-4">
<DataTablePagination
pageIndex={query.pageNum + 1}
pageSize={query.pageSize}
pageCount={pageCount}
onPageChange={(page) => setQuery(prev => ({
...prev,
pageNum: page - 1
}))}
/>
</div>
)}
</CardContent>
</Card>
{/* 预览弹窗 */}
{previewForm && (
<Dialog open={previewVisible} onOpenChange={setPreviewVisible}>
<DialogContent className="max-w-4xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{previewForm.name} - {previewForm.key} (v{previewForm.formVersion})
</DialogDescription>
</DialogHeader>
<Separator className="my-4" />
<div className="py-4">
<FormRenderer
schema={previewForm.schema}
value={{}}
readonly={true}
showSubmit={false}
onCancel={() => setPreviewVisible(false)}
/>
</div>
</DialogContent>
</Dialog>
)}
{/* 删除确认弹窗 */}
{deleteForm && (
<Dialog open={deleteVisible} onOpenChange={setDeleteVisible}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-destructive" />
</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="rounded-lg bg-muted p-4 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"></span>
<span className="text-sm font-medium">{deleteForm.name}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"></span>
<code className="text-xs bg-background px-2 py-1 rounded">{deleteForm.key}</code>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"></span>
<span className="text-sm">v{deleteForm.formVersion}</span>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteVisible(false)}
disabled={deleting}
>
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={deleting}
>
{deleting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
);
};
export default FormDefinitionList;

View File

@ -0,0 +1,58 @@
import request from '@/utils/request';
import type { Page } from '@/types/base';
import type {
FormDefinitionQuery,
FormDefinitionResponse,
FormDefinitionRequest,
} from './types';
const BASE_URL = '/api/v1/forms/definitions';
/**
*
*/
export const getDefinitions = (params?: FormDefinitionQuery) =>
request.get<Page<FormDefinitionResponse>>(`${BASE_URL}/page`, { params });
/**
*
*/
export const getDefinitionById = (id: number) =>
request.get<FormDefinitionResponse>(`${BASE_URL}/${id}`);
/**
* key
*/
export const getDefinitionByKey = (key: string) =>
request.get<FormDefinitionResponse>(`${BASE_URL}/by-key/${key}`);
/**
*
*/
export const createDefinition = (data: FormDefinitionRequest) =>
request.post<FormDefinitionResponse>(BASE_URL, data);
/**
*
*/
export const updateDefinition = (id: number, data: FormDefinitionRequest) =>
request.put<FormDefinitionResponse>(`${BASE_URL}/${id}`, data);
/**
*
*/
export const publishDefinition = (id: number) =>
request.post<FormDefinitionResponse>(`${BASE_URL}/${id}/publish`);
/**
*
*/
export const disableDefinition = (id: number) =>
request.post<FormDefinitionResponse>(`${BASE_URL}/${id}/disable`);
/**
*
*/
export const deleteDefinition = (id: number) =>
request.delete(`${BASE_URL}/${id}`);

View File

@ -0,0 +1,52 @@
import { BaseQuery } from '@/types/base';
import type { FormSchema } from '@/components/FormDesigner';
/**
*
*/
export type FormDefinitionStatus = 'DRAFT' | 'PUBLISHED' | 'DISABLED';
/**
*
*/
export interface FormDefinitionQuery extends BaseQuery {
name?: string;
key?: string;
categoryId?: number; // 分类ID
status?: FormDefinitionStatus;
}
/**
*
*/
export interface FormDefinitionResponse {
id: number;
name: string;
key: string;
formVersion: number;
categoryId?: number; // 分类ID
description?: string;
schema: FormSchema;
tags?: string[];
status: FormDefinitionStatus;
isTemplate: boolean;
createBy?: string;
createTime?: string;
updateBy?: string;
updateTime?: string;
}
/**
* /
*/
export interface FormDefinitionRequest {
name: string;
key: string;
categoryId?: number; // 分类ID
description?: string;
schema: FormSchema;
tags?: string[];
status?: FormDefinitionStatus;
isTemplate?: boolean;
}

View File

@ -45,6 +45,10 @@ const JenkinsManagerList = lazy(() => import('../pages/Deploy/JenkinsManager/Lis
const GitManagerList = lazy(() => import('../pages/Deploy/GitManager/List'));
const External = lazy(() => import('../pages/Deploy/External'));
const FormDesigner = lazy(() => import('../pages/FormDesigner'));
const FormDefinitionList = lazy(() => import('../pages/Form/Definition'));
const FormDefinitionDesigner = lazy(() => import('../pages/Form/Definition/Designer'));
const FormDataList = lazy(() => import('../pages/Form/Data'));
const FormDataDetail = lazy(() => import('../pages/Form/Data/Detail'));
// Workflow2 相关路由已迁移到 Workflow删除旧路由
@ -148,6 +152,51 @@ const router = createBrowserRouter([
}
]
},
{
path: 'form',
children: [
{
path: 'definitions',
element: (
<Suspense fallback={<LoadingComponent/>}>
<FormDefinitionList/>
</Suspense>
)
},
{
path: 'definitions/create',
element: (
<Suspense fallback={<LoadingComponent/>}>
<FormDefinitionDesigner/>
</Suspense>
)
},
{
path: 'definitions/:id/edit',
element: (
<Suspense fallback={<LoadingComponent/>}>
<FormDefinitionDesigner/>
</Suspense>
)
},
{
path: 'data',
element: (
<Suspense fallback={<LoadingComponent/>}>
<FormDataList/>
</Suspense>
)
},
{
path: 'data/:id',
element: (
<Suspense fallback={<LoadingComponent/>}>
<FormDataDetail/>
</Suspense>
)
}
]
},
{
path: 'workflow',
children: [