feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user