Files
jiang13-bbs/frontend/components/admin/AdminExtChipEditor.tsx
freefire 61bf9fb48c feat(attach): 附件可配置限额、流式落盘与分阶段上传
管理端可配类型/体积;上传改流式避免整文件进内存;发帖先选再传并拦截未上传提交;提高 Next proxy 体积上限。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 07:33:16 +08:00

245 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<HTMLInputElement>(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<HTMLInputElement>) => {
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) => (
<button
key={g.id}
type="button"
className="btn btn-line btn-sm !py-1 !px-2 text-[12px]"
disabled={disabled}
onClick={() => mergeGroup(g.exts)}
>
+ {g.label}
</button>
)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[disabled, value]
);
return (
<div className={`j13-ext-editor${disabled ? " is-disabled" : ""}`}>
{emptyWarn ? (
<p className="j13-ext-warn" role="status">
当前列表为空:开启限制时将无法上传任何附件
</p>
) : null}
<div className="j13-ext-groups" aria-label="分组一键加入">
{groupButtons}
<button
type="button"
className="btn btn-line btn-sm !py-1 !px-2 text-[12px] inline-flex items-center gap-1"
disabled={disabled}
onClick={() => setConfirmReset(true)}
>
<RotateCcw size={12} />
恢复默认
</button>
</div>
<div
className="j13-ext-wall"
onClick={() => inputRef.current?.focus()}
role="group"
aria-label="扩展名列表"
>
{value.map((ext, idx) => (
<span key={ext} className="j13-ext-chip">
.{ext}
<button
type="button"
className="j13-ext-chip-x"
disabled={disabled}
aria-label={`移除 ${ext}`}
onClick={(e) => {
e.stopPropagation();
removeAt(idx);
}}
>
<X size={12} />
</button>
</span>
))}
<span className="j13-ext-input-wrap">
<Plus size={14} className="opacity-40 shrink-0" aria-hidden />
<input
ref={inputRef}
type="text"
className="j13-ext-input"
value={draft}
disabled={disabled}
placeholder="输入扩展名,Enter 添加"
aria-label="添加扩展名"
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
onBlur={() => {
if (draft.trim()) addExts(parseInputTokens(draft));
}}
/>
</span>
</div>
{hint ? (
<p className="meta text-[12px] mt-1.5" role="status">
{hint}
</p>
) : (
<p className="meta text-[12px] mt-1.5">
支持 <code className="text-[11px]">.msi</code> / <code className="text-[11px]">msi</code>{" "}
或逗号批量;最多 {maxCount} 个
</p>
)}
<ConfirmDialog
open={confirmReset}
title="恢复默认扩展名"
message="将用站点默认列表覆盖当前编辑内容(需再点保存才会生效)。"
confirmLabel="恢复默认"
onCancel={() => setConfirmReset(false)}
onConfirm={() => {
onChange([...DEFAULT_ATTACHMENT_EXTS]);
setConfirmReset(false);
}}
/>
</div>
);
}