This commit is contained in:
asp_ly 2024-12-27 22:30:48 +08:00
parent 58e4cf2743
commit e7322f8af9
11 changed files with 1025 additions and 249 deletions

View File

@ -3,11 +3,13 @@ import { RouterProvider } from 'react-router-dom';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import router from './router';
import { Toaster } from "@/components/ui/toaster";
const App: React.FC = () => {
return (
<ConfigProvider locale={zhCN}>
<RouterProvider router={router} />
<Toaster />
</ConfigProvider>
);
};

View File

@ -0,0 +1,139 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ className, ...props }, ref) => (
<button
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ className, ...props }, ref) => (
<button
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -4,17 +4,19 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
success:
"border-transparent bg-green-500 text-white hover:bg-green-500/80",
},
},
defaultVariants: {

View File

@ -1,30 +1,30 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
@ -37,18 +37,29 @@ const buttonVariants = cva(
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
asChild?: boolean;
loading?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
({ className, variant, size, asChild = false, loading = false, children, disabled, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={disabled || loading}
{...props}
/>
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{children}
</>
) : (
children
)}
</Comp>
)
}
)

View File

@ -110,8 +110,8 @@ export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,

View File

@ -0,0 +1,127 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@ -0,0 +1,33 @@
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
import { useToast } from "@/components/ui/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@ -0,0 +1,191 @@
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_VALUE
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

View File

