| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572 |
- import { useUserStore } from "@/stores/modules/user";
- import { useSimpleUploadOverlayStore } from "@/stores/modules/simpleUploadOverlay";
- import { ElMessage } from "element-plus";
- import { AI_UPLOAD_FILES_PUBLIC_BASE, BASE_AI_URL } from "@/utils/config";
- import { withSimpleUploadOverlay } from "@/utils/withSimpleUploadOverlay";
- /** 简单上传的路径(不含 base) */
- const SIMPLE_UPLOAD_PATH = "/upload/simple";
- /**
- * 已由 uploadFilesToOss 弹出过 ElMessage,调用方勿重复 error
- * @param {unknown} err
- */
- export function isUploadApiErrorAlreadyMessaged(err) {
- return Boolean(err && typeof err === "object" && /** @type {{ __uploadMessageShown?: boolean }} */ (err).__uploadMessageShown);
- }
- /**
- * 用户取消上传(关闭弹层、AbortSignal)或请求被顶替:不向用户弹出技术性英文(如 signal is aborted without reason)
- * @param {unknown} err
- * @returns {boolean}
- */
- export function isUploadUserCancelledError(err) {
- if (err == null || typeof err !== "object") return false;
- const o = /** @type {{ name?: unknown; message?: unknown }} */ (err);
- if (String(o.name ?? "") === "AbortError") return true;
- const msg = String(o.message ?? "").toLowerCase();
- if (!msg) return false;
- return (
- msg.includes("signal is aborted") ||
- msg.includes("the user aborted") ||
- msg.includes("aborted a request") ||
- msg.includes("body stream was aborted")
- );
- }
- /** 从路径或 fileType 解析文件后缀(如 jpg、mp4),用于生成带格式的文件名 */
- export function getFileExtension(filePath, fileType) {
- const pathPart = (filePath || "").split("?")[0];
- const lastSegment = pathPart.split("/").pop() || "";
- const dot = lastSegment.lastIndexOf(".");
- if (dot > 0) {
- const ext = lastSegment.slice(dot + 1).toLowerCase();
- if (/^[a-z0-9]+$/i.test(ext) && ext.length <= 5) return ext;
- }
- if (fileType === "video") return "mp4";
- if (fileType === "image") return "jpg";
- return "jpg";
- }
- /**
- * 将入参统一为 File[]
- * @param {File | File[] | FileList} input
- * @returns {File[]}
- */
- function normalizeFiles(input) {
- if (input == null) return [];
- if (input instanceof File) return [input];
- if (typeof FileList !== "undefined" && input instanceof FileList) {
- return Array.from(input);
- }
- if (Array.isArray(input)) {
- return input.filter(item => item instanceof File);
- }
- throw new Error("请传入 File、File[] 或 FileList");
- }
- /**
- * 常见「可直接用于 img/video src」的字段(含蛇形命名)
- * 注意:勿把 upload_id / filename 放前面——/upload/simple 会返回 upload_id 与完整 URL 并存,
- * 若先按「裸 id」拼到 /files/ 会得到错误地址,图片会裂图。
- */
- const URL_LIKE_KEYS = [
- "download_url",
- "downloadUrl",
- "preview_url",
- "previewUrl",
- "url",
- "fileUrl",
- "file_url",
- "accessUrl",
- "access_url",
- "ossUrl",
- "cdnUrl",
- "path",
- "filePath",
- "file_path",
- "objectKey",
- "object_key",
- "key",
- "location",
- "href"
- ];
- /**
- * 将接口返回的路径或 id 规范为可访问 URL
- * @param {string} raw
- * @returns {string}
- */
- function normalizeToFileUrl(raw) {
- const t = String(raw ?? "").trim();
- if (!t) return "";
- if (/^https?:\/\//i.test(t)) return t;
- if (t.startsWith("//")) return `https:${t}`;
- const filesBase = String(AI_UPLOAD_FILES_PUBLIC_BASE || "").replace(/\/$/, "");
- if (filesBase.startsWith("http")) {
- try {
- if (t.startsWith("/")) {
- return new URL(t, new URL(`${filesBase}/`).origin).href;
- }
- if (/^files\//i.test(t)) {
- return new URL(`/${t.replace(/^\/+/, "")}`, new URL(`${filesBase}/`).origin).href;
- }
- // 相对路径 dir/file.ext(无协议)
- if (t.includes("/") && !t.startsWith("/") && !/^[a-z]+:/i.test(t)) {
- return `${filesBase}/${t.replace(/^\/+/, "")}`;
- }
- // 仅返回 uuid/文件名 等,按「对外文件基址 + 片段」拼接
- if (!t.includes("/") && !t.includes("\\") && t.length <= 512) {
- return `${filesBase}/${encodeURIComponent(t)}`;
- }
- } catch {
- /* ignore */
- }
- }
- return "";
- }
- /**
- * 从对象上按已知字段取「可能是地址」的字符串
- * @param {unknown} o
- * @returns {string}
- */
- function pickUrlLikeFromObject(o) {
- if (!o || typeof o !== "object" || Array.isArray(o)) return "";
- const r = /** @type {Record<string, unknown>} */ (o);
- for (const k of URL_LIKE_KEYS) {
- const v = r[k];
- if (typeof v === "string" && v.trim()) {
- const abs = normalizeToFileUrl(v);
- if (abs) return abs;
- }
- }
- return "";
- }
- /**
- * 深度查找第一个 http(s) 字符串
- * @param {unknown} val
- * @param {number} depth
- * @param {number} maxDepth
- * @returns {string}
- */
- function deepFindHttpUrl(val, depth = 0, maxDepth = 8) {
- if (depth > maxDepth || val == null) return "";
- if (typeof val === "string") {
- const t = val.trim();
- return /^https?:\/\//i.test(t) ? t : "";
- }
- if (Array.isArray(val)) {
- for (const item of val) {
- const u = deepFindHttpUrl(item, depth + 1, maxDepth);
- if (u) return u;
- }
- return "";
- }
- if (typeof val === "object") {
- for (const k of Object.keys(val)) {
- const u = deepFindHttpUrl(/** @type {Record<string, unknown>} */ (val)[k], depth + 1, maxDepth);
- if (u) return u;
- }
- }
- return "";
- }
- /**
- * 从 JSON 体中解析文件访问地址(兼容多种后端约定)
- * @param {unknown} body
- * @returns {string}
- */
- function pickUrlFromJsonBody(body) {
- if (body == null) return "";
- if (typeof body === "string") {
- return normalizeToFileUrl(body) || (body.trim().startsWith("http") ? body.trim() : "");
- }
- if (typeof body !== "object" || Array.isArray(body)) {
- return deepFindHttpUrl(body);
- }
- const b = /** @type {Record<string, unknown>} */ (body);
- const unwrap = v => {
- if (v == null) return null;
- if (typeof v === "object" && !Array.isArray(v) && /** @type {Record<string, unknown>} */ (v).data !== undefined) {
- return /** @type {Record<string, unknown>} */ (v).data;
- }
- return v;
- };
- const candidates = [unwrap(b.data), b.data, b.result, unwrap(b.result), b.payload, b.body, b];
- for (const d of candidates) {
- if (d == null) continue;
- if (typeof d === "string") {
- const u = normalizeToFileUrl(d) || (d.trim().startsWith("http") ? d.trim() : "");
- if (u) return u;
- } else if (typeof d === "object") {
- let u = pickUrlLikeFromObject(d);
- if (u) return u;
- u = deepFindHttpUrl(d);
- if (u) return u;
- }
- }
- return deepFindHttpUrl(body);
- }
- /** 审核服务返回的违规大类 → 面向用户的短说明(与业务文案风格一致) */
- const VIOLATION_CATEGORY_USER_HINT = {
- GAMBLING: "图片涉及赌博等违规内容",
- PORNOGRAPHY: "图片涉及色情等违规内容",
- PORN: "图片涉及色情等违规内容",
- ADULT: "图片涉及色情等违规内容",
- SEXUAL: "图片涉及色情等违规内容",
- DRUGS: "图片涉及毒品等违规内容",
- DRUG: "图片涉及毒品等违规内容",
- VIOLENCE: "图片涉及暴力、血腥等违规内容",
- GORE: "图片涉及暴力、血腥等违规内容",
- POLITICAL: "图片涉及不当政治或敏感信息",
- POLITICS: "图片涉及不当政治或敏感信息",
- SPAM: "图片涉及违规推广或垃圾信息",
- ABUSE: "图片涉及辱骂、人身攻击等不文明内容",
- ILLEGAL: "图片含违法违规内容"
- };
- /**
- * 根据 reason / 关键词生成友好提示(与 aiImageUpload 中审核话术对齐)
- * @param {string} raw
- * @returns {string}
- */
- /** 仅返回「原因」短句,后缀由 formatSimpleUploadModerationMessage 统一拼接 */
- function mapSimpleModerationReasonToTip(raw) {
- const s = raw == null ? "" : String(raw).trim();
- if (!s) return "";
- if (/赌|赌博|casino|gambl|GAMBLING/i.test(s)) return "图片涉及赌博等违规内容";
- if (/色情|淫秽|porn|sex|PORNOGRAPHY|PORN/i.test(s)) return "图片涉及色情等违规内容";
- if (/毒品|涉毒|drug|DRUG/i.test(s)) return "图片涉及毒品等违规内容";
- if (/暴力|血腥|violence|gore|VIOLENCE/i.test(s)) return "图片涉及暴力、血腥等违规内容";
- if (/辱骂|谩骂|人身攻击|abuse|insult/i.test(s)) return "图片涉及不文明内容";
- if (/政治|谣言/i.test(s)) return "图片涉及违规信息";
- if (/广告|spam|营销/i.test(s)) return "图片涉及违规推广内容";
- if (/违法|违禁|illegal/i.test(s)) return "图片含违法违规内容";
- if (/黄赌毒/i.test(s)) return "请勿上传涉黄、涉赌、涉毒等违规内容";
- if (/审核|moderat|violation|flagged|VLM/i.test(s)) return "图片未通过内容审核";
- return "";
- }
- /**
- * 解析 /upload/simple 审核未通过等响应(saved: false + moderation)
- * @param {unknown} parsed
- * @returns {string} 非空则可直接作为 ElMessage / Error 文案
- */
- function formatSimpleUploadModerationMessage(parsed) {
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "";
- const p = /** @type {Record<string, unknown>} */ (parsed);
- const notSaved = p.saved === false;
- const mod = p.moderation;
- const hasModeration = mod != null && typeof mod === "object";
- let flagged = false;
- if (hasModeration) {
- const results = /** @type {Record<string, unknown>} */ (mod).results;
- if (Array.isArray(results) && results[0] && typeof results[0] === "object") {
- flagged = /** @type {Record<string, unknown>} */ (results[0]).flagged === true;
- }
- }
- if (!notSaved && !flagged) return "";
- const apiMsg = String(p.message ?? p.msg ?? "").trim();
- let firstResult = null;
- if (hasModeration) {
- const results = /** @type {Record<string, unknown>} */ (mod).results;
- if (Array.isArray(results) && results[0] && typeof results[0] === "object") {
- firstResult = /** @type {Record<string, unknown>} */ (results[0]);
- }
- }
- let reason = "";
- let firstCategory = "";
- if (firstResult) {
- reason = String(firstResult.reason ?? "").trim();
- const cats = firstResult.violation_categories;
- if (Array.isArray(cats) && cats[0] && typeof cats[0] === "object") {
- firstCategory = String(/** @type {Record<string, unknown>} */ (cats[0]).category ?? "")
- .trim()
- .toUpperCase();
- }
- }
- const suffix = "未通过审核,文件未保存。请更换后重试";
- if (firstCategory && VIOLATION_CATEGORY_USER_HINT[firstCategory]) {
- return `${VIOLATION_CATEGORY_USER_HINT[firstCategory]},${suffix}`;
- }
- const fromReason = mapSimpleModerationReasonToTip(reason);
- if (fromReason) {
- return `${fromReason.replace(/。$/, "")},${suffix}`;
- }
- if (apiMsg) {
- if (/请更换|更换后|重新选择|换一张/i.test(apiMsg)) return apiMsg;
- if (/未保存|未通过/.test(apiMsg)) return `${apiMsg.replace(/。$/, "")}。请更换后重试`;
- return `${apiMsg.replace(/。$/, "")},${suffix}`;
- }
- return "图片未通过内容审核,文件未保存。请更换后重试";
- }
- /**
- * 业务层 code / success(HTTP 200 但业务失败时常有)
- * @param {unknown} parsed
- */
- function assertSimpleUploadBusinessOk(parsed) {
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
- const p = /** @type {Record<string, unknown>} */ (parsed);
- if (p.success === false) {
- throw new Error(String(p.msg ?? p.message ?? p.error ?? "上传失败"));
- }
- const c = p.code;
- if (c === undefined || c === null) return;
- const ok = c === 200 || c === 0 || c === "200" || c === "0";
- if (!ok) {
- throw new Error(String(p.msg ?? p.message ?? p.error ?? "上传失败"));
- }
- }
- /**
- * POST multipart:字段 file;可选 filename(此处不传则服务端用原名)
- * @param {File} file
- * @param {{ signal?: AbortSignal }} [fetchOptions]
- * @returns {Promise<string>} 文件访问 URL
- */
- async function postFileToSimpleUpload(file, fetchOptions = {}) {
- const base = String(BASE_AI_URL || "").replace(/\/$/, "");
- if (!base) {
- throw new Error("未配置上传服务地址(VITE_AI_UPLOAD_BASE 或默认 /ai-upload)");
- }
- const formData = new FormData();
- formData.append("file", file, file.name || "file");
- const userStore = useUserStore();
- const headers = {};
- const token = userStore.token || "";
- if (token) {
- headers.Authorization = token;
- }
- const { signal } = fetchOptions;
- const uploadUrl = `${base}${SIMPLE_UPLOAD_PATH}`;
- let res;
- try {
- res = await fetch(uploadUrl, {
- method: "POST",
- headers,
- /** 不带跨域 Cookie,减轻上传服务 CORS 要求(鉴权仅用 Authorization) */
- credentials: "omit",
- body: formData,
- signal: signal ?? undefined
- });
- } catch (err) {
- if (isUploadUserCancelledError(err)) {
- throw err instanceof Error ? err : new Error(String(err ?? "AbortError"));
- }
- console.error("[upload/simple] 上传请求失败:", uploadUrl, err);
- throw new Error("上传失败");
- }
- const rawText = await res.text();
- let parsed = null;
- const trimmed = rawText.trim();
- if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
- try {
- parsed = JSON.parse(rawText);
- } catch (_) {
- /* ignore */
- }
- }
- if (!res.ok) {
- const modHint = formatSimpleUploadModerationMessage(parsed);
- let msg = modHint;
- if (!msg && parsed && typeof parsed === "object") {
- const p = /** @type {Record<string, unknown>} */ (parsed);
- const m = p.msg ?? p.message ?? p.error;
- if (m != null && String(m)) msg = String(m);
- }
- if (!msg && trimmed) msg = trimmed.slice(0, 200);
- if (!msg) msg = "上传失败";
- throw new Error(msg);
- }
- try {
- assertSimpleUploadBusinessOk(parsed);
- } catch (bizErr) {
- console.error("[upload/simple] 业务失败", bizErr, trimmed.slice(0, 500));
- throw bizErr;
- }
- const moderationUserTip = formatSimpleUploadModerationMessage(parsed);
- if (moderationUserTip) {
- throw new Error(moderationUserTip);
- }
- let url = pickUrlFromJsonBody(parsed);
- if (!url && trimmed.startsWith("http")) {
- url = trimmed;
- }
- if (!url) {
- const fromHeader =
- res.headers.get("x-file-url") ||
- res.headers.get("x-url") ||
- res.headers.get("x-oss-url") ||
- res.headers.get("file-url") ||
- "";
- if (fromHeader?.trim()) {
- const h = fromHeader.trim();
- url = normalizeToFileUrl(h) || (h.startsWith("http") ? h : "");
- }
- }
- if (!url) {
- const loc = res.headers.get("location") || res.headers.get("Location") || "";
- if (loc) {
- url = normalizeToFileUrl(loc.trim()) || (loc.trim().startsWith("http") ? loc.trim() : "");
- }
- }
- if (!url) {
- console.error("[upload/simple] 无法解析文件地址,响应片段:", trimmed.slice(0, 800));
- throw new Error("上传失败,未返回文件地址");
- }
- return url;
- }
- /**
- * 上传文件:图片与视频均走同一接口 POST /upload/simple,formData 键 file。
- * @param {File | File[] | FileList} files 浏览器文件对象;支持单个 File、数组或 FileList
- * @param {string} [_fileType] 保留参数,兼容旧调用(当前不参与分支)
- * @param {{ showLoading?: boolean; skipSimpleUploadOverlay?: boolean; uploadSuccessMessage?: string | null; uploadOverlayTitle?: string }} [options]
- * showLoading:在未使用全局上传弹层时,用 ElMessage 提示上传中
- * skipSimpleUploadOverlay:为 true 时不展示 PopupLoading(不弹「上传成功」)
- * uploadSuccessMessage:传给弹层,`null` 表示上传成功不 toast(默认「上传成功」)
- * uploadOverlayTitle:弹层标题
- * @returns {Promise<string[]>} 上传成功后的文件 URL 列表
- */
- export async function uploadFilesToOss(files, _fileType, options = {}) {
- const { showLoading = false, skipSimpleUploadOverlay = false, uploadSuccessMessage, uploadOverlayTitle } = options;
- const fileArr = normalizeFiles(files);
- if (fileArr.length === 0) {
- throw new Error("请选择要上传的文件");
- }
- let closeLoading = () => {};
- if (showLoading && skipSimpleUploadOverlay) {
- const loading = ElMessage({ message: "上传中...", type: "info", duration: 0, showClose: false });
- closeLoading = () => loading.close();
- }
- try {
- const runUpload = async signal => {
- const uploadedUrls = [];
- for (const file of fileArr) {
- const url = await postFileToSimpleUpload(file, signal ? { signal } : {});
- uploadedUrls.push(url);
- }
- return uploadedUrls;
- };
- let uploadedUrls;
- if (skipSimpleUploadOverlay) {
- const overlaySignal = useSimpleUploadOverlayStore().getActiveAbortSignal?.();
- uploadedUrls = await runUpload(overlaySignal ?? null);
- } else {
- const overlayOpts =
- uploadSuccessMessage !== undefined || uploadOverlayTitle
- ? {
- title: uploadOverlayTitle,
- successMessage: uploadSuccessMessage
- }
- : undefined;
- uploadedUrls = await withSimpleUploadOverlay(signal => runUpload(signal), overlayOpts);
- }
- closeLoading();
- return uploadedUrls;
- } catch (e) {
- closeLoading();
- console.error("上传失败", e);
- if (isUploadUserCancelledError(e)) {
- throw e;
- }
- const msg = e?.message || "上传失败";
- ElMessage.error(msg);
- try {
- if (e && typeof e === "object") {
- Object.defineProperty(e, "__uploadMessageShown", { value: true, enumerable: false, configurable: true });
- }
- } catch (_) {
- /* ignore */
- }
- throw e;
- }
- }
- /**
- * 上传单个文件,返回 URL 字符串
- * @param {File} file
- * @param {string} [fileType]
- * @param {{ showLoading?: boolean }} [options]
- * @returns {Promise<string>}
- */
- export async function uploadFileToOss(file, fileType, options) {
- const urls = await uploadFilesToOss(file, fileType, options);
- return urls[0];
- }
- /**
- * 兼容 UploadImg 等组件的 api 格式:接收 FormData,返回 { data: { fileUrl } }
- * @param {FormData} formData 包含 file 字段
- * @param {string} [fileType] 不传或传空时按 file.type 自动区分 image / video
- * @param {{ showLoading?: boolean }} [options]
- * @returns {Promise<{ data: { fileUrl: string } }>}
- */
- export async function uploadFormDataToOss(formData, fileType, options = {}) {
- const file = formData.get("file");
- if (!file || !(file instanceof File)) {
- throw new Error("请选择要上传的文件");
- }
- const inferred =
- fileType === undefined || fileType === null || fileType === ""
- ? String(file.type || "").startsWith("video/")
- ? "video"
- : "image"
- : fileType;
- const url = await uploadFileToOss(file, inferred, options);
- return { data: { fileUrl: url } };
- }
- /**
- * FormData 走 `/upload/simple` 后,包装成旧组件习惯的 `{ code, msg, data: { fileUrl }, fileUrl }`(并非请求 `/file/uploadMore`)
- * @param {FormData} formData
- * @param {{ showLoading?: boolean }} [options]
- * @returns {Promise<{ code: number; msg: string; data: { fileUrl: string }; fileUrl: string }>}
- */
- export async function uploadFormDataSimpleCompat(formData, options = {}) {
- const file = formData.get("file");
- if (!file || !(file instanceof File)) {
- throw new Error("请选择要上传的文件");
- }
- const isVideo = String(file.type || "").startsWith("video/");
- const inner = await uploadFormDataToOss(formData, isVideo ? "video" : "image", options);
- const fileUrl = inner.data.fileUrl;
- return {
- code: 200,
- msg: "success",
- data: inner.data,
- fileUrl
- };
- }
|