feat(attach): 附件可配置限额、流式落盘与分阶段上传
管理端可配类型/体积;上传改流式避免整文件进内存;发帖先选再传并拦截未上传提交;提高 Next proxy 体积上限。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user