@ -1,8 +1,36 @@
import React, {useState} from 'react';
import {Modal, Form, Input, Select, Switch, InputNumber, message} from 'antd';
import React, {useState, useEffect} from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {Button} from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormDescription,
} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {Switch} from "@/components/ui/switch";
import {useToast} from "@/components/ui/use-toast";
import {useForm} from "react-hook-form";
import {zodResolver} from "@hookform/resolvers/zod";
import type {ProjectGroup} from '../types';
import {ProjectGroupTypeEnum} from '../types';
import {createProjectGroup, updateProjectGroup} from '../service';
import {formSchema, type FormValues} from '../schema';
interface ProjectGroupModalProps {
open: boolean;
@ -17,136 +45,243 @@ const ProjectGroupModal: React.FC<ProjectGroupModalProps> = ({
onSuccess,
initialValues,
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const {toast} = useToast();
const isEdit = !!initialValues?.id;
const handleSubmit = async () => {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
mode: 'onChange',
defaultValues: {
projectGroupCode: '',
projectGroupName: '',
projectGroupDesc: '',
type: undefined,
enabled: true,
sort: 0,
tenantCode: 'default',
},
});
useEffect(() => {
if (open) {
if (initialValues) {
form.reset({
projectGroupCode: initialValues.projectGroupCode,
projectGroupName: initialValues.projectGroupName,
projectGroupDesc: initialValues.projectGroupDesc || '',
type: initialValues.type,
enabled: initialValues.enabled,
sort: initialValues.sort || 0,
tenantCode: initialValues.tenantCode || 'default',
});
} else {
form.reset({
projectGroupCode: '',
projectGroupName: '',
projectGroupDesc: '',
type: undefined,
enabled: true,
sort: 0,
tenantCode: 'default',
});
}
}
}, [form, initialValues, open]);
const handleSubmit = async (values: FormValues) => {
if (!form.formState.isValid) {
toast({
variant: "destructive",
title: "错误",
description: "请检查表单填写是否正确",
});
return;
}
try {
setLoading(true);
const values = await form.validateFields();
if (isEdit) {
await updateProjectGroup({
...values,
id: initialValues.id,
id: initialValues!.id,
});
toast({
title: "成功",
description: "项目组更新成功",
});
} else {
await createProjectGroup(values);
toast({
title: "成功",
description: "项目组创建成功",
});
}
message.success(`${isEdit ? '更新' : '创建'}成功`);
form.resetFields();
onSuccess();
} catch (error) {
if (error instanceof Error) {
message.error(`${isEdit ? '更新' : '创建'}失败: ${error.message}`);
} else {
message.error(`${isEdit ? '更新' : '创建'}失败`);
}
console.error('操作失败:', error);
toast({
variant: "destructive",
title: "错误",
description: error instanceof Error ? error.message : `项目组${isEdit ? '更新' : '创建'}失败`,
});
} finally {
setLoading(false);
}
};
return (
<Modal
title={`${isEdit ? '编辑' : '新建'}项目组`}
open={open}
onCancel={() => {
form.resetFields();
onCancel();
}}
onOk={handleSubmit}
confirmLoading={loading}
maskClosable={false}
destroyOnClose
>
<Form
form={form}
layout="vertical"
initialValues={{
enabled: true,
sort: 0,
...initialValues,
}}
>
<Form.Item
<Dialog open={open} onOpenChange={(open) => !open && onCancel()}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>{isEdit ? '编辑' : '新建'}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField
control={form.control}
name="projectGroupCode"
label="项目组编码"
rules={[
{required: true, message: '请输入项目组编码'},
{max: 50, message: '项目组编码不能超过50个字符'},
]}
tooltip="项目组的唯一标识,创建后不可修改"
>
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
placeholder="请输入项目组编码"
disabled={isEdit}
maxLength={50}
{...field}
/>
</FormControl>
<FormDescription>
</FormDescription>
</FormItem>
)}
/>
</Form.Item>
<Form.Item
<FormField
control={form.control}
name="projectGroupName"
label="项目组名称"
rules={[
{required: true, message: '请输入项目组名称'},
{max: 50, message: '项目组名称不能超过50个字符'},
]}
tooltip="项目组的显示名称"
>
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
placeholder="请输入项目组名称"
maxLength={50}
{...field}
/>
</FormControl>
<FormDescription>
</FormDescription>
</FormItem>
)}
/>
</Form.Item>
<Form.Item
<FormField
control={form.control}
name="type"
label="项目组类型"
rules={[{required: true, message: '请选择项目组类型'}]}
tooltip="项目组的类型,创建后不可修改"
>
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<Select
placeholder="请选择项目组类型"
value={field.value}
onValueChange={field.onChange}
disabled={isEdit}
>
<Select.Option value={ProjectGroupTypeEnum.PRODUCT}></Select.Option>
<Select.Option value={ProjectGroupTypeEnum.PROJECT}></Select.Option>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="请选择项目组类型" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={ProjectGroupTypeEnum.PRODUCT}></SelectItem>
<SelectItem value={ProjectGroupTypeEnum.PROJECT}></SelectItem>
</SelectContent>
</Select>
</Form.Item>
<Form.Item
name="projectGroupDesc"
label="项目组描述"
rules={[{max: 200, message: '项目组描述不能超过200个字符'}]}
tooltip="项目组的详细描述信息"
>
<Input.TextArea
placeholder="请输入项目组描述"
maxLength={200}
showCount
rows={4}
<FormDescription>
</FormDescription>
</FormItem>
)}
/>
</Form.Item>
<Form.Item
<FormField
control={form.control}
name="projectGroupDesc"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
placeholder="请输入项目组描述"
{...field}
/>
</FormControl>
<FormDescription>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
label="状态"
valuePropName="checked"
tooltip="是否启用该项目组"
>
<Switch checkedChildren="启用" unCheckedChildren="禁用"/>
</Form.Item>
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel></FormLabel>
<FormDescription>
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Form.Item
<FormField
control={form.control}
name="sort"
label="排序"
tooltip="数字越小越靠前"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormDescription>
</FormDescription>
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={onCancel}
>
<InputNumber min={0} style={{width: '100%'}}/>
</Form.Item>
</Button>
<Button type="submit" loading={loading}>
</Button>
</DialogFooter>
</form>
</Form>
</Modal>
</DialogContent>
</Dialog>
);
};

View File

