245 lines
6.9 KiB
TypeScript
245 lines
6.9 KiB
TypeScript
/**
|
||
* Web App 站点产物 API 客户端:能力探测 / 站点列表 / 构建 / 预览签名。
|
||
*
|
||
* 所有接口在后端能力不可用时返回 `{ available: false, reasons }`(HTTP 200),
|
||
* 调用方据此优雅降级——入口按钮不渲染、预览页显示未启用态。
|
||
*/
|
||
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
|
||
import { apiFetch } from "@/core/api/fetch-client";
|
||
import { getBackendBaseURL } from "@/core/config";
|
||
|
||
export interface WebAppCapabilityCheck {
|
||
ok: boolean;
|
||
detail: string;
|
||
}
|
||
|
||
export interface WebAppCapability {
|
||
available: boolean;
|
||
enabled: boolean;
|
||
template_dir: string;
|
||
node_bin: string;
|
||
checks: Record<string, WebAppCapabilityCheck>;
|
||
reasons?: string[];
|
||
}
|
||
|
||
export interface SiteNavItem {
|
||
page: string;
|
||
label: string;
|
||
}
|
||
|
||
export interface SiteDistInfo {
|
||
exists: boolean;
|
||
file_count?: number;
|
||
total_bytes?: number;
|
||
mtime?: number | null;
|
||
}
|
||
|
||
export interface SiteBuildResult {
|
||
success?: boolean;
|
||
error?: string;
|
||
duration_ms?: number;
|
||
stdout_tail?: string;
|
||
stderr_tail?: string;
|
||
finished_at?: number;
|
||
}
|
||
|
||
export interface SiteBuildJob {
|
||
job_id: string;
|
||
status: "running" | "success" | "failed";
|
||
started_at?: number;
|
||
finished_at?: number | null;
|
||
error?: string;
|
||
result?: SiteBuildResult;
|
||
}
|
||
|
||
export interface SiteInfo {
|
||
name: string;
|
||
title: string;
|
||
description: string;
|
||
nav: SiteNavItem[];
|
||
map: Record<string, unknown>;
|
||
manifest_mtime: number | null;
|
||
dist: SiteDistInfo;
|
||
last_build: SiteBuildResult | null;
|
||
build_job?: SiteBuildJob;
|
||
}
|
||
|
||
export interface SiteListResponse {
|
||
available: boolean;
|
||
enabled?: boolean;
|
||
reasons?: string[];
|
||
sites?: SiteInfo[];
|
||
}
|
||
|
||
/** 跨对话站点 gallery 条目:站点信息 + 来源会话 */
|
||
export interface SiteGalleryItem extends SiteInfo {
|
||
thread_id: string;
|
||
thread_title: string;
|
||
build_job?: SiteBuildJob;
|
||
}
|
||
|
||
export interface SiteGalleryResponse {
|
||
available: boolean;
|
||
enabled?: boolean;
|
||
reasons?: string[];
|
||
sites?: SiteGalleryItem[];
|
||
}
|
||
|
||
export interface SiteBuildStatusResponse {
|
||
available: boolean;
|
||
job_id?: string;
|
||
status?: "running" | "success" | "failed" | "idle";
|
||
started_at?: number;
|
||
finished_at?: number | null;
|
||
error?: string;
|
||
result?: SiteBuildResult;
|
||
last_build?: SiteBuildResult | null;
|
||
}
|
||
|
||
export interface SitePreviewUrlResponse {
|
||
available: boolean;
|
||
reasons?: string[];
|
||
url_path?: string;
|
||
token?: string;
|
||
expires_at?: number;
|
||
}
|
||
|
||
async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||
if (!res.ok) {
|
||
let detail = `${ctx} 失败(HTTP ${res.status})`;
|
||
try {
|
||
const body = (await res.json()) as { detail?: string };
|
||
if (body?.detail) detail = String(body.detail);
|
||
} catch {
|
||
// keep default
|
||
}
|
||
throw new Error(detail);
|
||
}
|
||
return (await res.json()) as T;
|
||
}
|
||
|
||
const baseUrl = () => `${getBackendBaseURL()}/api`;
|
||
|
||
export async function fetchWebAppCapability(): Promise<WebAppCapability> {
|
||
const res = await apiFetch(`${baseUrl()}/web-app/capability`);
|
||
return readJson<WebAppCapability>(res, "站点能力探测");
|
||
}
|
||
|
||
export async function listSites(threadId: string): Promise<SiteListResponse> {
|
||
const res = await apiFetch(`${baseUrl()}/threads/${encodeURIComponent(threadId)}/sites`);
|
||
return readJson<SiteListResponse>(res, "站点列表");
|
||
}
|
||
|
||
export async function listAllSites(): Promise<SiteGalleryResponse> {
|
||
const res = await apiFetch(`${baseUrl()}/web-app/sites`);
|
||
return readJson<SiteGalleryResponse>(res, "站点 gallery");
|
||
}
|
||
|
||
export async function buildSite(
|
||
threadId: string,
|
||
siteName: string,
|
||
): Promise<{ available: boolean; job_id?: string; status?: string; reasons?: string[] }> {
|
||
const res = await apiFetch(
|
||
`${baseUrl()}/threads/${encodeURIComponent(threadId)}/sites/${encodeURIComponent(siteName)}/build`,
|
||
{ method: "POST" },
|
||
);
|
||
return readJson(res, "触发构建");
|
||
}
|
||
|
||
export async function fetchSiteBuildStatus(
|
||
threadId: string,
|
||
siteName: string,
|
||
): Promise<SiteBuildStatusResponse> {
|
||
const res = await apiFetch(
|
||
`${baseUrl()}/threads/${encodeURIComponent(threadId)}/sites/${encodeURIComponent(siteName)}/build-status`,
|
||
);
|
||
return readJson<SiteBuildStatusResponse>(res, "构建状态");
|
||
}
|
||
|
||
export async function mintSitePreviewUrl(
|
||
threadId: string,
|
||
siteName: string,
|
||
): Promise<SitePreviewUrlResponse> {
|
||
const res = await apiFetch(
|
||
`${baseUrl()}/threads/${encodeURIComponent(threadId)}/sites/${encodeURIComponent(siteName)}/preview-url`,
|
||
{ method: "POST" },
|
||
);
|
||
return readJson<SitePreviewUrlResponse>(res, "签发预览地址");
|
||
}
|
||
|
||
/** 站点预览页路由(HashRouter 顶层,独立风格页面) */
|
||
export function siteStudioRoute(threadId: string, siteName: string): string {
|
||
return `/sites/${encodeURIComponent(threadId)}/${encodeURIComponent(siteName)}`;
|
||
}
|
||
|
||
const CAPABILITY_KEY = ["web-app", "capability"] as const;
|
||
const SITES_KEY = (threadId: string) => ["web-app", "sites", threadId] as const;
|
||
|
||
export function useWebAppCapability(enabled = true) {
|
||
return useQuery({
|
||
queryKey: CAPABILITY_KEY,
|
||
queryFn: fetchWebAppCapability,
|
||
enabled,
|
||
staleTime: 60_000,
|
||
gcTime: 5 * 60_000,
|
||
retry: 1,
|
||
refetchOnWindowFocus: false,
|
||
});
|
||
}
|
||
|
||
export function useSites(threadId: string | undefined, options?: { refetchInterval?: number }) {
|
||
return useQuery({
|
||
queryKey: SITES_KEY(threadId ?? ""),
|
||
queryFn: () => listSites(threadId as string),
|
||
enabled: !!threadId,
|
||
refetchInterval: options?.refetchInterval ?? false,
|
||
refetchOnWindowFocus: false,
|
||
});
|
||
}
|
||
|
||
export function useAllSites() {
|
||
return useQuery({
|
||
queryKey: ["web-app", "all-sites"] as const,
|
||
queryFn: listAllSites,
|
||
staleTime: 15_000,
|
||
refetchOnWindowFocus: false,
|
||
});
|
||
}
|
||
|
||
export function useSiteBuildStatus(
|
||
threadId: string | undefined,
|
||
siteName: string | undefined,
|
||
options?: {
|
||
refetchInterval?: number | false | ((query: { state: { data: SiteBuildStatusResponse | undefined } }) => number | false);
|
||
},
|
||
) {
|
||
return useQuery({
|
||
queryKey: ["web-app", "build-status", threadId ?? "", siteName ?? ""] as const,
|
||
queryFn: () => fetchSiteBuildStatus(threadId as string, siteName as string),
|
||
enabled: !!threadId && !!siteName,
|
||
refetchInterval: options?.refetchInterval ?? false,
|
||
refetchOnWindowFocus: false,
|
||
});
|
||
}
|
||
|
||
export function useBuildSiteMutation(threadId: string | undefined, siteName: string | undefined) {
|
||
const queryClient = useQueryClient();
|
||
return useMutation({
|
||
mutationFn: () => buildSite(threadId as string, siteName as string),
|
||
onSuccess: () => {
|
||
void queryClient.invalidateQueries({ queryKey: SITES_KEY(threadId ?? "") });
|
||
void queryClient.invalidateQueries({
|
||
queryKey: ["web-app", "build-status", threadId ?? "", siteName ?? ""],
|
||
});
|
||
},
|
||
});
|
||
}
|
||
|
||
export function useMintPreviewUrlMutation(threadId: string | undefined, siteName: string | undefined) {
|
||
return useMutation({
|
||
mutationFn: () => mintSitePreviewUrl(threadId as string, siteName as string),
|
||
});
|
||
}
|