kioskApi.ts
9.53 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
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
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
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
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
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
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
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);
}