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 0000000..fe9a387 Binary files /dev/null and b/src/assets/fonts/NotoSansAvestan-Regular.ttf differ diff --git a/src/assets/no-pic.svg b/src/assets/no-pic.svg new file mode 100644 index 0000000..19db508 --- /dev/null +++ b/src/assets/no-pic.svg @@ -0,0 +1 @@ + \ 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}
+
+ )} +
+
+ +