feat(attach): 附件可配置限额、流式落盘与分阶段上传

管理端可配类型/体积;上传改流式避免整文件进内存;发帖先选再传并拦截未上传提交;提高 Next proxy 体积上限。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 07:33:16 +08:00
parent 6cc434f103
commit 61bf9fb48c
24 changed files with 2513 additions and 307 deletions

View 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>
);
}

View File

@@ -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";