feat: release v1.0.0

This commit is contained in:
2026-07-29 22:10:33 +08:00
parent 3abf272e33
commit 97235386ca
98 changed files with 2389 additions and 467 deletions

View File

@@ -41,6 +41,7 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
: {
name: '',
code: '',
sort: 0,
},
})
@@ -75,10 +76,10 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='name'></FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Input
{...field}
id='name'
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入分类名称'
autoComplete='off'
@@ -95,10 +96,10 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='code'></FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Input
{...field}
id='code'
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入分类编码'
autoComplete='off'
@@ -109,6 +110,25 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
/>
</>
)}
<Controller
name='sort'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Input
type='number'
{...field}
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入排序'
autoComplete='off'
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
</FieldGroup>
</form>

View File

@@ -1,5 +1,7 @@
import { type Table } from '@tanstack/react-table'
import { Plus } from 'lucide-react'
import { Auth } from '@/components/auth'
import { DataTable } from '@/components/data-table'
import { Button } from '@/components/ui/button.tsx'
@@ -13,12 +15,18 @@ export function CategoryTable<TData>({ table }: Props<TData>) {
const { setAction } = useCrud()
return (
<>
<Button className='mb-4' onClick={() => setAction('add')}>
</Button>
<DataTable table={table} />
</>
<Auth authority='category:list'>
<DataTable
table={table}
toolbarLeft={
<Auth authority='category:create'>
<Button size='sm' onClick={() => setAction('add')}>
<Plus />
</Button>
</Auth>
}
/>
</Auth>
)
}

View File

@@ -1,12 +1,14 @@
import type { ColumnDef } from '@tanstack/react-table'
import { formatDate } from '@/lib'
import { formatDate, hasPermission } from '@/lib'
import type { Category } from '@/schemas'
import { RowActions } from './row-actions'
export const createColumns = (): ColumnDef<Category>[] => {
return [
const showAction = hasPermission(['category:update', 'category:delete'], 'any')
const columns: ColumnDef<Category>[] = [
{
accessorKey: 'name',
header: '分类名称',
@@ -19,6 +21,12 @@ export const createColumns = (): ColumnDef<Category>[] => {
meta: { className: ' max-w-36' },
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('code')}</div>,
},
{
accessorKey: 'sort',
header: '排序',
meta: { className: ' max-w-36' },
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('sort')}</div>,
},
{
accessorKey: 'created_at',
header: '创建时间',
@@ -37,11 +45,15 @@ export const createColumns = (): ColumnDef<Category>[] => {
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
},
},
{
]
if (showAction) {
columns.push({
id: 'actions',
enableHiding: false,
meta: { className: ' max-w-36 sticky right-0' },
cell: RowActions,
},
]
})
}
return columns
}

View File

@@ -1,6 +1,7 @@
import { type Row } from '@tanstack/react-table'
import { MoreHorizontal } from 'lucide-react'
import { Auth } from '@/components/auth'
import { Button } from '@/components/ui/button.tsx'
import {
DropdownMenu,
@@ -42,8 +43,12 @@ export function RowActions({ row }: Props) {
<DropdownMenuContent align='end'>
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onEdit}></DropdownMenuItem>
<DropdownMenuItem onClick={onDelete}></DropdownMenuItem>
<Auth authority='category:update'>
<DropdownMenuItem onClick={onEdit}></DropdownMenuItem>
</Auth>
<Auth authority='category:delete'>
<DropdownMenuItem onClick={onDelete}></DropdownMenuItem>
</Auth>
</DropdownMenuContent>
</DropdownMenu>
)

View File

@@ -1,9 +1,10 @@
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
import { getCategories } from '@/api'
import { hasPermission } from '@/lib'
import { type Category } from '@/schemas'
import { ActionDialogs } from './components/action-dialogs.tsx'
@@ -17,9 +18,8 @@ export default function CrudPage() {
pageIndex: 0,
pageSize: 10,
})
const [rowCount, setRowCount] = useState<number>()
const { data, isFetching } = useQuery({
const { data, isFetching, refetch } = useQuery({
queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize],
queryFn: () =>
getCategories({
@@ -27,28 +27,24 @@ export default function CrudPage() {
page_size: pagination.pageSize,
}),
placeholderData: (prev) => prev,
enabled: hasPermission('category:list'),
})
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,
rowCount: data?.total,
state: { pagination },
onPaginationChange: setPagination,
meta: {
showLoading: isFetching,
isFetching,
refetch,
},
})

View File

