import type { GuideOverrides, KnowledgeEntry, KioskServerBundle, VideoSourceConfig, WelcomeMessages, } from "../kioskStorage"; import { applyServerBundle } from "../kioskStorage"; /** * API 根地址。 * - 若设置 `VITE_API_BASE_URL`(非空):开发与生产均使用该地址(浏览器直连该主机,需后端 CORS 正确)。 * - 生产构建且未设置:空字符串 → 同域 `/api/...`,由 Nginx 反代到 PHP。 * - 开发且未设置:空字符串 → 与 Vite 同源(默认 9999),由 `vite.config.ts` 把 `/api`、`/uploads` 代理到后端,避免 9999→9901 跨域导致 Failed to fetch。 */ export function apiBase(): string { const raw = import.meta.env.VITE_API_BASE_URL; if (typeof raw === "string" && raw.trim() !== "") { return raw.replace(/\/$/, ""); } return ""; } export function apiUrl(path: string): string { const p = path.startsWith("/") ? path : `/${path}`; return `${apiBase()}${p}`; } export type ApiResult = { code: number; data?: T; msg?: string }; export type KioskBundle = KioskServerBundle; /** 避免 ThinkPHP APP_DEBUG 下 trace 在 JSON 后追加脚本导致整段无法 parse */ function parseJsonTextLoose(text: string): unknown { const trimmed = text.trim(); if (!trimmed) return {}; try { return JSON.parse(trimmed); } catch { const s = trimmed.indexOf("{"); const e = trimmed.lastIndexOf("}"); if (s >= 0 && e > s) { try { return JSON.parse(trimmed.slice(s, e + 1)); } catch { return {}; } } return {}; } } async function parseBody(res: Response): Promise { const t = await res.text(); return parseJsonTextLoose(t); } export function apiCodeOk(code: unknown): boolean { return code === 0 || code === "0"; } function formatSyncError(e: unknown): string { return e instanceof Error ? e.message : String(e); } export async function kioskGet(path: string): Promise> { const res = await fetch(apiUrl(path), { method: "GET", headers: { Accept: "application/json", "X-Requested-With": "XMLHttpRequest", }, }); const body = (await parseBody(res)) as ApiResult; if (!res.ok && body.code === undefined) { return { code: res.status, msg: res.statusText }; } return body; } export type DataDisplayPayload = { realtimeImages: Array<{ id: number; name: string; time: string; telescope: string; exposure: string; image: string; }>; status: { weather: string; seeing: string; transparency: string; moonPhase: string; }; historicalData: Array<{ date: string; observations: number; quality: string; weather: string; }>; }; export type ObservatoryHistoryPayload = { items: Array<{ id: number; kind: string; title: string; summary: string; date: string; thumb: string; }>; }; /** 从后端拉取 bundle 并写入内存(不写 localStorage) */ export async function fetchAndApplyKioskBundle(): Promise { const res = await kioskGet("/api/kiosk/bundle"); if (!apiCodeOk(res.code) || !res.data) { return false; } applyServerBundle(res.data); return true; } export async function fetchDataDisplay(): Promise { const res = await kioskGet("/api/kiosk/data-display"); if (!apiCodeOk(res.code) || !res.data) return null; return res.data; } export async function fetchObservatoryHistory(): Promise { const res = await kioskGet("/api/kiosk/observatory-history"); if (!apiCodeOk(res.code) || !res.data?.items) return null; return res.data.items; } function adminHeaders(): HeadersInit { const token = import.meta.env.VITE_ADMIN_API_TOKEN ?? ""; const h: Record = { "Content-Type": "application/json", Accept: "application/json", "X-Requested-With": "XMLHttpRequest", }; if (token) h["X-Admin-Token"] = token; return h; } /** multipart 上传勿带 Content-Type,由浏览器自动带 boundary */ function adminHeadersMultipart(): HeadersInit { const token = import.meta.env.VITE_ADMIN_API_TOKEN ?? ""; const h: Record = { Accept: "application/json", "X-Requested-With": "XMLHttpRequest", }; if (token) h["X-Admin-Token"] = token; return h; } /** 知识库 image / videoUrl:data URL、绝对 URL 原样返回;否则拼 API 根(开发直连后端、生产同域相对路径) */ export function resolveKioskMediaUrl(stored: string | undefined | null): string { if (stored == null) return ""; const s = stored.trim(); if (s === "") return ""; if (s.startsWith("data:") || s.startsWith("http://") || s.startsWith("https://")) return s; const base = apiBase(); if (s.startsWith("/")) return base ? `${base}${s}` : s; return base ? `${base}/${s}` : `/${s}`; } /** 上传封面或视频,成功返回 `/uploads/knowledge/...`;失败 throw(含服务器 msg) */ export async function uploadKnowledgeMediaFile(file: File): Promise { const fd = new FormData(); fd.append("file", file); let res: Response; try { res = await fetch(apiUrl("/api/admin/knowledge/upload-media"), { method: "POST", headers: adminHeadersMultipart(), body: fd, }); } catch (e) { throw new Error( `无法连接上传接口(${formatSyncError(e)})。请确认后端已启动且可访问 ${apiUrl("/api/admin/knowledge/upload-media")}。` ); } const body = (await parseBody(res)) as ApiResult<{ url?: string }> & { url?: unknown }; const fromData = body.data?.url; const fromTop = body.url; const url = typeof fromData === "string" && fromData ? fromData : typeof fromTop === "string" && fromTop ? fromTop : ""; if (!res.ok || !apiCodeOk(body.code) || !url) { const detail = typeof body.msg === "string" && body.msg.trim() !== "" ? body.msg : `HTTP ${res.status} · ${JSON.stringify(body).slice(0, 400)}`; throw new Error(`上传失败:${detail}`); } return url; } export async function pushHomeBackgroundsToServer(urls: string[]): Promise { const res = await fetch(apiUrl("/api/admin/kiosk/home-backgrounds"), { method: "POST", headers: adminHeaders(), body: JSON.stringify({ urls }), }); const body = (await parseBody(res)) as ApiResult; return res.ok && apiCodeOk(body.code); } export async function pushWelcomeToServer(w: WelcomeMessages): Promise { const res = await fetch(apiUrl("/api/admin/kiosk/welcome"), { method: "POST", headers: adminHeaders(), body: JSON.stringify(w), }); const body = (await parseBody(res)) as ApiResult; return res.ok && apiCodeOk(body.code); } export async function pushGuideToServer(guide: GuideOverrides): Promise { const res = await fetch(apiUrl("/api/admin/kiosk/guide"), { method: "POST", headers: adminHeaders(), body: JSON.stringify({ guide }), }); const body = (await parseBody(res)) as ApiResult; return res.ok && apiCodeOk(body.code); } /** * 全量同步知识库。成功时 resolve;失败时 throw(网络、序列化、或接口返回错误),便于展示具体原因。 */ export async function pushKnowledgeToServer(entries: KnowledgeEntry[]): Promise { let body: string; try { body = JSON.stringify({ entries }); } catch (e) { throw new Error( `知识库 JSON 序列化失败(常见原因:库里仍有旧版写入的超大 base64 图片/视频,整表一起提交时超出浏览器限制):${formatSyncError(e)}` ); } const approxMb = Math.round(body.length / (1024 * 1024)); if (body.length > 60 * 1024 * 1024) { throw new Error( `本次同步请求体约 ${approxMb}MB,过大。请删除或编辑仍含「data:image/…」「data:video/…」大内联媒体的条目后重试,或调大 PHP post_max_size / Nginx client_max_body_size。` ); } let res: Response; try { res = await fetch(apiUrl("/api/admin/knowledge/sync"), { method: "POST", headers: adminHeaders(), body, }); } catch (e) { throw new Error( `无法连接保存接口(${formatSyncError(e)})。说明:页面在 ${typeof window !== "undefined" ? window.location.origin : ""},接口在 ${apiUrl("/api/admin/knowledge/sync")};本机开发请运行 daocheng-api(composer run dev 或 php think run -p 9901),并与 .env.development 中 VITE_DEV_PROXY_TARGET 端口一致。` ); } const parsed = (await parseBody(res)) as ApiResult; if (!res.ok || !apiCodeOk(parsed.code)) { const detail = typeof parsed.msg === "string" && parsed.msg.trim() !== "" ? parsed.msg : `${res.status} ${res.statusText}`.trim(); throw new Error(`服务器拒绝保存:${detail}`); } } export async function pushVideoSourceToServer(cfg: VideoSourceConfig): Promise { const res = await fetch(apiUrl("/api/admin/kiosk/video-source"), { method: "POST", headers: adminHeaders(), body: JSON.stringify({ protocol: cfg.protocol, url: cfg.url, note: cfg.note, }), }); const body = (await parseBody(res)) as ApiResult; return res.ok && apiCodeOk(body.code); } export async function pushCarouselBackgroundsToServer(urls: string[]): Promise { const res = await fetch(apiUrl("/api/admin/kiosk/carousel-backgrounds"), { method: "POST", headers: adminHeaders(), body: JSON.stringify({ urls }), }); const body = (await parseBody(res)) as ApiResult; return res.ok && apiCodeOk(body.code); }