Files
jiang13-bbs/frontend/components/PostComposer.tsx

1306 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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,
apiGetPoints,
fetchBoards,
type Board,
type PostAttachment,
} 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 DeadlinePicker, {
type DeadlineMode,
defaultCustomLocal,
localInputToRFC3339,
} from "@/components/DeadlinePicker";
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 */
}
}
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;
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<Board[]>([]);
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<string[]>(
parseTags(isCreate ? "" : props.initialTags || "")
);
const [tagInput, setTagInput] = useState("");
const [postType, setPostType] = useState<PostTypeValue>(
normalizePostType(isCreate ? "discussion" : props.initialPostType)
);
const [typeMeta, setTypeMeta] = useState(
isCreate ? "" : props.initialTypeMeta || ""
);
// 类型专属表单状态
const [pollOptions, setPollOptions] = useState<string[]>(() => {
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<number | "">(() => {
// 新建:空值 + 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<DeadlineMode>("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<PostAttachment[]>(
isCreate ? [] : props.initialAttachments || []
);
const [fileBusy, setFileBusy] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
/** 发帖页余额:仅新建悬赏时拉取,用于就地提示积分不足 */
const [pointsBalance, setPointsBalance] = useState<number | null>(null);
const [draftBanner, setDraftBanner] = useState<ComposeDraft | null>(null);
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const skipDraftSave = useRef(true);
const formRef = useRef<HTMLFormElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const actionsErrorRef = useRef<HTMLDivElement>(null);
const bountyFieldRef = useRef<HTMLDivElement>(null);
const pendingErrorFocus = useRef<"actions" | "bounty" | null>(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 || 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 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 bountyShort =
isCreate &&
postType === "bounty" &&
pointsBalance !== null &&
typeof bountyPoints === "number" &&
bountyPoints > pointsBalance;
const canSubmit =
!loading &&
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" = "actions") => {
pendingErrorFocus.current = focus;
setError(msg);
toast(msg, "error");
}, []);
useEffect(() => {
if (!error) return;
const focus = pendingErrorFocus.current ?? "actions";
pendingErrorFocus.current = null;
const el =
focus === "bounty" && bountyFieldRef.current
? bountyFieldRef.current
: actionsErrorRef.current;
el?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, [error]);
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
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 ? (
<Link href={cancelHref} className="btn btn-line btn-sm">
取消
</Link>
) : (
<button
type="button"
onClick={() => router.push(cancelHref)}
className="btn btn-line btn-sm"
>
取消
</button>
);
return (
<form ref={formRef} onSubmit={handleSubmit} className="j13-compose-stage">
<header className="j13-compose-toolbar">
<h1 className="j13-compose-toolbar-title">
{isCreate ? "写新帖" : "编辑帖子"}
</h1>
<div className="j13-compose-toolbar-actions">
{cancelControl}
<button
type="submit"
disabled={!canSubmit}
className="btn btn-primary btn-sm min-w-[4.5rem]"
>
{submitLabel}
</button>
</div>
</header>
<div className="j13-compose-body">
{draftBanner && (
<div
className="flex flex-wrap items-center justify-between gap-3 px-4 py-3 rounded-[12px] text-[13px] shrink-0"
style={{
background: "var(--accent-soft)",
color: "var(--ink)",
border:
"1px solid color-mix(in srgb, var(--accent) 18%, transparent)",
}}
>
<span>
检测到未发布的本地草稿
{draftBanner.updatedAt
? `(${new Date(draftBanner.updatedAt).toLocaleString("zh-CN")})`
: ""}
</span>
<span className="flex gap-2">
<button type="button" className="btn btn-primary btn-sm" onClick={restoreDraft}>
恢复
</button>
<button type="button" className="btn btn-line btn-sm" onClick={discardDraft}>
丢弃
</button>
</span>
</div>
)}
{/* 基本信息:类型 / 板块 / 标签 / 可见性 */}
<section className="j13-compose-section" aria-label="基本信息">
<h2 className="j13-compose-section-title">基本信息</h2>
{/* 帖子类型 */}
<div className="shrink-0">
<div className="field-label mb-2">帖子类型</div>
<div className="j13-compose-typebar" role="radiogroup" aria-label="帖子类型">
{POST_TYPES.map((t) => (
<button
key={t.value}
type="button"
role="radio"
aria-checked={postType === t.value}
className={`j13-compose-typechip${postType === t.value ? " is-on" : ""}`}
disabled={loading || (!isCreate && t.value !== postType)}
data-tip={!isCreate && t.value !== postType ? "编辑时不可更改类型" : t.hint}
onClick={() => setPostType(t.value)}
>
{t.label}
</button>
))}
</div>
</div>
{/* 元信息:板块 + 标签(标签 flex 占满) + 可见性 */}
<div className="j13-compose-meta shrink-0">
<div className="j13-compose-meta-board">
<label className="field-label" htmlFor="compose-board">
板块
</label>
{isCreate ? (
<BoardPicker
id="compose-board"
boards={boards}
value={boardId}
onChange={setBoardId}
disabled={loading}
loading={!boardsReady}
/>
) : (
<BoardBadge
name={props.boardName || "未知板块"}
colorIndex={props.boardColorIndex ?? 0}
/>
)}
</div>
<div className="j13-compose-meta-tags">
<label className="field-label" htmlFor="compose-tag-input">
标签
<span className="font-normal" style={{ color: "var(--ink-3)" }}>
{" "}
· 可选,最多 12 个
</span>
</label>
<div
className="j13-tag-field"
onClick={() => document.getElementById("compose-tag-input")?.focus()}
>
{tagList.map((t) => (
<span key={t} className="chip inline-flex items-center gap-1 !py-0.5">
# {t}
<button
type="button"
data-tip={`移除 ${t}`}
aria-label={`移除标签 ${t}`}
className="opacity-60 hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
removeTag(t);
}}
>
<X size={12} />
</button>
</span>
))}
<input
id="compose-tag-input"
value={tagInput}
disabled={loading || tagList.length >= 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)" }}
/>
</div>
</div>
</div>
</section>
{/* 类型扩展(投票 / 悬赏 / 抽奖) */}
{(postType === "poll" || postType === "bounty" || postType === "lottery") && (
<section className="j13-compose-section" aria-label="类型设置">
<h2 className="j13-compose-section-title">类型设置</h2>
{postType === "poll" && (
<div className="j13-compose-typepanel shrink-0 space-y-3">
<p className="font-semibold text-[14px]" style={{ color: "var(--ink)" }}>
投票选项
</p>
{!canEditPoll && (
<p className="meta text-[12.5px]">已有人投票,无法修改选项、单/多选或匿名设置</p>
)}
{pollOptions.map((opt, i) => (
<div key={i} className="flex items-center gap-2">
<input
className="j13-compose-field flex-1"
value={opt}
maxLength={64}
placeholder={`选项 ${i + 1}`}
disabled={loading || !canEditPoll}
onChange={(e) => {
const next = [...pollOptions];
next[i] = e.target.value;
setPollOptions(next);
}}
/>
{pollOptions.length > 2 && canEditPoll && (
<button
type="button"
className="btn btn-line btn-sm !px-2"
onClick={() => setPollOptions((p) => p.filter((_, j) => j !== i))}
>
<X size={14} />
</button>
)}
</div>
))}
<div className="flex flex-wrap items-center gap-3">
{pollOptions.length < 10 && canEditPoll && (
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setPollOptions((p) => [...p, ""])}
>
添加选项
</button>
)}
<label className="meta text-[13px] inline-flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={pollMulti}
disabled={loading || !canEditPoll}
onChange={(e) => setPollMulti(e.target.checked)}
/>
允许多选
</label>
<label className="meta text-[13px] inline-flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={pollAnonymous}
disabled={loading || !canEditPoll}
onChange={(e) => setPollAnonymous(e.target.checked)}
/>
匿名投票
</label>
</div>
{!pollAnonymous && (
<p className="meta text-[12px]">非匿名:投票结果公开后可看到每位投票人</p>
)}
{isCreate && (
<DeadlinePicker
presets={POLL_EXPIRE_DAYS}
mode={deadlineMode}
onModeChange={setDeadlineMode}
days={pollExpireDays}
onDaysChange={setPollExpireDays}
customLocal={customEndsLocal}
onCustomLocalChange={setCustomEndsLocal}
disabled={loading}
hint="到期后自动结束投票;也可由作者提前结束"
/>
)}
</div>
)}
{postType === "bounty" && (
<div className="j13-compose-typepanel shrink-0 space-y-3">
<p className="font-semibold text-[14px]" style={{ color: "var(--ink)" }}>
悬赏积分
</p>
<p className="meta text-[12.5px]">
发布时从余额托管;到期未采纳自动退回。截止后仍可采纳,将从余额扣款支付且不可撤回。
</p>
<div ref={bountyFieldRef} className="j13-lottery-prize-table-wrap">
<table className="j13-lottery-prize-table">
<thead>
<tr>
<th scope="col">悬赏积分</th>
<th scope="col" className="col-count">
余额
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<input
type="number"
min={1}
max={100000}
value={bountyPoints}
placeholder="如:20"
disabled={loading || !isCreate}
aria-invalid={bountyShort || undefined}
aria-label="悬赏积分"
required={postType === "bounty"}
onChange={(e) => {
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" : ""}`}
/>
</td>
<td className="col-count">
{isCreate ? (
<span
className="meta text-[13px] tabular-nums"
style={
bountyShort
? { color: "var(--danger)", fontWeight: 600 }
: undefined
}
>
{pointsBalance === null
? "—"
: pointsBalance.toLocaleString("zh-CN")}
</span>
) : (
<span className="meta text-[12px]">已托管</span>
)}
</td>
</tr>
</tbody>
</table>
</div>
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
{bountyShort ? (
<p
className="meta text-[12.5px] m-0"
style={{ color: "var(--danger)" }}
role="status"
>
积分不足,请下调悬赏金额后再发布
</p>
) : isCreate && typeof bountyPoints !== "number" ? (
<p
className="meta text-[12.5px] m-0"
style={{ color: "var(--danger)" }}
>
请填写悬赏积分,否则无法发布
</p>
) : !isCreate ? (
<p className="meta text-[12.5px] m-0">编辑不可更改已托管金额</p>
) : (
<span />
)}
<span
className="meta text-[12.5px] tabular-nums ml-auto"
style={
bountyShort
? { color: "var(--danger)", fontWeight: 600 }
: undefined
}
>
{typeof bountyPoints === "number"
? `托管 ${bountyPoints.toLocaleString("zh-CN")} 积分`
: "托管 —"}
{isCreate && pointsBalance !== null
? ` · 余额 ${pointsBalance.toLocaleString("zh-CN")}`
: ""}
</span>
</div>
{isCreate && (
<DeadlinePicker
presets={BOUNTY_EXPIRE_DAYS}
mode={deadlineMode}
onModeChange={setDeadlineMode}
days={bountyExpireDays}
onDaysChange={setBountyExpireDays}
customLocal={customEndsLocal}
onCustomLocalChange={setCustomEndsLocal}
disabled={loading}
/>
)}
</div>
)}
{postType === "lottery" && (
<div className="j13-compose-typepanel shrink-0 space-y-3">
<p className="font-semibold text-[14px]" style={{ color: "var(--ink)" }}>
抽奖设置
</p>
<p className="meta text-[12.5px]">
填写奖品名称与数量,中奖名额自动合计。有评论后不可再改奖品。
</p>
{!canEditLotteryPrizes && (
<p className="meta text-[12.5px]" style={{ color: "var(--gold)" }}>
已有评论,奖品设置已锁定
</p>
)}
<div className="j13-lottery-prize-table-wrap">
<table className="j13-lottery-prize-table">
<thead>
<tr>
<th scope="col" className="col-idx">
#
</th>
<th scope="col">奖品名称</th>
<th scope="col" className="col-count">
数量
</th>
{canEditLotteryPrizes && (
<th scope="col" className="col-actions">
操作
</th>
)}
</tr>
</thead>
<tbody>
{lotteryPrizes.map((p, idx) => (
<tr key={idx}>
<td className="col-idx">{idx + 1}</td>
<td>
<input
type="text"
value={p.name}
maxLength={32}
placeholder="如:一等奖 / 周边礼包"
disabled={loading || !canEditLotteryPrizes}
onChange={(e) => {
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"}
/>
</td>
<td className="col-count">
<input
type="number"
min={1}
max={100}
value={p.count}
placeholder="数量"
disabled={loading || !canEditLotteryPrizes}
onChange={(e) => {
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"}
/>
</td>
{canEditLotteryPrizes && (
<td className="col-actions">
{lotteryPrizes.length > 1 ? (
<button
type="button"
className="btn btn-line btn-sm"
disabled={loading}
onClick={() =>
setLotteryPrizes((prev) => prev.filter((_, i) => i !== idx))
}
>
删除
</button>
) : (
<span className="meta text-[12px]">—</span>
)}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
{canEditLotteryPrizes && lotteryPrizes.length < 10 ? (
<button
type="button"
className="btn btn-line btn-sm"
disabled={loading}
onClick={() =>
setLotteryPrizes((prev) => [...prev, { name: "", count: "" }])
}
>
添加一行
</button>
) : (
<span />
)}
<span className="meta text-[12.5px] tabular-nums">
合计名额 {lotteryPrizesReady ? lotterySlotTotal : "—"}
</span>
</div>
{canEditLotteryPrizes && !lotteryPrizesReady && (
<p className="meta text-[12.5px]" style={{ color: "var(--danger)" }}>
请填写每一行的奖品名称与数量,否则无法发布
</p>
)}
{isCreate && (
<DeadlinePicker
presets={LOTTERY_EXPIRE_DAYS}
mode={deadlineMode}
onModeChange={setDeadlineMode}
days={lotteryExpireDays}
onDaysChange={setLotteryExpireDays}
customLocal={customEndsLocal}
onCustomLocalChange={setCustomEndsLocal}
disabled={loading}
hint="到期后自动开奖"
/>
)}
</div>
)}
</section>
)}
{/* 标题与正文 */}
<section className="j13-compose-section j13-compose-section-writing" aria-label="标题与正文">
<div className="flex items-center justify-between gap-3">
<h2 className="j13-compose-section-title !mb-0">标题与正文</h2>
<span className="meta text-[12px] tabular-nums">
{title.length}/{TITLE_MAX}
</span>
</div>
<div className="shrink-0">
<label className="sr-only" htmlFor="compose-title">
标题
</label>
<input
id="compose-title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value.slice(0, TITLE_MAX))}
required
maxLength={TITLE_MAX}
placeholder="一句话说清楚你要讨论什么"
className="j13-compose-title"
disabled={loading}
/>
</div>
<div className="j13-compose-canvas">
<label className="sr-only" htmlFor="compose-content">
正文
</label>
<MarkdownEditor
id="compose-content"
value={content}
onChange={setContent}
disabled={loading}
rows={18}
placeholder={
"从这里开始写正文…\n\n工具栏可插入格式、代码块、链接、图片与隐藏内容(登录/回复/积分/密码可见)。支持粘贴与拖拽上传图片;默认双栏实时预览。"
}
onSubmitShortcut={() => {
if (canSubmit) void handleSubmit();
}}
/>
</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>
</div>
<div className="j13-compose-actions">
{error ? (
<div
ref={actionsErrorRef}
className="alert-error j13-compose-actions-error"
role="alert"
>
{error}
</div>
) : (
<p className="meta text-[12.5px] max-w-md">
{isCreate
? "普通用户发帖需审核 · Ctrl/⌘+Enter 快捷发布"
: "保存后立即生效 · 类型不可更改"}
</p>
)}
<div className="j13-compose-actions-end">
{isCreate ? (
<Link href={cancelHref} className="btn btn-line !py-3">
取消
</Link>
) : (
<button
type="button"
onClick={() => router.push(cancelHref)}
className="btn btn-line !py-3"
>
取消
</button>
)}
<button
type="submit"
disabled={!canSubmit}
className="btn btn-primary min-w-[8.5rem] !px-7 !py-3 !text-[15px]"
>
{loading
? isCreate
? "发布中…"
: "保存中…"
: isCreate
? "发布帖子"
: "保存修改"}
</button>
</div>
</div>
</form>
);
}