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

@@ -3,17 +3,16 @@
import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { X, Paperclip, Download } from "lucide-react";
import { X } from "lucide-react";
import {
apiCreatePost,
apiUpdatePost,
apiUploadPostFile,
apiDeletePostFile,
apiUpdatePostFilePrice,
apiGetPoints,
apiGetSettings,
fetchBoards,
type Board,
type PostAttachment,
DEFAULT_ATTACHMENT_EXTS,
} from "@/lib/api";
import {
POST_TYPES,
@@ -26,11 +25,15 @@ import {
import { toast } from "@/lib/toast";
import MarkdownEditor from "@/components/MarkdownEditor";
import BoardPicker, { BoardBadge } from "@/components/BoardPicker";
import ComposeAttachments, {
ComposeAttachAction,
} from "@/components/ComposeAttachments";
import DeadlinePicker, {
type DeadlineMode,
defaultCustomLocal,
localInputToRFC3339,
} from "@/components/DeadlinePicker";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
const TITLE_MAX = 256;
const DRAFT_KEY = "j13-compose-draft";
@@ -105,12 +108,6 @@ function writeDraft(d: ComposeDraft) {
}
}
function formatSize(n: number) {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
type CreateProps = {
mode: "create";
initialBoardId?: number;
@@ -260,6 +257,14 @@ export default function PostComposer(props: PostComposerProps) {
isCreate ? [] : props.initialAttachments || []
);
const [fileBusy, setFileBusy] = useState(false);
const [pendingFileCount, setPendingFileCount] = useState(0);
const [attachLimits, setAttachLimits] = useState({
maxMB: 20,
maxCount: 10,
extLimit: true,
exts: [...DEFAULT_ATTACHMENT_EXTS],
imageMaxMB: 5,
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
/** 发帖页余额:仅新建悬赏时拉取,用于就地提示积分不足 */
@@ -268,13 +273,55 @@ export default function PostComposer(props: PostComposerProps) {
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const skipDraftSave = useRef(true);
const formRef = useRef<HTMLFormElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const attachSectionRef = useRef<HTMLElement | null>(null);
const actionsErrorRef = useRef<HTMLDivElement>(null);
const bountyFieldRef = useRef<HTMLDivElement>(null);
const pendingErrorFocus = useRef<"actions" | "bounty" | null>(null);
const pendingErrorFocus = useRef<"actions" | "bounty" | "attachments" | null>(null);
const preferredBoardId = isCreate ? props.initialBoardId || 0 : 0;
useEffect(() => {
let cancelled = false;
apiGetSettings()
.then((s) => {
if (cancelled) return;
setAttachLimits({
maxMB: s.attachment_max_mb,
maxCount: s.attachment_max_count,
extLimit: s.attachment_ext_limit,
exts: s.attachment_exts,
imageMaxMB: s.image_max_mb,
});
})
.catch(() => {
/* 用默认限额 */
});
return realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
setAttachLimits((prev) => ({
maxMB:
typeof data.attachment_max_mb === "number" && data.attachment_max_mb >= 1
? data.attachment_max_mb
: prev.maxMB,
maxCount:
typeof data.attachment_max_count === "number" &&
data.attachment_max_count >= 1
? data.attachment_max_count
: prev.maxCount,
extLimit:
typeof data.attachment_ext_limit === "boolean"
? data.attachment_ext_limit
: prev.extLimit,
exts: Array.isArray(data.attachment_exts)
? data.attachment_exts
: prev.exts,
imageMaxMB:
typeof data.image_max_mb === "number" && data.image_max_mb >= 1
? data.image_max_mb
: prev.imageMaxMB,
}));
});
}, []);
useEffect(() => {
if (!isCreate) return;
let cancelled = false;
@@ -459,44 +506,28 @@ export default function PostComposer(props: PostComposerProps) {
return typeMeta.trim();
};
const onPickFile = async (file: File | null) => {
if (!file || fileBusy) return;
if (attachments.length >= 10) {
toast("最多 10 个附件", "error");
return;
}
setFileBusy(true);
try {
const res = await apiUploadPostFile(file, 0);
if (res.attachment) {
setAttachments((prev) => [...prev, res.attachment!]);
toast("附件已上传", "ok");
} else {
toast(res.error || "上传失败", "error");
}
} catch {
toast("上传失败", "error");
} finally {
setFileBusy(false);
if (fileRef.current) fileRef.current.value = "";
}
const scrollToAttachments = () => {
const el =
attachSectionRef.current ||
document.getElementById("j13-compose-attachments");
el?.scrollIntoView({ behavior: "smooth", block: "center" });
window.setTimeout(() => {
const drop = el?.querySelector(".j13-compose-drop") as HTMLElement | null;
drop?.focus();
}, 320);
};
const removeAttachment = async (id: number) => {
setAttachments((prev) => prev.filter((a) => a.id !== id));
try {
await apiDeletePostFile(id);
} catch {
/* 本地已移除 */
}
};
const setAttPrice = async (id: number, price: number) => {
const p = Math.max(0, Math.min(100000, Math.floor(price) || 0));
setAttachments((prev) =>
prev.map((a) => (a.id === id ? { ...a, price_points: p } : a))
);
await apiUpdatePostFilePrice(id, p);
const scrollToPendingUpload = () => {
const el =
attachSectionRef.current ||
document.getElementById("j13-compose-attachments");
el?.scrollIntoView({ behavior: "smooth", block: "center" });
window.setTimeout(() => {
const btn = el?.querySelector(
".j13-compose-att-upload-btn"
) as HTMLButtonElement | null;
btn?.focus();
}, 320);
};
const bountyShort =
@@ -508,6 +539,7 @@ export default function PostComposer(props: PostComposerProps) {
const canSubmit =
!loading &&
!fileBusy &&
title.trim().length > 0 &&
content.trim().length > 0 &&
(isCreate ? boardsReady && boardId > 0 : true) &&
@@ -517,17 +549,24 @@ export default function PostComposer(props: PostComposerProps) {
(typeof bountyPoints === "number" && bountyPoints > 0)) &&
(postType !== "lottery" || lotteryPrizesReady);
/** 提交失败:底部操作区可见 + Toast;积分类滚到悬赏输入 */
const reportSubmitError = useCallback((msg: string, focus: "actions" | "bounty" = "actions") => {
pendingErrorFocus.current = focus;
setError(msg);
toast(msg, "error");
}, []);
/** 提交失败:底部操作区可见 + Toast;积分类滚到悬赏输入;待传附件滚到上传按钮 */
const reportSubmitError = useCallback(
(msg: string, focus: "actions" | "bounty" | "attachments" = "actions") => {
pendingErrorFocus.current = focus;
setError(msg);
toast(msg, "error");
},
[]
);
useEffect(() => {
if (!error) return;
const focus = pendingErrorFocus.current ?? "actions";
pendingErrorFocus.current = null;
if (focus === "attachments") {
scrollToPendingUpload();
return;
}
const el =
focus === "bounty" && bountyFieldRef.current
? bountyFieldRef.current
@@ -535,8 +574,21 @@ export default function PostComposer(props: PostComposerProps) {
el?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, [error]);
// 待传清零后收起「尚未上传」提示(已上传或已移除)
useEffect(() => {
if (pendingFileCount > 0) return;
setError((prev) => (prev.includes("尚未上传") ? "" : prev));
}, [pendingFileCount]);
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
if (pendingFileCount > 0) {
reportSubmitError(
`还有 ${pendingFileCount} 个附件尚未上传,请先上传或移除`,
"attachments"
);
return;
}
if (
isCreate &&
deadlineMode === "custom" &&
@@ -1177,6 +1229,7 @@ export default function PostComposer(props: PostComposerProps) {
onChange={setContent}
disabled={loading}
rows={18}
imageMaxMB={attachLimits.imageMaxMB}
placeholder={
"从这里开始写正文…\n\n工具栏可插入格式、代码块、链接、图片与隐藏内容(登录/回复/积分/密码可见)。支持粘贴与拖拽上传图片;默认双栏实时预览。"
}
@@ -1187,72 +1240,15 @@ export default function PostComposer(props: PostComposerProps) {
</div>
</section>
{/* 附件 */}
<section className="j13-compose-section" aria-label="附件">
<h2 className="j13-compose-section-title">附件</h2>
<div className="j13-compose-attach shrink-0">
<div className="flex items-center justify-between gap-3 mb-2">
<p className="meta text-[12.5px] !mb-0">单文件 ≤20MB,最多 10 个;可设免费或积分下载</p>
<button
type="button"
className="btn btn-line btn-sm inline-flex items-center gap-1.5 shrink-0"
disabled={loading || fileBusy || attachments.length >= 10}
onClick={() => fileRef.current?.click()}
>
<Paperclip size={14} />
{fileBusy ? "上传中…" : "添加文件"}
</button>
<input
ref={fileRef}
type="file"
className="hidden"
onChange={(e) => void onPickFile(e.target.files?.[0] ?? null)}
/>
</div>
{attachments.length === 0 ? (
<p className="meta text-[13px] py-2">暂无附件</p>
) : (
<ul className="space-y-2">
{attachments.map((a) => (
<li key={a.id} className="j13-compose-attach-row">
<Download size={15} className="shrink-0 opacity-50" />
<div className="min-w-0 flex-1">
<div className="truncate text-[13.5px]">{a.name}</div>
<div className="meta text-[12px]">{formatSize(a.size)}</div>
</div>
<label
className={`meta text-[12px] flex items-center gap-1.5 shrink-0${
a.price_points > 0 ? " j13-compose-att-paid" : ""
}`}
>
积分
<input
type="number"
min={0}
max={100000}
value={a.price_points}
disabled={loading}
onChange={(e) => void setAttPrice(a.id, Number(e.target.value))}
className="j13-compose-field !py-1 !px-2 w-20 text-[13px]"
data-tip="0 = 免费下载"
aria-label={`${a.name} 下载积分,0 为免费`}
/>
</label>
<button
type="button"
className="btn btn-line btn-sm !px-2"
data-tip="移除附件"
aria-label="移除附件"
onClick={() => void removeAttachment(a.id)}
>
<X size={14} />
</button>
</li>
))}
</ul>
)}
</div>
</section>
<ComposeAttachments
attachments={attachments}
onChange={setAttachments}
limits={attachLimits}
disabled={loading}
onBusyChange={setFileBusy}
onPendingChange={setPendingFileCount}
sectionRef={attachSectionRef}
/>
</div>
<div className="j13-compose-actions">
@@ -1272,6 +1268,11 @@ export default function PostComposer(props: PostComposerProps) {
</p>
)}
<div className="j13-compose-actions-end">
<ComposeAttachAction
count={attachments.length}
max={attachLimits.maxCount}
onClick={scrollToAttachments}
/>
{isCreate ? (
<Link href={cancelHref} className="btn btn-line !py-3">
取消
@@ -1294,9 +1295,11 @@ export default function PostComposer(props: PostComposerProps) {
? isCreate
? "发布中…"
: "保存中…"
: isCreate
? "发布帖子"
: "保存修改"}
: fileBusy
? "上传中…"
: isCreate
? "发布帖子"
: "保存修改"}
</button>
</div>
</div>