1
This commit is contained in:
parent
58e4cf2743
commit
e7322f8af9
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
139
frontend/src/components/ui/alert-dialog.tsx
Normal file
139
frontend/src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
}
|
||||
@ -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: {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@ -110,8 +110,8 @@ export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
|
||||
127
frontend/src/components/ui/toast.tsx
Normal file
127
frontend/src/components/ui/toast.tsx
Normal 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,
|
||||
}
|
||||
33
frontend/src/components/ui/toaster.tsx
Normal file
33
frontend/src/components/ui/toaster.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
191
frontend/src/components/ui/use-toast.ts
Normal file
191
frontend/src/components/ui/use-toast.ts
Normal 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 }
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
46
frontend/src/pages/Deploy/ProjectGroup/List/schema.ts
Normal file
46
frontend/src/pages/Deploy/ProjectGroup/List/schema.ts
Normal 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>;
|
||||
Loading…
Reference in New Issue
Block a user