feat: 类型帖互动、积分经济与发帖体验

补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 03:17:07 +08:00
parent 1ef3d8e299
commit b9ba8cb8c9
58 changed files with 6913 additions and 506 deletions

View File

@@ -0,0 +1,957 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { X, Paperclip, Download } from "lucide-react";
import {
apiCreatePost,
apiUpdatePost,
apiUploadPostFile,
apiDeletePostFile,
apiUpdatePostFilePrice,
fetchBoards,
type Board,
type PostAttachment,
} from "@/lib/api";
import {
POST_TYPES,
CONTENT_ACCESS,
BOUNTY_EXPIRE_DAYS,
LOTTERY_EXPIRE_DAYS,
normalizePostType,
type PostTypeValue,
type ContentAccessValue,
} from "@/lib/postMeta";
import { toast } from "@/lib/toast";
import MarkdownEditor from "@/components/MarkdownEditor";
import BoardPicker, { BoardBadge } from "@/components/BoardPicker";
const TITLE_MAX = 256;
const DRAFT_KEY = "j13-compose-draft";
const DRAFT_DEBOUNCE_MS = 600;
type ComposeDraft = {
title: string;
content: string;
tags: string;
boardId: number;
postType?: string;
contentAccess?: string;
accessPoints?: number;
typeMeta?: string;
updatedAt: number;
};
function parseTags(raw: string): string[] {
return [
...new Set(
raw
.split(/[,,]/)
.map((t) => t.trim())
.filter(Boolean)
),
].slice(0, 12);
}
function serializeTags(tags: string[]): string {
return tags.join(", ");
}
function mapPostError(msg: string): string {
if (!msg) return "操作失败";
if (msg.includes("24 小时") || msg.includes("24小时") || msg.includes("冷静")) {
return "新用户注册满 24 小时后才能发帖,请稍后再试";
}
if (msg.includes("积分")) return msg;
if (msg.includes("rate") || msg.includes("频繁") || msg.includes("太快")) {
return "操作过于频繁,请稍后再试";
}
return msg;
}
function readDraft(): ComposeDraft | null {
try {
const raw = localStorage.getItem(DRAFT_KEY);
if (!raw) return null;
const d = JSON.parse(raw) as ComposeDraft;
if (!d || typeof d !== "object") return null;
return d;
} catch {
return null;
}
}
function clearDraft() {
try {
localStorage.removeItem(DRAFT_KEY);
} catch {
/* ignore */
}
}
function writeDraft(d: ComposeDraft) {
try {
localStorage.setItem(DRAFT_KEY, JSON.stringify(d));
} catch {
/* ignore */
}
}
function formatSize(n: number) {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
type CreateProps = {
mode: "create";
initialBoardId?: number;
cancelHref?: string;
};
type EditProps = {
mode: "edit";
postId: number;
initialTitle: string;
initialContent: string;
initialTags?: string;
initialPostType?: string;
initialContentAccess?: string;
initialAccessPoints?: number;
initialTypeMeta?: string;
initialAttachments?: PostAttachment[];
/** 投票帖:有票后禁止改选项 */
canEditPollOptions?: boolean;
boardName?: string;
boardId?: number;
boardColorIndex?: number;
cancelHref: string;
};
export type PostComposerProps = CreateProps | EditProps;
export default function PostComposer(props: PostComposerProps) {
const router = useRouter();
const isCreate = props.mode === "create";
const [boards, setBoards] = useState<Board[]>([]);
const [boardsReady, setBoardsReady] = useState(!isCreate);
const [boardId, setBoardId] = useState(
isCreate ? props.initialBoardId || 0 : props.boardId || 0
);
const [title, setTitle] = useState(isCreate ? "" : props.initialTitle);
const [content, setContent] = useState(isCreate ? "" : props.initialContent);
const [tagList, setTagList] = useState<string[]>(
parseTags(isCreate ? "" : props.initialTags || "")
);
const [tagInput, setTagInput] = useState("");
const [postType, setPostType] = useState<PostTypeValue>(
normalizePostType(isCreate ? "discussion" : props.initialPostType)
);
const [contentAccess, setContentAccess] = useState<ContentAccessValue>(
(isCreate
? "public"
: (props.initialContentAccess as ContentAccessValue) || "public") as ContentAccessValue
);
const [accessPoints, setAccessPoints] = useState(
isCreate ? 10 : props.initialAccessPoints || 10
);
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(() => {
try {
const p = Number(JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}").points);
return p > 0 ? p : 20;
} catch {
return 20;
}
});
const [lotterySlots, setLotterySlots] = useState(() => {
try {
const s = Number(JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}").slots);
return s > 0 ? s : 1;
} catch {
return 1;
}
});
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 canEditPoll =
isCreate || (props.mode === "edit" && props.canEditPollOptions !== false);
const [attachments, setAttachments] = useState<PostAttachment[]>(
isCreate ? [] : props.initialAttachments || []
);
const [fileBusy, setFileBusy] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [draftBanner, setDraftBanner] = useState<ComposeDraft | null>(null);
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const skipDraftSave = useRef(true);
const formRef = useRef<HTMLFormElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const preferredBoardId = isCreate ? props.initialBoardId || 0 : 0;
useEffect(() => {
if (!isCreate) return;
let cancelled = false;
fetchBoards()
.then((data) => {
if (cancelled) return;
const list = data.boards || [];
setBoards(list);
setBoardsReady(true);
const preferred = preferredBoardId;
const hit = preferred && list.some((b) => b.id === preferred);
if (hit) {
setBoardId(preferred);
} else if (list.length > 0) {
setBoardId((cur) =>
cur && list.some((b) => b.id === cur) ? cur : list[0].id
);
}
const draft = readDraft();
if (
draft &&
(draft.title.trim() || draft.content.trim() || draft.tags.trim())
) {
setDraftBanner(draft);
skipDraftSave.current = true;
} else {
skipDraftSave.current = false;
}
})
.catch(() => {
if (!cancelled) {
setBoardsReady(true);
skipDraftSave.current = false;
}
});
return () => {
cancelled = true;
};
}, [isCreate, preferredBoardId]);
useEffect(() => {
if (!isCreate || skipDraftSave.current) return;
if (draftTimer.current) clearTimeout(draftTimer.current);
draftTimer.current = setTimeout(() => {
if (!title.trim() && !content.trim() && tagList.length === 0) {
clearDraft();
return;
}
writeDraft({
title,
content,
tags: serializeTags(tagList),
boardId,
postType,
contentAccess,
accessPoints,
typeMeta,
updatedAt: Date.now(),
});
}, DRAFT_DEBOUNCE_MS);
return () => {
if (draftTimer.current) clearTimeout(draftTimer.current);
};
}, [
isCreate,
title,
content,
tagList,
boardId,
postType,
contentAccess,
accessPoints,
typeMeta,
]);
const restoreDraft = () => {
if (!draftBanner) return;
setTitle(draftBanner.title || "");
setContent(draftBanner.content || "");
setTagList(parseTags(draftBanner.tags || ""));
if (draftBanner.boardId) setBoardId(draftBanner.boardId);
if (draftBanner.postType) setPostType(normalizePostType(draftBanner.postType));
if (draftBanner.contentAccess) {
setContentAccess(draftBanner.contentAccess as ContentAccessValue);
}
if (draftBanner.accessPoints) setAccessPoints(draftBanner.accessPoints);
if (draftBanner.typeMeta) setTypeMeta(draftBanner.typeMeta);
setDraftBanner(null);
skipDraftSave.current = false;
toast("已恢复草稿", "ok");
};
const discardDraft = () => {
clearDraft();
setDraftBanner(null);
skipDraftSave.current = false;
};
const commitTagInput = useCallback(() => {
const parts = parseTags(tagInput);
if (parts.length === 0) {
setTagInput("");
return;
}
setTagList((prev) => {
const next = [...prev];
for (const p of parts) {
if (next.length >= 12) break;
if (!next.includes(p)) next.push(p);
}
return next;
});
setTagInput("");
}, [tagInput]);
const removeTag = (t: string) => {
setTagList((prev) => prev.filter((x) => x !== t));
};
const 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,
});
}
if (postType === "bounty") {
return JSON.stringify({
points: Math.max(1, bountyPoints || 1),
accepted_comment_id: 0,
escrowed: false,
refunded: false,
expired: false,
expire_days: bountyExpireDays,
});
}
if (postType === "lottery") {
return JSON.stringify({
slots: Math.max(1, lotterySlots || 1),
drawn: false,
winner_ids: [],
closed: false,
expire_days: lotteryExpireDays,
});
}
return typeMeta.trim();
};
const onPickFile = async (file: File | null) => {
if (!file || fileBusy) return;
if (attachments.length >= 10) {
toast("最多 10 个附件", "error");
return;
}
setFileBusy(true);
try {
const res = await apiUploadPostFile(file, 0);
if (res.attachment) {
setAttachments((prev) => [...prev, res.attachment!]);
toast("附件已上传", "ok");
} else {
toast(res.error || "上传失败", "error");
}
} catch {
toast("上传失败", "error");
} finally {
setFileBusy(false);
if (fileRef.current) fileRef.current.value = "";
}
};
const removeAttachment = async (id: number) => {
setAttachments((prev) => prev.filter((a) => a.id !== id));
try {
await apiDeletePostFile(id);
} catch {
/* 本地已移除 */
}
};
const setAttPrice = async (id: number, price: number) => {
const p = Math.max(0, Math.min(100000, Math.floor(price) || 0));
setAttachments((prev) =>
prev.map((a) => (a.id === id ? { ...a, price_points: p } : a))
);
await apiUpdatePostFilePrice(id, p);
};
const canSubmit =
!loading &&
title.trim().length > 0 &&
content.trim().length > 0 &&
(isCreate ? boardsReady && boardId > 0 : true) &&
(contentAccess !== "points" || accessPoints > 0) &&
(postType !== "poll" ||
pollOptions.map((o) => o.trim()).filter(Boolean).length >= 2) &&
(postType !== "bounty" || bountyPoints > 0) &&
(postType !== "lottery" || lotterySlots > 0);
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
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,
content_access: contentAccess,
access_points: contentAccess === "points" ? accessPoints : 0,
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 {
setError(mapPostError(res.error || "发帖失败"));
}
} else {
const res = await apiUpdatePost(String(props.postId), {
title: title.trim(),
content: content.trim(),
tags,
content_access: contentAccess,
access_points: contentAccess === "points" ? accessPoints : 0,
type_meta: meta,
attachment_ids: attIds,
});
if (res.post) {
toast("已保存", "ok");
router.push(props.cancelHref);
} else {
setError(mapPostError(res.error || "保存失败"));
}
}
} catch {
setError("网络错误,请稍后重试");
} finally {
setLoading(false);
}
};
const cancelHref = isCreate ? props.cancelHref || "/" : props.cancelHref;
return (
<form ref={formRef} onSubmit={handleSubmit} className="j13-compose-stage">
<div className="j13-compose-body">
{draftBanner && (
<div
className="flex flex-wrap items-center justify-between gap-3 px-4 py-3 rounded-[12px] text-[13px] shrink-0"
style={{
background: "var(--accent-soft)",
color: "var(--ink)",
border:
"1px solid color-mix(in srgb, var(--accent) 18%, transparent)",
}}
>
<span>
检测到未发布的本地草稿
{draftBanner.updatedAt
? `(${new Date(draftBanner.updatedAt).toLocaleString("zh-CN")})`
: ""}
</span>
<span className="flex gap-2">
<button type="button" className="btn btn-primary btn-sm" onClick={restoreDraft}>
恢复
</button>
<button type="button" className="btn btn-line btn-sm" onClick={discardDraft}>
丢弃
</button>
</span>
</div>
)}
{error && <div className="alert-error shrink-0">{error}</div>}
{/* 帖子类型 */}
<div className="shrink-0">
<div className="field-label mb-2">帖子类型</div>
<div className="j13-compose-typebar" role="radiogroup" aria-label="帖子类型">
{POST_TYPES.map((t) => (
<button
key={t.value}
type="button"
role="radio"
aria-checked={postType === t.value}
className={`j13-compose-typechip${postType === t.value ? " is-on" : ""}`}
disabled={loading || (!isCreate && t.value !== postType)}
data-tip={!isCreate && t.value !== postType ? "编辑时不可更改类型" : t.hint}
onClick={() => setPostType(t.value)}
>
{t.label}
</button>
))}
</div>
</div>
{/* 元信息:板块 + 标签(标签 flex 占满) + 可见性 */}
<div className="j13-compose-meta shrink-0">
<div className="j13-compose-meta-board">
<label className="field-label" htmlFor="compose-board">
板块
</label>
{isCreate ? (
<BoardPicker
id="compose-board"
boards={boards}
value={boardId}
onChange={setBoardId}
disabled={loading}
loading={!boardsReady}
/>
) : (
<BoardBadge
name={props.boardName || "未知板块"}
colorIndex={props.boardColorIndex ?? 0}
/>
)}
</div>
<div className="j13-compose-meta-tags">
<label className="field-label" htmlFor="compose-tag-input">
标签
<span className="font-normal" style={{ color: "var(--ink-3)" }}>
{" "}
· 可选,最多 12 个
</span>
</label>
<div
className="j13-tag-field"
onClick={() => document.getElementById("compose-tag-input")?.focus()}
>
{tagList.map((t) => (
<span key={t} className="chip inline-flex items-center gap-1 !py-0.5">
# {t}
<button
type="button"
data-tip={`移除 ${t}`}
aria-label={`移除标签 ${t}`}
className="opacity-60 hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
removeTag(t);
}}
>
<X size={12} />
</button>
</span>
))}
<input
id="compose-tag-input"
value={tagInput}
disabled={loading || tagList.length >= 12}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === "," || e.key === ",") {
e.preventDefault();
commitTagInput();
} else if (e.key === "Backspace" && !tagInput && tagList.length > 0) {
removeTag(tagList[tagList.length - 1]);
}
}}
onBlur={commitTagInput}
placeholder={tagList.length === 0 ? "输入后回车添加" : ""}
className="j13-tag-field-input flex-1 min-w-[8rem] self-stretch h-8 text-[13px]"
style={{ color: "var(--ink)" }}
/>
</div>
</div>
<div className="j13-compose-meta-access">
<label className="field-label" htmlFor="compose-access">
正文可见性
</label>
<select
id="compose-access"
className="j13-compose-field"
value={contentAccess}
disabled={loading}
onChange={(e) => setContentAccess(e.target.value as ContentAccessValue)}
>
{CONTENT_ACCESS.map((a) => (
<option key={a.value} value={a.value}>
{a.label}
</option>
))}
</select>
{contentAccess === "points" && (
<div className="mt-2 flex items-center gap-2">
<label className="meta text-[12.5px] shrink-0" htmlFor="compose-access-pts">
所需积分
</label>
<input
id="compose-access-pts"
type="number"
min={1}
max={100000}
value={accessPoints}
disabled={loading}
onChange={(e) => setAccessPoints(Number(e.target.value) || 0)}
className="j13-compose-field !py-1.5 w-28"
/>
</div>
)}
</div>
</div>
{/* 类型专属面板 */}
{postType === "poll" && (
<div className="j13-compose-typepanel shrink-0 space-y-3">
<p className="font-bold 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>
)}
</div>
)}
{postType === "bounty" && (
<div className="j13-compose-typepanel shrink-0 space-y-3">
<p className="font-bold text-[14px]" style={{ color: "var(--ink)" }}>
悬赏积分
</p>
<p className="meta text-[12.5px]">
发布时从你的积分余额托管;到期未采纳将自动退回。采纳回答后发给对方。
</p>
<div className="flex flex-wrap items-center gap-2">
<input
type="number"
min={1}
max={100000}
value={bountyPoints}
disabled={loading || !isCreate}
onChange={(e) => setBountyPoints(Number(e.target.value) || 0)}
className="j13-compose-field w-36"
/>
<span className="meta text-[13px]">积分</span>
{!isCreate && (
<span className="meta text-[12.5px]">编辑不可更改已托管金额</span>
)}
</div>
{isCreate && (
<div className="flex flex-wrap items-center gap-2">
<span className="meta text-[13px]">过期天数</span>
{BOUNTY_EXPIRE_DAYS.map((d) => (
<button
key={d}
type="button"
className={`btn btn-sm ${bountyExpireDays === d ? "btn-primary" : "btn-line"}`}
disabled={loading}
onClick={() => setBountyExpireDays(d)}
>
{d} 天
</button>
))}
</div>
)}
</div>
)}
{postType === "lottery" && (
<div className="j13-compose-typepanel shrink-0 space-y-3">
<p className="font-bold text-[14px]" style={{ color: "var(--ink)" }}>
抽奖设置
</p>
<p className="meta text-[12.5px]">
对本帖发表评论的用户自动进入奖池(楼主除外);截止时可自动开奖。
</p>
<div className="flex items-center gap-2">
<label className="meta text-[13px]" htmlFor="lottery-slots">
中奖名额
</label>
<input
id="lottery-slots"
type="number"
min={1}
max={100}
value={lotterySlots}
disabled={loading || !isCreate}
onChange={(e) => setLotterySlots(Number(e.target.value) || 1)}
className="j13-compose-field w-28"
/>
</div>
{isCreate && (
<div className="flex flex-wrap items-center gap-2">
<span className="meta text-[13px]">截止天数</span>
{LOTTERY_EXPIRE_DAYS.map((d) => (
<button
key={d}
type="button"
className={`btn btn-sm ${lotteryExpireDays === d ? "btn-primary" : "btn-line"}`}
disabled={loading}
onClick={() => setLotteryExpireDays(d)}
>
{d} 天
</button>
))}
</div>
)}
</div>
)}
{/* 大标题 */}
<div className="shrink-0">
<div className="flex items-end justify-between gap-3 mb-1">
<label className="sr-only" htmlFor="compose-title">
标题
</label>
<span className="meta text-[12px] tabular-nums ml-auto">
{title.length}/{TITLE_MAX}
</span>
</div>
<input
id="compose-title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value.slice(0, TITLE_MAX))}
required
maxLength={TITLE_MAX}
placeholder="一句话说清楚你要讨论什么"
className="j13-compose-title"
disabled={loading}
/>
</div>
{/* 正文 */}
<div className="j13-compose-canvas">
<label className="sr-only" htmlFor="compose-content">
正文
</label>
<MarkdownEditor
id="compose-content"
value={content}
onChange={setContent}
disabled={loading}
rows={18}
placeholder={
"从这里开始写正文…\n\n支持 Markdown;可用工具栏插入格式与图片。可点全屏沉浸写作。"
}
onSubmitShortcut={() => {
if (canSubmit) void handleSubmit();
}}
/>
</div>
{/* 附件 */}
<div className="j13-compose-attach shrink-0">
<div className="flex items-center justify-between gap-3 mb-2">
<div className="field-label !mb-0">附件</div>
<button
type="button"
className="btn btn-line btn-sm inline-flex items-center gap-1.5"
disabled={loading || fileBusy || attachments.length >= 10}
onClick={() => fileRef.current?.click()}
>
<Paperclip size={14} />
{fileBusy ? "上传中…" : "添加文件"}
</button>
<input
ref={fileRef}
type="file"
className="hidden"
onChange={(e) => void onPickFile(e.target.files?.[0] ?? null)}
/>
</div>
<p className="meta text-[12.5px] mb-2">
单文件 ≤20MB,最多 10 个;可设免费或积分下载
</p>
{attachments.length === 0 ? (
<p className="meta text-[13px] py-2">暂无附件</p>
) : (
<ul className="space-y-2">
{attachments.map((a) => (
<li key={a.id} className="j13-compose-attach-row">
<Download size={15} className="shrink-0 opacity-50" />
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-[13.5px]">{a.name}</div>
<div className="meta text-[12px]">{formatSize(a.size)}</div>
</div>
<label className="meta text-[12px] flex items-center gap-1.5 shrink-0">
积分
<input
type="number"
min={0}
max={100000}
value={a.price_points}
disabled={loading}
onChange={(e) => void setAttPrice(a.id, Number(e.target.value))}
className="j13-compose-field !py-1 !px-2 w-20 text-[13px]"
data-tip="0 = 免费下载"
/>
</label>
<button
type="button"
className="btn btn-line btn-sm !px-2"
data-tip="移除附件"
aria-label="移除附件"
onClick={() => void removeAttachment(a.id)}
>
<X size={14} />
</button>
</li>
))}
</ul>
)}
</div>
</div>
<div className="j13-compose-actions">
<button
type="submit"
disabled={!canSubmit}
className="btn btn-primary min-w-[8.5rem] !px-7 !py-3 !text-[15px]"
>
{loading
? isCreate
? "发布中…"
: "保存中…"
: isCreate
? "发布帖子"
: "保存修改"}
</button>
{isCreate ? (
<Link href={cancelHref} className="btn btn-line !py-3">
取消
</Link>
) : (
<button
type="button"
onClick={() => router.push(cancelHref)}
className="btn btn-line !py-3"
>
取消
</button>
)}
<span className="meta text-[12.5px] sm:ml-auto max-w-md">
{isCreate
? "普通用户发帖需审核 · Ctrl/⌘+Enter 提交 · Esc 退出全屏"
: "保存后立即生效 · 类型不可更改"}
</span>
</div>
</form>
);
}