kioskApi.ts 9.53 KB
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<T> = { 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<unknown> {
  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<T>(path: string): Promise<ApiResult<T>> {
  const res = await fetch(apiUrl(path), {
    method: "GET",
    headers: {
      Accept: "application/json",
      "X-Requested-With": "XMLHttpRequest",
    },
  });
  const body = (await parseBody(res)) as ApiResult<T>;
  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<boolean> {
  const res = await kioskGet<KioskBundle>("/api/kiosk/bundle");
  if (!apiCodeOk(res.code) || !res.data) {
    return false;
  }
  applyServerBundle(res.data);
  return true;
}

export async function fetchDataDisplay(): Promise<DataDisplayPayload | null> {
  const res = await kioskGet<DataDisplayPayload>("/api/kiosk/data-display");
  if (!apiCodeOk(res.code) || !res.data) return null;
  return res.data;
}

export async function fetchObservatoryHistory(): Promise<ObservatoryHistoryPayload["items"] | null> {
  const res = await kioskGet<ObservatoryHistoryPayload>("/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<string, string> = {
    "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<string, string> = {
    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<string> {
  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<boolean> {
  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<unknown>;
  return res.ok && apiCodeOk(body.code);
}

export async function pushWelcomeToServer(w: WelcomeMessages): Promise<boolean> {
  const res = await fetch(apiUrl("/api/admin/kiosk/welcome"), {
    method: "POST",
    headers: adminHeaders(),
    body: JSON.stringify(w),
  });
  const body = (await parseBody(res)) as ApiResult<unknown>;
  return res.ok && apiCodeOk(body.code);
}

export async function pushGuideToServer(guide: GuideOverrides): Promise<boolean> {
  const res = await fetch(apiUrl("/api/admin/kiosk/guide"), {
    method: "POST",
    headers: adminHeaders(),
    body: JSON.stringify({ guide }),
  });
  const body = (await parseBody(res)) as ApiResult<unknown>;
  return res.ok && apiCodeOk(body.code);
}

/**
 * 全量同步知识库。成功时 resolve;失败时 throw(网络、序列化、或接口返回错误),便于展示具体原因。
 */
export async function pushKnowledgeToServer(entries: KnowledgeEntry[]): Promise<void> {
  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<unknown>;
  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<boolean> {
  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<unknown>;
  return res.ok && apiCodeOk(body.code);
}

export async function pushCarouselBackgroundsToServer(urls: string[]): Promise<boolean> {
  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<unknown>;
  return res.ok && apiCodeOk(body.code);
}