Browse Source

Merge branch 'development' of http://8.152.195.41:3000/alien/group_web_merchant into development

sunshibo 1 day ago
parent
commit
e78210c278

+ 2 - 0
src/App.vue

@@ -1,5 +1,6 @@
 <template>
   <el-config-provider :locale="locale" :size="assemblySize" :button="buttonConfig">
+    <SimpleUploadOverlayHost />
     <router-view v-slot="{ Component }">
       <component :is="Component" :key="$route.fullPath" />
     </router-view>
@@ -16,6 +17,7 @@ import { LanguageType } from "./stores/interface";
 import { useGlobalStore } from "@/stores/modules/global";
 import en from "element-plus/es/locale/lang/en";
 import zhCn from "element-plus/es/locale/lang/zh-cn";
+import SimpleUploadOverlayHost from "@/components/popupLoading/SimpleUploadOverlayHost.vue";
 
 const globalStore = useGlobalStore();
 

+ 45 - 41
src/api/modules/aiImageUpload.ts

@@ -3,6 +3,7 @@
  */
 import { useUserStore } from "@/stores/modules/user";
 import { AI_UPLOAD_FILES_PUBLIC_BASE, BASE_AI_URL, BASE_DEV_UPLOAD_SIMPLE } from "@/utils/config";
+import { withSimpleUploadOverlay } from "@/utils/withSimpleUploadOverlay";
 
 const TUS_VERSION = "1.0.0";
 const TUS_CHUNK_SIZE = 1024 * 1024;
