"use client"; import { useState, useEffect, useRef, useCallback } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { X } from "lucide-react"; import { apiCreatePost, apiUpdatePost, apiGetPoints, apiGetSettings, fetchBoards, type Board, type PostAttachment, DEFAULT_ATTACHMENT_EXTS, } from "@/lib/api"; import { POST_TYPES, BOUNTY_EXPIRE_DAYS, LOTTERY_EXPIRE_DAYS, POLL_EXPIRE_DAYS, normalizePostType, type PostTypeValue, } from "@/lib/postMeta"; 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"; const DRAFT_DEBOUNCE_MS = 600; type ComposeDraft = { title: string; content: string; tags: string; boardId: number; postType?: string; typeMeta?: string; updatedAt: number; }; function parseTags(raw: string): string[] { return [ ...new Set( raw .split(/[,,]/) .map((t) => t.trim()) .filter(Boolean) ), ].slice(0, 12); } function serializeTags(tags: string[]): string { return tags.join(", "); } function mapPostError(msg: string): string { if (!msg) return "操作失败"; const cooldown = msg.match(/新用户注册\s*(\d+)\s*小时后才能发帖/); if (cooldown) { return `新用户注册满 ${cooldown[1]} 小时后才能发帖,请稍后再试`; } if (msg.includes("24 小时") || msg.includes("24小时") || msg.includes("冷静")) { return "新用户尚在发帖冷静期内,请稍后再试"; } if (msg.includes("积分")) return msg; if (msg.includes("rate") || msg.includes("频繁") || msg.includes("太快")) { return "操作过于频繁,请稍后再试"; } return msg; } function readDraft(): ComposeDraft | null { try { const raw = localStorage.getItem(DRAFT_KEY); if (!raw) return null; const d = JSON.parse(raw) as ComposeDraft; if (!d || typeof d !== "object") return null; return d; } catch { return null; } } function clearDraft() { try { localStorage.removeItem(DRAFT_KEY); } catch { /* ignore */ } } function writeDraft(d: ComposeDraft) { try { localStorage.setItem(DRAFT_KEY, JSON.stringify(d)); } catch { /* ignore */ } } type CreateProps = { mode: "create"; initialBoardId?: number; cancelHref?: string; }; type EditProps = { mode: "edit"; postId: number; initialTitle: string; initialContent: string; initialTags?: string; initialPostType?: string; initialTypeMeta?: string; initialAttachments?: PostAttachment[]; /** 投票帖:有票后禁止改选项 */ canEditPollOptions?: boolean; /** 抽奖帖:有评论后禁止改奖品 */ canEditLotteryPrizes?: boolean; boardName?: string; boardId?: number; boardColorIndex?: number; cancelHref: string; }; export type PostComposerProps = CreateProps | EditProps; export default function PostComposer(props: PostComposerProps) { const router = useRouter(); const isCreate = props.mode === "create"; const [boards, setBoards] = useState([]); const [boardsReady, setBoardsReady] = useState(!isCreate); const [boardId, setBoardId] = useState( isCreate ? props.initialBoardId || 0 : props.boardId || 0 ); const [title, setTitle] = useState(isCreate ? "" : props.initialTitle); const [content, setContent] = useState(isCreate ? "" : props.initialContent); const [tagList, setTagList] = useState( parseTags(isCreate ? "" : props.initialTags || "") ); const [tagInput, setTagInput] = useState(""); const [postType, setPostType] = useState( normalizePostType(isCreate ? "discussion" : props.initialPostType) ); const [typeMeta, setTypeMeta] = useState( isCreate ? "" : props.initialTypeMeta || "" ); // 类型专属表单状态 const [pollOptions, setPollOptions] = useState(() => { try { const m = JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}"); return Array.isArray(m.options) && m.options.length >= 2 ? m.options : ["", ""]; } catch { return ["", ""]; } }); const [pollMulti, setPollMulti] = useState(() => { try { return !!JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}").multi; } catch { return false; } }); const [pollAnonymous, setPollAnonymous] = useState(() => { if (isCreate) return true; try { const m = JSON.parse(props.initialTypeMeta || "{}"); // 缺省视为匿名(与后端一致) return m.anonymous !== false; } catch { return true; } }); const [bountyPoints, setBountyPoints] = useState(() => { // 新建:空值 + placeholder(与抽奖数量输入一致);编辑回填已托管金额 if (isCreate) return ""; try { const p = Number(JSON.parse(props.initialTypeMeta || "{}").points); return p > 0 ? p : ""; } catch { return ""; } }); const [lotteryPrizes, setLotteryPrizes] = useState<{ name: string; count: number | "" }[]>(() => { // 新建:空行 + placeholder;编辑才回填已保存奖品 if (isCreate) return [{ name: "", count: "" }]; try { const raw = JSON.parse(props.initialTypeMeta || "{}") as { prizes?: { name?: string; count?: number }[]; slots?: number; }; if (Array.isArray(raw.prizes) && raw.prizes.length > 0) { return raw.prizes.map((p) => ({ name: String(p.name || "").slice(0, 32), count: Math.max(1, Number(p.count) || 1), })); } // 旧数据仅有 slots:数量可回填,名称留空让作者补全 const slots = Number(raw.slots); if (slots > 0) return [{ name: "", count: slots }]; return [{ name: "", count: "" }]; } catch { return [{ name: "", count: "" }]; } }); const [bountyExpireDays, setBountyExpireDays] = useState(() => { try { const d = Number(JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}").expire_days); return (BOUNTY_EXPIRE_DAYS as readonly number[]).includes(d) ? d : 7; } catch { return 7; } }); const [lotteryExpireDays, setLotteryExpireDays] = useState(() => { try { const d = Number(JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}").expire_days); return (LOTTERY_EXPIRE_DAYS as readonly number[]).includes(d) ? d : 7; } catch { return 7; } }); const [pollExpireDays, setPollExpireDays] = useState(7); const [deadlineMode, setDeadlineMode] = useState("preset"); const [customEndsLocal, setCustomEndsLocal] = useState(() => defaultCustomLocal(7)); const canEditPoll = isCreate || (props.mode === "edit" && props.canEditPollOptions !== false); const canEditLotteryPrizes = isCreate || (props.mode === "edit" && props.canEditLotteryPrizes !== false); const lotterySlotTotal = lotteryPrizes.reduce((sum, p) => { if (typeof p.count !== "number") return sum; return sum + Math.max(0, Math.floor(p.count)); }, 0); /** 每一行都需填写名称与数量,否则不可发布 */ const lotteryPrizesReady = lotteryPrizes.length >= 1 && lotteryPrizes.every( (p) => p.name.trim().length > 0 && typeof p.count === "number" && p.count >= 1 && p.count <= 100 ); const [attachments, setAttachments] = useState( 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(""); /** 发帖页余额:仅新建悬赏时拉取,用于就地提示积分不足 */ const [pointsBalance, setPointsBalance] = useState(null); const [draftBanner, setDraftBanner] = useState(null); const draftTimer = useRef | null>(null); const skipDraftSave = useRef(true); const formRef = useRef(null); const attachSectionRef = useRef(null); const actionsErrorRef = useRef(null); const bountyFieldRef = useRef(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(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; fetchBoards() .then((data) => { if (cancelled) return; const list = data.boards || []; setBoards(list); setBoardsReady(true); const preferred = preferredBoardId; const hit = preferred && list.some((b) => b.id === preferred); if (hit) { setBoardId(preferred); } else if (list.length > 0) { setBoardId((cur) => cur && list.some((b) => b.id === cur) ? cur : list[0].id ); } const draft = readDraft(); if ( draft && (draft.title.trim() || draft.content.trim() || draft.tags.trim()) ) { setDraftBanner(draft); skipDraftSave.current = true; } else { skipDraftSave.current = false; } }) .catch(() => { if (!cancelled) { setBoardsReady(true); skipDraftSave.current = false; } }); return () => { cancelled = true; }; }, [isCreate, preferredBoardId]); // 新建悬赏:拉取余额,便于在积分输入旁提前提示不足 useEffect(() => { if (!isCreate || postType !== "bounty") { setPointsBalance(null); return; } let cancelled = false; apiGetPoints() .then((res) => { if (cancelled) return; const n = Number(res.points); setPointsBalance(Number.isFinite(n) ? Math.max(0, Math.floor(n)) : null); }) .catch(() => { if (!cancelled) setPointsBalance(null); }); return () => { cancelled = true; }; }, [isCreate, postType]); useEffect(() => { if (!isCreate || skipDraftSave.current) return; if (draftTimer.current) clearTimeout(draftTimer.current); draftTimer.current = setTimeout(() => { if (!title.trim() && !content.trim() && tagList.length === 0) { clearDraft(); return; } writeDraft({ title, content, tags: serializeTags(tagList), boardId, postType, typeMeta, updatedAt: Date.now(), }); }, DRAFT_DEBOUNCE_MS); return () => { if (draftTimer.current) clearTimeout(draftTimer.current); }; }, [ isCreate, title, content, tagList, boardId, postType, typeMeta, ]); const restoreDraft = () => { if (!draftBanner) return; setTitle(draftBanner.title || ""); setContent(draftBanner.content || ""); setTagList(parseTags(draftBanner.tags || "")); if (draftBanner.boardId) setBoardId(draftBanner.boardId); if (draftBanner.postType) setPostType(normalizePostType(draftBanner.postType)); if (draftBanner.typeMeta) setTypeMeta(draftBanner.typeMeta); setDraftBanner(null); skipDraftSave.current = false; toast("已恢复草稿", "ok"); }; const discardDraft = () => { clearDraft(); setDraftBanner(null); skipDraftSave.current = false; }; const commitTagInput = useCallback(() => { const parts = parseTags(tagInput); if (parts.length === 0) { setTagInput(""); return; } setTagList((prev) => { const next = [...prev]; for (const p of parts) { if (next.length >= 12) break; if (!next.includes(p)) next.push(p); } return next; }); setTagInput(""); }, [tagInput]); const removeTag = (t: string) => { setTagList((prev) => prev.filter((x) => x !== t)); }; const buildDeadlineFields = (presetDays: number) => { if (deadlineMode === "custom") { const iso = localInputToRFC3339(customEndsLocal); if (!iso) return { expire_days: presetDays }; return { ends_at: iso, expire_days: 0 }; } return { expire_days: presetDays }; }; const buildTypeMeta = (): string => { if (postType === "discussion") return ""; if (postType === "question") { return JSON.stringify({ solved: false, accepted_comment_id: 0 }); } if (postType === "poll") { return JSON.stringify({ options: pollOptions.map((o) => o.trim()).filter(Boolean), multi: pollMulti, anonymous: pollAnonymous, closed: false, ...buildDeadlineFields(pollExpireDays), }); } if (postType === "bounty") { return JSON.stringify({ points: typeof bountyPoints === "number" ? Math.max(1, Math.floor(bountyPoints)) : 1, accepted_comment_id: 0, escrowed: false, refunded: false, expired: false, ...buildDeadlineFields(bountyExpireDays), }); } if (postType === "lottery") { const prizes = lotteryPrizes .map((p) => ({ name: p.name.trim().slice(0, 32), count: typeof p.count === "number" ? Math.floor(p.count) : 0, })) .filter((p) => p.name.length > 0 && p.count >= 1); return JSON.stringify({ prizes, slots: prizes.reduce((s, p) => s + p.count, 0), drawn: false, winner_ids: [], closed: false, ...buildDeadlineFields(lotteryExpireDays), }); } return typeMeta.trim(); }; 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 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 = isCreate && postType === "bounty" && pointsBalance !== null && typeof bountyPoints === "number" && bountyPoints > pointsBalance; const canSubmit = !loading && !fileBusy && title.trim().length > 0 && content.trim().length > 0 && (isCreate ? boardsReady && boardId > 0 : true) && (postType !== "poll" || pollOptions.map((o) => o.trim()).filter(Boolean).length >= 2) && (postType !== "bounty" || (typeof bountyPoints === "number" && bountyPoints > 0)) && (postType !== "lottery" || lotteryPrizesReady); /** 提交失败:底部操作区可见 + 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 : actionsErrorRef.current; 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" && (postType === "poll" || postType === "bounty" || postType === "lottery") ) { const iso = localInputToRFC3339(customEndsLocal); if (!iso) { reportSubmitError("请选择有效的截止时间"); return; } const t = Date.parse(iso); if (!Number.isFinite(t) || t < Date.now() + 60 * 60 * 1000) { reportSubmitError("截止时间至少晚于当前 1 小时"); return; } } if (postType === "lottery" && !lotteryPrizesReady) { reportSubmitError("请填写奖品名称与数量后再发布"); return; } if ( isCreate && postType === "bounty" && (typeof bountyPoints !== "number" || bountyPoints < 1) ) { reportSubmitError("请填写悬赏积分后再发布", "bounty"); return; } if (bountyShort) { reportSubmitError( `积分不足(余额 ${pointsBalance},需要 ${bountyPoints})`, "bounty" ); return; } if (!canSubmit) return; setLoading(true); setError(""); const tags = serializeTags(tagList); const meta = buildTypeMeta(); const attIds = attachments.map((a) => a.id); try { if (isCreate) { const res = await apiCreatePost({ board_id: boardId, title: title.trim(), content: content.trim(), tags, post_type: postType, type_meta: meta, attachment_ids: attIds, }); if (res.post) { clearDraft(); if (res.post.status === "pending") { toast("已提交,等待审核通过后公开", "ok"); } else { toast("发布成功", "ok"); } router.push(`/post/${res.post.id}`); } else { const mapped = mapPostError(res.error || "发帖失败"); reportSubmitError( mapped, mapped.includes("积分") ? "bounty" : "actions" ); } } else { const res = await apiUpdatePost(String(props.postId), { title: title.trim(), content: content.trim(), tags, type_meta: meta, attachment_ids: attIds, }); if (res.post) { toast("已保存", "ok"); router.push(props.cancelHref); } else { reportSubmitError(mapPostError(res.error || "保存失败")); } } } catch { reportSubmitError("网络错误,请稍后重试"); } finally { setLoading(false); } }; const cancelHref = isCreate ? props.cancelHref || "/" : props.cancelHref; const submitLabel = loading ? isCreate ? "发布中…" : "保存中…" : isCreate ? "发布" : "保存"; const cancelControl = isCreate ? ( 取消 ) : ( ); return (

{isCreate ? "写新帖" : "编辑帖子"}

{cancelControl}
{draftBanner && (
检测到未发布的本地草稿 {draftBanner.updatedAt ? `(${new Date(draftBanner.updatedAt).toLocaleString("zh-CN")})` : ""}
)} {/* 基本信息:类型 / 板块 / 标签 / 可见性 */}

基本信息

{/* 帖子类型 */}
帖子类型
{POST_TYPES.map((t) => ( ))}
{/* 元信息:板块 + 标签(标签 flex 占满) + 可见性 */}
{isCreate ? ( ) : ( )}
document.getElementById("compose-tag-input")?.focus()} > {tagList.map((t) => ( # {t} ))} = 12} onChange={(e) => setTagInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === "," || e.key === ",") { e.preventDefault(); commitTagInput(); } else if (e.key === "Backspace" && !tagInput && tagList.length > 0) { removeTag(tagList[tagList.length - 1]); } }} onBlur={commitTagInput} placeholder={tagList.length === 0 ? "输入后回车添加" : ""} className="j13-tag-field-input flex-1 min-w-[8rem] self-stretch h-8 text-[13px]" style={{ color: "var(--ink)" }} />
{/* 类型扩展(投票 / 悬赏 / 抽奖) */} {(postType === "poll" || postType === "bounty" || postType === "lottery") && (

类型设置

{postType === "poll" && (

投票选项

{!canEditPoll && (

已有人投票,无法修改选项、单/多选或匿名设置

)} {pollOptions.map((opt, i) => (
{ const next = [...pollOptions]; next[i] = e.target.value; setPollOptions(next); }} /> {pollOptions.length > 2 && canEditPoll && ( )}
))}
{pollOptions.length < 10 && canEditPoll && ( )}
{!pollAnonymous && (

非匿名:投票结果公开后可看到每位投票人

)} {isCreate && ( )}
)} {postType === "bounty" && (

悬赏积分

发布时从余额托管;到期未采纳自动退回。截止后仍可采纳,将从余额扣款支付且不可撤回。

悬赏积分 余额
{ const raw = e.target.value; if (error.includes("积分")) setError(""); if (raw === "") { setBountyPoints(""); return; } const n = Math.max( 1, Math.min(100000, Number(raw) || 1) ); setBountyPoints(n); }} className={`j13-compose-field${bountyShort ? " j13-compose-field-invalid" : ""}`} /> {isCreate ? ( {pointsBalance === null ? "—" : pointsBalance.toLocaleString("zh-CN")} ) : ( 已托管 )}
{bountyShort ? (

积分不足,请下调悬赏金额后再发布

) : isCreate && typeof bountyPoints !== "number" ? (

请填写悬赏积分,否则无法发布

) : !isCreate ? (

编辑不可更改已托管金额

) : ( )} {typeof bountyPoints === "number" ? `托管 ${bountyPoints.toLocaleString("zh-CN")} 积分` : "托管 —"} {isCreate && pointsBalance !== null ? ` · 余额 ${pointsBalance.toLocaleString("zh-CN")}` : ""}
{isCreate && ( )}
)} {postType === "lottery" && (

抽奖设置

填写奖品名称与数量,中奖名额自动合计。有评论后不可再改奖品。

{!canEditLotteryPrizes && (

已有评论,奖品设置已锁定

)}
{canEditLotteryPrizes && ( )} {lotteryPrizes.map((p, idx) => ( {canEditLotteryPrizes && ( )} ))}
# 奖品名称 数量 操作
{idx + 1} { const v = e.target.value.slice(0, 32); setLotteryPrizes((prev) => prev.map((row, i) => (i === idx ? { ...row, name: v } : row)) ); }} className="j13-compose-field" aria-label={`奖品 ${idx + 1} 名称`} required={postType === "lottery"} /> { const raw = e.target.value; if (raw === "") { setLotteryPrizes((prev) => prev.map((row, i) => (i === idx ? { ...row, count: "" } : row)) ); return; } const n = Math.max(1, Math.min(100, Number(raw) || 1)); setLotteryPrizes((prev) => prev.map((row, i) => (i === idx ? { ...row, count: n } : row)) ); }} className="j13-compose-field j13-compose-field-count" aria-label={`奖品 ${idx + 1} 数量`} required={postType === "lottery"} /> {lotteryPrizes.length > 1 ? ( ) : ( — )}
{canEditLotteryPrizes && lotteryPrizes.length < 10 ? ( ) : ( )} 合计名额 {lotteryPrizesReady ? lotterySlotTotal : "—"}
{canEditLotteryPrizes && !lotteryPrizesReady && (

请填写每一行的奖品名称与数量,否则无法发布

)} {isCreate && ( )}
)}
)} {/* 标题与正文 */}

标题与正文

{title.length}/{TITLE_MAX}
setTitle(e.target.value.slice(0, TITLE_MAX))} required maxLength={TITLE_MAX} placeholder="一句话说清楚你要讨论什么" className="j13-compose-title" disabled={loading} />
{ if (canSubmit) void handleSubmit(); }} />
{error ? (
{error}
) : (

{isCreate ? "普通用户发帖需审核 · Ctrl/⌘+Enter 快捷发布" : "保存后立即生效 · 类型不可更改"}

)}
{isCreate ? ( 取消 ) : ( )}
); }