feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等

This commit is contained in:
2026-10-09 18:53:38 +08:00
parent 5598e1cbb7
commit da72843c70
64 changed files with 7047 additions and 248 deletions

View File

@@ -4987,3 +4987,403 @@ export async function apiAdminSetFriendLinkStatus(
export async function apiAdminDeleteFriendLink(id: number): Promise<{ ok: boolean }> {
return apiOperations(`/api/admin/friend-links/${id}`, "DELETE");
}
// ===== 代码片段(广场 + 我的 + 管理) =====
/** 片段可见性 */
export type SnippetVisibility = "public" | "private";
/** 片段广场/列表条目(不含代码全文,preview 为代码前缀) */
export interface SnippetListItem {
id: number;
title: string;
description: string;
language: string;
visibility: SnippetVisibility;
user_id: number;
forked_from_id?: number | null;
copy_count: number;
view_count: number;
comment_count: number;
fork_count: number;
preview: string;
user?: LibraryCreator | null;
created_at: string;
updated_at: string;
}
/** 片段详情(含代码全文与作者/Fork 来源摘要) */
export interface SnippetDetail {
id: number;
title: string;
description: string;
language: string;
code: string;
visibility: SnippetVisibility;
user_id: number;
forked_from_id?: number | null;
copy_count: number;
view_count: number;
comment_count: number;
fork_count: number;
created_at: string;
updated_at: string;
user?: LibraryCreator | null;
forked_from?: { id: number; title: string } | null;
}
/** 片段版本历史条目(编辑前旧值快照) */
export interface SnippetVersionItem {
id: number;
snippet_id: number;
editor_id: number;
old_title: string;
old_language: string;
old_description: string;
old_code: string;
created_at: string;
}
/** 片段评论(楼层树:parent_id 空=主评) */
export interface SnippetCommentItem {
id: number;
snippet_id: number;
user_id: number;
parent_id?: number | null;
root_id?: number | null;
depth: number;
content: string;
floor?: number;
deleted?: boolean;
user?: LibraryCreator | null;
replies?: SnippetCommentItem[];
edited?: boolean;
created_at: string;
updated_at: string;
}
export interface SnippetInput {
title: string;
language: string;
description?: string;
code: string;
visibility: SnippetVisibility;
}
export type SnippetSort = "latest" | "copy" | "comment" | "fork";
/** SSR / 公开:片段广场列表 */
export async function fetchSnippets(opts: {
q?: string;
lang?: string;
sort?: SnippetSort;
page?: number;
size?: number;
cookieHeader?: string;
}): Promise<{ items: SnippetListItem[]; total: number }> {
const params = new URLSearchParams();
if (opts.q) params.set("q", opts.q);
if (opts.lang) params.set("lang", opts.lang);
if (opts.sort) params.set("sort", opts.sort);
params.set("page", String(opts.page ?? 1));
params.set("size", String(opts.size ?? 12));
const res = await fetch(
`${API_BASE}/api/snippets?${params}`,
ssrInit(cookieHeaders(opts.cookieHeader))
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取片段列表失败");
return { items: data.items ?? [], total: data.total ?? 0 };
}
/** SSR / 公开:语言白名单 */
export async function fetchSnippetLanguages(): Promise<{ languages: string[] }> {
const res = await fetch(`${API_BASE}/api/snippets/languages`, ssrInit());
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取语言列表失败");
return { languages: data.languages ?? [] };
}
export interface SnippetLangStat {
language: string;
count: number;
}
export interface SnippetStats {
total_snippets: number;
total_copies: number;
total_views: number;
total_comments: number;
total_forks: number;
contributors: number;
top_languages: SnippetLangStat[];
}
/** SSR / 公开:片段广场聚合统计 */
export async function fetchSnippetStats(): Promise<SnippetStats> {
const res = await fetch(`${API_BASE}/api/snippets/stats`, ssrInit());
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取片段统计失败");
return (data.stats ?? {
total_snippets: 0,
total_copies: 0,
total_views: 0,
total_comments: 0,
total_forks: 0,
contributors: 0,
top_languages: [],
}) as SnippetStats;
}
/** SSR:片段详情(公开或本人/管理可见私有;404/403 返回 null) */
export async function fetchSnippetById(
id: number,
cookieHeader?: string
): Promise<{ snippet: SnippetDetail } | null> {
const res = await fetch(`${API_BASE}/api/snippets/${id}`, ssrInit(cookieHeaders(cookieHeader)));
if (res.status === 404 || res.status === 403) return null;
if (!res.ok) throw new Error("获取片段失败");
return res.json();
}
/** 客户端:片段详情(带登录凭证,私有片段仅 owner/staff 可见) */
export async function apiGetSnippet(id: number): Promise<{ snippet: SnippetDetail } | null> {
const res = await fetchWithRefresh(`/api/snippets/${id}`, { headers: clientHeaders() });
if (res.status === 404 || res.status === 403) return null;
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取片段失败");
return data as { snippet: SnippetDetail };
}
/** SSR / 公开:片段评论(主评分页 + 回复) */
export async function fetchSnippetComments(
id: number,
page = 1,
size = 20,
cookieHeader?: string
): Promise<{ items: SnippetCommentItem[]; total: number }> {
const res = await fetch(
`${API_BASE}/api/snippets/${id}/comments?page=${page}&size=${size}`,
ssrInit(cookieHeaders(cookieHeader))
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取评论失败");
return { items: data.items ?? [], total: data.total ?? 0 };
}
/** SSR / 公开:片段的公开 Fork 列表 */
export async function fetchSnippetForks(
id: number,
page = 1,
size = 20
): Promise<{ items: SnippetListItem[]; total: number }> {
const res = await fetch(`${API_BASE}/api/snippets/${id}/forks?page=${page}&size=${size}`, ssrInit());
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取 Fork 列表失败");
return { items: data.items ?? [], total: data.total ?? 0 };
}
/** 客户端:我的片段(含私有) */
export async function apiMySnippets(
q = "",
page = 1,
size = 12
): Promise<{ items: SnippetListItem[]; total: number }> {
const params = new URLSearchParams({ page: String(page), size: String(size) });
if (q) params.set("q", q);
const res = await fetchWithRefresh(`/api/snippets/mine?${params}`, { headers: clientHeaders() });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取我的片段失败");
return { items: data.items ?? [], total: data.total ?? 0 };
}
/** 客户端:创建片段 */
export async function apiCreateSnippet(input: SnippetInput): Promise<{ snippet: SnippetDetail }> {
const res = await fetchWithRefresh("/api/snippets", {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "创建片段失败");
return data as { snippet: SnippetDetail };
}
/** 客户端:编辑片段(owner/staff;服务端落版本快照) */
export async function apiUpdateSnippet(
id: number,
input: SnippetInput
): Promise<{ snippet: SnippetDetail }> {
const res = await fetchWithRefresh(`/api/snippets/${id}`, {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "保存片段失败");
return data as { snippet: SnippetDetail };
}
/** 客户端:删除片段(owner/staff) */
export async function apiDeleteSnippet(id: number): Promise<void> {
const res = await fetchWithRefresh(`/api/snippets/${id}`, {
method: "DELETE",
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "删除片段失败");
}
}
/** 客户端:Fork 公开片段为自己的私有副本 */
export async function apiForkSnippet(id: number): Promise<{ snippet: SnippetDetail }> {
const res = await fetchWithRefresh(`/api/snippets/${id}/fork`, {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Fork 失败");
return data as { snippet: SnippetDetail };
}
/** 客户端:复制计数(节流由服务端按用户/IP 控制;失败静默) */
export async function apiRecordSnippetCopy(id: number): Promise<void> {
try {
await fetchWithRefresh(`/api/snippets/${id}/copy`, {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
});
} catch {
/* 计数失败不影响复制本身 */
}
}
/** 客户端:版本历史(登录可见) */
export async function apiSnippetHistory(
id: number,
page = 1,
size = 20
): Promise<{ items: SnippetVersionItem[]; total: number }> {
const res = await fetchWithRefresh(`/api/snippets/${id}/history?page=${page}&size=${size}`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取版本历史失败");
return { items: data.items ?? [], total: data.total ?? 0 };
}
/** 客户端:发表片段评论(parent_id 非空为回复) */
export async function apiCreateSnippetComment(
id: number,
content: string,
parentId?: number
): Promise<{ comment: SnippetCommentItem }> {
const res = await fetchWithRefresh(`/api/snippets/${id}/comments`, {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ content, parent_id: parentId || undefined }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "评论失败");
return data as { comment: SnippetCommentItem };
}
/** 客户端:编辑片段评论(仅本人) */
export async function apiUpdateSnippetComment(
id: number,
cid: number,
content: string
): Promise<{ comment: SnippetCommentItem }> {
const res = await fetchWithRefresh(`/api/snippets/${id}/comments/${cid}`, {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ content }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "保存评论失败");
return data as { comment: SnippetCommentItem };
}
/** 客户端:删除片段评论(本人/staff) */
export async function apiDeleteSnippetComment(id: number, cid: number): Promise<void> {
const res = await fetchWithRefresh(`/api/snippets/${id}/comments/${cid}`, {
method: "DELETE",
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "删除评论失败");
}
}
export interface SnippetCommentHistoryItem {
id: number;
editor: { id: number; username: string; nickname: string; avatar: string } | null;
old_content: string;
created_at: string;
}
/** 客户端:片段评论编辑历史(登录可见) */
export async function apiSnippetCommentHistory(
id: number,
cid: number,
page = 1,
size = 10
): Promise<{ items: SnippetCommentHistoryItem[]; total: number; page: number; size: number }> {
const params = new URLSearchParams({ page: String(page), size: String(size) });
const res = await fetchWithRefresh(`/api/snippets/${id}/comments/${cid}/history?${params}`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取编辑历史失败");
return {
items: data.items ?? [],
total: data.total ?? 0,
page: data.page ?? page,
size: data.size ?? size,
};
}
/** 客户端:片段搜索(嵌入弹窗等场景;公开广场 + 可含我的) */
export async function apiSearchSnippets(opts: {
q?: string;
page?: number;
size?: number;
}): Promise<{ items: SnippetListItem[]; total: number }> {
const params = new URLSearchParams();
if (opts.q) params.set("q", opts.q);
params.set("page", String(opts.page ?? 1));
params.set("size", String(opts.size ?? 10));
const res = await fetchWithRefresh(`/api/snippets?${params}`, { headers: clientHeaders() });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "搜索片段失败");
return { items: data.items ?? [], total: data.total ?? 0 };
}
/** 管理端:片段全量列表 */
export async function apiAdminSnippets(opts: {
q?: string;
visibility?: string;
page?: number;
size?: number;
}): Promise<{ items: SnippetListItem[]; total: number }> {
const params = new URLSearchParams({ page: String(opts.page ?? 1), size: String(opts.size ?? 30) });
if (opts.q) params.set("q", opts.q);
if (opts.visibility) params.set("visibility", opts.visibility);
return apiOperations(`/api/admin/snippets?${params}`);
}
/** 管理端:删除片段(软删 + 原因) */
export async function apiAdminDeleteSnippet(id: number, reason = ""): Promise<{ ok: boolean }> {
return apiOperations(`/api/admin/snippets/${id}`, "DELETE", reason ? { reason } : undefined);
}
/** 管理端:强制修改片段可见性(下架为 private / 恢复 public) */
export async function apiAdminSetSnippetVisibility(
id: number,
visibility: SnippetVisibility
): Promise<{ ok: boolean }> {
return apiOperations(`/api/admin/snippets/${id}/visibility`, "PUT", { visibility });
}