@@ -131,53 +132,56 @@ function buildDevSimpleUploadRequestUrl(): string {
  * POST `/dev-upload-ailien/upload/simple`,表单字段 `file`
  */
 export async function uploadFileViaDevSimpleEndpoint(file: File): Promise<{ fileUrl: string; coverUrl?: string }> {
-  const reqUrl = buildDevSimpleUploadRequestUrl();
-  const fd = new FormData();
-  fd.append("file", file, file.name);
+  return withSimpleUploadOverlay(async signal => {
+    const reqUrl = buildDevSimpleUploadRequestUrl();
+    const fd = new FormData();
+    fd.append("file", file, file.name);
+
+    const res = await fetch(reqUrl, {
+      method: "POST",
+      headers: {
+        Authorization: authHeader()
+      },
+      body: fd,
+      credentials: "omit",
+      signal
+    });
 
-  const res = await fetch(reqUrl, {
-    method: "POST",
-    headers: {
-      Authorization: authHeader()
-    },
-    body: fd,
-    credentials: "omit"
-  });
+    let body: unknown = null;
+    const ct = res.headers.get("content-type") || "";
+    if (ct.includes("application/json")) {
+      try {
+        body = await res.json();
+      } catch {
+        body = null;
+      }
+    } else {
+      const t = await res.text();
+      try {
+        body = t ? JSON.parse(t) : null;
+      } catch {
+        body = t ? { raw: t } : null;
+      }
+    }
 
-  let body: unknown = null;
-  const ct = res.headers.get("content-type") || "";
-  if (ct.includes("application/json")) {
-    try {
-      body = await res.json();
-    } catch {
-      body = null;
+    if (res.status < 200 || res.status >= 300) {
+      const msg =
+        body && typeof body === "object" && (body as any).msg != null ? String((body as any).msg) : `上传失败(${res.status})`;
+      throw new Error(msg);
     }
-  } else {
-    const t = await res.text();
-    try {
-      body = t ? JSON.parse(t) : null;
-    } catch {
-      body = t ? { raw: t } : null;
+    if (body && typeof body === "object" && (body as any).code !== undefined) {
+      const c = (body as any).code;
+      if (c !== 200 && c !== 0) {
+        throw new Error((body as any).msg || (body as any).message || "上传失败");
+      }
     }
-  }
 
-  if (res.status < 200 || res.status >= 300) {
-    const msg =
-      body && typeof body === "object" && (body as any).msg != null ? String((body as any).msg) : `上传失败(${res.status})`;
-    throw new Error(msg);
-  }
-  if (body && typeof body === "object" && (body as any).code !== undefined) {
-    const c = (body as any).code;
-    if (c !== 200 && c !== 0) {
-      throw new Error((body as any).msg || (body as any).message || "上传失败");
+    const rawUrl = pickFileUrlFromBody(body);
+    if (!rawUrl) {
+      throw new Error("上传完成但未返回文件地址");
     }
-  }
-
-  const rawUrl = pickFileUrlFromBody(body);
-  if (!rawUrl) {
-    throw new Error("上传完成但未返回文件地址");
-  }
-  return normalizeSimpleUploadUrls(body, rawUrl);
+    return normalizeSimpleUploadUrls(body, rawUrl);
+  });
 }
 
 /** 创建上传会话 POST /upload */

+ 30 - 9
src/api/upload.js

@@ -1,6 +1,7 @@
 import { useUserStore } from "@/stores/modules/user";
 import { ElMessage } from "element-plus";
 import { AI_UPLOAD_FILES_PUBLIC_BASE, BASE_AI_URL } from "@/utils/config";
+import { withSimpleUploadOverlay } from "@/utils/withSimpleUploadOverlay";
 
 /** 非 TUS 简单上传接口路径(与 Apifox「上传文件-非TUS」一致;开发环境经 VITE_PROXY /ai-upload 转发) */
 const SIMPLE_UPLOAD_PATH = "/upload/simple";
@@ -311,9 +312,10 @@ function assertSimpleUploadBusinessOk(parsed) {
 /**
  * POST multipart:字段 file;可选 filename(此处不传则服务端用原名)
  * @param {File} file
+ * @param {{ signal?: AbortSignal }} [fetchOptions]
  * @returns {Promise<string>} 文件访问 URL
  */
-async function postFileToSimpleUpload(file) {
+async function postFileToSimpleUpload(file, fetchOptions = {}) {
   const base = String(BASE_AI_URL || "").replace(/\/$/, "");
   if (!base) {
     throw new Error("未配置上传服务地址(VITE_AI_UPLOAD_BASE 或默认 /ai-upload)");
@@ -329,12 +331,15 @@ async function postFileToSimpleUpload(file) {
     headers.Authorization = token;
   }
 
+  const { signal } = fetchOptions;
+
   const res = await fetch(`${base}${SIMPLE_UPLOAD_PATH}`, {
     method: "POST",
     headers,
     /** 不带跨域 Cookie,减轻上传服务 CORS 要求(鉴权仅用 Authorization) */
     credentials: "omit",
-    body: formData
+    body: formData,
+    signal: signal ?? undefined
   });
 
   const rawText = await res.text();
@@ -406,33 +411,49 @@ async function postFileToSimpleUpload(file) {
  * 上传文件:图片与视频均走同一接口 POST /upload/simple,formData 键 file。
  * @param {File | File[] | FileList} files 浏览器文件对象;支持单个 File、数组或 FileList
  * @param {string} [_fileType] 保留参数,兼容旧调用(当前不参与分支)
- * @param {{ showLoading?: boolean }} [options] showLoading 为 true 时用 ElMessage 提示上传中(非阻塞)
+ * @param {{ showLoading?: boolean; skipSimpleUploadOverlay?: boolean }} [options]
+ *   showLoading:在未使用全局上传弹层时,用 ElMessage 提示上传中
+ *   skipSimpleUploadOverlay:为 true 时不展示 PopupLoading(不弹「上传成功」)
  * @returns {Promise<string[]>} 上传成功后的文件 URL 列表
  */
 export async function uploadFilesToOss(files, _fileType, options = {}) {
-  const { showLoading = false } = options;
+  const { showLoading = false, skipSimpleUploadOverlay = false } = options;
   const fileArr = normalizeFiles(files);
   if (fileArr.length === 0) {
     throw new Error("请选择要上传的文件");
   }
 
   let closeLoading = () => {};
-  if (showLoading) {
+  if (showLoading && skipSimpleUploadOverlay) {
     const loading = ElMessage({ message: "上传中...", type: "info", duration: 0, showClose: false });
     closeLoading = () => loading.close();
   }
 
   try {
-    const uploadedUrls = [];
-    for (const file of fileArr) {
-      const url = await postFileToSimpleUpload(file);
-      uploadedUrls.push(url);
+    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) {
+      uploadedUrls = await runUpload(null);
+    } else {
+      uploadedUrls = await withSimpleUploadOverlay(signal => runUpload(signal));
     }
+
     closeLoading();
     return uploadedUrls;
   } catch (e) {
     closeLoading();
     console.error("上传失败", e);
+    if (e?.name === "AbortError") {
+      throw e;
+    }
     const msg = e?.message || "上传失败";
     ElMessage.error(msg);
     throw e;

+ 5 - 5
src/components/Upload/Img.vue

@@ -163,11 +163,11 @@ const beforeUpload: UploadProps["beforeUpload"] = rawFile => {
  * @description 图片上传成功
  * */
 const uploadSuccess = () => {
-  ElNotification({
-    title: "温馨提示",
-    message: "图片上传成功!",
-    type: "success"
-  });
+  // ElNotification({
+  //   title: "温馨提示",
+  //   message: "图片上传成功!11111",
+  //   type: "success"
+  // });
 };
 
 /**

+ 5 - 5
src/components/Upload/Imgs.vue

@@ -297,11 +297,11 @@ const uploadSuccess = (response: { fileUrl: string } | string | string[] | undef
     hasShownSuccessNotification = true;
     // 判断是否为视频文件,显示相应的提示语
     const isVideo = isVideoFile(uploadFile);
-    ElNotification({
-      title: "温馨提示",
-      message: isVideo ? "视频上传成功!" : "图片上传成功!",
-      type: "success"
-    });
+    // ElNotification({
+    //   title: "温馨提示",
+    //   message: isVideo ? "视频上传成功!" : "图片上传成功!1111",
+    //   type: "success"
+    // });
   }
 };
 

+ 16 - 0
src/components/popupLoading/SimpleUploadOverlayHost.vue

@@ -0,0 +1,16 @@
+<template>
+  <PopupLoading
+    :show="store.show"
+    :percent="Math.round(Number(store.percent) || 0)"
+    :title="store.title"
+    :cancel-text="store.cancelText"
+    @cancel="store.userCancel"
+  />
+</template>
+
+<script setup lang="ts">
+import { useSimpleUploadOverlayStore } from "@/stores/modules/simpleUploadOverlay";
+import PopupLoading from "./index.vue";
+
+const store = useSimpleUploadOverlayStore();
+</script>

+ 208 - 0
src/components/popupLoading/index.vue

@@ -0,0 +1,208 @@
+<template>
+  <div v-if="show" class="pl-mask" @click.stop>
+    <div class="pl-card" @click.stop>
+      <div class="pl-body">
+        <div class="pl-ring-wrap" :style="{ width: ringPx + 'px', height: ringPx + 'px' }">
+          <svg class="pl-ring-svg" :viewBox="viewBox" aria-hidden="true">
+            <defs>
+              <linearGradient :id="progressGradientId" x1="12" y1="18" x2="88" y2="82" gradientUnits="userSpaceOnUse">
+                <stop offset="0%" class="pl-grad-stop pl-grad-stop--start" />
+                <stop offset="52%" class="pl-grad-stop pl-grad-stop--mid" />
+                <stop offset="100%" class="pl-grad-stop pl-grad-stop--end" />
+              </linearGradient>
+            </defs>
+            <circle class="pl-ring-track" :cx="cx" :cy="cy" :r="radius" fill="none" :stroke-width="strokeW" />
+            <circle
+              class="pl-ring-progress"
+              :cx="cx"
+              :cy="cy"
+              :r="radius"
+              fill="none"
+              :stroke="`url(#${progressGradientId})`"
+              :stroke-width="strokeW"
+              stroke-linecap="round"
+              :stroke-dasharray="dashArray"
+              :stroke-dashoffset="dashOffset"
+              :transform="`rotate(-90 ${cx} ${cy})`"
+            />
+          </svg>
+          <div class="pl-percent-wrap">
+            <span class="pl-percent">{{ displayPercent }}%</span>
+          </div>
+        </div>
+        <p class="pl-title">
+          {{ title }}
+        </p>
+      </div>
+      <div class="pl-divider" />
+      <button type="button" class="pl-cancel" @click="onCancel">
+        <span class="pl-cancel__text">{{ cancelText }}</span>
+      </button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed } from "vue";
+
+/** 避免同页多个实例时 defs id 冲突 */
+const progressGradientId = `pl-ring-grad-${Math.random().toString(36).slice(2, 10)}`;
+
+const props = defineProps({
+  show: {
+    type: Boolean,
+    default: false
+  },
+  percent: {
+    type: Number,
+    default: 0
+  },
+  title: {
+    type: String,
+    default: "上传中"
+  },
+  cancelText: {
+    type: String,
+    default: "取消上传"
+  }
+});
+
+const emit = defineEmits(["cancel", "update:show"]);
+
+const displayPercent = computed(() => {
+  const n = Number(props.percent);
+  if (Number.isNaN(n)) return 0;
+  return Math.round(Math.min(100, Math.max(0, n)));
+});
+
+/** SVG 环形:与 Element 主题色对齐 */
+const vb = 100;
+const cx = vb / 2;
+const cy = vb / 2;
+const radius = 38;
+const strokeW = 7;
+const viewBox = `0 0 ${vb} ${vb}`;
+const ringPx = 118;
+
+const circumference = 2 * Math.PI * radius;
+
+const dashArray = computed(() => `${circumference} ${circumference}`);
+
+const dashOffset = computed(() => {
+  const p = displayPercent.value / 100;
+  return circumference * (1 - p);
+});
+
+function onCancel() {
+  emit("cancel");
+  emit("update:show", false);
+}
+</script>
+
+<style lang="scss" scoped>
+.pl-mask {
+  position: fixed;
+  inset: 0;
+  z-index: 10000;
+  box-sizing: border-box;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 24px;
+  background: rgb(0 0 0 / 50%);
+}
+.pl-card {
+  box-sizing: border-box;
+  width: 100%;
+  max-width: 320px;
+  overflow: hidden;
+  background: var(--el-bg-color, #ffffff);
+  border-radius: 12px;
+  box-shadow: var(--el-box-shadow, 0 12px 32px rgb(0 0 0 / 12%));
+}
+.pl-body {
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 32px 24px 24px;
+}
+.pl-ring-wrap {
+  position: relative;
+  flex-shrink: 0;
+}
+.pl-ring-svg {
+  display: block;
+  width: 100%;
+  height: 100%;
+}
+.pl-ring-track {
+  stroke: var(--el-color-primary-light-8, #e8ecfc);
+}
+.pl-ring-progress {
+  transition: stroke-dashoffset 0.35s ease;
+}
+.pl-grad-stop--start {
+  stop-color: var(--el-color-primary-light-5, #a3b9fc);
+}
+.pl-grad-stop--mid {
+  stop-color: var(--el-color-primary, #6c8ff8);
+}
+.pl-grad-stop--end {
+  stop-color: var(--el-color-primary-dark-2, #4a6fd6);
+}
+.pl-percent-wrap {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  pointer-events: none;
+}
+.pl-percent {
+  font-size: 22px;
+  font-weight: 600;
+  font-variant-numeric: tabular-nums;
+  line-height: 1.2;
+  color: var(--el-color-primary, #6c8ff8);
+}
+.pl-title {
+  margin: 20px 0 0;
+  font-size: 15px;
+  font-weight: 500;
+  line-height: 1.4;
+  color: var(--el-text-color-primary, #303133);
+  text-align: center;
+}
+.pl-divider {
+  height: 1px;
+  margin: 0;
+  background: var(--el-border-color-lighter, #ebeef5);
+}
+.pl-cancel {
+  box-sizing: border-box;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  padding: 14px 20px 16px;
+  margin: 0;
+  font: inherit;
+  cursor: pointer;
+  background: transparent;
+  border: none;
+  &:hover .pl-cancel__text {
+    color: var(--el-color-primary-light-3, #8aa6fa);
+  }
+  &:active .pl-cancel__text {
+    color: var(--el-color-primary-dark-2, #4a6fd6);
+  }
+}
+.pl-cancel__text {
+  font-size: 15px;
+  font-weight: 500;
+  line-height: 1.4;
+  color: var(--el-color-primary, #6c8ff8);
+  transition: color 0.15s ease;
+}
+</style>

+ 62 - 0
src/stores/modules/simpleUploadOverlay.ts

@@ -0,0 +1,62 @@
+import { defineStore } from "pinia";
+import { ref } from "vue";
+
+let progressTimer: ReturnType<typeof setInterval> | null = null;
+let activeController: AbortController | null = null;
+
+function clearProgressTimer() {
+  if (progressTimer) {
+    clearInterval(progressTimer);
+    progressTimer = null;
+  }
+}
+
+export const useSimpleUploadOverlayStore = defineStore("simple-upload-overlay", () => {
+  const show = ref(false);
+  const percent = ref(0);
+  const title = ref("上传中");
+  const cancelText = ref("取消上传");
+
+  function beginUpload(opts?: { title?: string }) {
+    activeController?.abort();
+    clearProgressTimer();
+    activeController = new AbortController();
+    title.value = opts?.title ?? "上传中";
+    percent.value = 3;
+    show.value = true;
+    progressTimer = setInterval(() => {
+      if (percent.value < 88) {
+        percent.value = Math.min(88, percent.value + 2 + Math.random() * 6);
+      }
+    }, 260);
+    return activeController.signal;
+  }
+
+  function bumpToComplete() {
+    clearProgressTimer();
+    percent.value = 100;
+  }
+
+  function dismiss() {
+    clearProgressTimer();
+    show.value = false;
+    percent.value = 0;
+    activeController = null;
+  }
+
+  function userCancel() {
+    activeController?.abort();
+    dismiss();
+  }
+
+  return {
+    show,
+    percent,
+    title,
+    cancelText,
+    beginUpload,
+    bumpToComplete,
+    dismiss,
+    userCancel
+  };
+});

+ 29 - 0
src/utils/withSimpleUploadOverlay.ts

@@ -0,0 +1,29 @@
+import { ElMessage } from "element-plus";
+import { useSimpleUploadOverlayStore } from "@/stores/modules/simpleUploadOverlay";
+
+function sleep(ms: number) {
+  return new Promise<void>(resolve => setTimeout(resolve, ms));
+}
+
+/**
+ * 使用全局 PopupLoading 包裹「/upload/simple」类上传;成功后提示「上传成功」。
+ * 取消(AbortError)不弹成功提示;失败时关闭弹层,由调用方决定是否 ElMessage.error。
+ */
+export async function withSimpleUploadOverlay<T>(
+  task: (signal: AbortSignal) => Promise<T>,
+  options?: { title?: string; successMessage?: string }
+): Promise<T> {
+  const overlay = useSimpleUploadOverlayStore();
+  const signal = overlay.beginUpload({ title: options?.title });
+  try {
+    const result = await task(signal);
+    overlay.bumpToComplete();
+    await sleep(280);
+    overlay.dismiss();
+    ElMessage.success(options?.successMessage ?? "上传成功");
+    return result;
+  } catch (e) {
+    overlay.dismiss();
+    throw e;
+  }
+}