"use client"; import { Plus, RotateCcw, X } from "lucide-react"; import { useCallback, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { DEFAULT_ATTACHMENT_EXTS } from "@/lib/api"; import ConfirmDialog from "@/components/ConfirmDialog"; /** 分组一键加入(只补缺,不删已有) */ export const ATTACHMENT_EXT_GROUPS: { id: string; label: string; exts: string[] }[] = [ { id: "doc", label: "文档", exts: ["pdf", "txt", "md", "csv", "json", "doc", "docx", "xls", "xlsx", "ppt", "pptx"], }, { id: "archive", label: "压缩包", exts: ["zip", "7z", "rar", "tar", "gz", "tgz"], }, { id: "image", label: "图片", exts: ["jpg", "jpeg", "png", "webp", "gif"], }, { id: "media", label: "音视频", exts: ["mp3", "wav", "ogg", "mp4", "webm"], }, { id: "install", label: "安装包", exts: ["exe", "msi", "msp", "apk", "dmg", "iso", "deb", "rpm", "dll"], }, { id: "web", label: "网页与脚本", exts: ["html", "htm", "js", "mjs", "css", "svg", "xml", "bat", "cmd", "ps1", "sh"], }, ]; function parseInputTokens(raw: string): string[] { return raw .split(/[,,;\s]+/) .map((s) => s.trim().toLowerCase().replace(/^\./, "")) .filter(Boolean); } function isValidExt(e: string): boolean { return /^[a-z0-9]{1,16}$/.test(e); } /** * 附件扩展名芯片编辑器:增删、批量输入、分组补缺、恢复默认。 * 列表变更需显式保存(由父级 dirty + 保存按钮控制)。 */ export default function AdminExtChipEditor({ value, onChange, disabled, maxCount = 80, }: { value: string[]; onChange: (next: string[]) => void; disabled?: boolean; maxCount?: number; }) { const [draft, setDraft] = useState(""); const [hint, setHint] = useState(""); const [confirmReset, setConfirmReset] = useState(false); const inputRef = useRef(null); const setHintBrief = useCallback((msg: string) => { setHint(msg); window.setTimeout(() => setHint(""), 2200); }, []); const addExts = useCallback( (raw: string[]) => { if (disabled) return; const next = [...value]; const seen = new Set(next); let added = 0; let invalid = 0; for (const token of raw) { if (!isValidExt(token)) { invalid++; continue; } if (seen.has(token)) continue; if (next.length >= maxCount) { setHintBrief(`最多 ${maxCount} 个扩展名`); break; } seen.add(token); next.push(token); added++; } if (invalid && !added) setHintBrief("扩展名仅允许字母与数字"); else if (invalid) setHintBrief(`已加入 ${added} 个,跳过非法项`); else if (!added && raw.length) setHintBrief("已在列表中"); if (added) onChange(next); setDraft(""); }, [disabled, maxCount, onChange, setHintBrief, value] ); const removeAt = (idx: number) => { if (disabled) return; onChange(value.filter((_, i) => i !== idx)); }; const mergeGroup = (exts: string[]) => { if (disabled) return; const seen = new Set(value); const next = [...value]; for (const e of exts) { if (seen.has(e)) continue; if (next.length >= maxCount) break; seen.add(e); next.push(e); } onChange(next); }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === ",") { e.preventDefault(); addExts(parseInputTokens(draft)); return; } if (e.key === "Backspace" && draft === "" && value.length > 0) { e.preventDefault(); removeAt(value.length - 1); } }; const emptyWarn = value.length === 0; const groupButtons = useMemo( () => ATTACHMENT_EXT_GROUPS.map((g) => ( )), // eslint-disable-next-line react-hooks/exhaustive-deps [disabled, value] ); return (
{emptyWarn ? (

当前列表为空:开启限制时将无法上传任何附件

) : null}
{groupButtons}
inputRef.current?.focus()} role="group" aria-label="扩展名列表" > {value.map((ext, idx) => ( .{ext} ))} setDraft(e.target.value)} onKeyDown={onKeyDown} onBlur={() => { if (draft.trim()) addExts(parseInputTokens(draft)); }} />
{hint ? (

{hint}

) : (

支持 .msi / msi{" "} 或逗号批量;最多 {maxCount} 个

)} setConfirmReset(false)} onConfirm={() => { onChange([...DEFAULT_ATTACHMENT_EXTS]); setConfirmReset(false); }} />
); }