表单CRUD
This commit is contained in:
parent
951fcbfebb
commit
d22285bc95
199
frontend/src/components/ui/dropdown-menu.tsx
Normal file
199
frontend/src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
46
frontend/src/pages/Form/Category/service.ts
Normal file
46
frontend/src/pages/Form/Category/service.ts
Normal 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}`);
|
||||
|
||||
40
frontend/src/pages/Form/Category/types.ts
Normal file
40
frontend/src/pages/Form/Category/types.ts
Normal 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;
|
||||
}
|
||||
|
||||
150
frontend/src/pages/Form/Data/Detail.tsx
Normal file
150
frontend/src/pages/Form/Data/Detail.tsx
Normal 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;
|
||||
|
||||
408
frontend/src/pages/Form/Data/index.tsx
Normal file
408
frontend/src/pages/Form/Data/index.tsx
Normal 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;
|
||||
|
||||
53
frontend/src/pages/Form/Data/service.ts
Normal file
53
frontend/src/pages/Form/Data/service.ts
Normal 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 });
|
||||
|
||||
70
frontend/src/pages/Form/Data/types.ts
Normal file
70
frontend/src/pages/Form/Data/types.ts
Normal 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;
|
||||
}
|
||||
|
||||
263
frontend/src/pages/Form/Definition/Designer.tsx
Normal file
263
frontend/src/pages/Form/Definition/Designer.tsx
Normal 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;
|
||||
571
frontend/src/pages/Form/Definition/index.tsx
Normal file
571
frontend/src/pages/Form/Definition/index.tsx
Normal 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;
|
||||
58
frontend/src/pages/Form/Definition/service.ts
Normal file
58
frontend/src/pages/Form/Definition/service.ts
Normal 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}`);
|
||||
|
||||
52
frontend/src/pages/Form/Definition/types.ts
Normal file
52
frontend/src/pages/Form/Definition/types.ts
Normal 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;
|
||||
}
|
||||
|
||||
@ -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: [
|
||||
|
||||
Loading…
Reference in New Issue
Block a user