feat(attach): 附件可配置限额、流式落盘与分阶段上传
管理端可配类型/体积;上传改流式避免整文件进内存;发帖先选再传并拦截未上传提交;提高 Next proxy 体积上限。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
244
frontend/components/admin/AdminExtChipEditor.tsx
Normal file
244
frontend/components/admin/AdminExtChipEditor.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -14,3 +14,5 @@ export { default as AdminIconButton } from "./AdminIconButton";
|
||||
export { default as AdminStatusChip, contentStatusChip } from "./AdminStatusChip";
|
||||
export type { AdminStatusTone } from "./AdminStatusChip";
|
||||
export { default as AdminShell } from "./AdminShell";
|
||||
export { default as AdminExtChipEditor } from "./AdminExtChipEditor";
|
||||
export { ATTACHMENT_EXT_GROUPS } from "./AdminExtChipEditor";
|
||||
|
||||
Reference in New Issue
Block a user