"use client"; import { useState, useEffect, useRef, useCallback } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { ArrowLeft, CircleHelp, Coins, Gift, ListChecks, MessagesSquare, SlidersHorizontal, X, type LucideIcon } 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 from "@/components/ComposeAttachments"; import DeadlinePicker, { type DeadlineMode, defaultCustomLocal, localInputToRFC3339, formatLocalPreview, toLocalInputValue, } from "@/components/DeadlinePicker"; import Modal from "@/components/Modal"; import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime"; import { postPath } from "@/lib/urlStyle"; import { useUrlStyle } from "@/components/SiteBrandProvider"; const TITLE_MAX = 256; /** 帖子正文字符上限(与后端 handler/post.go 校验一致) */ const CONTENT_MAX = 20000; const DRAFT_KEY = "j13-compose-draft"; const DRAFT_DEBOUNCE_MS = 600; /** 与 globals.css 工作台分栏断点一致 */ const RAIL_MQ = "(max-width: 1279px)"; const TYPE_ICON: Record = { discussion: MessagesSquare, question: CircleHelp, poll: ListChecks, bounty: Coins, lottery: Gift, }; type TypeFormSnapshot = { pollOptions: string[]; pollMulti: boolean; pollAnonymous: boolean; pollExpireDays: number; bountyPoints: number | ""; bountyExpireDays: number; lotteryPrizes: { name: string; count: number | "" }[]; lotteryExpireDays: number; deadlineMode: DeadlineMode; customEndsLocal: string; }; type ComposeDraft = { title: string; content: string; tags: string; boardId: number; postType?: string; typeForm?: TypeFormSnapshot; 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 draftHasContent(d: ComposeDraft): boolean { if (d.title.trim() || d.content.trim() || d.tags.trim()) return true; if (d.boardId > 0) return true; const pt = normalizePostType(d.postType); if (pt !== "discussion") return true; if (d.typeForm) return true; return false; } 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 & { typeMeta?: string }; if (!d || typeof d !== "object") return null; // 仅含旧 typeMeta、无 typeForm 的草稿已废弃 if (d.typeMeta && !d.typeForm) { clearDraft(); return null; } delete d.typeMeta; 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; boardIcon?: string; cancelHref: string; }; export type PostComposerProps = CreateProps | EditProps; export default function PostComposer(props: PostComposerProps) { const router = useRouter(); const urlStyle = useUrlStyle(); 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 ); /** 仅提交校验失败后为 true,选好板块后清除 */ const [boardInvalid, setBoardInvalid] = useState(false); 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 }[]; }; 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), })); } 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 titleRef = useRef(null); const attachSectionRef = useRef(null); const actionsErrorRef = useRef(null); const bountyFieldRef = useRef(null); const pendingErrorFocus = useRef<"actions" | "bounty" | "attachments" | null>(null); const [railOpen, setRailOpen] = useState(false); const [typeModalOpen, setTypeModalOpen] = useState(false); const [isNarrow, setIsNarrow] = useState(false); const preferredBoardId = isCreate ? props.initialBoardId || 0 : 0; useEffect(() => { const mq = window.matchMedia(RAIL_MQ); const sync = () => setIsNarrow(mq.matches); sync(); mq.addEventListener("change", sync); return () => mq.removeEventListener("change", sync); }, []); useEffect(() => { if (!railOpen) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setRailOpen(false); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [railOpen]); useEffect(() => { if (!isCreate) return; titleRef.current?.focus(); }, [isCreate]); 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 === 1) { setBoardId(list[0].id); } else { setBoardId((cur) => cur && list.some((b) => b.id === cur) ? cur : 0 ); } const draft = readDraft(); if (draft && draftHasContent(draft)) { 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(() => { const emptyCore = !title.trim() && !content.trim() && tagList.length === 0 && boardId <= 0; const emptyType = postType === "discussion" && pollOptions.every((o) => !o.trim()) && bountyPoints === "" && lotteryPrizes.every((p) => !p.name.trim() && p.count === ""); if (emptyCore && emptyType) { clearDraft(); return; } writeDraft({ title, content, tags: serializeTags(tagList), boardId, postType, typeForm: { pollOptions, pollMulti, pollAnonymous, pollExpireDays, bountyPoints, bountyExpireDays, lotteryPrizes, lotteryExpireDays, deadlineMode, customEndsLocal, }, updatedAt: Date.now(), }); }, DRAFT_DEBOUNCE_MS); return () => { if (draftTimer.current) clearTimeout(draftTimer.current); }; }, [ isCreate, title, content, tagList, boardId, postType, pollOptions, pollMulti, pollAnonymous, pollExpireDays, bountyPoints, bountyExpireDays, lotteryPrizes, lotteryExpireDays, deadlineMode, customEndsLocal, ]); const restoreDraft = () => { if (!draftBanner) return; setTitle(draftBanner.title || ""); setContent(draftBanner.content || ""); setTagList(parseTags(draftBanner.tags || "")); if (draftBanner.boardId) { setBoardId(draftBanner.boardId); setBoardInvalid(false); } const nextType = normalizePostType(draftBanner.postType); setPostType(nextType); const snap: Partial = { ...(draftBanner.typeForm || {}), }; if (snap.pollOptions && snap.pollOptions.length >= 2) { setPollOptions(snap.pollOptions); } if (typeof snap.pollMulti === "boolean") setPollMulti(snap.pollMulti); if (typeof snap.pollAnonymous === "boolean") { setPollAnonymous(snap.pollAnonymous); } if ( typeof snap.pollExpireDays === "number" && (POLL_EXPIRE_DAYS as readonly number[]).includes(snap.pollExpireDays) ) { setPollExpireDays(snap.pollExpireDays); } if (snap.bountyPoints === "" || typeof snap.bountyPoints === "number") { setBountyPoints(snap.bountyPoints); } if ( typeof snap.bountyExpireDays === "number" && (BOUNTY_EXPIRE_DAYS as readonly number[]).includes(snap.bountyExpireDays) ) { setBountyExpireDays(snap.bountyExpireDays); } if (snap.lotteryPrizes && snap.lotteryPrizes.length > 0) { setLotteryPrizes(snap.lotteryPrizes); } if ( typeof snap.lotteryExpireDays === "number" && (LOTTERY_EXPIRE_DAYS as readonly number[]).includes(snap.lotteryExpireDays) ) { setLotteryExpireDays(snap.lotteryExpireDays); } if (snap.deadlineMode === "custom" || snap.deadlineMode === "preset") { setDeadlineMode(snap.deadlineMode); } if (snap.customEndsLocal) setCustomEndsLocal(snap.customEndsLocal); if (draftBanner.typeForm) setTypeMeta(""); 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 = () => { if (isNarrow) setRailOpen(true); window.setTimeout(() => { 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); }, isNarrow ? 80 : 0); }; const scrollToPendingUpload = () => { if (isNarrow) setRailOpen(true); window.setTimeout(() => { 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); }, isNarrow ? 80 : 0); }; 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 : 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; } if (focus === "bounty") setTypeModalOpen(true); window.setTimeout(() => { const el = focus === "bounty" && bountyFieldRef.current ? bountyFieldRef.current : actionsErrorRef.current; el?.scrollIntoView({ behavior: "smooth", block: "nearest" }); }, focus === "bounty" ? 80 : 0); }, [error, isNarrow]); // 待传清零后收起「尚未上传」提示(已上传或已移除) useEffect(() => { if (pendingFileCount > 0) return; setError((prev) => (prev.includes("尚未上传") ? "" : prev)); }, [pendingFileCount]); const handleSubmit = async (e?: React.FormEvent) => { e?.preventDefault(); if (loading || fileBusy) return; if (pendingFileCount > 0) { reportSubmitError( `还有 ${pendingFileCount} 个附件尚未上传,请先上传或移除`, "attachments" ); return; } if ( isCreate && deadlineMode === "custom" && (postType === "poll" || postType === "bounty" || postType === "lottery") ) { const iso = localInputToRFC3339(customEndsLocal); if (!iso) { setTypeModalOpen(true); reportSubmitError("请选择有效的截止时间"); return; } const t = Date.parse(iso); if (!Number.isFinite(t) || t < Date.now() + 60 * 60 * 1000) { setTypeModalOpen(true); reportSubmitError("截止时间至少晚于当前 1 小时"); return; } } if (postType === "lottery" && !lotteryPrizesReady) { setTypeModalOpen(true); reportSubmitError("请填写奖品名称与数量后再发布"); return; } if ( isCreate && postType === "bounty" && (typeof bountyPoints !== "number" || bountyPoints < 1) ) { setTypeModalOpen(true); reportSubmitError("请填写悬赏积分后再发布", "bounty"); return; } if (bountyShort) { setTypeModalOpen(true); reportSubmitError( `积分不足(余额 ${pointsBalance},需要 ${bountyPoints})`, "bounty" ); return; } if (!title.trim()) { reportSubmitError("请填写标题"); titleRef.current?.focus(); return; } if (!content.trim()) { reportSubmitError("请填写正文"); document.getElementById("compose-content")?.focus(); return; } if (isCreate && (!boardsReady || boardId <= 0)) { if (isNarrow) setRailOpen(true); setBoardInvalid(true); reportSubmitError("请选择板块"); window.setTimeout(() => { document.getElementById("compose-board")?.focus(); }, isNarrow ? 80 : 0); return; } if ( postType === "poll" && pollOptions.map((o) => o.trim()).filter(Boolean).length < 2 ) { setTypeModalOpen(true); reportSubmitError("请至少填写 2 个投票选项"); 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(postPath(urlStyle, 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 ? "发布中…" : "保存中…" : fileBusy ? "上传中…" : isCreate ? "发布" : "保存"; const typeLabel = POST_TYPES.find((t) => t.value === postType)?.label || "讨论"; const blockReason = loading ? "" : fileBusy ? "附件上传中" : !title.trim() ? "请填写标题" : !content.trim() ? "请填写正文" : postType === "poll" && pollOptions.map((o) => o.trim()).filter(Boolean).length < 2 ? "请至少填写 2 个投票选项" : postType === "bounty" && (typeof bountyPoints !== "number" || bountyPoints < 1) ? "请填写悬赏积分" : bountyShort ? "积分不足" : postType === "lottery" && !lotteryPrizesReady ? "请填写奖品名称与数量" : ""; const railHidden = isNarrow && !railOpen; const typeSetupKind = postType === "poll" || postType === "bounty" || postType === "lottery"; const pollFilled = pollOptions.map((o) => o.trim()).filter(Boolean).length; const typeSetupReady = postType === "poll" ? pollFilled >= 2 : postType === "bounty" ? typeof bountyPoints === "number" && bountyPoints > 0 && !bountyShort : postType === "lottery" ? lotteryPrizesReady : true; const deadlineSummary = (() => { if (!isCreate || !typeSetupKind) return ""; if (deadlineMode === "custom") { const preview = formatLocalPreview(customEndsLocal); return preview ? `截止 ${preview}` : "未选截止时间"; } const days = postType === "poll" ? pollExpireDays : postType === "bounty" ? bountyExpireDays : lotteryExpireDays; return postType === "lottery" ? `${days} 天后开奖` : `${days} 天后结束`; })(); const typeSummary = (() => { if (postType === "poll") { const bits = [ pollFilled >= 2 ? `${pollFilled} 个选项` : "还差选项", pollMulti ? "多选" : "单选", pollAnonymous ? "匿名" : "实名", ]; if (deadlineSummary) bits.push(deadlineSummary); return bits.join(" · "); } if (postType === "bounty") { const bits = [ typeof bountyPoints === "number" ? `托管 ${bountyPoints.toLocaleString("zh-CN")} 积分` : "未填积分", ]; if (bountyShort) bits.push("余额不足"); if (deadlineSummary) bits.push(deadlineSummary); return bits.join(" · "); } if (postType === "lottery") { const bits = [ lotteryPrizesReady ? `${lotteryPrizes.length} 种奖品 · ${lotterySlotTotal} 名额` : "奖品未填完", ]; if (deadlineSummary) bits.push(deadlineSummary); return bits.join(" · "); } return ""; })(); const typeChipStatus = (() => { if (postType === "poll") { if (pollFilled < 2) return { text: "待设选项", warn: true }; const bits = [`${pollFilled} 选项`]; if (pollMulti) bits.push("多选"); if (isCreate) { bits.push( deadlineMode === "custom" ? "自选截止" : `${pollExpireDays} 天` ); } return { text: bits.join(" · "), warn: false }; } if (postType === "bounty") { if (bountyShort) return { text: "余额不足", warn: true }; if (typeof bountyPoints !== "number") return { text: "待填积分", warn: true }; const bits = [`${bountyPoints.toLocaleString("zh-CN")} 积分`]; if (isCreate) { bits.push( deadlineMode === "custom" ? "自选截止" : `${bountyExpireDays} 天` ); } return { text: bits.join(" · "), warn: false }; } if (postType === "lottery") { if (!lotteryPrizesReady) return { text: "待填奖品", warn: true }; const bits = [`${lotteryPrizes.length} 奖 · ${lotterySlotTotal} 名额`]; if (isCreate) { bits.push( deadlineMode === "custom" ? "自选截止" : `${lotteryExpireDays} 天` ); } return { text: bits.join(" · "), warn: false }; } return null; })(); const closeTypeModal = useCallback(() => setTypeModalOpen(false), []); const completeTypeModal = () => { if (postType === "poll" && pollFilled < 2) { toast("请至少填写 2 个投票选项", "error"); return; } if ( postType === "bounty" && isCreate && (typeof bountyPoints !== "number" || bountyPoints < 1) ) { toast("请填写悬赏积分", "error"); return; } if (postType === "bounty" && bountyShort) { toast(`积分不足(余额 ${pointsBalance},需要 ${bountyPoints})`, "error"); return; } if (postType === "lottery" && !lotteryPrizesReady) { toast("请填写每一行的奖品名称与数量", "error"); return; } setTypeModalOpen(false); }; const onPickType = (value: PostTypeValue) => { setPostType(value); if (value !== "poll" && value !== "bounty" && value !== "lottery") { setTypeModalOpen(false); } }; const cancelControl = ( ); const typeSettingsForm = ( <> {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"} />
{canEditLotteryPrizes && lotteryPrizes.length < 10 ? ( ) : ( )} 合计名额 {lotteryPrizesReady ? lotterySlotTotal : "—"}
{canEditLotteryPrizes && !lotteryPrizesReady && (

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

)} {isCreate && ( )}
)} ); const railInner = ( <>
{isCreate ? ( { setBoardId(id); setBoardInvalid(false); }} disabled={loading} loading={!boardsReady} invalid={boardInvalid} /> ) : ( )}

