feat: update template

This commit is contained in:
2026-08-19 22:07:42 +08:00
parent 99d5392a68
commit 6c45b75786
97 changed files with 1460 additions and 477 deletions

2
.gitignore vendored
View File

@@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.claude

View File

@@ -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
View File

@@ -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
View File

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

View File

@@ -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) => {

View File

@@ -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'

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

View File

@@ -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')
}

View File

@@ -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`)
}

Binary file not shown.

1
src/assets/no-pic.svg Normal file
View 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

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

View File

@@ -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)
},

View File

@@ -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}

View File

@@ -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)),
)

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

View File

@@ -1 +1 @@
export { StatusLabel } from './status.tsx'
export { DictLabel } from './dict.tsx'

View File

@@ -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>
)
}

View File

@@ -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
}

View File

@@ -1,4 +1,3 @@
.loading-wrap {
background-color: #ffffff;
opacity: 1;

193
src/components/ui/item.tsx Normal file
View 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,
}

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

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

View File

@@ -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}

View File

@@ -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),
}),
)

View File

@@ -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) },
]

View File

@@ -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;
}

View File

@@ -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`
}

View File

@@ -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 = () => {

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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>()

View File

@@ -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({

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 = () => {

View File

@@ -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>
},
},
{

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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>()

View File

@@ -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({

View File

@@ -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 = () => {

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>()

View File

@@ -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({

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

View 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,
},
]
}

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

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

View 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>()

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

View File

@@ -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 = () => {

View File

@@ -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> {

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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>()

View File

@@ -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({

View File

@@ -1,10 +1,11 @@
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',
@@ -42,6 +43,22 @@ export const createColumns = (): ColumnDef<File>[] => {
<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: '上传日期',

View File

@@ -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
}

View File

@@ -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>
)
}

View File

@@ -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>()

View File

@@ -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(),

View File

@@ -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}

View File

@@ -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 = () => {

View File

@@ -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'
@@ -37,7 +37,7 @@ 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} />
},
},
{
@@ -52,7 +52,8 @@ export const createColumns = (): ColumnDef<MenuTree>[] => {
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} />
},
},
{

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>()

View File

@@ -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({

View File

@@ -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 = () => {

View File

@@ -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'
@@ -32,7 +32,8 @@ export const createColumns = (): ColumnDef<Role>[] => {
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} />
},
},
{

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>()

View File

@@ -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({

View File

@@ -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>
)

View File

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

View File

@@ -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',
)
@@ -44,7 +44,8 @@ export const createColumns = (): ColumnDef<User>[] => {
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} />
},
},
{

View File

@@ -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
}

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>()

View File

@@ -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({

View File

@@ -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,
}
})

View File

@@ -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'

View 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>

View File

@@ -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>

View File

@@ -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) {