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

@@ -141,6 +141,9 @@ export interface PostListItem {
title: string;
tags: string;
post_type: string;
type_status?: string;
content_access?: string;
access_points?: number;
pinned: number;
recommended: boolean;
like_count: number;
@@ -157,6 +160,16 @@ export interface PostListItem {
user: User;
}
export interface PostAttachment {
id: number;
name: string;
size: number;
mime: string;
price_points: number;
download_count: number;
unlocked: boolean;
}
export interface Post {
id: number;
board_id: number;
@@ -165,6 +178,10 @@ export interface Post {
content: string;
tags: string;
post_type: string;
content_access?: string;
access_points?: number;
type_meta?: string;
type_status?: string;
pinned: number;
recommended: boolean;
status: string;
@@ -176,6 +193,80 @@ export interface Post {
updated_at: string;
board: Board;
user: User;
content_locked?: boolean;
access_hint?: string;
attachments?: PostAttachment[];
question?: QuestionState | null;
poll?: PollState | null;
bounty?: BountyState | null;
lottery?: LotteryState | null;
}
export interface QuestionState {
solved: boolean;
accepted_comment_id: number;
can_accept: boolean;
can_solve: boolean;
can_reopen: boolean;
}
export interface PollVoter {
id: number;
nickname: string;
username: string;
avatar: string;
}
export interface PollOptionStat {
index: number;
text: string;
votes: number;
percent: number;
voters?: PollVoter[];
}
export interface PollState {
options: PollOptionStat[];
multi: boolean;
closed: boolean;
anonymous: boolean;
total_votes: number;
voters: number;
my_options: number[];
can_vote: boolean;
can_close: boolean;
can_edit_options: boolean;
}
export interface BountyState {
points: number;
accepted_comment_id: number;
escrowed: boolean;
refunded: boolean;
expired?: boolean;
ends_at?: string;
settled: boolean;
can_accept: boolean;
can_refund: boolean;
}
export interface LotteryEntrant {
id: number;
nickname: string;
username: string;
avatar: string;
}
export interface LotteryState {
slots: number;
drawn: boolean;
closed: boolean;
ends_at?: string;
entry_count: number;
eligible?: boolean;
can_draw: boolean;
can_close: boolean;
winners: LotteryEntrant[];
}
export interface Comment {
@@ -528,6 +619,10 @@ export async function apiCreatePost(data: {
content: string;
tags?: string;
post_type?: string;
content_access?: string;
access_points?: number;
type_meta?: string;
attachment_ids?: number[];
}) {
const res = await fetchWithRefresh("/api/posts", {
method: "POST",
@@ -548,7 +643,15 @@ export async function apiCreateComment(postId: string, content: string, parentId
export async function apiUpdatePost(
postId: string,
data: { title?: string; content?: string; tags?: string }
data: {
title?: string;
content?: string;
tags?: string;
content_access?: string;
access_points?: number;
type_meta?: string;
attachment_ids?: number[];
}
) {
const res = await fetchWithRefresh(`/api/posts/${postId}`, {
method: "PUT",
@@ -558,6 +661,181 @@ export async function apiUpdatePost(
return res.json();
}
export async function apiUnlockPost(postId: string) {
const res = await fetchWithRefresh(`/api/posts/${postId}/unlock`, {
method: "POST",
headers: clientHeaders(),
});
return res.json();
}
export async function apiGetPoints(): Promise<{ points: number }> {
const res = await fetchWithRefresh("/api/points", {
method: "GET",
headers: clientHeaders(),
});
return res.json();
}
export interface PointLedgerItem {
id: number;
delta: number;
balance: number;
reason: string;
ref_type: string;
ref_id: number;
note: string;
created_at: string;
}
export async function apiGetPointsLedger(
page = 1,
size = 20
): Promise<{ items: PointLedgerItem[]; total: number; page: number; size: number; error?: string }> {
const res = await fetchWithRefresh(`/api/points/ledger?page=${page}&size=${size}`, {
method: "GET",
headers: clientHeaders(),
});
return res.json();
}
export interface EconomyStats {
total_balance: number;
ledger_count: number;
checkin_today: number;
open_bounties: number;
escrowed_points: number;
posts_by_type: Record<string, number>;
recent_ledger: PointLedgerItem[];
}
export async function apiAdminEconomyStats(): Promise<EconomyStats & { error?: string }> {
const res = await fetchWithRefresh("/api/admin/economy", {
method: "GET",
headers: clientHeaders(),
});
return res.json();
}
export async function fetchAdminEconomyStats(
cookieHeader?: string
): Promise<EconomyStats> {
const res = await fetch(`${API_BASE}/api/admin/economy`, ssrInit(cookieHeaders(cookieHeader)));
if (!res.ok) throw new Error("failed");
return res.json();
}
export async function apiUploadPostFile(
file: File,
pricePoints = 0
): Promise<{ attachment?: PostAttachment; error?: string }> {
const fd = new FormData();
fd.append("file", file);
fd.append("price_points", String(pricePoints));
const res = await fetchWithRefresh("/api/upload/file", {
method: "POST",
headers: clientHeaders(),
body: fd,
});
return res.json();
}
export async function apiDeletePostFile(id: number) {
const res = await fetchWithRefresh(`/api/upload/file/${id}`, {
method: "DELETE",
headers: clientHeaders(),
});
return res.json();
}
export async function apiUpdatePostFilePrice(id: number, pricePoints: number) {
const res = await fetchWithRefresh(`/api/upload/file/${id}/price`, {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ price_points: pricePoints }),
});
return res.json();
}
export function postAttachmentDownloadURL(postId: number | string, attId: number) {
return `/api/posts/${postId}/attachments/${attId}/download`;
}
export async function apiVotePoll(postId: string, options: number[]) {
const res = await fetchWithRefresh(`/api/posts/${postId}/poll/vote`, {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ options }),
});
return res.json();
}
export async function apiClosePoll(postId: string) {
const res = await fetchWithRefresh(`/api/posts/${postId}/poll/close`, {
method: "POST",
headers: clientHeaders(),
});
return res.json();
}
export async function apiAcceptBounty(postId: string, commentId: number) {
const res = await fetchWithRefresh(`/api/posts/${postId}/bounty/accept`, {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ comment_id: commentId }),
});
return res.json();
}
export async function apiRefundBounty(postId: string) {
const res = await fetchWithRefresh(`/api/posts/${postId}/bounty/refund`, {
method: "POST",
headers: clientHeaders(),
});
return res.json();
}
export async function apiAcceptQuestion(postId: string, commentId: number) {
const res = await fetchWithRefresh(`/api/posts/${postId}/question/accept`, {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ comment_id: commentId }),
});
return res.json();
}
export async function apiSolveQuestion(postId: string) {
const res = await fetchWithRefresh(`/api/posts/${postId}/question/solve`, {
method: "POST",
headers: clientHeaders(),
});
return res.json();
}
export async function apiReopenQuestion(postId: string) {
const res = await fetchWithRefresh(`/api/posts/${postId}/question/reopen`, {
method: "POST",
headers: clientHeaders(),
});
return res.json();
}
export async function apiCloseLottery(postId: string) {
const res = await fetchWithRefresh(`/api/posts/${postId}/lottery/close`, {
method: "POST",
headers: clientHeaders(),
});
return res.json();
}
export async function apiDrawLottery(postId: string) {
const res = await fetchWithRefresh(`/api/posts/${postId}/lottery/draw`, {
method: "POST",
headers: clientHeaders(),
});
return res.json();
}
export async function apiDeletePost(postId: string) {
const res = await fetchWithRefresh(`/api/posts/${postId}`, {
method: "DELETE",
@@ -630,6 +908,25 @@ export async function apiUploadAvatar(
return res.json();
}
// 上传帖子插图(JPEG / PNG / WebP,≤5MB)
export async function apiUploadPostImage(
file: File | Blob,
filename = "image.jpg"
): Promise<{ url?: string; attachment?: MediaAttachment; error?: string }> {
const form = new FormData();
const name =
file instanceof File && file.name
? file.name
: filename;
form.append("file", file, name);
const res = await fetchWithRefresh("/api/upload/image", {
method: "POST",
headers: clientHeaders(),
body: form,
});
return res.json();
}
// 选用一张本人历史上传的头像
export async function apiUseAvatar(
url: string

14
frontend/lib/points.ts Normal file
View File

@@ -0,0 +1,14 @@
/** 积分流水原因文案 */
const REASON_LABELS: Record<string, string> = {
checkin: "每日签到",
unlock_post: "解锁正文",
download_file: "附件下载",
migrate_checkin: "历史签到回填",
bounty_escrow: "悬赏托管",
bounty_refund: "悬赏退回",
bounty_award: "悬赏采纳",
};
export function pointReasonLabel(reason: string): string {
return REASON_LABELS[reason] || reason || "其他";
}

61
frontend/lib/postMeta.ts Normal file
View File

@@ -0,0 +1,61 @@
/** 帖子类型 / 可见性 / 展示文案(前后端共用约定) */
export const POST_TYPES = [
{ value: "discussion", label: "讨论", hint: "默认讨论帖" },
{ value: "question", label: "问答", hint: "提问求解" },
{ value: "poll", label: "投票", hint: "发起投票,读者可参与" },
{ value: "bounty", label: "悬赏", hint: "发布时托管积分,采纳后发放" },
{ value: "lottery", label: "抽奖", hint: "回帖即参与,截止自动开奖" },
] as const;
export type PostTypeValue = (typeof POST_TYPES)[number]["value"];
export const CONTENT_ACCESS = [
{ value: "public", label: "公开" },
{ value: "login", label: "登录可见" },
{ value: "reply", label: "回复可见" },
{ value: "points", label: "积分可见" },
] as const;
export type ContentAccessValue = (typeof CONTENT_ACCESS)[number]["value"];
export const BOUNTY_EXPIRE_DAYS = [3, 7, 14, 30] as const;
export const LOTTERY_EXPIRE_DAYS = [1, 3, 7, 14] as const;
export function normalizePostType(t?: string): PostTypeValue {
if (t === "normal" || !t) return "discussion";
if (POST_TYPES.some((x) => x.value === t)) return t as PostTypeValue;
return "discussion";
}
export function postTypeLabel(t?: string): string {
const n = normalizePostType(t);
return POST_TYPES.find((x) => x.value === n)?.label || "讨论";
}
export function contentAccessLabel(a?: string): string {
const hit = CONTENT_ACCESS.find((x) => x.value === a);
return hit?.label || "公开";
}
/** 列表 type_status → 徽章文案 */
export function typeStatusLabel(postType?: string, typeStatus?: string): string {
const pt = normalizePostType(postType);
const s = (typeStatus || "").toLowerCase();
switch (pt) {
case "question":
return s === "solved" ? "已解决" : "未解决";
case "poll":
return s === "closed" ? "已结束" : "进行中";
case "bounty":
if (s === "solved") return "已采纳";
if (s === "expired") return "已过期";
return "进行中";
case "lottery":
if (s === "drawn") return "已开奖";
if (s === "closed") return "已截止";
return "进行中";
default:
return "";
}
}