帖子类型

{POST_TYPES.map((t) => { const Icon = TYPE_ICON[t.value]; const isOn = postType === t.value; const locked = loading || (!isCreate && t.value !== postType); const status = isOn ? typeChipStatus : null; const needsSetup = t.value === "poll" || t.value === "bounty" || t.value === "lottery"; const hintText = status ? status.text : isOn ? t.hint : ""; return (
{isOn && needsSetup ? ( ) : null}
); })}
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)" }} />
); return (
{cancelControl}

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

{draftBanner ? `有未恢复的本地草稿${ draftBanner.updatedAt ? ` · ${new Date(draftBanner.updatedAt).toLocaleString("zh-CN")}` : "" }` : typeLabel}

{blockReason ? ( {blockReason} ) : ( {isCreate ? "Ctrl/⌘+Enter 发布" : "Ctrl/⌘+Enter 保存"} )}
{draftBanner && (
检测到未发布的本地草稿 {draftBanner.updatedAt ? `(${new Date(draftBanner.updatedAt).toLocaleString("zh-CN")})` : ""}
)} {error ? (
{error}
) : null}
setTitle(e.target.value.slice(0, TITLE_MAX))} required maxLength={TITLE_MAX} placeholder="一句话说清楚你要讨论什么" className="j13-compose-title" disabled={loading} /> {title.length}/{TITLE_MAX}
setContent(v.slice(0, CONTENT_MAX))} maxChars={CONTENT_MAX} allowHide disabled={loading} rows={18} imageMaxMB={attachLimits.imageMaxMB} placeholder={ "从这里开始写正文…\n\n工具栏可插入格式、代码块、链接、图片与隐藏内容(登录/回复/积分/密码可见)。支持粘贴与拖拽上传图片;右上角可切换预览。" } onSubmitShortcut={() => { void handleSubmit(); }} />
{railOpen ? (
{railInner} t.value === postType)?.hint} onClose={closeTypeModal} dismissOnOverlay maxWidthClass={postType === "lottery" ? "max-w-2xl" : "max-w-xl"} panelClassName="p-5 sm:p-6" bodyClassName="j13-compose-typemodal-body" footer={ <> {typeSummary} } >
{ if (e.key === "Enter" && (e.target as HTMLElement).tagName === "INPUT") { e.preventDefault(); } }} > {typeSettingsForm}
); }