feat: release v1.0.0
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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
60
pnpm-lock.yaml
generated
@@ -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
31
scripts/deploy.sh
Executable 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
22
src/api/blog/tag.ts
Normal 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')
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
29
src/components/auth/index.tsx
Normal file
29
src/components/auth/index.tsx
Normal 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}</>
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -51,66 +51,68 @@ 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 items-center justify-between gap-2'>
|
<div className='flex gap-4'>
|
||||||
<Button
|
<div className='flex items-center justify-between gap-2'>
|
||||||
disabled={!table.getCanPreviousPage()}
|
<Button
|
||||||
variant='outline'
|
disabled={!table.getCanPreviousPage()}
|
||||||
className='w-8 h-8'
|
variant='outline'
|
||||||
onClick={pagination.previous}
|
className='w-8 h-8'
|
||||||
>
|
onClick={pagination.previous}
|
||||||
<ChevronLeft />
|
>
|
||||||
</Button>
|
<ChevronLeft />
|
||||||
{pagination.range.map((range, index) => {
|
</Button>
|
||||||
if (range === 'dots') {
|
{pagination.range.map((range, index) => {
|
||||||
|
if (range === 'dots') {
|
||||||
|
return (
|
||||||
|
<Button key={index} variant='ghost' disabled className='w-8 min-w-8 h-8'>
|
||||||
|
<Ellipsis />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button key={index} variant='ghost' disabled className='w-8 min-w-8 h-8'>
|
<Button
|
||||||
<Ellipsis />
|
key={index}
|
||||||
|
variant={pagination.active === range ? 'default' : 'ghost'}
|
||||||
|
className='w-8 min-w-8 h-8'
|
||||||
|
onClick={() => pagination.setPage(range)}
|
||||||
|
>
|
||||||
|
{range}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
})}
|
||||||
|
|
||||||
return (
|
<Button
|
||||||
<Button
|
disabled={!table.getCanNextPage()}
|
||||||
key={index}
|
variant='outline'
|
||||||
variant={pagination.active === range ? 'default' : 'ghost'}
|
className='w-8 h-8'
|
||||||
className='w-8 min-w-8 h-8'
|
onClick={pagination.next}
|
||||||
onClick={() => pagination.setPage(range)}
|
>
|
||||||
>
|
<ChevronRight />
|
||||||
{range}
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
<Button
|
<div>
|
||||||
disabled={!table.getCanNextPage()}
|
<Select defaultValue={initialPageSize.toString()} onValueChange={handlePageSizeChange}>
|
||||||
variant='outline'
|
<SelectTrigger className='w-full max-w-48'>
|
||||||
className='w-8 h-8'
|
<SelectValue placeholder='' />
|
||||||
onClick={pagination.next}
|
</SelectTrigger>
|
||||||
>
|
<SelectContent position='popper'>
|
||||||
<ChevronRight />
|
<SelectGroup>
|
||||||
</Button>
|
{pageSizeOptions.map((option, index) => (
|
||||||
</div>
|
<SelectItem key={index} value={option.toString()}>
|
||||||
|
{option}条/页
|
||||||
<div>
|
</SelectItem>
|
||||||
<Select defaultValue={initialPageSize.toString()} onValueChange={handlePageSizeChange}>
|
))}
|
||||||
<SelectTrigger className='w-full max-w-48'>
|
</SelectGroup>
|
||||||
<SelectValue placeholder='' />
|
</SelectContent>
|
||||||
</SelectTrigger>
|
</Select>
|
||||||
<SelectContent position='popper'>
|
</div>
|
||||||
<SelectGroup>
|
|
||||||
{pageSizeOptions.map((option, index) => (
|
|
||||||
<SelectItem key={index} value={option.toString()}>
|
|
||||||
{option}条/页
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
55
src/components/data-table/toolbar.tsx
Normal file
55
src/components/data-table/toolbar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
63
src/components/data-table/view-options.tsx
Normal file
63
src/components/data-table/view-options.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
src/components/dialog-skeleton/index.tsx
Normal file
28
src/components/dialog-skeleton/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
src/components/label/index.ts
Normal file
1
src/components/label/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { StatusLabel } from './status.tsx'
|
||||||
18
src/components/label/status.tsx
Normal file
18
src/components/label/status.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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) => {
|
||||||
|
|||||||
78
src/components/multiple-select/index.tsx
Normal file
78
src/components/multiple-select/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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' />
|
||||||
310
src/components/ui/combobox.tsx
Normal file
310
src/components/ui/combobox.tsx
Normal 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,
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}`,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -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'>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
19
src/lib/auth.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 }),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
</Button>
|
toolbarLeft={
|
||||||
|
<Auth authority='category:create'>
|
||||||
<DataTable table={table} />
|
<Button size='sm' onClick={() => setAction('add')}>
|
||||||
</>
|
<Plus />
|
||||||
|
新建分类
|
||||||
|
</Button>
|
||||||
|
</Auth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Auth>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
<DropdownMenuItem onClick={onEdit}>编辑分类</DropdownMenuItem>
|
<Auth authority='category:update'>
|
||||||
<DropdownMenuItem onClick={onDelete}>删除分类</DropdownMenuItem>
|
<DropdownMenuItem onClick={onEdit}>编辑分类</DropdownMenuItem>
|
||||||
|
</Auth>
|
||||||
|
<Auth authority='category:delete'>
|
||||||
|
<DropdownMenuItem onClick={onDelete}>删除分类</DropdownMenuItem>
|
||||||
|
</Auth>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'>
|
||||||
<Link className='underline' to={`/post/edit?id=${row.original.id}`}>
|
<Auth authority='post:update' fallback={row.getValue('title')}>
|
||||||
{row.getValue('title')}
|
<Link className='underline' to={`/post/edit?id=${row.original.id}`}>
|
||||||
</Link>
|
{row.getValue('title')}
|
||||||
|
</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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
</Button>
|
toolbarLeft={
|
||||||
|
<Auth authority='post:create'>
|
||||||
<DataTable table={table} />
|
<Button size='sm' onClick={() => navigate({ to: '/post/edit' })}>
|
||||||
</>
|
<Plus />
|
||||||
|
新建文章
|
||||||
|
</Button>
|
||||||
|
</Auth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Auth>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
<DropdownMenuItem onClick={onEdit}>编辑文章</DropdownMenuItem>
|
<Auth authority='post:update'>
|
||||||
<DropdownMenuItem onClick={onDelete}>删除文章</DropdownMenuItem>
|
<DropdownMenuItem onClick={onEdit}>编辑文章</DropdownMenuItem>
|
||||||
|
</Auth>
|
||||||
|
<Auth authority='post:delete'>
|
||||||
|
<DropdownMenuItem onClick={onDelete}>删除文章</DropdownMenuItem>
|
||||||
|
</Auth>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
149
src/pages/blog/tag/components/action-dialog.tsx
Normal file
149
src/pages/blog/tag/components/action-dialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
55
src/pages/blog/tag/components/action-dialogs.tsx
Normal file
55
src/pages/blog/tag/components/action-dialogs.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { QUERY_KEY } from '../constants.ts'
|
||||||
|
import { ActionsDialog } from './action-dialog.tsx'
|
||||||
|
import { useCrud } from './crud-provider.tsx'
|
||||||
|
import { DeleteDialog } from './delete-dialog.tsx'
|
||||||
|
|
||||||
|
export const ActionDialogs = () => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { action, setAction, currentRow, setCurrentRow } = useCrud()
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setAction(null)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setCurrentRow(null)
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] })
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ActionsDialog
|
||||||
|
key='add'
|
||||||
|
open={action === 'add'}
|
||||||
|
onClose={handleClose}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{currentRow && (
|
||||||
|
<>
|
||||||
|
<ActionsDialog
|
||||||
|
currentRow={currentRow}
|
||||||
|
key={`edit-${currentRow.id}`}
|
||||||
|
open={action === 'edit'}
|
||||||
|
onClose={handleClose}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteDialog
|
||||||
|
key={`delete-${currentRow.id}`}
|
||||||
|
currentRow={currentRow}
|
||||||
|
open={action === 'delete'}
|
||||||
|
onClose={handleClose}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
src/pages/blog/tag/components/columns.tsx
Normal file
60
src/pages/blog/tag/components/columns.tsx
Normal 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
|
||||||
|
}
|
||||||
33
src/pages/blog/tag/components/crud-provider.tsx
Normal file
33
src/pages/blog/tag/components/crud-provider.tsx
Normal 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
|
||||||
|
}
|
||||||
57
src/pages/blog/tag/components/delete-dialog.tsx
Normal file
57
src/pages/blog/tag/components/delete-dialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
55
src/pages/blog/tag/components/row-actions.tsx
Normal file
55
src/pages/blog/tag/components/row-actions.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
src/pages/blog/tag/components/tag-table.tsx
Normal file
32
src/pages/blog/tag/components/tag-table.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
7
src/pages/blog/tag/constants.ts
Normal file
7
src/pages/blog/tag/constants.ts
Normal 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>
|
||||||
58
src/pages/blog/tag/index.tsx
Normal file
58
src/pages/blog/tag/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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}
|
||||||
</Button>
|
toolbarLeft={
|
||||||
|
<Auth authority='api:create'>
|
||||||
<DataTable table={table} />
|
<Button size='sm' onClick={() => setAction('add')}>
|
||||||
</>
|
<Plus />
|
||||||
|
新建接口
|
||||||
|
</Button>
|
||||||
|
</Auth>
|
||||||
|
}
|
||||||
|
search={<SearchForm onSearch={onSearch} />}
|
||||||
|
/>
|
||||||
|
</Auth>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
<DropdownMenuItem onClick={onEdit}>编辑接口</DropdownMenuItem>
|
<Auth authority='api:update'>
|
||||||
|
<DropdownMenuItem onClick={onEdit}>编辑接口</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={onDelete}>删除接口</DropdownMenuItem>
|
</Auth>
|
||||||
|
<Auth authority='api:delete'>
|
||||||
|
<DropdownMenuItem onClick={onDelete}>删除接口</DropdownMenuItem>
|
||||||
|
</Auth>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)
|
)
|
||||||
|
|||||||
108
src/pages/system/api/components/search-form.tsx
Normal file
108
src/pages/system/api/components/search-form.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,34 +2,45 @@ 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) => (
|
||||||
<Link
|
<Auth authority={menu.auth}>
|
||||||
key={menu.path}
|
<Link
|
||||||
to={menu.path}
|
key={menu.path}
|
||||||
className='group bg-card hover:bg-muted/50 rounded-lg border p-5 transition-colors'
|
to={menu.path}
|
||||||
>
|
className='group bg-card hover:bg-muted/50 rounded-lg border p-5 transition-colors'
|
||||||
<div className='flex items-center justify-between'>
|
>
|
||||||
<menu.icon className='text-muted-foreground group-hover:text-foreground size-5 transition-colors' />
|
<div className='flex items-center justify-between'>
|
||||||
<ArrowUpRight className='text-muted-foreground size-4 opacity-0 transition-opacity group-hover:opacity-100' />
|
<menu.icon className='text-muted-foreground group-hover:text-foreground size-5 transition-colors' />
|
||||||
</div>
|
<ArrowUpRight className='text-muted-foreground size-4 opacity-0 transition-opacity group-hover:opacity-100' />
|
||||||
<h3 className='mt-8 text-sm font-medium'>{menu.title}</h3>
|
</div>
|
||||||
<p className='text-muted-foreground mt-1 text-xs'>{menu.description}</p>
|
<h3 className='mt-8 text-sm font-medium'>{menu.title}</h3>
|
||||||
</Link>
|
<p className='text-muted-foreground mt-1 text-xs'>{menu.description}</p>
|
||||||
|
</Link>
|
||||||
|
</Auth>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,33 +171,38 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Controller
|
{type !== MenuType.button && (
|
||||||
name='icon'
|
<Controller
|
||||||
control={form.control}
|
name='icon'
|
||||||
render={({ field, fieldState }) => (
|
control={form.control}
|
||||||
<Field data-invalid={fieldState.invalid}>
|
render={({ field, fieldState }) => (
|
||||||
<FieldLabel>图标</FieldLabel>
|
<Field data-invalid={fieldState.invalid}>
|
||||||
<Select
|
<FieldLabel htmlFor={`${formId}-${field.name}`}>图标</FieldLabel>
|
||||||
value={field.value?.toString()}
|
<Select
|
||||||
onValueChange={(value) => field.onChange(Number(value))}
|
value={field.value == null ? NO_ICON_VALUE : field.value.toString()}
|
||||||
>
|
onValueChange={(value) =>
|
||||||
<SelectTrigger className='w-full'>
|
field.onChange(value === NO_ICON_VALUE ? null : Number(value))
|
||||||
<SelectValue placeholder='请选择菜单图标' />
|
}
|
||||||
</SelectTrigger>
|
>
|
||||||
<SelectContent>
|
<SelectTrigger id={`${formId}-${field.name}`} className='w-full'>
|
||||||
<SelectGroup>
|
<SelectValue placeholder='请选择菜单图标' />
|
||||||
{MenuIconOptions.map((option) => (
|
</SelectTrigger>
|
||||||
<SelectItem key={option.value} value={option.selectValue}>
|
<SelectContent>
|
||||||
<MenuIcon menuIconKey={option.value} />
|
<SelectGroup>
|
||||||
</SelectItem>
|
<SelectItem value={NO_ICON_VALUE}>无图标</SelectItem>
|
||||||
))}
|
{MenuIconOptions.map((option) => (
|
||||||
</SelectGroup>
|
<SelectItem key={option.value} value={option.selectValue}>
|
||||||
</SelectContent>
|
<MenuIcon menuIconKey={option.value} />
|
||||||
</Select>
|
</SelectItem>
|
||||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
))}
|
||||||
</Field>
|
</SelectGroup>
|
||||||
)}
|
</SelectContent>
|
||||||
/>
|
</Select>
|
||||||
|
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||||
|
</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,25 +327,27 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Controller
|
{type !== MenuType.button && (
|
||||||
name='hidden'
|
<Controller
|
||||||
control={form.control}
|
name='hidden'
|
||||||
render={({ field, fieldState }) => (
|
control={form.control}
|
||||||
<Field data-invalid={fieldState.invalid}>
|
render={({ field, fieldState }) => (
|
||||||
<FieldLabel htmlFor='hidden'>是否隐藏</FieldLabel>
|
<Field data-invalid={fieldState.invalid}>
|
||||||
<Switch
|
<FieldLabel htmlFor={`${formId}-${field.name}`}>是否隐藏</FieldLabel>
|
||||||
id='hidden'
|
<Switch
|
||||||
aria-invalid={fieldState.invalid}
|
id={`${formId}-${field.name}`}
|
||||||
name={field.name}
|
aria-invalid={fieldState.invalid}
|
||||||
disabled={field.disabled}
|
name={field.name}
|
||||||
checked={field.value}
|
disabled={field.disabled}
|
||||||
onCheckedChange={field.onChange}
|
checked={field.value}
|
||||||
onBlur={field.onBlur}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
onBlur={field.onBlur}
|
||||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
/>
|
||||||
</Field>
|
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||||
)}
|
</Field>
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
</Button>
|
toolbarLeft={
|
||||||
|
<Auth authority='menu:create'>
|
||||||
<DataTable table={table} />
|
<Button size='sm' onClick={() => setAction('add')}>
|
||||||
</>
|
<Plus />
|
||||||
|
新建菜单
|
||||||
|
</Button>
|
||||||
|
</Auth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Auth>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
<DropdownMenuItem onClick={onEdit}>编辑菜单</DropdownMenuItem>
|
<Auth authority='menu:update'>
|
||||||
<DropdownMenuItem onClick={onDelete}>删除菜单</DropdownMenuItem>
|
<DropdownMenuItem onClick={onEdit}>编辑菜单</DropdownMenuItem>
|
||||||
|
</Auth>
|
||||||
|
<Auth authority='menu:delete'>
|
||||||
|
<DropdownMenuItem onClick={onDelete}>删除菜单</DropdownMenuItem>
|
||||||
|
</Auth>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,22 +97,24 @@ export function AssignApisDialog({ open, onClose, onConfirm, currentRow }: Props
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className='h-125 overflow-y-auto'>
|
<div className='h-125 overflow-y-auto'>
|
||||||
<CheckboxTree
|
<DialogSkeleton rows={20} loading={skeletonLoading}>
|
||||||
data={treeData}
|
<CheckboxTree
|
||||||
getNodeState={getNodeState}
|
data={treeData}
|
||||||
onToggle={toggleNode}
|
getNodeState={getNodeState}
|
||||||
renderLabel={(node: CheckboxTreeNode) => (
|
onToggle={toggleNode}
|
||||||
<div className='flex gap-2'>
|
renderLabel={(node: CheckboxTreeNode) => (
|
||||||
{node.data?.method && (
|
<div className='flex gap-2'>
|
||||||
<Badge className={HttpMethodClass[node.data.method as HttpMethodValues]}>
|
{node.data?.method && (
|
||||||
{node.data.method}
|
<Badge className={HttpMethodClass[node.data.method as HttpMethodValues]}>
|
||||||
</Badge>
|
{node.data.method}
|
||||||
)}
|
</Badge>
|
||||||
<div>{node.label}</div>
|
)}
|
||||||
{node.data?.path && <div className='text-gray-500'>{node.data.path}</div>}
|
<div>{node.label}</div>
|
||||||
</div>
|
{node.data?.path && <div className='text-gray-500'>{node.data.path}</div>}
|
||||||
)}
|
</div>
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
</DialogSkeleton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -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,16 +86,18 @@ export function AssignMenusDialog({ open, onClose, onConfirm, currentRow }: Prop
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className='h-125 overflow-y-auto'>
|
<div className='h-125 overflow-y-auto'>
|
||||||
<CheckboxTree
|
<DialogSkeleton rows={20} loading={skeletonLoading}>
|
||||||
data={treeData}
|
<CheckboxTree
|
||||||
getNodeState={getNodeState}
|
data={treeData}
|
||||||
onToggle={toggleNode}
|
getNodeState={getNodeState}
|
||||||
renderLabel={(node: CheckboxTreeNode) => (
|
onToggle={toggleNode}
|
||||||
<div className='flex gap-2'>
|
renderLabel={(node: CheckboxTreeNode) => (
|
||||||
<div>{node.data?.name}</div>
|
<div className='flex gap-2'>
|
||||||
</div>
|
<div>{node.data?.name}</div>
|
||||||
)}
|
</div>
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
</DialogSkeleton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
</Button>
|
toolbarLeft={
|
||||||
|
<Auth authority='role:create'>
|
||||||
<DataTable table={table} />
|
<Button size='sm' onClick={() => setAction('add')}>
|
||||||
</>
|
<Plus />
|
||||||
|
新建角色
|
||||||
|
</Button>
|
||||||
|
</Auth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Auth>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
<DropdownMenuItem onClick={onEdit}>编辑角色</DropdownMenuItem>
|
<Auth authority='role:update'>
|
||||||
<DropdownMenuItem onClick={assignApis}>分配接口权限</DropdownMenuItem>
|
<DropdownMenuItem onClick={onEdit}>编辑角色</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={assignMenus}>分配菜单权限</DropdownMenuItem>
|
</Auth>
|
||||||
<DropdownMenuItem onClick={onDelete}>删除角色</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>
|
||||||
|
</Auth>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchUserRoles()
|
if (open) {
|
||||||
}, [currentRow])
|
fetchUserRoles()
|
||||||
|
}
|
||||||
|
}, [open, currentRow])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onClose}>
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
@@ -70,27 +78,28 @@ 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'>
|
||||||
{roles.map((role) => {
|
<DialogSkeleton loading={skeletonLoading}>
|
||||||
return (
|
{roles.map((role) => {
|
||||||
<Field orientation='horizontal' key={role.id}>
|
return (
|
||||||
<Checkbox
|
<Field orientation='horizontal' key={role.id}>
|
||||||
id={role.id.toString()}
|
<Checkbox
|
||||||
name={role.id.toString()}
|
id={role.id.toString()}
|
||||||
checked={checkedIds.includes(role.id)}
|
name={role.id.toString()}
|
||||||
onCheckedChange={(checked) => handleCheckedChange(role.id, !!checked)}
|
checked={checkedIds.includes(role.id)}
|
||||||
/>
|
onCheckedChange={(checked) => handleCheckedChange(role.id, !!checked)}
|
||||||
<FieldLabel htmlFor={role.id.toString()} className='font-normal'>
|
/>
|
||||||
{role.name}
|
<FieldLabel htmlFor={role.id.toString()} className='font-normal'>
|
||||||
</FieldLabel>
|
{role.name}
|
||||||
</Field>
|
</FieldLabel>
|
||||||
)
|
</Field>
|
||||||
})}
|
)
|
||||||
|
})}
|
||||||
|
</DialogSkeleton>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
<DropdownMenuItem onClick={onEdit}>编辑用户</DropdownMenuItem>
|
<Auth authority='user:update'>
|
||||||
<DropdownMenuItem onClick={assignUserRoles}>分配角色</DropdownMenuItem>
|
<DropdownMenuItem onClick={onEdit}>编辑用户</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={changePassword}>修改密码</DropdownMenuItem>
|
</Auth>
|
||||||
<DropdownMenuItem onClick={onDelete}>删除用户</DropdownMenuItem>
|
<Auth authority='user:assign-roles'>
|
||||||
|
<DropdownMenuItem onClick={assignUserRoles}>分配角色</DropdownMenuItem>
|
||||||
|
</Auth>
|
||||||
|
<Auth authority='user:update-password'>
|
||||||
|
<DropdownMenuItem onClick={changePassword}>修改密码</DropdownMenuItem>
|
||||||
|
</Auth>
|
||||||
|
<Auth authority='user:delete'>
|
||||||
|
<DropdownMenuItem onClick={onDelete}>删除用户</DropdownMenuItem>
|
||||||
|
</Auth>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)
|
)
|
||||||
|
|||||||
70
src/pages/system/user/components/search-form.tsx
Normal file
70
src/pages/system/user/components/search-form.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'>
|
||||||
<Button className='mb-4' onClick={() => setAction('add')}>
|
<DataTable
|
||||||
创建用户
|
table={table}
|
||||||
</Button>
|
search={<SearchForm onSearch={onSearch} />}
|
||||||
|
toolbarLeft={
|
||||||
<DataTable table={table} />
|
<>
|
||||||
</>
|
<Auth authority='user:create'>
|
||||||
|
<Button size='sm' onClick={() => setAction('add')}>
|
||||||
|
<Plus />
|
||||||
|
新建用户
|
||||||
|
</Button>
|
||||||
|
</Auth>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Auth>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
21
src/schemas/blog/tag.ts
Normal 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>
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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个字符'),
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
3
src/siteConfig.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const siteConfig = {
|
||||||
|
title: 'Uroboros',
|
||||||
|
}
|
||||||
@@ -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 () => {
|
||||||
await logout()
|
try {
|
||||||
set({ token: null, tokenExp: null, userInfo: null })
|
await logout()
|
||||||
location.href = '/login'
|
} catch (error) {
|
||||||
|
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
7
src/types/react-router.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import '@tanstack/react-router'
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface StaticDataRouteOption {
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/types/react-table-meta.d.ts
vendored
4
src/types/react-table-meta.d.ts
vendored
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user