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 { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN'; import zhCN from 'antd/locale/zh_CN';
import router from './router'; import router from './router';
import { Toaster } from "@/components/ui/toaster";
const App: React.FC = () => { const App: React.FC = () => {
return ( return (
<ConfigProvider locale={zhCN}> <ConfigProvider locale={zhCN}>
<RouterProvider router={router} /> <RouterProvider router={router} />
<Toaster />
</ConfigProvider> </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" import { cn } from "@/lib/utils"
const badgeVariants = cva( 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: { variants: {
variant: { variant: {
default: 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: secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: 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", outline: "text-foreground",
success:
"border-transparent bg-green-500 text-white hover:bg-green-500/80",
}, },
}, },
defaultVariants: { defaultVariants: {

View File

@ -1,30 +1,30 @@
import * as React from "react" import * as React from "react"
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { Loader2 } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( 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: { variants: {
variant: { variant: {
default: default: "bg-primary text-primary-foreground hover:bg-primary/90",
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: 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: 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", ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-9 px-4 py-2", default: "h-10 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs", sm: "h-9 rounded-md px-3",
lg: "h-10 rounded-md px-8", lg: "h-11 rounded-md px-8",
icon: "h-9 w-9", icon: "h-10 w-10",
}, },
}, },
defaultVariants: { defaultVariants: {
@ -37,18 +37,29 @@ const buttonVariants = cva(
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean asChild?: boolean;
loading?: boolean;
} }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( 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" const Comp = asChild ? Slot : "button"
return ( return (
<Comp <Comp
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
ref={ref} ref={ref}
disabled={disabled || loading}
{...props} {...props}
/> >
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{children}
</>
) : (
children
)}
</Comp>
) )
} }
) )

View File

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

View File

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