- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接 - 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配 - 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成 - middleware 与相关组件适配
1735 lines
58 KiB
TypeScript
1735 lines
58 KiB
TypeScript
"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<PostTypeValue, LucideIcon> = {
|
||
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<Board[]>([]);
|
||
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<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 }[];
|
||
};
|
||
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<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 [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<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 titleRef = useRef<HTMLInputElement>(null);
|
||
const attachSectionRef = useRef<HTMLElement | null>(null);
|
||
const actionsErrorRef = useRef<HTMLDivElement>(null);
|
||
const bountyFieldRef = useRef<HTMLDivElement>(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<RtSettingsData>(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<TypeFormSnapshot> = {
|
||
...(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 = (
|
||
<Link
|
||
href={cancelHref}
|
||
className="j13-compose-back"
|
||
aria-label="返回"
|
||
data-tip="返回"
|
||
>
|
||
<ArrowLeft size={18} strokeWidth={2.2} />
|
||
</Link>
|
||
);
|
||
|
||
const typeSettingsForm = (
|
||
<>
|
||
{postType === "poll" && (
|
||
<div className="j13-compose-typepanel 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 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 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">
|
||
<span
|
||
data-tip={
|
||
lotteryPrizes.length <= 1 ? "至少保留一项" : "删除此奖品"
|
||
}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="btn btn-line btn-sm"
|
||
disabled={loading || lotteryPrizes.length <= 1}
|
||
aria-label={
|
||
lotteryPrizes.length <= 1
|
||
? "删除(至少保留一项)"
|
||
: `删除奖品 ${idx + 1}`
|
||
}
|
||
onClick={() => {
|
||
if (lotteryPrizes.length <= 1) return;
|
||
setLotteryPrizes((prev) =>
|
||
prev.filter((_, i) => i !== idx)
|
||
);
|
||
}}
|
||
>
|
||
删除
|
||
</button>
|
||
</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>
|
||
)}
|
||
</>
|
||
);
|
||
|
||
const railInner = (
|
||
<>
|
||
<section className="j13-compose-rail-block" aria-label="板块">
|
||
<label className="j13-compose-section-title" htmlFor="compose-board">
|
||
板块
|
||
</label>
|
||
{isCreate ? (
|
||
<BoardPicker
|
||
id="compose-board"
|
||
boards={boards}
|
||
value={boardId}
|
||
onChange={(id) => {
|
||
setBoardId(id);
|
||
setBoardInvalid(false);
|
||
}}
|
||
disabled={loading}
|
||
loading={!boardsReady}
|
||
invalid={boardInvalid}
|
||
/>
|
||
) : (
|
||
<BoardBadge
|
||
name={props.boardName || "未知板块"}
|
||
boardId={props.boardId || 0}
|
||
colorIndex={props.boardColorIndex ?? 0}
|
||
icon={props.boardIcon}
|
||
/>
|
||
)}
|
||
</section>
|
||
|
||
<section className="j13-compose-rail-block" aria-label="基本信息">
|
||
<h2 className="j13-compose-section-title">帖子类型</h2>
|
||
<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;
|
||
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 (
|
||
<div
|
||
key={t.value}
|
||
className={`j13-compose-typeopt${isOn ? " is-on" : ""}${
|
||
status?.warn ? " is-warn" : ""
|
||
}`}
|
||
>
|
||
<button
|
||
type="button"
|
||
role="radio"
|
||
aria-checked={isOn}
|
||
className="j13-compose-typeopt-hit"
|
||
disabled={locked}
|
||
data-tip={
|
||
!isCreate && t.value !== postType
|
||
? "编辑时不可更改类型"
|
||
: !isOn
|
||
? t.hint
|
||
: undefined
|
||
}
|
||
onClick={() => onPickType(t.value)}
|
||
>
|
||
<span className="j13-compose-typeopt-ico" aria-hidden>
|
||
<Icon size={15} strokeWidth={2.2} />
|
||
</span>
|
||
<span className="j13-compose-typeopt-copy">
|
||
<span className="j13-compose-typeopt-label">{t.label}</span>
|
||
{hintText ? (
|
||
<span
|
||
className={`j13-compose-typeopt-hint${
|
||
status ? (status.warn ? " is-warn" : " is-status") : ""
|
||
}`}
|
||
>
|
||
{hintText}
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
</button>
|
||
{isOn && needsSetup ? (
|
||
<button
|
||
type="button"
|
||
className={`j13-compose-typeopt-setup${status?.warn ? " is-warn" : ""}`}
|
||
onClick={() => setTypeModalOpen(true)}
|
||
>
|
||
{status?.warn ? "去设置" : "设置"}
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="j13-compose-rail-block" aria-label="标签">
|
||
<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>
|
||
</section>
|
||
|
||
<ComposeAttachments
|
||
attachments={attachments}
|
||
onChange={setAttachments}
|
||
limits={attachLimits}
|
||
disabled={loading}
|
||
onBusyChange={setFileBusy}
|
||
onPendingChange={setPendingFileCount}
|
||
sectionRef={attachSectionRef}
|
||
/>
|
||
</>
|
||
);
|
||
|
||
return (
|
||
<form ref={formRef} onSubmit={handleSubmit} className="j13-compose-stage is-studio">
|
||
<header className="j13-compose-toolbar">
|
||
<div className="j13-compose-toolbar-lead">
|
||
{cancelControl}
|
||
<div className="j13-compose-toolbar-copy min-w-0">
|
||
<h1 className="j13-compose-toolbar-title">
|
||
{isCreate ? "写新帖" : "编辑帖子"}
|
||
</h1>
|
||
<p className="j13-compose-toolbar-sub">
|
||
{draftBanner
|
||
? `有未恢复的本地草稿${
|
||
draftBanner.updatedAt
|
||
? ` · ${new Date(draftBanner.updatedAt).toLocaleString("zh-CN")}`
|
||
: ""
|
||
}`
|
||
: typeLabel}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="j13-compose-toolbar-actions">
|
||
{blockReason ? (
|
||
<span className="j13-compose-toolbar-hint">{blockReason}</span>
|
||
) : (
|
||
<span className="j13-compose-toolbar-hint">
|
||
{isCreate ? "Ctrl/⌘+Enter 发布" : "Ctrl/⌘+Enter 保存"}
|
||
</span>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="btn btn-line btn-sm j13-compose-rail-toggle"
|
||
onClick={() => setRailOpen(true)}
|
||
aria-expanded={railOpen}
|
||
aria-controls="j13-compose-rail"
|
||
>
|
||
<SlidersHorizontal size={14} />
|
||
发布设置
|
||
{attachments.length > 0 ? (
|
||
<span className="j13-compose-rail-toggle-count">{attachments.length}</span>
|
||
) : null}
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={!canSubmit}
|
||
className="btn btn-primary min-w-[5.5rem] h-9 px-4 text-[13.5px] font-semibold"
|
||
>
|
||
{submitLabel}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{draftBanner && (
|
||
<div className="j13-compose-draft" role="status">
|
||
<span>
|
||
检测到未发布的本地草稿
|
||
{draftBanner.updatedAt
|
||
? `(${new Date(draftBanner.updatedAt).toLocaleString("zh-CN")})`
|
||
: ""}
|
||
</span>
|
||
<span className="flex gap-2 shrink-0">
|
||
<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>
|
||
)}
|
||
|
||
{error ? (
|
||
<div
|
||
ref={actionsErrorRef}
|
||
className="alert-error j13-compose-banner-error"
|
||
role="alert"
|
||
>
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="j13-compose-studio">
|
||
<div className="j13-compose-studio-canvas">
|
||
<div className="j13-compose-title-row">
|
||
<label className="sr-only" htmlFor="compose-title">
|
||
标题
|
||
</label>
|
||
<input
|
||
ref={titleRef}
|
||
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}
|
||
/>
|
||
<span className="j13-compose-title-count tabular-nums">
|
||
{title.length}/{TITLE_MAX}
|
||
</span>
|
||
</div>
|
||
<div className="j13-compose-canvas">
|
||
<label className="sr-only" htmlFor="compose-content">
|
||
正文
|
||
</label>
|
||
<MarkdownEditor
|
||
id="compose-content"
|
||
value={content}
|
||
onChange={(v) => setContent(v.slice(0, CONTENT_MAX))}
|
||
maxChars={CONTENT_MAX}
|
||
allowHide
|
||
disabled={loading}
|
||
rows={18}
|
||
imageMaxMB={attachLimits.imageMaxMB}
|
||
placeholder={
|
||
"从这里开始写正文…\n\n工具栏可插入格式、代码块、链接、图片与隐藏内容(登录/回复/积分/密码可见)。支持粘贴与拖拽上传图片;右上角可切换预览。"
|
||
}
|
||
onSubmitShortcut={() => {
|
||
void handleSubmit();
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{railOpen ? (
|
||
<button
|
||
type="button"
|
||
className="j13-compose-rail-backdrop"
|
||
aria-label="关闭发布设置"
|
||
onClick={() => setRailOpen(false)}
|
||
/>
|
||
) : null}
|
||
|
||
<aside
|
||
id="j13-compose-rail"
|
||
className={`j13-compose-studio-rail${railOpen ? " is-open" : ""}`}
|
||
aria-label="发布设置"
|
||
aria-hidden={railHidden}
|
||
inert={railHidden}
|
||
>
|
||
<div className="j13-compose-rail-head">
|
||
<h2 className="j13-compose-rail-head-title">发布设置</h2>
|
||
<button
|
||
type="button"
|
||
className="j13-compose-back"
|
||
aria-label="关闭发布设置"
|
||
onClick={() => setRailOpen(false)}
|
||
>
|
||
<X size={18} />
|
||
</button>
|
||
</div>
|
||
{railInner}
|
||
</aside>
|
||
</div>
|
||
|
||
<Modal
|
||
open={typeModalOpen && typeSetupKind}
|
||
title={`${typeLabel}设置`}
|
||
description={POST_TYPES.find((t) => 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={
|
||
<>
|
||
<span
|
||
className="meta text-[12.5px] mr-auto"
|
||
style={typeSetupReady ? undefined : { color: "var(--danger)", fontWeight: 600 }}
|
||
>
|
||
{typeSummary}
|
||
</span>
|
||
<button type="button" className="btn btn-line btn-sm" onClick={closeTypeModal}>
|
||
稍后
|
||
</button>
|
||
<button type="button" className="btn btn-primary btn-sm" onClick={completeTypeModal}>
|
||
完成
|
||
</button>
|
||
</>
|
||
}
|
||
>
|
||
<div
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && (e.target as HTMLElement).tagName === "INPUT") {
|
||
e.preventDefault();
|
||
}
|
||
}}
|
||
>
|
||
{typeSettingsForm}
|
||
</div>
|
||
</Modal>
|
||
|
||
</form>
|
||
);
|
||
}
|