chore: initial commit
This commit is contained in:
3
src/pages/404/index.tsx
Normal file
3
src/pages/404/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function NotFound() {
|
||||
return <div>404 Not Found</div>
|
||||
}
|
||||
129
src/pages/blog/category/components/action-dialog.tsx
Normal file
129
src/pages/blog/category/components/action-dialog.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useId, useState } from 'react'
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import z from 'zod'
|
||||
|
||||
import { createCategory, updateCategory } from '@/api'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog.tsx'
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Spinner } from '@/components/ui/spinner.tsx'
|
||||
import { categoryFormSchema, type Category, type CategoryForm } from '@/schemas'
|
||||
|
||||
interface Props {
|
||||
currentRow?: Category | null
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
|
||||
const isEdit = !!currentRow?.id
|
||||
|
||||
const formId = useId()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const form = useForm<z.infer<typeof categoryFormSchema>>({
|
||||
resolver: zodResolver(categoryFormSchema),
|
||||
defaultValues: currentRow
|
||||
? { ...currentRow }
|
||||
: {
|
||||
name: '',
|
||||
code: '',
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (values: CategoryForm) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
if (values.id) {
|
||||
await updateCategory(values.id, values)
|
||||
} else {
|
||||
await createCategory(values)
|
||||
}
|
||||
toast('操作成功!')
|
||||
onConfirm()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent onCloseAutoFocus={() => form.reset()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? '编辑' : '创建'}分类</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FieldGroup className='gap-4'>
|
||||
<Controller
|
||||
name='name'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='name'>分类名称</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id='name'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='请输入分类名称'
|
||||
autoComplete='off'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isEdit && (
|
||||
<>
|
||||
<Controller
|
||||
name='code'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='code'>分类编码</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id='code'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='请输入分类编码'
|
||||
autoComplete='off'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button disabled={loading} variant='outline'>
|
||||
取消
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button disabled={loading} form={formId} type='submit'>
|
||||
保存
|
||||
{loading && <Spinner data-icon='inline-start' />}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
55
src/pages/blog/category/components/action-dialogs.tsx
Normal file
55
src/pages/blog/category/components/action-dialogs.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { QUERY_KEY } from '../constants.ts'
|
||||
import { ActionsDialog } from './action-dialog.tsx'
|
||||
import { useCrud } from './crud-provider.tsx'
|
||||
import { DeleteDialog } from './delete-dialog.tsx'
|
||||
|
||||
export const ActionDialogs = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { action, setAction, currentRow, setCurrentRow } = useCrud()
|
||||
|
||||
const handleClose = () => {
|
||||
setAction(null)
|
||||
|
||||
setTimeout(() => {
|
||||
setCurrentRow(null)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] })
|
||||
handleClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionsDialog
|
||||
key='add'
|
||||
open={action === 'add'}
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
|
||||
{currentRow && (
|
||||
<>
|
||||
<ActionsDialog
|
||||
currentRow={currentRow}
|
||||
key={`edit-${currentRow.id}`}
|
||||
open={action === 'edit'}
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
key={`delete-${currentRow.id}`}
|
||||
currentRow={currentRow}
|
||||
open={action === 'delete'}
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
24
src/pages/blog/category/components/category-table.tsx
Normal file
24
src/pages/blog/category/components/category-table.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
|
||||
import { DataTable } from '@/components/data-table'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
|
||||
import { useCrud } from './crud-provider.tsx'
|
||||
|
||||
interface Props<TData> {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function CategoryTable<TData>({ table }: Props<TData>) {
|
||||
const { setAction } = useCrud()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button className='mb-4' onClick={() => setAction('add')}>
|
||||
创建分类
|
||||
</Button>
|
||||
|
||||
<DataTable table={table} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
47
src/pages/blog/category/components/columns.tsx
Normal file
47
src/pages/blog/category/components/columns.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
|
||||
import { formatDate } from '@/lib'
|
||||
import type { Category } from '@/schemas'
|
||||
|
||||
import { RowActions } from './row-actions'
|
||||
|
||||
export const createColumns = (): ColumnDef<Category>[] => {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: '分类名称',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('name')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'code',
|
||||
header: '分类编码',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('code')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: '创建时间',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const rawDate = row.getValue('created_at') as string
|
||||
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: '修改时间',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const rawDate = row.getValue('updated_at') as string
|
||||
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
meta: { className: ' max-w-36 sticky right-0' },
|
||||
cell: RowActions,
|
||||
},
|
||||
]
|
||||
}
|
||||
33
src/pages/blog/category/components/crud-provider.tsx
Normal file
33
src/pages/blog/category/components/crud-provider.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
|
||||
import { type Category } from '@/schemas'
|
||||
|
||||
import type { Action } from '../constants'
|
||||
|
||||
type CrudContextType<T> = {
|
||||
action: Action
|
||||
setAction: (action: Action) => void
|
||||
currentRow: T | null
|
||||
setCurrentRow: (row: T | null) => void
|
||||
}
|
||||
|
||||
export const CrudContext = createContext<CrudContextType<Category> | null>(null)
|
||||
|
||||
export const CrudProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [action, setAction] = useState<Action>(null)
|
||||
const [currentRow, setCurrentRow] = useState<Category | null>(null)
|
||||
|
||||
return (
|
||||
<CrudContext value={{ action, setAction, currentRow, setCurrentRow }}>{children}</CrudContext>
|
||||
)
|
||||
}
|
||||
|
||||
export const useCrud = () => {
|
||||
const context = useContext(CrudContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('context must be used within a CrudProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
57
src/pages/blog/category/components/delete-dialog.tsx
Normal file
57
src/pages/blog/category/components/delete-dialog.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { deleteCategory } from '@/api'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import { Spinner } from '@/components/ui/spinner.tsx'
|
||||
import { type Category } from '@/schemas'
|
||||
|
||||
interface Props {
|
||||
currentRow: Category
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export function DeleteDialog({ open, onClose, currentRow, onConfirm }: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const onDelete = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
await deleteCategory(currentRow.id)
|
||||
toast('操作成功!')
|
||||
onConfirm()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onClose}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除分类?</AlertDialogTitle>
|
||||
<AlertDialogDescription>确认删除【{currentRow?.name}】吗?</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
|
||||
<Button variant='destructive' onClick={onDelete}>
|
||||
确定
|
||||
{loading && <Spinner data-icon='inline-start' />}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
50
src/pages/blog/category/components/row-actions.tsx
Normal file
50
src/pages/blog/category/components/row-actions.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { type Row } from '@tanstack/react-table'
|
||||
import { MoreHorizontal } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu.tsx'
|
||||
import { type Category } from '@/schemas'
|
||||
|
||||
import { useCrud } from './crud-provider'
|
||||
|
||||
type Props = {
|
||||
row: Row<Category>
|
||||
}
|
||||
|
||||
export function RowActions({ row }: Props) {
|
||||
const { setAction, setCurrentRow } = useCrud()
|
||||
const { original } = row
|
||||
|
||||
const onEdit = () => {
|
||||
setAction('edit')
|
||||
setCurrentRow(original)
|
||||
}
|
||||
|
||||
const onDelete = () => {
|
||||
setAction('delete')
|
||||
setCurrentRow(original)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' className='h-8 w-8 p-0'>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>操作</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onEdit}>编辑分类</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDelete}>删除分类</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
7
src/pages/blog/category/constants.ts
Normal file
7
src/pages/blog/category/constants.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const QUERY_KEY = 'category'
|
||||
|
||||
export const ActionSchema = z.enum(['add', 'edit', 'delete']).nullable()
|
||||
|
||||
export type Action = z.infer<typeof ActionSchema>
|
||||
62
src/pages/blog/category/index.tsx
Normal file
62
src/pages/blog/category/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
||||
|
||||
import { getCategories } from '@/api'
|
||||
import { type Category } from '@/schemas'
|
||||
|
||||
import { ActionDialogs } from './components/action-dialogs.tsx'
|
||||
import { CategoryTable } from './components/category-table.tsx'
|
||||
import { createColumns } from './components/columns.tsx'
|
||||
import { CrudProvider } from './components/crud-provider.tsx'
|
||||
import { QUERY_KEY } from './constants.ts'
|
||||
|
||||
export default function CrudPage() {
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
const [rowCount, setRowCount] = useState<number>()
|
||||
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize],
|
||||
queryFn: () =>
|
||||
getCategories({
|
||||
page: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
}),
|
||||
placeholderData: (prev) => prev,
|
||||
})
|
||||
|
||||
const tableData = useMemo<Category[]>(() => data?.list ?? [], [data])
|
||||
|
||||
const columns = useMemo(() => createColumns(), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetching && data?.total !== undefined) {
|
||||
setRowCount(data.total)
|
||||
}
|
||||
}, [isFetching, data?.total])
|
||||
|
||||
const table = useReactTable<Category>({
|
||||
data: tableData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
rowCount: rowCount,
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
meta: {
|
||||
showLoading: isFetching,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<CrudProvider>
|
||||
<ActionDialogs />
|
||||
|
||||
<CategoryTable table={table} />
|
||||
</CrudProvider>
|
||||
)
|
||||
}
|
||||
73
src/pages/blog/post-edit/components/cover-upload.tsx
Normal file
73
src/pages/blog/post-edit/components/cover-upload.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { ImagePlus } from 'lucide-react'
|
||||
|
||||
import { uploadFile } from '@/api'
|
||||
import type { SysFileForm } from '@/schemas'
|
||||
|
||||
interface Props {
|
||||
value: SysFileForm | undefined
|
||||
onChange?: (file: SysFileForm) => void
|
||||
invalid?: boolean
|
||||
}
|
||||
|
||||
export const CoverUpload = ({ value, onChange, invalid }: Props) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [initialFile, setInitialFile] = useState<SysFileForm>()
|
||||
|
||||
useEffect(() => {
|
||||
setInitialFile(value)
|
||||
}, [value])
|
||||
|
||||
const handleTriggerUpload = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
handleUpload(file)
|
||||
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
const { data } = await uploadFile(file)
|
||||
setInitialFile(data)
|
||||
onChange?.(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept='image/*'
|
||||
className='hidden'
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
onClick={handleTriggerUpload}
|
||||
className='cursor-pointer h-45 rounded-md overflow-hidden '
|
||||
>
|
||||
{initialFile ? (
|
||||
<div className='h-full'>
|
||||
<img className='object-cover w-full h-full' src={initialFile.file_path} alt='' />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-invalid={invalid}
|
||||
className='border-2 flex flex-col items-center justify-center h-full gap-2 rounded-md border-dashed
|
||||
border-gray-200 text-sm text-gray-600 data-[invalid=true]:border-red-400 data-[invalid=true]:text-red-400'
|
||||
>
|
||||
<ImagePlus />
|
||||
<div>上传封面图</div>
|
||||
<div>建议1600 x 900</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
70
src/pages/blog/post-edit/components/datetime-picker.tsx
Normal file
70
src/pages/blog/post-edit/components/datetime-picker.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import dayjs from 'dayjs'
|
||||
import { ChevronDownIcon } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
onChange?: (value: string) => void
|
||||
}
|
||||
|
||||
export function DatetimePicker({ value, onChange }: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [date, setDate] = useState<Date | undefined>()
|
||||
const [time, setTime] = useState('00:00:00')
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setDate(dayjs(value).toDate())
|
||||
setTime(dayjs(value).format('HH:mm:ss'))
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const onChangeDate = (_date: Date | undefined) => {
|
||||
setDate(_date)
|
||||
setOpen(false)
|
||||
const datetime = _date ? `${dayjs(_date).format('YYYY-MM-DD')} ${time}` : ''
|
||||
onChange?.(dayjs(datetime).toISOString())
|
||||
}
|
||||
|
||||
const onChangeTime = (_time: string) => {
|
||||
setTime(_time)
|
||||
const datetime = date ? `${dayjs(date).format('YYYY-MM-DD')} ${_time}` : ''
|
||||
onChange?.(dayjs(datetime).toISOString())
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full flex gap-2'>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant='outline' className='w-45 justify-between font-normal'>
|
||||
{date ? dayjs(date).format('YYYY-MM-DD') : '选择日期'}
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-auto overflow-hidden p-0' align='start'>
|
||||
<Calendar
|
||||
mode='single'
|
||||
selected={date}
|
||||
captionLayout='dropdown'
|
||||
defaultMonth={date}
|
||||
onSelect={onChangeDate}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Input
|
||||
type='time'
|
||||
step='1'
|
||||
value={time}
|
||||
onChange={(e) => onChangeTime(e.target.value)}
|
||||
className='appearance-none bg-background [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
324
src/pages/blog/post-edit/index.tsx
Normal file
324
src/pages/blog/post-edit/index.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import { useCallback, useEffect, useId, useState } from 'react'
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { Save } from 'lucide-react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from 'src/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from 'src/components/ui/card'
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from 'src/components/ui/field.tsx'
|
||||
import { Input } from 'src/components/ui/input.tsx'
|
||||
import { postFormSchema, type Category, type PostFormInput, type PostFormOutput } from 'src/schemas'
|
||||
|
||||
import { createPost, getPost, listAllCategories, updatePost } from '@/api'
|
||||
import { WEditor } from '@/components/editor'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Spinner } from '@/components/ui/spinner.tsx'
|
||||
import { Textarea } from '@/components/ui/textarea.tsx'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import { postStatusOptions } from '@/enum'
|
||||
|
||||
import { CoverUpload } from './components/cover-upload.tsx'
|
||||
import { DatetimePicker } from './components/datetime-picker.tsx'
|
||||
|
||||
export default function PostEdit() {
|
||||
const formId = useId()
|
||||
const navigate = useNavigate()
|
||||
const { id } = useSearch({ strict: false })
|
||||
const [editorContent, setEditorContent] = useState('')
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const form = useForm<PostFormInput, undefined, PostFormOutput>({
|
||||
resolver: zodResolver(postFormSchema),
|
||||
defaultValues: {
|
||||
id: undefined,
|
||||
title: '',
|
||||
status: 0,
|
||||
published_at: dayjs().toISOString(),
|
||||
slug: '',
|
||||
summary: '',
|
||||
cover: undefined,
|
||||
sort: 0,
|
||||
category_id: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const fetchCategories = async () => {
|
||||
const { data } = await listAllCategories()
|
||||
setCategories(data)
|
||||
}
|
||||
|
||||
const fetchPostDetail = useCallback(
|
||||
async (postId: number) => {
|
||||
const { data } = await getPost(postId)
|
||||
|
||||
form.reset({
|
||||
...data,
|
||||
cover: { id: data.cover_id, file_path: data.cover },
|
||||
})
|
||||
|
||||
setEditorContent(data.content)
|
||||
},
|
||||
[form],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
await fetchCategories()
|
||||
if (id) {
|
||||
await fetchPostDetail(id)
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
}, [id, fetchPostDetail])
|
||||
|
||||
const onSubmit = async (values: PostFormOutput) => {
|
||||
values.content = editorContent.replace(/<br\s*\/?>/gi, '\n')
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
if (!id) {
|
||||
const { data } = await createPost(values)
|
||||
toast('文章已保存!')
|
||||
navigate({ to: '/post/edit?id=' + data.post_id })
|
||||
} else {
|
||||
await updatePost(id, values)
|
||||
await fetchPostDetail(id)
|
||||
toast('文章已保存!')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className='flex mb-4 items-center gap-6'>
|
||||
<div className='h-full flex-1 overflow-hidden '>
|
||||
<Controller
|
||||
name='title'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<input
|
||||
{...field}
|
||||
placeholder='请输入标题'
|
||||
className='w-full h-14 appearance-none border-0 bg-transparent p-0 m-0
|
||||
outline-none shadow-none ring-0 focus:outline-none focus:ring-0 text-4xl font-bold'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button disabled={loading}>
|
||||
<Save />
|
||||
保存
|
||||
{loading && <Spinner data-icon='inline-start' />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='w-full flex gap-6 flex-col-reverse md:flex-row'>
|
||||
<div className='flex-1 overflow-hidden border rounded-md'>
|
||||
<WEditor defaultValues={editorContent} onChange={setEditorContent} />
|
||||
</div>
|
||||
|
||||
<div className='overflow-y-auto flex flex-col gap-6 w-full md:w-90'>
|
||||
<Card className='w-full gap-2 shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle>封面图</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Controller
|
||||
name='cover'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<CoverUpload
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
invalid={fieldState.invalid}
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className='w-full gap-2 shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle>发布设置</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup className='gap-4 w-full'>
|
||||
<Controller
|
||||
name='status'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='status'>可见性</FieldLabel>
|
||||
<ToggleGroup
|
||||
type='single'
|
||||
value={field.value.toString()}
|
||||
onValueChange={(value) => field.onChange(Number(value))}
|
||||
variant='outline'
|
||||
spacing={2}
|
||||
size='lg'
|
||||
>
|
||||
{postStatusOptions.map((item) => (
|
||||
<ToggleGroupItem
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
className='flex-1 flex size-16 flex-col items-center justify-center rounded-xl'
|
||||
>
|
||||
{item.icon}
|
||||
<span className='text-xs text-muted-foreground'>{item.label}</span>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name='published_at'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel>发布日期</FieldLabel>
|
||||
<DatetimePicker value={field.value} onChange={field.onChange} />
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name='category_id'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel>文章分类</FieldLabel>
|
||||
<Select
|
||||
value={field.value?.toString()}
|
||||
onValueChange={(value) => field.onChange(Number(value))}
|
||||
>
|
||||
<SelectTrigger className='w-full'>
|
||||
<SelectValue placeholder='请选择文章分类' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{categories.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id.toString()}>
|
||||
{option.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name='sort'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='sort'>文章排序</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id='sort'
|
||||
type='number'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='请输入文章排序'
|
||||
autoComplete='off'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className='w-full gap-2 shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle>SEO</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup className='gap-4 w-full'>
|
||||
<Controller
|
||||
name='slug'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='slug'>URL 别名</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id='slug'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='请输入URL 别名'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<FieldDescription>
|
||||
文章唯一标识,使用英文或数字,不能包含空格和特殊字符。以短横线(-)连接,例如:hello-world,
|
||||
<span
|
||||
className='text-blue-800 underline cursor-pointer'
|
||||
onClick={() => field.onChange(dayjs().format('YYYY-MM-DD'))}
|
||||
>
|
||||
填入当前日期
|
||||
</span>
|
||||
</FieldDescription>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name='summary'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='summary'>SEO摘要</FieldLabel>
|
||||
<Textarea
|
||||
{...field}
|
||||
id='summary'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='请输入'
|
||||
autoComplete='off'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
<FieldDescription>
|
||||
用一句话概括文章内容,帮助搜索引擎理解文章主题。建议200字以内。
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
39
src/pages/blog/post/components/action-dialogs.tsx
Normal file
39
src/pages/blog/post/components/action-dialogs.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { QUERY_KEY } from '../constants.ts'
|
||||
import { useCrud } from './crud-provider.tsx'
|
||||
import { DeleteDialog } from './delete-dialog.tsx'
|
||||
|
||||
export const ActionDialogs = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { action, setAction, currentRow, setCurrentRow } = useCrud()
|
||||
|
||||
const handleClose = () => {
|
||||
setAction(null)
|
||||
|
||||
setTimeout(() => {
|
||||
setCurrentRow(null)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] })
|
||||
handleClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentRow && (
|
||||
<>
|
||||
<DeleteDialog
|
||||
key={`delete-${currentRow.id}`}
|
||||
currentRow={currentRow}
|
||||
open={action === 'delete'}
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
102
src/pages/blog/post/components/columns.tsx
Normal file
102
src/pages/blog/post/components/columns.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { postStatusOptions } from '@/enum'
|
||||
import { formatDate } from '@/lib'
|
||||
import type { Post } from '@/schemas'
|
||||
|
||||
import { RowActions } from './row-actions'
|
||||
|
||||
export const createColumns = (): ColumnDef<Post>[] => {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: '文章标题',
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className='w-full truncate'>
|
||||
<Link className='underline' to={`/post/edit?id=${row.original.id}`}>
|
||||
{row.getValue('title')}
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{row.getValue('title')}</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'slug',
|
||||
header: 'URL别名',
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
return <div className='overflow-hidden'>{row.getValue('slug')}</div>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'category_name',
|
||||
header: '所属分类',
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
return <div className='overflow-hidden'>{row.getValue('category_name')}</div>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: '状态',
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const findOption = postStatusOptions.find(
|
||||
(item) => Number(item.value) === row.getValue('status'),
|
||||
)
|
||||
return (
|
||||
<Badge className={findOption?.className}>
|
||||
{findOption?.icon}
|
||||
{findOption?.label}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'view',
|
||||
header: '阅读量',
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
return <div className='overflow-hidden'>{row.getValue('view')}</div>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'sort',
|
||||
header: '文章排序',
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
return <div className='overflow-hidden'>{row.getValue('sort')}</div>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'published_at',
|
||||
header: '发布日期',
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const rawDate = row.getValue('published_at') as string
|
||||
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: '更新时间',
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const rawDate = row.getValue('updated_at') as string
|
||||
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
meta: { className: 'max-w-36 sticky right-0' },
|
||||
cell: RowActions,
|
||||
},
|
||||
]
|
||||
}
|
||||
33
src/pages/blog/post/components/crud-provider.tsx
Normal file
33
src/pages/blog/post/components/crud-provider.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
|
||||
import { type Post } from '@/schemas'
|
||||
|
||||
import type { Action } from '../constants'
|
||||
|
||||
type CrudContextType<T> = {
|
||||
action: Action
|
||||
setAction: (action: Action) => void
|
||||
currentRow: T | null
|
||||
setCurrentRow: (row: T | null) => void
|
||||
}
|
||||
|
||||
export const CrudContext = createContext<CrudContextType<Post> | null>(null)
|
||||
|
||||
export const CrudProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [action, setAction] = useState<Action>(null)
|
||||
const [currentRow, setCurrentRow] = useState<Post | null>(null)
|
||||
|
||||
return (
|
||||
<CrudContext value={{ action, setAction, currentRow, setCurrentRow }}>{children}</CrudContext>
|
||||
)
|
||||
}
|
||||
|
||||
export const useCrud = () => {
|
||||
const context = useContext(CrudContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('context must be used within a CrudProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
57
src/pages/blog/post/components/delete-dialog.tsx
Normal file
57
src/pages/blog/post/components/delete-dialog.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { deletePost } from '@/api'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import { Spinner } from '@/components/ui/spinner.tsx'
|
||||
import { type Post } from '@/schemas'
|
||||
|
||||
interface Props {
|
||||
currentRow: Post
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export function DeleteDialog({ open, onClose, currentRow, onConfirm }: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const onDelete = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
await deletePost(currentRow.id)
|
||||
toast('操作成功!')
|
||||
onConfirm()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onClose}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除文章?</AlertDialogTitle>
|
||||
<AlertDialogDescription>确认删除这篇文章吗?</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
|
||||
<Button variant='destructive' onClick={onDelete}>
|
||||
确定
|
||||
{loading && <Spinner data-icon='inline-start' />}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
23
src/pages/blog/post/components/post-table.tsx
Normal file
23
src/pages/blog/post/components/post-table.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
|
||||
import { DataTable } from '@/components/data-table'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
|
||||
interface Props<TData> {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function PostTable<TData>({ table }: Props<TData>) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button className='mb-4' onClick={() => navigate({ to: '/post/edit' })}>
|
||||
创建文章
|
||||
</Button>
|
||||
|
||||
<DataTable table={table} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
54
src/pages/blog/post/components/row-actions.tsx
Normal file
54
src/pages/blog/post/components/row-actions.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { type Row } from '@tanstack/react-table'
|
||||
import { MoreHorizontal } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu.tsx'
|
||||
import { type Post } from '@/schemas'
|
||||
|
||||
import { useCrud } from './crud-provider'
|
||||
|
||||
type Props = {
|
||||
row: Row<Post>
|
||||
}
|
||||
|
||||
export function RowActions({ row }: Props) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { setAction, setCurrentRow } = useCrud()
|
||||
const { original } = row
|
||||
|
||||
const onEdit = () => {
|
||||
setAction('edit')
|
||||
setCurrentRow(original)
|
||||
navigate({ to: '/post/edit', search: { id: original.id } })
|
||||
}
|
||||
|
||||
const onDelete = () => {
|
||||
setAction('delete')
|
||||
setCurrentRow(original)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' className='h-8 w-8 p-0'>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>操作</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onEdit}>编辑文章</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDelete}>删除文章</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
9
src/pages/blog/post/constants.ts
Normal file
9
src/pages/blog/post/constants.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const QUERY_KEY = 'post'
|
||||
|
||||
export const ActionSchema = z
|
||||
.enum(['add', 'edit', 'delete', 'assign-apis', 'assign-menus'])
|
||||
.nullable()
|
||||
|
||||
export type Action = z.infer<typeof ActionSchema>
|
||||
62
src/pages/blog/post/index.tsx
Normal file
62
src/pages/blog/post/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
||||
|
||||
import { getPosts } from '@/api'
|
||||
import { type Post } from '@/schemas'
|
||||
|
||||
import { ActionDialogs } from './components/action-dialogs.tsx'
|
||||
import { createColumns } from './components/columns.tsx'
|
||||
import { CrudProvider } from './components/crud-provider.tsx'
|
||||
import { PostTable } from './components/post-table.tsx'
|
||||
import { QUERY_KEY } from './constants.ts'
|
||||
|
||||
export default function CrudPage() {
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
const [rowCount, setRowCount] = useState<number>()
|
||||
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize],
|
||||
queryFn: () =>
|
||||
getPosts({
|
||||
page: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
}),
|
||||
placeholderData: (prev) => prev,
|
||||
})
|
||||
|
||||
const tableData = useMemo<Post[]>(() => data?.list ?? [], [data])
|
||||
|
||||
const columns = useMemo(() => createColumns(), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetching && data?.total !== undefined) {
|
||||
setRowCount(data.total)
|
||||
}
|
||||
}, [isFetching, data?.total])
|
||||
|
||||
const table = useReactTable<Post>({
|
||||
data: tableData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
rowCount: rowCount,
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
meta: {
|
||||
showLoading: isFetching,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<CrudProvider>
|
||||
<ActionDialogs />
|
||||
|
||||
<PostTable table={table} />
|
||||
</CrudProvider>
|
||||
)
|
||||
}
|
||||
144
src/pages/login/components/login-form.tsx
Normal file
144
src/pages/login/components/login-form.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
|
||||
import { login } from '@/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { loginSchema, type LoginUser } from '@/schemas'
|
||||
import { useStore } from '@/store'
|
||||
|
||||
const REMEMBER_KEY = 'remember_me'
|
||||
const LOCAL_ACCOUNT_INFO_KEY = 'account_info'
|
||||
|
||||
function getLocalAccountInfo(): LoginUser | null {
|
||||
const accountInfo = localStorage.getItem(LOCAL_ACCOUNT_INFO_KEY)
|
||||
|
||||
if (!accountInfo) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedAccountInfo = JSON.parse(accountInfo) as Partial<LoginUser>
|
||||
|
||||
if (
|
||||
typeof parsedAccountInfo.account === 'string' &&
|
||||
typeof parsedAccountInfo.password === 'string'
|
||||
) {
|
||||
return {
|
||||
account: parsedAccountInfo.account,
|
||||
password: parsedAccountInfo.password,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
} catch {
|
||||
localStorage.removeItem(LOCAL_ACCOUNT_INFO_KEY)
|
||||
localStorage.removeItem(REMEMBER_KEY)
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function LoginForm({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
const isRemembered = localStorage.getItem(REMEMBER_KEY) === 'true'
|
||||
const accountInfo = isRemembered ? getLocalAccountInfo() : null
|
||||
const setToken = useStore((state) => state.setToken)
|
||||
|
||||
const { control, handleSubmit } = useForm<LoginUser>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
account: accountInfo?.account ?? '',
|
||||
password: accountInfo?.password ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
const [rememberMe, setRememberMe] = useState<boolean>(isRemembered)
|
||||
|
||||
const onSubmit = async (values: LoginUser) => {
|
||||
const { data } = await login(values)
|
||||
setToken(data.access_token, data.access_token_exp)
|
||||
|
||||
if (rememberMe) {
|
||||
localStorage.setItem(REMEMBER_KEY, 'true')
|
||||
localStorage.setItem(LOCAL_ACCOUNT_INFO_KEY, JSON.stringify(values))
|
||||
} else {
|
||||
localStorage.removeItem(REMEMBER_KEY)
|
||||
localStorage.removeItem(LOCAL_ACCOUNT_INFO_KEY)
|
||||
}
|
||||
location.replace('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader className='text-center'>
|
||||
<CardTitle className='text-xl'>登录管理系统</CardTitle>
|
||||
<CardDescription></CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form autoComplete='off' onSubmit={handleSubmit(onSubmit)}>
|
||||
<FieldGroup className='gap-5'>
|
||||
<Controller
|
||||
name='account'
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='account'>账号</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id='account'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='请输入账号'
|
||||
autoComplete='off'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name='password'
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='password'>密码</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id='password'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='请输入密码'
|
||||
autoComplete='off'
|
||||
type='password'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Field>
|
||||
<Field orientation='horizontal'>
|
||||
<Checkbox
|
||||
checked={rememberMe}
|
||||
onCheckedChange={(value) => setRememberMe(value === true)}
|
||||
id='remember-me'
|
||||
/>
|
||||
<FieldLabel htmlFor='remember-me'>记住密码</FieldLabel>
|
||||
</Field>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<Button type='submit'>登录</Button>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
src/pages/login/index.tsx
Normal file
11
src/pages/login/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { LoginForm } from './components/login-form'
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className='bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10'>
|
||||
<div className='flex w-full max-w-sm flex-col gap-6'>
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
200
src/pages/system/api/components/action-dialog.tsx
Normal file
200
src/pages/system/api/components/action-dialog.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useId, useState } from 'react'
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import z from 'zod'
|
||||
|
||||
import { createSysApi, updateSysApi } from '@/api'
|
||||
import { Badge } from '@/components/ui/badge.tsx'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog.tsx'
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select.tsx'
|
||||
import { Spinner } from '@/components/ui/spinner.tsx'
|
||||
import { HttpMethod, HttpMethodClass } from '@/enum'
|
||||
import { SysApiFormSchema, type SysApi, type SysApiForm } from '@/schemas'
|
||||
|
||||
interface Props {
|
||||
currentRow?: SysApi | null
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
|
||||
const isEdit = !!currentRow?.id
|
||||
|
||||
const formId = useId()
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const form = useForm<z.infer<typeof SysApiFormSchema>>({
|
||||
resolver: zodResolver(SysApiFormSchema),
|
||||
defaultValues: currentRow
|
||||
? { ...currentRow }
|
||||
: {
|
||||
name: '',
|
||||
path: '',
|
||||
group_name: '',
|
||||
method: '' as HttpMethod,
|
||||
sort: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (values: SysApiForm) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
if (values.id) {
|
||||
await updateSysApi(values.id, values)
|
||||
} else {
|
||||
await createSysApi(values)
|
||||
}
|
||||
toast('操作成功!')
|
||||
onConfirm()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent onCloseAutoFocus={() => form.reset()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? '编辑' : '创建'}接口</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FieldGroup className='gap-4'>
|
||||
<Controller
|
||||
name='name'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='name'>接口名称</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id='name'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='请输入接口名称'
|
||||
autoComplete='off'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name='group_name'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='group_name'>分组名称</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id='group_name'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='请输入分组名称'
|
||||
autoComplete='off'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name='path'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='path'>接口路径</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id='path'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='请输入接口路径'
|
||||
autoComplete='off'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name='method'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel>接口方法</FieldLabel>
|
||||
<Select value={field.value} onValueChange={(value) => field.onChange(value)}>
|
||||
<SelectTrigger className='w-full'>
|
||||
<SelectValue placeholder='请选择接口方法' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{Object.values(HttpMethod).map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
<Badge className={HttpMethodClass[option]}>{option}</Badge>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name='sort'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='sort'>排序</FieldLabel>
|
||||
<Input
|
||||
type='number'
|
||||
{...field}
|
||||
id='sort'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='请输入排序'
|
||||
autoComplete='off'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button disabled={loading} variant='outline'>
|
||||
取消
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button disabled={loading} form={formId} type='submit'>
|
||||
保存
|
||||
{loading && <Spinner data-icon='inline-start' />}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
55
src/pages/system/api/components/action-dialogs.tsx
Normal file
55
src/pages/system/api/components/action-dialogs.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { QUERY_KEY } from '../constants.ts'
|
||||
import { ActionsDialog } from './action-dialog.tsx'
|
||||
import { useCrud } from './crud-provider.tsx'
|
||||
import { DeleteDialog } from './delete-dialog.tsx'
|
||||
|
||||
export const ActionDialogs = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { action, setAction, currentRow, setCurrentRow } = useCrud()
|
||||
|
||||
const handleClose = () => {
|
||||
setAction(null)
|
||||
|
||||
setTimeout(() => {
|
||||
setCurrentRow(null)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] })
|
||||
handleClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionsDialog
|
||||
key='add'
|
||||
open={action === 'add'}
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
|
||||
{currentRow && (
|
||||
<>
|
||||
<ActionsDialog
|
||||
currentRow={currentRow}
|
||||
key={`edit-${currentRow.id}`}
|
||||
open={action === 'edit'}
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
key={`delete-${currentRow.id}`}
|
||||
currentRow={currentRow}
|
||||
open={action === 'delete'}
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
24
src/pages/system/api/components/api-table.tsx
Normal file
24
src/pages/system/api/components/api-table.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
|
||||
import { DataTable } from '@/components/data-table'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
|
||||
import { useCrud } from './crud-provider.tsx'
|
||||
|
||||
interface Props<TData> {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function ApiTable<TData>({ table }: Props<TData>) {
|
||||
const { setAction } = useCrud()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button className='mb-4' onClick={() => setAction('add')}>
|
||||
创建接口
|
||||
</Button>
|
||||
|
||||
<DataTable table={table} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
71
src/pages/system/api/components/columns.tsx
Normal file
71
src/pages/system/api/components/columns.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { HttpMethodClass, type HttpMethodValues } from '@/enum'
|
||||
import { formatDate } from '@/lib'
|
||||
import type { SysApi } from '@/schemas'
|
||||
|
||||
import { RowActions } from './row-actions'
|
||||
|
||||
export const createColumns = (): ColumnDef<SysApi>[] => {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: '接口名称',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('name')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'group_name',
|
||||
header: '接口分组',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('group_name')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'method',
|
||||
header: '接口方法',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => (
|
||||
<Badge className={HttpMethodClass[row.getValue('method') as HttpMethodValues]}>
|
||||
{row.getValue('method')}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'path',
|
||||
header: '接口路径',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('path')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'sort',
|
||||
header: '排序',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('sort')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: '创建时间',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const rawDate = row.getValue('created_at') as string
|
||||
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: '修改时间',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const rawDate = row.getValue('updated_at') as string
|
||||
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
meta: { className: ' max-w-36 sticky right-0' },
|
||||
cell: RowActions,
|
||||
},
|
||||
]
|
||||
}
|
||||
33
src/pages/system/api/components/crud-provider.tsx
Normal file
33
src/pages/system/api/components/crud-provider.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
|
||||
import { type SysApi } from '@/schemas'
|
||||
|
||||
import type { Action } from '../constants'
|
||||
|
||||
type CrudContextType<T> = {
|
||||
action: Action
|
||||
setAction: (action: Action) => void
|
||||
currentRow: T | null
|
||||
setCurrentRow: (row: T | null) => void
|
||||
}
|
||||
|
||||
export const CrudContext = createContext<CrudContextType<SysApi> | null>(null)
|
||||
|
||||
export const CrudProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [action, setAction] = useState<Action>(null)
|
||||
const [currentRow, setCurrentRow] = useState<SysApi | null>(null)
|
||||
|
||||
return (
|
||||
<CrudContext value={{ action, setAction, currentRow, setCurrentRow }}>{children}</CrudContext>
|
||||
)
|
||||
}
|
||||
|
||||
export const useCrud = () => {
|
||||
const context = useContext(CrudContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('context must be used within a CrudProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
57
src/pages/system/api/components/delete-dialog.tsx
Normal file
57
src/pages/system/api/components/delete-dialog.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { deleteSysApi } from '@/api'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import { Spinner } from '@/components/ui/spinner.tsx'
|
||||
import { type SysApi } from '@/schemas'
|
||||
|
||||
interface Props {
|
||||
currentRow: SysApi
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export function DeleteDialog({ open, onClose, currentRow, onConfirm }: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const onDelete = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
await deleteSysApi(currentRow.id)
|
||||
toast('操作成功!')
|
||||
onConfirm()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onClose}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除角色?</AlertDialogTitle>
|
||||
<AlertDialogDescription>确认删除【{currentRow?.name}】吗?</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
|
||||
<Button disabled={loading} variant='destructive' onClick={onDelete}>
|
||||
确定
|
||||
{loading && <Spinner data-icon='inline-start' />}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
51
src/pages/system/api/components/row-actions.tsx
Normal file
51
src/pages/system/api/components/row-actions.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { type Row } from '@tanstack/react-table'
|
||||
import { MoreHorizontal } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu.tsx'
|
||||
import { type SysApi } from '@/schemas'
|
||||
|
||||
import { useCrud } from './crud-provider'
|
||||
|
||||
type Props = {
|
||||
row: Row<SysApi>
|
||||
}
|
||||
|
||||
export function RowActions({ row }: Props) {
|
||||
const { setAction, setCurrentRow } = useCrud()
|
||||
const { original } = row
|
||||
|
||||
const onEdit = () => {
|
||||
setAction('edit')
|
||||
setCurrentRow(original)
|
||||
}
|
||||
|
||||
const onDelete = () => {
|
||||
setAction('delete')
|
||||
setCurrentRow(original)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' className='h-8 w-8 p-0'>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>操作</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onEdit}>编辑接口</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={onDelete}>删除接口</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
7
src/pages/system/api/constants.ts
Normal file
7
src/pages/system/api/constants.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const QUERY_KEY = 'sys_api'
|
||||
|
||||
export const ActionSchema = z.enum(['add', 'edit', 'delete']).nullable()
|
||||
|
||||
export type Action = z.infer<typeof ActionSchema>
|
||||
62
src/pages/system/api/index.tsx
Normal file
62
src/pages/system/api/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
||||
|
||||
import { getSysApis } from '@/api'
|
||||
import { type SysApi } from '@/schemas'
|
||||
|
||||
import { ActionDialogs } from './components/action-dialogs.tsx'
|
||||
import { ApiTable } from './components/api-table.tsx'
|
||||
import { createColumns } from './components/columns.tsx'
|
||||
import { CrudProvider } from './components/crud-provider.tsx'
|
||||
import { QUERY_KEY } from './constants.ts'
|
||||
|
||||
export default function CrudPage() {
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
const [rowCount, setRowCount] = useState<number>()
|
||||
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize],
|
||||
queryFn: () =>
|
||||
getSysApis({
|
||||
page: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
}),
|
||||
placeholderData: (prev) => prev,
|
||||
})
|
||||
|
||||
const tableData = useMemo<SysApi[]>(() => data?.list ?? [], [data])
|
||||
|
||||
const columns = useMemo(() => createColumns(), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetching && data?.total !== undefined) {
|
||||
setRowCount(data.total)
|
||||
}
|
||||
}, [isFetching, data?.total])
|
||||
|
||||
const table = useReactTable<SysApi>({
|
||||
data: tableData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
rowCount: rowCount,
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
meta: {
|
||||
showLoading: isFetching,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<CrudProvider>
|
||||
<ActionDialogs />
|
||||
|
||||
<ApiTable table={table} />
|
||||
</CrudProvider>
|
||||
)
|
||||
}
|
||||
55
src/pages/system/file/components/columns.tsx
Normal file
55
src/pages/system/file/components/columns.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatDate, formatFileSize } from '@/lib'
|
||||
import type { SysFile } from '@/schemas'
|
||||
|
||||
export const createColumns = (): ColumnDef<SysFile>[] => {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'file_path',
|
||||
header: '文件路径',
|
||||
meta: { className: ' max-w-24' },
|
||||
cell: ({ row }) => (
|
||||
<Button variant='link' onClick={() => window.open(row.getValue('file_path'))}>
|
||||
点击查看
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'file_name',
|
||||
header: '文件名称',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('file_name')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'original_name',
|
||||
header: '原始文件名',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('original_name')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'mime_type',
|
||||
header: '文件类型',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('mime_type')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'file_size',
|
||||
header: '文件大小',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => (
|
||||
<div className='overflow-hidden'>{formatFileSize(row.getValue('file_size'))}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: '上传日期',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const rawDate = row.getValue('created_at') as string
|
||||
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
33
src/pages/system/file/components/crud-provider.tsx
Normal file
33
src/pages/system/file/components/crud-provider.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
|
||||
import { type SysRole } from '@/schemas'
|
||||
|
||||
import type { Action } from '../constants'
|
||||
|
||||
type CrudContextType<T> = {
|
||||
action: Action
|
||||
setAction: (action: Action) => void
|
||||
currentRow: T | null
|
||||
setCurrentRow: (row: T | null) => void
|
||||
}
|
||||
|
||||
export const CrudContext = createContext<CrudContextType<SysRole> | null>(null)
|
||||
|
||||
export const CrudProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [action, setAction] = useState<Action>(null)
|
||||
const [currentRow, setCurrentRow] = useState<SysRole | null>(null)
|
||||
|
||||
return (
|
||||
<CrudContext value={{ action, setAction, currentRow, setCurrentRow }}>{children}</CrudContext>
|
||||
)
|
||||
}
|
||||
|
||||
export const useCrud = () => {
|
||||
const context = useContext(CrudContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('context must be used within a CrudProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
15
src/pages/system/file/components/file-table.tsx
Normal file
15
src/pages/system/file/components/file-table.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
|
||||
import { DataTable } from '@/components/data-table'
|
||||
|
||||
interface Props<TData> {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function FileTable<TData>({ table }: Props<TData>) {
|
||||
return (
|
||||
<>
|
||||
<DataTable table={table} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
7
src/pages/system/file/constants.ts
Normal file
7
src/pages/system/file/constants.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const QUERY_KEY = 'sys_file'
|
||||
|
||||
export const ActionSchema = z.enum([]).nullable()
|
||||
|
||||
export type Action = z.infer<typeof ActionSchema>
|
||||
59
src/pages/system/file/index.tsx
Normal file
59
src/pages/system/file/index.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
||||
|
||||
import type { SysFile } from '@/schemas'
|
||||
|
||||
import { getSysFiles } from '../../../api/system/file.ts'
|
||||
import { createColumns } from './components/columns.tsx'
|
||||
import { CrudProvider } from './components/crud-provider.tsx'
|
||||
import { FileTable } from './components/file-table.tsx'
|
||||
import { QUERY_KEY } from './constants.ts'
|
||||
|
||||
export default function CrudPage() {
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
const [rowCount, setRowCount] = useState<number>()
|
||||
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize],
|
||||
queryFn: () =>
|
||||
getSysFiles({
|
||||
page: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
}),
|
||||
placeholderData: (prev) => prev,
|
||||
})
|
||||
|
||||
const tableData = useMemo<SysFile[]>(() => data?.list ?? [], [data])
|
||||
|
||||
const columns = useMemo(() => createColumns(), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetching && data?.total !== undefined) {
|
||||
setRowCount(data.total)
|
||||
}
|
||||
}, [isFetching, data?.total])
|
||||
|
||||
const table = useReactTable<SysFile>({
|
||||
data: tableData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
rowCount: rowCount,
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
meta: {
|
||||
showLoading: isFetching,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<CrudProvider>
|
||||
<FileTable table={table} />
|
||||
</CrudProvider>
|
||||
)
|
||||
}
|
||||
36
src/pages/system/home/index.tsx
Normal file
36
src/pages/system/home/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ArrowUpRight, FileText, Users } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface QuickMenuItem {
|
||||
title: string
|
||||
description: string
|
||||
path: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
const quickMenus: QuickMenuItem[] = [
|
||||
{ title: '博客列表', description: '查看所有博客文章', path: '/post', icon: FileText },
|
||||
{ title: '用户管理', description: '管理用户信息', path: '/user', icon: Users },
|
||||
]
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4'>
|
||||
{quickMenus.map((menu) => (
|
||||
<Link
|
||||
key={menu.path}
|
||||
to={menu.path}
|
||||
className='group bg-card hover:bg-muted/50 rounded-lg border p-5 transition-colors'
|
||||
>
|
||||
<div className='flex items-center justify-between'>
|
||||
<menu.icon className='text-muted-foreground group-hover:text-foreground size-5 transition-colors' />
|
||||
<ArrowUpRight className='text-muted-foreground size-4 opacity-0 transition-opacity group-hover:opacity-100' />
|
||||
</div>
|
||||
<h3 className='mt-8 text-sm font-medium'>{menu.title}</h3>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>{menu.description}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
363
src/pages/system/menu/components/action-dialog.tsx
Normal file
363
src/pages/system/menu/components/action-dialog.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { createSysMenu, getAllSysMenus, updateSysMenu } from '@/api'
|
||||
import { MenuIcon } from '@/components/menu-icon'
|
||||
import { TreeSelect, type TreeSelectNode } from '@/components/tree-select'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog.tsx'
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Spinner } from '@/components/ui/spinner.tsx'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { MenuIconOptions, MenuType, menuTypeOptions, Status, statusOptions } from '@/enum'
|
||||
import { sysMenuFormSchema, type SysMenu, type SysMenuForm, type SysMenuTree } from '@/schemas'
|
||||
|
||||
interface Props {
|
||||
currentRow?: SysMenuTree | null
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
const ROOT_PARENT_VALUE = '__no_parent__'
|
||||
|
||||
const generateMenuOptions = (
|
||||
data: SysMenu[],
|
||||
parent_id: number | null = null,
|
||||
currentId: number | null = null,
|
||||
): TreeSelectNode[] => {
|
||||
return data
|
||||
.filter((item) => item.parent_id === parent_id)
|
||||
.map((item) => ({
|
||||
id: item.id.toString(),
|
||||
label: item.name,
|
||||
disabled: item.id === currentId,
|
||||
children: generateMenuOptions(data, item.id, currentId),
|
||||
}))
|
||||
}
|
||||
|
||||
export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
|
||||
const isEdit = !!currentRow?.id
|
||||
|
||||
const [menuOptions, setMenuOptions] = useState<TreeSelectNode[]>()
|
||||
|
||||
const formId = useId()
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const form = useForm<SysMenuForm>({
|
||||
resolver: zodResolver(sysMenuFormSchema),
|
||||
defaultValues: currentRow
|
||||
? { ...currentRow }
|
||||
: {
|
||||
name: '',
|
||||
path: '',
|
||||
component: '',
|
||||
hidden: false,
|
||||
sort: 0,
|
||||
type: MenuType.menu,
|
||||
status: Status.enabled,
|
||||
permission_code: '',
|
||||
parent_id: null,
|
||||
icon: null,
|
||||
},
|
||||
})
|
||||
|
||||
const [type] = form.watch(['type'])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const fetchOptions = async () => {
|
||||
const { data } = await getAllSysMenus()
|
||||
const options = generateMenuOptions(data, null, currentRow?.id)
|
||||
setMenuOptions([{ id: ROOT_PARENT_VALUE, label: '无父级' }, ...options])
|
||||
}
|
||||
|
||||
fetchOptions()
|
||||
}, [open, currentRow?.id])
|
||||
|
||||
const onSubmit = async (values: SysMenuForm) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
if (values.id) {
|
||||
await updateSysMenu(values.id, values)
|
||||
} else {
|
||||
await createSysMenu(values)
|
||||
}
|
||||
toast('操作成功!')
|
||||
onConfirm()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent onCloseAutoFocus={() => form.reset()} className='sm:max-w-2xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? '编辑' : '创建'}菜单</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={form.handleSubmit(onSubmit, (err) => {
|
||||
console.log(err)
|
||||
})}
|
||||
>
|
||||
<FieldGroup className='grid grid-cols-2 gap-4'>
|
||||
<Controller
|
||||
name='name'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='name'>菜单名称</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id='name'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='请输入菜单名称'
|
||||
autoComplete='off'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name='type'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel>菜单类型</FieldLabel>
|
||||
<Select
|
||||
value={field.value?.toString()}
|
||||
onValueChange={(value) => field.onChange(Number(value))}
|
||||
>
|
||||
<SelectTrigger className='w-full'>
|
||||
<SelectValue placeholder='请选择菜单类型' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{menuTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name='icon'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel>图标</FieldLabel>
|
||||
<Select
|
||||
value={field.value?.toString()}
|
||||
onValueChange={(value) => field.onChange(Number(value))}
|
||||
>
|
||||
<SelectTrigger className='w-full'>
|
||||
<SelectValue placeholder='请选择菜单图标' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{MenuIconOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.selectValue}>
|
||||
<MenuIcon menuIconKey={option.value} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
{(type === MenuType.directory || type === MenuType.menu) && (
|
||||
<Controller
|
||||
name='path'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='path'>菜单路径</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id='path'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='请输入菜单路径'
|
||||
autoComplete='off'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{type === MenuType.menu && (
|
||||
<Controller
|
||||
name='component'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='component'>组件路径</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id='component'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='请输入组件路径'
|
||||
autoComplete='off'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
name='permission_code'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='permission_code'>权限字符</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id='permission_code'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='请输入权限字符'
|
||||
autoComplete='off'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name='status'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel>菜单状态</FieldLabel>
|
||||
<Select
|
||||
value={field.value?.toString()}
|
||||
onValueChange={(value) => field.onChange(Number(value))}
|
||||
>
|
||||
<SelectTrigger className='w-full'>
|
||||
<SelectValue placeholder='请选择菜单状态' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{statusOptions.map((status) => (
|
||||
<SelectItem key={status.value} value={status.value}>
|
||||
{status.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name='parent_id'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel>父级菜单</FieldLabel>
|
||||
<TreeSelect
|
||||
data={menuOptions}
|
||||
value={field.value == null ? ROOT_PARENT_VALUE : field.value.toString()}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === ROOT_PARENT_VALUE ? null : Number(value))
|
||||
}
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name='sort'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='sort'>排序</FieldLabel>
|
||||
<Input
|
||||
type='number'
|
||||
{...field}
|
||||
id='sort'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='请输入排序'
|
||||
autoComplete='off'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name='hidden'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='hidden'>是否隐藏</FieldLabel>
|
||||
<Switch
|
||||
id='hidden'
|
||||
aria-invalid={fieldState.invalid}
|
||||
name={field.name}
|
||||
disabled={field.disabled}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button disabled={loading} variant='outline'>
|
||||
取消
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button disabled={loading} form={formId} type='submit'>
|
||||
保存
|
||||
{loading && <Spinner data-icon='inline-start' />}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
55
src/pages/system/menu/components/action-dialogs.tsx
Normal file
55
src/pages/system/menu/components/action-dialogs.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { QUERY_KEY } from '../constants.ts'
|
||||
import { ActionsDialog } from './action-dialog.tsx'
|
||||
import { useCrud } from './crud-provider.tsx'
|
||||
import { DeleteDialog } from './delete-dialog.tsx'
|
||||
|
||||
export const ActionDialogs = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { action, setAction, currentRow, setCurrentRow } = useCrud()
|
||||
|
||||
const handleClose = () => {
|
||||
setAction(null)
|
||||
|
||||
setTimeout(() => {
|
||||
setCurrentRow(null)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] })
|
||||
handleClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionsDialog
|
||||
key='add'
|
||||
open={action === 'add'}
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
|
||||
{currentRow && (
|
||||
<>
|
||||
<ActionsDialog
|
||||
currentRow={currentRow}
|
||||
key={`edit-${currentRow.id}`}
|
||||
open={action === 'edit'}
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
key={`delete-${currentRow.id}`}
|
||||
currentRow={currentRow}
|
||||
open={action === 'delete'}
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
92
src/pages/system/menu/components/columns.tsx
Normal file
92
src/pages/system/menu/components/columns.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
|
||||
import { MenuIcon } from '@/components/menu-icon'
|
||||
import { menuTypeDict, statusDict, type MenuTypeValues, type StatusValues } from '@/enum'
|
||||
import { formatDate } from '@/lib'
|
||||
import type { SysMenuTree } from '@/schemas'
|
||||
|
||||
import { RowActions } from './row-actions.tsx'
|
||||
|
||||
export const createColumns = (): ColumnDef<SysMenuTree>[] => {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: '菜单名称',
|
||||
meta: { className: 'min-w-36 max-w-36' },
|
||||
cell: ({ row }) => (
|
||||
<div className='overflow-hidden flex gap-2 items-center'>
|
||||
{row.original.icon && (
|
||||
<MenuIcon className='size-4 text-gray-500' menuIconKey={row.original.icon} />
|
||||
)}
|
||||
{row.getValue('name')}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'path',
|
||||
header: '菜单路径',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('path')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: '菜单类型',
|
||||
meta: { className: 'max-w-20' },
|
||||
cell: ({ row }) => {
|
||||
const menuType = menuTypeDict[row.getValue('type') as MenuTypeValues]
|
||||
return <div>{menuType}</div>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'permission_code',
|
||||
header: '权限字符',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => <div>{row.getValue('permission_code')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: '菜单状态',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const status = statusDict[row.getValue('status') as StatusValues]
|
||||
return <div>{status}</div>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'hidden',
|
||||
header: '是否隐藏',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => <div>{row.getValue('hidden') ? '是' : '否'}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'sort',
|
||||
header: '菜单排序',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => <div>{row.getValue('sort')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: '创建时间',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const rawDate = row.getValue('created_at') as string
|
||||
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: '修改时间',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const rawDate = row.getValue('updated_at') as string
|
||||
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
meta: { className: ' max-w-36 sticky right-0' },
|
||||
cell: RowActions,
|
||||
},
|
||||
]
|
||||
}
|
||||
33
src/pages/system/menu/components/crud-provider.tsx
Normal file
33
src/pages/system/menu/components/crud-provider.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
|
||||
import { type SysMenuTree } from '@/schemas'
|
||||
|
||||
import type { Action } from '../constants'
|
||||
|
||||
type CrudContextType<T> = {
|
||||
action: Action
|
||||
setAction: (action: Action) => void
|
||||
currentRow: T | null
|
||||
setCurrentRow: (row: T | null) => void
|
||||
}
|
||||
|
||||
export const CrudContext = createContext<CrudContextType<SysMenuTree> | null>(null)
|
||||
|
||||
export const CrudProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [action, setAction] = useState<Action>(null)
|
||||
const [currentRow, setCurrentRow] = useState<SysMenuTree | null>(null)
|
||||
|
||||
return (
|
||||
<CrudContext value={{ action, setAction, currentRow, setCurrentRow }}>{children}</CrudContext>
|
||||
)
|
||||
}
|
||||
|
||||
export const useCrud = () => {
|
||||
const context = useContext(CrudContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('context must be used within a CrudProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
57
src/pages/system/menu/components/delete-dialog.tsx
Normal file
57
src/pages/system/menu/components/delete-dialog.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { deleteSysMenu } from '@/api'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import { Spinner } from '@/components/ui/spinner.tsx'
|
||||
import { type SysMenuTree } from '@/schemas'
|
||||
|
||||
interface Props {
|
||||
currentRow: SysMenuTree
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export function DeleteDialog({ open, onClose, currentRow, onConfirm }: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const onDelete = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
await deleteSysMenu(currentRow.id)
|
||||
toast('操作成功!')
|
||||
onConfirm()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onClose}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除菜单?</AlertDialogTitle>
|
||||
<AlertDialogDescription>确认删除【{currentRow?.name}】吗?</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
|
||||
<Button variant='destructive' onClick={onDelete}>
|
||||
确定
|
||||
{loading && <Spinner data-icon='inline-start' />}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
24
src/pages/system/menu/components/menu-table.tsx
Normal file
24
src/pages/system/menu/components/menu-table.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
|
||||
import { DataTable } from '@/components/data-table'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
|
||||
import { useCrud } from './crud-provider.tsx'
|
||||
|
||||
interface Props<TData> {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function MenuTable<TData>({ table }: Props<TData>) {
|
||||
const { setAction } = useCrud()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button className='mb-4' onClick={() => setAction('add')}>
|
||||
创建菜单
|
||||
</Button>
|
||||
|
||||
<DataTable table={table} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
50
src/pages/system/menu/components/row-actions.tsx
Normal file
50
src/pages/system/menu/components/row-actions.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { type Row } from '@tanstack/react-table'
|
||||
import { MoreHorizontal } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu.tsx'
|
||||
import { type SysMenuTree } from '@/schemas'
|
||||
|
||||
import { useCrud } from './crud-provider'
|
||||
|
||||
type Props = {
|
||||
row: Row<SysMenuTree>
|
||||
}
|
||||
|
||||
export function RowActions({ row }: Props) {
|
||||
const { setAction, setCurrentRow } = useCrud()
|
||||
const { original } = row
|
||||
|
||||
const onEdit = () => {
|
||||
setAction('edit')
|
||||
setCurrentRow(original)
|
||||
}
|
||||
|
||||
const onDelete = () => {
|
||||
setAction('delete')
|
||||
setCurrentRow(original)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' className='h-8 w-8 p-0'>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>操作</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onEdit}>编辑菜单</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDelete}>删除菜单</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
7
src/pages/system/menu/constants.ts
Normal file
7
src/pages/system/menu/constants.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const QUERY_KEY = 'sys_menu'
|
||||
|
||||
export const ActionSchema = z.enum(['add', 'edit', 'delete']).nullable()
|
||||
|
||||
export type Action = z.infer<typeof ActionSchema>
|
||||
48
src/pages/system/menu/index.tsx
Normal file
48
src/pages/system/menu/index.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getCoreRowModel, getExpandedRowModel, useReactTable } from '@tanstack/react-table'
|
||||
|
||||
import { getAllSysMenus } from '@/api'
|
||||
import { generateMenus } from '@/lib'
|
||||
import { type SysMenuTree } from '@/schemas'
|
||||
|
||||
import { ActionDialogs } from './components/action-dialogs.tsx'
|
||||
import { createColumns } from './components/columns.tsx'
|
||||
import { CrudProvider } from './components/crud-provider.tsx'
|
||||
import { MenuTable } from './components/menu-table.tsx'
|
||||
import { QUERY_KEY } from './constants.ts'
|
||||
|
||||
export default function CrudPage() {
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: [QUERY_KEY],
|
||||
queryFn: () => getAllSysMenus(),
|
||||
placeholderData: (prev) => prev,
|
||||
})
|
||||
|
||||
const tableData = useMemo<SysMenuTree[]>(
|
||||
() => generateMenus({ menus: data?.data ?? [], showHidden: true, showButton: true }),
|
||||
[data],
|
||||
)
|
||||
|
||||
const columns = useMemo(() => createColumns(), [])
|
||||
|
||||
const table = useReactTable<SysMenuTree>({
|
||||
data: tableData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
manualPagination: true,
|
||||
meta: {
|
||||
showLoading: isFetching,
|
||||
},
|
||||
getSubRows: (row) => row.children,
|
||||
})
|
||||
|
||||
return (
|
||||
<CrudProvider>
|
||||
<ActionDialogs />
|
||||
<MenuTable table={table} />
|
||||
</CrudProvider>
|
||||
)
|
||||
}
|
||||
126
src/pages/system/role/components/action-dialog.tsx
Normal file
126
src/pages/system/role/components/action-dialog.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useId, useState } from 'react'
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import z from 'zod'
|
||||
|
||||
import { createSysRole, updateSysRole } from '@/api'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog.tsx'
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Spinner } from '@/components/ui/spinner.tsx'
|
||||
import { sysRoleFormSchema, type SysRole, type SysRoleForm } from '@/schemas'
|
||||
|
||||
interface Props {
|
||||
currentRow?: SysRole | null
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
|
||||
const isEdit = !!currentRow?.id
|
||||
const formId = useId()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const form = useForm<z.infer<typeof sysRoleFormSchema>>({
|
||||
resolver: zodResolver(sysRoleFormSchema),
|
||||
defaultValues: currentRow
|
||||
? { ...currentRow }
|
||||
: {
|
||||
name: '',
|
||||
code: '',
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (values: SysRoleForm) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
if (values.id) {
|
||||
await updateSysRole(values.id, values)
|
||||
} else {
|
||||
await createSysRole(values)
|
||||
}
|
||||
toast('操作成功!')
|
||||
onConfirm()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent onCloseAutoFocus={() => form.reset()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? '编辑' : '创建'}角色</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FieldGroup className='gap-4'>
|
||||
<Controller
|
||||
name='name'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='name'>角色名称</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id='name'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='请输入角色名称'
|
||||
autoComplete='off'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isEdit && (
|
||||
<>
|
||||
<Controller
|
||||
name='code'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='code'>角色编码</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id='code'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='请输入角色编码'
|
||||
autoComplete='off'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button disabled={loading} variant='outline'>
|
||||
取消
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button disabled={loading} form={formId} type='submit'>
|
||||
保存 {loading && <Spinner data-icon='inline-start' />}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
73
src/pages/system/role/components/action-dialogs.tsx
Normal file
73
src/pages/system/role/components/action-dialogs.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { QUERY_KEY } from '../constants.ts'
|
||||
import { ActionsDialog } from './action-dialog.tsx'
|
||||
import { AssignApisDialog } from './assign-apis-dialog.tsx'
|
||||
import { AssignMenusDialog } from './assign-menus-dialog.tsx'
|
||||
import { useCrud } from './crud-provider.tsx'
|
||||
import { DeleteDialog } from './delete-dialog.tsx'
|
||||
|
||||
export const ActionDialogs = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { action, setAction, currentRow, setCurrentRow } = useCrud()
|
||||
|
||||
const handleClose = () => {
|
||||
setAction(null)
|
||||
|
||||
setTimeout(() => {
|
||||
setCurrentRow(null)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] })
|
||||
handleClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionsDialog
|
||||
key='add'
|
||||
open={action === 'add'}
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
|
||||
{currentRow && (
|
||||
<>
|
||||
<ActionsDialog
|
||||
currentRow={currentRow}
|
||||
key={`edit-${currentRow.id}`}
|
||||
open={action === 'edit'}
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
key={`delete-${currentRow.id}`}
|
||||
currentRow={currentRow}
|
||||
open={action === 'delete'}
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
|
||||
<AssignApisDialog
|
||||
onClose={handleClose}
|
||||
key={`assign-apis-${currentRow.id}`}
|
||||
open={action === 'assign-apis'}
|
||||
currentRow={currentRow}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
|
||||
<AssignMenusDialog
|
||||
onClose={handleClose}
|
||||
key={`assign-menus-${currentRow.id}`}
|
||||
open={action === 'assign-menus'}
|
||||
currentRow={currentRow}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
127
src/pages/system/role/components/assign-apis-dialog.tsx
Normal file
127
src/pages/system/role/components/assign-apis-dialog.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { assignSysRoleApis, getAllSysApi, getRoleApis, getSysApiGroups } from '@/api'
|
||||
import { CheckboxTree, useCheckboxTree, type CheckboxTreeNode } from '@/components/checkbox-tree'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog.tsx'
|
||||
import { Spinner } from '@/components/ui/spinner.tsx'
|
||||
import { HttpMethodClass, type HttpMethodValues } from '@/enum'
|
||||
import type { SysRole } from '@/schemas'
|
||||
|
||||
interface Props {
|
||||
currentRow?: SysRole | null
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export function AssignApisDialog({ open, onClose, onConfirm, currentRow }: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const [treeData, setTreeData] = useState<CheckboxTreeNode[]>([])
|
||||
const { getNodeState, toggleNode, checkedIds, setCheckedIds } = useCheckboxTree(treeData, [])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchApis = async () => {
|
||||
const { data: groups } = await getSysApiGroups()
|
||||
const { data: apis } = await getAllSysApi()
|
||||
const { data: checkedApi } = await getRoleApis(currentRow?.id as number)
|
||||
|
||||
const ids: string[] = checkedApi.map((api) => api.id.toString())
|
||||
|
||||
const options = groups.map((item, idx) => {
|
||||
const nodeId = `group_names_${idx}`
|
||||
|
||||
const children = apis
|
||||
.filter((api) => api.group_name === item)
|
||||
.map((api) => ({ id: api.id.toString(), label: api.name, data: api }))
|
||||
|
||||
const allChildrenChecked = children.every((child) => ids.includes(child.id))
|
||||
|
||||
if (allChildrenChecked) {
|
||||
ids.push(nodeId)
|
||||
}
|
||||
|
||||
return {
|
||||
id: nodeId,
|
||||
label: item,
|
||||
data: {},
|
||||
children,
|
||||
}
|
||||
})
|
||||
|
||||
setTreeData(options)
|
||||
setCheckedIds(ids)
|
||||
}
|
||||
|
||||
if (open && currentRow?.id) fetchApis()
|
||||
}, [open, currentRow, setCheckedIds])
|
||||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const ids = checkedIds
|
||||
.filter((item) => !item.startsWith('group_names_'))
|
||||
.map((item) => parseInt(item))
|
||||
|
||||
await assignSysRoleApis(currentRow?.id as number, ids)
|
||||
toast('操作成功!')
|
||||
onConfirm()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>分配接口权限</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<div className='h-125 overflow-y-auto'>
|
||||
<CheckboxTree
|
||||
data={treeData}
|
||||
getNodeState={getNodeState}
|
||||
onToggle={toggleNode}
|
||||
renderLabel={(node: CheckboxTreeNode) => (
|
||||
<div className='flex gap-2'>
|
||||
{node.data?.method && (
|
||||
<Badge className={HttpMethodClass[node.data.method as HttpMethodValues]}>
|
||||
{node.data.method}
|
||||
</Badge>
|
||||
)}
|
||||
<div>{node.label}</div>
|
||||
{node.data?.path && <div className='text-gray-500'>{node.data.path}</div>}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button disabled={loading} variant='outline'>
|
||||
取消
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button disabled={loading} onClick={handleConfirm}>
|
||||
保存
|
||||
{loading && <Spinner data-icon='inline-start' />}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
106
src/pages/system/role/components/assign-menus-dialog.tsx
Normal file
106
src/pages/system/role/components/assign-menus-dialog.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { assignSysRoleMenus, getAllSysMenus, getSysRoleMenus } from '@/api'
|
||||
import { CheckboxTree, useCheckboxTree, type CheckboxTreeNode } from '@/components/checkbox-tree'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog.tsx'
|
||||
import { Spinner } from '@/components/ui/spinner.tsx'
|
||||
import type { SysMenu, SysRole } from '@/schemas'
|
||||
|
||||
interface Props {
|
||||
currentRow?: SysRole | null
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
const buildTreeData = (data: SysMenu[], parentId: string | null = null): CheckboxTreeNode[] => {
|
||||
return data
|
||||
.filter((item) => (item.parent_id?.toString() ?? null) === parentId)
|
||||
.map((item) => {
|
||||
const children = buildTreeData(data, item.id.toString())
|
||||
return {
|
||||
id: item.id.toString(),
|
||||
label: item.name,
|
||||
data: item,
|
||||
children: children.length > 0 ? children : [],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function AssignMenusDialog({ open, onClose, onConfirm, currentRow }: Props) {
|
||||
const [treeData, setTreeData] = useState<CheckboxTreeNode[]>([])
|
||||
const { getNodeState, toggleNode, checkedIds, setCheckedIds } = useCheckboxTree(treeData, [])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMenus = async () => {
|
||||
const { data: menus } = await getAllSysMenus()
|
||||
const { data: checkedMenus } = await getSysRoleMenus(currentRow?.id as number)
|
||||
setTreeData(buildTreeData(menus))
|
||||
setCheckedIds(checkedMenus.map((item) => item.id.toString()))
|
||||
}
|
||||
|
||||
if (open && currentRow?.id) fetchMenus()
|
||||
}, [open, currentRow, setCheckedIds])
|
||||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
await assignSysRoleMenus(
|
||||
currentRow?.id as number,
|
||||
checkedIds.map((item) => parseInt(item)),
|
||||
)
|
||||
toast('操作成功!')
|
||||
onConfirm()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>分配菜单权限</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<div className='h-125 overflow-y-auto'>
|
||||
<CheckboxTree
|
||||
data={treeData}
|
||||
getNodeState={getNodeState}
|
||||
onToggle={toggleNode}
|
||||
renderLabel={(node: CheckboxTreeNode) => (
|
||||
<div className='flex gap-2'>
|
||||
<div>{node.data?.name}</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button disabled={loading} variant='outline'>
|
||||
取消
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button disabled={loading} onClick={handleConfirm}>
|
||||
保存
|
||||
{loading && <Spinner data-icon='inline-start' />}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
47
src/pages/system/role/components/columns.tsx
Normal file
47
src/pages/system/role/components/columns.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
|
||||
import { formatDate } from '@/lib'
|
||||
import type { SysRole } from '@/schemas'
|
||||
|
||||
import { RowActions } from './row-actions'
|
||||
|
||||
export const createColumns = (): ColumnDef<SysRole>[] => {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: '角色名称',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('name')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'code',
|
||||
header: '角色编码',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('code')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: '创建时间',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const rawDate = row.getValue('created_at') as string
|
||||
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: '修改时间',
|
||||
meta: { className: ' max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const rawDate = row.getValue('updated_at') as string
|
||||
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
meta: { className: ' max-w-36 sticky right-0' },
|
||||
cell: RowActions,
|
||||
},
|
||||
]
|
||||
}
|
||||
33
src/pages/system/role/components/crud-provider.tsx
Normal file
33
src/pages/system/role/components/crud-provider.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
|
||||
import { type SysRole } from '@/schemas'
|
||||
|
||||
import type { Action } from '../constants'
|
||||
|
||||
type CrudContextType<T> = {
|
||||
action: Action
|
||||
setAction: (action: Action) => void
|
||||
currentRow: T | null
|
||||
setCurrentRow: (row: T | null) => void
|
||||
}
|
||||
|
||||
export const CrudContext = createContext<CrudContextType<SysRole> | null>(null)
|
||||
|
||||
export const CrudProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [action, setAction] = useState<Action>(null)
|
||||
const [currentRow, setCurrentRow] = useState<SysRole | null>(null)
|
||||
|
||||
return (
|
||||
<CrudContext value={{ action, setAction, currentRow, setCurrentRow }}>{children}</CrudContext>
|
||||
)
|
||||
}
|
||||
|
||||
export const useCrud = () => {
|
||||
const context = useContext(CrudContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('context must be used within a CrudProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
56
src/pages/system/role/components/delete-dialog.tsx
Normal file
56
src/pages/system/role/components/delete-dialog.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { deleteSysRole } from '@/api'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import { Spinner } from '@/components/ui/spinner.tsx'
|
||||
import { type SysRole } from '@/schemas'
|
||||
|
||||
interface Props {
|
||||
currentRow: SysRole
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export function DeleteDialog({ open, onClose, currentRow, onConfirm }: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const onDelete = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
await deleteSysRole(currentRow.id)
|
||||
toast('操作成功!')
|
||||
onConfirm()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onClose}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除角色?</AlertDialogTitle>
|
||||
<AlertDialogDescription>确认删除【{currentRow?.name}】吗?</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
|
||||
<Button disabled={loading} variant='destructive' onClick={onDelete}>
|
||||
确定 {loading && <Spinner data-icon='inline-start' />}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
24
src/pages/system/role/components/role-table.tsx
Normal file
24
src/pages/system/role/components/role-table.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
|
||||
import { DataTable } from '@/components/data-table'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
|
||||
import { useCrud } from './crud-provider.tsx'
|
||||
|
||||
interface Props<TData> {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function RoleTable<TData>({ table }: Props<TData>) {
|
||||
const { setAction } = useCrud()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button className='mb-4' onClick={() => setAction('add')}>
|
||||
创建角色
|
||||
</Button>
|
||||
|
||||
<DataTable table={table} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
62
src/pages/system/role/components/row-actions.tsx
Normal file
62
src/pages/system/role/components/row-actions.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { type Row } from '@tanstack/react-table'
|
||||
import { MoreHorizontal } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu.tsx'
|
||||
import { type SysRole } from '@/schemas'
|
||||
|
||||
import { useCrud } from './crud-provider'
|
||||
|
||||
type Props = {
|
||||
row: Row<SysRole>
|
||||
}
|
||||
|
||||
export function RowActions({ row }: Props) {
|
||||
const { setAction, setCurrentRow } = useCrud()
|
||||
const { original } = row
|
||||
|
||||
const onEdit = () => {
|
||||
setAction('edit')
|
||||
setCurrentRow(original)
|
||||
}
|
||||
|
||||
const onDelete = () => {
|
||||
setAction('delete')
|
||||
setCurrentRow(original)
|
||||
}
|
||||
|
||||
const assignApis = () => {
|
||||
setAction('assign-apis')
|
||||
setCurrentRow(original)
|
||||
}
|
||||
|
||||
const assignMenus = () => {
|
||||
setAction('assign-menus')
|
||||
setCurrentRow(original)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' className='h-8 w-8 p-0'>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>操作</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onEdit}>编辑角色</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={assignApis}>分配接口权限</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={assignMenus}>分配菜单权限</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDelete}>删除角色</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
9
src/pages/system/role/constants.ts
Normal file
9
src/pages/system/role/constants.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const QUERY_KEY = 'sys_role'
|
||||
|
||||
export const ActionSchema = z
|
||||
.enum(['add', 'edit', 'delete', 'assign-apis', 'assign-menus'])
|
||||
.nullable()
|
||||
|
||||
export type Action = z.infer<typeof ActionSchema>
|
||||
62
src/pages/system/role/index.tsx
Normal file
62
src/pages/system/role/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
||||
|
||||
import { getSysRoles } from '@/api'
|
||||
import { type SysRole } from '@/schemas'
|
||||
|
||||
import { ActionDialogs } from './components/action-dialogs.tsx'
|
||||
import { createColumns } from './components/columns.tsx'
|
||||
import { CrudProvider } from './components/crud-provider.tsx'
|
||||
import { RoleTable } from './components/role-table.tsx'
|
||||
import { QUERY_KEY } from './constants.ts'
|
||||
|
||||
export default function CrudPage() {
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
const [rowCount, setRowCount] = useState<number>()
|
||||
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize],
|
||||
queryFn: () =>
|
||||
getSysRoles({
|
||||
page: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
}),
|
||||
placeholderData: (prev) => prev,
|
||||
})
|
||||
|
||||
const tableData = useMemo<SysRole[]>(() => data?.list ?? [], [data])
|
||||
|
||||
const columns = useMemo(() => createColumns(), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetching && data?.total !== undefined) {
|
||||
setRowCount(data.total)
|
||||
}
|
||||
}, [isFetching, data?.total])
|
||||
|
||||
const table = useReactTable<SysRole>({
|
||||
data: tableData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
rowCount: rowCount,
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
meta: {
|
||||
showLoading: isFetching,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<CrudProvider>
|
||||
<ActionDialogs />
|
||||
|
||||
<RoleTable table={table} />
|
||||
</CrudProvider>
|
||||
)
|
||||
}
|
||||
192
src/pages/system/user/components/action-dialog.tsx
Normal file
192
src/pages/system/user/components/action-dialog.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useId, useState } from 'react'
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { createSysUser, updateSysUser } from '@/api'
|
||||
import { FileUpload } from '@/components/file-upload'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog.tsx'
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Spinner } from '@/components/ui/spinner.tsx'
|
||||
import {
|
||||
sysUserFormSchema,
|
||||
type SysUser,
|
||||
type SysUserFormInput,
|
||||
type SysUserFormOutput,
|
||||
} from '@/schemas'
|
||||
|
||||
interface Props {
|
||||
currentRow?: SysUser | null
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
|
||||
const isEdit = !!currentRow?.id
|
||||
const formId = useId()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const form = useForm<SysUserFormInput, undefined, SysUserFormOutput>({
|
||||
resolver: zodResolver(sysUserFormSchema),
|
||||
defaultValues: currentRow
|
||||
? {
|
||||
...currentRow,
|
||||
avatar: currentRow.avatar_id
|
||||
? [{ id: currentRow.avatar_id, file_path: currentRow.avatar_url }]
|
||||
: [],
|
||||
}
|
||||
: {
|
||||
username: '',
|
||||
account: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
avatar: [],
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (values: SysUserFormOutput) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
if (values.id) {
|
||||
await updateSysUser(values.id, values)
|
||||
} else {
|
||||
await createSysUser(values)
|
||||
}
|
||||
|
||||
toast('操作成功!')
|
||||
onConfirm()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent onCloseAutoFocus={() => form.reset()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? '编辑' : '创建'}用户</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FieldGroup className='gap-4'>
|
||||
<Controller
|
||||
name='avatar'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => {
|
||||
return (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel>用户头像</FieldLabel>
|
||||
<FileUpload defaultFiles={field.value} onChange={field.onChange} />
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name='username'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='username'>用户名称</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id='username'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='请输入用户名称'
|
||||
autoComplete='off'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isEdit && (
|
||||
<>
|
||||
<Controller
|
||||
name='account'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='account'>账户名称</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id='account'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='请输入账户名称'
|
||||
autoComplete='off'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name='password'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='password'>用户密码</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id='password'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='请输入用户密码'
|
||||
autoComplete='off'
|
||||
type='password'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name='confirmPassword'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='confirmPassword'>确认密码</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id='confirmPassword'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='请输入确认密码'
|
||||
autoComplete='off'
|
||||
type='password'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button disabled={loading} variant='outline'>
|
||||
取消
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button disabled={loading} form={formId} type='submit'>
|
||||
保存
|
||||
{loading && <Spinner data-icon='inline-start' />}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
73
src/pages/system/user/components/action-dialogs.tsx
Normal file
73
src/pages/system/user/components/action-dialogs.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { QUERY_KEY } from '../constants.ts'
|
||||
import { ActionsDialog } from './action-dialog.tsx'
|
||||
import { AssignRolesDialog } from './assign-roles-dialog.tsx'
|
||||
import { ChangePasswordDialog } from './change-password-dialog.tsx'
|
||||
import { useCrud } from './crud-provider.tsx'
|
||||
import { DeleteDialog } from './delete-dialog.tsx'
|
||||
|
||||
export const ActionDialogs = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { action, setAction, currentRow, setCurrentRow } = useCrud()
|
||||
|
||||
const handleClose = () => {
|
||||
setAction(null)
|
||||
|
||||
setTimeout(() => {
|
||||
setCurrentRow(null)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] })
|
||||
handleClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionsDialog
|
||||
key='add'
|
||||
open={action === 'add'}
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
|
||||
{currentRow && (
|
||||
<>
|
||||
<ActionsDialog
|
||||
currentRow={currentRow}
|
||||
key={`edit-${currentRow.id}`}
|
||||
open={action === 'edit'}
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
|
||||
<ChangePasswordDialog
|
||||
currentRow={currentRow}
|
||||
key={`change-password-${currentRow.id}`}
|
||||
open={action === 'change-password'}
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
|
||||
<AssignRolesDialog
|
||||
currentRow={currentRow}
|
||||
key={`assign-user-roles-${currentRow.id}`}
|
||||
open={action === 'assign-roles'}
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
key={`delete-${currentRow.id}`}
|
||||
currentRow={currentRow}
|
||||
open={action === 'delete'}
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
112
src/pages/system/user/components/assign-roles-dialog.tsx
Normal file
112
src/pages/system/user/components/assign-roles-dialog.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { assignSysUserRoles, getAllSysRoles, getSysUserRoles } from '@/api'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog.tsx'
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from '@/components/ui/field.tsx'
|
||||
import { Spinner } from '@/components/ui/spinner.tsx'
|
||||
import type { SysRole, SysUser } from '@/schemas'
|
||||
|
||||
interface Props {
|
||||
currentRow?: SysUser | null
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export function AssignRolesDialog({ open, onClose, onConfirm, currentRow }: Props) {
|
||||
const [roles, setRoles] = useState<SysRole[]>([])
|
||||
const [checkedIds, setCheckedIds] = useState<number[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
await assignSysUserRoles(currentRow!.id, checkedIds)
|
||||
onConfirm()
|
||||
toast('操作成功!')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckedChange = (id: number, checked: boolean) => {
|
||||
setCheckedIds((prev) => (checked ? [...prev, id] : prev.filter((v) => v !== id)))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserRoles = async () => {
|
||||
const { data } = await getAllSysRoles()
|
||||
const { data: userRoles } = await getSysUserRoles(currentRow!.id)
|
||||
setRoles(data)
|
||||
setCheckedIds(userRoles.map((role) => role.id))
|
||||
}
|
||||
|
||||
fetchUserRoles()
|
||||
}, [currentRow])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>分配角色</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<div className='h-125 overflow-y-auto'>
|
||||
<FieldSet>
|
||||
<FieldLegend variant='label'>用户【{currentRow?.username}】角色信息:</FieldLegend>
|
||||
<FieldDescription></FieldDescription>
|
||||
<FieldGroup className='gap-3'>
|
||||
{roles.map((role) => {
|
||||
return (
|
||||
<Field orientation='horizontal' key={role.id}>
|
||||
<Checkbox
|
||||
id={role.id.toString()}
|
||||
name={role.id.toString()}
|
||||
checked={checkedIds.includes(role.id)}
|
||||
onCheckedChange={(checked) => handleCheckedChange(role.id, !!checked)}
|
||||
/>
|
||||
<FieldLabel htmlFor={role.id.toString()} className='font-normal'>
|
||||
{role.name}
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
)
|
||||
})}
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button disabled={loading} variant='outline'>
|
||||
取消
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button disabled={loading} onClick={handleConfirm}>
|
||||
保存
|
||||
{loading && <Spinner data-icon='inline-start' />}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
119
src/pages/system/user/components/change-password-dialog.tsx
Normal file
119
src/pages/system/user/components/change-password-dialog.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useId, useState } from 'react'
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { changeSysUserPassword } from '@/api'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog.tsx'
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Spinner } from '@/components/ui/spinner.tsx'
|
||||
import { changePasswordFormSchema, type ChangePasswordForm, type SysUser } from '@/schemas'
|
||||
|
||||
interface Props {
|
||||
currentRow?: SysUser | null
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export function ChangePasswordDialog({ open, currentRow, onClose, onConfirm }: Props) {
|
||||
const formId = useId()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const form = useForm<z.infer<typeof changePasswordFormSchema>>({
|
||||
resolver: zodResolver(changePasswordFormSchema),
|
||||
defaultValues: currentRow ? { password: '' } : { password: '' },
|
||||
})
|
||||
|
||||
const onSubmit = async (values: ChangePasswordForm) => {
|
||||
if (!currentRow?.id) return
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
await changeSysUserPassword(currentRow.id, values.password)
|
||||
toast('操作成功!')
|
||||
onConfirm()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent onCloseAutoFocus={() => form.reset()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>修改用户密码</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FieldGroup className='gap-4'>
|
||||
<>
|
||||
<Controller
|
||||
name='password'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='password'>用户密码</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id='password'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='请输入用户密码'
|
||||
autoComplete='off'
|
||||
type='password'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name='confirmPassword'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='confirmPassword'>确认密码</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id='confirmPassword'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='请输入确认密码'
|
||||
autoComplete='off'
|
||||
type='password'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button disabled={loading} variant='outline'>
|
||||
取消
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button disabled={loading} form={formId} type='submit'>
|
||||
保存
|
||||
{loading && <Spinner data-icon='inline-start' />}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
59
src/pages/system/user/components/columns.tsx
Normal file
59
src/pages/system/user/components/columns.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { formatDate } from '@/lib'
|
||||
import type { SysUser } from '@/schemas'
|
||||
|
||||
import { RowActions } from './row-actions'
|
||||
|
||||
export const createColumns = (): ColumnDef<SysUser>[] => {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'avatar_url',
|
||||
header: '用户头像',
|
||||
meta: { className: 'w-[120px]' },
|
||||
cell: ({ row }) => (
|
||||
<Avatar size='lg'>
|
||||
<AvatarImage src={row.getValue('avatar_url')} />
|
||||
<AvatarFallback>头像</AvatarFallback>
|
||||
</Avatar>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'account',
|
||||
header: '账号',
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('account')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'username',
|
||||
header: '用户名',
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('username')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: '创建时间',
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const rawDate = row.getValue('created_at') as string
|
||||
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: '修改时间',
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const rawDate = row.getValue('updated_at') as string
|
||||
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
meta: { className: 'max-w-36 sticky right-0' },
|
||||
cell: RowActions,
|
||||
},
|
||||
]
|
||||
}
|
||||
33
src/pages/system/user/components/crud-provider.tsx
Normal file
33
src/pages/system/user/components/crud-provider.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
|
||||
import { type SysUser } from '@/schemas'
|
||||
|
||||
import type { Action } from '../constants'
|
||||
|
||||
type CrudContextType<T> = {
|
||||
action: Action
|
||||
setAction: (action: Action) => void
|
||||
currentRow: T | null
|
||||
setCurrentRow: (row: T | null) => void
|
||||
}
|
||||
|
||||
export const CrudContext = createContext<CrudContextType<SysUser> | null>(null)
|
||||
|
||||
export const CrudProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [action, setAction] = useState<Action>(null)
|
||||
const [currentRow, setCurrentRow] = useState<SysUser | null>(null)
|
||||
|
||||
return (
|
||||
<CrudContext value={{ action, setAction, currentRow, setCurrentRow }}>{children}</CrudContext>
|
||||
)
|
||||
}
|
||||
|
||||
export const useCrud = () => {
|
||||
const context = useContext(CrudContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('context must be used within a CrudProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
57
src/pages/system/user/components/delete-dialog.tsx
Normal file
57
src/pages/system/user/components/delete-dialog.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { deleteSysUser } from '@/api'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import { Spinner } from '@/components/ui/spinner.tsx'
|
||||
import { type SysUser } from '@/schemas'
|
||||
|
||||
interface Props {
|
||||
currentRow: SysUser
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export function DeleteDialog({ open, onClose, currentRow, onConfirm }: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const onDelete = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
await deleteSysUser(currentRow.id)
|
||||
toast('操作成功!')
|
||||
onConfirm()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onClose}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除用户?</AlertDialogTitle>
|
||||
<AlertDialogDescription>确认删除【{currentRow?.account}】吗?</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
|
||||
<Button disabled={loading} variant='destructive' onClick={onDelete}>
|
||||
确定
|
||||
{loading && <Spinner data-icon='inline-start' />}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
62
src/pages/system/user/components/row-actions.tsx
Normal file
62
src/pages/system/user/components/row-actions.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { type Row } from '@tanstack/react-table'
|
||||
import { MoreHorizontal } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu.tsx'
|
||||
import { type SysUser } from '@/schemas'
|
||||
|
||||
import { useCrud } from './crud-provider'
|
||||
|
||||
type Props = {
|
||||
row: Row<SysUser>
|
||||
}
|
||||
|
||||
export function RowActions({ row }: Props) {
|
||||
const { setAction, setCurrentRow } = useCrud()
|
||||
const { original } = row
|
||||
|
||||
const onEdit = () => {
|
||||
setAction('edit')
|
||||
setCurrentRow(original)
|
||||
}
|
||||
|
||||
const onDelete = () => {
|
||||
setAction('delete')
|
||||
setCurrentRow(original)
|
||||
}
|
||||
|
||||
const assignUserRoles = () => {
|
||||
setAction('assign-roles')
|
||||
setCurrentRow(original)
|
||||
}
|
||||
|
||||
const changePassword = () => {
|
||||
setAction('change-password')
|
||||
setCurrentRow(original)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' className='h-8 w-8 p-0'>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>操作</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onEdit}>编辑用户</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={assignUserRoles}>分配角色</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={changePassword}>修改密码</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDelete}>删除用户</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
24
src/pages/system/user/components/user-table.tsx
Normal file
24
src/pages/system/user/components/user-table.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
|
||||
import { DataTable } from '@/components/data-table'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
|
||||
import { useCrud } from './crud-provider.tsx'
|
||||
|
||||
interface Props<TData> {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function UserTable<TData>({ table }: Props<TData>) {
|
||||
const { setAction } = useCrud()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button className='mb-4' onClick={() => setAction('add')}>
|
||||
创建用户
|
||||
</Button>
|
||||
|
||||
<DataTable table={table} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
9
src/pages/system/user/constants.ts
Normal file
9
src/pages/system/user/constants.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const QUERY_KEY = 'sys_user'
|
||||
|
||||
export const ActionSchema = z
|
||||
.enum(['add', 'edit', 'delete', 'assign-roles', 'change-password'])
|
||||
.nullable()
|
||||
|
||||
export type Action = z.infer<typeof ActionSchema>
|
||||
62
src/pages/system/user/index.tsx
Normal file
62
src/pages/system/user/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
||||
|
||||
import { getSysUsers } from '@/api'
|
||||
import { type SysUser } from '@/schemas'
|
||||
|
||||
import { ActionDialogs } from './components/action-dialogs.tsx'
|
||||
import { createColumns } from './components/columns.tsx'
|
||||
import { CrudProvider } from './components/crud-provider.tsx'
|
||||
import { UserTable } from './components/user-table.tsx'
|
||||
import { QUERY_KEY } from './constants.ts'
|
||||
|
||||
export default function CrudPage() {
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
const [rowCount, setRowCount] = useState<number>()
|
||||
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize],
|
||||
queryFn: () =>
|
||||
getSysUsers({
|
||||
page: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
}),
|
||||
placeholderData: (prev) => prev,
|
||||
})
|
||||
|
||||
const tableData = useMemo<SysUser[]>(() => data?.list ?? [], [data])
|
||||
|
||||
const columns = useMemo(() => createColumns(), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetching && data?.total !== undefined) {
|
||||
setRowCount(data.total)
|
||||
}
|
||||
}, [isFetching, data?.total])
|
||||
|
||||
const table = useReactTable<SysUser>({
|
||||
data: tableData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
rowCount: rowCount,
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
meta: {
|
||||
showLoading: isFetching,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<CrudProvider>
|
||||
<ActionDialogs />
|
||||
|
||||
<UserTable table={table} />
|
||||
</CrudProvider>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user