- {variant === "question" && (
-
问答 · 已解决
- )}
-
-
-
- {label}
-
-
-
-
-
-
- {name}
-
-
-
- {answer.floor > 0 && (
- <>
- ·
-
- #{answer.floor}
-
- >
- )}
-
-
-
+
+
+
+
+ {isQuestion ? (
+
+ ) : (
+
+ )}
+ {title}
+
+ {statusLabel}
- {showReopen &&
}
-
+
+
+
+
+
+ {answerLabel}
+
+
+
+
+
+
+ {name}
+
+
+
+ {answer.floor > 0 && (
+ <>
+ ·
+
+ #{answer.floor}
+
+ >
+ )}
+
+
+
{answer.deleted ? (
@@ -81,6 +88,25 @@ export default async function AcceptedAnswerCard({
)}
+
+
+
+
+ {isQuestion ? "已采纳答案" : "悬赏已发放"}
+
+ {endLabel ? (
+ 结束时间: {endLabel}
+ ) : null}
+
);
}
+
+function formatEndsAt(iso?: string) {
+ if (!iso) return "";
+ try {
+ return new Date(iso).toLocaleString("zh-CN");
+ } catch {
+ return iso;
+ }
+}
diff --git a/frontend/components/AcceptedAnswerReopen.tsx b/frontend/components/AcceptedAnswerReopen.tsx
deleted file mode 100644
index 5189442..0000000
--- a/frontend/components/AcceptedAnswerReopen.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { useRouter } from "next/navigation";
-import { apiReopenQuestion } from "@/lib/api";
-import { toast } from "@/lib/toast";
-
-/** 已采纳答案卡上的「重新打开」——并入正文下方资料卡,避免与上方状态条重复 */
-export default function AcceptedAnswerReopen({ postId }: { postId: number }) {
- const router = useRouter();
- const [busy, setBusy] = useState(false);
-
- const reopen = async () => {
- setBusy(true);
- try {
- const res = await apiReopenQuestion(String(postId));
- if (res.post) {
- toast("已重新打开", "ok");
- router.refresh();
- } else toast(res.error || "操作失败", "error");
- } finally {
- setBusy(false);
- }
- };
-
- return (
-
- );
-}
diff --git a/frontend/components/DeadlinePicker.tsx b/frontend/components/DeadlinePicker.tsx
new file mode 100644
index 0000000..97a78b6
--- /dev/null
+++ b/frontend/components/DeadlinePicker.tsx
@@ -0,0 +1,130 @@
+"use client";
+
+/** 发帖截止时间:快捷天数 + 自定义时刻 */
+
+export type DeadlineMode = "preset" | "custom";
+
+function pad2(n: number) {
+ return String(n).padStart(2, "0");
+}
+
+/** datetime-local 值(本地时区) */
+export function toLocalInputValue(d: Date): string {
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
+}
+
+/** 默认自定义:从现在起 +N 天 */
+export function defaultCustomLocal(days = 7): string {
+ const d = new Date();
+ d.setDate(d.getDate() + days);
+ d.setSeconds(0, 0);
+ return toLocalInputValue(d);
+}
+
+/** 本地 datetime-local → RFC3339(UTC) */
+export function localInputToRFC3339(local: string): string | null {
+ const t = Date.parse(local);
+ if (!Number.isFinite(t)) return null;
+ return new Date(t).toISOString().replace(/\.\d{3}Z$/, "Z");
+}
+
+export function formatLocalPreview(local: string): string {
+ const t = Date.parse(local);
+ if (!Number.isFinite(t)) return "";
+ try {
+ return new Date(t).toLocaleString("zh-CN");
+ } catch {
+ return local;
+ }
+}
+
+export default function DeadlinePicker({
+ label = "截止时间",
+ presets,
+ mode,
+ onModeChange,
+ days,
+ onDaysChange,
+ customLocal,
+ onCustomLocalChange,
+ disabled,
+ hint,
+}: {
+ label?: string;
+ presets: readonly number[];
+ mode: DeadlineMode;
+ onModeChange: (m: DeadlineMode) => void;
+ days: number;
+ onDaysChange: (d: number) => void;
+ customLocal: string;
+ onCustomLocalChange: (v: string) => void;
+ disabled?: boolean;
+ hint?: string;
+}) {
+ const minLocal = toLocalInputValue(new Date(Date.now() + 60 * 60 * 1000));
+ const maxLocal = toLocalInputValue(new Date(Date.now() + 90 * 24 * 60 * 60 * 1000));
+
+ return (
+
+
{label}
+
+
+
+
+
+ {mode === "preset" ? (
+
+ {presets.map((d) => (
+
+ ))}
+
+ ) : (
+
+
onCustomLocalChange(e.target.value)}
+ aria-label="指定截止时刻"
+ />
+ {customLocal ? (
+
将于 {formatLocalPreview(customLocal)} 截止
+ ) : null}
+
+ )}
+ {hint ?
{hint}
: null}
+
+ );
+}
diff --git a/frontend/components/PostComposer.tsx b/frontend/components/PostComposer.tsx
index 9f0965d..48df7bb 100644
--- a/frontend/components/PostComposer.tsx
+++ b/frontend/components/PostComposer.tsx
@@ -10,6 +10,7 @@ import {
apiUploadPostFile,
apiDeletePostFile,
apiUpdatePostFilePrice,
+ apiGetPoints,
fetchBoards,
type Board,
type PostAttachment,
@@ -19,6 +20,7 @@ import {
CONTENT_ACCESS,
BOUNTY_EXPIRE_DAYS,
LOTTERY_EXPIRE_DAYS,
+ POLL_EXPIRE_DAYS,
normalizePostType,
type PostTypeValue,
type ContentAccessValue,
@@ -26,6 +28,11 @@ import {
import { toast } from "@/lib/toast";
import MarkdownEditor from "@/components/MarkdownEditor";
import BoardPicker, { BoardBadge } from "@/components/BoardPicker";
+import DeadlinePicker, {
+ type DeadlineMode,
+ defaultCustomLocal,
+ localInputToRFC3339,
+} from "@/components/DeadlinePicker";
const TITLE_MAX = 256;
const DRAFT_KEY = "j13-compose-draft";
@@ -127,6 +134,8 @@ type EditProps = {
initialAttachments?: PostAttachment[];
/** 投票帖:有票后禁止改选项 */
canEditPollOptions?: boolean;
+ /** 抽奖帖:有评论后禁止改奖品 */
+ canEditLotteryPrizes?: boolean;
boardName?: string;
boardId?: number;
boardColorIndex?: number;
@@ -192,20 +201,36 @@ export default function PostComposer(props: PostComposerProps) {
return true;
}
});
- const [bountyPoints, setBountyPoints] = useState(() => {
+ const [bountyPoints, setBountyPoints] = useState
(() => {
+ // 新建:空值 + placeholder(与抽奖数量输入一致);编辑回填已托管金额
+ if (isCreate) return "";
try {
- const p = Number(JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}").points);
- return p > 0 ? p : 20;
+ const p = Number(JSON.parse(props.initialTypeMeta || "{}").points);
+ return p > 0 ? p : "";
} catch {
- return 20;
+ return "";
}
});
- const [lotterySlots, setLotterySlots] = useState(() => {
+ const [lotteryPrizes, setLotteryPrizes] = useState<{ name: string; count: number | "" }[]>(() => {
+ // 新建:空行 + placeholder;编辑才回填已保存奖品
+ if (isCreate) return [{ name: "", count: "" }];
try {
- const s = Number(JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}").slots);
- return s > 0 ? s : 1;
+ const raw = JSON.parse(props.initialTypeMeta || "{}") as {
+ prizes?: { name?: string; count?: number }[];
+ slots?: 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),
+ }));
+ }
+ // 旧数据仅有 slots:数量可回填,名称留空让作者补全
+ const slots = Number(raw.slots);
+ if (slots > 0) return [{ name: "", count: slots }];
+ return [{ name: "", count: "" }];
} catch {
- return 1;
+ return [{ name: "", count: "" }];
}
});
const [bountyExpireDays, setBountyExpireDays] = useState(() => {
@@ -224,19 +249,43 @@ export default function PostComposer(props: PostComposerProps) {
return 7;
}
});
+ const [pollExpireDays, setPollExpireDays] = useState(7);
+ const [deadlineMode, setDeadlineMode] = useState("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(
isCreate ? [] : props.initialAttachments || []
);
const [fileBusy, setFileBusy] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
+ /** 发帖页余额:仅新建悬赏时拉取,用于就地提示积分不足 */
+ const [pointsBalance, setPointsBalance] = useState(null);
const [draftBanner, setDraftBanner] = useState(null);
const draftTimer = useRef | null>(null);
const skipDraftSave = useRef(true);
const formRef = useRef(null);
const fileRef = useRef(null);
+ const actionsErrorRef = useRef(null);
+ const bountyFieldRef = useRef(null);
+ const pendingErrorFocus = useRef<"actions" | "bounty" | null>(null);
const preferredBoardId = isCreate ? props.initialBoardId || 0 : 0;
@@ -280,6 +329,27 @@ export default function PostComposer(props: PostComposerProps) {
};
}, [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);
@@ -359,6 +429,15 @@ export default function PostComposer(props: PostComposerProps) {
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") {
@@ -370,25 +449,33 @@ export default function PostComposer(props: PostComposerProps) {
multi: pollMulti,
anonymous: pollAnonymous,
closed: false,
+ ...buildDeadlineFields(pollExpireDays),
});
}
if (postType === "bounty") {
return JSON.stringify({
- points: Math.max(1, bountyPoints || 1),
+ points: typeof bountyPoints === "number" ? Math.max(1, Math.floor(bountyPoints)) : 1,
accepted_comment_id: 0,
escrowed: false,
refunded: false,
expired: false,
- expire_days: bountyExpireDays,
+ ...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({
- slots: Math.max(1, lotterySlots || 1),
+ prizes,
+ slots: prizes.reduce((s, p) => s + p.count, 0),
drawn: false,
winner_ids: [],
closed: false,
- expire_days: lotteryExpireDays,
+ ...buildDeadlineFields(lotteryExpireDays),
});
}
return typeMeta.trim();
@@ -434,6 +521,13 @@ export default function PostComposer(props: PostComposerProps) {
await apiUpdatePostFilePrice(id, p);
};
+ const bountyShort =
+ isCreate &&
+ postType === "bounty" &&
+ pointsBalance !== null &&
+ typeof bountyPoints === "number" &&
+ bountyPoints > pointsBalance;
+
const canSubmit =
!loading &&
title.trim().length > 0 &&
@@ -442,11 +536,65 @@ export default function PostComposer(props: PostComposerProps) {
(contentAccess !== "points" || accessPoints > 0) &&
(postType !== "poll" ||
pollOptions.map((o) => o.trim()).filter(Boolean).length >= 2) &&
- (postType !== "bounty" || bountyPoints > 0) &&
- (postType !== "lottery" || lotterySlots > 0);
+ (postType !== "bounty" ||
+ (typeof bountyPoints === "number" && bountyPoints > 0)) &&
+ (postType !== "lottery" || lotteryPrizesReady);
+
+ /** 提交失败:底部操作区可见 + Toast;积分类滚到悬赏输入 */
+ const reportSubmitError = useCallback((msg: string, focus: "actions" | "bounty" = "actions") => {
+ pendingErrorFocus.current = focus;
+ setError(msg);
+ toast(msg, "error");
+ }, []);
+
+ useEffect(() => {
+ if (!error) return;
+ const focus = pendingErrorFocus.current ?? "actions";
+ pendingErrorFocus.current = null;
+ const el =
+ focus === "bounty" && bountyFieldRef.current
+ ? bountyFieldRef.current
+ : actionsErrorRef.current;
+ el?.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ }, [error]);
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
+ if (
+ isCreate &&
+ deadlineMode === "custom" &&
+ (postType === "poll" || postType === "bounty" || postType === "lottery")
+ ) {
+ const iso = localInputToRFC3339(customEndsLocal);
+ if (!iso) {
+ reportSubmitError("请选择有效的截止时间");
+ return;
+ }
+ const t = Date.parse(iso);
+ if (!Number.isFinite(t) || t < Date.now() + 60 * 60 * 1000) {
+ reportSubmitError("截止时间至少晚于当前 1 小时");
+ return;
+ }
+ }
+ if (postType === "lottery" && !lotteryPrizesReady) {
+ reportSubmitError("请填写奖品名称与数量后再发布");
+ return;
+ }
+ if (
+ isCreate &&
+ postType === "bounty" &&
+ (typeof bountyPoints !== "number" || bountyPoints < 1)
+ ) {
+ reportSubmitError("请填写悬赏积分后再发布", "bounty");
+ return;
+ }
+ if (bountyShort) {
+ reportSubmitError(
+ `积分不足(余额 ${pointsBalance},需要 ${bountyPoints})`,
+ "bounty"
+ );
+ return;
+ }
if (!canSubmit) return;
setLoading(true);
setError("");
@@ -475,7 +623,11 @@ export default function PostComposer(props: PostComposerProps) {
}
router.push(`/post/${res.post.id}`);
} else {
- setError(mapPostError(res.error || "发帖失败"));
+ const mapped = mapPostError(res.error || "发帖失败");
+ reportSubmitError(
+ mapped,
+ mapped.includes("积分") ? "bounty" : "actions"
+ );
}
} else {
const res = await apiUpdatePost(String(props.postId), {
@@ -491,20 +643,56 @@ export default function PostComposer(props: PostComposerProps) {
toast("已保存", "ok");
router.push(props.cancelHref);
} else {
- setError(mapPostError(res.error || "保存失败"));
+ reportSubmitError(mapPostError(res.error || "保存失败"));
}
}
} catch {
- setError("网络错误,请稍后重试");
+ reportSubmitError("网络错误,请稍后重试");
} finally {
setLoading(false);
}
};
const cancelHref = isCreate ? props.cancelHref || "/" : props.cancelHref;
+ const submitLabel = loading
+ ? isCreate
+ ? "发布中…"
+ : "保存中…"
+ : isCreate
+ ? "发布"
+ : "保存";
+ const cancelControl = isCreate ? (
+
+ 取消
+
+ ) : (
+
+ );
return (