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

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.png" /> <link rel="icon" type="image/svg+xml" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Uroboros</title> <title></title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,5 +1,5 @@
{ {
"name": "blog-admin", "name": "react-tmpl",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
@@ -11,9 +11,11 @@
"preview": "vite preview", "preview": "vite preview",
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
"format": "prettier --write src/", "format": "prettier --write src/",
"type-check": "vue-tsc --build" "type-check": "vue-tsc --build",
"deploy": "./scripts/deploy.sh"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.6.0",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@mantine/hooks": "^9.4.1", "@mantine/hooks": "^9.4.1",
"@radix-ui/react-collapsible": "^1.1.15", "@radix-ui/react-collapsible": "^1.1.15",

60
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@base-ui/react':
specifier: ^1.6.0
version: 1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^5.4.0 specifier: ^5.4.0
version: 5.4.0(react-hook-form@7.81.0(react@19.2.7)) version: 5.4.0(react-hook-form@7.81.0(react@19.2.7))
@@ -221,6 +224,33 @@ packages:
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@base-ui/react@1.6.0':
resolution: {integrity: sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@date-fns/tz': ^1.2.0
'@types/react': ^17 || ^18 || ^19
date-fns: ^4.0.0
react: ^17 || ^18 || ^19
react-dom: ^17 || ^18 || ^19
peerDependenciesMeta:
'@date-fns/tz':
optional: true
'@types/react':
optional: true
date-fns:
optional: true
'@base-ui/utils@0.3.1':
resolution: {integrity: sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==}
peerDependencies:
'@types/react': ^17 || ^18 || ^19
react: ^17 || ^18 || ^19
react-dom: ^17 || ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@date-fns/tz@1.5.0': '@date-fns/tz@1.5.0':
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
@@ -2836,6 +2866,9 @@ packages:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
reselect@5.2.0:
resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
resolve-from@4.0.0: resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -3298,6 +3331,31 @@ snapshots:
'@babel/helper-string-parser': 7.29.7 '@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7 '@babel/helper-validator-identifier': 7.29.7
'@base-ui/react@1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@babel/runtime': 7.29.7
'@base-ui/utils': 0.3.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@floating-ui/utils': 0.2.11
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
use-sync-external-store: 1.6.0(react@19.2.7)
optionalDependencies:
'@date-fns/tz': 1.5.0
'@types/react': 19.2.17
date-fns: 4.4.0
'@base-ui/utils@0.3.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@babel/runtime': 7.29.7
'@floating-ui/utils': 0.2.11
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
reselect: 5.2.0
use-sync-external-store: 1.6.0(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@date-fns/tz@1.5.0': {} '@date-fns/tz@1.5.0': {}
'@esbuild/aix-ppc64@0.28.1': '@esbuild/aix-ppc64@0.28.1':
@@ -6022,6 +6080,8 @@ snapshots:
gopd: 1.2.0 gopd: 1.2.0
set-function-name: 2.0.2 set-function-name: 2.0.2
reselect@5.2.0: {}
resolve-from@4.0.0: {} resolve-from@4.0.0: {}
resolve@2.0.0-next.7: resolve@2.0.0-next.7:

31
scripts/deploy.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/bin/bash
set -euo pipefail
PROJECT_ROOT=$(cd -- "$(dirname "${BASH_SOURCE[0]}")/.." &> /dev/null && pwd)
REMOTE_SERVER="01"
REMOTE_DIR="/srv/docker/nginx/www/admin"
cd "${PROJECT_ROOT}"
pnpm run build
echo $PWD
tar -czf dist.tar.gz ./dist >/dev/null 2>&1
scp dist.tar.gz "${REMOTE_SERVER}:${REMOTE_DIR}"
ssh "$REMOTE_SERVER" bash -s -- "${REMOTE_DIR}" << 'EOF'
REMOTE_DIR="$1"
set -euo pipefail
cd "${REMOTE_DIR}"
rm -rf dist
tar -xzf dist.tar.gz
rm dist.tar.gz
EOF
rm -rf dist
rm -f dist.tar.gz
echo "部署完成"

22
src/api/blog/tag.ts Normal file
View File

@@ -0,0 +1,22 @@
import { http } from '@/lib'
import type { PageParams, Tag, TagForm } from '@/schemas'
export const getTags = (params: PageParams) => {
return http.get<Tag[], ApiPageResponse<Tag>>('/tag', { params })
}
export const createTag = (data: TagForm) => {
return http.post('/tag', data)
}
export const updateTag = (id: number, data: TagForm) => {
return http.patch(`/tag/${id}`, data)
}
export const deleteTag = (id: number) => {
return http.delete(`/tag/${id}`)
}
export const listAllTags = () => {
return http.get<Tag[]>('/tag/all')
}

View File

@@ -8,3 +8,4 @@ export * from './system/file'
// blog // blog
export * from './blog/post' export * from './blog/post'
export * from './blog/category' export * from './blog/category'
export * from './blog/tag'

View File

@@ -1,7 +1,7 @@
import { http } from '@/lib' import { http } from '@/lib'
import type { PageParams, SysApi, SysApiForm } from '@/schemas' import type { PageParams, SearchSysApiParams, SysApi, SysApiForm } from '@/schemas'
export const getSysApis = (params: PageParams) => { export const getSysApis = (params: PageParams & SearchSysApiParams) => {
return http.get<SysApi[], ApiPageResponse<SysApi>>('/api', { params }) return http.get<SysApi[], ApiPageResponse<SysApi>>('/api', { params })
} }

View File

@@ -1,8 +1,14 @@
import { http } from '@/lib' import { http } from '@/lib'
import type { SysRole, SysUser, SysUserFormOutput, SysUserInfo } from '@/schemas' import type {
SearchSysUserParams,
SysRole,
SysUser,
SysUserFormOutput,
SysUserInfo,
} from '@/schemas'
import { type PageParams } from '@/schemas' import { type PageParams } from '@/schemas'
export const getSysUsers = (params: PageParams) => { export const getSysUsers = (params: PageParams & SearchSysUserParams) => {
return http.get<SysUser[], ApiPageResponse<SysUser>>('/user', { params }) return http.get<SysUser[], ApiPageResponse<SysUser>>('/user', { params })
} }

View File

@@ -0,0 +1,29 @@
import { type ReactNode } from 'react'
import { useStore } from '@/store'
interface AuthProps {
authority: string | string[]
fallback?: ReactNode | null
mode?: 'all' | 'any'
children: ReactNode
}
export const Auth = ({ authority, fallback = null, mode = 'all', children }: AuthProps) => {
const userInfo = useStore((state) => state.userInfo)
const permissions = userInfo?.permissions || []
let hasAuth: boolean
if (Array.isArray(authority)) {
if (mode === 'all') {
hasAuth = authority.every((role) => permissions.includes(role))
} else {
hasAuth = authority.some((role) => permissions.includes(role))
}
} else {
hasAuth = permissions.includes(authority)
}
return hasAuth ? <>{children}</> : <>{fallback}</>
}

View File

@@ -89,5 +89,20 @@ export const useCheckboxTree = (treeData: CheckboxTreeNode[], defaultChecked: st
[checkedIds, childIdsMap, directChildrenMap, getNodeState, parentMap], [checkedIds, childIdsMap, directChildrenMap, getNodeState, parentMap],
) )
return { checkedIds, toggleNode, getNodeState, setCheckedIds } const getCheckedIdsWithParents = useCallback(() => {
const ids = new Set(checkedIds)
checkedIds.forEach((id) => {
let parentId = parentMap.get(id)
while (parentId) {
ids.add(parentId)
parentId = parentMap.get(parentId)
}
})
return Array.from(ids)
}, [checkedIds, parentMap])
return { checkedIds, toggleNode, getNodeState, setCheckedIds, getCheckedIdsWithParents }
} }

View File

@@ -24,7 +24,7 @@ export function BaseTable<TData>({ table }: Props<TData>) {
const pageSize = table.getState().pagination.pageSize const pageSize = table.getState().pagination.pageSize
const rowCount = table.options.rowCount const rowCount = table.options.rowCount
const currentPageRowsLength = table.getCoreRowModel().rows.length const currentPageRowsLength = table.getCoreRowModel().rows.length
const showLoading = table.options.meta?.showLoading const isFetching = table.options.meta?.isFetching
useEffect(() => { useEffect(() => {
if (pageIndex > 0 && currentPageRowsLength === 0 && rowCount) { if (pageIndex > 0 && currentPageRowsLength === 0 && rowCount) {
@@ -39,12 +39,12 @@ export function BaseTable<TData>({ table }: Props<TData>) {
const isTree = !!table.options.getExpandedRowModel const isTree = !!table.options.getExpandedRowModel
return ( return (
<Loading visible={showLoading}> <Loading visible={isFetching}>
<div className='rounded-md border'> <div className='border border-border/80 rounded-md'>
<Table> <Table>
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}> <TableRow key={headerGroup.id} className='bg-muted/50'>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<TableHead <TableHead
key={header.id} key={header.id}

View File

@@ -1,20 +1,31 @@
import { useEffect } from 'react' import { useEffect, type ReactNode } from 'react'
import { useRouterState } from '@tanstack/react-router'
import { type Table } from '@tanstack/react-table' import { type Table } from '@tanstack/react-table'
import { DataTablePagination } from '@/components/data-table/pagination.tsx' import { DataTablePagination } from '@/components/data-table/pagination.tsx'
import { DataTableToolbar } from '@/components/data-table/toolbar.tsx'
import { BaseTable } from './base-table' import { BaseTable } from './base-table'
interface Props<TData> { interface Props<TData> {
table: Table<TData> table: Table<TData>
toolbarLeft?: ReactNode
toolbarRight?: ReactNode
search?: ReactNode
} }
export function DataTable<TData>({ table }: Props<TData>) { function TableTitle({ title }: { title?: string }) {
if (!title) return null
return <div className='text-xl font-semibold mb-4'>{title}</div>
}
export function DataTable<TData>({ table, search, toolbarLeft, toolbarRight }: Props<TData>) {
const pageIndex = table.getState().pagination.pageIndex const pageIndex = table.getState().pagination.pageIndex
const pageSize = table.getState().pagination.pageSize const pageSize = table.getState().pagination.pageSize
const rowCount = table.options.rowCount const rowCount = table.options.rowCount
const rowLength = table.getCoreRowModel().rows.length const rowLength = table.getCoreRowModel().rows.length
const title = useRouterState().matches.at(-1)?.staticData?.title
useEffect(() => { useEffect(() => {
// 如果非第一页且数据为空 返回最近一页有数据的页面 // 如果非第一页且数据为空 返回最近一页有数据的页面
@@ -29,6 +40,12 @@ export function DataTable<TData>({ table }: Props<TData>) {
return ( return (
<> <>
<TableTitle title={title} />
{search && <div className='pt-2 pb-5 border-b mb-4'>{search}</div>}
<DataTableToolbar table={table} toolbarLeft={toolbarLeft} toolbarRight={toolbarRight} />
<BaseTable table={table} /> <BaseTable table={table} />
<DataTablePagination table={table} /> <DataTablePagination table={table} />

View File

@@ -51,11 +51,12 @@ export function DataTablePagination<TData>({ table }: DataTablePaginationProps<T
if (total <= 0) return null if (total <= 0) return null
return ( return (
<div className='w-full flex items-center justify-end mt-4 h-10 gap-4'> <div className='w-full flex flex-col md:flex-row md:items-center justify-end mt-4 min-h-10 gap-4'>
<div className='text-sm text-gray-700'> <div className='text-sm text-gray-700'>
<span>{pagination.active}</span> <span>{total}</span> <span>{pagination.active}</span> <span>{total}</span>
</div> </div>
<div className='flex gap-4'>
<div className='flex items-center justify-between gap-2'> <div className='flex items-center justify-between gap-2'>
<Button <Button
disabled={!table.getCanPreviousPage()} disabled={!table.getCanPreviousPage()}
@@ -113,5 +114,6 @@ export function DataTablePagination<TData>({ table }: DataTablePaginationProps<T
</Select> </Select>
</div> </div>
</div> </div>
</div>
) )
} }

View File

@@ -0,0 +1,55 @@
import type { ReactNode } from 'react'
import type { Table } from '@tanstack/react-table'
import { RefreshCcw } from 'lucide-react'
import { DataTableViewOptions } from '@/components/data-table/view-options.tsx'
import { Button } from '@/components/ui/button.tsx'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip.tsx'
type DataTableToolbarProps<TData> = {
table: Table<TData>
toolbarLeft?: ReactNode
toolbarRight?: ReactNode
}
export function DataTableToolbar<TData>({
table,
toolbarLeft,
toolbarRight,
}: DataTableToolbarProps<TData>) {
const refetch = table.options.meta?.refetch
const isFetching = table.options.meta?.isFetching
return (
<div className='flex mb-4 items-center'>
<div className='flex-1 flex items-center'>{toolbarLeft}</div>
<div className='flex gap-2 items-center'>
{toolbarRight}
<div className='flex items-center'>
{refetch && (
<Tooltip>
<TooltipTrigger asChild>
<span className='inline-flex'>
<Button
disabled={isFetching}
variant='ghost'
size='icon'
className='cursor-pointer'
onClick={() => refetch()}
>
<RefreshCcw />
</Button>
</span>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
)}
<DataTableViewOptions table={table} />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,63 @@
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'
import { type Column, type Table } from '@tanstack/react-table'
import { Settings2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
type DataTableViewOptionsProps<TData> = {
table: Table<TData>
}
const getColumnLabel = <TData,>(column: Column<TData, unknown>) => {
const metaLabel = column.columnDef.meta?.label
const header = column.columnDef.header
if (typeof metaLabel === 'string') {
return metaLabel
}
if (typeof header === 'string') {
return header
}
return column.id
}
export function DataTableViewOptions<TData>({ table }: DataTableViewOptionsProps<TData>) {
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant='ghost' size='sm' className='ms-auto h-8 flex'>
<Settings2 />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-37.5'>
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter((column) => typeof column.accessorFn !== 'undefined' && column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className='capitalize'
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{getColumnLabel(column)}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,28 @@
import { type ReactNode } from 'react'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
interface Props {
loading: boolean
children: ReactNode
className?: string
rows?: number
}
export const DialogSkeleton = ({ loading, children, className, rows = 3 }: Props) => {
if (!loading) {
return children
}
return (
<div className={cn('flex w-full max-w-xs flex-col gap-2', className)}>
{Array.from({ length: rows }).map((_, index) => (
<Skeleton
key={index}
className={cn('h-4 w-full', index === rows - 1 && rows > 1 && 'w-3/4')}
/>
))}
</div>
)
}

View File

@@ -0,0 +1 @@
export { StatusLabel } from './status.tsx'

View File

@@ -0,0 +1,18 @@
import { Status, statusDict, type StatusValues } from '@/enum'
import { cn } from '@/lib'
const colorMap = {
[Status.enabled]: 'bg-green-500',
[Status.disabled]: 'bg-red-500',
}
export function StatusLabel({ value }: { value: StatusValues }) {
const label = statusDict[value] || value
return (
<div className='flex items-center gap-2'>
<span className={cn('w-1.5 h-1.5 rounded-full', colorMap[value])}></span>
<span>{label}</span>
</div>
)
}

View File

@@ -7,6 +7,7 @@ import {
House, House,
NotebookText, NotebookText,
SquarePen, SquarePen,
Tags,
TextAlignJustify, TextAlignJustify,
UserRoundKey, UserRoundKey,
UsersRound, UsersRound,
@@ -31,6 +32,7 @@ const iconMap: Partial<Record<MenuIconKeyValues, IconComponent>> = {
[MenuIconKey.POST]: NotebookText, [MenuIconKey.POST]: NotebookText,
[MenuIconKey.POST_EDIT]: SquarePen, [MenuIconKey.POST_EDIT]: SquarePen,
[MenuIconKey.CATEGORY]: ChartBarStacked, [MenuIconKey.CATEGORY]: ChartBarStacked,
[MenuIconKey.TAG]: Tags,
} }
export const MenuIcon = ({ className, menuIconKey }: Props) => { export const MenuIcon = ({ className, menuIconKey }: Props) => {

View File

@@ -0,0 +1,78 @@
'use client'
import * as React from 'react'
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from '@/components/ui/combobox'
export interface MultipleSelectOption {
label: string
value: number
}
interface MultipleSelectProps {
value?: number[]
onValueChange?: (value: number[]) => void
items?: MultipleSelectOption[]
placeholder?: string
className?: string
}
export function MultipleSelect({
value,
onValueChange,
items = [],
placeholder,
className,
}: MultipleSelectProps) {
const anchor = useComboboxAnchor()
const selectedItems = items.filter((item) => value?.includes(item.value))
const handleValueChange = (selectedOptions: MultipleSelectOption[]) => {
onValueChange?.(selectedOptions.map((item) => item.value))
}
return (
<Combobox
multiple
autoHighlight
items={items}
value={selectedItems}
onValueChange={handleValueChange}
>
<ComboboxChips ref={anchor} className={className ?? 'w-full max-w-xs'}>
<ComboboxValue>
{(values) => (
<React.Fragment>
{values.map((item: MultipleSelectOption) => (
<ComboboxChip key={item.value}>{item.label}</ComboboxChip>
))}
<ComboboxChipsInput placeholder={placeholder} />
</React.Fragment>
)}
</ComboboxValue>
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>No items found.</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem key={item.value} value={item}>
{item.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
)
}

View File

@@ -2,7 +2,7 @@ import logo from '@/assets/logo.png'
import styles from './loading.module.css' import styles from './loading.module.css'
export const Loading = () => { export const PageLoading = () => {
return ( return (
<div className={`${styles['loading-wrap']}`}> <div className={`${styles['loading-wrap']}`}>
<img src={logo} className={styles.logo} alt='Logo' /> <img src={logo} className={styles.logo} alt='Logo' />

View File

@@ -0,0 +1,310 @@
"use client"
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
const Combobox = ComboboxPrimitive.Root
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon
data-slot="combobox-trigger-icon"
className="pointer-events-none size-4 text-muted-foreground"
/>
</ComboboxPrimitive.Trigger>
)
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
asChild
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
>
<ComboboxTrigger />
</InputGroupButton>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
)
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
"group/combobox-content relative max-h-96 w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"max-h-[min(calc(--spacing(96)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto p-1 data-empty:p-0",
className
)}
{...props}
/>
)
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
data-slot="combobox-item-indicator"
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none size-4 pointer-coarse:size-5" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
)
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn(
"px-2 py-1.5 text-xs text-muted-foreground pointer-coarse:px-3 pointer-coarse:py-2 pointer-coarse:text-sm",
className
)}
{...props}
/>
)
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
)
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
className
)}
{...props}
/>
)
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border border-input bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-[3px] has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1.5 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
}
function ComboboxChipsInput({
className,
children,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
)
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
}

View File

@@ -12,7 +12,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="input-group" data-slot="input-group"
role="group" role="group"
className={cn( className={cn(
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none", "group/input-group relative flex w-full items-center rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30",
"h-9 min-w-0 has-[>textarea]:h-auto", "h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment. // Variants based on alignment.
@@ -22,10 +22,10 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state. // Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]", "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50",
// Error state. // Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", "has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className className
)} )}
@@ -35,7 +35,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
} }
const inputGroupAddonVariants = cva( const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50", "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
align: { align: {
@@ -44,9 +44,9 @@ const inputGroupAddonVariants = cva(
"inline-end": "inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]", "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start": "block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5", "order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3",
"block-end": "block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5", "order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3",
}, },
}, },
defaultVariants: { defaultVariants: {
@@ -78,12 +78,12 @@ function InputGroupAddon({
} }
const inputGroupButtonVariants = cva( const inputGroupButtonVariants = cva(
"text-sm shadow-none flex gap-2 items-center", "flex items-center gap-2 text-sm shadow-none",
{ {
variants: { variants: {
size: { size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2", xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5", sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
"icon-xs": "icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0", "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0", "icon-sm": "size-8 p-0 has-[>svg]:p-0",
@@ -118,7 +118,7 @@ function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return ( return (
<span <span
className={cn( className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4", "flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} {...props}

View File

@@ -8,9 +8,9 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className className
)} )}
{...props} {...props}

View File

@@ -8,6 +8,7 @@ export const MenuIconKey = {
POST: 7, POST: 7,
POST_EDIT: 8, POST_EDIT: 8,
CATEGORY: 9, CATEGORY: 9,
TAG: 10,
} as const } as const
export type MenuIconKeyValues = (typeof MenuIconKey)[keyof typeof MenuIconKey] export type MenuIconKeyValues = (typeof MenuIconKey)[keyof typeof MenuIconKey]
@@ -22,6 +23,6 @@ export const MenuIconOptions: MenuIconOption[] = Object.entries(MenuIconKey).map
([key, value]) => ({ ([key, value]) => ({
label: key as keyof typeof MenuIconKey, label: key as keyof typeof MenuIconKey,
value, value,
selectValue: value.toString() as `${MenuIconKeyValues}`, selectValue: value?.toString() as `${MenuIconKeyValues}`,
}), }),
) )

View File

@@ -18,19 +18,19 @@ export const postStatusOptions = [
{ {
label: postStatusDict[PostStatus.draft], label: postStatusDict[PostStatus.draft],
value: String(PostStatus.draft), value: String(PostStatus.draft),
icon: <ClipboardPenLine />, icon: ClipboardPenLine,
className: 'bg-gray-200 text-gray-600', className: 'bg-gray-200 text-gray-600',
}, },
{ {
label: postStatusDict[PostStatus.published], label: postStatusDict[PostStatus.published],
value: String(PostStatus.published), value: String(PostStatus.published),
icon: <Globe />, icon: Globe,
className: 'bg-lime-100 text-emerald-700', className: 'bg-blue-400 text-white',
}, },
{ {
label: postStatusDict[PostStatus.offline], label: postStatusDict[PostStatus.offline],
value: String(PostStatus.offline), value: String(PostStatus.offline),
icon: <EyeOff />, icon: EyeOff,
className: 'bg-gray-100 text-gray-300', className: 'bg-gray-100 text-gray-400',
}, },
] ]

View File

@@ -26,13 +26,13 @@ import { generateBreadcrumbs } from '@/lib'
import { useStore } from '@/store' import { useStore } from '@/store'
export const AppHeader = () => { export const AppHeader = () => {
const location = useLocation() const pathname = useLocation({ select: (location) => location.pathname })
const userInfo = useStore((state) => state.userInfo) const userInfo = useStore((state) => state.userInfo)
const logout = useStore((state) => state.logout) const logout = useStore((state) => state.logout)
const breadcrumbs = useMemo(() => { const breadcrumbs = useMemo(() => {
return generateBreadcrumbs(userInfo?.menus || [], location.pathname) return generateBreadcrumbs(userInfo?.menus || [], pathname)
}, [userInfo?.menus, location.pathname]) }, [userInfo?.menus, pathname])
return ( return (
<header className='bg-white z-50 h-16 px-6 border-b flex justify-between'> <header className='bg-white z-50 h-16 px-6 border-b flex justify-between'>

View File

@@ -19,6 +19,7 @@ import {
import { MenuType } from '@/enum' import { MenuType } from '@/enum'
import { generateMenus } from '@/lib' import { generateMenus } from '@/lib'
import type { SysMenuTree } from '@/schemas' import type { SysMenuTree } from '@/schemas'
import { siteConfig } from '@/siteConfig.ts'
import { useStore } from '@/store' import { useStore } from '@/store'
const isMenuActive = (menu: SysMenuTree, pathname: string): boolean => { const isMenuActive = (menu: SysMenuTree, pathname: string): boolean => {
@@ -86,7 +87,7 @@ export function AppSidebar({ ...props }: ComponentProps<typeof Sidebar>) {
<img className='w-full h-full' src={Logo} alt='' /> <img className='w-full h-full' src={Logo} alt='' />
</div> </div>
<div className='flex flex-col gap-0.5 leading-none'> <div className='flex flex-col gap-0.5 leading-none'>
<span className='font-medium text-xl'>Uroboros</span> <span className='font-medium text-xl'>{siteConfig.title}</span>
</div> </div>
</div> </div>
</SidebarMenuButton> </SidebarMenuButton>

View File

@@ -21,8 +21,8 @@ export default function Layout({ children }: { children?: React.ReactNode }) {
<AppSidebar /> <AppSidebar />
<SidebarInset className='h-svh h-screen min-w-0'> <SidebarInset className='h-svh h-screen min-w-0'>
<AppHeader /> <AppHeader />
<div className='flex-1 overflow-y-auto min-h-0 p-4 bg-[#f5f5f5] flex flex-col'> <div className='flex-1 overflow-y-auto min-h-0 p-2 md:p-4 bg-[#f5f5f5] flex flex-col'>
<div className='bg-white rounded-md p-6 flex-1 shadow-[0_1px_2px_0_rgba(0,0,0,0.03),0_1px_6px_-1px_rgba(0,0,0,0.02),0_2px_4px_0_rgba(0,0,0,0.02)]'> <div className='bg-white rounded-md p-2 md:p-6 flex-1 shadow-[0_1px_2px_0_rgba(0,0,0,0.03),0_1px_6px_-1px_rgba(0,0,0,0.02),0_2px_4px_0_rgba(0,0,0,0.02)]'>
{children ?? <Outlet />} {children ?? <Outlet />}
</div> </div>
</div> </div>

19
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,19 @@
import { useStore } from '@/store'
export function hasPermission(authority: string | string[], mode: 'all' | 'any' = 'all'): boolean {
const permissions = useStore.getState().userInfo?.permissions ?? []
let hasAuth: boolean
if (Array.isArray(authority)) {
if (mode === 'all') {
hasAuth = authority.every((role) => permissions.includes(role))
} else {
hasAuth = authority.some((role) => permissions.includes(role))
}
} else {
hasAuth = permissions.includes(authority)
}
return hasAuth
}

View File

@@ -5,7 +5,22 @@ import { toast } from 'sonner'
import type { LoginResponse } from '@/schemas' import type { LoginResponse } from '@/schemas'
import { useStore } from '@/store' import { useStore } from '@/store'
type RequestConfig<D = unknown> = AxiosRequestConfig<D> & {
skipAuth?: boolean
}
const TOKEN_REFRESH_BUFFER = 60 const TOKEN_REFRESH_BUFFER = 60
const AUTH_WHITELIST = ['/auth/login', '/auth/logout']
const isAuthWhitelistUrl = (url?: string) => {
if (!url) {
return false
}
const pathname = url.split('?')[0]
return AUTH_WHITELIST.includes(pathname)
}
const isTokenExpired = (tokenExp: string | null) => { const isTokenExpired = (tokenExp: string | null) => {
if (!tokenExp) { if (!tokenExp) {
@@ -41,7 +56,6 @@ const getValidToken = async () => {
if (token && !isTokenExpired(tokenExp)) { if (token && !isTokenExpired(tokenExp)) {
return token return token
} }
if (!refreshTokenPromise) { if (!refreshTokenPromise) {
refreshTokenPromise = refreshInstance refreshTokenPromise = refreshInstance
.post<ApiResponse<LoginResponse>>('/auth/refresh-token') .post<ApiResponse<LoginResponse>>('/auth/refresh-token')
@@ -51,7 +65,9 @@ const getValidToken = async () => {
return access_token return access_token
}) })
.catch((error) => { .catch((error) => {
if (!isLoginPage()) { const status = error.response?.status
if (!isLoginPage() && (status === 400 || status === 401 || status === 403)) {
logout() logout()
} }
@@ -76,6 +92,12 @@ class HttpClient {
// 请求拦截器 // 请求拦截器
this.instance.interceptors.request.use( this.instance.interceptors.request.use(
async (config) => { async (config) => {
const requestConfig = config as RequestConfig
if (requestConfig.skipAuth || isAuthWhitelistUrl(config.url)) {
return config
}
const token = await getValidToken() const token = await getValidToken()
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`
@@ -127,14 +149,14 @@ class HttpClient {
} }
public request<T = unknown, R = ApiResponse<T>, D = unknown>( public request<T = unknown, R = ApiResponse<T>, D = unknown>(
config: AxiosRequestConfig, config: RequestConfig<D>,
): Promise<R> { ): Promise<R> {
return this.instance<T, R, D>(config) return this.instance<T, R, D>(config)
} }
public get<T = unknown, R = ApiResponse<T>, D = unknown>( public get<T = unknown, R = ApiResponse<T>, D = unknown>(
url: string, url: string,
config?: AxiosRequestConfig, config?: RequestConfig<D>,
): Promise<R> { ): Promise<R> {
return this.instance.get<T, R, D>(url, config) return this.instance.get<T, R, D>(url, config)
} }
@@ -142,7 +164,7 @@ class HttpClient {
public post<T = unknown, R = ApiResponse<T>, D = unknown>( public post<T = unknown, R = ApiResponse<T>, D = unknown>(
url: string, url: string,
data?: D, data?: D,
config?: AxiosRequestConfig, config?: RequestConfig<D>,
): Promise<R> { ): Promise<R> {
return this.instance.post<T, R, D>(url, data, config) return this.instance.post<T, R, D>(url, data, config)
} }
@@ -150,7 +172,7 @@ class HttpClient {
public patch<T = unknown, R = ApiResponse<T>, D = unknown>( public patch<T = unknown, R = ApiResponse<T>, D = unknown>(
url: string, url: string,
data?: D, data?: D,
config?: AxiosRequestConfig, config?: RequestConfig<D>,
): Promise<R> { ): Promise<R> {
return this.instance.patch<T, R, D>(url, data, config) return this.instance.patch<T, R, D>(url, data, config)
} }
@@ -158,14 +180,14 @@ class HttpClient {
public put<T = unknown, R = ApiResponse<T>, D = unknown>( public put<T = unknown, R = ApiResponse<T>, D = unknown>(
url: string, url: string,
data?: D, data?: D,
config?: AxiosRequestConfig, config?: RequestConfig<D>,
): Promise<R> { ): Promise<R> {
return this.instance.put<T, R, D>(url, data, config) return this.instance.put<T, R, D>(url, data, config)
} }
public delete<T = unknown, R = ApiResponse<T>, D = unknown>( public delete<T = unknown, R = ApiResponse<T>, D = unknown>(
url: string, url: string,
config?: AxiosRequestConfig, config?: RequestConfig<D>,
): Promise<R> { ): Promise<R> {
return this.instance.delete<T, R, D>(url, config) return this.instance.delete<T, R, D>(url, config)
} }

View File

@@ -3,3 +3,4 @@ export * from './menus'
export * from './utils' export * from './utils'
export * from './format' export * from './format'
export * from './sleep' export * from './sleep'
export * from './auth'

View File

@@ -23,7 +23,7 @@ export const generateMenus = ({
.sort((a, b) => a.sort - b.sort) .sort((a, b) => a.sort - b.sort)
.map((item) => ({ .map((item) => ({
...item, ...item,
children: generateMenus({ menus, parent_id: item.id, showHidden }), children: generateMenus({ menus, parent_id: item.id, showHidden, showButton }),
})) }))
} }

View File

@@ -7,7 +7,7 @@ import { Toaster } from 'sonner'
import { TooltipProvider } from '@/components/ui/tooltip' import { TooltipProvider } from '@/components/ui/tooltip'
import { Loading } from './components/loading' import { PageLoading } from './components/page-loading'
import { setupRouter } from './routes' import { setupRouter } from './routes'
import { useStore } from './store' import { useStore } from './store'
@@ -26,7 +26,7 @@ const queryClient = new QueryClient({
const bootstrap = async () => { const bootstrap = async () => {
const root = ReactDOM.createRoot(document.getElementById('root')!) const root = ReactDOM.createRoot(document.getElementById('root')!)
root.render(<Loading />) root.render(<PageLoading />)
try { try {
await useStore.getState().init() await useStore.getState().init()

View File

@@ -41,6 +41,7 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
: { : {
name: '', name: '',
code: '', code: '',
sort: 0,
}, },
}) })
@@ -75,10 +76,10 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
control={form.control} control={form.control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}> <Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='name'></FieldLabel> <FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Input <Input
{...field} {...field}
id='name' id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid} aria-invalid={fieldState.invalid}
placeholder='请输入分类名称' placeholder='请输入分类名称'
autoComplete='off' autoComplete='off'
@@ -95,10 +96,10 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
control={form.control} control={form.control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}> <Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='code'></FieldLabel> <FieldLabel htmlFor={`${formId}-${field.name}`}></FieldLabel>
<Input <Input
{...field} {...field}
id='code' id={`${formId}-${field.name}`}
aria-invalid={fieldState.invalid} aria-invalid={fieldState.invalid}
placeholder='请输入分类编码' placeholder='请输入分类编码'
autoComplete='off' 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> </FieldGroup>
</form> </form>

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { type Row } from '@tanstack/react-table' import { type Row } from '@tanstack/react-table'
import { MoreHorizontal } from 'lucide-react' import { MoreHorizontal } from 'lucide-react'
import { Auth } from '@/components/auth'
import { Button } from '@/components/ui/button.tsx' import { Button } from '@/components/ui/button.tsx'
import { import {
DropdownMenu, DropdownMenu,
@@ -42,8 +43,12 @@ export function RowActions({ row }: Props) {
<DropdownMenuContent align='end'> <DropdownMenuContent align='end'>
<DropdownMenuLabel></DropdownMenuLabel> <DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<Auth authority='category:update'>
<DropdownMenuItem onClick={onEdit}></DropdownMenuItem> <DropdownMenuItem onClick={onEdit}></DropdownMenuItem>
</Auth>
<Auth authority='category:delete'>
<DropdownMenuItem onClick={onDelete}></DropdownMenuItem> <DropdownMenuItem onClick={onDelete}></DropdownMenuItem>
</Auth>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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 { useQuery } from '@tanstack/react-query'
import { getCoreRowModel, useReactTable } from '@tanstack/react-table' import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
import { getCategories } from '@/api' import { getCategories } from '@/api'
import { hasPermission } from '@/lib'
import { type Category } from '@/schemas' import { type Category } from '@/schemas'
import { ActionDialogs } from './components/action-dialogs.tsx' import { ActionDialogs } from './components/action-dialogs.tsx'
@@ -17,9 +18,8 @@ export default function CrudPage() {
pageIndex: 0, pageIndex: 0,
pageSize: 10, pageSize: 10,
}) })
const [rowCount, setRowCount] = useState<number>()
const { data, isFetching } = useQuery({ const { data, isFetching, refetch } = useQuery({
queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize], queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize],
queryFn: () => queryFn: () =>
getCategories({ getCategories({
@@ -27,28 +27,24 @@ export default function CrudPage() {
page_size: pagination.pageSize, page_size: pagination.pageSize,
}), }),
placeholderData: (prev) => prev, placeholderData: (prev) => prev,
enabled: hasPermission('category:list'),
}) })
const tableData = useMemo<Category[]>(() => data?.list ?? [], [data]) const tableData = useMemo<Category[]>(() => data?.list ?? [], [data])
const columns = useMemo(() => createColumns(), []) const columns = useMemo(() => createColumns(), [])
useEffect(() => {
if (!isFetching && data?.total !== undefined) {
setRowCount(data.total)
}
}, [isFetching, data?.total])
const table = useReactTable<Category>({ const table = useReactTable<Category>({
data: tableData, data: tableData,
columns, columns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
manualPagination: true, manualPagination: true,
rowCount: rowCount, rowCount: data?.total,
state: { pagination }, state: { pagination },
onPaginationChange: setPagination, onPaginationChange: setPagination,
meta: { meta: {
showLoading: isFetching, isFetching,
refetch,
}, },
}) })

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { useNavigate } from '@tanstack/react-router'
import { type Row } from '@tanstack/react-table' import { type Row } from '@tanstack/react-table'
import { MoreHorizontal } from 'lucide-react' import { MoreHorizontal } from 'lucide-react'
import { Auth } from '@/components/auth'
import { Button } from '@/components/ui/button.tsx' import { Button } from '@/components/ui/button.tsx'
import { import {
DropdownMenu, DropdownMenu,
@@ -46,8 +47,12 @@ export function RowActions({ row }: Props) {
<DropdownMenuContent align='end'> <DropdownMenuContent align='end'>
<DropdownMenuLabel></DropdownMenuLabel> <DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<Auth authority='post:update'>
<DropdownMenuItem onClick={onEdit}></DropdownMenuItem> <DropdownMenuItem onClick={onEdit}></DropdownMenuItem>
</Auth>
<Auth authority='post:delete'>
<DropdownMenuItem onClick={onDelete}></DropdownMenuItem> <DropdownMenuItem onClick={onDelete}></DropdownMenuItem>
</Auth>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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 { useQuery } from '@tanstack/react-query'
import { getCoreRowModel, useReactTable } from '@tanstack/react-table' import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
import { getPosts } from '@/api' import { getPosts } from '@/api'
import { hasPermission } from '@/lib'
import { type Post } from '@/schemas' import { type Post } from '@/schemas'
import { ActionDialogs } from './components/action-dialogs.tsx' import { ActionDialogs } from './components/action-dialogs.tsx'
@@ -17,9 +18,8 @@ export default function CrudPage() {
pageIndex: 0, pageIndex: 0,
pageSize: 10, pageSize: 10,
}) })
const [rowCount, setRowCount] = useState<number>()
const { data, isFetching } = useQuery({ const { data, isFetching, refetch } = useQuery({
queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize], queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize],
queryFn: () => queryFn: () =>
getPosts({ getPosts({
@@ -27,28 +27,24 @@ export default function CrudPage() {
page_size: pagination.pageSize, page_size: pagination.pageSize,
}), }),
placeholderData: (prev) => prev, placeholderData: (prev) => prev,
enabled: hasPermission('post:list'),
}) })
const tableData = useMemo<Post[]>(() => data?.list ?? [], [data]) const tableData = useMemo<Post[]>(() => data?.list ?? [], [data])
const columns = useMemo(() => createColumns(), []) const columns = useMemo(() => createColumns(), [])
useEffect(() => {
if (!isFetching && data?.total !== undefined) {
setRowCount(data.total)
}
}, [isFetching, data?.total])
const table = useReactTable<Post>({ const table = useReactTable<Post>({
data: tableData, data: tableData,
columns, columns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
manualPagination: true, manualPagination: true,
rowCount: rowCount, rowCount: data?.total,
state: { pagination }, state: { pagination },
onPaginationChange: setPagination, onPaginationChange: setPagination,
meta: { 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 accountInfo = isRemembered ? getLocalAccountInfo() : null
const setToken = useStore((state) => state.setToken) const setToken = useStore((state) => state.setToken)
const { control, handleSubmit } = useForm<LoginUser>({ const form = useForm<LoginUser>({
resolver: zodResolver(loginSchema), resolver: zodResolver(loginSchema),
defaultValues: { defaultValues: {
account: accountInfo?.account ?? '', account: accountInfo?.account ?? '',
@@ -74,6 +74,15 @@ export function LoginForm({ className, ...props }: React.ComponentProps<'div'>)
location.replace('/') location.replace('/')
} }
const guideLogin = () => {
form.reset({
account: 'guest',
password: 'guest123',
})
form.handleSubmit(onSubmit)()
}
return ( return (
<div className={cn('flex flex-col gap-6', className)} {...props}> <div className={cn('flex flex-col gap-6', className)} {...props}>
<Card> <Card>
@@ -82,11 +91,11 @@ export function LoginForm({ className, ...props }: React.ComponentProps<'div'>)
<CardDescription></CardDescription> <CardDescription></CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form autoComplete='off' onSubmit={handleSubmit(onSubmit)}> <form autoComplete='off' onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup className='gap-5'> <FieldGroup className='gap-5'>
<Controller <Controller
name='account' name='account'
control={control} control={form.control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}> <Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='account'></FieldLabel> <FieldLabel htmlFor='account'></FieldLabel>
@@ -104,7 +113,7 @@ export function LoginForm({ className, ...props }: React.ComponentProps<'div'>)
<Controller <Controller
name='password' name='password'
control={control} control={form.control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}> <Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='password'></FieldLabel> <FieldLabel htmlFor='password'></FieldLabel>
@@ -135,6 +144,12 @@ export function LoginForm({ className, ...props }: React.ComponentProps<'div'>)
<Field> <Field>
<Button type='submit'></Button> <Button type='submit'></Button>
</Field> </Field>
<Field>
<Button type='button' onClick={guideLogin}>
</Button>
</Field>
</FieldGroup> </FieldGroup>
</form> </form>
</CardContent> </CardContent>

View File

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

View File

@@ -1,24 +1,36 @@
import { type Table } from '@tanstack/react-table' import { type Table } from '@tanstack/react-table'
import { Plus } from 'lucide-react'
import { Auth } from '@/components/auth'
import { DataTable } from '@/components/data-table' import { DataTable } from '@/components/data-table'
import { Button } from '@/components/ui/button.tsx' import { Button } from '@/components/ui/button.tsx'
import type { SearchSysApiParams } from '@/schemas'
import { useCrud } from './crud-provider.tsx' import { useCrud } from './crud-provider.tsx'
import { SearchForm } from './search-form.tsx'
interface Props<TData> { interface Props<TData> {
table: Table<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() const { setAction } = useCrud()
return ( return (
<> <Auth authority='api:list'>
<Button className='mb-4' onClick={() => setAction('add')}> <DataTable
table={table}
toolbarLeft={
<Auth authority='api:create'>
<Button size='sm' onClick={() => setAction('add')}>
<Plus />
</Button> </Button>
</Auth>
<DataTable table={table} /> }
</> 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 { Badge } from '@/components/ui/badge'
import { HttpMethodClass, type HttpMethodValues } from '@/enum' import { HttpMethodClass, type HttpMethodValues } from '@/enum'
import { formatDate } from '@/lib' import { formatDate, hasPermission } from '@/lib'
import type { SysApi } from '@/schemas' import type { SysApi } from '@/schemas'
import { RowActions } from './row-actions' import { RowActions } from './row-actions'
export const createColumns = (): ColumnDef<SysApi>[] => { export const createColumns = (): ColumnDef<SysApi>[] => {
return [ const showAction = hasPermission(['api:update', 'api:delete'], 'any')
const columns: ColumnDef<SysApi>[] = [
{ {
accessorKey: 'name', accessorKey: 'name',
header: '接口名称', header: '接口名称',
@@ -61,11 +63,16 @@ export const createColumns = (): ColumnDef<SysApi>[] => {
return <div className='overflow-hidden'>{formatDate(rawDate)}</div> return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
}, },
}, },
{ ]
if (showAction) {
columns.push({
id: 'actions', id: 'actions',
enableHiding: false, enableHiding: false,
meta: { className: ' max-w-36 sticky right-0' }, meta: { className: ' max-w-36 sticky right-0' },
cell: RowActions, cell: RowActions,
}, })
] }
return columns
} }

View File

@@ -1,6 +1,7 @@
import { type Row } from '@tanstack/react-table' import { type Row } from '@tanstack/react-table'
import { MoreHorizontal } from 'lucide-react' import { MoreHorizontal } from 'lucide-react'
import { Auth } from '@/components/auth'
import { Button } from '@/components/ui/button.tsx' import { Button } from '@/components/ui/button.tsx'
import { import {
DropdownMenu, DropdownMenu,
@@ -42,9 +43,12 @@ export function RowActions({ row }: Props) {
<DropdownMenuContent align='end'> <DropdownMenuContent align='end'>
<DropdownMenuLabel></DropdownMenuLabel> <DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<Auth authority='api:update'>
<DropdownMenuItem onClick={onEdit}></DropdownMenuItem> <DropdownMenuItem onClick={onEdit}></DropdownMenuItem>
</Auth>
<Auth authority='api:delete'>
<DropdownMenuItem onClick={onDelete}></DropdownMenuItem> <DropdownMenuItem onClick={onDelete}></DropdownMenuItem>
</Auth>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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 { useQuery } from '@tanstack/react-query'
import { getCoreRowModel, useReactTable } from '@tanstack/react-table' import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
import { getSysApis } from '@/api' 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 { ActionDialogs } from './components/action-dialogs.tsx'
import { ApiTable } from './components/api-table.tsx' import { ApiTable } from './components/api-table.tsx'
@@ -17,46 +18,55 @@ export default function CrudPage() {
pageIndex: 0, pageIndex: 0,
pageSize: 10, pageSize: 10,
}) })
const [rowCount, setRowCount] = useState<number>()
const { data, isFetching } = useQuery({ const [searchParams, setSearchParams] = useState<SearchSysApiParams>({})
queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize],
const { data, isFetching, refetch } = useQuery({
queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize, searchParams],
queryFn: () => queryFn: () =>
getSysApis({ getSysApis({
page: pagination.pageIndex + 1, page: pagination.pageIndex + 1,
page_size: pagination.pageSize, page_size: pagination.pageSize,
...searchParams,
}), }),
placeholderData: (prev) => prev, placeholderData: (prev) => prev,
enabled: hasPermission('api:list'),
}) })
const tableData = useMemo<SysApi[]>(() => data?.list ?? [], [data]) const tableData = useMemo<SysApi[]>(() => data?.list ?? [], [data])
const columns = useMemo(() => createColumns(), []) const columns = useMemo(() => createColumns(), [])
useEffect(() => {
if (!isFetching && data?.total !== undefined) {
setRowCount(data.total)
}
}, [isFetching, data?.total])
const table = useReactTable<SysApi>({ const table = useReactTable<SysApi>({
data: tableData, data: tableData,
columns, columns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
manualPagination: true, manualPagination: true,
rowCount: rowCount, rowCount: data?.total,
state: { pagination }, state: { pagination },
onPaginationChange: setPagination, onPaginationChange: setPagination,
meta: { 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 ( return (
<CrudProvider> <CrudProvider>
<ActionDialogs /> <ActionDialogs />
<ApiTable table={table} /> <ApiTable table={table} onSearch={handleSearch} />
</CrudProvider> </CrudProvider>
) )
} }

View File

@@ -1,5 +1,6 @@
import { type Table } from '@tanstack/react-table' import { type Table } from '@tanstack/react-table'
import { Auth } from '@/components/auth'
import { DataTable } from '@/components/data-table' import { DataTable } from '@/components/data-table'
interface Props<TData> { interface Props<TData> {
@@ -8,8 +9,8 @@ interface Props<TData> {
export function FileTable<TData>({ table }: Props<TData>) { export function FileTable<TData>({ table }: Props<TData>) {
return ( return (
<> <Auth authority='file:list'>
<DataTable table={table} /> <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 { useQuery } from '@tanstack/react-query'
import { getCoreRowModel, useReactTable } from '@tanstack/react-table' import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
import { getSysFiles } from '@/api'
import { hasPermission } from '@/lib'
import type { SysFile } from '@/schemas' import type { SysFile } from '@/schemas'
import { getSysFiles } from '../../../api/system/file.ts'
import { createColumns } from './components/columns.tsx' import { createColumns } from './components/columns.tsx'
import { CrudProvider } from './components/crud-provider.tsx' import { CrudProvider } from './components/crud-provider.tsx'
import { FileTable } from './components/file-table.tsx' import { FileTable } from './components/file-table.tsx'
@@ -16,9 +17,8 @@ export default function CrudPage() {
pageIndex: 0, pageIndex: 0,
pageSize: 10, pageSize: 10,
}) })
const [rowCount, setRowCount] = useState<number>()
const { data, isFetching } = useQuery({ const { data, isFetching, refetch } = useQuery({
queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize], queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize],
queryFn: () => queryFn: () =>
getSysFiles({ getSysFiles({
@@ -26,28 +26,24 @@ export default function CrudPage() {
page_size: pagination.pageSize, page_size: pagination.pageSize,
}), }),
placeholderData: (prev) => prev, placeholderData: (prev) => prev,
enabled: hasPermission('file:list'),
}) })
const tableData = useMemo<SysFile[]>(() => data?.list ?? [], [data]) const tableData = useMemo<SysFile[]>(() => data?.list ?? [], [data])
const columns = useMemo(() => createColumns(), []) const columns = useMemo(() => createColumns(), [])
useEffect(() => {
if (!isFetching && data?.total !== undefined) {
setRowCount(data.total)
}
}, [isFetching, data?.total])
const table = useReactTable<SysFile>({ const table = useReactTable<SysFile>({
data: tableData, data: tableData,
columns, columns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
manualPagination: true, manualPagination: true,
rowCount: rowCount, rowCount: data?.total,
state: { pagination }, state: { pagination },
onPaginationChange: setPagination, onPaginationChange: setPagination,
meta: { meta: {
showLoading: isFetching, isFetching,
refetch,
}, },
}) })

View File

@@ -2,22 +2,32 @@ import { Link } from '@tanstack/react-router'
import { ArrowUpRight, FileText, Users } from 'lucide-react' import { ArrowUpRight, FileText, Users } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import { Auth } from '@/components/auth'
interface QuickMenuItem { interface QuickMenuItem {
title: string title: string
description: string description: string
path: string path: string
icon: LucideIcon icon: LucideIcon
auth: string
} }
const quickMenus: QuickMenuItem[] = [ 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() { export default function Home() {
return ( return (
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4'> <div className='grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4'>
{quickMenus.map((menu) => ( {quickMenus.map((menu) => (
<Auth authority={menu.auth}>
<Link <Link
key={menu.path} key={menu.path}
to={menu.path} to={menu.path}
@@ -30,6 +40,7 @@ export default function Home() {
<h3 className='mt-8 text-sm font-medium'>{menu.title}</h3> <h3 className='mt-8 text-sm font-medium'>{menu.title}</h3>
<p className='text-muted-foreground mt-1 text-xs'>{menu.description}</p> <p className='text-muted-foreground mt-1 text-xs'>{menu.description}</p>
</Link> </Link>
</Auth>
))} ))}
</div> </div>
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,19 @@
import type { ColumnDef } from '@tanstack/react-table' 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 type { SysRole } from '@/schemas'
import { RowActions } from './row-actions' import { RowActions } from './row-actions'
export const createColumns = (): ColumnDef<SysRole>[] => { 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', accessorKey: 'name',
header: '角色名称', header: '角色名称',
@@ -19,6 +26,15 @@ export const createColumns = (): ColumnDef<SysRole>[] => {
meta: { className: ' max-w-36' }, meta: { className: ' max-w-36' },
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('code')}</div>, 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', accessorKey: 'created_at',
header: '创建时间', header: '创建时间',
@@ -37,11 +53,16 @@ export const createColumns = (): ColumnDef<SysRole>[] => {
return <div className='overflow-hidden'>{formatDate(rawDate)}</div> return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
}, },
}, },
{ ]
if (showAction) {
columns.push({
id: 'actions', id: 'actions',
enableHiding: false, enableHiding: false,
meta: { className: ' max-w-36 sticky right-0' }, meta: { className: ' max-w-36 sticky right-0' },
cell: RowActions, cell: RowActions,
}, })
] }
return columns
} }

View File

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

View File

@@ -1,6 +1,7 @@
import { type Row } from '@tanstack/react-table' import { type Row } from '@tanstack/react-table'
import { MoreHorizontal } from 'lucide-react' import { MoreHorizontal } from 'lucide-react'
import { Auth } from '@/components/auth'
import { Button } from '@/components/ui/button.tsx' import { Button } from '@/components/ui/button.tsx'
import { import {
DropdownMenu, DropdownMenu,
@@ -52,10 +53,18 @@ export function RowActions({ row }: Props) {
<DropdownMenuContent align='end'> <DropdownMenuContent align='end'>
<DropdownMenuLabel></DropdownMenuLabel> <DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<Auth authority='role:update'>
<DropdownMenuItem onClick={onEdit}></DropdownMenuItem> <DropdownMenuItem onClick={onEdit}></DropdownMenuItem>
<DropdownMenuItem onClick={assignApis}></DropdownMenuItem> </Auth>
<DropdownMenuItem onClick={assignMenus}></DropdownMenuItem> <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> <DropdownMenuItem onClick={onDelete}></DropdownMenuItem>
</Auth>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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 { useQuery } from '@tanstack/react-query'
import { getCoreRowModel, useReactTable } from '@tanstack/react-table' import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
import { getSysRoles } from '@/api' import { getSysRoles } from '@/api'
import { hasPermission } from '@/lib'
import { type SysRole } from '@/schemas' import { type SysRole } from '@/schemas'
import { ActionDialogs } from './components/action-dialogs.tsx' import { ActionDialogs } from './components/action-dialogs.tsx'
@@ -17,9 +18,8 @@ export default function CrudPage() {
pageIndex: 0, pageIndex: 0,
pageSize: 10, pageSize: 10,
}) })
const [rowCount, setRowCount] = useState<number>()
const { data, isFetching } = useQuery({ const { data, isFetching, refetch } = useQuery({
queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize], queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize],
queryFn: () => queryFn: () =>
getSysRoles({ getSysRoles({
@@ -27,28 +27,24 @@ export default function CrudPage() {
page_size: pagination.pageSize, page_size: pagination.pageSize,
}), }),
placeholderData: (prev) => prev, placeholderData: (prev) => prev,
enabled: hasPermission('role:list'),
}) })
const tableData = useMemo<SysRole[]>(() => data?.list ?? [], [data]) const tableData = useMemo<SysRole[]>(() => data?.list ?? [], [data])
const columns = useMemo(() => createColumns(), []) const columns = useMemo(() => createColumns(), [])
useEffect(() => {
if (!isFetching && data?.total !== undefined) {
setRowCount(data.total)
}
}, [isFetching, data?.total])
const table = useReactTable<SysRole>({ const table = useReactTable<SysRole>({
data: tableData, data: tableData,
columns, columns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
manualPagination: true, manualPagination: true,
rowCount: rowCount, rowCount: data?.total,
state: { pagination }, state: { pagination },
onPaginationChange: setPagination, onPaginationChange: setPagination,
meta: { meta: {
showLoading: isFetching, isFetching,
refetch,
}, },
}) })

View File

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

View File

@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { assignSysUserRoles, getAllSysRoles, getSysUserRoles } from '@/api' import { assignSysUserRoles, getAllSysRoles, getSysUserRoles } from '@/api'
import { DialogSkeleton } from '@/components/dialog-skeleton'
import { Button } from '@/components/ui/button.tsx' import { Button } from '@/components/ui/button.tsx'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { import {
@@ -36,6 +37,7 @@ export function AssignRolesDialog({ open, onClose, onConfirm, currentRow }: Prop
const [roles, setRoles] = useState<SysRole[]>([]) const [roles, setRoles] = useState<SysRole[]>([])
const [checkedIds, setCheckedIds] = useState<number[]>([]) const [checkedIds, setCheckedIds] = useState<number[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [skeletonLoading, setSkeletonLoading] = useState(true)
const handleConfirm = async () => { const handleConfirm = async () => {
try { try {
@@ -54,14 +56,20 @@ export function AssignRolesDialog({ open, onClose, onConfirm, currentRow }: Prop
useEffect(() => { useEffect(() => {
const fetchUserRoles = async () => { const fetchUserRoles = async () => {
setSkeletonLoading(true)
const { data } = await getAllSysRoles() const { data } = await getAllSysRoles()
const { data: userRoles } = await getSysUserRoles(currentRow!.id) const { data: userRoles } = await getSysUserRoles(currentRow!.id)
setRoles(data) setRoles(data)
setCheckedIds(userRoles.map((role) => role.id)) setCheckedIds(userRoles.map((role) => role.id))
setSkeletonLoading(false)
} }
if (open) {
fetchUserRoles() fetchUserRoles()
}, [currentRow]) }
}, [open, currentRow])
return ( return (
<Dialog open={open} onOpenChange={onClose}> <Dialog open={open} onOpenChange={onClose}>
@@ -70,12 +78,12 @@ export function AssignRolesDialog({ open, onClose, onConfirm, currentRow }: Prop
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
<DialogDescription /> <DialogDescription />
</DialogHeader> </DialogHeader>
<div className='h-125 overflow-y-auto'> <div className='h-125 overflow-y-auto'>
<FieldSet> <FieldSet>
<FieldLegend variant='label'>{currentRow?.username}</FieldLegend> <FieldLegend variant='label'>{currentRow?.username}</FieldLegend>
<FieldDescription></FieldDescription> <FieldDescription></FieldDescription>
<FieldGroup className='gap-3'> <FieldGroup className='gap-3'>
<DialogSkeleton loading={skeletonLoading}>
{roles.map((role) => { {roles.map((role) => {
return ( return (
<Field orientation='horizontal' key={role.id}> <Field orientation='horizontal' key={role.id}>
@@ -91,6 +99,7 @@ export function AssignRolesDialog({ open, onClose, onConfirm, currentRow }: Prop
</Field> </Field>
) )
})} })}
</DialogSkeleton>
</FieldGroup> </FieldGroup>
</FieldSet> </FieldSet>
</div> </div>

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { type Row } from '@tanstack/react-table' import { type Row } from '@tanstack/react-table'
import { MoreHorizontal } from 'lucide-react' import { MoreHorizontal } from 'lucide-react'
import { Auth } from '@/components/auth'
import { Button } from '@/components/ui/button.tsx' import { Button } from '@/components/ui/button.tsx'
import { import {
DropdownMenu, DropdownMenu,
@@ -52,10 +53,18 @@ export function RowActions({ row }: Props) {
<DropdownMenuContent align='end'> <DropdownMenuContent align='end'>
<DropdownMenuLabel></DropdownMenuLabel> <DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<Auth authority='user:update'>
<DropdownMenuItem onClick={onEdit}></DropdownMenuItem> <DropdownMenuItem onClick={onEdit}></DropdownMenuItem>
</Auth>
<Auth authority='user:assign-roles'>
<DropdownMenuItem onClick={assignUserRoles}></DropdownMenuItem> <DropdownMenuItem onClick={assignUserRoles}></DropdownMenuItem>
</Auth>
<Auth authority='user:update-password'>
<DropdownMenuItem onClick={changePassword}></DropdownMenuItem> <DropdownMenuItem onClick={changePassword}></DropdownMenuItem>
</Auth>
<Auth authority='user:delete'>
<DropdownMenuItem onClick={onDelete}></DropdownMenuItem> <DropdownMenuItem onClick={onDelete}></DropdownMenuItem>
</Auth>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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 { type Table } from '@tanstack/react-table'
import { Plus } from 'lucide-react'
import { Auth } from '@/components/auth'
import { DataTable } from '@/components/data-table' import { DataTable } from '@/components/data-table'
import { Button } from '@/components/ui/button.tsx' import { Button } from '@/components/ui/button.tsx'
import type { SearchSysUserParams } from '@/schemas'
import { useCrud } from './crud-provider.tsx' import { useCrud } from './crud-provider.tsx'
import { SearchForm } from './search-form.tsx'
interface Props<TData> { interface Props<TData> {
table: Table<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() const { setAction } = useCrud()
return ( return (
<Auth authority='user:list'>
<DataTable
table={table}
search={<SearchForm onSearch={onSearch} />}
toolbarLeft={
<> <>
<Button className='mb-4' onClick={() => setAction('add')}> <Auth authority='user:create'>
<Button size='sm' onClick={() => setAction('add')}>
<Plus />
</Button> </Button>
</Auth>
<DataTable table={table} />
</> </>
}
/>
</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 { useQuery } from '@tanstack/react-query'
import { getCoreRowModel, useReactTable } from '@tanstack/react-table' import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
import { getSysUsers } from '@/api' 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 { ActionDialogs } from './components/action-dialogs.tsx'
import { createColumns } from './components/columns.tsx' import { createColumns } from './components/columns.tsx'
@@ -17,46 +18,49 @@ export default function CrudPage() {
pageIndex: 0, pageIndex: 0,
pageSize: 10, pageSize: 10,
}) })
const [rowCount, setRowCount] = useState<number>()
const { data, isFetching } = useQuery({ const [searchParams, setSearchParams] = useState<SearchSysUserParams>({})
queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize],
const { data, isFetching, refetch } = useQuery({
queryKey: [QUERY_KEY, pagination.pageIndex, pagination.pageSize, searchParams],
queryFn: () => queryFn: () =>
getSysUsers({ getSysUsers({
page: pagination.pageIndex + 1, page: pagination.pageIndex + 1,
page_size: pagination.pageSize, page_size: pagination.pageSize,
...searchParams,
}), }),
placeholderData: (prev) => prev, placeholderData: (prev) => prev,
enabled: hasPermission('user:list'),
}) })
const tableData = useMemo<SysUser[]>(() => data?.list ?? [], [data]) const tableData = useMemo<SysUser[]>(() => data?.list ?? [], [data])
const columns = useMemo(() => createColumns(), []) const columns = useMemo(() => createColumns(), [])
useEffect(() => {
if (!isFetching && data?.total !== undefined) {
setRowCount(data.total)
}
}, [isFetching, data?.total])
const table = useReactTable<SysUser>({ const table = useReactTable<SysUser>({
data: tableData, data: tableData,
columns, columns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
manualPagination: true, manualPagination: true,
rowCount: rowCount, rowCount: data?.total,
state: { pagination }, state: { pagination },
onPaginationChange: setPagination, onPaginationChange: setPagination,
meta: { meta: {
showLoading: isFetching, isFetching,
refetch,
}, },
}) })
const handleSearch = (params: SearchSysUserParams) => {
setSearchParams(params)
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
}
return ( return (
<CrudProvider> <CrudProvider>
<ActionDialogs /> <ActionDialogs />
<UserTable table={table} /> <UserTable table={table} onSearch={handleSearch} />
</CrudProvider> </CrudProvider>
) )
} }

View File

@@ -13,6 +13,7 @@ import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
import NotFound from '@/pages/404/index' import NotFound from '@/pages/404/index'
import { type SysMenu } from '@/schemas' import { type SysMenu } from '@/schemas'
import { siteConfig } from '@/siteConfig.ts'
import { useStore } from '@/store' import { useStore } from '@/store'
import Layout from '../layout' import Layout from '../layout'
@@ -30,11 +31,16 @@ const rootRoute = createRootRoute({
<NotFound /> <NotFound />
</Layout> </Layout>
), ),
beforeLoad: async ({ location }) => { beforeLoad: async ({ location, matches }) => {
const token = useStore.getState().token const isLogin = useStore.getState().isLogin
if (!token && location.pathname !== '/login') { if (!isLogin && location.pathname !== '/login') {
throw redirect({ to: '/login' }) throw redirect({ to: '/login' })
} }
const title = matches.at(-1)?.staticData?.title
if (title) {
document.title = title ? `${title} - ${siteConfig.title}` : siteConfig.title
}
}, },
}) })
@@ -81,6 +87,9 @@ export const setupRouter = (menus: SysMenu[]) => {
getParentRoute: () => (shouldUseLayout ? appLayoutRoute : rootRoute), getParentRoute: () => (shouldUseLayout ? appLayoutRoute : rootRoute),
path: config.path, path: config.path,
component: getComponent(config.component), component: getComponent(config.component),
staticData: {
title: config.name,
},
}) })
if (shouldUseLayout) { if (shouldUseLayout) {

View File

@@ -4,6 +4,7 @@ export const categorySchema = z.object({
id: z.number().int(), id: z.number().int(),
name: z.string(), name: z.string(),
code: z.string(), code: z.string(),
sort: z.number().int(),
created_at: z.string(), created_at: z.string(),
updated_at: z.string(), updated_at: z.string(),
}) })
@@ -12,8 +13,9 @@ export type Category = z.infer<typeof categorySchema>
export const categoryFormSchema = z.object({ export const categoryFormSchema = z.object({
id: z.number().int().optional(), id: z.number().int().optional(),
name: z.string().min(1, '请输入分类名称!'), name: z.string().min(1, '请输入标签名称!'),
code: z.string().min(1, '请输入分类code'), sort: z.coerce.number<number>().int().min(0, '排序值不能小于0'),
code: z.string().min(1, '请输入标签code'),
}) })
export type CategoryForm = z.infer<typeof categoryFormSchema> export type CategoryForm = z.infer<typeof categoryFormSchema>

View File

@@ -18,6 +18,7 @@ export const postSchema = z.object({
published_at: z.string(), published_at: z.string(),
created_at: z.string(), created_at: z.string(),
updated_at: z.string(), updated_at: z.string(),
tags: z.array(z.number().int()),
}) })
export type Post = z.infer<typeof postSchema> export type Post = z.infer<typeof postSchema>
@@ -41,6 +42,7 @@ export const postFormSchema = z
.int() .int()
.optional() .optional()
.refine((v) => v !== undefined, '请选择文章分类'), .refine((v) => v !== undefined, '请选择文章分类'),
tags: z.array(z.number().int()).optional(),
}) })
.transform((data) => { .transform((data) => {
const { cover, ...rest } = data const { cover, ...rest } = data

21
src/schemas/blog/tag.ts Normal file
View File

@@ -0,0 +1,21 @@
import { z } from 'zod'
export const tagSchema = z.object({
id: z.number().int(),
name: z.string(),
code: z.string(),
sort: z.number().int(),
created_at: z.string(),
updated_at: z.string(),
})
export type Tag = z.infer<typeof tagSchema>
export const tagFormSchema = z.object({
id: z.number().int().optional(),
name: z.string().min(1, '请输入分类名称!'),
sort: z.coerce.number<number>().int().min(0, '排序值不能小于0'),
code: z.string().min(1, '请输入分类code'),
})
export type TagForm = z.infer<typeof tagFormSchema>

View File

@@ -9,3 +9,4 @@ export * from './system/file'
//blog //blog
export * from './blog/post' export * from './blog/post'
export * from './blog/category' export * from './blog/category'
export * from './blog/tag'

View File

@@ -13,6 +13,14 @@ export const SysApiSchema = z.object({
export type SysApi = z.infer<typeof SysApiSchema> export type SysApi = z.infer<typeof SysApiSchema>
// 搜索参数
export const searchSysApiSchema = z.object({
group_name: z.string().optional(),
method: HttpMethodSchema.optional(),
})
export type SearchSysApiParams = z.infer<typeof searchSysApiSchema>
export const SysApiFormSchema = z.object({ export const SysApiFormSchema = z.object({
id: z.number().optional(), id: z.number().optional(),
name: z.string().min(2, '接口名称至少2个字符').max(16, '接口名称不能超过16个字符'), name: z.string().min(2, '接口名称至少2个字符').max(16, '接口名称不能超过16个字符'),

View File

@@ -35,7 +35,7 @@ export const sysMenuFormSchema = z
.object({ .object({
id: z.number().optional(), id: z.number().optional(),
name: z.string().min(1, '菜单名称不能为空').max(16, '菜单名称不能超过16个字符'), name: z.string().min(1, '菜单名称不能为空').max(16, '菜单名称不能超过16个字符'),
path: z.string().min(1, '路径不能为空'), path: z.string().optional(),
component: z.string().optional(), component: z.string().optional(),
hidden: z.boolean(), hidden: z.boolean(),
// https://www.reddit.com/r/reactjs/comments/1mmfnyt/typescript_error_when_using_zcoercenumberstring/ // https://www.reddit.com/r/reactjs/comments/1mmfnyt/typescript_error_when_using_zcoercenumberstring/

View File

@@ -1,9 +1,12 @@
import { z } from 'zod' import { z } from 'zod'
import { StatusSchema } from '@/enum'
export const sysRoleSchema = z.object({ export const sysRoleSchema = z.object({
id: z.number().int(), id: z.number().int(),
name: z.string(), name: z.string(),
code: z.string(), code: z.string(),
status: StatusSchema,
created_at: z.string(), created_at: z.string(),
updated_at: z.string(), updated_at: z.string(),
}) })
@@ -20,6 +23,9 @@ export const sysRoleFormSchema = z
.max(16, '角色编码不能超过16个字符') .max(16, '角色编码不能超过16个字符')
.regex(/^[a-zA-Z0-9_]+$/, '角色编码仅支持字母、数字和下划线') .regex(/^[a-zA-Z0-9_]+$/, '角色编码仅支持字母、数字和下划线')
.optional(), .optional(),
status: StatusSchema.refine((val) => val !== undefined, {
message: '请选择状态',
}),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (!data.id && !data.code) { if (!data.id && !data.code) {

View File

@@ -1,5 +1,7 @@
import { z } from 'zod' import { z } from 'zod'
import { StatusSchema } from '@/enum'
import { sysFileFormSchema } from './file' import { sysFileFormSchema } from './file'
import { sysMenuSchema } from './menu' import { sysMenuSchema } from './menu'
@@ -7,6 +9,7 @@ export const sysUserSchema = z.object({
id: z.number().int(), id: z.number().int(),
account: z.string(), account: z.string(),
username: z.string(), username: z.string(),
status: StatusSchema,
created_at: z.string(), created_at: z.string(),
updated_at: z.string(), updated_at: z.string(),
avatar_url: z.string().optional(), avatar_url: z.string().optional(),
@@ -15,6 +18,12 @@ export const sysUserSchema = z.object({
export type SysUser = z.infer<typeof sysUserSchema> export type SysUser = z.infer<typeof sysUserSchema>
// 搜索参数
export const searchSysUserSchema = z.object({
username: z.string().optional(),
})
export type SearchSysUserParams = z.infer<typeof searchSysUserSchema>
export const sysUserInfoSchema = sysUserSchema export const sysUserInfoSchema = sysUserSchema
.pick({ .pick({
id: true, id: true,
@@ -26,6 +35,7 @@ export const sysUserInfoSchema = sysUserSchema
.extend({ .extend({
roles: z.array(z.string()), roles: z.array(z.string()),
menus: z.array(sysMenuSchema), menus: z.array(sysMenuSchema),
permissions: z.array(z.string()),
}) })
export type SysUserInfo = z.infer<typeof sysUserInfoSchema> export type SysUserInfo = z.infer<typeof sysUserInfoSchema>
@@ -61,6 +71,9 @@ export const sysUserFormSchema = z
.max(16, '用户名称不能超过16个字符') .max(16, '用户名称不能超过16个字符')
.optional(), .optional(),
password: z.string().regex(passwordRegex, '密码需5-16位且包含字母和数字').optional(), password: z.string().regex(passwordRegex, '密码需5-16位且包含字母和数字').optional(),
status: StatusSchema.refine((val) => val !== undefined, {
message: '请选择状态',
}),
confirmPassword: z.string().optional(), confirmPassword: z.string().optional(),
avatar: z.array(sysFileFormSchema).optional(), avatar: z.array(sysFileFormSchema).optional(),
}) })

3
src/siteConfig.ts Normal file
View File

@@ -0,0 +1,3 @@
export const siteConfig = {
title: 'Uroboros',
}

View File

@@ -1,14 +1,18 @@
import { toast } from 'sonner'
import { create } from 'zustand' import { create } from 'zustand'
import { getSysUserInfo, logout } from '@/api' import { getSysUserInfo, logout } from '@/api'
import { getExpandedMenus, sleep } from '@/lib' import { getExpandedMenus, sleep } from '@/lib'
import type { SysUserInfo } from '@/schemas' import type { SysUserInfo } from '@/schemas'
const IS_LOGIN_KEY = 'is_login'
interface Store { interface Store {
token: string | null token: string | null
tokenExp: string | null tokenExp: string | null
userInfo: SysUserInfo | null userInfo: SysUserInfo | null
expandedMenus: number[] expandedMenus: number[]
isLogin: boolean
setToken: (token: string | null, tokenExp?: string | null) => void setToken: (token: string | null, tokenExp?: string | null) => void
logout: () => void logout: () => void
init: () => Promise<void> init: () => Promise<void>
@@ -19,19 +23,42 @@ const isLoginPage = () => {
return typeof window !== 'undefined' && window.location.pathname === '/login' return typeof window !== 'undefined' && window.location.pathname === '/login'
} }
const getIsLogin = () => {
return typeof window !== 'undefined' && localStorage.getItem(IS_LOGIN_KEY) === 'true'
}
const setLoginFlag = (isLogin: boolean) => {
if (isLogin) {
localStorage.setItem(IS_LOGIN_KEY, 'true')
} else {
localStorage.removeItem(IS_LOGIN_KEY)
}
}
export const useStore = create<Store>((set) => { export const useStore = create<Store>((set) => {
return { return {
token: null, token: null,
tokenExp: null, tokenExp: null,
userInfo: null, userInfo: null,
expandedMenus: [], expandedMenus: [],
isLogin: getIsLogin(),
setToken: (token, tokenExp = null) => { setToken: (token, tokenExp = null) => {
setLoginFlag(Boolean(token))
set({ token, tokenExp }) set({ token, tokenExp })
}, },
logout: async () => { logout: async () => {
try {
await logout() await logout()
set({ token: null, tokenExp: null, userInfo: null }) } catch (error) {
location.href = '/login' console.error(error)
toast.error('退出登录失败,请稍后重试', { richColors: true })
return
}
set({ token: null, tokenExp: null, userInfo: null, isLogin: false })
setLoginFlag(false)
location.replace('/login')
}, },
init: async () => { init: async () => {
if (isLoginPage()) { if (isLoginPage()) {

7
src/types/react-router.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
import '@tanstack/react-router'
declare module '@tanstack/react-router' {
interface StaticDataRouteOption {
title?: string
}
}

View File

@@ -5,10 +5,12 @@ declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> { interface ColumnMeta<TData extends RowData, TValue> {
className?: string className?: string
label?: string
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
interface TableMeta<TData extends RowData> { interface TableMeta<TData extends RowData> {
showLoading?: boolean isFetching?: boolean
refetch?: () => void
} }
} }