diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 166098c..42d858e 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -14,12 +14,16 @@ const axiosClient = axios.create({ timeout: 10000, }) -function isBrowserFormData(v: any) { +function isBrowserFormData(v: unknown): v is FormData { return typeof FormData !== 'undefined' && v instanceof FormData } // node form-data (form-data package) heuristic -function isNodeFormData(v: any) { - return v && typeof v.getHeaders === 'function' && typeof v.pipe === 'function' +function isNodeFormData(v: unknown): v is { getHeaders: (...args: unknown[]) => Record; pipe: (...args: unknown[]) => unknown } { + return Boolean( + v && + typeof (v as { getHeaders?: unknown }).getHeaders === 'function' && + typeof (v as { pipe?: unknown }).pipe === 'function' + ) } axiosClient.interceptors.request.use((config) => { diff --git a/web/src/app/console/layout.tsx b/web/src/app/console/layout.tsx index 312059c..fcbceee 100644 --- a/web/src/app/console/layout.tsx +++ b/web/src/app/console/layout.tsx @@ -32,7 +32,7 @@ export default function ConsoleLayout({ } else { setTitle("Title"); } - }, [pathname]) + }, [pathname, sideBarItems]); useEffect(() => { if (!user) { diff --git a/web/src/components/comment/comment-input.tsx b/web/src/components/comment/comment-input.tsx index 0eb20d1..9bae6f7 100644 --- a/web/src/components/comment/comment-input.tsx +++ b/web/src/components/comment/comment-input.tsx @@ -1,5 +1,4 @@ import { useToLogin, useToUserProfile } from "@/hooks/use-route"; -import { User } from "@/models/user"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { toast } from "sonner"; @@ -8,7 +7,7 @@ import { Textarea } from "@/components/ui/textarea"; import { Checkbox } from "@/components/ui/checkbox" import { Label } from "@/components/ui/label"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { getGravatarFromUser, getGravatarUrl } from "@/utils/common/gravatar"; +import { getGravatarFromUser } from "@/utils/common/gravatar"; import { getFirstCharFromUser } from "@/utils/common/username"; import { useAuth } from "@/contexts/auth-context"; diff --git a/web/src/components/comment/comment-item.tsx b/web/src/components/comment/comment-item.tsx index 5bd971b..8bfbc65 100644 --- a/web/src/components/comment/comment-item.tsx +++ b/web/src/components/comment/comment-item.tsx @@ -1,5 +1,4 @@ import { useToLogin, useToUserProfile } from "@/hooks/use-route"; -import { User } from "@/models/user"; import { useLocale, useTranslations } from "next-intl"; import { useState } from "react"; import { toast } from "sonner"; @@ -13,7 +12,7 @@ import { createComment, deleteComment, getComment, listComments, updateComment } import { OrderBy } from "@/models/common"; import { formatDateTime } from "@/utils/common/datetime"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { getGravatarFromUser, getGravatarUrl } from "@/utils/common/gravatar"; +import { getGravatarFromUser } from "@/utils/common/gravatar"; import { getFirstCharFromUser } from "@/utils/common/username"; import { useAuth } from "@/contexts/auth-context"; diff --git a/web/src/components/comment/index.tsx b/web/src/components/comment/index.tsx index 3fcc079..dc532a5 100644 --- a/web/src/components/comment/index.tsx +++ b/web/src/components/comment/index.tsx @@ -10,7 +10,6 @@ import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { CommentInput } from "./comment-input"; import { CommentItem } from "./comment-item"; -import { useAuth } from "@/contexts/auth-context"; import config from "@/config"; import "./style.css"; @@ -33,9 +32,6 @@ export function CommentSection( const [page, setPage] = useState(1); // 当前页码 const [totalCommentCount, setTotalCommentCount] = useState(totalCount); // 评论总数 const [needLoadMore, setNeedLoadMore] = useState(true); // 是否需要加载更多,当最后一次获取的评论数小于分页大小时设为false - - // 获取登录用户信息 - const {user} = useAuth(); // 加载0/顶层评论 useEffect(() => { listComments({ diff --git a/web/src/components/common/image-cropper.tsx b/web/src/components/common/image-cropper.tsx index 6278b3e..9e0bcfc 100644 --- a/web/src/components/common/image-cropper.tsx +++ b/web/src/components/common/image-cropper.tsx @@ -1,28 +1,232 @@ +"use client" import { Button } from "@/components/ui/button" import { Dialog, - DialogClose, DialogContent, - DialogDescription, DialogFooter, - DialogHeader, - DialogTitle, DialogTrigger, } from "@/components/ui/dialog" -import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" +import Image from "next/image" +import React, { useEffect, useRef, useState, useCallback } from "react"; +import ReactCrop, { Crop } from 'react-image-crop'; +import 'react-image-crop/dist/ReactCrop.css' + +export function ImageCropper({ + image, + onCropped, + onCancel, +}: { + image: File | Blob | null; + onCropped: (blob: Blob) => void; + onCancel?: () => void; +}) { + const [crop, setCrop] = useState>({ + unit: '%', + x: 25, + y: 25, + width: 50, + height: 50, + }) + + const [imageSrc, setImageSrc] = useState("") + const [open, setOpen] = useState(false) + const imgRef = useRef(null) + const objectUrlRef = useRef(null) + + useEffect(() => { + // 清理旧的 objectURL + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current) + objectUrlRef.current = null + } + + if (!image) { + setImageSrc("") + return + } + + if (typeof image === "string") { + setImageSrc(image) + return + } + + try { + const url = URL.createObjectURL(image as Blob) + objectUrlRef.current = url + setImageSrc(url) + } catch (err) { + console.error("createObjectURL failed", err) + setImageSrc("") + } + + return () => { + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current) + objectUrlRef.current = null + } + } + }, [image]) + + const onImageLoad = useCallback((e: React.SyntheticEvent) => { + imgRef.current = e.currentTarget + }, []) + + const getCroppedBlob = useCallback(async (): Promise => { + const img = imgRef.current + if (!img || !crop) return null + + // 计算渲染像素上的裁剪区域(支持 '%' 或 'px') + const unitIsPercent = (crop.unit ?? '%') === '%' + const renderWidth = img.width + const renderHeight = img.height + + const sxRender = unitIsPercent ? ((crop.x ?? 0) / 100) * renderWidth : (crop.x ?? 0) + const syRender = unitIsPercent ? ((crop.y ?? 0) / 100) * renderHeight : (crop.y ?? 0) + const swRender = unitIsPercent ? ((crop.width ?? 0) / 100) * renderWidth : (crop.width ?? 0) + const shRender = unitIsPercent ? ((crop.height ?? 0) / 100) * renderHeight : (crop.height ?? 0) + + // 把渲染像素坐标映射到原始图片像素(naturalWidth/naturalHeight) + const scaleX = img.naturalWidth / renderWidth + const scaleY = img.naturalHeight / renderHeight + const sx = Math.round(sxRender * scaleX) + const sy = Math.round(syRender * scaleY) + const sw = Math.max(1, Math.round(swRender * scaleX)) + const sh = Math.max(1, Math.round(shRender * scaleY)) + + const canvas = document.createElement('canvas') + canvas.width = sw + canvas.height = sh + const ctx = canvas.getContext('2d') + if (!ctx) return null + ctx.clearRect(0, 0, sw, sh) + ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh) + + return await new Promise((resolve) => { + canvas.toBlob((b) => resolve(b), 'image/png', 0.95) + }) + }, [crop]) + + const handleClose = () => { + setOpen(false) + } -export function ImageCropper({ image, onCropped, onCancel }: { image: File, onCropped: (blob: Blob) => void, onCancel: () => void }) { return ( - -
+ { + setOpen(isOpen) + if (!isOpen && onCancel) onCancel() + }}> + e.preventDefault()}> - + - - + {image && ( + +

裁剪图片

+
+ {imageSrc ? ( + setCrop(c)} + // 保持正方形,可按需移除 + aspect={1} + > + {/* 必须用原生 img 元素 */} + source + + ) : ( +
没有待裁剪图片
+ )} +
+ +
+
+ +
+ {/* 临时预览:把裁剪结果画到 canvas 并显示 */} + +
+
+
+ +
+ 建议上传正方形头像;裁剪将导出 PNG 文件。 +
+
+
+ + + + + +
+ )}
) } + +// 简单的预览 canvas 组件(显示当前裁剪区域) +function PreviewCanvas({ crop, imgRef }: { crop: Partial, imgRef: React.RefObject }) { + const canvasRef = useRef(null) + + useEffect(() => { + const img = imgRef.current + const canvas = canvasRef.current + if (!img || !canvas || !crop) return + + const unitIsPercent = (crop.unit ?? '%') === '%' + const renderWidth = img.width + const renderHeight = img.height + + const sxRender = unitIsPercent ? ((crop.x ?? 0) / 100) * renderWidth : (crop.x ?? 0) + const syRender = unitIsPercent ? ((crop.y ?? 0) / 100) * renderHeight : (crop.y ?? 0) + const swRender = unitIsPercent ? ((crop.width ?? 0) / 100) * renderWidth : (crop.width ?? 0) + const shRender = unitIsPercent ? ((crop.height ?? 0) / 100) * renderHeight : (crop.height ?? 0) + + const scaleX = img.naturalWidth / renderWidth + const scaleY = img.naturalHeight / renderHeight + const sx = Math.round(sxRender * scaleX) + const sy = Math.round(syRender * scaleY) + const sw = Math.max(1, Math.round(swRender * scaleX)) + const sh = Math.max(1, Math.round(shRender * scaleY)) + + // 画到 preview canvas(缩放以适应 128x128) + const outW = 128 + const outH = Math.max(1, Math.round((sh / sw) * outW)) + canvas.width = outW + canvas.height = outH + const ctx = canvas.getContext('2d') + if (!ctx) return + ctx.clearRect(0, 0, outW, outH) + // drawImage 使用 natural pixel 区域 + ctx.drawImage(img, sx, sy, sw, sh, 0, 0, outW, outH) + }, [crop, imgRef]) + + return +} \ No newline at end of file diff --git a/web/src/components/console/chart-area-interactive.tsx b/web/src/components/console/chart-area-interactive.tsx deleted file mode 100644 index 5b475ea..0000000 --- a/web/src/components/console/chart-area-interactive.tsx +++ /dev/null @@ -1,291 +0,0 @@ -"use client" - -import * as React from "react" -import { Area, AreaChart, CartesianGrid, XAxis } from "recharts" - -import { useIsMobile } from "@/hooks/use-mobile" -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card" -import { - ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from "@/components/ui/chart" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { - ToggleGroup, - ToggleGroupItem, -} from "@/components/ui/toggle-group" - -export const description = "An interactive area chart" - -const chartData = [ - { date: "2024-04-01", desktop: 222, mobile: 150 }, - { date: "2024-04-02", desktop: 97, mobile: 180 }, - { date: "2024-04-03", desktop: 167, mobile: 120 }, - { date: "2024-04-04", desktop: 242, mobile: 260 }, - { date: "2024-04-05", desktop: 373, mobile: 290 }, - { date: "2024-04-06", desktop: 301, mobile: 340 }, - { date: "2024-04-07", desktop: 245, mobile: 180 }, - { date: "2024-04-08", desktop: 409, mobile: 320 }, - { date: "2024-04-09", desktop: 59, mobile: 110 }, - { date: "2024-04-10", desktop: 261, mobile: 190 }, - { date: "2024-04-11", desktop: 327, mobile: 350 }, - { date: "2024-04-12", desktop: 292, mobile: 210 }, - { date: "2024-04-13", desktop: 342, mobile: 380 }, - { date: "2024-04-14", desktop: 137, mobile: 220 }, - { date: "2024-04-15", desktop: 120, mobile: 170 }, - { date: "2024-04-16", desktop: 138, mobile: 190 }, - { date: "2024-04-17", desktop: 446, mobile: 360 }, - { date: "2024-04-18", desktop: 364, mobile: 410 }, - { date: "2024-04-19", desktop: 243, mobile: 180 }, - { date: "2024-04-20", desktop: 89, mobile: 150 }, - { date: "2024-04-21", desktop: 137, mobile: 200 }, - { date: "2024-04-22", desktop: 224, mobile: 170 }, - { date: "2024-04-23", desktop: 138, mobile: 230 }, - { date: "2024-04-24", desktop: 387, mobile: 290 }, - { date: "2024-04-25", desktop: 215, mobile: 250 }, - { date: "2024-04-26", desktop: 75, mobile: 130 }, - { date: "2024-04-27", desktop: 383, mobile: 420 }, - { date: "2024-04-28", desktop: 122, mobile: 180 }, - { date: "2024-04-29", desktop: 315, mobile: 240 }, - { date: "2024-04-30", desktop: 454, mobile: 380 }, - { date: "2024-05-01", desktop: 165, mobile: 220 }, - { date: "2024-05-02", desktop: 293, mobile: 310 }, - { date: "2024-05-03", desktop: 247, mobile: 190 }, - { date: "2024-05-04", desktop: 385, mobile: 420 }, - { date: "2024-05-05", desktop: 481, mobile: 390 }, - { date: "2024-05-06", desktop: 498, mobile: 520 }, - { date: "2024-05-07", desktop: 388, mobile: 300 }, - { date: "2024-05-08", desktop: 149, mobile: 210 }, - { date: "2024-05-09", desktop: 227, mobile: 180 }, - { date: "2024-05-10", desktop: 293, mobile: 330 }, - { date: "2024-05-11", desktop: 335, mobile: 270 }, - { date: "2024-05-12", desktop: 197, mobile: 240 }, - { date: "2024-05-13", desktop: 197, mobile: 160 }, - { date: "2024-05-14", desktop: 448, mobile: 490 }, - { date: "2024-05-15", desktop: 473, mobile: 380 }, - { date: "2024-05-16", desktop: 338, mobile: 400 }, - { date: "2024-05-17", desktop: 499, mobile: 420 }, - { date: "2024-05-18", desktop: 315, mobile: 350 }, - { date: "2024-05-19", desktop: 235, mobile: 180 }, - { date: "2024-05-20", desktop: 177, mobile: 230 }, - { date: "2024-05-21", desktop: 82, mobile: 140 }, - { date: "2024-05-22", desktop: 81, mobile: 120 }, - { date: "2024-05-23", desktop: 252, mobile: 290 }, - { date: "2024-05-24", desktop: 294, mobile: 220 }, - { date: "2024-05-25", desktop: 201, mobile: 250 }, - { date: "2024-05-26", desktop: 213, mobile: 170 }, - { date: "2024-05-27", desktop: 420, mobile: 460 }, - { date: "2024-05-28", desktop: 233, mobile: 190 }, - { date: "2024-05-29", desktop: 78, mobile: 130 }, - { date: "2024-05-30", desktop: 340, mobile: 280 }, - { date: "2024-05-31", desktop: 178, mobile: 230 }, - { date: "2024-06-01", desktop: 178, mobile: 200 }, - { date: "2024-06-02", desktop: 470, mobile: 410 }, - { date: "2024-06-03", desktop: 103, mobile: 160 }, - { date: "2024-06-04", desktop: 439, mobile: 380 }, - { date: "2024-06-05", desktop: 88, mobile: 140 }, - { date: "2024-06-06", desktop: 294, mobile: 250 }, - { date: "2024-06-07", desktop: 323, mobile: 370 }, - { date: "2024-06-08", desktop: 385, mobile: 320 }, - { date: "2024-06-09", desktop: 438, mobile: 480 }, - { date: "2024-06-10", desktop: 155, mobile: 200 }, - { date: "2024-06-11", desktop: 92, mobile: 150 }, - { date: "2024-06-12", desktop: 492, mobile: 420 }, - { date: "2024-06-13", desktop: 81, mobile: 130 }, - { date: "2024-06-14", desktop: 426, mobile: 380 }, - { date: "2024-06-15", desktop: 307, mobile: 350 }, - { date: "2024-06-16", desktop: 371, mobile: 310 }, - { date: "2024-06-17", desktop: 475, mobile: 520 }, - { date: "2024-06-18", desktop: 107, mobile: 170 }, - { date: "2024-06-19", desktop: 341, mobile: 290 }, - { date: "2024-06-20", desktop: 408, mobile: 450 }, - { date: "2024-06-21", desktop: 169, mobile: 210 }, - { date: "2024-06-22", desktop: 317, mobile: 270 }, - { date: "2024-06-23", desktop: 480, mobile: 530 }, - { date: "2024-06-24", desktop: 132, mobile: 180 }, - { date: "2024-06-25", desktop: 141, mobile: 190 }, - { date: "2024-06-26", desktop: 434, mobile: 380 }, - { date: "2024-06-27", desktop: 448, mobile: 490 }, - { date: "2024-06-28", desktop: 149, mobile: 200 }, - { date: "2024-06-29", desktop: 103, mobile: 160 }, - { date: "2024-06-30", desktop: 446, mobile: 400 }, -] - -const chartConfig = { - visitors: { - label: "Visitors", - }, - desktop: { - label: "Desktop", - color: "var(--primary)", - }, - mobile: { - label: "Mobile", - color: "var(--primary)", - }, -} satisfies ChartConfig - -export function ChartAreaInteractive() { - const isMobile = useIsMobile() - const [timeRange, setTimeRange] = React.useState("90d") - - React.useEffect(() => { - if (isMobile) { - setTimeRange("7d") - } - }, [isMobile]) - - const filteredData = chartData.filter((item) => { - const date = new Date(item.date) - const referenceDate = new Date("2024-06-30") - let daysToSubtract = 90 - if (timeRange === "30d") { - daysToSubtract = 30 - } else if (timeRange === "7d") { - daysToSubtract = 7 - } - const startDate = new Date(referenceDate) - startDate.setDate(startDate.getDate() - daysToSubtract) - return date >= startDate - }) - - return ( - - - Total Visitors - - - Total for the last 3 months - - Last 3 months - - - - Last 3 months - Last 30 days - Last 7 days - - - - - - - - - - - - - - - - - - - { - const date = new Date(value) - return date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }) - }} - /> - { - return new Date(value).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }) - }} - indicator="dot" - /> - } - /> - - - - - - - ) -} diff --git a/web/src/components/console/data-table.tsx b/web/src/components/console/data-table.tsx deleted file mode 100644 index 4834681..0000000 --- a/web/src/components/console/data-table.tsx +++ /dev/null @@ -1,807 +0,0 @@ -"use client" - -import * as React from "react" -import { - closestCenter, - DndContext, - KeyboardSensor, - MouseSensor, - TouchSensor, - useSensor, - useSensors, - type DragEndEvent, - type UniqueIdentifier, -} from "@dnd-kit/core" -import { restrictToVerticalAxis } from "@dnd-kit/modifiers" -import { - arrayMove, - SortableContext, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable" -import { CSS } from "@dnd-kit/utilities" -import { - IconChevronDown, - IconChevronLeft, - IconChevronRight, - IconChevronsLeft, - IconChevronsRight, - IconCircleCheckFilled, - IconDotsVertical, - IconGripVertical, - IconLayoutColumns, - IconLoader, - IconPlus, - IconTrendingUp, -} from "@tabler/icons-react" -import { - ColumnDef, - ColumnFiltersState, - flexRender, - getCoreRowModel, - getFacetedRowModel, - getFacetedUniqueValues, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - Row, - SortingState, - useReactTable, - VisibilityState, -} from "@tanstack/react-table" -import { Area, AreaChart, CartesianGrid, XAxis } from "recharts" -import { toast } from "sonner" -import { z } from "zod" - -import { useIsMobile } from "@/hooks/use-mobile" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { - ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from "@/components/ui/chart" -import { Checkbox } from "@/components/ui/checkbox" -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from "@/components/ui/drawer" -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { Separator } from "@/components/ui/separator" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "@/components/ui/tabs" - -export const schema = z.object({ - id: z.number(), - header: z.string(), - type: z.string(), - status: z.string(), - target: z.string(), - limit: z.string(), - reviewer: z.string(), -}) - -// Create a separate component for the drag handle -function DragHandle({ id }: { id: number }) { - const { attributes, listeners } = useSortable({ - id, - }) - - return ( - - ) -} - -const columns: ColumnDef>[] = [ - { - id: "drag", - header: () => null, - cell: ({ row }) => , - }, - { - id: "select", - header: ({ table }) => ( -
- table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - /> -
- ), - cell: ({ row }) => ( -
- row.toggleSelected(!!value)} - aria-label="Select row" - /> -
- ), - enableSorting: false, - enableHiding: false, - }, - { - accessorKey: "header", - header: "Header", - cell: ({ row }) => { - return - }, - enableHiding: false, - }, - { - accessorKey: "type", - header: "Section Type", - cell: ({ row }) => ( -
- - {row.original.type} - -
- ), - }, - { - accessorKey: "status", - header: "Status", - cell: ({ row }) => ( - - {row.original.status === "Done" ? ( - - ) : ( - - )} - {row.original.status} - - ), - }, - { - accessorKey: "target", - header: () =>
Target
, - cell: ({ row }) => ( -
{ - e.preventDefault() - toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), { - loading: `Saving ${row.original.header}`, - success: "Done", - error: "Error", - }) - }} - > - - -
- ), - }, - { - accessorKey: "limit", - header: () =>
Limit
, - cell: ({ row }) => ( -
{ - e.preventDefault() - toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), { - loading: `Saving ${row.original.header}`, - success: "Done", - error: "Error", - }) - }} - > - - -
- ), - }, - { - accessorKey: "reviewer", - header: "Reviewer", - cell: ({ row }) => { - const isAssigned = row.original.reviewer !== "Assign reviewer" - - if (isAssigned) { - return row.original.reviewer - } - - return ( - <> - - - - ) - }, - }, - { - id: "actions", - cell: () => ( - - - - - - Edit - Make a copy - Favorite - - Delete - - - ), - }, -] - -function DraggableRow({ row }: { row: Row> }) { - const { transform, transition, setNodeRef, isDragging } = useSortable({ - id: row.original.id, - }) - - return ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ) -} - -export function DataTable({ - data: initialData, -}: { - data: z.infer[] -}) { - const [data, setData] = React.useState(() => initialData) - const [rowSelection, setRowSelection] = React.useState({}) - const [columnVisibility, setColumnVisibility] = - React.useState({}) - const [columnFilters, setColumnFilters] = React.useState( - [] - ) - const [sorting, setSorting] = React.useState([]) - const [pagination, setPagination] = React.useState({ - pageIndex: 0, - pageSize: 10, - }) - const sortableId = React.useId() - const sensors = useSensors( - useSensor(MouseSensor, {}), - useSensor(TouchSensor, {}), - useSensor(KeyboardSensor, {}) - ) - - const dataIds = React.useMemo( - () => data?.map(({ id }) => id) || [], - [data] - ) - - const table = useReactTable({ - data, - columns, - state: { - sorting, - columnVisibility, - rowSelection, - columnFilters, - pagination, - }, - getRowId: (row) => row.id.toString(), - enableRowSelection: true, - onRowSelectionChange: setRowSelection, - onSortingChange: setSorting, - onColumnFiltersChange: setColumnFilters, - onColumnVisibilityChange: setColumnVisibility, - onPaginationChange: setPagination, - getCoreRowModel: getCoreRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - getFacetedRowModel: getFacetedRowModel(), - getFacetedUniqueValues: getFacetedUniqueValues(), - }) - - function handleDragEnd(event: DragEndEvent) { - const { active, over } = event - if (active && over && active.id !== over.id) { - setData((data) => { - const oldIndex = dataIds.indexOf(active.id) - const newIndex = dataIds.indexOf(over.id) - return arrayMove(data, oldIndex, newIndex) - }) - } - } - - return ( - -
- - - - Outline - - Past Performance 3 - - - Key Personnel 2 - - Focus Documents - -
- - - - - - {table - .getAllColumns() - .filter( - (column) => - typeof column.accessorFn !== "undefined" && - column.getCanHide() - ) - .map((column) => { - return ( - - column.toggleVisibility(!!value) - } - > - {column.id} - - ) - })} - - - -
-
- -
- - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - - ) - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - - {table.getRowModel().rows.map((row) => ( - - ))} - - ) : ( - - - No results. - - - )} - -
-
-
-
-
- {table.getFilteredSelectedRowModel().rows.length} of{" "} - {table.getFilteredRowModel().rows.length} row(s) selected. -
-
-
- - -
-
- Page {table.getState().pagination.pageIndex + 1} of{" "} - {table.getPageCount()} -
-
- - - - -
-
-
-
- -
-
- -
-
- -
-
-
- ) -} - -const chartData = [ - { month: "January", desktop: 186, mobile: 80 }, - { month: "February", desktop: 305, mobile: 200 }, - { month: "March", desktop: 237, mobile: 120 }, - { month: "April", desktop: 73, mobile: 190 }, - { month: "May", desktop: 209, mobile: 130 }, - { month: "June", desktop: 214, mobile: 140 }, -] - -const chartConfig = { - desktop: { - label: "Desktop", - color: "var(--primary)", - }, - mobile: { - label: "Mobile", - color: "var(--primary)", - }, -} satisfies ChartConfig - -function TableCellViewer({ item }: { item: z.infer }) { - const isMobile = useIsMobile() - - return ( - - - - - - - {item.header} - - Showing total visitors for the last 6 months - - -
- {!isMobile && ( - <> - - - - value.slice(0, 3)} - hide - /> - } - /> - - - - - -
-
- Trending up by 5.2% this month{" "} - -
-
- Showing total visitors for the last 6 months. This is just - some random text to test the layout. It spans multiple lines - and should wrap around. -
-
- - - )} -
-
- - -
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
- - -
-
-
- - - - - - -
-
- ) -} diff --git a/web/src/components/console/data.ts b/web/src/components/console/data.ts index 6ef678c..4a92d21 100644 --- a/web/src/components/console/data.ts +++ b/web/src/components/console/data.ts @@ -1,11 +1,13 @@ import type { User } from "@/models/user"; +import { IconType } from "@/types/icon"; import { isAdmin, isEditor } from "@/utils/common/permission"; import { Folder, Gauge, MessageCircle, Newspaper, Palette, Settings, ShieldCheck, UserPen, Users } from "lucide-react"; + export interface SidebarItem { title: string; url: string; - icon: React.ComponentType; + icon: IconType; permission: ({ user }: { user: User }) => boolean; } diff --git a/web/src/components/console/nav-main.tsx b/web/src/components/console/nav-main.tsx index 05ca650..a152f2d 100644 --- a/web/src/components/console/nav-main.tsx +++ b/web/src/components/console/nav-main.tsx @@ -9,11 +9,10 @@ import { SidebarMenuItem, } from "@/components/ui/sidebar" import Link from "next/link" -import type { LucideProps } from "lucide-react"; -import { ComponentType, SVGProps } from "react" import { usePathname } from "next/navigation"; import { User } from "@/models/user"; import { useAuth } from "@/contexts/auth-context"; +import { IconType } from "@/types/icon"; export function NavMain({ items, @@ -21,7 +20,7 @@ export function NavMain({ items: { title: string url: string - icon?: ComponentType & LucideProps>; + icon?: IconType; permission: ({ user }: { user: User }) => boolean }[] }) { diff --git a/web/src/components/console/nav-secondary.tsx b/web/src/components/console/nav-secondary.tsx deleted file mode 100644 index 3f3636f..0000000 --- a/web/src/components/console/nav-secondary.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client" - -import * as React from "react" -import { type Icon } from "@tabler/icons-react" - -import { - SidebarGroup, - SidebarGroupContent, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, -} from "@/components/ui/sidebar" - -export function NavSecondary({ - items, - ...props -}: { - items: { - title: string - url: string - icon: Icon - }[] -} & React.ComponentPropsWithoutRef) { - return ( - - - - {items.map((item) => ( - - - - - {item.title} - - - - ))} - - - - ) -} diff --git a/web/src/components/console/nav-ucenter.tsx b/web/src/components/console/nav-ucenter.tsx index b7769b1..d214569 100644 --- a/web/src/components/console/nav-ucenter.tsx +++ b/web/src/components/console/nav-ucenter.tsx @@ -1,35 +1,18 @@ "use client" -import { - IconDots, - IconFolder, - IconShare3, - IconTrash, - type Icon, -} from "@tabler/icons-react" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" import { SidebarGroup, SidebarGroupLabel, SidebarMenu, - SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, - useSidebar, } from "@/components/ui/sidebar" -import { ComponentType, SVGProps } from "react" -import { LucideProps } from "lucide-react" import { User } from "@/models/user" import Link from "next/link" import { usePathname } from "next/navigation" import { useAuth } from "@/contexts/auth-context" +import { IconType } from "@/types/icon" export function NavUserCenter({ items, @@ -37,11 +20,10 @@ export function NavUserCenter({ items: { title: string url: string - icon?: ComponentType & LucideProps>; + icon?: IconType; permission: ({ user }: { user: User }) => boolean }[] }) { - const { isMobile } = useSidebar() const { user } = useAuth(); const pathname = usePathname() ?? "/" diff --git a/web/src/components/console/nav-user.tsx b/web/src/components/console/nav-user.tsx index 0548a97..dd8c1af 100644 --- a/web/src/components/console/nav-user.tsx +++ b/web/src/components/console/nav-user.tsx @@ -35,7 +35,7 @@ import { useAuth } from "@/contexts/auth-context" import { userLogout } from "@/api/user" import { toast } from "sonner" -export function NavUser({ }: {}) { +export function NavUser() { const { isMobile } = useSidebar() const { user } = useAuth(); diff --git a/web/src/components/console/section-cards.tsx b/web/src/components/console/section-cards.tsx deleted file mode 100644 index f714d25..0000000 --- a/web/src/components/console/section-cards.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-react" - -import { Badge } from "@/components/ui/badge" -import { - Card, - CardAction, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card" - -export function SectionCards() { - return ( -
- - - Total Revenue - - $1,250.00 - - - - - +12.5% - - - - -
- Trending up this month -
-
- Visitors for the last 6 months -
-
-
- - - New Customers - - 1,234 - - - - - -20% - - - - -
- Down 20% this period -
-
- Acquisition needs attention -
-
-
- - - Active Accounts - - 45,678 - - - - - +12.5% - - - - -
- Strong user retention -
-
Engagement exceed targets
-
-
- - - Growth Rate - - 4.5% - - - - - +4.5% - - - - -
- Steady performance increase -
-
Meets growth projections
-
-
-
- ) -} diff --git a/web/src/components/console/user-profile/index.tsx b/web/src/components/console/user-profile/index.tsx index 4414efe..d0fa3c0 100644 --- a/web/src/components/console/user-profile/index.tsx +++ b/web/src/components/console/user-profile/index.tsx @@ -14,6 +14,7 @@ import { getFallbackAvatarFromUsername } from "@/utils/common/username"; import { useEffect, useState } from "react"; import { toast } from "sonner"; + interface UploadConstraints { allowedTypes: string[]; maxSize: number; @@ -25,24 +26,28 @@ interface PictureInputChangeEvent { export function UserProfilePage() { const { user } = useAuth(); - if (!user) return null + - const [nickname, setNickname] = useState(user.nickname || '') - const [username, setUsername] = useState(user.username || '') - const [avatarUrl, setAvatarUrl] = useState(user.avatarUrl || '') + const [nickname, setNickname] = useState(user?.nickname || '') + const [username, setUsername] = useState(user?.username || '') const [avatarFile, setAvatarFile] = useState(null) - const [gender, setGender] = useState(user.gender || '') + const [avatarFileUrl, setAvatarFileUrl] = useState(null) // 这部分交由useEffect控制,监听 avatarFile 变化 + const [gender, setGender] = useState(user?.gender || '') + useEffect(() => { - // if (!avatarFile) return - // uploadFile({ file: avatarFile! }).then(res => { - // setAvatarUrl(getFileUri(res.data.id)) - // toast.success('Avatar uploaded successfully') - // }).catch(err => { - // console.log(err) - // toast.error(`Error: ${err?.response?.data?.message || err.message || 'Failed to upload avatar'}`) - // }) - }, [avatarFile]) + if (!user) return; + if (!avatarFile) { + setAvatarFileUrl(getGravatarFromUser({ user })); + return; + } + const url = URL.createObjectURL(avatarFile); + setAvatarFileUrl(url); + return () => { + URL.revokeObjectURL(url); + setAvatarFileUrl(getGravatarFromUser({ user })); + }; + }, [avatarFile, user]); const handlePictureSelected = (e: PictureInputChangeEvent): void => { const file: File | null = e.target.files?.[0] ?? null; @@ -68,27 +73,63 @@ export function UserProfilePage() { } const handleSubmit = () => { - if (nickname.trim() === '' || username.trim() === '') { + if (!user) return; + if ( + nickname.trim() === '' || + username.trim() === '' + ) { toast.error('Nickname and Username cannot be empty') return } - if ((username.length < 3 || username.length > 20) || (nickname.length < 1 || nickname.length > 20)) { + + if ( + (username.length < 3 || username.length > 20) || + (nickname.length < 1 || nickname.length > 20) + ) { toast.error('Nickname and Username must be between 3 and 20 characters') return } - if (username === user.username && nickname === user.nickname && avatarUrl === user.avatarUrl && gender === user.gender) { + + if ( + username === user.username && + nickname === user.nickname && + gender === user.gender && + avatarFile === null + ) { toast.warning('No changes made') return } - updateUser({ nickname, username, avatarUrl, gender, id: user.id }).then(res => { - toast.success('Profile updated successfully') - window.location.reload() - }).catch(err => { - console.log(err) - toast.error(`Error: ${err?.response.data?.message || err.message || 'Failed to update profile'}`) - }) + + let avatarUrl = user.avatarUrl; + (async () => { + if (avatarFile) { + try { + const resp = await uploadFile({ file: avatarFile }); + avatarUrl = getFileUri(resp.data.id); + console.log('Uploaded avatar, got URL:', avatarUrl); + } catch (error: unknown) { + toast.error(`Failed to upload avatar ${error}`); + return; + } + } + + try { + await updateUser({ nickname, username, avatarUrl, gender, id: user.id }); + toast.success('Profile updated successfully'); + window.location.reload(); + } catch (error: unknown) { + toast.error(`Failed to update profile ${error}`); + } + })(); } + const handleCropped = (blob: Blob) => { + const file = new File([blob], 'avatar.png', { type: blob.type }); + setAvatarFile(file); + } + + if (!user) return null + return (

@@ -98,26 +139,19 @@ export function UserProfilePage() {
- {!avatarFile && } - {avatarFile && } + {avatarFileUrl ? + : + } {getFallbackAvatarFromUsername(nickname || username)} -
- +
- setAvatarUrl(e.target.value)} - placeholder="若要用外链图像,请直接填写,不支持裁剪" - /> setNickname(e.target.value)} /> @@ -130,6 +164,6 @@ export function UserProfilePage() { ) } -export function PictureEditor({}){ +export function PictureEditor({ }) { } \ No newline at end of file diff --git a/web/src/components/console/user-security/index.tsx b/web/src/components/console/user-security/index.tsx index b0941fd..ddf240d 100644 --- a/web/src/components/console/user-security/index.tsx +++ b/web/src/components/console/user-security/index.tsx @@ -10,7 +10,7 @@ import { } from "@/components/ui/input-otp" import { useEffect, useState } from "react"; -const VERIFY_CODE_COOL_DOWN = 60; // seconds +// const VERIFY_CODE_COOL_DOWN = 60; // seconds export function UserSecurityPage() { const [email, setEmail] = useState("") diff --git a/web/src/types/icon.ts b/web/src/types/icon.ts new file mode 100644 index 0000000..6e6fe71 --- /dev/null +++ b/web/src/types/icon.ts @@ -0,0 +1,4 @@ +import { LucideProps } from "lucide-react"; +import { ComponentType, SVGProps } from "react"; + +export type IconType = ComponentType & LucideProps> \ No newline at end of file