From 6c45b75786113196d2b77d4a817f34e14a5c5acd Mon Sep 17 00:00:00 2001 From: xy <10816187@qq.com> Date: Wed, 19 Aug 2026 22:07:42 +0800 Subject: [PATCH] feat: update template --- .gitignore | 2 + package.json | 2 + pnpm-lock.yaml | 19 +- scripts/deploy.sh | 31 ++ src/api/blog/post.ts | 4 +- src/api/index.ts | 1 + src/api/system/access-log.ts | 6 + src/api/system/file.ts | 19 +- src/api/system/user.ts | 8 + src/assets/fonts/NotoSansAvestan-Regular.ttf | Bin 0 -> 60196 bytes src/assets/no-pic.svg | 1 + src/components/crud-provider.tsx | 39 +++ src/components/data-table/pagination.tsx | 2 +- src/components/editor/index.tsx | 4 +- src/components/file-upload/file-upload.tsx | 14 +- src/components/label/dict.tsx | 20 ++ src/components/label/index.ts | 2 +- src/components/label/status.tsx | 18 -- src/components/menu-icon/index.tsx | 34 +-- .../page-loading/loading.module.css | 1 - src/components/ui/item.tsx | 193 +++++++++++++ src/components/ui/progress.tsx | 29 ++ src/components/ui/radio-group.tsx | 43 +++ src/components/ui/separator.tsx | 6 +- src/enum/menu_icon.ts | 37 ++- src/enum/status.ts | 12 +- src/index.css | 5 + src/lib/format.ts | 12 + .../category/components/action-dialogs.tsx | 3 +- .../category/components/category-table.tsx | 2 +- .../blog/category/components/columns.tsx | 12 +- .../category/components/crud-provider.tsx | 33 --- .../blog/category/components/row-actions.tsx | 2 +- src/pages/blog/category/constants.ts | 5 + src/pages/blog/category/index.tsx | 3 +- .../post-edit/components/cover-generator.tsx | 270 ++++++++++++++++++ .../post-edit/components/cover-upload.tsx | 52 +++- src/pages/blog/post-edit/index.tsx | 21 +- .../blog/post/components/action-dialogs.tsx | 3 +- src/pages/blog/post/components/columns.tsx | 37 ++- .../blog/post/components/crud-provider.tsx | 33 --- .../blog/post/components/row-actions.tsx | 2 +- src/pages/blog/post/constants.ts | 5 + src/pages/blog/post/index.tsx | 3 +- .../blog/tag/components/action-dialogs.tsx | 3 +- .../blog/tag/components/crud-provider.tsx | 33 --- src/pages/blog/tag/components/row-actions.tsx | 2 +- src/pages/blog/tag/components/tag-table.tsx | 2 +- src/pages/blog/tag/constants.ts | 5 + src/pages/blog/tag/index.tsx | 3 +- .../components/access-logs-table.tsx | 16 ++ .../system/access-log/components/columns.tsx | 110 +++++++ .../access-log/components/view-actions.tsx | 28 ++ .../access-log/components/view-dialog.tsx | 68 +++++ src/pages/system/access-log/constants.ts | 12 + src/pages/system/access-log/index.tsx | 57 ++++ .../system/api/components/action-dialogs.tsx | 3 +- src/pages/system/api/components/api-table.tsx | 2 +- src/pages/system/api/components/columns.tsx | 14 +- .../system/api/components/crud-provider.tsx | 33 --- .../system/api/components/row-actions.tsx | 2 +- src/pages/system/api/constants.ts | 5 + src/pages/system/api/index.tsx | 3 +- src/pages/system/file/components/columns.tsx | 33 ++- .../system/file/components/crud-provider.tsx | 33 --- .../system/file/components/file-table.tsx | 32 ++- src/pages/system/file/constants.ts | 5 + src/pages/system/file/index.tsx | 9 +- src/pages/system/home/index.tsx | 2 +- .../system/menu/components/action-dialogs.tsx | 3 +- src/pages/system/menu/components/columns.tsx | 25 +- .../system/menu/components/crud-provider.tsx | 33 --- .../system/menu/components/menu-table.tsx | 2 +- .../system/menu/components/row-actions.tsx | 2 +- src/pages/system/menu/constants.ts | 5 + src/pages/system/menu/index.tsx | 3 +- .../system/role/components/action-dialogs.tsx | 3 +- src/pages/system/role/components/columns.tsx | 19 +- .../system/role/components/crud-provider.tsx | 33 --- .../system/role/components/role-table.tsx | 2 +- .../system/role/components/row-actions.tsx | 2 +- src/pages/system/role/constants.ts | 5 + src/pages/system/role/index.tsx | 3 +- .../system/user/components/action-dialog.tsx | 6 +- .../system/user/components/action-dialogs.tsx | 19 +- src/pages/system/user/components/columns.tsx | 11 +- .../system/user/components/crud-provider.tsx | 33 --- .../system/user/components/kick-dialog.tsx | 65 +++++ .../system/user/components/row-actions.tsx | 10 +- .../system/user/components/user-table.tsx | 11 +- src/pages/system/user/constants.ts | 7 +- src/pages/system/user/index.tsx | 3 +- src/schemas/blog/post.ts | 18 +- src/schemas/index.ts | 1 + src/schemas/system/access-log.ts | 18 ++ src/schemas/system/file.ts | 26 +- src/schemas/system/user.ts | 4 +- 97 files changed, 1460 insertions(+), 477 deletions(-) create mode 100755 scripts/deploy.sh create mode 100644 src/api/system/access-log.ts create mode 100644 src/assets/fonts/NotoSansAvestan-Regular.ttf create mode 100644 src/assets/no-pic.svg create mode 100644 src/components/crud-provider.tsx create mode 100644 src/components/label/dict.tsx delete mode 100644 src/components/label/status.tsx create mode 100644 src/components/ui/item.tsx create mode 100644 src/components/ui/progress.tsx create mode 100644 src/components/ui/radio-group.tsx delete mode 100644 src/pages/blog/category/components/crud-provider.tsx create mode 100644 src/pages/blog/post-edit/components/cover-generator.tsx delete mode 100644 src/pages/blog/post/components/crud-provider.tsx delete mode 100644 src/pages/blog/tag/components/crud-provider.tsx create mode 100644 src/pages/system/access-log/components/access-logs-table.tsx create mode 100644 src/pages/system/access-log/components/columns.tsx create mode 100644 src/pages/system/access-log/components/view-actions.tsx create mode 100644 src/pages/system/access-log/components/view-dialog.tsx create mode 100644 src/pages/system/access-log/constants.ts create mode 100644 src/pages/system/access-log/index.tsx delete mode 100644 src/pages/system/api/components/crud-provider.tsx delete mode 100644 src/pages/system/file/components/crud-provider.tsx delete mode 100644 src/pages/system/menu/components/crud-provider.tsx delete mode 100644 src/pages/system/role/components/crud-provider.tsx delete mode 100644 src/pages/system/user/components/crud-provider.tsx create mode 100644 src/pages/system/user/components/kick-dialog.tsx create mode 100644 src/schemas/system/access-log.ts diff --git a/.gitignore b/.gitignore index a547bf3..4d63689 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ dist-ssr *.njsproj *.sln *.sw? + +.claude diff --git a/package.json b/package.json index 7991b43..089d30a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9660d67..e8a8b4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..5e506ce --- /dev/null +++ b/scripts/deploy.sh @@ -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 "部署完成" diff --git a/src/api/blog/post.ts b/src/api/blog/post.ts index c35c567..4201f27 100644 --- a/src/api/blog/post.ts +++ b/src/api/blog/post.ts @@ -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>('/posts', { params }) @@ -14,7 +14,7 @@ export const updatePost = (id: number, data: PostFormOutput) => { } export const getPost = (id: number) => { - return http.get(`/posts/${id}`) + return http.get(`/posts/${id}`) } export const deletePost = (id: number) => { diff --git a/src/api/index.ts b/src/api/index.ts index 9c289a1..96e8100 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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' diff --git a/src/api/system/access-log.ts b/src/api/system/access-log.ts new file mode 100644 index 0000000..85ffa8f --- /dev/null +++ b/src/api/system/access-log.ts @@ -0,0 +1,6 @@ +import { http } from '@/lib' +import type { AccessLog, PageParams } from '@/schemas' + +export const listAccessLogs = (params: PageParams) => { + return http.get>('/access-logs', { params }) +} diff --git a/src/api/system/file.ts b/src/api/system/file.ts index bec4830..7116e8b 100644 --- a/src/api/system/file.ts +++ b/src/api/system/file.ts @@ -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>('/files', { params }) + return http.get>('/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('/files', formData, { + if (folder) { + formData.append('folder', folder) + } + + return http.post('/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') +} diff --git a/src/api/system/user.ts b/src/api/system/user.ts index 1c6b869..5283231 100644 --- a/src/api/system/user.ts +++ b/src/api/system/user.ts @@ -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`) +} diff --git a/src/assets/fonts/NotoSansAvestan-Regular.ttf b/src/assets/fonts/NotoSansAvestan-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..fe9a3877838cf39b23bf28bc0a58898f967c9ecf GIT binary patch literal 60196 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 \ No newline at end of file diff --git a/src/components/crud-provider.tsx b/src/components/crud-provider.tsx new file mode 100644 index 0000000..de75f68 --- /dev/null +++ b/src/components/crud-provider.tsx @@ -0,0 +1,39 @@ +import { createContext, useContext, useState, type ReactNode } from 'react' + +type CrudContextType = { + action: A + setAction: (action: A) => void + currentRow: T | null + setCurrentRow: (row: T | null) => void +} + +/** + * 创建页面级 CRUD 弹窗上下文,绑定页面自己的行类型与 Action 类型 + * + * @example + * export const { CrudProvider, useCrud } = createCrudProvider() + */ +export const createCrudProvider = () => { + const CrudContext = createContext | null>(null) + + const CrudProvider = ({ children }: { children: ReactNode }) => { + const [action, setAction] = useState(null as A) + const [currentRow, setCurrentRow] = useState(null) + + return ( + {children} + ) + } + + const useCrud = () => { + const context = useContext(CrudContext) + + if (!context) { + throw new Error('useCrud must be used within a CrudProvider') + } + + return context + } + + return { CrudProvider, useCrud } +} diff --git a/src/components/data-table/pagination.tsx b/src/components/data-table/pagination.tsx index 59681c7..59dfb30 100644 --- a/src/components/data-table/pagination.tsx +++ b/src/components/data-table/pagination.tsx @@ -35,7 +35,7 @@ export function DataTablePagination({ table }: DataTablePaginationProps { table.setPageIndex(page - 1) }, diff --git a/src/components/editor/index.tsx b/src/components/editor/index.tsx index be6e5d0..f6123ee 100644 --- a/src/components/editor/index.tsx +++ b/src/components/editor/index.tsx @@ -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 ( <> -
+
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( (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)), ) diff --git a/src/components/label/dict.tsx b/src/components/label/dict.tsx new file mode 100644 index 0000000..b85c3a0 --- /dev/null +++ b/src/components/label/dict.tsx @@ -0,0 +1,20 @@ +import type { ComponentType, SVGProps } from 'react' + +import { cn } from '@/lib' + +interface Props { + text: string + dotClass?: string + icon?: ComponentType> + className?: string +} + +export function DictLabel({ text, dotClass, icon: Icon, className }: Props) { + return ( +
+ {dotClass && } + {Icon && } + {text} +
+ ) +} diff --git a/src/components/label/index.ts b/src/components/label/index.ts index f92340b..99686e7 100644 --- a/src/components/label/index.ts +++ b/src/components/label/index.ts @@ -1 +1 @@ -export { StatusLabel } from './status.tsx' +export { DictLabel } from './dict.tsx' diff --git a/src/components/label/status.tsx b/src/components/label/status.tsx deleted file mode 100644 index c4002bc..0000000 --- a/src/components/label/status.tsx +++ /dev/null @@ -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 ( -
- - {label} -
- ) -} diff --git a/src/components/menu-icon/index.tsx b/src/components/menu-icon/index.tsx index 1559eda..7fa5e3e 100644 --- a/src/components/menu-icon/index.tsx +++ b/src/components/menu-icon/index.tsx @@ -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> - -const iconMap: Partial> = { - [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 ? : null } diff --git a/src/components/page-loading/loading.module.css b/src/components/page-loading/loading.module.css index 6087175..550ba18 100644 --- a/src/components/page-loading/loading.module.css +++ b/src/components/page-loading/loading.module.css @@ -1,4 +1,3 @@ - .loading-wrap { background-color: #ffffff; opacity: 1; diff --git a/src/components/ui/item.tsx b/src/components/ui/item.tsx new file mode 100644 index 0000000..d5dbb1a --- /dev/null +++ b/src/components/ui/item.tsx @@ -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 ( +
+ ) +} + +function ItemSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +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 & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "div" + return ( + + ) +} + +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) { + return ( +
+ ) +} + +function ItemContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function ItemTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function ItemDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +

a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary", + className + )} + {...props} + /> + ) +} + +function ItemActions({ className, ...props }: React.ComponentProps<"div">) { + return ( +

+ ) +} + +function ItemHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function ItemFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Item, + ItemMedia, + ItemContent, + ItemActions, + ItemGroup, + ItemSeparator, + ItemTitle, + ItemDescription, + ItemHeader, + ItemFooter, +} diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 0000000..af2ccf5 --- /dev/null +++ b/src/components/ui/progress.tsx @@ -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) { + return ( + + + + ) +} + +export { Progress } diff --git a/src/components/ui/radio-group.tsx b/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..20416f0 --- /dev/null +++ b/src/components/ui/radio-group.tsx @@ -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) { + return ( + + ) +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { RadioGroup, RadioGroupItem } diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx index 275381c..db91e97 100644 --- a/src/components/ui/separator.tsx +++ b/src/components/ui/separator.tsx @@ -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} diff --git a/src/enum/menu_icon.ts b/src/enum/menu_icon.ts index 5e37527..75efeed 100644 --- a/src/enum/menu_icon.ts +++ b/src/enum/menu_icon.ts @@ -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> + +export const MenuIconMap: Record = { + [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), }), ) diff --git a/src/enum/status.ts b/src/enum/status.ts index d0d8efe..810b2df 100644 --- a/src/enum/status.ts +++ b/src/enum/status.ts @@ -7,14 +7,14 @@ export const Status = { export type StatusValues = (typeof Status)[keyof typeof Status] -export const statusDict: Record = { - 0: '禁用', - 1: '启用', -} as const +export const statusLabel: Record = { + [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) }, ] diff --git a/src/index.css b/src/index.css index 15c6430..50f78a2 100644 --- a/src/index.css +++ b/src/index.css @@ -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; } diff --git a/src/lib/format.ts b/src/lib/format.ts index 0932ea6..4f40ed1 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -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` +} diff --git a/src/pages/blog/category/components/action-dialogs.tsx b/src/pages/blog/category/components/action-dialogs.tsx index 799860f..5411fa1 100644 --- a/src/pages/blog/category/components/action-dialogs.tsx +++ b/src/pages/blog/category/components/action-dialogs.tsx @@ -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 = () => { diff --git a/src/pages/blog/category/components/category-table.tsx b/src/pages/blog/category/components/category-table.tsx index 76ffd54..ce142da 100644 --- a/src/pages/blog/category/components/category-table.tsx +++ b/src/pages/blog/category/components/category-table.tsx @@ -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 { table: Table diff --git a/src/pages/blog/category/components/columns.tsx b/src/pages/blog/category/components/columns.tsx index 34bfb60..d7ba326 100644 --- a/src/pages/blog/category/components/columns.tsx +++ b/src/pages/blog/category/components/columns.tsx @@ -12,25 +12,25 @@ export const createColumns = (): ColumnDef[] => { { accessorKey: 'name', header: '分类名称', - meta: { className: ' max-w-36' }, + meta: { className: 'max-w-36' }, cell: ({ row }) =>
{row.getValue('name')}
, }, { accessorKey: 'code', header: '分类编码', - meta: { className: ' max-w-36' }, + meta: { className: 'max-w-36' }, cell: ({ row }) =>
{row.getValue('code')}
, }, { accessorKey: 'sort', header: '排序', - meta: { className: ' max-w-36' }, + meta: { className: 'max-w-36' }, cell: ({ row }) =>
{row.getValue('sort')}
, }, { 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
{formatDate(rawDate)}
@@ -39,7 +39,7 @@ export const createColumns = (): ColumnDef[] => { { 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
{formatDate(rawDate)}
@@ -51,7 +51,7 @@ export const createColumns = (): ColumnDef[] => { columns.push({ id: 'actions', enableHiding: false, - meta: { className: ' max-w-36 sticky right-0' }, + meta: { className: 'max-w-36 sticky right-0' }, cell: RowActions, }) } diff --git a/src/pages/blog/category/components/crud-provider.tsx b/src/pages/blog/category/components/crud-provider.tsx deleted file mode 100644 index cc254b2..0000000 --- a/src/pages/blog/category/components/crud-provider.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { createContext, useContext, useState, type ReactNode } from 'react' - -import { type Category } from '@/schemas' - -import type { Action } from '../constants' - -type CrudContextType = { - action: Action - setAction: (action: Action) => void - currentRow: T | null - setCurrentRow: (row: T | null) => void -} - -export const CrudContext = createContext | null>(null) - -export const CrudProvider = ({ children }: { children: ReactNode }) => { - const [action, setAction] = useState(null) - const [currentRow, setCurrentRow] = useState(null) - - return ( - {children} - ) -} - -export const useCrud = () => { - const context = useContext(CrudContext) - - if (!context) { - throw new Error('context must be used within a CrudProvider') - } - - return context -} diff --git a/src/pages/blog/category/components/row-actions.tsx b/src/pages/blog/category/components/row-actions.tsx index 7601322..dca0b61 100644 --- a/src/pages/blog/category/components/row-actions.tsx +++ b/src/pages/blog/category/components/row-actions.tsx @@ -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 diff --git a/src/pages/blog/category/constants.ts b/src/pages/blog/category/constants.ts index 65527b3..ea45ca0 100644 --- a/src/pages/blog/category/constants.ts +++ b/src/pages/blog/category/constants.ts @@ -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 + +export const { CrudProvider, useCrud } = createCrudProvider() diff --git a/src/pages/blog/category/index.tsx b/src/pages/blog/category/index.tsx index 9959537..dfcea7f 100644 --- a/src/pages/blog/category/index.tsx +++ b/src/pages/blog/category/index.tsx @@ -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({ diff --git a/src/pages/blog/post-edit/components/cover-generator.tsx b/src/pages/blog/post-edit/components/cover-generator.tsx new file mode 100644 index 0000000..d4dfea4 --- /dev/null +++ b/src/pages/blog/post-edit/components/cover-generator.tsx @@ -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(null) + const coverRef = useRef(null) + const [cover, setCover] = useState() + 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) => { + 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 ( + <> + + + + +
+ 封面生成器 +
+
+ + + 封面生成器 + +
+
+
+ {cover ? ( +
+ +
+ {text} +
+
+ ) : ( +
+ +
上传封面图
+
建议{ratio}
+
+ )} +
+
+ +