Files
jiang13-bbs/frontend/components/PostComposer.tsx
freefire 3e55b5d230 feat: 管理端板块管理与旧版数据导入,补充部署运营文档
- 新增管理端板块管理页面与后端接口(admin_board)
- 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具
- 聊天用户卡片、板块图标等 UI 组件与界面优化
- 补充 about/公告/1Panel 部署等文档
- gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
2026-09-24 03:37:44 +08:00

1729 lines
58 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 { 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";
const TITLE_MAX = 256;
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 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(`/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
? "发布中…"
: "保存中…"
: 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={setContent}
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>
);
}