管理端可配类型/体积;上传改流式避免整文件进内存;发帖先选再传并拦截未上传提交;提高 Next proxy 体积上限。 Co-authored-by: Cursor <cursoragent@cursor.com>
245 lines
6.8 KiB
TypeScript
245 lines
6.8 KiB
TypeScript
"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>
|
||
);
|
||
}
|