Blame view

天文台pc/tianwentai-ui/src/app/api/kioskApi.ts 9.53 KB
3a3dc915   王天杨   feat: 稻城亚丁项目批量更新
1
2
3
4
5
6
  import type {
    GuideOverrides,
    KnowledgeEntry,
    KioskServerBundle,
    VideoSourceConfig,
    WelcomeMessages,
bc518174   王天杨   提交两个项目文件
7
  } from "../kioskStorage";
3a3dc915   王天杨   feat: 稻城亚丁项目批量更新
8
  import { applyServerBundle } from "../kioskStorage";
bc518174   王天杨   提交两个项目文件
9
  
3a3dc915   王天杨   feat: 稻城亚丁项目批量更新
10
11
12
13
14
15
  /**
   * API 根地址。
   * - 若设置 `VITE_API_BASE_URL`(非空):开发与生产均使用该地址(浏览器直连该主机,需后端 CORS 正确)。
   * - 生产构建且未设置:空字符串 → 同域 `/api/...`,由 Nginx 反代到 PHP。
   * - 开发且未设置:空字符串 → 与 Vite 同源(默认 9999),由 `vite.config.ts` 把 `/api`、`/uploads` 代理到后端,避免 9999→9901 跨域导致 Failed to fetch。
   */
bc518174   王天杨   提交两个项目文件
16
  export function apiBase(): string {
3a3dc915   王天杨   feat: 稻城亚丁项目批量更新
17
18
19
20
21
    const raw = import.meta.env.VITE_API_BASE_URL;
    if (typeof raw === "string" && raw.trim() !== "") {
      return raw.replace(/\/$/, "");
    }
    return "";
bc518174   王天杨   提交两个项目文件
22
23
24
25
26
27
28
29
30
  }
  
  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 };
  
3a3dc915   王天杨   feat: 稻城亚丁项目批量更新
31
32
33
34
35
36
  export type KioskBundle = KioskServerBundle;
  
  /** 避免 ThinkPHP APP_DEBUG 下 trace 在 JSON 后追加脚本导致整段无法 parse */
  function parseJsonTextLoose(text: string): unknown {
    const trimmed = text.trim();
    if (!trimmed) return {};
bc518174   王天杨   提交两个项目文件
37
    try {
3a3dc915   王天杨   feat: 稻城亚丁项目批量更新
38
      return JSON.parse(trimmed);
bc518174   王天杨   提交两个项目文件
39
    } catch {
3a3dc915   王天杨   feat: 稻城亚丁项目批量更新
40
41
42
43
44
45
46
47
48
      const s = trimmed.indexOf("{");
      const e = trimmed.lastIndexOf("}");
      if (s >= 0 && e > s) {
        try {
          return JSON.parse(trimmed.slice(s, e + 1));
        } catch {
          return {};
        }
      }
bc518174   王天杨   提交两个项目文件
49
50
51
52
      return {};
    }
  }
  
3a3dc915   王天杨   feat: 稻城亚丁项目批量更新
53
54
55
56
57
58
59
60
61
62
63
64
65
  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);
  }
  
bc518174   王天杨   提交两个项目文件
66
67
68
  export async function kioskGet<T>(path: string): Promise<ApiResult<T>> {
    const res = await fetch(apiUrl(path), {
      method: "GET",
3a3dc915   王天杨   feat: 稻城亚丁项目批量更新
69
70
71
72
      headers: {
        Accept: "application/json",
        "X-Requested-With": "XMLHttpRequest",
      },
bc518174   王天杨   提交两个项目文件
73
74
75
76
77
78
79
80
    });
    const body = (await parseBody(res)) as ApiResult<T>;
    if (!res.ok && body.code === undefined) {
      return { code: res.status, msg: res.statusText };
    }
    return body;
  }
  
bc518174   王天杨   提交两个项目文件
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
  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;
    }>;
  };
  
3a3dc915   王天杨   feat: 稻城亚丁项目批量更新
115
  /** 从后端拉取 bundle 并写入内存(不写 localStorage) */
bc518174   王天杨   提交两个项目文件
116
117
  export async function fetchAndApplyKioskBundle(): Promise<boolean> {
    const res = await kioskGet<KioskBundle>("/api/kiosk/bundle");
3a3dc915   王天杨   feat: 稻城亚丁项目批量更新
118
    if (!apiCodeOk(res.code) || !res.data) {
bc518174   王天杨   提交两个项目文件
119
120
      return false;
    }
3a3dc915   王天杨   feat: 稻城亚丁项目批量更新
121
    applyServerBundle(res.data);
bc518174   王天杨   提交两个项目文件
122
123
124
125
126
    return true;
  }
  
  export async function fetchDataDisplay(): Promise<DataDisplayPayload | null> {
    const res = await kioskGet<DataDisplayPayload>("/api/kiosk/data-display");
3a3dc915   王天杨   feat: 稻城亚丁项目批量更新
127
    if (!apiCodeOk(res.code) || !res.data) return null;
bc518174   王天杨   提交两个项目文件
128
129
130
131
132
    return res.data;
  }
  
  export async function fetchObservatoryHistory(): Promise<ObservatoryHistoryPayload["items"] | null> {
    const res = await kioskGet<ObservatoryHistoryPayload>("/api/kiosk/observatory-history");
3a3dc915   王天杨   feat: 稻城亚丁项目批量更新
133
    if (!apiCodeOk(res.code) || !res.data?.items) return null;
bc518174   王天杨   提交两个项目文件
134
135
136
137
138
    return res.data.items;
  }
  
  function adminHeaders(): HeadersInit {
    const token = import.meta.env.VITE_ADMIN_API_TOKEN ?? "";
3a3dc915   王天杨   feat: 稻城亚丁项目批量更新
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
    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",
    };
bc518174   王天杨   提交两个项目文件
155
156
157
158
    if (token) h["X-Admin-Token"] = token;
    return h;
  }
  
3a3dc915   王天杨   feat: 稻城亚丁项目批量更新
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
  /** 知识库 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;
bc518174   王天杨   提交两个项目文件
203
204
205
206
207
208
209
210
211
  }
  
  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>;
3a3dc915   王天杨   feat: 稻城亚丁项目批量更新
212
    return res.ok && apiCodeOk(body.code);
bc518174   王天杨   提交两个项目文件
213
214
215
216
217
218
219
220
221
  }
  
  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>;
3a3dc915   王天杨   feat: 稻城亚丁项目批量更新
222
    return res.ok && apiCodeOk(body.code);
bc518174   王天杨   提交两个项目文件
223
224
225
226
227
228
229
230
231
  }
  
  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>;
3a3dc915   王天杨   feat: 稻城亚丁项目批量更新
232
    return res.ok && apiCodeOk(body.code);
bc518174   王天杨   提交两个项目文件
233
234
  }
  
3a3dc915   王天杨   feat: 稻城亚丁项目批量更新
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
  /**
   * 全量同步知识库。成功时 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"), {
bc518174   王天杨   提交两个项目文件
279
280
      method: "POST",
      headers: adminHeaders(),
3a3dc915   王天杨   feat: 稻城亚丁项目批量更新
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
      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 }),
bc518174   王天杨   提交两个项目文件
296
297
    });
    const body = (await parseBody(res)) as ApiResult<unknown>;
3a3dc915   王天杨   feat: 稻城亚丁项目批量更新
298
    return res.ok && apiCodeOk(body.code);
bc518174   王天杨   提交两个项目文件
299
  }