@ -1,6 +1,5 @@
import React, {useState} from 'react';
import {PageContainer} from '@ant-design/pro-layout';
import {Button, Space, Popconfirm, Tag, App, Form, Input, Select, Card} from 'antd';
import {
PlusOutlined,
EditOutlined,
@ -22,6 +21,44 @@ import {
TableRow,
TableCell,
} from "@/components/ui/table";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {Button} from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {Badge} from "@/components/ui/badge";
import {useToast} from "@/components/ui/use-toast";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {useForm} from "react-hook-form";
import {zodResolver} from "@hookform/resolvers/zod";
import {searchFormSchema, type SearchFormValues} from "./schema";
interface Column {
accessorKey?: keyof ProjectGroup;
@ -36,8 +73,17 @@ const ProjectGroupList: React.FC = () => {
const [currentProject, setCurrentProject] = useState<ProjectGroup>();
const [list, setList] = useState<ProjectGroup[]>([]);
const [loading, setLoading] = useState(false);
const [searchForm] = Form.useForm();
const {message: messageApi} = App.useApp();
const {toast} = useToast();
const form = useForm<SearchFormValues>({
resolver: zodResolver(searchFormSchema),
defaultValues: {
projectGroupCode: "",
projectGroupName: "",
type: undefined,
enabled: undefined,
},
});
const loadData = async (params?: ProjectGroupQueryParams) => {
setLoading(true);
@ -45,7 +91,11 @@ const ProjectGroupList: React.FC = () => {
const data = await getProjectGroupPage(params);
setList(data.content);
} catch (error) {
messageApi.error('加载数据失败');
toast({
variant: "destructive",
title: "错误",
description: "加载数据失败",
});
} finally {
setLoading(false);
}
@ -58,10 +108,17 @@ const ProjectGroupList: React.FC = () => {
const handleDelete = async (id: number) => {
try {
await deleteProjectGroup(id);
messageApi.success('删除成功');
loadData(searchForm.getFieldsValue());
toast({
title: "成功",
description: "删除成功",
});
loadData(form.getValues());
} catch (error) {
messageApi.error('删除失败');
toast({
variant: "destructive",
title: "错误",
description: "删除失败",
});
}
};
@ -83,10 +140,10 @@ const ProjectGroupList: React.FC = () => {
const handleSuccess = () => {
setModalVisible(false);
setCurrentProject(undefined);
loadData(searchForm.getFieldsValue());
loadData(form.getValues());
};
const handleSearch = async (values: any) => {
const handleSearch = async (values: SearchFormValues) => {
loadData(values);
};
@ -113,12 +170,12 @@ const ProjectGroupList: React.FC = () => {
cell: ({ row }) => {
const typeInfo = getProjectTypeInfo(row.original.type);
return (
<Tag color={typeInfo.color}>
<Space>
<Badge variant="outline">
<span className="flex items-center gap-1">
{typeInfo.icon}
{typeInfo.label}
</Space>
</Tag>
</span>
</Badge>
);
},
},
@ -127,9 +184,9 @@ const ProjectGroupList: React.FC = () => {
header: '状态',
size: 100,
cell: ({ row }) => (
<Tag color={row.original.enabled ? 'success' : 'default'}>
<Badge variant={row.original.enabled ? "outline" : "secondary"}>
{row.original.enabled ? '启用' : '禁用'}
</Tag>
</Badge>
),
},
{
@ -137,10 +194,10 @@ const ProjectGroupList: React.FC = () => {
header: '环境数量',
size: 100,
cell: ({ row }) => (
<Space>
<span className="flex items-center gap-1">
<EnvironmentOutlined/>
{row.original.totalEnvironments || 0}
</Space>
</span>
),
},
{
@ -148,10 +205,10 @@ const ProjectGroupList: React.FC = () => {
header: '项目数量',
size: 100,
cell: ({ row }) => (
<Space>
<span className="flex items-center gap-1">
<TeamOutlined/>
{row.original.totalApplications || 0}
</Space>
</span>
),
},
{
@ -164,100 +221,132 @@ const ProjectGroupList: React.FC = () => {
header: '操作',
size: 180,
cell: ({ row }) => (
<Space>
<div className="flex items-center gap-2">
<Button
type="link"
variant="ghost"
size="sm"
onClick={() => handleEdit(row.original)}
>
<Space>
<EditOutlined/>
<EditOutlined className="mr-1" />
</Space>
</Button>
<Popconfirm
title="确定要删除该项目组吗?"
description="删除后将无法恢复,请谨慎操作"
onConfirm={() => handleDelete(row.original.id)}
>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="link"
danger
variant="ghost"
size="sm"
className="text-destructive"
>
<Space>
<DeleteOutlined/>
<DeleteOutlined className="mr-1" />
</Space>
</Button>
</Popconfirm>
</Space>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(row.original.id)}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
),
},
];
return (
<PageContainer>
<Card style={{ marginBottom: 16 }}>
<Form
form={searchForm}
layout="inline"
onFinish={handleSearch}
style={{ gap: '16px' }}
<Card className="mb-4">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSearch)} className="flex items-end gap-4">
<FormField
control={form.control}
name="projectGroupCode"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入项目组编码" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="projectGroupName"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入项目组名称" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<Form.Item name="projectGroupCode" label="项目组编码">
<Input placeholder="请输入项目组编码" allowClear />
</Form.Item>
<Form.Item name="projectGroupName" label="项目组名称">
<Input placeholder="请输入项目组名称" allowClear />
</Form.Item>
<Form.Item name="type" label="项目组类型">
<Select
placeholder="请选择项目组类型"
allowClear
style={{ width: 200 }}
options={[
{ label: '产品型', value: ProjectGroupTypeEnum.PRODUCT },
{ label: '项目型', value: ProjectGroupTypeEnum.PROJECT },
]}
<FormControl>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="请选择项目组类型" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={ProjectGroupTypeEnum.PRODUCT}></SelectItem>
<SelectItem value={ProjectGroupTypeEnum.PROJECT}></SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
/>
</Form.Item>
<Form.Item name="enabled" label="状态">
<Select
placeholder="请选择状态"
allowClear
style={{ width: 120 }}
options={[
{ label: '启用', value: true },
{ label: '禁用', value: false },
]}
/>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
</Button>
<Button onClick={() => {
searchForm.resetFields();
handleSearch({});
}}>
<Button
type="button"
variant="outline"
onClick={() => form.reset()}
>
</Button>
</Space>
</Form.Item>
<Button type="submit">
<SearchOutlined className="mr-1" />
</Button>
</form>
</Form>
</CardContent>
</Card>
<Card>
<div className="flex justify-between items-center mb-4">
<Button
type="primary"
icon={<PlusOutlined/>}
onClick={handleAdd}
>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle></CardTitle>
<Button onClick={handleAdd}>
<PlusOutlined className="mr-1" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
@ -273,15 +362,15 @@ const ProjectGroupList: React.FC = () => {
</TableRow>
</TableHeader>
<TableBody>
{list.map((row) => (
<TableRow key={row.id}>
{list.map((item) => (
<TableRow key={item.id}>
{columns.map((column) => (
<TableCell key={column.accessorKey || column.id}>
<TableCell
key={column.accessorKey || column.id}
>
{column.cell
? column.cell({ row: { original: row } })
: column.accessorKey
? String(row[column.accessorKey])
: null}
? column.cell({ row: { original: item } })
: item[column.accessorKey!]}
</TableCell>
))}
</TableRow>
@ -289,6 +378,7 @@ const ProjectGroupList: React.FC = () => {
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<ProjectGroupModal

View File

@ -0,0 +1,46 @@
import * as z from "zod";
import {ProjectGroupTypeEnum} from "./types";
export const searchFormSchema = z.object({
projectGroupCode: z.string().optional(),
projectGroupName: z.string().optional(),
type: z.nativeEnum(ProjectGroupTypeEnum).optional(),
enabled: z.boolean().optional(),
});
export const formSchema = z.object({
projectGroupCode: z.string({
required_error: "项目组编码不能为空",
invalid_type_error: "项目组编码格式不正确",
})
.min(1, "项目组编码不能为空")
.max(50, "项目组编码不能超过50个字符")
.regex(/^[a-z0-9-]+$/, "项目组编码只能包含小写字母、数字和连字符"),
projectGroupName: z.string({
required_error: "项目组名称不能为空",
invalid_type_error: "项目组名称格式不正确",
})
.min(1, "项目组名称不能为空")
.max(50, "项目组名称不能超过50个字符"),
type: z.nativeEnum(ProjectGroupTypeEnum, {
required_error: "请选择项目组类型",
invalid_type_error: "项目组类型无效",
}),
projectGroupDesc: z.string()
.max(200, "项目组描述不能超过200个字符")
.nullable()
.optional()
.transform(val => val || ''),
enabled: z.boolean().default(true),
sort: z.number({
required_error: "排序不能为空",
invalid_type_error: "排序必须是数字",
})
.int("排序必须是整数")
.min(0, "排序不能小于0")
.default(0),
tenantCode: z.string().default('default'),
});
export type SearchFormValues = z.infer<typeof searchFormSchema>;
export type FormValues = z.infer<typeof formSchema>;