/** * 平台端图片上传:对接 `/api/app/picture/category/upload`(见《平台端Categories图片上传接口说明》)。 * 返回的 `url` 为相对路径(如 `/picture/category/xxx.png`),保存到业务字段;展示时用 `resolvePictureUrlForDisplay`。 */ /** 与文档一致:POST multipart,字段 file + 可选 subDir */ export const PICTURE_CATEGORY_UPLOAD_PATH = "/api/app/picture/category/upload"; function joinBaseAndPath(baseUrl: string, path: string): string { const b = baseUrl.replace(/\/$/, ""); const p = path.startsWith("/") ? path : `/${path}`; return `${b}${p}`; } /** 与后端限制一致:5MB */ export const PICTURE_UPLOAD_MAX_BYTES = 5 * 1024 * 1024; export type UploadImageOptions = { /** 可选子目录,如 `category`、`product`;禁止包含 `..` */ subDir?: string; signal?: AbortSignal; }; function getApiBaseUrl(): string { return (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace(/\/$/, "") ?? "http://localhost:19001"; } function getToken(): string | null { try { return localStorage.getItem("access_token") ?? localStorage.getItem("token"); } catch { return null; } } function pickUrlFromPayload(x: unknown): string | null { if (typeof x === "string" && x.trim()) { const t = x.trim(); if (/^https?:\/\//i.test(t) || t.startsWith("/") || t.startsWith("data:")) return t; } if (!x || typeof x !== "object") return null; const o = x as Record; for (const k of ["url", "Url", "fileUrl", "FileUrl", "imageUrl", "ImageUrl", "path", "Path"]) { const v = o[k]; if (typeof v === "string" && v.trim()) return v.trim(); } return null; } function unwrapData(payload: unknown): unknown { if (!payload || typeof payload !== "object") return payload; const o = payload as Record; if ("data" in o && o.data !== undefined) return o.data; if ("result" in o && o.result !== undefined) return o.result; return payload; } function messageFromErrorPayload(payload: unknown, status: number): string { if (payload && typeof payload === "object") { const o = payload as Record; const err = o.errors ?? o.Errors; if (typeof err === "string" && err.trim()) return err.trim(); const nested = o.error as { message?: string } | undefined; if (nested && typeof nested.message === "string" && nested.message.trim()) return nested.message.trim(); } return `Upload failed (${status})`; } /** * 将保存的地址转为浏览器可请求的绝对 URL(相对 `/picture/...` 会拼上 API 宿主)。 */ export function resolvePictureUrlForDisplay(stored: string): string { const s = (stored ?? "").trim(); if (!s) return ""; if (s.startsWith("data:")) return s; if (/^https?:\/\//i.test(s)) return s; const base = getApiBaseUrl(); return s.startsWith("/") ? `${base}${s}` : `${base}/${s}`; } function validateFile(file: File): void { if (file.size > PICTURE_UPLOAD_MAX_BYTES) { throw new Error("Image must be 5 MB or smaller."); } const okMime = /^(image\/(jpeg|png|webp|gif))$/i.test(file.type); const okExt = /\.(jpe?g|png|webp|gif)$/i.test(file.name); if (!okMime && !okExt) { throw new Error("Only JPG, PNG, WebP, and GIF are allowed."); } } function readFileAsDataUrl(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const r = reader.result; if (typeof r === "string") resolve(r); else reject(new Error("Failed to read file.")); }; reader.onerror = () => reject(new Error("Failed to read file.")); reader.readAsDataURL(file); }); } /** * 上传图片;返回后端 `PictureUploadOutputDto.url`(相对路径,可写入 CategoryPhotoUrl 等字段)。 */ export async function uploadImageFile(file: File, options?: UploadImageOptions): Promise { if (import.meta.env.VITE_IMAGE_UPLOAD_MOCK === "true") { validateFile(file); return readFileAsDataUrl(file); } validateFile(file); const sub = options?.subDir?.trim(); if (sub && sub.includes("..")) { throw new Error("Invalid subDir."); } const uploadUrl = joinBaseAndPath(getApiBaseUrl(), PICTURE_CATEGORY_UPLOAD_PATH); const fd = new FormData(); fd.append("file", file); if (sub) fd.append("subDir", sub); const headers: Record = {}; const token = getToken(); if (token) headers.Authorization = `Bearer ${token}`; const res = await fetch(uploadUrl, { method: "POST", body: fd, headers, signal: options?.signal, }); const text = await res.text(); let payload: unknown = text; try { payload = text ? JSON.parse(text) : null; } catch { payload = text; } if (!res.ok) { throw new Error(messageFromErrorPayload(payload, res.status)); } const inner = unwrapData(payload); const url = pickUrlFromPayload(inner) ?? pickUrlFromPayload(payload); if (!url) { throw new Error("Upload response did not contain a usable image URL."); } return url; }