feat: update template
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -22,3 +22,5 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.claude
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dayjs": "^1.11.21",
|
||||
"html-to-image": "^1.11.13",
|
||||
"isbot": "^5.2.1",
|
||||
"lucide-react": "^1.25.0",
|
||||
"nanoid": "^5.1.16",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -71,6 +71,12 @@ importers:
|
||||
dayjs:
|
||||
specifier: ^1.11.21
|
||||
version: 1.11.21
|
||||
html-to-image:
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.13
|
||||
isbot:
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1
|
||||
lucide-react:
|
||||
specifier: ^1.25.0
|
||||
version: 1.25.0(react@19.2.7)
|
||||
@@ -2175,6 +2181,9 @@ packages:
|
||||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
html-to-image@1.11.13:
|
||||
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
|
||||
|
||||
html-void-elements@3.0.0:
|
||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||
|
||||
@@ -2219,8 +2228,8 @@ packages:
|
||||
is-url@1.2.4:
|
||||
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
||||
|
||||
isbot@5.1.44:
|
||||
resolution: {integrity: sha512-PGEHtwMnKbZpeSEXW2Utx+/JWed7dp6DiH0WWg33vGSDA7RUvpUeJSVlLrVkQ1RCpvDOUc/eH9ql7VsdbBZ8pA==}
|
||||
isbot@5.2.1:
|
||||
resolution: {integrity: sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
isexe@2.0.0:
|
||||
@@ -4118,7 +4127,7 @@ snapshots:
|
||||
'@tanstack/history': 1.162.0
|
||||
'@tanstack/react-store': 0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@tanstack/router-core': 1.171.14
|
||||
isbot: 5.1.44
|
||||
isbot: 5.2.1
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
|
||||
@@ -4829,6 +4838,8 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
html-to-image@1.11.13: {}
|
||||
|
||||
html-void-elements@3.0.0: {}
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
@@ -4865,7 +4876,7 @@ snapshots:
|
||||
|
||||
is-url@1.2.4: {}
|
||||
|
||||
isbot@5.1.44: {}
|
||||
isbot@5.2.1: {}
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
|
||||
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 "部署完成"
|
||||
@@ -1,5 +1,5 @@
|
||||
import { http } from '@/lib'
|
||||
import type { PageParams, Post, PostFormOutput } from '@/schemas'
|
||||
import type { PageParams, Post, PostDetail, PostFormOutput } from '@/schemas'
|
||||
|
||||
export const listPosts = (params: PageParams) => {
|
||||
return http.get<Post[], ApiPageResponse<Post>>('/posts', { params })
|
||||
@@ -14,7 +14,7 @@ export const updatePost = (id: number, data: PostFormOutput) => {
|
||||
}
|
||||
|
||||
export const getPost = (id: number) => {
|
||||
return http.get<Post>(`/posts/${id}`)
|
||||
return http.get<PostDetail>(`/posts/${id}`)
|
||||
}
|
||||
|
||||
export const deletePost = (id: number) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './system/role'
|
||||
export * from './system/api'
|
||||
export * from './system/menu'
|
||||
export * from './system/file'
|
||||
export * from './system/access-log'
|
||||
|
||||
// blog
|
||||
export * from './blog/post'
|
||||
|
||||
6
src/api/system/access-log.ts
Normal file
6
src/api/system/access-log.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { http } from '@/lib'
|
||||
import type { AccessLog, PageParams } from '@/schemas'
|
||||
|
||||
export const listAccessLogs = (params: PageParams) => {
|
||||
return http.get<AccessLog[], ApiPageResponse<AccessLog>>('/access-logs', { params })
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
import { http } from '@/lib'
|
||||
import type { File, FileForm, PageParams } from '@/schemas'
|
||||
import type { FileRecord, PageParams, UploadedFile, UploadFileInput } from '@/schemas'
|
||||
|
||||
export const listFiles = (params: PageParams) => {
|
||||
return http.get<File[], ApiPageResponse<File>>('/files', { params })
|
||||
return http.get<FileRecord[], ApiPageResponse<FileRecord>>('/files', { params })
|
||||
}
|
||||
|
||||
export const uploadFile = (file: globalThis.File, onProgress?: (percent: number) => void) => {
|
||||
export const uploadFile = (
|
||||
{ file, folder }: UploadFileInput,
|
||||
onProgress?: (percent: number) => void,
|
||||
) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return http.post<FileForm>('/files', formData, {
|
||||
if (folder) {
|
||||
formData.append('folder', folder)
|
||||
}
|
||||
|
||||
return http.post<UploadedFile>('/files', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
@@ -18,3 +25,7 @@ export const uploadFile = (file: globalThis.File, onProgress?: (percent: number)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const syncFileMetadata = () => {
|
||||
return http.post('/files/sync-metadata')
|
||||
}
|
||||
|
||||
@@ -33,3 +33,11 @@ export const assignUserRoles = (id: number, role_ids: number[]) => {
|
||||
export const updateUserPassword = (id: number, password: string) => {
|
||||
return http.patch(`/users/${id}/password`, { password })
|
||||
}
|
||||
|
||||
export const kickUser = (id: number) => {
|
||||
return http.post(`/users/${id}/logout-all`)
|
||||
}
|
||||
|
||||
export const kickAllUsers = () => {
|
||||
return http.post(`/users/logout-all`)
|
||||
}
|
||||
|
||||
BIN
src/assets/fonts/NotoSansAvestan-Regular.ttf
Normal file
BIN
src/assets/fonts/NotoSansAvestan-Regular.ttf
Normal file
Binary file not shown.
1
src/assets/no-pic.svg
Normal file
1
src/assets/no-pic.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786807040910" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3069" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M687.52 328.47C683.38 267.24 633.8 221 569.23 219.03c-65.96-2.05-116.44 53.36-118.49 113.52-2.16 63.18 55.7 111.55 118.49 113.52 64.47 2 114.12-50.88 118.28-109.39 0.19-1.31 0.29-2.68 0.25-4.14 0.05-1.43-0.05-2.78-0.24-4.07z m-118.29 73.96c-40.77 1.26-71.71-32.71-72.94-69.88-1.33-39.11 34.15-68.73 72.94-69.91 40.8-1.26 71.71 32.74 72.97 69.91 0 0.07 0.02 0.14 0.02 0.21-1.47 38.98-32.26 68.41-72.99 69.67z" fill="#7E8285" p-id="3070"></path><path d="M767.21 507.08c-105.17 0-190.43 85.26-190.43 190.43s85.26 190.43 190.43 190.43 190.43-85.26 190.43-190.43-85.26-190.43-190.43-190.43z m-0.01 63.47c14.02-0.01 25.4 11.36 25.4 25.38v0.03l-12.7 139.65h-25.39l-12.7-139.65c0.01-14.03 11.37-25.41 25.39-25.41z m1.16 241.2c-0.38 0.01-0.76 0.01-1.13 0-14.02-0.31-25.13-11.93-24.82-25.95 0.3-13.58 11.23-24.52 24.81-24.82 14.02-0.32 25.64 10.79 25.96 24.81 0.31 14.03-10.8 25.65-24.82 25.96z" fill="#D0D7DA" p-id="3071"></path><path d="M576.64 822.02H161.7c-11.73 0-22.99 1.08-33.82-4.57-17.52-9.07-16.55-27.78-16.55-44.26V640.24L293.84 498.5l245.89 206.22c-0.07-2.4-0.18-4.79-0.18-7.21 0-16.04 1.68-31.69 4.83-46.79L310.63 454.64c-10.11-8.49-22.09-7.92-32.24 0L111.32 584.42V210.68c0-43.18 58.54-33.46 86.5-33.46h578.02c26.88 0 59.84-5.22 86.18 2.05 33.1 9.1 21.73 79.09 21.73 103.27v219.39c16.3 9.73 31.29 21.42 44.62 34.77V288.8c0-32.74 6.51-75.81-5.76-107.12-19.57-49.8-67.43-49.08-111.33-49.08H145.33c-44.33 0-76.28 35.26-78.59 78.05-0.47 8.64 0 17.42 0 26.05v388.21c-0.27 1.7-0.27 3.4 0 5.08v141.62c0 15.83-0.22 30.73 5.76 45.95 12.16 31.02 42.53 49.08 75.2 49.08h467.12a229.212 229.212 0 0 1-38.18-44.62z" fill="#7E8285" p-id="3072"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
39
src/components/crud-provider.tsx
Normal file
39
src/components/crud-provider.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
|
||||
type CrudContextType<T, A> = {
|
||||
action: A
|
||||
setAction: (action: A) => void
|
||||
currentRow: T | null
|
||||
setCurrentRow: (row: T | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建页面级 CRUD 弹窗上下文,绑定页面自己的行类型与 Action 类型
|
||||
*
|
||||
* @example
|
||||
* export const { CrudProvider, useCrud } = createCrudProvider<User, Action>()
|
||||
*/
|
||||
export const createCrudProvider = <T, A extends string | null>() => {
|
||||
const CrudContext = createContext<CrudContextType<T, A> | null>(null)
|
||||
|
||||
const CrudProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [action, setAction] = useState<A>(null as A)
|
||||
const [currentRow, setCurrentRow] = useState<T | null>(null)
|
||||
|
||||
return (
|
||||
<CrudContext value={{ action, setAction, currentRow, setCurrentRow }}>{children}</CrudContext>
|
||||
)
|
||||
}
|
||||
|
||||
const useCrud = () => {
|
||||
const context = useContext(CrudContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useCrud must be used within a CrudProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
return { CrudProvider, useCrud }
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export function DataTablePagination<TData>({ table }: DataTablePaginationProps<T
|
||||
const pagination = usePagination({
|
||||
total: table.getPageCount(),
|
||||
page: initialPage,
|
||||
siblings: 0,
|
||||
siblings: 1,
|
||||
onChange: (page) => {
|
||||
table.setPageIndex(page - 1)
|
||||
},
|
||||
|
||||
@@ -73,7 +73,7 @@ export const WEditor = ({ defaultValues, onChange }: Props) => {
|
||||
},
|
||||
uploadImage: {
|
||||
async customUpload(file: File, insertFn: InsertFnType) {
|
||||
const { data } = await uploadFile(file)
|
||||
const { data } = await uploadFile({ file, folder: 'article' })
|
||||
insertFn(data.file_url, data.file_url, data.file_url)
|
||||
},
|
||||
},
|
||||
@@ -91,7 +91,7 @@ export const WEditor = ({ defaultValues, onChange }: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='rich-editor h-full relative z-9999'>
|
||||
<div className='rich-editor h-full relative z-50'>
|
||||
<Toolbar
|
||||
editor={editor}
|
||||
defaultConfig={toolbarConfig}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from '@/components/ui/attachment.tsx'
|
||||
import { Input } from '@/components/ui/input.tsx'
|
||||
import { Spinner } from '@/components/ui/spinner.tsx'
|
||||
import type { FileForm } from '@/schemas'
|
||||
import type { FileFolder, UploadedFile } from '@/schemas'
|
||||
|
||||
type State = 'idle' | 'uploading' | 'processing' | 'error' | 'done'
|
||||
|
||||
@@ -27,11 +27,12 @@ type UploadFile = {
|
||||
}
|
||||
|
||||
export interface FileUploadProps {
|
||||
defaultFiles?: FileForm[]
|
||||
onChange?: (files: FileForm[]) => void
|
||||
folder?: FileFolder
|
||||
defaultFiles?: UploadedFile[]
|
||||
onChange?: (files: UploadedFile[]) => void
|
||||
}
|
||||
|
||||
export const FileUpload = ({ defaultFiles, onChange }: FileUploadProps) => {
|
||||
export const FileUpload = ({ defaultFiles, onChange, folder }: FileUploadProps) => {
|
||||
const [fileList, setFileList] = useState<UploadFile[]>(
|
||||
(defaultFiles || []).map((item) => ({
|
||||
id: item.id,
|
||||
@@ -73,7 +74,10 @@ export const FileUpload = ({ defaultFiles, onChange }: FileUploadProps) => {
|
||||
setFileList((prev) => [...prev, _file])
|
||||
|
||||
try {
|
||||
const { data } = await uploadFile(file)
|
||||
const { data } = await uploadFile({
|
||||
file,
|
||||
folder,
|
||||
})
|
||||
setFileList((prev) =>
|
||||
prev.map((item) => (item.uid === uid ? { ...item, state: 'done', id: data.id } : item)),
|
||||
)
|
||||
|
||||
20
src/components/label/dict.tsx
Normal file
20
src/components/label/dict.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
|
||||
import { cn } from '@/lib'
|
||||
|
||||
interface Props {
|
||||
text: string
|
||||
dotClass?: string
|
||||
icon?: ComponentType<SVGProps<SVGSVGElement>>
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DictLabel({ text, dotClass, icon: Icon, className }: Props) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
{dotClass && <span className={cn('size-1.5 rounded-full', dotClass)} />}
|
||||
{Icon && <Icon className='size-3.5' />}
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export { StatusLabel } from './status.tsx'
|
||||
export { DictLabel } from './dict.tsx'
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +1,12 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
|
||||
import {
|
||||
ChartBarStacked,
|
||||
FilePlusCorner,
|
||||
Group,
|
||||
House,
|
||||
NotebookText,
|
||||
SquarePen,
|
||||
Tags,
|
||||
TextAlignJustify,
|
||||
UserRoundKey,
|
||||
UsersRound,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { MenuIconKey, type MenuIconKeyValues } from '@/enum'
|
||||
import { MenuIconMap, type MenuIconKeyValues } from '@/enum'
|
||||
|
||||
interface Props {
|
||||
className?: string
|
||||
menuIconKey: MenuIconKeyValues
|
||||
}
|
||||
|
||||
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
const iconMap: Partial<Record<MenuIconKeyValues, IconComponent>> = {
|
||||
[MenuIconKey.HOME]: House,
|
||||
[MenuIconKey.USER]: UsersRound,
|
||||
[MenuIconKey.ROLE]: UserRoundKey,
|
||||
[MenuIconKey.MENU]: TextAlignJustify,
|
||||
[MenuIconKey.API]: Group,
|
||||
[MenuIconKey.FILE]: FilePlusCorner,
|
||||
[MenuIconKey.POST]: NotebookText,
|
||||
[MenuIconKey.POST_EDIT]: SquarePen,
|
||||
[MenuIconKey.CATEGORY]: ChartBarStacked,
|
||||
[MenuIconKey.TAG]: Tags,
|
||||
}
|
||||
|
||||
export const MenuIcon = ({ className, menuIconKey }: Props) => {
|
||||
const Icon = iconMap[menuIconKey]
|
||||
const Icon = MenuIconMap[menuIconKey]
|
||||
|
||||
return Icon ? <Icon className={className} /> : null
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
.loading-wrap {
|
||||
background-color: #ffffff;
|
||||
opacity: 1;
|
||||
|
||||
193
src/components/ui/item.tsx
Normal file
193
src/components/ui/item.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
className={cn("group/item-group flex flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="item-separator"
|
||||
orientation="horizontal"
|
||||
className={cn("my-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
"group/item flex flex-wrap items-center rounded-md border border-transparent text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-accent/50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border-border",
|
||||
muted: "bg-muted/50",
|
||||
},
|
||||
size: {
|
||||
default: "gap-4 p-4",
|
||||
sm: "gap-2.5 px-4 py-3",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> &
|
||||
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "div"
|
||||
return (
|
||||
<Comp
|
||||
data-slot="item"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(itemVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "size-8 rounded-sm border bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
"size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-content"
|
||||
className={cn(
|
||||
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-title"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
"line-clamp-2 text-sm leading-normal font-normal text-balance text-muted-foreground",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-actions"
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemActions,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemFooter,
|
||||
}
|
||||
29
src/components/ui/progress.tsx
Normal file
29
src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
43
src/components/ui/radio-group.tsx
Normal file
43
src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as React from "react"
|
||||
import { CircleIcon } from "lucide-react"
|
||||
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"aspect-square size-4 shrink-0 rounded-full border border-input text-primary shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 fill-primary" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
@@ -1,7 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -17,7 +15,7 @@ function Separator({
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
|
||||
import {
|
||||
ChartBarStacked,
|
||||
FilePlusCorner,
|
||||
Group,
|
||||
House,
|
||||
NotebookPen,
|
||||
NotebookText,
|
||||
SquarePen,
|
||||
Tags,
|
||||
TextAlignJustify,
|
||||
UserRoundKey,
|
||||
UsersRound,
|
||||
} from 'lucide-react'
|
||||
|
||||
export const MenuIconKey = {
|
||||
HOME: 1,
|
||||
USER: 2,
|
||||
@@ -9,20 +25,37 @@ export const MenuIconKey = {
|
||||
POST_EDIT: 8,
|
||||
CATEGORY: 9,
|
||||
TAG: 10,
|
||||
LOG: 11,
|
||||
} as const
|
||||
|
||||
export type MenuIconKeyValues = (typeof MenuIconKey)[keyof typeof MenuIconKey]
|
||||
|
||||
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
export const MenuIconMap: Record<MenuIconKeyValues, IconComponent> = {
|
||||
[MenuIconKey.HOME]: House,
|
||||
[MenuIconKey.USER]: UsersRound,
|
||||
[MenuIconKey.ROLE]: UserRoundKey,
|
||||
[MenuIconKey.MENU]: TextAlignJustify,
|
||||
[MenuIconKey.API]: Group,
|
||||
[MenuIconKey.FILE]: FilePlusCorner,
|
||||
[MenuIconKey.POST]: NotebookText,
|
||||
[MenuIconKey.POST_EDIT]: SquarePen,
|
||||
[MenuIconKey.CATEGORY]: ChartBarStacked,
|
||||
[MenuIconKey.TAG]: Tags,
|
||||
[MenuIconKey.LOG]: NotebookPen,
|
||||
}
|
||||
|
||||
type MenuIconOption = {
|
||||
label: keyof typeof MenuIconKey
|
||||
value: MenuIconKeyValues
|
||||
selectValue: `${MenuIconKeyValues}`
|
||||
selectValue: string
|
||||
}
|
||||
|
||||
export const MenuIconOptions: MenuIconOption[] = Object.entries(MenuIconKey).map(
|
||||
([key, value]) => ({
|
||||
label: key as keyof typeof MenuIconKey,
|
||||
value,
|
||||
selectValue: value?.toString() as `${MenuIconKeyValues}`,
|
||||
selectValue: String(value),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -7,14 +7,14 @@ export const Status = {
|
||||
|
||||
export type StatusValues = (typeof Status)[keyof typeof Status]
|
||||
|
||||
export const statusDict: Record<StatusValues, string> = {
|
||||
0: '禁用',
|
||||
1: '启用',
|
||||
} as const
|
||||
export const statusLabel: Record<StatusValues, { text: string; dotClass: string }> = {
|
||||
[Status.enabled]: { text: '启用', dotClass: 'bg-green-500' },
|
||||
[Status.disabled]: { text: '禁用', dotClass: 'bg-red-500' },
|
||||
}
|
||||
|
||||
export const StatusSchema = z.union(Object.values(Status).map((v) => z.literal(v)))
|
||||
|
||||
export const statusOptions = [
|
||||
{ label: statusDict[Status.enabled], value: String(Status.enabled) },
|
||||
{ label: statusDict[Status.disabled], value: String(Status.disabled) },
|
||||
{ label: statusLabel[Status.enabled].text, value: String(Status.enabled) },
|
||||
{ label: statusLabel[Status.disabled].text, value: String(Status.disabled) },
|
||||
]
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
scrollbar-color: #e2e2e2 transparent;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'NotoSansAvestan';
|
||||
src: url('@/assets/fonts/NotoSansAvestan-Regular.ttf') format('ttf');
|
||||
}
|
||||
|
||||
#nprogress .bar {
|
||||
background: #d4d4d8;
|
||||
}
|
||||
|
||||
@@ -16,3 +16,15 @@ export const formatFileSize = (bytes: number, decimals = 2) => {
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
export const formatResponseMs = (ms?: number): string => {
|
||||
if (ms === undefined) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (ms <= 0) {
|
||||
return '<1ms'
|
||||
}
|
||||
|
||||
return `${ms}ms`
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { QUERY_KEY } from '../constants.ts'
|
||||
import { QUERY_KEY, useCrud } from '../constants.ts'
|
||||
import { ActionsDialog } from './action-dialog.tsx'
|
||||
import { useCrud } from './crud-provider.tsx'
|
||||
import { DeleteDialog } from './delete-dialog.tsx'
|
||||
|
||||
export const ActionDialogs = () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Auth } from '@/components/auth'
|
||||
import { DataTable } from '@/components/data-table'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
|
||||
import { useCrud } from './crud-provider.tsx'
|
||||
import { useCrud } from '../constants.ts'
|
||||
|
||||
interface Props<TData> {
|
||||
table: Table<TData>
|
||||
|
||||
@@ -12,25 +12,25 @@ export const createColumns = (): ColumnDef<Category>[] => {
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: '分类名称',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('name')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'code',
|
||||
header: '分类编码',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('code')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'sort',
|
||||
header: '排序',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('sort')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: '创建时间',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const rawDate = row.getValue('created_at') as string
|
||||
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
|
||||
@@ -39,7 +39,7 @@ export const createColumns = (): ColumnDef<Category>[] => {
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: '修改时间',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const rawDate = row.getValue('updated_at') as string
|
||||
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
|
||||
@@ -51,7 +51,7 @@ export const createColumns = (): ColumnDef<Category>[] => {
|
||||
columns.push({
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
meta: { className: ' max-w-36 sticky right-0' },
|
||||
meta: { className: 'max-w-36 sticky right-0' },
|
||||
cell: RowActions,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
|
||||
import { type Category } from '@/schemas'
|
||||
|
||||
import type { Action } from '../constants'
|
||||
|
||||
type CrudContextType<T> = {
|
||||
action: Action
|
||||
setAction: (action: Action) => void
|
||||
currentRow: T | null
|
||||
setCurrentRow: (row: T | null) => void
|
||||
}
|
||||
|
||||
export const CrudContext = createContext<CrudContextType<Category> | null>(null)
|
||||
|
||||
export const CrudProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [action, setAction] = useState<Action>(null)
|
||||
const [currentRow, setCurrentRow] = useState<Category | null>(null)
|
||||
|
||||
return (
|
||||
<CrudContext value={{ action, setAction, currentRow, setCurrentRow }}>{children}</CrudContext>
|
||||
)
|
||||
}
|
||||
|
||||
export const useCrud = () => {
|
||||
const context = useContext(CrudContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('context must be used within a CrudProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu.tsx'
|
||||
import { type Category } from '@/schemas'
|
||||
|
||||
import { useCrud } from './crud-provider'
|
||||
import { useCrud } from '../constants'
|
||||
|
||||
type Props = {
|
||||
row: Row<Category>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { createCrudProvider } from '@/components/crud-provider'
|
||||
import { type Category } from '@/schemas'
|
||||
|
||||
export const QUERY_KEY = 'category'
|
||||
|
||||
export const ActionSchema = z.enum(['add', 'edit', 'delete']).nullable()
|
||||
|
||||
export type Action = z.infer<typeof ActionSchema>
|
||||
|
||||
export const { CrudProvider, useCrud } = createCrudProvider<Category, Action>()
|
||||
|
||||
@@ -10,8 +10,7 @@ import { type Category } from '@/schemas'
|
||||
import { ActionDialogs } from './components/action-dialogs.tsx'
|
||||
import { CategoryTable } from './components/category-table.tsx'
|
||||
import { createColumns } from './components/columns.tsx'
|
||||
import { CrudProvider } from './components/crud-provider.tsx'
|
||||
import { QUERY_KEY } from './constants.ts'
|
||||
import { CrudProvider, QUERY_KEY } from './constants.ts'
|
||||
|
||||
export default function CrudPage() {
|
||||
const [pagination, setPagination] = useState({
|
||||
|
||||
270
src/pages/blog/post-edit/components/cover-generator.tsx
Normal file
270
src/pages/blog/post-edit/components/cover-generator.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import { useId, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import dayjs from 'dayjs'
|
||||
import * as htmlToImage from 'html-to-image'
|
||||
import { ImagePlus } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { uploadFile } from '@/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import type { UploadedFile } from '@/schemas'
|
||||
|
||||
interface Props {
|
||||
onChange?: (file: UploadedFile) => void
|
||||
}
|
||||
|
||||
const DEFAULT_TEXT = dayjs().format('YYYY年M月D日')
|
||||
const DEFAULT_FONT_WEIGHT = 'bold'
|
||||
const DEFAULT_RATIO = '1200/480'
|
||||
|
||||
const RATIO_MAP = {
|
||||
'1200/480': '40%',
|
||||
'1600/900': '56.25%',
|
||||
}
|
||||
|
||||
export function CoverGenerator({ onChange }: Props) {
|
||||
const uid = useId()
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const coverRef = useRef<HTMLDivElement>(null)
|
||||
const [cover, setCover] = useState<string>()
|
||||
const [text, setText] = useState(dayjs().format(DEFAULT_TEXT))
|
||||
const [fontSize, setFontSize] = useState(50)
|
||||
const [color, setColor] = useState('white')
|
||||
const [fontWeight, setFontWeight] = useState(DEFAULT_FONT_WEIGHT)
|
||||
const [ratio, setRatio] = useState<'1200/480' | '1600/900'>(DEFAULT_RATIO)
|
||||
|
||||
const textStyles = useMemo(() => {
|
||||
return {
|
||||
color: color,
|
||||
fontSize: `${fontSize}px`,
|
||||
fontWeight: fontWeight,
|
||||
fontFamily: 'NotoSansAvestan',
|
||||
}
|
||||
}, [color, fontSize, fontWeight])
|
||||
|
||||
const handleTriggerUpload = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// 限制size
|
||||
if (file.size > 1024 * 1024 * 10) {
|
||||
toast.error('文件大小不能超过10MB')
|
||||
e.target.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
setCover(e.target?.result as string)
|
||||
}
|
||||
|
||||
reader.readAsDataURL(file)
|
||||
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const onSave = async () => {
|
||||
if (!coverRef.current) return
|
||||
const dataUrl = await htmlToImage.toJpeg(coverRef.current, { quality: 0.92, pixelRatio: 2 })
|
||||
|
||||
const response = await fetch(dataUrl)
|
||||
const blob = await response.blob()
|
||||
|
||||
const file = new File([blob], `custom-cover-${Date.now()}.jpg`, {
|
||||
type: 'image/jpeg',
|
||||
})
|
||||
|
||||
const { data } = await uploadFile({ file, folder: 'cover' })
|
||||
if (onChange) {
|
||||
onChange(data)
|
||||
}
|
||||
setOpen(false)
|
||||
reset()
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
setCover(undefined)
|
||||
setText(dayjs().format(DEFAULT_TEXT))
|
||||
setFontSize(50)
|
||||
setColor('white')
|
||||
setFontWeight(DEFAULT_FONT_WEIGHT)
|
||||
setRatio(DEFAULT_RATIO)
|
||||
}
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
setOpen(value)
|
||||
|
||||
if (!value) {
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept='image/*'
|
||||
className='hidden'
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<div className='font-normal text-sm select-none cursor-pointer hover:text-blue-500 hover:underline'>
|
||||
封面生成器
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
<DialogContent className='md:min-w-125'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>封面生成器</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className=''>
|
||||
<div
|
||||
style={{ paddingBottom: RATIO_MAP[ratio] }}
|
||||
className='relative'
|
||||
onClick={handleTriggerUpload}
|
||||
>
|
||||
<div className='absolute top-0 left-0 right-0 bottom-0'>
|
||||
{cover ? (
|
||||
<div className='w-full h-full relative' ref={coverRef}>
|
||||
<img className='w-full h-full object-cover' src={cover} alt='' />
|
||||
<div
|
||||
style={textStyles}
|
||||
className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 whitespace-pre text-center leading-normal'
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className='h-full border-2 flex flex-col items-center justify-center gap-2 rounded-md border-dashed
|
||||
border-gray-200 text-sm text-gray-600'
|
||||
>
|
||||
<ImagePlus />
|
||||
<div>上传封面图</div>
|
||||
<div>建议{ratio}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
className='mt-4'
|
||||
value={text}
|
||||
placeholder='请输入文字'
|
||||
/>
|
||||
|
||||
<div className='mt-4 flex-1 flex flex-col gap-4'>
|
||||
<Field orientation='horizontal' className='w-full'>
|
||||
<FieldLabel className='w-24'>文字颜色</FieldLabel>
|
||||
<RadioGroup
|
||||
className='w-full flex'
|
||||
defaultValue='white'
|
||||
onValueChange={(e) => setColor(e)}
|
||||
>
|
||||
<Field orientation='horizontal' className='w-auto flex-none'>
|
||||
<RadioGroupItem value='white' id={`${uid}-white`} />
|
||||
<FieldLabel className='font-normal' htmlFor={`${uid}-white`}>
|
||||
白色
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
<Field orientation='horizontal' className='w-auto flex-none'>
|
||||
<RadioGroupItem value='black' id={`${uid}-black`} />
|
||||
<FieldLabel className='font-normal' htmlFor={`${uid}-black`}>
|
||||
黑色
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
</RadioGroup>
|
||||
</Field>
|
||||
|
||||
<Field orientation='horizontal' className='w-full'>
|
||||
<FieldLabel className='w-24'>图片比例</FieldLabel>
|
||||
<RadioGroup
|
||||
className='w-full flex'
|
||||
defaultValue={DEFAULT_RATIO}
|
||||
onValueChange={(e) => setRatio(e as '1200/480' | '1600/900')}
|
||||
>
|
||||
<Field orientation='horizontal' className='w-auto flex-none'>
|
||||
<RadioGroupItem value='1200/480' id={`${uid}-12`} />
|
||||
<FieldLabel className='font-normal' htmlFor={`${uid}-12`}>
|
||||
1200/480
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
<Field orientation='horizontal' className='w-auto flex-none'>
|
||||
<RadioGroupItem value='1600/900' id={`${uid}-16`} />
|
||||
<FieldLabel className='font-normal' htmlFor={`${uid}-16`}>
|
||||
1600/900
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
</RadioGroup>
|
||||
</Field>
|
||||
|
||||
<Field orientation='horizontal' className='w-full'>
|
||||
<FieldLabel className='w-24'>字重</FieldLabel>
|
||||
<RadioGroup
|
||||
className='w-full flex'
|
||||
defaultValue={DEFAULT_FONT_WEIGHT}
|
||||
onValueChange={(e) => setFontWeight(e)}
|
||||
>
|
||||
<Field orientation='horizontal' className='w-auto flex-none'>
|
||||
<RadioGroupItem value='normal' id={`${uid}-normal`} />
|
||||
<FieldLabel className='font-normal' htmlFor={`${uid}-normal`}>
|
||||
normal
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
<Field orientation='horizontal' className='w-auto flex-none'>
|
||||
<RadioGroupItem value='bold' id={`${uid}-bold`} />
|
||||
<FieldLabel className='font-normal' htmlFor={`${uid}-bold`}>
|
||||
bold
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
</RadioGroup>
|
||||
</Field>
|
||||
|
||||
<Field className='w-full max-w-xs'>
|
||||
<FieldLabel>文字大小 {fontSize}px</FieldLabel>
|
||||
<Field orientation='horizontal'>
|
||||
<Slider
|
||||
value={[fontSize]}
|
||||
onValueChange={(value) => setFontSize(value[0])}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
/>
|
||||
</Field>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant='outline'>取消</Button>
|
||||
</DialogClose>
|
||||
<Button onClick={onSave}>确定</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { ImagePlus } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { uploadFile } from '@/api'
|
||||
import type { FileForm } from '@/schemas'
|
||||
import { formatFileSize } from '@/lib'
|
||||
import type { UploadedFile } from '@/schemas'
|
||||
|
||||
interface Props {
|
||||
value: FileForm | undefined
|
||||
onChange?: (file: FileForm) => void
|
||||
value: UploadedFile | undefined
|
||||
onChange?: (file?: UploadedFile) => void
|
||||
invalid?: boolean
|
||||
}
|
||||
|
||||
export const CoverUpload = ({ value, onChange, invalid }: Props) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [initialFile, setInitialFile] = useState<FileForm>()
|
||||
const [initialFile, setInitialFile] = useState<UploadedFile>()
|
||||
|
||||
useEffect(() => {
|
||||
setInitialFile(value)
|
||||
@@ -27,17 +29,28 @@ export const CoverUpload = ({ value, onChange, invalid }: Props) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// 限制size
|
||||
if (file.size > 1024 * 1024 * 1) {
|
||||
toast.error('文件大小不能超过1MB')
|
||||
e.target.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
handleUpload(file)
|
||||
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
const { data } = await uploadFile(file)
|
||||
const { data } = await uploadFile({ file, folder: 'cover' })
|
||||
setInitialFile(data)
|
||||
onChange?.(data)
|
||||
}
|
||||
|
||||
const handleClearCover = () => {
|
||||
onChange?.(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
@@ -48,23 +61,38 @@ export const CoverUpload = ({ value, onChange, invalid }: Props) => {
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
onClick={handleTriggerUpload}
|
||||
className='cursor-pointer h-45 rounded-md overflow-hidden '
|
||||
>
|
||||
<div className='cursor-pointer pb-[40%] relative rounded-md overflow-hidden '>
|
||||
{initialFile ? (
|
||||
<div className='h-full'>
|
||||
<div className='absolute left-0 top-0 right-0 bottom-0 group w-full h-full'>
|
||||
<img className='object-cover w-full h-full' src={initialFile.file_url} alt='' />
|
||||
{initialFile?.file_size && (
|
||||
<div className='absolute top-0 right-0 z-10 bg-black/20 text-xs text-white px-2 py-1'>
|
||||
{formatFileSize(initialFile.file_size)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className='absolute left-0 top-0 right-0 bottom-0 w-ful h-full bg-black/70 hidden
|
||||
group-hover:flex z-20 text-white flex-col gap-4 justify-center items-center'
|
||||
>
|
||||
<div className='text-sm hover:text-blue-400' onClick={handleTriggerUpload}>
|
||||
重新上传
|
||||
</div>
|
||||
<div className='text-sm hover:text-red-400' onClick={handleClearCover}>
|
||||
清除封面
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={handleTriggerUpload}
|
||||
data-invalid={invalid}
|
||||
className='border-2 flex flex-col items-center justify-center h-full gap-2 rounded-md border-dashed
|
||||
className='left-0 top-0 right-0 bottom-0 absolute border-2 flex flex-col items-center justify-center h-full gap-2 rounded-md border-dashed
|
||||
border-gray-200 text-sm text-gray-600 data-[invalid=true]:border-red-400 data-[invalid=true]:text-red-400'
|
||||
>
|
||||
<ImagePlus />
|
||||
<div>上传封面图</div>
|
||||
<div>建议1600 x 900</div>
|
||||
<div>建议1200 x 480</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -32,8 +32,15 @@ import { Spinner } from '@/components/ui/spinner.tsx'
|
||||
import { Textarea } from '@/components/ui/textarea.tsx'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import { postStatusOptions } from '@/enum'
|
||||
import { postFormSchema, type Category, type PostFormInput, type PostFormOutput } from '@/schemas'
|
||||
import {
|
||||
postFormSchema,
|
||||
type Category,
|
||||
type PostFormInput,
|
||||
type PostFormOutput,
|
||||
type UploadedFile,
|
||||
} from '@/schemas'
|
||||
|
||||
import { CoverGenerator } from './components/cover-generator.tsx'
|
||||
import { CoverUpload } from './components/cover-upload.tsx'
|
||||
import { DatetimePicker } from './components/datetime-picker.tsx'
|
||||
|
||||
@@ -76,7 +83,9 @@ export default function PostEdit() {
|
||||
|
||||
form.reset({
|
||||
...data,
|
||||
cover: { id: data.cover_id, file_url: data.cover },
|
||||
cover: data.cover
|
||||
? { id: data.cover_id, file_url: data.cover, file_size: data.cover_size }
|
||||
: undefined,
|
||||
})
|
||||
|
||||
setEditorContent(data.content)
|
||||
@@ -95,6 +104,10 @@ export default function PostEdit() {
|
||||
init()
|
||||
}, [id, fetchPostDetail])
|
||||
|
||||
const handlerCoverChange = (file: UploadedFile) => {
|
||||
form.setValue('cover', { id: file.id, file_url: file.file_url, file_size: file.file_size })
|
||||
}
|
||||
|
||||
const onSubmit = async (values: PostFormOutput) => {
|
||||
values.content = editorContent.replace(/<br\s*\/?>/gi, '\n')
|
||||
|
||||
@@ -148,7 +161,9 @@ export default function PostEdit() {
|
||||
<div className='overflow-y-auto flex flex-col gap-6 w-full md:w-90'>
|
||||
<Card className='w-full gap-2 shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle>封面图</CardTitle>
|
||||
<CardTitle className='flex justify-between items-center'>
|
||||
封面图 <CoverGenerator onChange={handlerCoverChange} />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { QUERY_KEY } from '../constants.ts'
|
||||
import { useCrud } from './crud-provider.tsx'
|
||||
import { QUERY_KEY, useCrud } from '../constants.ts'
|
||||
import { DeleteDialog } from './delete-dialog.tsx'
|
||||
|
||||
export const ActionDialogs = () => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
|
||||
import NoPic from '@/assets/no-pic.svg'
|
||||
import { Auth } from '@/components/auth'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { postStatusOptions } from '@/enum'
|
||||
import { formatDate, hasPermission } from '@/lib'
|
||||
import { formatDate, formatFileSize, hasPermission } from '@/lib'
|
||||
import type { Post } from '@/schemas'
|
||||
|
||||
import { RowActions } from './row-actions'
|
||||
@@ -14,15 +15,41 @@ export const createColumns = (): ColumnDef<Post>[] => {
|
||||
const showAction = hasPermission(['post:update', 'post:delete'], 'any')
|
||||
|
||||
const columns: ColumnDef<Post>[] = [
|
||||
{
|
||||
accessorKey: 'cover',
|
||||
header: '封面',
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const cover = row.getValue('cover') as string
|
||||
const cover_size = row.original.cover_size as number
|
||||
return (
|
||||
<div className='w-30 h-17.5 relative my-1 mx-2'>
|
||||
{cover ? (
|
||||
<>
|
||||
<img className='w-full h-full object-cover' src={cover} />
|
||||
<div className='absolute top-0 right-0 z-10 bg-black/20 text-xs text-white px-1 py-0.5'>
|
||||
{formatFileSize(cover_size)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className='w-full h-full flex flex-col gap-2 text-sm text-gray-500 justify-center items-center'>
|
||||
<img className='w-5' src={NoPic} alt='' />
|
||||
未上传封面
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: '文章标题',
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className='w-full truncate'>
|
||||
<TooltipTrigger className='w-full truncate text-left'>
|
||||
<Auth authority='post:update' fallback={row.getValue('title')}>
|
||||
<Link className='underline' to={`/post/edit?id=${row.original.id}`}>
|
||||
<Link className='w-full truncate underline' to={`/post/edit?id=${row.original.id}`}>
|
||||
{row.getValue('title')}
|
||||
</Link>
|
||||
</Auth>
|
||||
@@ -65,11 +92,11 @@ export const createColumns = (): ColumnDef<Post>[] => {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'view',
|
||||
accessorKey: 'view_count',
|
||||
header: '阅读量',
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
return <div className='overflow-hidden'>{row.getValue('view')}</div>
|
||||
return <div className='overflow-hidden'>{row.getValue('view_count')}</div>
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
|
||||
import { type Post } from '@/schemas'
|
||||
|
||||
import type { Action } from '../constants'
|
||||
|
||||
type CrudContextType<T> = {
|
||||
action: Action
|
||||
setAction: (action: Action) => void
|
||||
currentRow: T | null
|
||||
setCurrentRow: (row: T | null) => void
|
||||
}
|
||||
|
||||
export const CrudContext = createContext<CrudContextType<Post> | null>(null)
|
||||
|
||||
export const CrudProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [action, setAction] = useState<Action>(null)
|
||||
const [currentRow, setCurrentRow] = useState<Post | null>(null)
|
||||
|
||||
return (
|
||||
<CrudContext value={{ action, setAction, currentRow, setCurrentRow }}>{children}</CrudContext>
|
||||
)
|
||||
}
|
||||
|
||||
export const useCrud = () => {
|
||||
const context = useContext(CrudContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('context must be used within a CrudProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu.tsx'
|
||||
import { type Post } from '@/schemas'
|
||||
|
||||
import { useCrud } from './crud-provider'
|
||||
import { useCrud } from '../constants'
|
||||
|
||||
type Props = {
|
||||
row: Row<Post>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { createCrudProvider } from '@/components/crud-provider'
|
||||
import { type Post } from '@/schemas'
|
||||
|
||||
export const QUERY_KEY = 'post'
|
||||
|
||||
export const ActionSchema = z
|
||||
@@ -7,3 +10,5 @@ export const ActionSchema = z
|
||||
.nullable()
|
||||
|
||||
export type Action = z.infer<typeof ActionSchema>
|
||||
|
||||
export const { CrudProvider, useCrud } = createCrudProvider<Post, Action>()
|
||||
|
||||
@@ -9,9 +9,8 @@ import { type Post } from '@/schemas'
|
||||
|
||||
import { ActionDialogs } from './components/action-dialogs.tsx'
|
||||
import { createColumns } from './components/columns.tsx'
|
||||
import { CrudProvider } from './components/crud-provider.tsx'
|
||||
import { PostTable } from './components/post-table.tsx'
|
||||
import { QUERY_KEY } from './constants.ts'
|
||||
import { CrudProvider, QUERY_KEY } from './constants.ts'
|
||||
|
||||
export default function CrudPage() {
|
||||
const [pagination, setPagination] = useState({
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { QUERY_KEY } from '../constants.ts'
|
||||
import { QUERY_KEY, useCrud } from '../constants.ts'
|
||||
import { ActionsDialog } from './action-dialog.tsx'
|
||||
import { useCrud } from './crud-provider.tsx'
|
||||
import { DeleteDialog } from './delete-dialog.tsx'
|
||||
|
||||
export const ActionDialogs = () => {
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu.tsx'
|
||||
import { type Tag } from '@/schemas'
|
||||
|
||||
import { useCrud } from './crud-provider'
|
||||
import { useCrud } from '../constants'
|
||||
|
||||
type Props = {
|
||||
row: Row<Tag>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Auth } from '@/components/auth'
|
||||
import { DataTable } from '@/components/data-table'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
|
||||
import { useCrud } from './crud-provider.tsx'
|
||||
import { useCrud } from '../constants.ts'
|
||||
|
||||
interface Props<TData> {
|
||||
table: Table<TData>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { createCrudProvider } from '@/components/crud-provider'
|
||||
import { type Tag } from '@/schemas'
|
||||
|
||||
export const QUERY_KEY = 'tag'
|
||||
|
||||
export const ActionSchema = z.enum(['add', 'edit', 'delete']).nullable()
|
||||
|
||||
export type Action = z.infer<typeof ActionSchema>
|
||||
|
||||
export const { CrudProvider, useCrud } = createCrudProvider<Tag, Action>()
|
||||
|
||||
@@ -9,9 +9,8 @@ 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'
|
||||
import { CrudProvider, QUERY_KEY } from './constants.ts'
|
||||
|
||||
export default function CrudPage() {
|
||||
const [pagination, setPagination] = useState({
|
||||
|
||||
16
src/pages/system/access-log/components/access-logs-table.tsx
Normal file
16
src/pages/system/access-log/components/access-logs-table.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
|
||||
import { Auth } from '@/components/auth'
|
||||
import { DataTable } from '@/components/data-table'
|
||||
|
||||
interface Props<TData> {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function AccessLogsTable<TData>({ table }: Props<TData>) {
|
||||
return (
|
||||
<Auth authority='access-log:list'>
|
||||
<DataTable table={table} />
|
||||
</Auth>
|
||||
)
|
||||
}
|
||||
110
src/pages/system/access-log/components/columns.tsx
Normal file
110
src/pages/system/access-log/components/columns.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { isBot } from 'isbot'
|
||||
import { Bot } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge.tsx'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip.tsx'
|
||||
import { HttpMethodClass, type HttpMethodValues } from '@/enum'
|
||||
import { formatDate, formatResponseMs } from '@/lib'
|
||||
import type { AccessLog } from '@/schemas'
|
||||
|
||||
import { ViewActions } from './view-actions.tsx'
|
||||
|
||||
export const createColumns = (): ColumnDef<AccessLog>[] => {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'request_id',
|
||||
header: 'ID',
|
||||
meta: { className: 'max-w-32' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('request_id')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'request_path',
|
||||
header: '请求路径',
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className='w-full truncate text-left'>
|
||||
{row.getValue('request_path')}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{row.getValue('request_path')}</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'request_method',
|
||||
header: '请求方法',
|
||||
meta: { className: 'max-w-16' },
|
||||
cell: ({ row }) => (
|
||||
<Badge className={HttpMethodClass[row.getValue('request_method') as HttpMethodValues]}>
|
||||
{row.getValue('request_method')}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'referer',
|
||||
header: '请求来源',
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className='w-full truncate text-left'>
|
||||
{row.getValue('referer')}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{row.getValue('referer')}</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'ip',
|
||||
header: 'IP地址',
|
||||
meta: { className: 'max-w-32' },
|
||||
cell: ({ row }) => {
|
||||
const ip = row.getValue('ip') as string
|
||||
const user_agent = row.original.user_agent
|
||||
const _isBot = isBot(user_agent)
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-1'>
|
||||
{ip}
|
||||
{_isBot && <Bot className='w-4 h-4 text-blue-500' />}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'username',
|
||||
header: '操作用户',
|
||||
meta: { className: 'max-w-32' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('username')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'response_time_ms',
|
||||
header: '响应耗时',
|
||||
meta: { className: 'max-w-16' },
|
||||
cell: ({ row }) => (
|
||||
<div className='overflow-hidden'>{formatResponseMs(row.getValue('response_time_ms'))}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'status_code',
|
||||
header: 'HTTP状态码',
|
||||
meta: { className: 'max-w-16' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('status_code')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'started_at',
|
||||
header: '请求时间',
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const rawDate = row.getValue('started_at') as string
|
||||
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
meta: { className: 'max-w-36 sticky right-0 text-center' },
|
||||
cell: ViewActions,
|
||||
},
|
||||
]
|
||||
}
|
||||
28
src/pages/system/access-log/components/view-actions.tsx
Normal file
28
src/pages/system/access-log/components/view-actions.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Row } from '@tanstack/react-table'
|
||||
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import type { AccessLog } from '@/schemas'
|
||||
|
||||
import { useCrud } from '../constants.ts'
|
||||
|
||||
type Props = {
|
||||
row: Row<AccessLog>
|
||||
}
|
||||
|
||||
export const ViewActions = ({ row }: Props) => {
|
||||
const { setAction, setCurrentRow } = useCrud()
|
||||
const { original } = row
|
||||
|
||||
const handleView = () => {
|
||||
setAction('view')
|
||||
setCurrentRow(original)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-end gap-2'>
|
||||
<Button variant='link' onClick={handleView}>
|
||||
查看详情
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
68
src/pages/system/access-log/components/view-dialog.tsx
Normal file
68
src/pages/system/access-log/components/view-dialog.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { isBot } from 'isbot'
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { formatDate, formatResponseMs } from '@/lib'
|
||||
|
||||
import { useCrud } from '../constants.ts'
|
||||
|
||||
function LogItem({ label, value }: { label: string; value?: React.ReactNode }) {
|
||||
return (
|
||||
<div className='flex gap-1 border-b border-gray-100 py-2.5 last:border-0'>
|
||||
<div className='w-24 shrink-0 text-sm text-gray-500'>{label}</div>
|
||||
|
||||
<div className='flex-1 break-all text-sm whitespace-pre-wrap'>{value ?? '-'}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ViewDialog() {
|
||||
const { action, currentRow, setAction, setCurrentRow } = useCrud()
|
||||
|
||||
const open = action === 'view'
|
||||
|
||||
const onClose = () => {
|
||||
setAction(null)
|
||||
}
|
||||
|
||||
const showIsBotItem = useMemo(() => {
|
||||
return isBot(currentRow?.user_agent)
|
||||
}, [currentRow])
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) onClose()
|
||||
}}
|
||||
>
|
||||
<DialogContent onCloseAutoFocus={() => setCurrentRow(null)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>日志详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className='h-125 overflow-y-auto flex flex-col'>
|
||||
<LogItem label='请求ID' value={currentRow?.request_id} />
|
||||
<LogItem label='请求时间' value={formatDate(currentRow?.started_at)} />
|
||||
<LogItem label='操作用户' value={currentRow?.username} />
|
||||
<LogItem label='请求路径' value={currentRow?.request_path} />
|
||||
<LogItem label='请求方法' value={currentRow?.request_method} />
|
||||
<LogItem label='请求来源' value={currentRow?.referer} />
|
||||
<LogItem label='IP地址' value={currentRow?.ip} />
|
||||
{showIsBotItem && (
|
||||
<LogItem label='异常访问' value={<div className='text-red-500'>疑似爬虫</div>} />
|
||||
)}
|
||||
<LogItem label='响应耗时' value={formatResponseMs(currentRow?.response_time_ms)} />
|
||||
<LogItem label='HTTP状态码' value={currentRow?.status_code} />
|
||||
{currentRow?.message && (
|
||||
<LogItem
|
||||
label='报错信息'
|
||||
value={<span className='text-red-500'>{currentRow?.message}</span>}
|
||||
/>
|
||||
)}
|
||||
<LogItem label='user_agent' value={currentRow?.user_agent} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
12
src/pages/system/access-log/constants.ts
Normal file
12
src/pages/system/access-log/constants.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { createCrudProvider } from '@/components/crud-provider'
|
||||
import { type AccessLog } from '@/schemas'
|
||||
|
||||
export const QUERY_KEY = 'access-logs'
|
||||
|
||||
export const ActionSchema = z.enum(['view']).nullable()
|
||||
|
||||
export type Action = z.infer<typeof ActionSchema>
|
||||
|
||||
export const { CrudProvider, useCrud } = createCrudProvider<AccessLog, Action>()
|
||||
57
src/pages/system/access-log/index.tsx
Normal file
57
src/pages/system/access-log/index.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
||||
|
||||
import { listAccessLogs } from '@/api'
|
||||
import { hasPermission } from '@/lib'
|
||||
import type { AccessLog } from '@/schemas'
|
||||
|
||||
import { AccessLogsTable } from './components/access-logs-table.tsx'
|
||||
import { createColumns } from './components/columns.tsx'
|
||||
import { ViewDialog } from './components/view-dialog.tsx'
|
||||
import { CrudProvider, 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: () =>
|
||||
listAccessLogs({
|
||||
page: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
}),
|
||||
placeholderData: (prev) => prev,
|
||||
enabled: hasPermission('access-log:list'),
|
||||
})
|
||||
|
||||
const tableData = useMemo<AccessLog[]>(() => data?.list ?? [], [data])
|
||||
|
||||
const columns = useMemo(() => createColumns(), [])
|
||||
|
||||
const table = useReactTable<AccessLog>({
|
||||
data: tableData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
rowCount: data?.total,
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
meta: {
|
||||
isFetching,
|
||||
refetch,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<CrudProvider>
|
||||
<ViewDialog />
|
||||
|
||||
<AccessLogsTable table={table} />
|
||||
</CrudProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { QUERY_KEY } from '../constants.ts'
|
||||
import { QUERY_KEY, useCrud } from '../constants.ts'
|
||||
import { ActionsDialog } from './action-dialog.tsx'
|
||||
import { useCrud } from './crud-provider.tsx'
|
||||
import { DeleteDialog } from './delete-dialog.tsx'
|
||||
|
||||
export const ActionDialogs = () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { DataTable } from '@/components/data-table'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import type { SearchApiParams } from '@/schemas'
|
||||
|
||||
import { useCrud } from './crud-provider.tsx'
|
||||
import { useCrud } from '../constants.ts'
|
||||
import { SearchForm } from './search-form.tsx'
|
||||
|
||||
interface Props<TData> {
|
||||
|
||||
@@ -14,19 +14,19 @@ export const createColumns = (): ColumnDef<Api>[] => {
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: '接口名称',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('name')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'group_name',
|
||||
header: '接口分组',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('group_name')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'method',
|
||||
header: '接口方法',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => (
|
||||
<Badge className={HttpMethodClass[row.getValue('method') as HttpMethodValues]}>
|
||||
{row.getValue('method')}
|
||||
@@ -36,19 +36,19 @@ export const createColumns = (): ColumnDef<Api>[] => {
|
||||
{
|
||||
accessorKey: 'path',
|
||||
header: '接口路径',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('path')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'sort',
|
||||
header: '排序',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('sort')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: '创建时间',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const rawDate = row.getValue('created_at') as string
|
||||
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
|
||||
@@ -57,7 +57,7 @@ export const createColumns = (): ColumnDef<Api>[] => {
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: '修改时间',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const rawDate = row.getValue('updated_at') as string
|
||||
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
|
||||
import { type Api } 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<Api> | null>(null)
|
||||
|
||||
export const CrudProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [action, setAction] = useState<Action>(null)
|
||||
const [currentRow, setCurrentRow] = useState<Api | 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
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu.tsx'
|
||||
import { type Api } from '@/schemas'
|
||||
|
||||
import { useCrud } from './crud-provider'
|
||||
import { useCrud } from '../constants'
|
||||
|
||||
type Props = {
|
||||
row: Row<Api>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { createCrudProvider } from '@/components/crud-provider'
|
||||
import { type Api } from '@/schemas'
|
||||
|
||||
export const QUERY_KEY = 'sys_api'
|
||||
|
||||
export const ActionSchema = z.enum(['add', 'edit', 'delete']).nullable()
|
||||
|
||||
export type Action = z.infer<typeof ActionSchema>
|
||||
|
||||
export const { CrudProvider, useCrud } = createCrudProvider<Api, Action>()
|
||||
|
||||
@@ -10,8 +10,7 @@ import { type Api, type SearchApiParams } from '@/schemas'
|
||||
import { ActionDialogs } from './components/action-dialogs.tsx'
|
||||
import { ApiTable } from './components/api-table.tsx'
|
||||
import { createColumns } from './components/columns.tsx'
|
||||
import { CrudProvider } from './components/crud-provider.tsx'
|
||||
import { QUERY_KEY } from './constants.ts'
|
||||
import { CrudProvider, QUERY_KEY } from './constants.ts'
|
||||
|
||||
export default function CrudPage() {
|
||||
const [pagination, setPagination] = useState({
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip.tsx'
|
||||
import { formatDate, formatFileSize } from '@/lib'
|
||||
import type { File } from '@/schemas'
|
||||
import type { FileRecord } from '@/schemas'
|
||||
|
||||
export const createColumns = (): ColumnDef<File>[] => {
|
||||
export const createColumns = (): ColumnDef<FileRecord>[] => {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'file_url',
|
||||
header: '文件路径',
|
||||
meta: { className: ' max-w-24' },
|
||||
meta: { className: 'max-w-24' },
|
||||
cell: ({ row }) => (
|
||||
<Button variant='link' onClick={() => window.open(row.getValue('file_url'))}>
|
||||
点击查看
|
||||
@@ -19,33 +20,49 @@ export const createColumns = (): ColumnDef<File>[] => {
|
||||
{
|
||||
accessorKey: 'file_name',
|
||||
header: '文件名称',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('file_name')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'original_name',
|
||||
header: '原始文件名',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('original_name')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'mime_type',
|
||||
header: '文件类型',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('mime_type')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'file_size',
|
||||
header: '文件大小',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => (
|
||||
<div className='overflow-hidden'>{formatFileSize(row.getValue('file_size'))}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'metadata',
|
||||
header: '图片元信息',
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const metadata = row.getValue('metadata')
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className='w-full truncate text-left'>
|
||||
{JSON.stringify(metadata)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{JSON.stringify(metadata)}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: '上传日期',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const rawDate = row.getValue('created_at') as string
|
||||
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
|
||||
import { type Role } 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<Role> | null>(null)
|
||||
|
||||
export const CrudProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [action, setAction] = useState<Action>(null)
|
||||
const [currentRow, setCurrentRow] = useState<Role | 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
|
||||
}
|
||||
@@ -1,16 +1,44 @@
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { syncFileMetadata } from '@/api'
|
||||
import { Auth } from '@/components/auth'
|
||||
import { DataTable } from '@/components/data-table'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import { Spinner } from '@/components/ui/spinner.tsx'
|
||||
|
||||
interface Props<TData> {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function FileTable<TData>({ table }: Props<TData>) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const sync = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
await syncFileMetadata()
|
||||
toast.info('正在执行同步进程... 请稍后查看同步结果。')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Auth authority='file:list'>
|
||||
<DataTable table={table} />
|
||||
<DataTable
|
||||
table={table}
|
||||
toolbarLeft={
|
||||
<Auth authority='file:sync-image-metadata'>
|
||||
<Button disabled={loading} size='sm' onClick={sync}>
|
||||
同步图片元信息
|
||||
{loading && <Spinner data-icon='inline-start' />}
|
||||
</Button>
|
||||
</Auth>
|
||||
}
|
||||
/>
|
||||
</Auth>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { createCrudProvider } from '@/components/crud-provider'
|
||||
import { type FileRecord } from '@/schemas'
|
||||
|
||||
export const QUERY_KEY = 'sys_file'
|
||||
|
||||
export const ActionSchema = z.enum([]).nullable()
|
||||
|
||||
export type Action = z.infer<typeof ActionSchema>
|
||||
|
||||
export const { CrudProvider, useCrud } = createCrudProvider<FileRecord, Action>()
|
||||
|
||||
@@ -5,12 +5,11 @@ import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
||||
|
||||
import { listFiles } from '@/api'
|
||||
import { hasPermission } from '@/lib'
|
||||
import type { File } from '@/schemas'
|
||||
import type { FileRecord } from '@/schemas'
|
||||
|
||||
import { createColumns } from './components/columns.tsx'
|
||||
import { CrudProvider } from './components/crud-provider.tsx'
|
||||
import { FileTable } from './components/file-table.tsx'
|
||||
import { QUERY_KEY } from './constants.ts'
|
||||
import { CrudProvider, QUERY_KEY } from './constants.ts'
|
||||
|
||||
export default function CrudPage() {
|
||||
const [pagination, setPagination] = useState({
|
||||
@@ -29,11 +28,11 @@ export default function CrudPage() {
|
||||
enabled: hasPermission('file:list'),
|
||||
})
|
||||
|
||||
const tableData = useMemo<File[]>(() => data?.list ?? [], [data])
|
||||
const tableData = useMemo<FileRecord[]>(() => data?.list ?? [], [data])
|
||||
|
||||
const columns = useMemo(() => createColumns(), [])
|
||||
|
||||
const table = useReactTable<File>({
|
||||
const table = useReactTable<FileRecord>({
|
||||
data: tableData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function Home() {
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4'>
|
||||
{quickMenus.map((menu) => (
|
||||
<Auth authority={menu.auth}>
|
||||
<Auth key={menu.path} authority={menu.auth}>
|
||||
<Link
|
||||
key={menu.path}
|
||||
to={menu.path}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { QUERY_KEY } from '../constants.ts'
|
||||
import { QUERY_KEY, useCrud } from '../constants.ts'
|
||||
import { ActionsDialog } from './action-dialog.tsx'
|
||||
import { useCrud } from './crud-provider.tsx'
|
||||
import { DeleteDialog } from './delete-dialog.tsx'
|
||||
|
||||
export const ActionDialogs = () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
|
||||
import { StatusLabel } from '@/components/label'
|
||||
import { DictLabel } from '@/components/label'
|
||||
import { MenuIcon } from '@/components/menu-icon'
|
||||
import { menuTypeDict, type MenuTypeValues, type StatusValues } from '@/enum'
|
||||
import { menuTypeDict, statusLabel, type MenuTypeValues, type StatusValues } from '@/enum'
|
||||
import { formatDate, hasPermission } from '@/lib'
|
||||
import type { MenuTree } from '@/schemas'
|
||||
|
||||
@@ -28,7 +28,7 @@ export const createColumns = (): ColumnDef<MenuTree>[] => {
|
||||
{
|
||||
accessorKey: 'path',
|
||||
header: '菜单路径',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('path')}</div>,
|
||||
},
|
||||
{
|
||||
@@ -37,40 +37,41 @@ export const createColumns = (): ColumnDef<MenuTree>[] => {
|
||||
meta: { className: 'max-w-20' },
|
||||
cell: ({ row }) => {
|
||||
const menuType = menuTypeDict[row.getValue('type') as MenuTypeValues]
|
||||
return <div>{menuType}</div>
|
||||
return <DictLabel text={menuType} />
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'permission_code',
|
||||
header: '权限字符',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => <div>{row.getValue('permission_code')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: '菜单状态',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status') as StatusValues
|
||||
return <StatusLabel value={status} />
|
||||
const { text, dotClass } = statusLabel[status]
|
||||
return <DictLabel text={text} dotClass={dotClass} />
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'hidden',
|
||||
header: '是否隐藏',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => <div>{row.getValue('hidden') ? '是' : '否'}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'sort',
|
||||
header: '菜单排序',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => <div>{row.getValue('sort')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: '创建时间',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const rawDate = row.getValue('created_at') as string
|
||||
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
|
||||
@@ -79,7 +80,7 @@ export const createColumns = (): ColumnDef<MenuTree>[] => {
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: '修改时间',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const rawDate = row.getValue('updated_at') as string
|
||||
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
|
||||
@@ -91,7 +92,7 @@ export const createColumns = (): ColumnDef<MenuTree>[] => {
|
||||
columns.push({
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
meta: { className: ' max-w-36 sticky right-0' },
|
||||
meta: { className: 'max-w-36 sticky right-0' },
|
||||
cell: RowActions,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
|
||||
import { type MenuTree } 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<MenuTree> | null>(null)
|
||||
|
||||
export const CrudProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [action, setAction] = useState<Action>(null)
|
||||
const [currentRow, setCurrentRow] = useState<MenuTree | 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
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { Auth } from '@/components/auth'
|
||||
import { DataTable } from '@/components/data-table'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
|
||||
import { useCrud } from './crud-provider.tsx'
|
||||
import { useCrud } from '../constants.ts'
|
||||
|
||||
interface Props<TData> {
|
||||
table: Table<TData>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu.tsx'
|
||||
import { type MenuTree } from '@/schemas'
|
||||
|
||||
import { useCrud } from './crud-provider'
|
||||
import { useCrud } from '../constants'
|
||||
|
||||
type Props = {
|
||||
row: Row<MenuTree>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { createCrudProvider } from '@/components/crud-provider'
|
||||
import { type MenuTree } from '@/schemas'
|
||||
|
||||
export const QUERY_KEY = 'sys_menu'
|
||||
|
||||
export const ActionSchema = z.enum(['add', 'edit', 'delete']).nullable()
|
||||
|
||||
export type Action = z.infer<typeof ActionSchema>
|
||||
|
||||
export const { CrudProvider, useCrud } = createCrudProvider<MenuTree, Action>()
|
||||
|
||||
@@ -9,9 +9,8 @@ import { type MenuTree } from '@/schemas'
|
||||
|
||||
import { ActionDialogs } from './components/action-dialogs.tsx'
|
||||
import { createColumns } from './components/columns.tsx'
|
||||
import { CrudProvider } from './components/crud-provider.tsx'
|
||||
import { MenuTable } from './components/menu-table.tsx'
|
||||
import { QUERY_KEY } from './constants.ts'
|
||||
import { CrudProvider, QUERY_KEY } from './constants.ts'
|
||||
|
||||
export default function CrudPage() {
|
||||
const { data, isFetching, refetch } = useQuery({
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { QUERY_KEY } from '../constants.ts'
|
||||
import { QUERY_KEY, useCrud } from '../constants.ts'
|
||||
import { ActionsDialog } from './action-dialog.tsx'
|
||||
import { AssignApisDialog } from './assign-apis-dialog.tsx'
|
||||
import { AssignMenusDialog } from './assign-menus-dialog.tsx'
|
||||
import { useCrud } from './crud-provider.tsx'
|
||||
import { DeleteDialog } from './delete-dialog.tsx'
|
||||
|
||||
export const ActionDialogs = () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
|
||||
import { StatusLabel } from '@/components/label'
|
||||
import { type StatusValues } from '@/enum'
|
||||
import { DictLabel } from '@/components/label'
|
||||
import { statusLabel, type StatusValues } from '@/enum'
|
||||
import { formatDate, hasPermission } from '@/lib'
|
||||
import type { Role } from '@/schemas'
|
||||
|
||||
@@ -17,28 +17,29 @@ export const createColumns = (): ColumnDef<Role>[] => {
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: '角色名称',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('name')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'code',
|
||||
header: '角色编码',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => <div className='overflow-hidden'>{row.getValue('code')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: '角色状态',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status') as StatusValues
|
||||
return <StatusLabel value={status} />
|
||||
const { text, dotClass } = statusLabel[status]
|
||||
return <DictLabel text={text} dotClass={dotClass} />
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: '创建时间',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const rawDate = row.getValue('created_at') as string
|
||||
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
|
||||
@@ -47,7 +48,7 @@ export const createColumns = (): ColumnDef<Role>[] => {
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: '修改时间',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const rawDate = row.getValue('updated_at') as string
|
||||
return <div className='overflow-hidden'>{formatDate(rawDate)}</div>
|
||||
@@ -59,7 +60,7 @@ export const createColumns = (): ColumnDef<Role>[] => {
|
||||
columns.push({
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
meta: { className: ' max-w-36 sticky right-0' },
|
||||
meta: { className: 'max-w-36 sticky right-0' },
|
||||
cell: RowActions,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
|
||||
import { type Role } 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<Role> | null>(null)
|
||||
|
||||
export const CrudProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [action, setAction] = useState<Action>(null)
|
||||
const [currentRow, setCurrentRow] = useState<Role | 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
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { Auth } from '@/components/auth'
|
||||
import { DataTable } from '@/components/data-table'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
|
||||
import { useCrud } from './crud-provider.tsx'
|
||||
import { useCrud } from '../constants.ts'
|
||||
|
||||
interface Props<TData> {
|
||||
table: Table<TData>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu.tsx'
|
||||
import { type Role } from '@/schemas'
|
||||
|
||||
import { useCrud } from './crud-provider'
|
||||
import { useCrud } from '../constants'
|
||||
|
||||
type Props = {
|
||||
row: Row<Role>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { createCrudProvider } from '@/components/crud-provider'
|
||||
import { type Role } from '@/schemas'
|
||||
|
||||
export const QUERY_KEY = 'sys_role'
|
||||
|
||||
export const ActionSchema = z
|
||||
@@ -7,3 +10,5 @@ export const ActionSchema = z
|
||||
.nullable()
|
||||
|
||||
export type Action = z.infer<typeof ActionSchema>
|
||||
|
||||
export const { CrudProvider, useCrud } = createCrudProvider<Role, Action>()
|
||||
|
||||
@@ -9,9 +9,8 @@ import { type Role } from '@/schemas'
|
||||
|
||||
import { ActionDialogs } from './components/action-dialogs.tsx'
|
||||
import { createColumns } from './components/columns.tsx'
|
||||
import { CrudProvider } from './components/crud-provider.tsx'
|
||||
import { RoleTable } from './components/role-table.tsx'
|
||||
import { QUERY_KEY } from './constants.ts'
|
||||
import { CrudProvider, QUERY_KEY } from './constants.ts'
|
||||
|
||||
export default function CrudPage() {
|
||||
const [pagination, setPagination] = useState({
|
||||
|
||||
@@ -95,7 +95,11 @@ export function ActionsDialog({ open, currentRow, onClose, onConfirm }: Props) {
|
||||
return (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel>用户头像</FieldLabel>
|
||||
<FileUpload defaultFiles={field.value} onChange={field.onChange} />
|
||||
<FileUpload
|
||||
defaultFiles={field.value}
|
||||
onChange={field.onChange}
|
||||
folder='avatar'
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { QUERY_KEY } from '../constants.ts'
|
||||
import { QUERY_KEY, useCrud } from '../constants.ts'
|
||||
import { ActionsDialog } from './action-dialog.tsx'
|
||||
import { AssignRolesDialog } from './assign-roles-dialog.tsx'
|
||||
import { ChangePasswordDialog } from './change-password-dialog.tsx'
|
||||
import { useCrud } from './crud-provider.tsx'
|
||||
import { DeleteDialog } from './delete-dialog.tsx'
|
||||
import { KickDialog } from './kick-dialog.tsx'
|
||||
|
||||
export const ActionDialogs = () => {
|
||||
const queryClient = useQueryClient()
|
||||
@@ -33,6 +33,13 @@ export const ActionDialogs = () => {
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
|
||||
<KickDialog
|
||||
key='kick-all'
|
||||
open={action === 'kick-all'}
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
|
||||
{currentRow && (
|
||||
<>
|
||||
<ActionsDialog
|
||||
@@ -66,6 +73,14 @@ export const ActionDialogs = () => {
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
|
||||
<KickDialog
|
||||
currentRow={currentRow}
|
||||
key={`kick-${currentRow.id}`}
|
||||
open={action === 'kick'}
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
|
||||
import { StatusLabel } from '@/components/label'
|
||||
import { DictLabel } from '@/components/label'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { type StatusValues } from '@/enum'
|
||||
import { statusLabel, type StatusValues } from '@/enum'
|
||||
import { formatDate, hasPermission } from '@/lib'
|
||||
import type { User } from '@/schemas'
|
||||
|
||||
@@ -10,7 +10,7 @@ import { RowActions } from './row-actions'
|
||||
|
||||
export const createColumns = (): ColumnDef<User>[] => {
|
||||
const showAction = hasPermission(
|
||||
['user:update', 'user:assign-roles', 'user:update-password', 'user:delete'],
|
||||
['user:update', 'user:assign-roles', 'user:update-password', 'user:delete', 'user:kick'],
|
||||
'any',
|
||||
)
|
||||
|
||||
@@ -41,10 +41,11 @@ export const createColumns = (): ColumnDef<User>[] => {
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: '用户状态',
|
||||
meta: { className: ' max-w-36' },
|
||||
meta: { className: 'max-w-36' },
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status') as StatusValues
|
||||
return <StatusLabel value={status} />
|
||||
const { text, dotClass } = statusLabel[status]
|
||||
return <DictLabel text={text} dotClass={dotClass} />
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
|
||||
import { type User } 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<User> | null>(null)
|
||||
|
||||
export const CrudProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [action, setAction] = useState<Action>(null)
|
||||
const [currentRow, setCurrentRow] = useState<User | 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
|
||||
}
|
||||
65
src/pages/system/user/components/kick-dialog.tsx
Normal file
65
src/pages/system/user/components/kick-dialog.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { kickAllUsers, kickUser } 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 User } from '@/schemas'
|
||||
|
||||
interface Props {
|
||||
currentRow?: User | null
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export function KickDialog({ open, onClose, currentRow, onConfirm }: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const onKick = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
if (currentRow) {
|
||||
await kickUser(currentRow.id)
|
||||
} else {
|
||||
await kickAllUsers()
|
||||
}
|
||||
toast('操作成功!')
|
||||
onConfirm()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onClose}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{currentRow ? '踢用户下线?' : '下线所有用户?'}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{currentRow
|
||||
? `确认将【${currentRow.account}】强制下线吗?`
|
||||
: '确认将全部用户强制下线吗?此操作会注销所有用户的登录态。'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
|
||||
<Button disabled={loading} variant='destructive' onClick={onKick}>
|
||||
确定
|
||||
{loading && <Spinner data-icon='inline-start' />}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu.tsx'
|
||||
import { type User } from '@/schemas'
|
||||
|
||||
import { useCrud } from './crud-provider'
|
||||
import { useCrud } from '../constants'
|
||||
|
||||
type Props = {
|
||||
row: Row<User>
|
||||
@@ -43,6 +43,11 @@ export function RowActions({ row }: Props) {
|
||||
setCurrentRow(original)
|
||||
}
|
||||
|
||||
const onKick = () => {
|
||||
setAction('kick')
|
||||
setCurrentRow(original)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -62,6 +67,9 @@ export function RowActions({ row }: Props) {
|
||||
<Auth authority='user:update-password'>
|
||||
<DropdownMenuItem onClick={changePassword}>修改密码</DropdownMenuItem>
|
||||
</Auth>
|
||||
<Auth authority='user:kick'>
|
||||
<DropdownMenuItem onClick={onKick}>踢下线</DropdownMenuItem>
|
||||
</Auth>
|
||||
<Auth authority='user:delete'>
|
||||
<DropdownMenuItem onClick={onDelete}>删除用户</DropdownMenuItem>
|
||||
</Auth>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { DataTable } from '@/components/data-table'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import type { SearchUserParams } from '@/schemas'
|
||||
|
||||
import { useCrud } from './crud-provider.tsx'
|
||||
import { useCrud } from '../constants.ts'
|
||||
import { SearchForm } from './search-form.tsx'
|
||||
|
||||
interface Props<TData> {
|
||||
@@ -23,14 +23,19 @@ export function UserTable<TData>({ table, onSearch }: Props<TData>) {
|
||||
table={table}
|
||||
search={<SearchForm onSearch={onSearch} />}
|
||||
toolbarLeft={
|
||||
<>
|
||||
<div className='flex gap-4'>
|
||||
<Auth authority='user:create'>
|
||||
<Button size='sm' onClick={() => setAction('add')}>
|
||||
<Plus />
|
||||
新建用户
|
||||
</Button>
|
||||
</Auth>
|
||||
</>
|
||||
<Auth authority='user:kick-all'>
|
||||
<Button size='sm' variant='outline' onClick={() => setAction('kick-all')}>
|
||||
下线所有用户
|
||||
</Button>
|
||||
</Auth>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Auth>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { createCrudProvider } from '@/components/crud-provider'
|
||||
import { type User } from '@/schemas'
|
||||
|
||||
export const QUERY_KEY = 'sys_user'
|
||||
|
||||
export const ActionSchema = z
|
||||
.enum(['add', 'edit', 'delete', 'assign-roles', 'change-password'])
|
||||
.enum(['add', 'edit', 'delete', 'assign-roles', 'change-password', 'kick', 'kick-all'])
|
||||
.nullable()
|
||||
|
||||
export type Action = z.infer<typeof ActionSchema>
|
||||
|
||||
export const { CrudProvider, useCrud } = createCrudProvider<User, Action>()
|
||||
|
||||
@@ -9,9 +9,8 @@ import { type SearchUserParams, type User } from '@/schemas'
|
||||
|
||||
import { ActionDialogs } from './components/action-dialogs.tsx'
|
||||
import { createColumns } from './components/columns.tsx'
|
||||
import { CrudProvider } from './components/crud-provider.tsx'
|
||||
import { UserTable } from './components/user-table.tsx'
|
||||
import { QUERY_KEY } from './constants.ts'
|
||||
import { CrudProvider, QUERY_KEY } from './constants.ts'
|
||||
|
||||
export default function CrudPage() {
|
||||
const [pagination, setPagination] = useState({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { fileFormSchema } from '@/schemas'
|
||||
import { uploadedFileSchema } from '@/schemas'
|
||||
|
||||
export const postSchema = z.object({
|
||||
id: z.number().int(),
|
||||
@@ -8,21 +8,27 @@ export const postSchema = z.object({
|
||||
cover_id: z.number().int(),
|
||||
cover: z.string(),
|
||||
slug: z.string(),
|
||||
content: z.string(),
|
||||
summary: z.string(),
|
||||
status: z.number().int(),
|
||||
view: z.number().int(),
|
||||
view_count: z.number().int(),
|
||||
sort: z.number().int(),
|
||||
category_name: z.string(),
|
||||
category_id: z.number().int().optional(),
|
||||
published_at: z.string(),
|
||||
tags: z.array(z.number().int()),
|
||||
cover_size: z.number(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
tags: z.array(z.number().int()),
|
||||
})
|
||||
|
||||
export const postDetailSchema = postSchema.extend({
|
||||
content: z.string(),
|
||||
})
|
||||
|
||||
export type Post = z.infer<typeof postSchema>
|
||||
|
||||
export type PostDetail = z.infer<typeof postDetailSchema>
|
||||
|
||||
export const postFormSchema = z
|
||||
.object({
|
||||
id: z.number().int().optional(),
|
||||
@@ -34,7 +40,7 @@ export const postFormSchema = z
|
||||
summary: z.string(),
|
||||
status: z.number().int(),
|
||||
published_at: z.string().min(1, '请选择发布日期!'),
|
||||
cover: fileFormSchema,
|
||||
cover: uploadedFileSchema.optional(),
|
||||
sort: z.coerce.number<number>().int().min(0, '排序值不能小于0'),
|
||||
content: z.string().optional(),
|
||||
category_id: z
|
||||
@@ -48,7 +54,7 @@ export const postFormSchema = z
|
||||
const { cover, ...rest } = data
|
||||
return {
|
||||
...rest,
|
||||
cover_id: cover && cover.id,
|
||||
cover_id: cover?.id || null,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export * from './system/role'
|
||||
export * from './system/api'
|
||||
export * from './system/menu'
|
||||
export * from './system/file'
|
||||
export * from './system/access-log'
|
||||
|
||||
//blog
|
||||
export * from './blog/post'
|
||||
|
||||
18
src/schemas/system/access-log.ts
Normal file
18
src/schemas/system/access-log.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const AccessLogSchema = z.object({
|
||||
id: z.number().int(),
|
||||
request_id: z.string(),
|
||||
username: z.string().nullable(),
|
||||
message: z.string(),
|
||||
ip: z.string(),
|
||||
user_agent: z.string(),
|
||||
request_method: z.string(),
|
||||
request_path: z.string(),
|
||||
referer: z.string(),
|
||||
status_code: z.number(),
|
||||
response_time_ms: z.number(),
|
||||
started_at: z.string(),
|
||||
})
|
||||
|
||||
export type AccessLog = z.infer<typeof AccessLogSchema>
|
||||
@@ -11,13 +11,33 @@ export const fileSchema = z.object({
|
||||
file_size: z.number().int(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
metadata: z
|
||||
.object({
|
||||
width: z.number().int(),
|
||||
height: z.number().int(),
|
||||
format: z.string(),
|
||||
})
|
||||
.nullable(),
|
||||
})
|
||||
|
||||
export type File = z.infer<typeof fileSchema>
|
||||
export type FileRecord = z.infer<typeof fileSchema>
|
||||
|
||||
export const fileFormSchema = z.object({
|
||||
export const fileFolderSchema = z.enum(['article', 'cover', 'avatar'])
|
||||
|
||||
export type FileFolder = z.infer<typeof fileFolderSchema>
|
||||
|
||||
export const uploadFileInputSchema = z.object({
|
||||
file: z.instanceof(File),
|
||||
folder: fileFolderSchema.optional(),
|
||||
})
|
||||
|
||||
export type UploadFileInput = z.infer<typeof uploadFileInputSchema>
|
||||
|
||||
export const uploadedFileSchema = z.object({
|
||||
id: z.number().int(),
|
||||
file_url: z.string(),
|
||||
file_name: z.string().optional(),
|
||||
file_size: z.number().optional(),
|
||||
})
|
||||
|
||||
export type FileForm = z.infer<typeof fileFormSchema>
|
||||
export type UploadedFile = z.infer<typeof uploadedFileSchema>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from 'zod'
|
||||
|
||||
import { StatusSchema } from '@/enum'
|
||||
|
||||
import { fileFormSchema } from './file'
|
||||
import { uploadedFileSchema } from './file'
|
||||
import { menuSchema } from './menu'
|
||||
|
||||
export const userSchema = z.object({
|
||||
@@ -75,7 +75,7 @@ export const userFormSchema = z
|
||||
message: '请选择状态',
|
||||
}),
|
||||
confirmPassword: z.string().optional(),
|
||||
avatar: z.array(fileFormSchema).optional(),
|
||||
avatar: z.array(uploadedFileSchema).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!data.id) {
|
||||
|
||||
Reference in New Issue
Block a user