From 3e80e63f7dbae64d22377288a1251e789a12f8a0 Mon Sep 17 00:00:00 2001 From: fxbin Date: Mon, 19 Oct 2026 08:49:43 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat(admin):=20=E5=90=8E=E5=8F=B0=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=20token=20=E5=B1=82=E2=80=94=E2=80=94ink=20=E4=B8=89?= =?UTF-8?q?=E6=A1=A3=E4=B8=8E=E8=AF=AD=E4=B9=89=E8=89=B2=E5=AF=B9=E5=8F=AA?= =?UTF-8?q?=E5=8A=A0=E4=B8=8D=E6=94=B9=20(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit globals.css @theme 增量:--color-ink-900/800/700/500/300/200/100、 --color-admin-accent(指向品牌橙)、admin-danger/warn text+bg 对 (深底徽章 300 系文字+12% 透明底保证 4.5:1),外加骨架 shimmer 动画及 prefers-reduced-motion 静态降级。前台零波及。 --- frontend/src/app/globals.css | 52 ++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index b9c73a2a..b844b9b7 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -41,6 +41,29 @@ --color-page: #F7F7F8; --color-text: #1A1A2E; + /* ── 管理后台(工具面)token —— 只加不改,前台不受影响 ── + * 圆桌 2026-10-18(issue #121):后台走工具面路线——ink 纯墨深侧边栏、 + * 品牌橙为唯一点缀;语义色(danger/warn)与品牌色双通道隔离不互借。 + * ink 三档 + 200/100 浅档供浅底分隔与表头使用。 + */ + --color-ink-900: #1D1D20; + --color-ink-800: #2A2A2E; + --color-ink-700: #3F3F46; + --color-ink-500: #71717A; + --color-ink-300: #D4D4D8; + --color-ink-200: #E4E4E7; + --color-ink-100: #F4F4F5; + + /* 后台点缀色:指向品牌橙,语义上只承担「导航激活 / 主操作」。 */ + --color-admin-accent: #FF6B35; + + /* 深底徽章/警示:文字用 300 系亮色保证对 ink-900 的 4.5:1 对比度, + * 底为同色 12% 透明。与浅底语义色(red/amber 系)分属两个使用面。 */ + --color-admin-danger-text: #FCA5A5; + --color-admin-danger-bg: rgba(239, 68, 68, 0.12); + --color-admin-warn-text: #FCD34D; + --color-admin-warn-bg: rgba(217, 119, 6, 0.12); + --font-sans: var(--font-dm-sans), -apple-system, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; --font-mono: var(--font-dm-mono), ui-monospace, monospace; --font-serif-display: var(--font-display), "Noto Serif SC", "Songti SC", "STSong", "SimSun", serif; @@ -165,6 +188,35 @@ svg { background-clip: content-box; } +/* 骨架屏 shimmer:1.6s 循环;reduced-motion 时降级为静态块(见下方媒体查询) */ +@keyframes admin-shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +.admin-shimmer { + background-image: linear-gradient( + 90deg, + rgba(29, 29, 32, 0.06) 25%, + rgba(29, 29, 32, 0.1) 50%, + rgba(29, 29, 32, 0.06) 75% + ); + background-size: 200% 100%; + animation: admin-shimmer 1.6s linear infinite; +} + +@media (prefers-reduced-motion: reduce) { + .admin-shimmer { + animation: none; + background-image: none; + background-color: rgba(29, 29, 32, 0.06); + } +} + /* Radar ping animation */ @keyframes radar-ping { 0% { From 9d46816082be178afcee5d14b6f6ec5d3731a240 Mon Sep 17 00:00:00 2001 From: fxbin Date: Mon, 19 Oct 2026 08:49:53 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat(admin):=20=E6=95=B0=E6=8D=AE=E5=AF=86?= =?UTF-8?q?=E9=9B=86=E9=A1=B5=E4=BA=94=E5=8E=9F=E8=AF=AD+=E5=88=86?= =?UTF-8?q?=E9=A1=B5=E5=8D=87=E7=BA=A7+=E5=BC=B9=E7=AA=97=E8=84=8F?= =?UTF-8?q?=E5=85=B3=E9=97=AD=20(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit components/admin/ 新增 AdminTable(columns 定义式/三态内建/粘性操作 列/可选批量)、AdminFilters(防抖+chips+清空全部)、AdminEmptyState、 AdminTableSkeleton/BlockSkeleton、Toast(sonner,生产依赖)。 Pagination 升级跳页输入与每页条数(向后兼容)。AdminPageHeader 按 18px/600 降档,AdminModal 加 isDirty 内联脏关闭确认。 --- frontend/package-lock.json | 17 ++ frontend/package.json | 1 + frontend/src/components/Pagination.tsx | 70 ++++- frontend/src/components/admin-ui.tsx | 59 ++++- .../src/components/admin/AdminEmptyState.tsx | 50 ++++ .../src/components/admin/AdminFilters.tsx | 127 +++++++++ .../src/components/admin/AdminSkeleton.tsx | 70 +++++ frontend/src/components/admin/AdminTable.tsx | 245 ++++++++++++++++++ frontend/src/components/admin/admin-toast.tsx | 35 +++ frontend/src/components/admin/index.ts | 18 ++ 10 files changed, 684 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/admin/AdminEmptyState.tsx create mode 100644 frontend/src/components/admin/AdminFilters.tsx create mode 100644 frontend/src/components/admin/AdminSkeleton.tsx create mode 100644 frontend/src/components/admin/AdminTable.tsx create mode 100644 frontend/src/components/admin/admin-toast.tsx create mode 100644 frontend/src/components/admin/index.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f9364797..e4d9ec7b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "next": "^16.3.8", "react": "19.2.4", "react-dom": "19.2.4", + "sonner": "^2.0.8", "zustand": "^5.0.14" }, "devDependencies": { @@ -6555,6 +6556,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sonner": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz", + "integrity": "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/source-map-js": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index d94e660d..e82e99c2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "next": "^16.3.8", "react": "19.2.4", "react-dom": "19.2.4", + "sonner": "^2.0.8", "zustand": "^5.0.14" }, "devDependencies": { diff --git a/frontend/src/components/Pagination.tsx b/frontend/src/components/Pagination.tsx index aa919ffb..68e57a08 100644 --- a/frontend/src/components/Pagination.tsx +++ b/frontend/src/components/Pagination.tsx @@ -8,9 +8,12 @@ * `summary?: React.ReactNode` 用于在左侧渲染自定义统计文本 * (如「第 1 / 5 页,共 234 条」)。 * `onPage` 支持 updater 函数形式,兼容 `setPage(prev => ...)` 调用模式。 + * + * #121 后台改版升级(全部可选、向后兼容):总页数 > 5 时显示跳页输入; + * 传入 `pageSize` + `onPageSizeChange` 时显示每页条数选择器(10/20/50)。 */ -import React from 'react'; +import React, { useState } from 'react'; import { ChevronLeft, ChevronRight } from 'lucide-react'; import { Button, cx } from '@/components/ui'; @@ -36,19 +39,43 @@ function PageButton({ ); } +const PAGE_SIZE_OPTIONS = [10, 20, 50] as const; + export function Pagination({ page, totalPages, onPage, summary, + pageSize, + pageSizeOptions, + onPageSizeChange, className, }: { page: number; totalPages: number; onPage: (updater: number | ((page: number) => number)) => void; summary?: React.ReactNode; + /** 每页条数。与 onPageSizeChange 同时传入才显示条数选择器。 */ + pageSize?: number; + /** 可选的自定义档位,默认 10/20/50。 */ + pageSizeOptions?: readonly number[]; + onPageSizeChange?: (pageSize: number) => void; className?: string; }) { + const [jumpValue, setJumpValue] = useState(''); + const showJump = totalPages > 5; + const showPageSize = pageSize != null && onPageSizeChange != null; + const options = pageSizeOptions ?? PAGE_SIZE_OPTIONS; + + const jumpToPage = () => { + const target = Number.parseInt(jumpValue, 10); + if (Number.isFinite(target)) { + const clamped = Math.min(Math.max(1, target), Math.max(1, totalPages)); + onPage(clamped); + } + setJumpValue(''); + }; + // 计算最多 5 个页码窗口,始终围绕当前页 const pageNumbers = Array.from({ length: Math.min(5, totalPages) }, (_, index) => { if (totalPages <= 5) return index + 1; @@ -58,9 +85,27 @@ export function Pagination({ }); return ( -
+
{summary && {summary}} -
+
+ {showPageSize && ( + + )}
{pageNumbers.map((pageNumber) => { const active = page === pageNumber; @@ -81,6 +126,25 @@ export function Pagination({ ); })}
+ {showJump && ( +
+ 跳至 + setJumpValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') jumpToPage(); + }} + onBlur={() => setJumpValue('')} + aria-label={`跳转页码(1-${totalPages})`} + className="h-8 w-14 rounded-sm border border-gray-200 bg-white px-2 text-center text-[13px] text-gray-700 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> + 页 +
+ )}
onPage((current) => Math.max(1, current - 1))}> 上一页 diff --git a/frontend/src/components/admin-ui.tsx b/frontend/src/components/admin-ui.tsx index 77895700..fffeaf1c 100644 --- a/frontend/src/components/admin-ui.tsx +++ b/frontend/src/components/admin-ui.tsx @@ -92,12 +92,14 @@ export function AdminPageHeader({ return (
-

- {Icon && } + {/* #121 规格:标题 18px/600(原 26px font-black 与卡片标题拉差过大, + 扫读定位不了页面结构);icon 18px 跟随字号降档。 */} +

+ {Icon && } {title}

{description && ( -

{description}

+

{description}

)}

{actions &&
{actions}
} @@ -188,17 +190,39 @@ export function AdminModal({ children, onClose, maxWidth = 480, + isDirty = false, }: { title: string; children: React.ReactNode; onClose: () => void; maxWidth?: number; + /** + * 表单已脏时拦截关闭(#121 圆桌规范,H5 错误预防 / H3 紧急出口): + * 遮罩点击与 Esc 先弹内联确认条,「放弃修改」才真正 onClose。 + */ + isDirty?: boolean; }) { - const { dialogRef, onKeyDown } = useDialogFocus(true, onClose); + // 拦截链:requestClose → (isDirty ? 内联确认 : onClose)。useDialogFocus 的 + // Esc 走 requestClose,焦点管理不受确认条影响。 + const [confirming, setConfirming] = React.useState(false); + const requestClose = React.useCallback(() => { + if (isDirty) { + setConfirming(true); + } else { + onClose(); + } + }, [isDirty, onClose]); + + // 脏状态被外部清掉(如保存成功后父组件直接关闭)时同步撤掉确认条 + React.useEffect(() => { + if (!isDirty && confirming) setConfirming(false); + }, [isDirty, confirming]); + + const { dialogRef, onKeyDown } = useDialogFocus(true, requestClose); const titleId = React.useId(); return ( <> -