外观设置改为单页分区;发帖草稿与板块校验优化,WS 延迟释放避开 Strict Mode 误断。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user