imageUploadService.ts 4.97 KB
/**
 * 平台端图片上传:对接 `/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<string, unknown>;
  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<string, unknown>;
  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<string, unknown>;
    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<string> {
  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<string> {
  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<string, string> = {};
  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;
}