Explorar o código

修复视频上传

liuxiaole hai 1 semana
pai
achega
112bb6a403

+ 187 - 49
src/api/modules/aiImageUpload.ts

@@ -281,7 +281,7 @@ export async function moderateImage(params: ModerateImageParams = {}): Promise<a
 }
 
 export interface ModerateVideoParams {
-  /** 本地视频文件(Web);与 uni 的 filePath 二选一或同时有远端字段 */
+  /** 可选;与图片同一套 Tus 上传后应只传 `video_url`,不在此再传文件 */
   file?: File | null;
   filePath?: string;
   path?: string;
@@ -294,9 +294,8 @@ export interface ModerateVideoParams {
 }
 
 /**
- * 上传视频并进入审核:POST `/api/v1/video/submit`,multipart/form-data(与 Apifox 一致)
- * 字段:`file`(可选,二进制)+ `video_path` / `video_url` / `text`(可选)
- * 服务基址:`BASE_AI_MODERATE_URL`(默认 https://verify.ailien.shop:8444)
+ * 仅提交「已上传视频」的审核任务:POST `/api/v1/video/submit`
+ * 与图片共用 Tus 上传拿到地址后,传 `video_url`;`getVideoModerateResult` 查询审核进度/结果。
  */
 export async function moderateVideo(params: ModerateVideoParams = {}): Promise<any> {
   const fd = new FormData();
@@ -344,7 +343,7 @@ export async function moderateVideo(params: ModerateVideoParams = {}): Promise<a
 }
 
 /**
- * 查询视频异步审核结果:GET `/api/v1/video/status/{task_id}`
+ * 查询视频审核进度与结果:GET `/api/v1/video/status/{task_id}`
  * @param data.task_id 或传入字符串即 task_id
  */
 export async function getVideoModerateResult(data: { task_id?: string } | string): Promise<any> {
@@ -386,6 +385,165 @@ export async function getVideoModerateResult(data: { task_id?: string } | string
   return body;
 }
 
+function mapVideoModerateReasonToUserMessage(raw?: string | null): string {
+  return mapModerateReasonToUserMessage(raw).replace(/图片/g, "视频");
+}
+
+function pickVideoTaskIdFromSubmit(res: unknown): string {
+  const r = res as Record<string, any> | null | undefined;
+  if (!r || typeof r !== "object") return "";
+  let cur: any = r.data !== undefined ? r.data : r;
+  if (cur && typeof cur === "object" && cur.data !== undefined) cur = cur.data;
+  if (cur && typeof cur === "object") {
+    return String(cur.task_id ?? cur.taskId ?? cur.taskID ?? "").trim();
+  }
+  return "";
+}
+
+function flattenVideoStatusPayload(body: Record<string, any>): Record<string, any> {
+  const d = body?.data;
+  if (d && typeof d === "object" && !Array.isArray(d)) {
+    return { ...body, ...d };
+  }
+  return body;
+}
+
+function isVideoModerationStatusTerminal(payload: Record<string, any>): boolean {
+  const p = flattenVideoStatusPayload(payload);
+  const s = String(p?.status ?? p?.state ?? "").toLowerCase();
+  const terminal = [
+    "completed",
+    "complete",
+    "success",
+    "succeeded",
+    "done",
+    "finished",
+    "failed",
+    "fail",
+    "rejected",
+    "error",
+    "cancelled",
+    "canceled"
+  ];
+  if (terminal.includes(s)) return true;
+  const r = p?.result;
+  if (r && typeof r === "object" && (r.flagged !== undefined || r.passed !== undefined || r.approved !== undefined)) {
+    return true;
+  }
+  return false;
+}
+
+function isVideoModerationRejectedPayload(payload: Record<string, any>): boolean {
+  const p = flattenVideoStatusPayload(payload);
+  if (p?.flagged === true) return true;
+  const r = p?.result;
+  if (r && typeof r === "object" && r.flagged === true) return true;
+  const s = String(p?.status ?? "").toLowerCase();
+  if (["failed", "fail", "rejected", "error"].includes(s)) return true;
+  return false;
+}
+
+function pickVideoRejectReason(payload: Record<string, any>): string | null {
+  const p = flattenVideoStatusPayload(payload);
+  const r = p?.result;
+  const fromResult = r && typeof r === "object" ? (r.reason ?? r.message) : null;
+  const raw = fromResult ?? p?.reason ?? p?.message ?? p?.msg;
+  return raw != null && typeof raw === "string" ? raw : null;
+}
+
+/**
+ * 是否为视频(MIME 或后缀)。上传与图片同走 Tus;通过后以 `moderateVideo({ video_url })` + 轮询区分审核链路。
+ */
+export function isLikelyVideoFileForAiUpload(file: File): boolean {
+  if (!(file instanceof File)) return false;
+  if (typeof file.type === "string" && file.type.startsWith("video/")) return true;
+  const n = file.name || "";
+  return /\.(mp4|m4v|webm|ogg|mov)(\?.*)?$/i.test(n);
+}
+
+/**
+ * Tus 上传 → finalize → 返回公网 URL(图片/视频同一套上传;不含审核)
+ */
+async function uploadViaTusToPublicUrl(file: File, onProgress?: (p: number) => void): Promise<string> {
+  const report = (n: number) => onProgress?.(Math.min(100, Math.max(0, Math.round(n))));
+  report(5);
+  const buf = await file.arrayBuffer();
+  const size = buf.byteLength;
+  report(18);
+
+  const defaultName = isLikelyVideoFileForAiUpload(file) ? `video_${Date.now()}.mp4` : `img_${Date.now()}.jpg`;
+  const { response: sessionRes, body: sessionBody } = await createUploadSession({
+    filename: file.name || defaultName,
+    size
+  });
+  const uploadId = pickUploadIdFromResponse(sessionRes, sessionBody);
+  if (!uploadId) {
+    throw new Error("创建上传会话失败:未返回 upload_id");
+  }
+
+  const publicUrl = `${AI_UPLOAD_FILES_PUBLIC_BASE}/${uploadId}`;
+
+  report(35);
+  await patchBinaryToUploadChunked(uploadId, buf, ratio => {
+    report(35 + ratio * (82 - 35));
+  });
+  report(82);
+
+  const finalizeBody = await finalizeUploadSession(uploadId, {});
+  report(90);
+
+  const urlFromApi = pickFileUrlFromBody(finalizeBody);
+  const fileUrl = urlFromApi || publicUrl;
+  if (!fileUrl) {
+    throw new Error("上传完成确认失败:未返回文件地址");
+  }
+  return fileUrl;
+}
+
+/**
+ * Tus 已得到视频 URL:`moderateVideo` 仅提交审核(video_url),`getVideoModerateResult` 轮询至结束。
+ */
+async function submitAndAwaitVideoModeration(fileUrl: string, onProgress?: (p: number) => void): Promise<void> {
+  const report = (n: number) => onProgress?.(Math.min(100, Math.max(0, Math.round(n))));
+  const submitRes: any = await moderateVideo({ video_url: fileUrl, text: "", video_path: "" });
+  report(92);
+  const taskId = pickVideoTaskIdFromSubmit(submitRes);
+  const submitFlat =
+    submitRes && typeof submitRes === "object" ? flattenVideoStatusPayload(submitRes as Record<string, any>) : {};
+
+  if (!taskId) {
+    if (isVideoModerationStatusTerminal(submitFlat)) {
+      if (isVideoModerationRejectedPayload(submitFlat)) {
+        throw new Error(mapVideoModerateReasonToUserMessage(pickVideoRejectReason(submitFlat)));
+      }
+      return;
+    }
+    const c = submitRes?.code;
+    if (c === 200 || c === 0) return;
+    throw new Error("视频审核提交未返回任务信息,请稍后重试");
+  }
+
+  const maxMs = 5 * 60 * 1000;
+  const step = 2000;
+  const t0 = Date.now();
+  let pollCount = 0;
+  while (Date.now() - t0 < maxMs) {
+    const raw: any = await getVideoModerateResult(taskId);
+    const flat = raw && typeof raw === "object" ? flattenVideoStatusPayload(raw as Record<string, any>) : {};
+    pollCount += 1;
+    report(Math.min(98, 92 + Math.min(6, pollCount)));
+
+    if (isVideoModerationStatusTerminal(flat)) {
+      if (isVideoModerationRejectedPayload(flat)) {
+        throw new Error(mapVideoModerateReasonToUserMessage(pickVideoRejectReason(flat)));
+      }
+      return;
+    }
+    await new Promise<void>(resolve => setTimeout(resolve, step));
+  }
+  throw new Error("视频审核等待超时,请稍后重试");
+}
+
 export interface UploadFileViaAiOptions {
   /** 0~100 */
   onProgress?: (progress: number) => void;
@@ -394,7 +552,8 @@ export interface UploadFileViaAiOptions {
 }
 
 /**
- * 浏览器 File 走 Tus 上传 → finalize →(可选)AI 审核,返回可访问的文件 URL
+ * 图片与视频均:Tus 上传 → finalize 得同一套访问 URL。
+ * 图片再 `moderateImage`;视频再 `moderateVideo({ video_url })` + `getVideoModerateResult` 轮询。`skipModerate` 则只上传不审。
  */
 export async function uploadFileViaAi(file: File, options: UploadFileViaAiOptions = {}): Promise<string> {
   const { onProgress, skipModerate } = options;
@@ -403,49 +562,23 @@ export async function uploadFileViaAi(file: File, options: UploadFileViaAiOption
     onProgress?.(Math.min(100, Math.max(0, Math.round(p))));
   };
 
-  let uploadId = "";
   try {
-    report(5);
-    const buf = await file.arrayBuffer();
-    const size = buf.byteLength;
-    report(18);
-
-    const { response: sessionRes, body: sessionBody } = await createUploadSession({
-      filename: file.name || `img_${Date.now()}.jpg`,
-      size
-    });
-    uploadId = pickUploadIdFromResponse(sessionRes, sessionBody);
-    if (!uploadId) {
-      throw new Error("创建上传会话失败:未返回 upload_id");
-    }
-
-    const publicUrl = `${AI_UPLOAD_FILES_PUBLIC_BASE}/${uploadId}`;
-
-    report(35);
-    await patchBinaryToUploadChunked(uploadId, buf, ratio => {
-      report(35 + ratio * (82 - 35));
-    });
-    report(82);
-
-    const finalizeBody = await finalizeUploadSession(uploadId, {});
-    report(90);
-
-    const urlFromApi = pickFileUrlFromBody(finalizeBody);
-    const fileUrl = urlFromApi || publicUrl;
-    if (!fileUrl) {
-      throw new Error("上传完成确认失败:未返回文件地址");
-    }
+    const fileUrl = await uploadViaTusToPublicUrl(file, onProgress);
 
     if (!skipModerate) {
-      const moderateRes = await moderateImage({
-        text: "",
-        image_urls: fileUrl,
-        file
-      });
-      const first = moderateRes?.results?.[0];
-      if (first?.flagged) {
-        if (first.reason) console.warn("[AI审核]", first.reason);
-        throw new Error(mapModerateReasonToUserMessage(first.reason));
+      if (isLikelyVideoFileForAiUpload(file)) {
+        await submitAndAwaitVideoModeration(fileUrl, onProgress);
+      } else {
+        const moderateRes = await moderateImage({
+          text: "",
+          image_urls: fileUrl,
+          file
+        });
+        const first = moderateRes?.results?.[0];
+        if (first?.flagged) {
+          if (first.reason) console.warn("[AI审核]", first.reason);
+          throw new Error(mapModerateReasonToUserMessage(first.reason));
+        }
       }
     }
 
@@ -469,7 +602,7 @@ export function isAiImageModerationEnabled(): boolean {
   return import.meta.env.VITE_UPLOAD_IMAGE_AI_MODERATE !== "false";
 }
 
-/** 图片走 Tus + 审核,返回与 uploadMore 相近结构 */
+/** 图/视频均 Tus 上传;图 moderateImage、视频 moderateVideo(url)+轮询;返回与 uploadMore 相近结构 */
 export async function uploadImageAsUploadMoreResponse(file: File): Promise<UploadMoreLikeResponse> {
   const fileUrl = await uploadFileViaAi(file);
   return {
@@ -481,7 +614,7 @@ export async function uploadImageAsUploadMoreResponse(file: File): Promise<Uploa
 }
 
 /**
- * FormData 内含字段 `file` 且为图片时走 AI 上传+审核,否则走原接口
+ * FormData 内含字段 `file` 且为图片或视频时走 Tus + 对应审核(图 moderateImage,视频 moderateVideo 的 video_url + 轮询),否则走原接口
  */
 export function runUploadMoreWithOptionalAi<T = UploadMoreLikeResponse>(
   params: FormData,
@@ -491,7 +624,12 @@ export function runUploadMoreWithOptionalAi<T = UploadMoreLikeResponse>(
     return fallback();
   }
   const file = params.get("file");
-  if (file instanceof File && typeof file.type === "string" && file.type.startsWith("image/")) {
+  if (!(file instanceof File)) {
+    return fallback();
+  }
+  const isImage = typeof file.type === "string" && file.type.startsWith("image/");
+  const isVideo = isLikelyVideoFileForAiUpload(file);
+  if (isImage || isVideo) {
     return uploadImageAsUploadMoreResponse(file) as Promise<T>;
   }
   return fallback();

+ 12 - 4
src/api/upload.js

@@ -1,6 +1,6 @@
 import { useUserStore } from "@/stores/modules/user";
 import { ElMessage } from "element-plus";
-import { isAiImageModerationEnabled, uploadFileViaAi } from "@/api/modules/aiImageUpload";
+import { isAiImageModerationEnabled, uploadFileViaAi, isLikelyVideoFileForAiUpload } from "@/api/modules/aiImageUpload";
 
 /**
  * 门店/文件相关接口基址(与 src/api/modules/upload.ts 中 httpStoreAlienStore 一致)
@@ -114,7 +114,8 @@ async function postFileToOss(signRes, file, key) {
 }
 
 /**
- * 上传文件到 OSS:先 GET 获取签名,再 POST 到 OSS host(与 src/api/1.js 逻辑一致,Web 使用 File / FormData)
+ * 上传文件:视频一律走 Tus + moderateVideo / 轮询(uploadFileViaAi),不走 OSS;
+ * 开启 AI 时图片走 Tus+审核,否则图片与其它类型走 OSS 直传。
  * @param {File | File[] | FileList} files 浏览器文件对象;支持单个 File、数组或 FileList
  * @param {string} [fileType] 文件类型(如 'image' | 'video'),用于在文件名无后缀时推断格式
  * @param {{ showLoading?: boolean }} [options] showLoading 为 true 时用 ElMessage 提示上传中(非阻塞)
@@ -140,9 +141,16 @@ export async function uploadFilesToOss(files, fileType, options = {}) {
 
     for (let i = 0; i < fileArr.length; i++) {
       const file = fileArr[i];
-      const isImage = fileType === "image" || (!fileType && file.type && String(file.type).startsWith("image/"));
+      const isImageBranch = fileType === "image" || (!fileType && file.type && String(file.type).startsWith("image/"));
+      const isVideoBranch = fileType === "video" || isLikelyVideoFileForAiUpload(file);
 
-      if (useAiImage && isImage) {
+      if (isVideoBranch) {
+        const url = await uploadFileViaAi(file, { skipModerate: !useAiImage });
+        uploadedUrls.push(url);
+        continue;
+      }
+
+      if (useAiImage && isImageBranch) {
         const url = await uploadFileViaAi(file);
         uploadedUrls.push(url);
         continue;

+ 15 - 9
src/components/Upload/Imgs.vue

@@ -69,7 +69,7 @@ interface UploadFileProps {
   disabled?: boolean; // 是否禁用上传组件 ==> 非必传(默认为 false)
   limit?: number; // 最大图片上传数 ==> 非必传(默认为 5张)
   fileSize?: number; // 图片大小限制 ==> 非必传(默认为 5M)
-  fileType?: File.ImageMimeType[]; // 图片类型限制 ==> 非必传(默认为 ["image/jpeg", "image/png", "image/gif"])
+  fileType?: string[]; // 接受的 MIME(图片 + 可选视频)==> 非必传
   height?: string; // 组件高度 ==> 非必传(默认为 150px)
   width?: string; // 组件宽度 ==> 非必传(默认为 150px)
   borderRadius?: string; // 组件边框圆角 ==> 非必传(默认为 8px)
@@ -85,7 +85,7 @@ const props = withDefaults(defineProps<UploadFileProps>(), {
   disabled: false,
   limit: 5,
   fileSize: 5,
-  fileType: () => ["image/jpeg", "image/png", "image/gif"],
+  fileType: () => ["image/jpeg", "image/png", "image/gif", "video/mp4", "video/webm", "video/quicktime", "video/ogg"],
   height: "150px",
   width: "150px",
   borderRadius: "8px",
@@ -160,22 +160,28 @@ const isVideoFile = (file: UploadFile) => {
  * */
 const beforeUpload: UploadProps["beforeUpload"] = rawFile => {
   const imgSize = rawFile.size / 1024 / 1024 < props.fileSize;
-  const imgType = props.fileType.includes(rawFile.type as File.ImageMimeType);
-  if (!imgType)
+  const acceptVideo = props.fileType.some(t => String(t).startsWith("video/"));
+  const byNameVideo = /\.(mp4|m4v|webm|ogg|mov)(\?.*)?$/i.test(rawFile.name || "");
+  const mimeVideo = typeof rawFile.type === "string" && rawFile.type.startsWith("video/");
+  const looseVideoMime =
+    acceptVideo && byNameVideo && (!rawFile.type || rawFile.type === "application/octet-stream" || mimeVideo);
+  const typeListed = props.fileType.includes(rawFile.type);
+  const okType = typeListed || looseVideoMime;
+  if (!okType)
     ElNotification({
       title: "温馨提示",
-      message: "上传图片不符合所需的格式!",
+      message: "上传文件不符合所需的格式!",
       type: "warning"
     });
   if (!imgSize)
     setTimeout(() => {
       ElNotification({
         title: "温馨提示",
-        message: `上传图片大小不能超过 ${props.fileSize}M!`,
+        message: `上传文件大小不能超过 ${props.fileSize}M!`,
         type: "warning"
       });
     }, 0);
-  return imgType && imgSize;
+  return okType && imgSize;
 };
 
 /**
@@ -333,7 +339,7 @@ const handleImageLoad = (event: Event) => {
 const uploadError = () => {
   ElNotification({
     title: "温馨提示",
-    message: "图片上传失败,请您重新上传!",
+    message: "上传失败,请您重新上传!",
     type: "error"
   });
 };
@@ -344,7 +350,7 @@ const uploadError = () => {
 const handleExceed = () => {
   ElNotification({
     title: "温馨提示",
-    message: `当前最多只能上传 ${props.limit} 张图片,请移除后上传!`,
+    message: `当前最多只能上传 ${props.limit} 个文件,请移除后上传!`,
     type: "warning"
   });
 };

+ 1 - 1
src/views/dynamicManagement/reviewAppeal.vue

@@ -22,7 +22,7 @@
           </div>
         </div>
         <div class="stat-card">
-          <div class="stat-label">差评中评数</div>
+          <div class="stat-label">新增中评数</div>
           <div class="stat-value">
             {{ statistics.neutralReviews }}
           </div>