diff --git a/frontend/app/admin/users/UsersAdmin.tsx b/frontend/app/admin/users/UsersAdmin.tsx index e9ba07e..17a4398 100644 --- a/frontend/app/admin/users/UsersAdmin.tsx +++ b/frontend/app/admin/users/UsersAdmin.tsx @@ -38,6 +38,7 @@ import { toast } from "@/lib/toast"; import { realtime } from "@/lib/realtime"; import Avatar from "@/components/Avatar"; import RoleBadge from "@/components/RoleBadge"; +import Modal from "@/components/Modal"; export type UserFilter = "all" | "staff" | "banned"; @@ -848,15 +849,6 @@ function RoleAssignDialog({ } }, [target]); - useEffect(() => { - if (!target) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape" && !acting) onClose(); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [target, acting, onClose]); - if (!target) return null; const toggleBoard = (id: number) => { @@ -904,217 +896,198 @@ function RoleAssignDialog({ const saveLabel = willBan ? "保存并封禁" : willUnban ? "保存并解封" : "保存"; return ( -
- @{target.username}({target.nickname}) -
-- 角色决定后台入口与前台管理能力;封禁立即强制下线。登录历史、站长内容档案仍走列表其他入口。 -
- -- 管理角色 -
-- 授权板块 * -
- {boards.length === 0 ? ( -板块加载中…
- ) : ( -- {err} -
- )} -- 账号状态 -
-- 封禁后立即强制下线,无法登录、发帖与回复;已发布内容保留。角色变更与封禁将一并保存。 -
- )} -+ 管理角色 +
++ 授权板块 * +
+ {boards.length === 0 ? ( +板块加载中…
+ ) : ( ++ {err} +
+ )} ++ 账号状态 +
++ 封禁后立即强制下线,无法登录、发帖与回复;已发布内容保留。角色变更与封禁将一并保存。 +
+ )} +- @{target.username} · 共 {total} 条记录(含失败尝试) -
-{err}
- ) : logs.length === 0 ? ( -暂无登录记录
-- {log.ip || "未知 IP"} - - {log.success ? "成功" : "失败"} - -
-- {log.user_agent || "未知设备"} -
-{err}
+ ) : logs.length === 0 ? ( +暂无登录记录
++ {log.ip || "未知 IP"} + + {log.success ? "成功" : "失败"} + +
++ {log.user_agent || "未知设备"} +
+- 你的账号因违反社区规则已被管理员封禁,当前登录状态已失效,无法继续发布内容与互动。 - 如对处理结果有异议,请联系管理员核实。 -
+ ariaLabel="账号已被封禁" + maxWidthClass="max-w-[400px]" + footer={+ 你的账号因违反社区规则已被管理员封禁,当前登录状态已失效,无法继续发布内容与互动。 + 如对处理结果有异议,请联系管理员核实。 +
+ ); } diff --git a/frontend/components/AvatarCropModal.tsx b/frontend/components/AvatarCropModal.tsx index 26a1ac5..464ce64 100644 --- a/frontend/components/AvatarCropModal.tsx +++ b/frontend/components/AvatarCropModal.tsx @@ -4,13 +4,14 @@ import { useCallback, useEffect, useRef, useState } from "react"; import Cropper, { type Area } from "react-easy-crop"; import "react-easy-crop/react-easy-crop.css"; import { - X, UploadCloud, ZoomIn, Check, Loader2, Images, RotateCcw, Trash2, AlertTriangle, Crop, + UploadCloud, ZoomIn, Check, Loader2, Images, RotateCcw, Trash2, AlertTriangle, Crop, } from "lucide-react"; import { apiMyMedia, apiUploadAvatar, apiUseAvatar, apiDeleteAttachment, type MediaAttachment, } from "@/lib/api"; import { getCroppedAvatarWebp } from "@/lib/cropImage"; +import Modal from "@/components/Modal"; interface AvatarCropModalProps { open: boolean; @@ -77,16 +78,6 @@ export default function AvatarCropModal({ if (confirmTimer.current) clearTimeout(confirmTimer.current); }, []); - // ESC 关闭 - useEffect(() => { - if (!open) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape" && !busy) onClose(); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [open, busy, onClose]); - const onCropComplete = useCallback((_: Area, areaPixels: Area) => { setPixelCrop(areaPixels); }, []); @@ -242,38 +233,17 @@ export default function AvatarCropModal({ } }; - if (!open) return null; - return ( -- {message} -
-+ {message} +
+ ); } diff --git a/frontend/components/Modal.tsx b/frontend/components/Modal.tsx new file mode 100644 index 0000000..9557c35 --- /dev/null +++ b/frontend/components/Modal.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { useEffect, type ReactNode } from "react"; +import { X } from "lucide-react"; + +export type ModalProps = { + open: boolean; + title: ReactNode; + onClose: () => void; + children: ReactNode; + /** 底部操作区(取消/主按钮等) */ + footer?: ReactNode; + /** 标题旁辅助说明 */ + description?: ReactNode; + /** 面板最大宽度 class,默认 max-w-md */ + maxWidthClass?: string; + /** 面板额外 class(如 max-h / flex) */ + panelClassName?: string; + /** 点遮罩关闭;表单/确认默认 false 防误关 */ + dismissOnOverlay?: boolean; + /** busy 时禁用关闭与 ESC */ + busy?: boolean; + /** 叠层高度,确认框用 1100,普通 1000 */ + zIndexClass?: string; + /** 无障碍:dialog | alertdialog */ + role?: "dialog" | "alertdialog"; + ariaLabel?: string; + /** 自定义标题区(如带图标);提供时覆盖默认 title 行,仍渲染关闭钮除非 hideClose */ + header?: ReactNode; + hideClose?: boolean; + /** 是否响应 ESC;封禁告知等不可关场景设 false */ + closeOnEscape?: boolean; + /** 内容区额外 class */ + bodyClassName?: string; +}; + +/** + * 全站弹层壳:统一遮罩、面板、标题关闭钮与 ESC。 + * 主 CTA 请用 btn-primary;危险操作用 btn-danger;取消用 btn-line。 + */ +export default function Modal({ + open, + title, + onClose, + children, + footer, + description, + maxWidthClass = "max-w-md", + panelClassName = "", + dismissOnOverlay = false, + busy = false, + zIndexClass = "z-[1000]", + role = "dialog", + ariaLabel, + header, + hideClose = false, + closeOnEscape = true, + bodyClassName = "", +}: ModalProps) { + useEffect(() => { + if (!open || !closeOnEscape) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && !busy) onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, busy, onClose, closeOnEscape]); + + if (!open) return null; + + const label = + ariaLabel || (typeof title === "string" ? title : undefined); + + return ( +- 确定要删除这篇帖子吗?此操作不可恢复。 -
-