@@ -6,20 +6,20 @@ 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 { createPost, getPost, listAllCategories, listAllTags, updatePost } from '@/api'
import { WEditor } from '@/components/editor'
import { MultipleSelect, type MultipleSelectOption } from '@/components/multiple-select'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/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'
} from '@/components/ui/field.tsx'
import { Input } from '@/components/ui/input.tsx'
import {
Select,
SelectContent,
@@ -32,6 +32,7 @@ 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 { postFormSchema, type Category, type PostFormInput, type PostFormOutput } from '@/schemas'
import { CoverUpload } from './components/cover-upload.tsx'
import { DatetimePicker } from './components/datetime-picker.tsx'
@@ -42,6 +43,7 @@ export default function PostEdit() {
const { id } = useSearch({ strict: false })
const [editorContent, setEditorContent] = useState('')
const [categories, setCategories] = useState<Category[]>([])
const [tags, setTags] = useState<MultipleSelectOption[]>([])
const [loading, setLoading] = useState(false)
const form = useForm<PostFormInput, undefined, PostFormOutput>({
@@ -56,12 +58,16 @@ export default function PostEdit() {
cover: undefined,
sort: 0,
category_id: undefined,
tags: [],
},
})
const fetchCategories = async () => {
const { data } = await listAllCategories()
setCategories(data)
const { data: tags } = await listAllTags()
setTags(tags.map((tag) => ({ label: tag.name, value: tag.id })))
}
const fetchPostDetail = useCallback(
@@ -174,7 +180,7 @@ export default function PostEdit() {
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='status'></FieldLabel>
<FieldLabel></FieldLabel>
<ToggleGroup
type='single'
value={field.value.toString()}
@@ -189,7 +195,7 @@ export default function PostEdit() {
value={item.value}
className='flex-1 flex size-16 flex-col items-center justify-center rounded-xl'
>
{item.icon}
<item.icon />
<span className='text-xs text-muted-foreground'>{item.label}</span>
</ToggleGroupItem>
))}
@@ -216,12 +222,12 @@ export default function PostEdit() {
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel></FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Select
value={field.value?.toString()}
onValueChange={(value) => field.onChange(Number(value))}
>
<SelectTrigger className='w-full'>
<SelectTrigger id={`${formId}-${field.name}`} className='w-full'>
<SelectValue placeholder='请选择文章分类' />
</SelectTrigger>
<SelectContent>
@@ -239,15 +245,32 @@ export default function PostEdit() {
)}
/>
<Controller
name='tags'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel></FieldLabel>
<MultipleSelect
placeholder='请选择标签'
items={tags}
value={field.value}
onValueChange={(value) => field.onChange(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>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Input
{...field}
id='sort'
id={`${formId}-${field.name}`}
type='number'
aria-invalid={fieldState.invalid}
placeholder='请输入文章排序'
@@ -272,10 +295,10 @@ export default function PostEdit() {
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='slug'>URL </FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}>URL </FieldLabel>
<Input
{...field}
id='slug'
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入URL 别名'
autoComplete='off'
@@ -299,10 +322,10 @@ export default function PostEdit() {
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='summary'>SEO摘要</FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}>SEO摘要</FieldLabel>
<Textarea
{...field}
id='summary'
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入'
autoComplete='off'

View File

@@ -1,16 +1,19 @@
import { Link } from '@tanstack/react-router'
import type { ColumnDef } from '@tanstack/react-table'
import { Auth } from '@/components/auth'
import { Badge } from '@/components/ui/badge'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { postStatusOptions } from '@/enum'
import { formatDate } from '@/lib'
import { formatDate, hasPermission } from '@/lib'
import type { Post } from '@/schemas'
import { RowActions } from './row-actions'
export const createColumns = (): ColumnDef<Post>[] => {
return [
const showAction = hasPermission(['post:update', 'post:delete'], 'any')
const columns: ColumnDef<Post>[] = [
{
accessorKey: 'title',
header: '文章标题',
@@ -18,9 +21,11 @@ export const createColumns = (): ColumnDef<Post>[] => {
cell: ({ row }) => (
<Tooltip>
<TooltipTrigger className='w-full truncate'>
<Link className='underline' to={`/post/edit?id=${row.original.id}`}>
{row.getValue('title')}
</Link>
<Auth authority='post:update' fallback={row.getValue('title')}>
<Link className='underline' to={`/post/edit?id=${row.original.id}`}>
{row.getValue('title')}
</Link>
</Auth>
</TooltipTrigger>
<TooltipContent>{row.getValue('title')}</TooltipContent>
</Tooltip>
@@ -47,13 +52,14 @@ export const createColumns = (): ColumnDef<Post>[] => {
header: '状态',
meta: { className: 'max-w-36' },
cell: ({ row }) => {
const findOption = postStatusOptions.find(
const option = postStatusOptions.find(
(item) => Number(item.value) === row.getValue('status'),
)
return (
<Badge className={findOption?.className}>
{findOption?.icon}
{findOption?.label}
<Badge className={option?.className}>
{option?.icon && <option.icon />}
{option?.label}
</Badge>
)
},
@@ -92,11 +98,16 @@ export const createColumns = (): ColumnDef<Post>[] => {
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
},
},
{
]
if (showAction) {
columns.push({
id: 'actions',
enableHiding: false,
meta: { className: 'max-w-36 sticky right-0' },
cell: RowActions,
},
]
})
}
return columns
}

View File

@@ -1,6 +1,8 @@
import { useNavigate } from '@tanstack/react-router'
import { type Table } from '@tanstack/react-table'
import { Plus } from 'lucide-react'
import { Auth } from '@/components/auth'
import { DataTable } from '@/components/data-table'
import { Button } from '@/components/ui/button.tsx'
@@ -12,12 +14,18 @@ export function PostTable<TData>({ table }: Props<TData>) {
const navigate = useNavigate()
return (
<>
<Button className='mb-4' onClick={() => navigate({ to: '/post/edit' })}>
</Button>
<DataTable table={table} />
</>
<Auth authority='post:list'>
<DataTable
table={table}
toolbarLeft={
<Auth authority='post:create'>
<Button size='sm' onClick={() => navigate({ to: '/post/edit' })}>
<Plus />
</Button>
</Auth>
}
/>
</Auth>
)
}

View File

@@ -2,6 +2,7 @@ import { useNavigate } from '@tanstack/react-router'
import { type Row } from '@tanstack/react-table'
import { MoreHorizontal } from 'lucide-react'
import { Auth } from '@/components/auth'
import { Button } from '@/components/ui/button.tsx'
import {
DropdownMenu,
@@ -46,8 +47,12 @@ export function RowActions({ row }: Props) {
<DropdownMenuContent align='end'>
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onEdit}></DropdownMenuItem>
<DropdownMenuItem onClick={onDelete}></DropdownMenuItem>
<Auth authority='post:update'>
<DropdownMenuItem onClick={onEdit}></DropdownMenuItem>
</Auth>
<Auth authority='post:delete'>
<DropdownMenuItem onClick={onDelete}></DropdownMenuItem>
</Auth>
</DropdownMenuContent>
</DropdownMenu>
)

View File

@@ -1,9 +1,10 @@
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
import { getPosts } from '@/api'
import { hasPermission } from '@/lib'
import { type Post } from '@/schemas'
import { ActionDialogs } from './components/action-dialogs.tsx'
@@ -17,9 +18,8 @@ export default function CrudPage() {
pageIndex: 0,
pageSize: 10,
})
const [rowCount, setRowCount] = useState<number>()
const { data, isFetching } = useQuery({
const { data, isFetching, refetch } = useQuery({
queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize],
queryFn: () =>
getPosts({
@@ -27,28 +27,24 @@ export default function CrudPage() {
page_size: pagination.pageSize,
}),
placeholderData: (prev) => prev,
enabled: hasPermission('post:list'),
})
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,
rowCount: data?.total,
state: { pagination },
onPaginationChange: setPagination,
meta: {
showLoading: isFetching,
isFetching,
refetch,
},
})

View File

@@ -0,0 +1,149 @@
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 { createTag, updateTag } 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 { tagFormSchema, type Tag, type TagForm } from '@/schemas'
interface Props {
currentRow?: Tag | 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 tagFormSchema>>({
resolver: zodResolver(tagFormSchema),
defaultValues: currentRow
? { ...currentRow }
: {
name: '',
code: '',
sort: 0,
},
})
const onSubmit = async (values: TagForm) => {
try {
setLoading(true)
if (values.id) {
await updateTag(values.id, values)
} else {
await createTag(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={`${formId}-${field.name}`}></FieldLabel>
<Input
{...field}
id={`${formId}-${field.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={`${formId}-${field.name}`}></FieldLabel>
<Input
{...field}
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入标签编码'
autoComplete='off'
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
</>
)}
<Controller
name='sort'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Input
type='number'
{...field}
id={`${formId}-${field.name}`}
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>
)
}

View 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}
/>
</>
)}
</>
)
}

View File

@@ -0,0 +1,60 @@
import type { ColumnDef } from '@tanstack/react-table'
import { formatDate, hasPermission } from '@/lib'
import type { Tag } from '@/schemas'
import { RowActions } from './row-actions'
export const createColumns = (): ColumnDef<Tag>[] => {
const showAction = hasPermission(['tag:update', 'tag:delete'], 'any')
const columns: ColumnDef<Tag>[] = [
{
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: '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>
},
},
]
if (showAction) {
columns.push({
id: 'actions',
enableHiding: false,
meta: { className: 'max-w-36 sticky right-0' },
cell: RowActions,
})
}
return columns
}

View File

@@ -0,0 +1,33 @@
import { createContext, useContext, useState, type ReactNode } from 'react'
import { type Tag } 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<Tag> | null>(null)
export const CrudProvider = ({ children }: { children: ReactNode }) => {
const [action, setAction] = useState<Action>(null)
const [currentRow, setCurrentRow] = useState<Tag | 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
}

View File

@@ -0,0 +1,57 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { deleteTag } 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 Tag } from '@/schemas'
interface Props {
currentRow: Tag
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 deleteTag(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>
)
}

View File

@@ -0,0 +1,55 @@
import { type Row } from '@tanstack/react-table'
import { MoreHorizontal } from 'lucide-react'
import { Auth } from '@/components/auth'
import { Button } from '@/components/ui/button.tsx'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu.tsx'
import { type Tag } from '@/schemas'
import { useCrud } from './crud-provider'
type Props = {
row: Row<Tag>
}
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 />
<Auth authority='tag:update'>
<DropdownMenuItem onClick={onEdit}></DropdownMenuItem>
</Auth>
<Auth authority='tag:delete'>
<DropdownMenuItem onClick={onDelete}></DropdownMenuItem>
</Auth>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,32 @@
import { type Table } from '@tanstack/react-table'
import { Plus } from 'lucide-react'
import { Auth } from '@/components/auth'
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 TagTable<TData>({ table }: Props<TData>) {
const { setAction } = useCrud()
return (
<Auth authority='tag:list'>
<DataTable
table={table}
toolbarLeft={
<Auth authority='tag:create'>
<Button size='sm' onClick={() => setAction('add')}>
<Plus />
</Button>
</Auth>
}
/>
</Auth>
)
}

View File

@@ -0,0 +1,7 @@
import { z } from 'zod'
export const QUERY_KEY = 'tag'
export const ActionSchema = z.enum(['add', 'edit', 'delete']).nullable()
export type Action = z.infer<typeof ActionSchema>

View File

@@ -0,0 +1,58 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
import { getTags } from '@/api'
import { hasPermission } from '@/lib'
import { type Tag } from '@/schemas'
import { ActionDialogs } from './components/action-dialogs.tsx'
import { createColumns } from './components/columns.tsx'
import { CrudProvider } from './components/crud-provider.tsx'
import { TagTable } from './components/tag-table.tsx'
import { QUERY_KEY } from './constants.ts'
export default function CrudPage() {
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 10,
})
const { data, isFetching, refetch } = useQuery({
queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize],
queryFn: () =>
getTags({
page: pagination.pageIndex + 1,
page_size: pagination.pageSize,
}),
placeholderData: (prev) => prev,
enabled: hasPermission('tag:list'),
})
const tableData = useMemo<Tag[]>(() => data?.list ?? [], [data])
const columns = useMemo(() => createColumns(), [])
const table = useReactTable<Tag>({
data: tableData,
columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
rowCount: data?.total,
state: { pagination },
onPaginationChange: setPagination,
meta: {
isFetching,
refetch,
},
})
return (
<CrudProvider>
<ActionDialogs />
<TagTable table={table} />
</CrudProvider>
)
}

View File

@@ -50,7 +50,7 @@ export function LoginForm({ className, ...props }: React.ComponentProps<'div'>)
const accountInfo = isRemembered ? getLocalAccountInfo() : null
const setToken = useStore((state) => state.setToken)
const { control, handleSubmit } = useForm<LoginUser>({
const form = useForm<LoginUser>({
resolver: zodResolver(loginSchema),
defaultValues: {
account: accountInfo?.account ?? '',
@@ -74,6 +74,15 @@ export function LoginForm({ className, ...props }: React.ComponentProps<'div'>)
location.replace('/')
}
const guideLogin = () => {
form.reset({
account: 'guest',
password: 'guest123',
})
form.handleSubmit(onSubmit)()
}
return (
<div className={cn('flex flex-col gap-6', className)} {...props}>
<Card>
@@ -82,11 +91,11 @@ export function LoginForm({ className, ...props }: React.ComponentProps<'div'>)
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<form autoComplete='off' onSubmit={handleSubmit(onSubmit)}>
<form autoComplete='off' onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup className='gap-5'>
<Controller
name='account'
control={control}
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='account'></FieldLabel>
@@ -104,7 +113,7 @@ export function LoginForm({ className, ...props }: React.ComponentProps<'div'>)
<Controller
name='password'
control={control}
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='password'></FieldLabel>
@@ -135,6 +144,12 @@ export function LoginForm({ className, ...props }: React.ComponentProps<'div'>)
<Field>
<Button type='submit'></Button>
</Field>
<Field>
<Button type='button' onClick={guideLogin}>
</Button>
</Field>
</FieldGroup>
</form>
</CardContent>

View File

@@ -88,10 +88,10 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='name'></FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Input
{...field}
id='name'
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入接口名称'
autoComplete='off'
@@ -106,10 +106,10 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='group_name'></FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Input
{...field}
id='group_name'
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入分组名称'
autoComplete='off'
@@ -124,10 +124,10 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='path'></FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Input
{...field}
id='path'
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入接口路径'
autoComplete='off'
@@ -142,9 +142,9 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel></FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Select value={field.value} onValueChange={(value) => field.onChange(value)}>
<SelectTrigger className='w-full'>
<SelectTrigger id={`${formId}-${field.name}`} className='w-full'>
<SelectValue placeholder='请选择接口方法' />
</SelectTrigger>
<SelectContent>
@@ -167,11 +167,11 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='sort'></FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Input
type='number'
{...field}
id='sort'
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入排序'
autoComplete='off'

View File

@@ -1,24 +1,36 @@
import { type Table } from '@tanstack/react-table'
import { Plus } from 'lucide-react'
import { Auth } from '@/components/auth'
import { DataTable } from '@/components/data-table'
import { Button } from '@/components/ui/button.tsx'
import type { SearchSysApiParams } from '@/schemas'
import { useCrud } from './crud-provider.tsx'
import { SearchForm } from './search-form.tsx'
interface Props<TData> {
table: Table<TData>
onSearch?: (params: SearchSysApiParams) => void
}
export function ApiTable<TData>({ table }: Props<TData>) {
export function ApiTable<TData>({ table, onSearch }: Props<TData>) {
const { setAction } = useCrud()
return (
<>
<Button className='mb-4' onClick={() => setAction('add')}>
</Button>
<DataTable table={table} />
</>
<Auth authority='api:list'>
<DataTable
table={table}
toolbarLeft={
<Auth authority='api:create'>
<Button size='sm' onClick={() => setAction('add')}>
<Plus />
</Button>
</Auth>
}
search={<SearchForm onSearch={onSearch} />}
/>
</Auth>
)
}

View File

@@ -2,13 +2,15 @@ import type { ColumnDef } from '@tanstack/react-table'
import { Badge } from '@/components/ui/badge'
import { HttpMethodClass, type HttpMethodValues } from '@/enum'
import { formatDate } from '@/lib'
import { formatDate, hasPermission } from '@/lib'
import type { SysApi } from '@/schemas'
import { RowActions } from './row-actions'
export const createColumns = (): ColumnDef<SysApi>[] => {
return [
const showAction = hasPermission(['api:update', 'api:delete'], 'any')
const columns: ColumnDef<SysApi>[] = [
{
accessorKey: 'name',
header: '接口名称',
@@ -61,11 +63,16 @@ export const createColumns = (): ColumnDef<SysApi>[] => {
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
},
},
{
]
if (showAction) {
columns.push({
id: 'actions',
enableHiding: false,
meta: { className: ' max-w-36 sticky right-0' },
cell: RowActions,
},
]
})
}
return columns
}

View File

@@ -1,6 +1,7 @@
import { type Row } from '@tanstack/react-table'
import { MoreHorizontal } from 'lucide-react'
import { Auth } from '@/components/auth'
import { Button } from '@/components/ui/button.tsx'
import {
DropdownMenu,
@@ -42,9 +43,12 @@ export function RowActions({ row }: Props) {
<DropdownMenuContent align='end'>
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onEdit}></DropdownMenuItem>
<DropdownMenuItem onClick={onDelete}></DropdownMenuItem>
<Auth authority='api:update'>
<DropdownMenuItem onClick={onEdit}></DropdownMenuItem>
</Auth>
<Auth authority='api:delete'>
<DropdownMenuItem onClick={onDelete}></DropdownMenuItem>
</Auth>
</DropdownMenuContent>
</DropdownMenu>
)

View File

@@ -0,0 +1,108 @@
import { useId } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { RotateCcw, Search } from 'lucide-react'
import { Controller, useForm } from 'react-hook-form'
import { Badge } from '@/components/ui/badge.tsx'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field.tsx'
import { Input } from '@/components/ui/input.tsx'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select.tsx'
import { HttpMethod, HttpMethodClass } from '@/enum'
import { searchSysApiSchema, type SearchSysApiParams } from '@/schemas'
interface SearchFormProps {
onSearch?: (params: SearchSysApiParams) => void
}
export function SearchForm({ onSearch }: SearchFormProps) {
const formId = useId()
const form = useForm<SearchSysApiParams>({
resolver: zodResolver(searchSysApiSchema),
defaultValues: {
method: undefined,
group_name: '',
},
})
const handleSubmit = (values: SearchSysApiParams) => {
onSearch?.(values)
}
const handleReset = () => {
form.reset()
}
return (
<form id={formId} onSubmit={form.handleSubmit(handleSubmit)}>
<FieldGroup className='flex-row flex-wrap'>
<Controller
name='group_name'
control={form.control}
render={({ field, fieldState }) => (
<Field className='max-w-85' orientation='horizontal' data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={`${formId}-${field.name}`} className='w-24'>
</FieldLabel>
<Input
{...field}
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入分组名称'
autoComplete='off'
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<Controller
name='method'
control={form.control}
render={({ field, fieldState }) => (
<Field className='max-w-85' orientation='horizontal' data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={`${formId}-${field.name}`} className='w-24'>
</FieldLabel>
<Select value={field.value || ''} onValueChange={(value) => field.onChange(value)}>
<SelectTrigger id={`${formId}-${field.name}`} 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>
)}
/>
<div className='flex gap-2 items-center'>
<Button type='submit' size='sm'>
<Search />
</Button>
<Button type='button' variant='outline' size='sm' onClick={handleReset}>
<RotateCcw />
</Button>
</div>
</FieldGroup>
</form>
)
}

View File

@@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from 'react'
import { 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 { hasPermission } from '@/lib'
import { type SearchSysApiParams, type SysApi } from '@/schemas'
import { ActionDialogs } from './components/action-dialogs.tsx'
import { ApiTable } from './components/api-table.tsx'
@@ -17,46 +18,55 @@ export default function CrudPage() {
pageIndex: 0,
pageSize: 10,
})
const [rowCount, setRowCount] = useState<number>()
const { data, isFetching } = useQuery({
queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize],
const [searchParams, setSearchParams] = useState<SearchSysApiParams>({})
const { data, isFetching, refetch } = useQuery({
queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize, searchParams],
queryFn: () =>
getSysApis({
page: pagination.pageIndex + 1,
page_size: pagination.pageSize,
...searchParams,
}),
placeholderData: (prev) => prev,
enabled: hasPermission('api:list'),
})
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,
rowCount: data?.total,
state: { pagination },
onPaginationChange: setPagination,
meta: {
showLoading: isFetching,
isFetching,
refetch,
},
})
const handleSearch = (params: SearchSysApiParams) => {
const isSameParams = JSON.stringify(params) === JSON.stringify(searchParams)
setSearchParams(params)
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
if (isSameParams && pagination.pageIndex === 0) {
void refetch()
}
}
return (
<CrudProvider>
<ActionDialogs />
<ApiTable table={table} />
<ApiTable table={table} onSearch={handleSearch} />
</CrudProvider>
)
}

View File

@@ -1,5 +1,6 @@
import { type Table } from '@tanstack/react-table'
import { Auth } from '@/components/auth'
import { DataTable } from '@/components/data-table'
interface Props<TData> {
@@ -8,8 +9,8 @@ interface Props<TData> {
export function FileTable<TData>({ table }: Props<TData>) {
return (
<>
<Auth authority='file:list'>
<DataTable table={table} />
</>
</Auth>
)
}

View File

@@ -1,11 +1,12 @@
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
import { getSysFiles } from '@/api'
import { hasPermission } from '@/lib'
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'
@@ -16,9 +17,8 @@ export default function CrudPage() {
pageIndex: 0,
pageSize: 10,
})
const [rowCount, setRowCount] = useState<number>()
const { data, isFetching } = useQuery({
const { data, isFetching, refetch } = useQuery({
queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize],
queryFn: () =>
getSysFiles({
@@ -26,28 +26,24 @@ export default function CrudPage() {
page_size: pagination.pageSize,
}),
placeholderData: (prev) => prev,
enabled: hasPermission('file:list'),
})
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,
rowCount: data?.total,
state: { pagination },
onPaginationChange: setPagination,
meta: {
showLoading: isFetching,
isFetching,
refetch,
},
})

View File

@@ -2,34 +2,45 @@ import { Link } from '@tanstack/react-router'
import { ArrowUpRight, FileText, Users } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { Auth } from '@/components/auth'
interface QuickMenuItem {
title: string
description: string
path: string
icon: LucideIcon
auth: string
}
const quickMenus: QuickMenuItem[] = [
{ title: '博客列表', description: '查看所有博客文章', path: '/post', icon: FileText },
{ title: '用户管理', description: '管理用户信息', path: '/user', icon: Users },
{
title: '博客列表',
description: '查看所有博客文章',
path: '/post',
icon: FileText,
auth: 'post:list',
},
{ title: '用户管理', description: '管理用户信息', path: '/user', icon: Users, auth: 'user:list' },
]
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>
<Auth authority={menu.auth}>
<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>
</Auth>
))}
</div>
)

View File

@@ -40,6 +40,7 @@ interface Props {
}
const ROOT_PARENT_VALUE = '__no_parent__'
const NO_ICON_VALUE = '__no_icon__'
const generateMenuOptions = (
data: SysMenu[],
@@ -51,18 +52,15 @@ const generateMenuOptions = (
.map((item) => ({
id: item.id.toString(),
label: item.name,
disabled: item.id === currentId,
disabled: item.id === currentId || item.type === MenuType.button,
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>({
@@ -132,10 +130,10 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='name'></FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Input
{...field}
id='name'
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入菜单名称'
autoComplete='off'
@@ -150,12 +148,12 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel></FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Select
value={field.value?.toString()}
onValueChange={(value) => field.onChange(Number(value))}
>
<SelectTrigger className='w-full'>
<SelectTrigger id={`${formId}-${field.name}`} className='w-full'>
<SelectValue placeholder='请选择菜单类型' />
</SelectTrigger>
<SelectContent>
@@ -173,33 +171,38 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
)}
/>
<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.button && (
<Controller
name='icon'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Select
value={field.value == null ? NO_ICON_VALUE : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === NO_ICON_VALUE ? null : Number(value))
}
>
<SelectTrigger id={`${formId}-${field.name}`} className='w-full'>
<SelectValue placeholder='请选择菜单图标' />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value={NO_ICON_VALUE}></SelectItem>
{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
@@ -207,10 +210,10 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='path'></FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Input
{...field}
id='path'
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入菜单路径'
autoComplete='off'
@@ -227,10 +230,10 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='component'></FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Input
{...field}
id='component'
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入组件路径'
autoComplete='off'
@@ -246,10 +249,10 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='permission_code'></FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Input
{...field}
id='permission_code'
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入权限字符'
autoComplete='off'
@@ -264,12 +267,12 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel></FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Select
value={field.value?.toString()}
onValueChange={(value) => field.onChange(Number(value))}
>
<SelectTrigger className='w-full'>
<SelectTrigger id={`${formId}-${field.name}`} className='w-full'>
<SelectValue placeholder='请选择菜单状态' />
</SelectTrigger>
<SelectContent>
@@ -310,11 +313,11 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='sort'></FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Input
type='number'
{...field}
id='sort'
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入排序'
autoComplete='off'
@@ -324,25 +327,27 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
)}
/>
<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>
)}
/>
{type !== MenuType.button && (
<Controller
name='hidden'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Switch
id={`${formId}-${field.name}`}
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>

View File

@@ -1,14 +1,17 @@
import type { ColumnDef } from '@tanstack/react-table'
import { StatusLabel } from '@/components/label'
import { MenuIcon } from '@/components/menu-icon'
import { menuTypeDict, statusDict, type MenuTypeValues, type StatusValues } from '@/enum'
import { formatDate } from '@/lib'
import { menuTypeDict, type MenuTypeValues, type StatusValues } from '@/enum'
import { formatDate, hasPermission } from '@/lib'
import type { SysMenuTree } from '@/schemas'
import { RowActions } from './row-actions.tsx'
export const createColumns = (): ColumnDef<SysMenuTree>[] => {
return [
const showAction = hasPermission(['menu:update', 'menu:delete'], 'any')
const columns: ColumnDef<SysMenuTree>[] = [
{
accessorKey: 'name',
header: '菜单名称',
@@ -48,8 +51,8 @@ export const createColumns = (): ColumnDef<SysMenuTree>[] => {
header: '菜单状态',
meta: { className: ' max-w-36' },
cell: ({ row }) => {
const status = statusDict[row.getValue('status') as StatusValues]
return <div>{status}</div>
const status = row.getValue('status') as StatusValues
return <StatusLabel value={status} />
},
},
{
@@ -82,11 +85,16 @@ export const createColumns = (): ColumnDef<SysMenuTree>[] => {
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
},
},
{
]
if (showAction) {
columns.push({
id: 'actions',
enableHiding: false,
meta: { className: ' max-w-36 sticky right-0' },
cell: RowActions,
},
]
})
}
return columns
}

View File

@@ -1,5 +1,7 @@
import { type Table } from '@tanstack/react-table'
import { Plus } from 'lucide-react'
import { Auth } from '@/components/auth'
import { DataTable } from '@/components/data-table'
import { Button } from '@/components/ui/button.tsx'
@@ -13,12 +15,18 @@ export function MenuTable<TData>({ table }: Props<TData>) {
const { setAction } = useCrud()
return (
<>
<Button className='mb-4' onClick={() => setAction('add')}>
</Button>
<DataTable table={table} />
</>
<Auth authority='menu:list'>
<DataTable
table={table}
toolbarLeft={
<Auth authority='menu:create'>
<Button size='sm' onClick={() => setAction('add')}>
<Plus />
</Button>
</Auth>
}
/>
</Auth>
)
}

View File

@@ -1,6 +1,7 @@
import { type Row } from '@tanstack/react-table'
import { MoreHorizontal } from 'lucide-react'
import { Auth } from '@/components/auth'
import { Button } from '@/components/ui/button.tsx'
import {
DropdownMenu,
@@ -42,8 +43,12 @@ export function RowActions({ row }: Props) {
<DropdownMenuContent align='end'>
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onEdit}></DropdownMenuItem>
<DropdownMenuItem onClick={onDelete}></DropdownMenuItem>
<Auth authority='menu:update'>
<DropdownMenuItem onClick={onEdit}></DropdownMenuItem>
</Auth>
<Auth authority='menu:delete'>
<DropdownMenuItem onClick={onDelete}></DropdownMenuItem>
</Auth>
</DropdownMenuContent>
</DropdownMenu>
)

View File

@@ -4,7 +4,7 @@ import { useQuery } from '@tanstack/react-query'
import { getCoreRowModel, getExpandedRowModel, useReactTable } from '@tanstack/react-table'
import { getAllSysMenus } from '@/api'
import { generateMenus } from '@/lib'
import { generateMenus, hasPermission } from '@/lib'
import { type SysMenuTree } from '@/schemas'
import { ActionDialogs } from './components/action-dialogs.tsx'
@@ -14,10 +14,11 @@ import { MenuTable } from './components/menu-table.tsx'
import { QUERY_KEY } from './constants.ts'
export default function CrudPage() {
const { data, isFetching } = useQuery({
const { data, isFetching, refetch } = useQuery({
queryKey: [QUERY_KEY],
queryFn: () => getAllSysMenus(),
placeholderData: (prev) => prev,
enabled: hasPermission('menu:list'),
})
const tableData = useMemo<SysMenuTree[]>(
@@ -34,7 +35,8 @@ export default function CrudPage() {
getExpandedRowModel: getExpandedRowModel(),
manualPagination: true,
meta: {
showLoading: isFetching,
isFetching,
refetch,
},
getSubRows: (row) => row.children,
})

View File

@@ -18,7 +18,16 @@ import {
} 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 { Status, statusOptions } from '@/enum'
import { sysRoleFormSchema, type SysRole, type SysRoleForm } from '@/schemas'
interface Props {
@@ -40,6 +49,7 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
: {
name: '',
code: '',
status: Status.enabled,
},
})
@@ -73,10 +83,10 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='name'></FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Input
{...field}
id='name'
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入角色名称'
autoComplete='off'
@@ -93,10 +103,10 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='code'></FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Input
{...field}
id='code'
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入角色编码'
autoComplete='off'
@@ -107,6 +117,34 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
/>
</>
)}
<Controller
name='status'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Select
value={field.value?.toString()}
onValueChange={(value) => field.onChange(Number(value))}
>
<SelectTrigger id={`${formId}-${field.name}`} 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>
)}
/>
</FieldGroup>
</form>

View File

@@ -4,6 +4,7 @@ import { toast } from 'sonner'
import { assignSysRoleApis, getAllSysApi, getRoleApis, getSysApiGroups } from '@/api'
import { CheckboxTree, useCheckboxTree, type CheckboxTreeNode } from '@/components/checkbox-tree'
import { DialogSkeleton } from '@/components/dialog-skeleton'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button.tsx'
import {
@@ -28,12 +29,15 @@ interface Props {
export function AssignApisDialog({ open, onClose, onConfirm, currentRow }: Props) {
const [loading, setLoading] = useState(false)
const [skeletonLoading, setSkeletonLoading] = useState(true)
const [treeData, setTreeData] = useState<CheckboxTreeNode[]>([])
const { getNodeState, toggleNode, checkedIds, setCheckedIds } = useCheckboxTree(treeData, [])
useEffect(() => {
const fetchApis = async () => {
setSkeletonLoading(true)
const { data: groups } = await getSysApiGroups()
const { data: apis } = await getAllSysApi()
const { data: checkedApi } = await getRoleApis(currentRow?.id as number)
@@ -63,6 +67,7 @@ export function AssignApisDialog({ open, onClose, onConfirm, currentRow }: Props
setTreeData(options)
setCheckedIds(ids)
setSkeletonLoading(false)
}
if (open && currentRow?.id) fetchApis()
@@ -92,22 +97,24 @@ export function AssignApisDialog({ open, onClose, onConfirm, currentRow }: Props
</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>
)}
/>
<DialogSkeleton rows={20} loading={skeletonLoading}>
<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>
)}
/>
</DialogSkeleton>
</div>
<DialogFooter>

View File

@@ -4,6 +4,7 @@ import { toast } from 'sonner'
import { assignSysRoleMenus, getAllSysMenus, getSysRoleMenus } from '@/api'
import { CheckboxTree, useCheckboxTree, type CheckboxTreeNode } from '@/components/checkbox-tree'
import { DialogSkeleton } from '@/components/dialog-skeleton'
import { Button } from '@/components/ui/button.tsx'
import {
Dialog,
@@ -40,15 +41,23 @@ const buildTreeData = (data: SysMenu[], parentId: string | null = null): Checkbo
export function AssignMenusDialog({ open, onClose, onConfirm, currentRow }: Props) {
const [treeData, setTreeData] = useState<CheckboxTreeNode[]>([])
const { getNodeState, toggleNode, checkedIds, setCheckedIds } = useCheckboxTree(treeData, [])
const { getNodeState, toggleNode, setCheckedIds, getCheckedIdsWithParents } = useCheckboxTree(
treeData,
[],
)
const [loading, setLoading] = useState(false)
const [skeletonLoading, setSkeletonLoading] = useState(true)
useEffect(() => {
const fetchMenus = async () => {
setSkeletonLoading(true)
const { data: menus } = await getAllSysMenus()
const { data: checkedMenus } = await getSysRoleMenus(currentRow?.id as number)
setTreeData(buildTreeData(menus))
setCheckedIds(checkedMenus.map((item) => item.id.toString()))
setSkeletonLoading(false)
}
if (open && currentRow?.id) fetchMenus()
@@ -59,7 +68,7 @@ export function AssignMenusDialog({ open, onClose, onConfirm, currentRow }: Prop
setLoading(true)
await assignSysRoleMenus(
currentRow?.id as number,
checkedIds.map((item) => parseInt(item)),
getCheckedIdsWithParents().map((item) => parseInt(item)),
)
toast('操作成功!')
onConfirm()
@@ -77,16 +86,18 @@ export function AssignMenusDialog({ open, onClose, onConfirm, currentRow }: Prop
</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>
)}
/>
<DialogSkeleton rows={20} loading={skeletonLoading}>
<CheckboxTree
data={treeData}
getNodeState={getNodeState}
onToggle={toggleNode}
renderLabel={(node: CheckboxTreeNode) => (
<div className='flex gap-2'>
<div>{node.data?.name}</div>
</div>
)}
/>
</DialogSkeleton>
</div>
<DialogFooter>

View File

@@ -1,12 +1,19 @@
import type { ColumnDef } from '@tanstack/react-table'
import { formatDate } from '@/lib'
import { StatusLabel } from '@/components/label'
import { type StatusValues } from '@/enum'
import { formatDate, hasPermission } from '@/lib'
import type { SysRole } from '@/schemas'
import { RowActions } from './row-actions'
export const createColumns = (): ColumnDef<SysRole>[] => {
return [
const showAction = hasPermission(
['role:update', 'role:delete', 'role:assign-menus', 'role:assign-apis'],
'any',
)
const columns: ColumnDef<SysRole>[] = [
{
accessorKey: 'name',
header: '角色名称',
@@ -19,6 +26,15 @@ export const createColumns = (): ColumnDef<SysRole>[] => {
meta: { className: ' max-w-36' },
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('code')}</div>,
},
{
accessorKey: 'status',
header: '角色状态',
meta: { className: ' max-w-36' },
cell: ({ row }) => {
const status = row.getValue('status') as StatusValues
return <StatusLabel value={status} />
},
},
{
accessorKey: 'created_at',
header: '创建时间',
@@ -37,11 +53,16 @@ export const createColumns = (): ColumnDef<SysRole>[] => {
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
},
},
{
]
if (showAction) {
columns.push({
id: 'actions',
enableHiding: false,
meta: { className: ' max-w-36 sticky right-0' },
cell: RowActions,
},
]
})
}
return columns
}

View File

@@ -1,5 +1,7 @@
import { type Table } from '@tanstack/react-table'
import { Plus } from 'lucide-react'
import { Auth } from '@/components/auth'
import { DataTable } from '@/components/data-table'
import { Button } from '@/components/ui/button.tsx'
@@ -13,12 +15,18 @@ export function RoleTable<TData>({ table }: Props<TData>) {
const { setAction } = useCrud()
return (
<>
<Button className='mb-4' onClick={() => setAction('add')}>
</Button>
<DataTable table={table} />
</>
<Auth authority='role:list'>
<DataTable
table={table}
toolbarLeft={
<Auth authority='role:create'>
<Button size='sm' onClick={() => setAction('add')}>
<Plus />
</Button>
</Auth>
}
/>
</Auth>
)
}

View File

@@ -1,6 +1,7 @@
import { type Row } from '@tanstack/react-table'
import { MoreHorizontal } from 'lucide-react'
import { Auth } from '@/components/auth'
import { Button } from '@/components/ui/button.tsx'
import {
DropdownMenu,
@@ -52,10 +53,18 @@ export function RowActions({ row }: Props) {
<DropdownMenuContent align='end'>
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onEdit}></DropdownMenuItem>
<DropdownMenuItem onClick={assignApis}></DropdownMenuItem>
<DropdownMenuItem onClick={assignMenus}></DropdownMenuItem>
<DropdownMenuItem onClick={onDelete}></DropdownMenuItem>
<Auth authority='role:update'>
<DropdownMenuItem onClick={onEdit}></DropdownMenuItem>
</Auth>
<Auth authority='role:assign-menus'>
<DropdownMenuItem onClick={assignMenus}></DropdownMenuItem>
</Auth>
<Auth authority='role:assign-apis'>
<DropdownMenuItem onClick={assignApis}></DropdownMenuItem>
</Auth>
<Auth authority='role:delete'>
<DropdownMenuItem onClick={onDelete}></DropdownMenuItem>
</Auth>
</DropdownMenuContent>
</DropdownMenu>
)

View File

@@ -1,9 +1,10 @@
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
import { getSysRoles } from '@/api'
import { hasPermission } from '@/lib'
import { type SysRole } from '@/schemas'
import { ActionDialogs } from './components/action-dialogs.tsx'
@@ -17,9 +18,8 @@ export default function CrudPage() {
pageIndex: 0,
pageSize: 10,
})
const [rowCount, setRowCount] = useState<number>()
const { data, isFetching } = useQuery({
const { data, isFetching, refetch } = useQuery({
queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize],
queryFn: () =>
getSysRoles({
@@ -27,28 +27,24 @@ export default function CrudPage() {
page_size: pagination.pageSize,
}),
placeholderData: (prev) => prev,
enabled: hasPermission('role:list'),
})
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,
rowCount: data?.total,
state: { pagination },
onPaginationChange: setPagination,
meta: {
showLoading: isFetching,
isFetching,
refetch,
},
})

View File

@@ -18,7 +18,16 @@ import {
} 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 { Status, statusOptions } from '@/enum'
import {
sysUserFormSchema,
type SysUser,
@@ -52,6 +61,7 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
account: '',
password: '',
confirmPassword: '',
status: Status.enabled,
avatar: [],
},
})
@@ -102,10 +112,10 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='username'></FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Input
{...field}
id='username'
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入用户名称'
autoComplete='off'
@@ -122,10 +132,10 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='account'></FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Input
{...field}
id='account'
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入账户名称'
autoComplete='off'
@@ -139,10 +149,10 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='password'></FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Input
{...field}
id='password'
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入用户密码'
autoComplete='off'
@@ -157,10 +167,10 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='confirmPassword'></FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Input
{...field}
id='confirmPassword'
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入确认密码'
autoComplete='off'
@@ -172,6 +182,34 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
/>
</>
)}
<Controller
name='status'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Select
value={field.value?.toString()}
onValueChange={(value) => field.onChange(Number(value))}
>
<SelectTrigger id={`${formId}-${field.name}`} 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>
)}
/>
</FieldGroup>
</form>

View File

@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { assignSysUserRoles, getAllSysRoles, getSysUserRoles } from '@/api'
import { DialogSkeleton } from '@/components/dialog-skeleton'
import { Button } from '@/components/ui/button.tsx'
import { Checkbox } from '@/components/ui/checkbox'
import {
@@ -36,6 +37,7 @@ export function AssignRolesDialog({ open, onClose, onConfirm, currentRow }: Prop
const [roles, setRoles] = useState<SysRole[]>([])
const [checkedIds, setCheckedIds] = useState<number[]>([])
const [loading, setLoading] = useState(false)
const [skeletonLoading, setSkeletonLoading] = useState(true)
const handleConfirm = async () => {
try {
@@ -54,14 +56,20 @@ export function AssignRolesDialog({ open, onClose, onConfirm, currentRow }: Prop
useEffect(() => {
const fetchUserRoles = async () => {
setSkeletonLoading(true)
const { data } = await getAllSysRoles()
const { data: userRoles } = await getSysUserRoles(currentRow!.id)
setRoles(data)
setCheckedIds(userRoles.map((role) => role.id))
setSkeletonLoading(false)
}
fetchUserRoles()
}, [currentRow])
if (open) {
fetchUserRoles()
}
}, [open, currentRow])
return (
<Dialog open={open} onOpenChange={onClose}>
@@ -70,27 +78,28 @@ export function AssignRolesDialog({ open, onClose, onConfirm, currentRow }: Prop
<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>
)
})}
<DialogSkeleton loading={skeletonLoading}>
{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>
)
})}
</DialogSkeleton>
</FieldGroup>
</FieldSet>
</div>

View File

@@ -66,10 +66,10 @@ export function ChangePasswordDialog({ open, currentRow, onClose, onConfirm }: P
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='password'></FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Input
{...field}
id='password'
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入用户密码'
autoComplete='off'
@@ -85,10 +85,10 @@ export function ChangePasswordDialog({ open, currentRow, onClose, onConfirm }: P
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='confirmPassword'></FieldLabel>
<FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Input
{...field}
id='confirmPassword'
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入确认密码'
autoComplete='off'

View File

@@ -1,13 +1,20 @@
import type { ColumnDef } from '@tanstack/react-table'
import { StatusLabel } from '@/components/label'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { formatDate } from '@/lib'
import { type StatusValues } from '@/enum'
import { formatDate, hasPermission } from '@/lib'
import type { SysUser } from '@/schemas'
import { RowActions } from './row-actions'
export const createColumns = (): ColumnDef<SysUser>[] => {
return [
const showAction = hasPermission(
['user:update', 'user:assign-roles', 'user:update-password', 'user:delete'],
'any',
)
const columns: ColumnDef<SysUser>[] = [
{
accessorKey: 'avatar_url',
header: '用户头像',
@@ -31,6 +38,15 @@ export const createColumns = (): ColumnDef<SysUser>[] => {
meta: { className: 'max-w-36' },
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('username')}</div>,
},
{
accessorKey: 'status',
header: '用户状态',
meta: { className: ' max-w-36' },
cell: ({ row }) => {
const status = row.getValue('status') as StatusValues
return <StatusLabel value={status} />
},
},
{
accessorKey: 'created_at',
header: '创建时间',
@@ -49,11 +65,16 @@ export const createColumns = (): ColumnDef<SysUser>[] => {
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
},
},
{
]
if (showAction) {
columns.push({
id: 'actions',
enableHiding: false,
meta: { className: 'max-w-36 sticky right-0' },
cell: RowActions,
},
]
})
}
return columns
}

View File

@@ -1,6 +1,7 @@
import { type Row } from '@tanstack/react-table'
import { MoreHorizontal } from 'lucide-react'
import { Auth } from '@/components/auth'
import { Button } from '@/components/ui/button.tsx'
import {
DropdownMenu,
@@ -52,10 +53,18 @@ export function RowActions({ row }: Props) {
<DropdownMenuContent align='end'>
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onEdit}></DropdownMenuItem>
<DropdownMenuItem onClick={assignUserRoles}></DropdownMenuItem>
<DropdownMenuItem onClick={changePassword}></DropdownMenuItem>
<DropdownMenuItem onClick={onDelete}></DropdownMenuItem>
<Auth authority='user:update'>
<DropdownMenuItem onClick={onEdit}></DropdownMenuItem>
</Auth>
<Auth authority='user:assign-roles'>
<DropdownMenuItem onClick={assignUserRoles}></DropdownMenuItem>
</Auth>
<Auth authority='user:update-password'>
<DropdownMenuItem onClick={changePassword}></DropdownMenuItem>
</Auth>
<Auth authority='user:delete'>
<DropdownMenuItem onClick={onDelete}></DropdownMenuItem>
</Auth>
</DropdownMenuContent>
</DropdownMenu>
)

View File

@@ -0,0 +1,70 @@
import { useId } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { RotateCcw, Search } from 'lucide-react'
import { Controller, useForm } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field.tsx'
import { Input } from '@/components/ui/input.tsx'
import { searchSysUserSchema, type SearchSysUserParams } from '@/schemas'
interface SearchFormProps {
onSearch?: (params: SearchSysUserParams) => void
}
export function SearchForm({ onSearch }: SearchFormProps) {
const formId = useId()
const form = useForm<SearchSysUserParams>({
resolver: zodResolver(searchSysUserSchema),
defaultValues: {
username: '',
},
})
const handleSubmit = (values: SearchSysUserParams) => {
onSearch?.(values)
}
const handleReset = () => {
form.reset()
}
return (
<form id={formId} onSubmit={form.handleSubmit(handleSubmit)}>
<FieldGroup className='flex-row flex-wrap'>
<Controller
name='username'
control={form.control}
render={({ field, fieldState }) => (
<Field className='max-w-85' orientation='horizontal' data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={`${formId}-${field.name}`} className='w-24'>
</FieldLabel>
<Input
{...field}
id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid}
placeholder='请输入用户名称'
autoComplete='off'
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<div className='flex gap-2 items-center'>
<Button type='submit' size='sm'>
<Search />
</Button>
<Button type='button' variant='outline' size='sm' onClick={handleReset}>
<RotateCcw />
</Button>
</div>
</FieldGroup>
</form>
)
}

View File

@@ -1,24 +1,38 @@
import { type Table } from '@tanstack/react-table'
import { Plus } from 'lucide-react'
import { Auth } from '@/components/auth'
import { DataTable } from '@/components/data-table'
import { Button } from '@/components/ui/button.tsx'
import type { SearchSysUserParams } from '@/schemas'
import { useCrud } from './crud-provider.tsx'
import { SearchForm } from './search-form.tsx'
interface Props<TData> {
table: Table<TData>
onSearch?: (params: SearchSysUserParams) => void
}
export function UserTable<TData>({ table }: Props<TData>) {
export function UserTable<TData>({ table, onSearch }: Props<TData>) {
const { setAction } = useCrud()
return (
<>
<Button className='mb-4' onClick={() => setAction('add')}>
</Button>
<DataTable table={table} />
</>
<Auth authority='user:list'>
<DataTable
table={table}
search={<SearchForm onSearch={onSearch} />}
toolbarLeft={
<>
<Auth authority='user:create'>
<Button size='sm' onClick={() => setAction('add')}>
<Plus />
</Button>
</Auth>
</>
}
/>
</Auth>
)
}

View File

@@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from 'react'
import { 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 { hasPermission } from '@/lib'
import { type SearchSysUserParams, type SysUser } from '@/schemas'
import { ActionDialogs } from './components/action-dialogs.tsx'
import { createColumns } from './components/columns.tsx'
@@ -17,46 +18,49 @@ export default function CrudPage() {
pageIndex: 0,
pageSize: 10,
})
const [rowCount, setRowCount] = useState<number>()
const { data, isFetching } = useQuery({
queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize],
const [searchParams, setSearchParams] = useState<SearchSysUserParams>({})
const { data, isFetching, refetch } = useQuery({
queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize, searchParams],
queryFn: () =>
getSysUsers({
page: pagination.pageIndex + 1,
page_size: pagination.pageSize,
...searchParams,
}),
placeholderData: (prev) => prev,
enabled: hasPermission('user:list'),
})
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,
rowCount: data?.total,
state: { pagination },
onPaginationChange: setPagination,
meta: {
showLoading: isFetching,
isFetching,
refetch,
},
})
const handleSearch = (params: SearchSysUserParams) => {
setSearchParams(params)
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
}
return (
<CrudProvider>
<ActionDialogs />
<UserTable table={table} />
<UserTable table={table} onSearch={handleSearch} />
</CrudProvider>
)
}