"use client"; 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 { apiCreatePost, apiUpdatePost, apiUploadPostFile, apiDeletePostFile, apiUpdatePostFilePrice, fetchBoards, type Board, type PostAttachment, } from "@/lib/api"; import { POST_TYPES, CONTENT_ACCESS, BOUNTY_EXPIRE_DAYS, LOTTERY_EXPIRE_DAYS, normalizePostType, type PostTypeValue, type ContentAccessValue, } from "@/lib/postMeta"; import { toast } from "@/lib/toast"; import MarkdownEditor from "@/components/MarkdownEditor"; import BoardPicker, { BoardBadge } from "@/components/BoardPicker"; 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; contentAccess?: string; accessPoints?: number; 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 */ } } 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; cancelHref?: string; }; type EditProps = { mode: "edit"; postId: number; initialTitle: string; initialContent: string; initialTags?: string; initialPostType?: string; initialContentAccess?: string; initialAccessPoints?: number; initialTypeMeta?: string; initialAttachments?: PostAttachment[]; /** 投票帖:有票后禁止改选项 */ canEditPollOptions?: 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 [contentAccess, setContentAccess] = useState( (isCreate ? "public" : (props.initialContentAccess as ContentAccessValue) || "public") as ContentAccessValue ); const [accessPoints, setAccessPoints] = useState( isCreate ? 10 : props.initialAccessPoints || 10 ); 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(() => { try { const p = Number(JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}").points); return p > 0 ? p : 20; } catch { return 20; } }); const [lotterySlots, setLotterySlots] = useState(() => { try { const s = Number(JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}").slots); return s > 0 ? s : 1; } catch { return 1; } }); 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 canEditPoll = isCreate || (props.mode === "edit" && props.canEditPollOptions !== false); const [attachments, setAttachments] = useState( isCreate ? [] : props.initialAttachments || [] ); const [fileBusy, setFileBusy] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [draftBanner, setDraftBanner] = useState(null); const draftTimer = useRef | null>(null); const skipDraftSave = useRef(true); const formRef = useRef(null); const fileRef = useRef(null); const preferredBoardId = isCreate ? props.initialBoardId || 0 : 0; 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 || 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, contentAccess, accessPoints, typeMeta, updatedAt: Date.now(), }); }, DRAFT_DEBOUNCE_MS); return () => { if (draftTimer.current) clearTimeout(draftTimer.current); }; }, [ isCreate, title, content, tagList, boardId, postType, contentAccess, accessPoints, 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.contentAccess) { setContentAccess(draftBanner.contentAccess as ContentAccessValue); } if (draftBanner.accessPoints) setAccessPoints(draftBanner.accessPoints); 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 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, }); } if (postType === "bounty") { return JSON.stringify({ points: Math.max(1, bountyPoints || 1), accepted_comment_id: 0, escrowed: false, refunded: false, expired: false, expire_days: bountyExpireDays, }); } if (postType === "lottery") { return JSON.stringify({ slots: Math.max(1, lotterySlots || 1), drawn: false, winner_ids: [], closed: false, expire_days: lotteryExpireDays, }); } 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 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 canSubmit = !loading && title.trim().length > 0 && content.trim().length > 0 && (isCreate ? boardsReady && boardId > 0 : true) && (contentAccess !== "points" || accessPoints > 0) && (postType !== "poll" || pollOptions.map((o) => o.trim()).filter(Boolean).length >= 2) && (postType !== "bounty" || bountyPoints > 0) && (postType !== "lottery" || lotterySlots > 0); const handleSubmit = async (e?: React.FormEvent) => { e?.preventDefault(); 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, content_access: contentAccess, access_points: contentAccess === "points" ? accessPoints : 0, 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 { setError(mapPostError(res.error || "发帖失败")); } } else { const res = await apiUpdatePost(String(props.postId), { title: title.trim(), content: content.trim(), tags, content_access: contentAccess, access_points: contentAccess === "points" ? accessPoints : 0, type_meta: meta, attachment_ids: attIds, }); if (res.post) { toast("已保存", "ok"); router.push(props.cancelHref); } else { setError(mapPostError(res.error || "保存失败")); } } } catch { setError("网络错误,请稍后重试"); } finally { setLoading(false); } }; const cancelHref = isCreate ? props.cancelHref || "/" : props.cancelHref; return (
{draftBanner && (
检测到未发布的本地草稿 {draftBanner.updatedAt ? `(${new Date(draftBanner.updatedAt).toLocaleString("zh-CN")})` : ""}
)} {error &&
{error}
} {/* 帖子类型 */}
帖子类型
{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)" }} />
{contentAccess === "points" && (
setAccessPoints(Number(e.target.value) || 0)} className="j13-compose-field !py-1.5 w-28" />
)}
{/* 类型专属面板 */} {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 && (

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

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

悬赏积分

发布时从你的积分余额托管;到期未采纳将自动退回。采纳回答后发给对方。

setBountyPoints(Number(e.target.value) || 0)} className="j13-compose-field w-36" /> 积分 {!isCreate && ( 编辑不可更改已托管金额 )}
{isCreate && (
过期天数 {BOUNTY_EXPIRE_DAYS.map((d) => ( ))}
)}
)} {postType === "lottery" && (

抽奖设置

对本帖发表评论的用户自动进入奖池(楼主除外);截止时可自动开奖。

setLotterySlots(Number(e.target.value) || 1)} className="j13-compose-field w-28" />
{isCreate && (
截止天数 {LOTTERY_EXPIRE_DAYS.map((d) => ( ))}
)}
)} {/* 大标题 */}
{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(); }} />
{/* 附件 */}
附件
void onPickFile(e.target.files?.[0] ?? null)} />

单文件 ≤20MB,最多 10 个;可设免费或积分下载

{attachments.length === 0 ? (

暂无附件

) : (
    {attachments.map((a) => (
  • {a.name}
    {formatSize(a.size)}
  • ))}
)}
{isCreate ? ( 取消 ) : ( )} {isCreate ? "普通用户发帖需审核 · Ctrl/⌘+Enter 提交 · Esc 退出全屏" : "保存后立即生效 · 类型不可更改"}
); }