外观设置改为单页分区;发帖草稿与板块校验优化,WS 延迟释放避开 Strict Mode 误断。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 11:27:39 +08:00
parent 68e90ff6ff
commit 1f2e4b2a33
8 changed files with 684 additions and 618 deletions

View File

@@ -31,6 +31,7 @@ import DeadlinePicker, {
defaultCustomLocal,
localInputToRFC3339,
formatLocalPreview,
toLocalInputValue,
} from "@/components/DeadlinePicker";
import Modal from "@/components/Modal";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
@@ -49,13 +50,28 @@ const TYPE_ICON: Record<PostTypeValue, LucideIcon> = {
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;
/** @deprecated 旧草稿仅有 JSON 字符串;新草稿用 typeForm */
typeMeta?: string;
typeForm?: TypeFormSnapshot;
updatedAt: number;
};
@@ -74,6 +90,73 @@ function serializeTags(tags: string[]): string {
return tags.join(", ");
}
/** 从旧版 typeMeta JSON 尽量还原类型表单(不完整则用默认) */
function typeFormFromMeta(
postType: PostTypeValue,
raw: string | undefined
): Partial<TypeFormSnapshot> {
if (!raw?.trim()) return {};
try {
const m = JSON.parse(raw) as Record<string, unknown>;
const out: Partial<TypeFormSnapshot> = {};
const endsAt = typeof m.ends_at === "string" ? m.ends_at : "";
const expireDays = Number(m.expire_days);
if (endsAt) {
const t = Date.parse(endsAt);
if (Number.isFinite(t)) {
out.deadlineMode = "custom";
out.customEndsLocal = toLocalInputValue(new Date(t));
}
} else if (Number.isFinite(expireDays) && expireDays > 0) {
out.deadlineMode = "preset";
}
if (postType === "poll") {
if (Array.isArray(m.options)) {
const opts = m.options.map((o) => String(o ?? ""));
out.pollOptions = opts.length >= 2 ? opts : ["", ""];
}
out.pollMulti = !!m.multi;
out.pollAnonymous = m.anonymous !== false;
if ((POLL_EXPIRE_DAYS as readonly number[]).includes(expireDays)) {
out.pollExpireDays = expireDays;
}
} else if (postType === "bounty") {
const p = Number(m.points);
out.bountyPoints = p > 0 ? Math.floor(p) : "";
if ((BOUNTY_EXPIRE_DAYS as readonly number[]).includes(expireDays)) {
out.bountyExpireDays = expireDays;
}
} else if (postType === "lottery") {
const prizes = Array.isArray(m.prizes)
? (m.prizes as { name?: string; count?: number }[]).map((p) => ({
name: String(p.name || "").slice(0, 32),
count:
typeof p.count === "number" && p.count >= 1
? Math.floor(p.count)
: ("" as const),
}))
: [];
out.lotteryPrizes =
prizes.length > 0 ? prizes : [{ name: "", count: "" as const }];
if ((LOTTERY_EXPIRE_DAYS as readonly number[]).includes(expireDays)) {
out.lotteryExpireDays = expireDays;
}
}
return out;
} catch {
return {};
}
}
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 || (d.typeMeta && d.typeMeta.trim())) return true;
return false;
}
function mapPostError(msg: string): string {
if (!msg) return "操作失败";
const cooldown = msg.match(/新用户注册\s*(\d+)\s*小时后才能发帖/);
@@ -154,6 +237,8 @@ export default function PostComposer(props: PostComposerProps) {
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<string[]>(
@@ -379,10 +464,7 @@ export default function PostComposer(props: PostComposerProps) {
);
}
const draft = readDraft();
if (
draft &&
(draft.title.trim() || draft.content.trim() || draft.tags.trim())
) {
if (draft && draftHasContent(draft)) {
setDraftBanner(draft);
skipDraftSave.current = true;
} else {
@@ -425,7 +507,14 @@ export default function PostComposer(props: PostComposerProps) {
if (!isCreate || skipDraftSave.current) return;
if (draftTimer.current) clearTimeout(draftTimer.current);
draftTimer.current = setTimeout(() => {
if (!title.trim() && !content.trim() && tagList.length === 0) {
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;
}
@@ -435,7 +524,18 @@ export default function PostComposer(props: PostComposerProps) {
tags: serializeTags(tagList),
boardId,
postType,
typeMeta,
typeForm: {
pollOptions,
pollMulti,
pollAnonymous,
pollExpireDays,
bountyPoints,
bountyExpireDays,
lotteryPrizes,
lotteryExpireDays,
deadlineMode,
customEndsLocal,
},
updatedAt: Date.now(),
});
}, DRAFT_DEBOUNCE_MS);
@@ -449,7 +549,16 @@ export default function PostComposer(props: PostComposerProps) {
tagList,
boardId,
postType,
typeMeta,
pollOptions,
pollMulti,
pollAnonymous,
pollExpireDays,
bountyPoints,
bountyExpireDays,
lotteryPrizes,
lotteryExpireDays,
deadlineMode,
customEndsLocal,
]);
const restoreDraft = () => {
@@ -457,9 +566,59 @@ export default function PostComposer(props: PostComposerProps) {
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.boardId) {
setBoardId(draftBanner.boardId);
setBoardInvalid(false);
}
const nextType = normalizePostType(draftBanner.postType);
setPostType(nextType);
const fromMeta = typeFormFromMeta(nextType, draftBanner.typeMeta);
const snap: Partial<TypeFormSnapshot> = {
...fromMeta,
...(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);
// 编辑态用的 typeMeta 字符串一并同步,避免后续误用空串
if (draftBanner.typeMeta) setTypeMeta(draftBanner.typeMeta);
else if (draftBanner.typeForm) setTypeMeta("");
setDraftBanner(null);
skipDraftSave.current = false;
toast("已恢复草稿", "ok");
@@ -586,7 +745,7 @@ export default function PostComposer(props: PostComposerProps) {
!fileBusy &&
title.trim().length > 0 &&
content.trim().length > 0 &&
(isCreate ? boardsReady && boardId > 0 : true) &&
(isCreate ? boardsReady : true) &&
(postType !== "poll" ||
pollOptions.map((o) => o.trim()).filter(Boolean).length >= 2) &&
(postType !== "bounty" ||
@@ -689,7 +848,11 @@ export default function PostComposer(props: PostComposerProps) {
}
if (isCreate && (!boardsReady || boardId <= 0)) {
if (isNarrow) setRailOpen(true);
setBoardInvalid(true);
reportSubmitError("请选择板块");
window.setTimeout(() => {
document.getElementById("compose-board")?.focus();
}, isNarrow ? 80 : 0);
return;
}
if (
@@ -764,9 +927,6 @@ export default function PostComposer(props: PostComposerProps) {
: isCreate
? "发布"
: "保存";
const selectedBoardName = isCreate
? boards.find((b) => b.id === boardId)?.name
: props.boardName;
const typeLabel = POST_TYPES.find((t) => t.value === postType)?.label || "讨论";
const blockReason = loading
? ""
@@ -776,19 +936,17 @@ export default function PostComposer(props: PostComposerProps) {
? "请填写标题"
: !content.trim()
? "请填写正文"
: isCreate && (!boardsReady || boardId <= 0)
? "请选择板块"
: postType === "poll" &&
pollOptions.map((o) => o.trim()).filter(Boolean).length < 2
? "请至少填写 2 个投票选项"
: postType === "bounty" &&
(typeof bountyPoints !== "number" || bountyPoints < 1)
? "请填写悬赏积分"
: bountyShort
? "积分不足"
: postType === "lottery" && !lotteryPrizesReady
? "请填写奖品名称与数量"
: "";
: 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";
@@ -1284,16 +1442,19 @@ export default function PostComposer(props: PostComposerProps) {
<section className="j13-compose-rail-block" aria-label="板块">
<label className="j13-compose-section-title" htmlFor="compose-board">
板块
{isCreate ? <span className="j13-compose-req">必选</span> : null}
</label>
{isCreate ? (
<BoardPicker
id="compose-board"
boards={boards}
value={boardId}
onChange={setBoardId}
onChange={(id) => {
setBoardId(id);
setBoardInvalid(false);
}}
disabled={loading}
loading={!boardsReady}
invalid={boardInvalid}
/>
) : (
<BoardBadge
@@ -1305,7 +1466,7 @@ export default function PostComposer(props: PostComposerProps) {
<section className="j13-compose-rail-block" aria-label="基本信息">
<h2 className="j13-compose-section-title">帖子类型</h2>
<div className="j13-compose-typebar" role="radiogroup" aria-label="帖子类型">
<div className="j13-compose-typebar is-compact" role="radiogroup" aria-label="帖子类型">
{POST_TYPES.map((t) => {
const Icon = TYPE_ICON[t.value];
const isOn = postType === t.value;
@@ -1313,6 +1474,7 @@ export default function PostComposer(props: PostComposerProps) {
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 (
<div
key={t.value}
@@ -1327,22 +1489,28 @@ export default function PostComposer(props: PostComposerProps) {
className="j13-compose-typeopt-hit"
disabled={locked}
data-tip={
!isCreate && t.value !== postType ? "编辑时不可更改类型" : undefined
!isCreate && t.value !== postType
? "编辑时不可更改类型"
: !isOn
? t.hint
: undefined
}
onClick={() => onPickType(t.value)}
>
<span className="j13-compose-typeopt-ico" aria-hidden>
<Icon size={16} strokeWidth={2.2} />
<Icon size={15} strokeWidth={2.2} />
</span>
<span className="j13-compose-typeopt-copy">
<span className="j13-compose-typeopt-label">{t.label}</span>
<span
className={`j13-compose-typeopt-hint${
status ? (status.warn ? " is-warn" : " is-status") : ""
}`}
>
{status ? status.text : t.hint}
</span>
{hintText ? (
<span
className={`j13-compose-typeopt-hint${
status ? (status.warn ? " is-warn" : " is-status") : ""
}`}
>
{hintText}
</span>
) : null}
</span>
</button>
{isOn && needsSetup ? (
@@ -1432,9 +1600,13 @@ export default function PostComposer(props: PostComposerProps) {
{isCreate ? "写新帖" : "编辑帖子"}
</h1>
<p className="j13-compose-toolbar-sub">
{selectedBoardName || (isCreate && !boardsReady ? "加载板块…" : "选择板块")}
{" · "}
{typeLabel}
{draftBanner
? `有未恢复的本地草稿${
draftBanner.updatedAt
? ` · ${new Date(draftBanner.updatedAt).toLocaleString("zh-CN")}`
: ""
}`
: typeLabel}
</p>
</div>
</div>
@@ -1462,7 +1634,7 @@ export default function PostComposer(props: PostComposerProps) {
<button
type="submit"
disabled={!canSubmit}
className="btn btn-primary btn-sm min-w-[4.75rem]"
className="btn btn-primary min-w-[5.5rem] h-9 px-4 text-[13.5px] font-semibold"
>
{submitLabel}
</button>