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} */ (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} */ (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} */ (body); const unwrap = v => { if (v == null) return null; if (typeof v === "object" && !Array.isArray(v) && /** @type {Record} */ (v).data !== undefined) { return /** @type {Record} */ (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} */ (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} */ (mod).results; if (Array.isArray(results) && results[0] && typeof results[0] === "object") { flagged = /** @type {Record} */ (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} */ (mod).results; if (Array.isArray(results) && results[0] && typeof results[0] === "object") { firstResult = /** @type {Record} */ (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} */ (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} */ (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} 文件访问 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} */ (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} 上传成功后的文件 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} */ 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 }; }