Procházet zdrojové kódy

Merge remote-tracking branch 'origin/development' into uat

# Conflicts:
#	.env.production
LuTong před 16 hodinami
rodič
revize
d5997412d6
40 změnil soubory, kde provedl 939 přidání a 639 odebrání
  1. 1 3
      .env.development
  2. 2 1
      .env.production
  3. 0 3
      .env.test
  4. 5 1
      src/api/index.ts
  5. 5 1
      src/api/indexApi.ts
  6. 0 46
      src/api/modules/coverAudit.ts
  7. 19 8
      src/api/modules/storeDecoration.ts
  8. 35 4
      src/assets/json/authMenuList.json
  9. 32 0
      src/components/pcMediaPreview/PcImagePreviewViewer.vue
  10. 49 0
      src/components/pcMediaPreview/PcVideoPreviewDialog.vue
  11. 2 0
      src/stores/modules/auth.ts
  12. 0 4
      src/typings/global.d.ts
  13. 0 26
      src/utils/config.ts
  14. 29 2
      src/views/appoinmentManagement/classifyManagement.vue
  15. 17 1
      src/views/dynamicManagement/index.vue
  16. 17 2
      src/views/dynamicManagement/myDynamic.vue
  17. 26 14
      src/views/dynamicManagement/publishDynamic.vue
  18. 5 8
      src/views/dynamicManagement/reviewAppeal.vue
  19. 5 8
      src/views/dynamicManagement/reviewAppealDetail.vue
  20. 5 8
      src/views/dynamicManagement/reviewAppealHistory.vue
  21. 17 1
      src/views/dynamicManagement/userDynamic.vue
  22. 3 3
      src/views/groupPackageManagement/newGroup.vue
  23. 3 3
      src/views/home/components/go-flow.vue
  24. 4 4
      src/views/licenseManagement/businessLicense.vue
  25. 4 4
      src/views/licenseManagement/contractManagement.vue
  26. 4 4
      src/views/licenseManagement/entertainmentLicense.vue
  27. 4 4
      src/views/licenseManagement/foodBusinessLicense.vue
  28. 3 3
      src/views/operationManagement/activityList.vue
  29. 107 186
      src/views/operationManagement/caseDetail.vue
  30. 4 4
      src/views/operationManagement/newActivity.vue
  31. 3 3
      src/views/storeDecoration/add.vue
  32. 6 12
      src/views/storeDecoration/decorationCompanyDetail.vue
  33. 22 52
      src/views/storeDecoration/detail.vue
  34. 5 3
      src/views/storeDecoration/facilitiesAndServices/components/FacilityManagement.vue
  35. 2 0
      src/views/storeDecoration/facilitiesAndServices/components/ServiceManagement.vue
  36. 6 34
      src/views/storeDecoration/officialPhotoAlbum/index.vue
  37. 207 95
      src/views/storeDecoration/personnelConfig/index.vue
  38. 269 0
      src/views/storeDecoration/positionManagement/index.vue
  39. 10 78
      src/views/storeDecoration/storeCoverMap/index.vue
  40. 2 6
      src/views/storeDecoration/storeHeadMap/index.vue

+ 1 - 3
.env.development

@@ -25,9 +25,7 @@ VITE_API_URL_PLATFORM = /api/alienStorePlatform
 # 开发环境跨域代理,支持配置多个
 # VITE_PROXY = [["/api","https://api.ailien.shop"]] #生产环境
 # /ai-upload:Tus/simple 上传
-# /cover-audit:封面合规审核 → http://124.93.18.180:9000(避免浏览器直连跨域)
-VITE_COVER_AUDIT_BASE = /cover-audit
-VITE_PROXY = [["/api","http://120.26.186.130:8000"],["/ai-upload","https://uat.ailien.shop"],["/cover-audit","http://124.93.18.180:9000"]] # 邹建宇
+VITE_PROXY = [["/api","http://120.26.186.130:8000"],["/ai-upload","https://uat.ailien.shop"]] # 邹建宇
 
 
 # WebSocket 基础地址(分享等能力,与商家端一致)

+ 2 - 1
.env.production

@@ -38,7 +38,7 @@ VITE_PROXY = [["/alienStore","http://120.26.186.130:8000/alienStore"]]
 VITE_PROXY_AI = [["/ai-api","http://124.93.18.180:9000"]]
 
 # 封面合规审核(预生产端口 9300;正式环境若端口不同请改此处或 nginx 反代为同源路径)
-VITE_COVER_AUDIT_BASE = http://124.93.18.180:9300
+VITE_COVER_AUDIT_BASE = http://183.252.196.135:9500
 
 # WebSocket:HTTPS 部署下「联系业主」等页面会使用 getWebSocketBase() → wss://当前访问域名/alienStore/socket
 # 可不配置 VITE_WS_BASE,避免写死域名与实际上线域名不一致。仅 HTTP/本地联调时再配 ws/wss。
@@ -63,3 +63,4 @@ VITE_CRYPTO_KEY = alien67890982316
 
 # AES 向量(16字节)
 VITE_CRYPTO_IV = 1234560405060708
+

+ 0 - 3
.env.test

@@ -44,9 +44,6 @@ VITE_PROXY = [["/api","http://120.26.186.130:8000"],["/ai-upload","https://uat.a
 # 简单上传与 Tus:与 UAT 上传服务一致(不配则默认同源 /ai-upload,测试部署在非 uat 域名时常 404)
 VITE_AI_UPLOAD_BASE = https://uat.ailien.shop
 
-# 封面合规审核(测试环境端口 9100,需服务端 CORS 或 Nginx 反代)
-VITE_COVER_AUDIT_BASE = http://124.93.18.180:9100
-
 # 接口加密配置
 # 加密功能总开关
 VITE_API_ENCRYPTION_ENABLED = false

+ 5 - 1
src/api/index.ts

@@ -16,6 +16,8 @@ export interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
   loading?: boolean;
   cancel?: boolean;
   encrypt?: boolean; // 是否加密请求参数
+  /** 业务 code 非成功时不弹出全局 ElMessage,由调用方自行处理 */
+  hideBusinessErrorMessage?: boolean;
 }
 
 const config = {
@@ -101,7 +103,9 @@ class RequestHttp {
         }
         // 全局错误信息拦截(防止下载文件的时候返回数据流,没有 code 直接报错)
         if (processedData.code && processedData.code !== ResultEnum.SUCCESS) {
-          ElMessage.error(processedData.msg);
+          if (!config?.hideBusinessErrorMessage) {
+            ElMessage.error(processedData.msg);
+          }
           return Promise.reject(processedData);
         }
         // 成功请求(在页面上除非特殊情况,否则不用处理失败逻辑)

+ 5 - 1
src/api/indexApi.ts

@@ -15,6 +15,8 @@ export interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
   loading?: boolean;
   cancel?: boolean;
   encrypt?: boolean; // 是否加密请求参数
+  /** 业务 code 非成功时不弹出全局 ElMessage,由调用方自行处理 */
+  hideBusinessErrorMessage?: boolean;
 }
 
 const config = {
@@ -102,7 +104,9 @@ class RequestHttp {
         }
         // 全局错误信息拦截(防止下载文件的时候返回数据流,没有 code 直接报错)
         if (processedData.code && processedData.code !== ResultEnum.SUCCESS) {
-          ElMessage.error(processedData.msg);
+          if (!config?.hideBusinessErrorMessage) {
+            ElMessage.error(processedData.msg);
+          }
           return Promise.reject(processedData);
         }
         // 成功请求(在页面上除非特殊情况,否则不用处理失败逻辑)

+ 0 - 46
src/api/modules/coverAudit.ts

@@ -1,46 +0,0 @@
-import { COVER_AUDIT_BASE_URL } from "@/utils/config";
-import { useUserStore } from "@/stores/modules/user";
-
-/**
- * 封面素材合规审核(与商家端 `merchantSetInfo.getCoverAuditData`、路径一致)
- * POST `/ai/auto-review/api/v1/cover_audit/audit`,body `{ url }` 为图片或视频直链。
- * 基址:`COVER_AUDIT_BASE_URL`(固定 IP + 环境端口或 `VITE_COVER_AUDIT_BASE`)
- */
-export async function getCoverAuditData(payload: { url: string }): Promise<Record<string, unknown>> {
-  const base = String(COVER_AUDIT_BASE_URL || "").replace(/\/+$/, "");
-  if (!base) {
-    throw new Error("未配置封面审核服务地址");
-  }
-
-  const userStore = useUserStore();
-  const token = userStore.token || "";
-  const reqUrl = `${base}/ai/auto-review/api/v1/cover_audit/audit`;
-
-  const res = await fetch(reqUrl, {
-    method: "POST",
-    headers: {
-      "Content-Type": "application/json",
-      ...(token ? { Authorization: token } : {})
-    },
-    body: JSON.stringify(payload),
-    credentials: "omit"
-  });
-
-  const text = await res.text();
-  let body: Record<string, unknown> = {};
-  try {
-    body = text ? (JSON.parse(text) as Record<string, unknown>) : {};
-  } catch {
-    throw new Error(text?.slice?.(0, 200) || "封面审核响应解析失败");
-  }
-
-  if (!res.ok) {
-    const msg =
-      (typeof body.msg === "string" && body.msg) ||
-      (typeof body.message === "string" && body.message) ||
-      `请求失败(${res.status})`;
-    throw new Error(msg);
-  }
-
-  return body;
-}

+ 19 - 8
src/api/modules/storeDecoration.ts

@@ -2,6 +2,7 @@ import { ResPage, StoreUser } from "@/api/interface/index";
 import { PORT_NONE } from "@/api/config/servicePort";
 import http from "@/api";
 import httpApi from "@/api/indexApi";
+import type { CustomAxiosRequestConfig } from "@/api/indexApi";
 import { uploadFormDataSimpleCompat } from "@/api/upload.js";
 
 /**
@@ -374,12 +375,17 @@ export const setOnlineStatus = (params: { id: string | number; onlineStatus: 0 |
 export const getStaffConfigDetail = (params: any) => {
   return httpApi.get(`/alienStore/storeStaffConfig/getStaffConfigDeatail`, params);
 };
-//创建标题
-export const createTitle = (params: any) => {
-  return httpApi.post(`/alienStore/storeStaffTitle/createTitle`, params);
-};
-//修改标题
-export const updateTitle = (params: any) => {
+/** 新增标题(职位)POST /alienStore/storeStaffTitle/createTitle */
+export const createTitle = (
+  params: { storeId: number | string; titleName: string },
+  requestConfig?: Pick<CustomAxiosRequestConfig, "hideBusinessErrorMessage" | "loading" | "cancel">
+) => {
+  return httpApi.post(`/alienStore/storeStaffTitle/createTitle`, params, {
+    ...(requestConfig ?? {})
+  } as CustomAxiosRequestConfig);
+};
+/** 编辑标题 POST /alienStore/storeStaffTitle/updateTitle */
+export const updateTitle = (params: { id: string | number; storeId: number | string; titleName: string }) => {
   return httpApi.post(`/alienStore/storeStaffTitle/updateTitle`, params);
 };
 //获取标题列表
@@ -404,7 +410,12 @@ export const getTitleDetail = (params: any) => {
     return Promise.resolve({ code: 200, data: null, msg: "" });
   });
 };
-//删除标题
-export const deleteTitle = (params: any) => {
+//删除标题(入参 id)
+export const deleteTitle = (params: { id: string | number }) => {
   return httpApi.get(`/alienStore/storeStaffTitle/deleteTitle`, params);
 };
+
+/** 职位/标题列表查询(仅 storeId) */
+export const queryStaffTitle = (params: { storeId: number | string }) => {
+  return httpApi.get(`/alienStore/storeStaffTitle/query`, params);
+};

+ 35 - 4
src/assets/json/authMenuList.json

@@ -183,9 +183,9 @@
       ]
     },
     {
-      "path": "/storeDecoration/personnelConfig",
-      "name": "personnelConfig",
-      "component": "/storeDecoration/personnelConfig/index",
+      "path": "/personnelConfig",
+      "name": "personnelConfigMenu",
+      "redirect": "/storeDecoration/positionManagement",
       "meta": {
         "icon": "User",
         "title": "人员配置",
@@ -195,7 +195,38 @@
         "isAffix": false,
         "isKeepAlive": false
       },
-      "children": []
+      "children": [
+        {
+          "path": "/storeDecoration/positionManagement",
+          "name": "positionManagement",
+          "component": "/storeDecoration/positionManagement/index",
+          "meta": {
+            "icon": "Postcard",
+            "title": "职位管理",
+            "activeMenu": "/personnelConfig",
+            "isLink": "",
+            "isHide": false,
+            "isFull": false,
+            "isAffix": false,
+            "isKeepAlive": false
+          }
+        },
+        {
+          "path": "/storeDecoration/personnelConfig",
+          "name": "personnelConfig",
+          "component": "/storeDecoration/personnelConfig/index",
+          "meta": {
+            "icon": "UserFilled",
+            "title": "人员管理",
+            "activeMenu": "/personnelConfig",
+            "isLink": "",
+            "isHide": false,
+            "isFull": false,
+            "isAffix": false,
+            "isKeepAlive": false
+          }
+        }
+      ]
     },
     {
       "path": "/performance",

+ 32 - 0
src/components/pcMediaPreview/PcImagePreviewViewer.vue

@@ -0,0 +1,32 @@
+<template>
+  <el-image-viewer
+    v-if="visible && urlList.length"
+    :url-list="urlList"
+    :initial-index="initialIndex"
+    teleported
+    :z-index="zIndex"
+    @close="handleClose"
+  />
+</template>
+
+<script setup lang="ts">
+withDefaults(
+  defineProps<{
+    /** 与 v-model:visible 同步 */
+    visible: boolean;
+    urlList: string[];
+    initialIndex?: number;
+    /** 高于 el-table fixed 列、侧栏等,避免预览被盖住 */
+    zIndex?: number;
+  }>(),
+  { initialIndex: 0, zIndex: 5000 }
+);
+
+const emit = defineEmits<{
+  "update:visible": [boolean];
+}>();
+
+function handleClose() {
+  emit("update:visible", false);
+}
+</script>

+ 49 - 0
src/components/pcMediaPreview/PcVideoPreviewDialog.vue

@@ -0,0 +1,49 @@
+<template>
+  <el-dialog
+    v-model="open"
+    :title="title"
+    width="min(92vw, 720px)"
+    class="pc-video-preview-dialog"
+    append-to-body
+    destroy-on-close
+    align-center
+    @closed="onDialogClosed"
+  >
+    <video v-if="src" :key="src" :src="src" class="pc-video-preview-dialog__video" controls playsinline :autoplay="autoplay" />
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+const open = defineModel<boolean>({ required: true });
+
+withDefaults(
+  defineProps<{
+    src: string;
+    title?: string;
+    /** 与旧版各页一致:需要自动播放的页面可传 true */
+    autoplay?: boolean;
+  }>(),
+  {
+    title: "查看",
+    autoplay: false
+  }
+);
+
+const emit = defineEmits<{
+  closed: [];
+}>();
+
+function onDialogClosed() {
+  emit("closed");
+}
+</script>
+
+<style scoped lang="scss">
+.pc-video-preview-dialog__video {
+  display: block;
+  width: 100%;
+  max-height: 72vh;
+  background: #0a0a0a;
+  border-radius: 8px;
+}
+</style>

+ 2 - 0
src/stores/modules/auth.ts

@@ -86,6 +86,8 @@ export const useAuthStore = defineStore({
                 menu.meta.isHide = false;
                 break;
               case "人员配置":
+              case "人员管理":
+              case "职位管理":
                 menu.meta.isHide = !storeId;
                 break;
               case "食品经营许可证":

+ 0 - 4
src/typings/global.d.ts

@@ -66,10 +66,6 @@ declare interface ViteEnv {
   VITE_WS_BASE?: string;
   /** 上传 API 请求根(可选;不配则默认同源 /ai-upload,依赖 Vite/Nginx 反代) */
   VITE_AI_UPLOAD_BASE?: string;
-  /** 封面审核根:完整 URL 或同源代理路径(如 /cover-audit);不配则用 124.93.18.180 + 环境默认端口 */
-  VITE_COVER_AUDIT_BASE?: string;
-  /** 封面审核端口(仅当未配 VITE_COVER_AUDIT_BASE 时):与固定 IP 拼接 */
-  VITE_COVER_AUDIT_PORT?: string;
   /** 上传完成后对外访问 URL 前缀 */
   VITE_AI_FILES_PUBLIC_BASE?: string;
   /** 可选;官方相册视频 simple 上传服务根,不配则开发走相对路径 /dev-upload-ailien/...、生产默认 upload.ailien.shop:8443 */

+ 0 - 26
src/utils/config.ts

@@ -7,32 +7,6 @@ const trimSlash = (s: string) => s.replace(/\/$/, "");
 
 export const BASE_AI_URL = trimSlash(String(import.meta.env.VITE_AI_UPLOAD_BASE || "").trim() || "/ai-upload");
 
-/** 封面合规审核服务固定主机(与商家端 AI 网关一致) */
-const COVER_AUDIT_FIXED_HOST = "124.93.18.180";
-
-/**
- * 封面审核:`POST …/ai/auto-review/api/v1/cover_audit/audit`
- * - 优先 `VITE_COVER_AUDIT_BASE`:完整 URL(如 `http://124.93.18.180:9000`)或开发代理路径(如 `/cover-audit`)
- * - 否则 `VITE_COVER_AUDIT_PORT`:与固定 IP 拼装
- * - 再否则按构建模式:`development`→9000,`test`→9100,`production`→9300(预生产等可在 .env 覆盖)
- */
-function resolveCoverAuditBase(): string {
-  const explicit = String(import.meta.env.VITE_COVER_AUDIT_BASE || "").trim();
-  if (explicit) {
-    return trimSlash(explicit);
-  }
-  let port = String(import.meta.env.VITE_COVER_AUDIT_PORT || "").trim();
-  if (!port) {
-    const mode = import.meta.env.MODE;
-    if (mode === "test") port = "9100";
-    else if (mode === "production") port = "9300";
-    else port = "9000";
-  }
-  return trimSlash(`http://${COVER_AUDIT_FIXED_HOST}:${port}`);
-}
-
-export const COVER_AUDIT_BASE_URL = resolveCoverAuditBase();
-
 /** 上传完成后对外可访问的文件 URL:`${AI_UPLOAD_FILES_PUBLIC_BASE}/${uploadId}` */
 export const AI_UPLOAD_FILES_PUBLIC_BASE = trimSlash(
   String(import.meta.env.VITE_AI_FILES_PUBLIC_BASE || "").trim() || "https://uat.ailien.shop/files"

+ 29 - 2
src/views/appoinmentManagement/classifyManagement.vue

@@ -57,6 +57,7 @@
               :limit="9"
               :http-request="handlePlaneImageUpload"
               :on-remove="onPlaneImageRemove"
+              :on-preview="onPlaneImagePreview"
               accept="image/*"
               multiple
             >
@@ -90,6 +91,12 @@
         <el-button type="primary" :loading="submitLoading" @click="submitForm"> 确定 </el-button>
       </template>
     </el-dialog>
+
+    <PcImagePreviewViewer
+      v-model:visible="planeImageViewerVisible"
+      :url-list="planeImageViewerUrlList"
+      :initial-index="planeImageViewerInitialIndex"
+    />
   </div>
 </template>
 
@@ -98,8 +105,9 @@ import { ref, reactive } from "vue";
 import { ElMessage, ElMessageBox } from "element-plus";
 import { useDebounceFn } from "@vueuse/core";
 import type { FormInstance, FormRules } from "element-plus";
-import type { UploadRequestOptions, UploadUserFile } from "element-plus";
+import type { UploadFile, UploadRequestOptions, UploadUserFile } from "element-plus";
 import { Plus } from "@element-plus/icons-vue";
+import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
 import ProTable from "@/components/ProTable/index.vue";
 import type { ProTableInstance, ColumnProps } from "@/components/ProTable/interface";
 import {
@@ -203,6 +211,11 @@ const form = reactive<{
   planeImageFileList: []
 });
 
+/** picture-card 放大镜:EP2 默认 onPreview 为空,需自行打开预览(挂 body,高于弹窗/表格) */
+const planeImageViewerVisible = ref(false);
+const planeImageViewerUrlList = ref<string[]>([]);
+const planeImageViewerInitialIndex = ref(0);
+
 const rules: FormRules = {
   name: [{ required: true, message: "请输入分类名称", trigger: "blur" }],
   maxBookingTime: [{ required: true, message: "请输入最长预订时间", trigger: "blur" }],
@@ -252,6 +265,19 @@ function onPlaneImageRemove(_file: UploadUserFile, fileList: UploadUserFile[]) {
   formRef.value?.validateField("planeImageUrls").catch(() => {});
 }
 
+function onPlaneImagePreview(file: UploadFile) {
+  const urls = form.planeImageFileList.map(f => String((f as UploadUserFile).url || "").trim()).filter(Boolean);
+  if (!urls.length) {
+    ElMessage.warning("暂无可预览的图片");
+    return;
+  }
+  const u = String(file.url || "").trim();
+  const idx = u ? urls.indexOf(u) : 0;
+  planeImageViewerUrlList.value = urls;
+  planeImageViewerInitialIndex.value = idx >= 0 ? idx : 0;
+  planeImageViewerVisible.value = true;
+}
+
 function getStoreId(): number | string | null {
   return localGet("geeker-user")?.userInfo?.storeId ?? localGet("createdId") ?? null;
 }
@@ -300,7 +326,7 @@ function parseHasReservationResponse(res: any): boolean {
   return Boolean(val);
 }
 
-async function categoryHasReservation(categoryId: number | string, storeId: number | string): Promise<boolean> {
+async function categoryHasReservation(categoryId: number | string): Promise<boolean> {
   const res: any = await getHasReservation({ categoryId: categoryId, storeId: getStoreId() });
   return parseHasReservationResponse(res);
 }
@@ -394,6 +420,7 @@ async function handleDelete(row: CategoryRow) {
 }
 
 function onDialogClose() {
+  planeImageViewerVisible.value = false;
   editId.value = null;
   form.name = "";
   form.maxBookingTime = 0;

+ 17 - 1
src/views/dynamicManagement/index.vue

@@ -494,6 +494,11 @@
         </div>
       </template>
     </el-dialog>
+    <PcImagePreviewViewer
+      v-model:visible="reportEvidencePreviewVisible"
+      :url-list="reportEvidencePreviewUrls"
+      :initial-index="reportEvidencePreviewIndex"
+    />
   </div>
 </template>
 
@@ -537,6 +542,7 @@ import {
 import { normalizeCommonCommentListResponse, countTopAndNestedComments } from "@/utils/commonCommentList";
 import { useUserStore } from "@/stores/modules/user";
 import { useWebSocketStore } from "@/stores/modules/websocket";
+import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
 
 const router = useRouter();
 const userStore = useUserStore();
@@ -657,6 +663,9 @@ const reportForm = reactive({
   fileList: [] as any[],
   agreed: false
 });
+const reportEvidencePreviewVisible = ref(false);
+const reportEvidencePreviewUrls = ref<string[]>([]);
+const reportEvidencePreviewIndex = ref(0);
 
 // 分享对话框相关
 interface ShareFriend {
@@ -1394,7 +1403,14 @@ const handleReportDynamic = () => {
 
 // 举报图片预览
 const handleReportPreview = (uploadFile: any) => {
-  console.log("预览图片", uploadFile);
+  const list = (reportForm.fileList || []).map((f: any) => String(f.url || "").trim()).filter(Boolean);
+  const url = String(uploadFile?.url || "").trim();
+  const urls = list.length ? list : url ? [url] : [];
+  if (!urls.length) return;
+  const idx = url ? urls.indexOf(url) : 0;
+  reportEvidencePreviewUrls.value = urls;
+  reportEvidencePreviewIndex.value = Math.max(0, idx);
+  reportEvidencePreviewVisible.value = true;
 };
 
 // 移除举报图片

+ 17 - 2
src/views/dynamicManagement/myDynamic.vue

@@ -707,6 +707,11 @@
         </div>
       </template>
     </el-dialog>
+    <PcImagePreviewViewer
+      v-model:visible="reportEvidencePreviewVisible"
+      :url-list="reportEvidencePreviewUrls"
+      :initial-index="reportEvidencePreviewIndex"
+    />
   </div>
 </template>
 
@@ -758,6 +763,7 @@ import { useUserStore } from "@/stores/modules/user";
 import FriendCoupon from "./friendCoupon.vue";
 import { localGet } from "@/utils";
 import { normalizeCommonCommentListResponse, countTopAndNestedComments } from "@/utils/commonCommentList";
+import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
 const router = useRouter();
 const userStore = useUserStore();
 
@@ -917,6 +923,9 @@ const reportForm = reactive({
   fileList: [] as any[],
   agreed: false
 });
+const reportEvidencePreviewVisible = ref(false);
+const reportEvidencePreviewUrls = ref<string[]>([]);
+const reportEvidencePreviewIndex = ref(0);
 
 // 分享对话框相关
 interface ShareFriend {
@@ -1525,8 +1534,14 @@ const handleBlockUser = async (skipConfirm: boolean = false) => {
 
 // 举报图片预览
 const handleReportPreview = (uploadFile: any) => {
-  // 可以添加图片预览功能
-  console.log("预览图片", uploadFile);
+  const list = (reportForm.fileList || []).map((f: any) => String(f.url || "").trim()).filter(Boolean);
+  const url = String(uploadFile?.url || "").trim();
+  const urls = list.length ? list : url ? [url] : [];
+  if (!urls.length) return;
+  const idx = url ? urls.indexOf(url) : 0;
+  reportEvidencePreviewUrls.value = urls;
+  reportEvidencePreviewIndex.value = Math.max(0, idx);
+  reportEvidencePreviewVisible.value = true;
 };
 
 // 移除举报图片

+ 26 - 14
src/views/dynamicManagement/publishDynamic.vue

@@ -94,13 +94,18 @@
       </div>
     </div>
 
-    <!-- 图片/视频预览对话框 -->
-    <el-dialog v-model="previewDialogVisible" width="800px" append-to-body>
-      <!-- 视频预览 -->
-      <video v-if="previewIsVideo" :src="previewImageUrl" controls style="width: 100%; max-height: 70vh" />
-      <!-- 图片预览 -->
-      <img v-else :src="previewImageUrl" alt="预览图片" style="width: 100%" />
-    </el-dialog>
+    <PcImagePreviewViewer
+      v-model:visible="previewImageViewerVisible"
+      :url-list="previewImageUrl ? [previewImageUrl] : []"
+      :initial-index="0"
+    />
+    <PcVideoPreviewDialog
+      v-model="previewVideoDialogVisible"
+      :src="previewVideoUrl"
+      title="查看"
+      :autoplay="false"
+      @closed="previewVideoUrl = ''"
+    />
 
     <!-- 位置选择对话框 -->
     <el-dialog v-model="locationDialogVisible" title="选择位置" width="500px" append-to-body>
@@ -137,6 +142,8 @@ import { uploadFilesToOss, isUploadUserCancelledError } from "@/api/upload.js";
 import { useSimpleUploadOverlayStore } from "@/stores/modules/simpleUploadOverlay";
 import { useUserStore } from "@/stores/modules/user";
 import { getUserDraftDynamics, getInputPrompt } from "@/api/modules/newLoginApi";
+import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
+import PcVideoPreviewDialog from "@/components/pcMediaPreview/PcVideoPreviewDialog.vue";
 
 const router = useRouter();
 const route = useRoute();
@@ -188,9 +195,10 @@ interface LocationItem {
 // 响应式数据
 const formRef = ref<FormInstance>();
 const fileList = ref<UploadUserFile[]>([]);
-const previewDialogVisible = ref(false);
+const previewImageViewerVisible = ref(false);
+const previewVideoDialogVisible = ref(false);
 const previewImageUrl = ref("");
-const previewIsVideo = ref(false); // 预览的是否为视频
+const previewVideoUrl = ref("");
 const locationDialogVisible = ref(false);
 const locationSearch = ref("");
 const locationList = ref<LocationItem[]>([]);
@@ -514,12 +522,16 @@ const checkUploadPermission = (file: File, excludeRawFile?: File): { allowed: bo
   return { allowed: true };
 };
 
-// 图片/视频预览
+// 图片/视频预览(统一 PC 组件)
 const handlePicturePreview = (uploadFile: UploadUserFile) => {
-  previewImageUrl.value = uploadFile.url!;
-  // 判断是否为视频文件
-  previewIsVideo.value = isVideoFile(uploadFile);
-  previewDialogVisible.value = true;
+  const url = uploadFile.url!;
+  if (isVideoFile(uploadFile)) {
+    previewVideoUrl.value = url;
+    previewVideoDialogVisible.value = true;
+  } else {
+    previewImageUrl.value = url;
+    previewImageViewerVisible.value = true;
+  }
 };
 
 // 移除图片/视频(组件回调)

+ 5 - 8
src/views/dynamicManagement/reviewAppeal.vue

@@ -207,17 +207,13 @@
     </el-dialog>
 
     <!-- 视频预览弹窗 -->
-    <el-dialog
+    <PcVideoPreviewDialog
       v-model="videoPreviewVisible"
+      :src="videoPreviewUrl"
       title="视频预览"
-      width="80%"
-      max-width="720px"
-      destroy-on-close
-      align-center
+      :autoplay="true"
       @closed="closeVideoPreview"
-    >
-      <video v-if="videoPreviewUrl" :src="videoPreviewUrl" controls autoplay style="width: 100%; max-height: 70vh" />
-    </el-dialog>
+    />
   </div>
 </template>
 
@@ -231,6 +227,7 @@ import { getList, addAppealNew, saveComment2, getRatingCount } from "@/api/modul
 import { uploadFileToOss } from "@/api/upload.js";
 import { localGet } from "@/utils";
 import { useUserStore } from "@/stores/modules/user";
+import PcVideoPreviewDialog from "@/components/pcMediaPreview/PcVideoPreviewDialog.vue";
 
 const router = useRouter();
 const userStore = useUserStore();

+ 5 - 8
src/views/dynamicManagement/reviewAppealDetail.vue

@@ -141,17 +141,13 @@
     </div>
 
     <!-- 视频预览弹窗 -->
-    <el-dialog
+    <PcVideoPreviewDialog
       v-model="videoPreviewVisible"
+      :src="videoPreviewUrl"
       title="视频预览"
-      width="80%"
-      max-width="720px"
-      destroy-on-close
-      align-center
+      :autoplay="true"
       @closed="closeVideoPreview"
-    >
-      <video v-if="videoPreviewUrl" :src="videoPreviewUrl" controls autoplay style="width: 100%; max-height: 70vh" />
-    </el-dialog>
+    />
   </div>
 </template>
 
@@ -161,6 +157,7 @@ import { useRouter, useRoute } from "vue-router";
 import { Clock, User, VideoPlay } from "@element-plus/icons-vue";
 import { ElMessage } from "element-plus";
 import { getAppealDetail } from "@/api/modules/newLoginApi";
+import PcVideoPreviewDialog from "@/components/pcMediaPreview/PcVideoPreviewDialog.vue";
 
 const router = useRouter();
 const route = useRoute();

+ 5 - 8
src/views/dynamicManagement/reviewAppealHistory.vue

@@ -107,17 +107,13 @@
     </div>
 
     <!-- 视频预览弹窗 -->
-    <el-dialog
+    <PcVideoPreviewDialog
       v-model="videoPreviewVisible"
+      :src="videoPreviewUrl"
       title="视频预览"
-      width="80%"
-      max-width="720px"
-      destroy-on-close
-      align-center
+      :autoplay="true"
       @closed="closeVideoPreview"
-    >
-      <video v-if="videoPreviewUrl" :src="videoPreviewUrl" controls autoplay style="width: 100%; max-height: 70vh" />
-    </el-dialog>
+    />
   </div>
 </template>
 
@@ -128,6 +124,7 @@ import { User, Clock, CircleCheck, CircleClose, VideoPlay } from "@element-plus/
 import { ElMessage } from "element-plus";
 import { getAppealHistory } from "@/api/modules/newLoginApi";
 import { localGet } from "@/utils";
+import PcVideoPreviewDialog from "@/components/pcMediaPreview/PcVideoPreviewDialog.vue";
 
 const router = useRouter();
 

+ 17 - 1
src/views/dynamicManagement/userDynamic.vue

@@ -566,6 +566,11 @@
         </div>
       </template>
     </el-dialog>
+    <PcImagePreviewViewer
+      v-model:visible="reportEvidencePreviewVisible"
+      :url-list="reportEvidencePreviewUrls"
+      :initial-index="reportEvidencePreviewIndex"
+    />
   </div>
 </template>
 
@@ -609,6 +614,7 @@ import {
 import { useUserStore } from "@/stores/modules/user";
 import { localGet } from "@/utils";
 import { normalizeCommonCommentListResponse, countTopAndNestedComments } from "@/utils/commonCommentList";
+import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
 
 const route = useRoute();
 const router = useRouter();
@@ -671,6 +677,9 @@ const reportForm = reactive({
   fileList: [] as any[],
   agreed: false
 });
+const reportEvidencePreviewVisible = ref(false);
+const reportEvidencePreviewUrls = ref<string[]>([]);
+const reportEvidencePreviewIndex = ref(0);
 
 // 评论相关
 const commentDrawerVisible = ref(false);
@@ -1455,7 +1464,14 @@ const handleBlockUser = async (skipConfirm: boolean = false) => {
 
 // 举报图片预览
 const handleReportPreview = (uploadFile: any) => {
-  console.log("预览图片", uploadFile);
+  const list = (reportForm.fileList || []).map((f: any) => String(f.url || "").trim()).filter(Boolean);
+  const url = String(uploadFile?.url || "").trim();
+  const urls = list.length ? list : url ? [url] : [];
+  if (!urls.length) return;
+  const idx = url ? urls.indexOf(url) : 0;
+  reportEvidencePreviewUrls.value = urls;
+  reportEvidencePreviewIndex.value = Math.max(0, idx);
+  reportEvidencePreviewVisible.value = true;
 };
 
 // 移除举报图片

+ 3 - 3
src/views/groupPackageManagement/newGroup.vue

@@ -413,11 +413,10 @@
       <el-button type="primary" @click="handleSubmit()" :disabled="hasUnuploadedImages"> 确定 </el-button>
     </div>
     <!-- 图片预览 -->
-    <el-image-viewer
-      v-if="imageViewerVisible"
+    <PcImagePreviewViewer
+      v-model:visible="imageViewerVisible"
       :url-list="imageViewerUrlList"
       :initial-index="imageViewerInitialIndex"
-      @close="imageViewerVisible = false"
     />
     <!-- 菜品选择对话框 -->
     <el-dialog v-model="dishDialogVisible" title="菜品" width="600px" :close-on-click-modal="false">
@@ -478,6 +477,7 @@ import { saveDraft, getHolidayList, getMenuByStoreId, getThaliById, saveThali }
 import { useRouter, useRoute } from "vue-router";
 import type { UploadProps, FormInstance, UploadInstance, UploadFile } from "element-plus";
 import { localGet } from "@/utils";
+import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
 import {
   validatePositiveInteger,
   validateDateRangeArray,

+ 3 - 3
src/views/home/components/go-flow.vue

@@ -325,11 +325,10 @@
   </el-dialog>
 
   <!-- 图片预览 -->
-  <el-image-viewer
-    v-if="imageViewerVisible"
+  <PcImagePreviewViewer
+    v-model:visible="imageViewerVisible"
     :url-list="imageViewerUrlList"
     :initial-index="imageViewerInitialIndex"
-    @close="imageViewerVisible = false"
   />
 </template>
 
@@ -344,6 +343,7 @@ import GoBusinessHours from "./go-businessHours.vue";
 import { localGet, localSet } from "@/utils/index";
 import { useAuthStore } from "@/stores/modules/auth";
 import { useRouter } from "vue-router";
+import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
 
 const authStore = useAuthStore();
 const router = useRouter();

+ 4 - 4
src/views/licenseManagement/businessLicense.vue

@@ -67,11 +67,10 @@
     </el-dialog>
 
     <!-- 图片预览 -->
-    <el-image-viewer
-      v-if="imageViewerVisible"
+    <PcImagePreviewViewer
+      v-model:visible="imageViewerVisible"
       :url-list="imageViewerUrlList"
       :initial-index="imageViewerInitialIndex"
-      @close="imageViewerVisible = false"
     />
 
     <!-- 变更记录弹窗 -->
@@ -132,6 +131,7 @@ import {
   ocrRequestUrl
 } from "@/api/modules/licenseManagement";
 import { localGet } from "@/utils";
+import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
 
 const userInfo = localGet("geeker-user")?.userInfo || {};
 // 状态映射对象
@@ -504,7 +504,7 @@ const uploadSingleFile = async (file: UploadFile) => {
 };
 
 /**
- * 图片预览 - 使用 el-image-viewer 预览功能
+ * 图片预览 - 使用统一 PcImagePreviewViewer(Element Plus 图集层)
  * @param file 上传文件对象
  */
 const handlePictureCardPreview = (file: any) => {

+ 4 - 4
src/views/licenseManagement/contractManagement.vue

@@ -80,11 +80,10 @@
     </el-dialog>
 
     <!-- 图片预览 -->
-    <el-image-viewer
-      v-if="imageViewerVisible"
+    <PcImagePreviewViewer
+      v-model:visible="imageViewerVisible"
       :url-list="imageViewerUrlList"
       :initial-index="imageViewerInitialIndex"
-      @close="imageViewerVisible = false"
     />
 
     <!-- 变更记录弹窗 -->
@@ -137,6 +136,7 @@ import { ElMessage, ElMessageBox } from "element-plus";
 import { Plus, Picture } from "@element-plus/icons-vue";
 import type { UploadProps, UploadFile } from "element-plus";
 import { localGet } from "@/utils";
+import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
 import {
   getContractImages,
   uploadContractImage,
@@ -474,7 +474,7 @@ const uploadSingleFile = async (file: UploadFile) => {
 };
 
 /**
- * 图片预览 - 使用 el-image-viewer 预览功能
+ * 图片预览 - 使用统一 PcImagePreviewViewer(Element Plus 图集层)
  * @param file 上传文件对象
  */
 const handlePictureCardPreview = (file: any) => {

+ 4 - 4
src/views/licenseManagement/entertainmentLicense.vue

@@ -67,11 +67,10 @@
     </el-dialog>
 
     <!-- 图片预览 -->
-    <el-image-viewer
-      v-if="imageViewerVisible"
+    <PcImagePreviewViewer
+      v-model:visible="imageViewerVisible"
       :url-list="imageViewerUrlList"
       :initial-index="imageViewerInitialIndex"
-      @close="imageViewerVisible = false"
     />
 
     <!-- 变更记录弹窗 -->
@@ -131,6 +130,7 @@ import {
 } from "@/api/modules/licenseManagement";
 import { uploadFileToOss } from "@/api/upload.js";
 import { localGet } from "@/utils";
+import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
 
 // 状态映射对象
 const statusMap: Record<number, { name: string; class: string }> = {
@@ -449,7 +449,7 @@ const uploadSingleFile = async (file: UploadFile) => {
 };
 
 /**
- * 图片预览 - 使用 el-image-viewer 预览功能
+ * 图片预览 - 使用统一 PcImagePreviewViewer(Element Plus 图集层)
  * @param file 上传文件对象
  */
 const handlePictureCardPreview = (file: any) => {

+ 4 - 4
src/views/licenseManagement/foodBusinessLicense.vue

@@ -67,11 +67,10 @@
     </el-dialog>
 
     <!-- 图片预览 -->
-    <el-image-viewer
-      v-if="imageViewerVisible"
+    <PcImagePreviewViewer
+      v-model:visible="imageViewerVisible"
       :url-list="imageViewerUrlList"
       :initial-index="imageViewerInitialIndex"
-      @close="imageViewerVisible = false"
     />
 
     <!-- 变更记录弹窗 -->
@@ -132,6 +131,7 @@ import {
   ocrRequestUrl
 } from "@/api/modules/licenseManagement";
 import { localGet } from "@/utils";
+import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
 
 const userInfo = localGet("geeker-user")?.userInfo || {};
 // 状态映射对象
@@ -507,7 +507,7 @@ const uploadSingleFile = async (file: UploadFile) => {
 };
 
 /**
- * 图片预览 - 使用 el-image-viewer 预览功能
+ * 图片预览 - 使用统一 PcImagePreviewViewer(Element Plus 图集层)
  * @param file 上传文件对象
  */
 const handlePictureCardPreview = (file: any) => {

+ 3 - 3
src/views/operationManagement/activityList.vue

@@ -160,11 +160,10 @@
     </el-drawer>
 
     <!-- 图片预览 -->
-    <el-image-viewer
-      v-if="resultImageViewerVisible"
+    <PcImagePreviewViewer
+      v-model:visible="resultImageViewerVisible"
       :url-list="resultImageViewerUrlList"
       :initial-index="resultImageViewerInitialIndex"
-      @close="resultImageViewerVisible = false"
     />
   </div>
 </template>
@@ -180,6 +179,7 @@ import { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
 import { getActivityList, deleteActivity, updateActivityStatus, uploadActivityResult } from "@/api/modules/operationManagement";
 import { uploadContractImage } from "@/api/modules/licenseManagement";
 import { localGet, usePermission } from "@/utils";
+import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
 
 const router = useRouter();
 const proTable = ref<ProTableInstance>();

+ 107 - 186
src/views/operationManagement/caseDetail.vue

@@ -33,42 +33,20 @@
               <div class="detail-item">
                 <div class="detail-label">成果描述 : {{ items.achievementDesc || "-" }}</div>
               </div>
-              <div class="detail-item" v-if="items.mediaUrlList && items.mediaUrlList.length > 0">
+              <div class="detail-item" v-if="parseAchievementMedia(items).length > 0">
                 <div class="detail-label">图片与视频 :</div>
                 <div class="media-grid">
-                  <template v-for="(item, mediaIndex) in items.mediaUrlList" :key="mediaIndex">
-                    <!-- 视频:包含 | 分隔符 -->
-                    <div v-if="isVideoUrl(item)" class="media-item video-item" @click="playVideo(getVideoUrl(item))">
-                      <el-image v-if="getVideoCoverUrl(item)" :src="getVideoCoverUrl(item)" fit="cover" class="media-image">
-                        <template #error>
-                          <div class="media-placeholder">
-                            <el-icon class="play-icon">
-                              <VideoPlay />
-                            </el-icon>
-                          </div>
-                        </template>
-                      </el-image>
-                      <div v-else class="media-placeholder">
-                        <el-icon class="play-icon">
-                          <VideoPlay />
-                        </el-icon>
-                      </div>
-                      <!-- 视频播放图标覆盖层 -->
+                  <template v-for="(m, mediaIndex) in parseAchievementMedia(items)" :key="mediaIndex + m.url">
+                    <div v-if="m.type === 'video'" class="media-item video-item" @click="playVideo(m.url)">
+                      <video class="video-thumb" :src="thumbVideoSrc(m.url)" muted playsinline preload="metadata" />
                       <div class="video-overlay">
                         <el-icon class="play-icon-overlay">
                           <VideoPlay />
                         </el-icon>
                       </div>
                     </div>
-                    <!-- 图片:不包含 | 分隔符 -->
-                    <div v-else class="media-item image-item" @click="previewImage(item, items.mediaUrlList, mediaIndex)">
-                      <el-image
-                        :src="item"
-                        fit="cover"
-                        class="media-image"
-                        :preview-src-list="getImageListForItem(items.mediaUrlList)"
-                        :initial-index="getImageIndex(item, items.mediaUrlList)"
-                      >
+                    <div v-else class="media-item image-item" @click.stop="openImagePreview(items, m.url)">
+                      <el-image :src="m.url" fit="cover" class="media-image">
                         <template #error>
                           <div class="image-slot">
                             <el-icon>
@@ -91,9 +69,19 @@
       <el-empty v-else-if="!loading" description="暂无数据" />
     </el-card>
 
-    <el-dialog v-model="videoDialogVisible" title="视频预览" width="640px" destroy-on-close @close="previewVideo = ''">
-      <video v-if="previewVideo" :src="previewVideo" controls autoplay class="dialog-video" />
-    </el-dialog>
+    <PcVideoPreviewDialog
+      v-model="videoDialogVisible"
+      :src="previewVideo"
+      title="视频预览"
+      :autoplay="true"
+      @closed="previewVideo = ''"
+    />
+
+    <PcImagePreviewViewer
+      v-model:visible="imagePreviewVisible"
+      :url-list="imagePreviewUrlList"
+      :initial-index="imagePreviewInitialIndex"
+    />
   </div>
 </template>
 
@@ -102,6 +90,76 @@ import { ref, onMounted, computed } from "vue";
 import { useRoute, useRouter } from "vue-router";
 import { Picture, VideoPlay } from "@element-plus/icons-vue";
 import { getPersonCaseDetail } from "@/api/modules/operationManagement";
+import PcVideoPreviewDialog from "@/components/pcMediaPreview/PcVideoPreviewDialog.vue";
+import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
+
+/** 与 query 无关,仅看路径扩展名(忽略 ? 后参数) */
+function pathLooksVideo(url: string): boolean {
+  const path = String(url || "")
+    .split("?")[0]
+    .split("#")[0]
+    .toLowerCase();
+  return /\.(mp4|m3u8|mov|webm|mkv|mpeg|mpg|avi|3gp)$/i.test(path);
+}
+
+/**
+ * 列表里视频缩略用:尽量用视频自身首帧,不用下一张独立图片当 poster(避免与右侧图片格长得完全一样)。
+ * 对常见 MP4 等追加 `#t=0.001` 促使解码第一帧;HLS(m3u8)或已有 hash 的地址不改动。
+ */
+function thumbVideoSrc(url: string): string {
+  const s = String(url || "").trim();
+  if (!s || s.includes("#")) return s;
+  const pathOnly = s.split("?")[0].toLowerCase();
+  if (/\.m3u8$/i.test(pathOnly)) return s;
+  return `${s}#t=0.001`;
+}
+
+type ParsedMedia = { type: "image" | "video"; url: string };
+
+/**
+ * 成果媒体:仅按英文分号 `;` 切割多条 URL(与后端 mediaUrls / mediaUrlList 一致)。
+ * 每条按扩展名区分视频 / 图片,列表项一一对应网格。
+ */
+function parseAchievementMedia(row: Record<string, unknown>): ParsedMedia[] {
+  const chunks: string[] = [];
+  const pushSplit = (raw: unknown) => {
+    if (raw == null || raw === "") return;
+    if (typeof raw === "string") {
+      raw
+        .split(";")
+        .map(s => s.trim())
+        .filter(Boolean)
+        .forEach(s => chunks.push(s));
+    }
+  };
+
+  pushSplit(row.mediaUrls);
+  const list = row.mediaUrlList;
+  const arr = Array.isArray(list) ? list : list != null ? [list] : [];
+  for (const it of arr) {
+    if (typeof it === "string") pushSplit(it);
+    else if (it && typeof it === "object" && "url" in it) pushSplit((it as { url?: string }).url);
+  }
+
+  const seen = new Set<string>();
+  const tokens = chunks.filter(u => (seen.has(u) ? false : (seen.add(u), true)));
+
+  return tokens.map(u => ({
+    type: pathLooksVideo(u) ? ("video" as const) : ("image" as const),
+    url: u
+  }));
+}
+
+function getImagePreviewList(row: Record<string, unknown>): string[] {
+  return parseAchievementMedia(row)
+    .filter(m => m.type === "image")
+    .map(m => m.url);
+}
+
+function getImagePreviewIndex(url: string, row: Record<string, unknown>): number {
+  const imgs = getImagePreviewList(row);
+  return Math.max(0, imgs.indexOf(url));
+}
 
 const route = useRoute();
 const router = useRouter();
@@ -109,6 +167,9 @@ const loading = ref(false);
 const detail = ref<any>(null);
 const videoDialogVisible = ref(false);
 const previewVideo = ref("");
+const imagePreviewVisible = ref(false);
+const imagePreviewUrlList = ref<string[]>([]);
+const imagePreviewInitialIndex = ref(0);
 
 const activityId = computed(() => route.query.activityId as string);
 const userId = computed(() => route.query.userId as string);
@@ -133,147 +194,13 @@ const formatTime = (time: string | null | undefined) => {
   }
 };
 
-type MediaItem = { type: "image" | "video"; url: string; coverUrl?: string };
-
-const mediaList = computed<MediaItem[]>(() => {
-  if (!detail.value) return [];
-  const list: MediaItem[] = [];
-
-  // 优先处理 achievementList[0].mediaUrlList
-  if (detail.value.achievementList && detail.value.achievementList[0]?.mediaUrlList) {
-    const mediaUrlList = detail.value.achievementList[0].mediaUrlList;
-    const arr = Array.isArray(mediaUrlList) ? mediaUrlList : [mediaUrlList];
-    for (const it of arr) {
-      if (typeof it === "string") {
-        // 检查是否包含 | 分隔符(视频格式:xxx.mp4 | XXX.jpg)
-        if (it.includes("|")) {
-          const parts = it
-            .split("|")
-            .map(s => s.trim())
-            .filter(s => s);
-          if (parts.length >= 2) {
-            // 第一部分是视频URL,第二部分是封面URL
-            list.push({ type: "video", url: parts[0], coverUrl: parts[1] });
-          } else if (parts.length === 1) {
-            // 只有视频URL,没有封面
-            list.push({ type: "video", url: parts[0] });
-          }
-        } else {
-          // 如果是字符串,根据文件扩展名判断类型
-          const isVideo = /\.(mp4|avi|mov|wmv|flv|webm)$/i.test(it);
-          list.push({ type: isVideo ? "video" : "image", url: it });
-        }
-      } else if (it?.url) {
-        // 如果是对象,使用 type 字段或根据 url 判断
-        const isVideo = it.type === "video" || /\.(mp4|avi|mov|wmv|flv|webm)$/i.test(it.url);
-        list.push({ type: isVideo ? "video" : "image", url: it.url, coverUrl: it.coverUrl });
-      } else if (it?.mediaUrl) {
-        // 处理 mediaUrl 字段
-        const isVideo = it.mediaType === "video" || it.type === "video" || /\.(mp4|avi|mov|wmv|flv|webm)$/i.test(it.mediaUrl);
-        list.push({ type: isVideo ? "video" : "image", url: it.mediaUrl, coverUrl: it.coverUrl });
-      }
-    }
-  }
-
-  // 处理 mediaList 字段(可能是数组或对象数组)
-  const raw = detail.value.mediaList ?? detail.value.images ?? detail.value.videos ?? [];
-  const arr = Array.isArray(raw) ? raw : [raw];
-  for (const it of arr) {
-    if (typeof it === "string") {
-      // 检查是否包含 | 分隔符(视频格式:xxx.mp4 | XXX.jpg)
-      if (it.includes("|")) {
-        const parts = it
-          .split("|")
-          .map(s => s.trim())
-          .filter(s => s);
-        if (parts.length >= 2) {
-          // 第一部分是视频URL,第二部分是封面URL
-          list.push({ type: "video", url: parts[0], coverUrl: parts[1] });
-        } else if (parts.length === 1) {
-          // 只有视频URL,没有封面
-          list.push({ type: "video", url: parts[0] });
-        }
-      } else {
-        const isVideo = /\.(mp4|avi|mov|wmv|flv|webm)$/i.test(it);
-        list.push({ type: isVideo ? "video" : "image", url: it });
-      }
-    } else if (it?.url) {
-      const isVideo = it.type === "video" || /\.(mp4|avi|mov|wmv|flv|webm)$/i.test(it.url);
-      list.push({ type: isVideo ? "video" : "image", url: it.url, coverUrl: it.coverUrl });
-    } else if (it?.mediaUrl) {
-      // 处理 mediaUrl 字段
-      const isVideo = it.mediaType === "video" || it.type === "video" || /\.(mp4|avi|mov|wmv|flv|webm)$/i.test(it.mediaUrl);
-      list.push({ type: isVideo ? "video" : "image", url: it.mediaUrl, coverUrl: it.coverUrl });
-    }
-  }
-
-  // 处理单独的图片和视频数组
-  if (detail.value.resultImages && Array.isArray(detail.value.resultImages))
-    detail.value.resultImages.forEach((u: string) => list.push({ type: "image", url: u }));
-  if (detail.value.resultVideos && Array.isArray(detail.value.resultVideos))
-    detail.value.resultVideos.forEach((u: string) => list.push({ type: "video", url: u }));
-
-  return list.filter(Boolean);
-});
-
-const imageList = computed(() => mediaList.value.filter(m => m.type === "image").map(m => m.url));
-
-// 判断是否为视频URL(包含 | 分隔符)
-const isVideoUrl = (url: string) => {
-  if (typeof url !== "string") return false;
-  return url.includes("|");
-};
-
-// 获取视频URL(从 "视频链接|封面链接" 中提取视频链接)
-const getVideoUrl = (url: string) => {
-  if (typeof url !== "string") return "";
-  if (url.includes("|")) {
-    return url.split("|")[0].trim();
-  }
-  return url;
-};
-
-// 获取视频封面URL(从 "视频链接|封面链接" 中提取封面链接)
-const getVideoCoverUrl = (url: string) => {
-  if (typeof url !== "string") return "";
-  if (url.includes("|")) {
-    const parts = url.split("|");
-    if (parts.length >= 2) {
-      return parts[1].trim();
-    }
-  }
-  return "";
-};
-
-// 获取指定 mediaUrlList 中的图片列表(用于预览)
-const getImageListForItem = (mediaUrlList: any[]) => {
-  if (!mediaUrlList || !Array.isArray(mediaUrlList)) return [];
-  return mediaUrlList
-    .filter(item => {
-      // 过滤出图片类型(不包含 | 分隔符的)
-      if (typeof item === "string") {
-        return !item.includes("|");
-      }
-      return false;
-    })
-    .map(item => {
-      if (typeof item === "string") {
-        return item.trim();
-      }
-      return "";
-    })
-    .filter(url => url);
-};
-
-// 获取图片在指定 mediaUrlList 中的索引
-const getImageIndex = (url: string, mediaUrlList: any[]) => {
-  const imageList = getImageListForItem(mediaUrlList);
-  return imageList.indexOf(url);
-};
-
-const previewImage = (url: string, mediaUrlList: any[], index: any) => {
-  // el-image 的 preview-src-list 会自动处理预览
-};
+function openImagePreview(row: Record<string, unknown>, url: string) {
+  const list = getImagePreviewList(row);
+  if (!list.length) return;
+  imagePreviewUrlList.value = list;
+  imagePreviewInitialIndex.value = getImagePreviewIndex(url, row);
+  imagePreviewVisible.value = true;
+}
 
 const playVideo = (url: string) => {
   previewVideo.value = url;
@@ -381,18 +308,17 @@ onMounted(async () => {
   justify-content: center;
   width: 200px;
   height: 200px;
-  background: #000000;
+  background: #1a1a1a;
 }
-.media-placeholder {
-  display: flex;
-  align-items: center;
-  justify-content: center;
+.video-thumb {
+  position: absolute;
+  inset: 0;
+  display: block;
   width: 100%;
   height: 100%;
-  color: #909399;
-}
-.play-icon {
-  font-size: 40px;
+  pointer-events: none;
+  object-fit: cover;
+  background: #1a1a1a;
 }
 .video-overlay {
   position: absolute;
@@ -418,9 +344,4 @@ onMounted(async () => {
   color: #909399;
   background: #f5f7fa;
 }
-.dialog-video {
-  display: block;
-  width: 100%;
-  max-height: 70vh;
-}
 </style>

+ 4 - 4
src/views/operationManagement/newActivity.vue

@@ -339,11 +339,10 @@
     </el-form>
 
     <!-- 图片预览 -->
-    <el-image-viewer
-      v-if="imageViewerVisible"
-      :initial-index="imageViewerInitialIndex"
+    <PcImagePreviewViewer
+      v-model:visible="imageViewerVisible"
       :url-list="imageViewerUrlList"
-      @close="imageViewerVisible = false"
+      :initial-index="imageViewerInitialIndex"
     />
   </div>
 </template>
@@ -367,6 +366,7 @@ import {
 } from "@/api/modules/operationManagement";
 import { uploadFileToOss } from "@/api/upload.js";
 import { localGet } from "@/utils";
+import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
 
 // ==================== 响应式数据定义 ====================
 

+ 3 - 3
src/views/storeDecoration/add.vue

@@ -188,11 +188,10 @@
     </el-dialog>
 
     <!-- 图片预览 -->
-    <el-image-viewer
-      v-if="imageViewerVisible"
+    <PcImagePreviewViewer
+      v-model:visible="imageViewerVisible"
       :url-list="imageViewerUrlList"
       :initial-index="imageViewerInitialIndex"
-      @close="imageViewerVisible = false"
     />
 
     <!-- 用户服务协议弹窗 -->
@@ -564,6 +563,7 @@ import { saveOrUpdateDecoration, getDistrict } from "@/api/modules/storeDecorati
 import { uploadFileToOss } from "@/api/upload.js";
 import { useSimpleUploadOverlayStore } from "@/stores/modules/simpleUploadOverlay";
 import { localGet } from "@/utils";
+import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
 
 const route = useRoute();
 const router = useRouter();

+ 6 - 12
src/views/storeDecoration/decorationCompanyDetail.vue

@@ -133,15 +133,13 @@
       </div>
     </div>
 
-    <el-dialog
+    <PcVideoPreviewDialog
       v-model="videoDialogVisible"
+      :src="previewVideoUrl"
       title="视频预览"
-      width="min(640px, 92vw)"
-      destroy-on-close
-      @close="previewVideoUrl = ''"
-    >
-      <video v-if="previewVideoUrl" :src="previewVideoUrl" controls autoplay class="dialog-video" />
-    </el-dialog>
+      :autoplay="true"
+      @closed="previewVideoUrl = ''"
+    />
   </div>
 </template>
 
@@ -151,6 +149,7 @@ import { useRoute, useRouter } from "vue-router";
 import { ElMessage } from "element-plus";
 import { Plus, Close, VideoPlay } from "@element-plus/icons-vue";
 import { getDecorationDetail } from "@/api/modules/storeDecoration";
+import PcVideoPreviewDialog from "@/components/pcMediaPreview/PcVideoPreviewDialog.vue";
 
 const route = useRoute();
 const router = useRouter();
@@ -403,11 +402,6 @@ onMounted(() => {
     font-size: 28px;
     color: #ffffff;
   }
-  .dialog-video {
-    display: block;
-    width: 100%;
-    max-height: 70vh;
-  }
   .detail-footer {
     padding: 20px 0 0;
     margin-top: 20px;

+ 22 - 52
src/views/storeDecoration/detail.vue

@@ -17,9 +17,9 @@
 
             <el-form-item label="装修类型">
               <el-radio-group v-model="formData.renovationType" disabled>
-                <el-radio :label="1">新房装修</el-radio>
-                <el-radio :label="2">旧房改造</el-radio>
-                <el-radio :label="3">局部装修</el-radio>
+                <el-radio :label="1"> 新房装修 </el-radio>
+                <el-radio :label="2"> 旧房改造 </el-radio>
+                <el-radio :label="3"> 局部装修 </el-radio>
               </el-radio-group>
             </el-form-item>
 
@@ -32,13 +32,7 @@
             </el-form-item>
 
             <el-form-item label="详细需求">
-              <el-input
-                v-model="formData.detailedRequirement"
-                type="textarea"
-                :rows="4"
-                placeholder="请输入"
-                disabled
-              />
+              <el-input v-model="formData.detailedRequirement" type="textarea" :rows="4" placeholder="请输入" disabled />
             </el-form-item>
 
             <el-form-item label="期望装修时间" required>
@@ -80,13 +74,7 @@
             </el-form-item>
 
             <el-form-item label="详细地址">
-              <el-input
-                v-model="formData.detailedAddress"
-                type="textarea"
-                :rows="3"
-                placeholder="请输入"
-                disabled
-              />
+              <el-input v-model="formData.detailedAddress" type="textarea" :rows="3" placeholder="请输入" disabled />
             </el-form-item>
           </el-col>
 
@@ -105,13 +93,7 @@
             </el-form-item>
 
             <el-form-item v-if="formData.auditStatus === 2" label="拒绝原因">
-              <el-input
-                v-model="formData.rejectionReason"
-                type="textarea"
-                :rows="4"
-                placeholder="请输入"
-                disabled
-              />
+              <el-input v-model="formData.rejectionReason" type="textarea" :rows="4" placeholder="请输入" disabled />
               <div class="form-tip">根据审核状态 对应展示</div>
             </el-form-item>
           </el-col>
@@ -119,17 +101,16 @@
       </el-form>
 
       <div class="detail-footer">
-        <el-button @click="handleClose">取消</el-button>
-        <el-button type="primary" @click="handleConfirm">确定</el-button>
+        <el-button @click="handleClose"> 取消 </el-button>
+        <el-button type="primary" @click="handleConfirm"> 确定 </el-button>
       </div>
     </div>
 
     <!-- 图片预览 -->
-    <el-image-viewer
-      v-if="imageViewerVisible"
+    <PcImagePreviewViewer
+      v-model:visible="imageViewerVisible"
       :url-list="imageViewerUrlList"
       :initial-index="imageViewerInitialIndex"
-      @close="imageViewerVisible = false"
     />
   </div>
 </template>
@@ -141,6 +122,7 @@ import { ElMessage } from "element-plus";
 import { Plus, Close } from "@element-plus/icons-vue";
 import type { UploadFile } from "element-plus";
 import { getDecorationDetail } from "@/api/modules/storeDecoration";
+import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
 
 const route = useRoute();
 const router = useRouter();
@@ -272,79 +254,67 @@ onMounted(() => {
   width: 100%;
   min-height: 100%;
   background-color: white;
-
   .content-box {
     padding: 20px;
   }
-
   .detail-header {
     display: flex;
-    justify-content: space-between;
     align-items: center;
-    margin-bottom: 20px;
+    justify-content: space-between;
     padding-bottom: 15px;
+    margin-bottom: 20px;
     border-bottom: 1px solid #ebeef5;
-
     h3 {
       margin: 0;
       font-size: 18px;
       font-weight: 500;
     }
   }
-
   :deep(.el-form-item) {
     margin-bottom: 20px;
   }
-
   :deep(.el-radio-group) {
     display: flex;
     gap: 20px;
   }
-
   .upload-tip {
-    font-size: 12px;
-    color: #999;
     margin-top: 5px;
+    font-size: 12px;
+    color: #999999;
   }
-
   .form-tip {
-    font-size: 12px;
-    color: #999;
     margin-top: 5px;
+    font-size: 12px;
+    color: #999999;
     text-align: right;
   }
-
   .detail-footer {
-    text-align: center;
     padding: 20px 0 0;
     margin-top: 20px;
+    text-align: center;
     border-top: 1px solid #ebeef5;
   }
-
   .upload-wrapper {
     :deep(.el-upload) {
       display: inline-block;
     }
-
     :deep(.el-upload-list--picture-card) {
       display: grid;
       grid-template-columns: repeat(3, 1fr);
       gap: 10px;
-      margin: 0;
       width: 100%;
+      margin: 0;
     }
-
     :deep(.el-upload--picture-card) {
+      display: none; // 禁用状态下隐藏上传按钮
       width: 100%;
       margin: 0;
-      display: none; // 禁用状态下隐藏上传按钮
     }
-
     :deep(.el-upload-list--picture-card .el-upload-list__item) {
       width: 100%;
-      margin: 0;
-      aspect-ratio: 1;
       height: auto;
+      aspect-ratio: 1;
+      margin: 0;
     }
 
     // 确保图片缩略图正确显示

+ 5 - 3
src/views/storeDecoration/facilitiesAndServices/components/FacilityManagement.vue

@@ -83,6 +83,8 @@
                 fit="cover"
                 class="facility-thumb"
                 :preview-src-list="[row.equipmentImage || row.imgUrl]"
+                preview-teleported
+                :z-index="5000"
               >
                 <template #error>
                   <div class="image-placeholder">
@@ -205,11 +207,10 @@
     </el-dialog>
 
     <!-- 图片预览 -->
-    <el-image-viewer
-      v-if="imageViewerVisible"
+    <PcImagePreviewViewer
+      v-model:visible="imageViewerVisible"
       :url-list="imageViewerUrlList"
       :initial-index="imageViewerInitialIndex"
-      @close="imageViewerVisible = false"
     />
   </div>
 </template>
@@ -221,6 +222,7 @@ import type { FormInstance, FormRules, UploadRequestOptions, UploadUserFile } fr
 import { Plus, Picture, Delete, ArrowDown } from "@element-plus/icons-vue";
 import UploadImg from "@/components/Upload/Img.vue";
 import { localGet } from "@/utils";
+import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
 import { uploadFileToOss, uploadFormDataToOss } from "@/api/upload.js";
 import {
   getSportsFacilityAreaList,

+ 2 - 0
src/views/storeDecoration/facilitiesAndServices/components/ServiceManagement.vue

@@ -23,6 +23,8 @@
               :src="row.imageUrl"
               :preview-src-list="[row.imageUrl]"
               fit="cover"
+              preview-teleported
+              :z-index="5000"
               style="width: 60px; height: 60px; border-radius: 4px"
             >
               <template #error>

+ 6 - 34
src/views/storeDecoration/officialPhotoAlbum/index.vue

@@ -95,12 +95,11 @@
       </template>
     </el-dialog>
 
-    <!-- 图片预览 -->
-    <el-dialog v-model="previewVisible" width="600px" align-center class="preview-dialog">
-      <div class="preview-content">
-        <img v-if="previewImageUrl" :src="previewImageUrl" alt="预览" class="preview-image" />
-      </div>
-    </el-dialog>
+    <PcImagePreviewViewer
+      v-model:visible="previewVisible"
+      :url-list="previewImageUrl ? [previewImageUrl] : []"
+      :initial-index="0"
+    />
   </div>
 </template>
 
@@ -121,6 +120,7 @@ import {
   deleteOfficialImg
 } from "@/api/modules/storeDecoration";
 import { uploadFileToOssWithDownloadMeta } from "@/api/upload.js";
+import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
 
 /** 普通官方相册图 */
 const OFFICIAL_IMG_TYPE_ALBUM = 2;
@@ -1035,32 +1035,4 @@ onMounted(async () => {
   display: flex;
   justify-content: flex-end;
 }
-
-// 预览弹窗样式
-:deep(.preview-dialog) {
-  .el-dialog {
-    max-width: 90vw;
-    margin: 5vh auto;
-  }
-  .el-dialog__body {
-    max-height: 80vh;
-    padding: 20px;
-    overflow: auto;
-  }
-  .preview-content {
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    max-width: 100%;
-    max-height: 70vh;
-    overflow: hidden;
-  }
-  .preview-image {
-    width: auto;
-    max-width: 100%;
-    height: auto;
-    max-height: 70vh;
-    object-fit: contain;
-  }
-}
 </style>

+ 207 - 95
src/views/storeDecoration/personnelConfig/index.vue

@@ -1,29 +1,9 @@
 <template>
   <div class="personnel-config-container">
-    <!-- 操作按钮区域 -->
-    <div class="action-section">
-      <el-button v-if="!hasTitle" type="primary" @click="handleCreateTitle"> 创建标题 </el-button>
-      <el-button type="primary" @click="openCreateDialog"> 创建人员 </el-button>
-    </div>
-
-    <!-- 表格区域 -->
+    <!-- 表格区域:工具栏在表格上方左侧 -->
     <div class="table-box button-table">
-      <div class="table-header-title">
-        <span class="table-title">演出人员</span>
-        <el-dropdown v-if="hasTitle" trigger="click" @command="handleTitleCommand">
-          <el-icon class="table-icon">
-            <ArrowDown />
-          </el-icon>
-          <template #dropdown>
-            <el-dropdown-menu>
-              <el-dropdown-item command="edit"> 编辑标题 </el-dropdown-item>
-              <el-dropdown-item command="delete"> 删除标题 </el-dropdown-item>
-            </el-dropdown-menu>
-          </template>
-        </el-dropdown>
-        <el-icon v-else class="table-icon">
-          <ArrowDown />
-        </el-icon>
+      <div class="personnel-table-toolbar">
+        <el-button type="primary"> 人员 </el-button>
       </div>
       <ProTable
         ref="proTable"
@@ -32,6 +12,11 @@
         :init-param="initParam"
         :data-callback="dataCallback"
       >
+        <template #tableHeader>
+          <div class="personnel-table-header-actions">
+            <el-button type="primary" @click="openCreateDialog"> 新增人员 </el-button>
+          </div>
+        </template>
         <template #empty>
           <div class="personnel-config-empty">暂无数据</div>
         </template>
@@ -44,7 +29,7 @@
           <!-- 已驳回(status === 2):显示编辑、删除、查看详情 -->
           <template v-else-if="scope.row.status === 2 || scope.row.status === '2'">
             <el-button type="primary" link @click="editPersonnel(scope.row, 0)"> 编辑 </el-button>
-            <el-button type="primary" link @click="deletePersonnelHandler(scope.row.id!, 0)"> 删除 </el-button>
+            <el-button type="danger" link @click="deletePersonnelHandler(scope.row.id!, 0)"> 删除 </el-button>
             <el-button type="primary" link @click="handleViewDetail(scope.row)"> 查看详情 </el-button>
           </template>
 
@@ -76,7 +61,7 @@
               取消置顶
             </el-button>
             <el-button v-else type="primary" link @click="handlePin(scope.row)"> 置顶 </el-button>
-            <el-button type="primary" link @click="deletePersonnelHandler(scope.row.id!, 0)"> 删除 </el-button>
+            <el-button type="danger" link @click="deletePersonnelHandler(scope.row.id!, 0)"> 删除 </el-button>
             <el-button type="primary" link @click="handleViewDetail(scope.row)"> 查看详情 </el-button>
           </template>
 
@@ -110,7 +95,13 @@
           <div class="detail-item">
             <div class="detail-label">头像:</div>
             <div class="detail-value">
-              <img v-if="personnelDetail.staffImage" :src="personnelDetail.staffImage" alt="头像" class="detail-avatar" />
+              <img
+                v-if="personnelDetail.staffImage"
+                :src="personnelDetail.staffImage"
+                alt="头像"
+                class="detail-avatar"
+                @click="previewBackgroundImage(personnelDetail.staffImage)"
+              />
               <span v-else>—</span>
             </div>
           </div>
@@ -174,18 +165,44 @@
           <div class="detail-item">
             <div class="detail-label">在线状态:</div>
             <div class="detail-value">
-              <span v-if="personnelDetail.onlineStatus === 0 || personnelDetail.onlineStatus === '0'">上线</span>
-              <span v-else-if="personnelDetail.onlineStatus === 1 || personnelDetail.onlineStatus === '1'">下线</span>
-              <span v-else>—</span>
+              <template
+                v-if="
+                  personnelDetail.status === 0 ||
+                  personnelDetail.status === '0' ||
+                  personnelDetail.status === 2 ||
+                  personnelDetail.status === '2'
+                "
+              >
+                <span class="personnel-cell-dash">—</span>
+              </template>
+              <template v-else>
+                <span
+                  v-if="personnelDetail.onlineStatus === 1 || personnelDetail.onlineStatus === '1'"
+                  class="personnel-cell-dash"
+                  >—</span
+                >
+                <span
+                  v-else-if="personnelDetail.onlineStatus === 0 || personnelDetail.onlineStatus === '0'"
+                  class="personnel-cell-online-up"
+                  >在线</span
+                >
+                <span v-else class="personnel-cell-dash">—</span>
+              </template>
             </div>
           </div>
           <div class="detail-item">
             <div class="detail-label">审核状态:</div>
             <div class="detail-value">
-              <el-tag v-if="personnelDetail.status === 1 || personnelDetail.status === '1'" type="success"> 已通过 </el-tag>
-              <el-tag v-else-if="personnelDetail.status === 2 || personnelDetail.status === '2'" type="danger"> 已驳回 </el-tag>
-              <el-tag v-else-if="personnelDetail.status === 0 || personnelDetail.status === '0'" type="info"> 待审核 </el-tag>
-              <span v-else>—</span>
+              <span v-if="personnelDetail.status === 1 || personnelDetail.status === '1'" class="personnel-cell-pass"
+                >已通过</span
+              >
+              <span v-else-if="personnelDetail.status === 2 || personnelDetail.status === '2'" class="personnel-cell-reject"
+                >已驳回</span
+              >
+              <span v-else-if="personnelDetail.status === 0 || personnelDetail.status === '0'" class="personnel-cell-pending"
+                >待审核</span
+              >
+              <span v-else class="personnel-cell-dash">—</span>
             </div>
           </div>
           <div v-if="personnelDetail.status === 2 || personnelDetail.status === '2'" class="detail-item">
@@ -217,22 +234,19 @@
     </el-dialog>
 
     <!-- 背景图片预览弹窗 -->
-    <el-image-viewer v-if="previewImageVisible" :url-list="[previewImageUrl]" @close="previewImageVisible = false" />
+    <PcImagePreviewViewer
+      v-model:visible="previewImageVisible"
+      :url-list="previewImageUrl ? [previewImageUrl] : []"
+      :initial-index="0"
+    />
 
-    <!-- 背景视频放大预览弹窗 -->
-    <el-dialog
+    <PcVideoPreviewDialog
       v-model="previewVideoVisible"
+      :src="previewVideoUrl"
       title="视频预览"
-      width="70%"
-      top="5vh"
-      destroy-on-close
-      align-center
-      @close="previewVideoUrl = ''"
-    >
-      <div class="video-preview-wrap">
-        <video v-if="previewVideoUrl" :src="previewVideoUrl" controls autoplay class="video-preview-player" />
-      </div>
-    </el-dialog>
+      :autoplay="true"
+      @closed="previewVideoUrl = ''"
+    />
 
     <!-- 新建/编辑人员弹窗 -->
     <el-dialog v-model="dialogVisible" :title="dialogTitle" width="720px" @close="resetForm">
@@ -252,7 +266,9 @@
           <el-input v-model="formData.name" placeholder="请输入" maxlength="20" show-word-limit clearable />
         </el-form-item>
         <el-form-item label="人员职位*" prop="position">
-          <el-input v-model="formData.position" placeholder="请输入" maxlength="50" clearable />
+          <el-select v-model="formData.position" placeholder="请选择" filterable clearable style="width: 100%">
+            <el-option v-for="opt in staffPositionOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
+          </el-select>
         </el-form-item>
         <el-form-item label="背景*" prop="backgroundImages">
           <div class="background-upload-three-col">
@@ -325,7 +341,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, computed, onMounted, watch, nextTick } from "vue";
+import { ref, reactive, computed, onMounted, watch, nextTick, h } from "vue";
 import { ElMessage, ElMessageBox } from "element-plus";
 import type { FormInstance, FormRules } from "element-plus";
 import { Picture, Plus, Delete, ArrowDown, VideoPlay } from "@element-plus/icons-vue";
@@ -336,6 +352,8 @@ import UploadImgs from "@/components/Upload/Imgs.vue";
 import { uploadFileToOss, uploadFilesToOss } from "@/api/upload.js";
 import type { UploadUserFile } from "element-plus";
 import { localGet } from "@/utils";
+import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
+import PcVideoPreviewDialog from "@/components/pcMediaPreview/PcVideoPreviewDialog.vue";
 import {
   getPersonnelList,
   getStaffConfigList,
@@ -348,7 +366,8 @@ import {
   getTitleDetail,
   deleteTitle,
   setTopStatus,
-  setOnlineStatus
+  setOnlineStatus,
+  queryStaffTitle
 } from "@/api/modules/storeDecoration";
 
 // 人员接口
@@ -403,10 +422,10 @@ const titleRules = reactive<FormRules>({
 // 标题弹窗标题
 const titleDialogTitle = computed(() => (titleEditId.value !== null ? "修改标题" : "创建标题"));
 
-// 在线状态枚举 - 0: 上线, 1: 下线
+// 在线状态枚举 - 0: 在线, 1: 离线(列表展示为「—」)
 const onlineStatusEnum = [
-  { label: "线", value: 0 },
-  { label: "线", value: 1 }
+  { label: "线", value: 0 },
+  { label: "线", value: 1 }
 ];
 
 // 审核状态枚举
@@ -433,6 +452,29 @@ const formatTime = (time: string | null | undefined) => {
   }
 };
 
+/** ProTable「职位」筛选项:GET alienStore/storeStaffTitle/query?storeId= */
+async function fetchPositionSearchEnum() {
+  const storeId = localGet("geeker-user")?.userInfo?.storeId || localGet("createdId");
+  if (!storeId) return { data: [] as { label: string; value: string }[] };
+  try {
+    const res: any = await queryStaffTitle({ storeId });
+    const inner = res?.data;
+    const raw = Array.isArray(inner) ? inner : (inner?.records ?? inner?.list ?? []);
+    const arr = Array.isArray(raw) ? raw : [];
+    const seen = new Set<string>();
+    const list: { label: string; value: string }[] = [];
+    for (const item of arr) {
+      const title = String(item.titleName ?? item.name ?? item.positionName ?? "").trim();
+      if (!title || seen.has(title)) continue;
+      seen.add(title);
+      list.push({ label: title, value: title });
+    }
+    return { data: list };
+  } catch {
+    return { data: [] };
+  }
+}
+
 // 表格配置项
 const columns = reactive<ColumnProps<any>[]>([
   {
@@ -457,9 +499,11 @@ const columns = reactive<ColumnProps<any>[]>([
     prop: "position",
     label: "职位",
     minWidth: 100,
+    fieldNames: { label: "label", value: "value" },
+    enum: fetchPositionSearchEnum,
     search: {
-      el: "input",
-      props: { placeholder: "请输入" },
+      el: "select",
+      props: { placeholder: "请选择", filterable: true, clearable: true },
       order: 2
     }
   },
@@ -486,9 +530,17 @@ const columns = reactive<ColumnProps<any>[]>([
       order: 3
     },
     render: (scope: any) => {
-      if (scope.row.onlineStatus === 0 || scope.row.onlineStatus === "0") return "上线";
-      if (scope.row.onlineStatus === 1 || scope.row.onlineStatus === "1") return "下线";
-      return "—";
+      const audit = scope.row.status ?? scope.row.approvalStatus;
+      if (audit === 0 || audit === "0" || audit === 2 || audit === "2") {
+        return h("span", { class: "personnel-cell-dash" }, "—");
+      }
+      if (scope.row.onlineStatus === 1 || scope.row.onlineStatus === "1") {
+        return h("span", { class: "personnel-cell-dash" }, "—");
+      }
+      if (scope.row.onlineStatus === 0 || scope.row.onlineStatus === "0") {
+        return h("span", { class: "personnel-cell-online-up" }, "在线");
+      }
+      return h("span", { class: "personnel-cell-dash" }, "—");
     }
   },
   {
@@ -497,17 +549,22 @@ const columns = reactive<ColumnProps<any>[]>([
     width: 120,
     enum: approvalStatusEnum,
     fieldNames: { label: "label", value: "value" },
-    tag: true,
     search: {
       el: "select",
       props: { placeholder: "请选择" },
       order: 4
     },
     render: (scope: any) => {
-      if (scope.row.approvalStatus === 1 || scope.row.approvalStatus === "1") return "已通过";
-      if (scope.row.approvalStatus === 2 || scope.row.approvalStatus === "2") return "已驳回";
-      if (scope.row.approvalStatus === 0 || scope.row.approvalStatus === "0") return "待审核";
-      return "—";
+      if (scope.row.approvalStatus === 1 || scope.row.approvalStatus === "1") {
+        return h("span", { class: "personnel-cell-pass" }, "已通过");
+      }
+      if (scope.row.approvalStatus === 2 || scope.row.approvalStatus === "2") {
+        return h("span", { class: "personnel-cell-reject" }, "已驳回");
+      }
+      if (scope.row.approvalStatus === 0 || scope.row.approvalStatus === "0") {
+        return h("span", { class: "personnel-cell-pending" }, "待审核");
+      }
+      return h("span", { class: "personnel-cell-dash" }, "—");
     }
   },
   {
@@ -720,6 +777,47 @@ const formData = reactive({
   description: ""
 });
 
+/** 人员职位下拉:来自 storeStaffTitle/query */
+const staffPositionOptions = ref<{ label: string; value: string }[]>([]);
+
+const getPersonnelStoreId = (): string | number | undefined => {
+  return localGet("geeker-user")?.userInfo?.storeId || localGet("createdId");
+};
+
+async function loadStaffPositionOptions() {
+  const storeId = getPersonnelStoreId();
+  if (!storeId) {
+    staffPositionOptions.value = [];
+    return;
+  }
+  try {
+    const res: any = await queryStaffTitle({ storeId });
+    const inner = res?.data;
+    const raw = Array.isArray(inner) ? inner : (inner?.records ?? inner?.list ?? []);
+    const arr = Array.isArray(raw) ? raw : [];
+    const seen = new Set<string>();
+    const list: { label: string; value: string }[] = [];
+    for (const item of arr) {
+      const title = String(item.titleName ?? item.name ?? item.positionName ?? "").trim();
+      if (!title || seen.has(title)) continue;
+      seen.add(title);
+      list.push({ label: title, value: title });
+    }
+    staffPositionOptions.value = list;
+  } catch {
+    staffPositionOptions.value = [];
+  }
+}
+
+/** 编辑回显:接口返回的职位若不在当前标题列表中,仍补一条可选 */
+function mergeLegacyPositionOption(position: string) {
+  const v = (position || "").trim();
+  if (!v) return;
+  if (!staffPositionOptions.value.some(o => o.value === v)) {
+    staffPositionOptions.value = [{ label: v, value: v }, ...staffPositionOptions.value];
+  }
+}
+
 // 同步组件内部文件列表到 formData(避免上传中因过滤 blob 导致列表被清空或重复)
 const syncFileListFromComponent = async () => {
   if (!backgroundImagesUploadRef.value || !dialogVisible.value) return;
@@ -923,7 +1021,7 @@ watch(
 // 表单校验规则
 const rules = reactive<FormRules>({
   name: [{ required: true, message: "请输入姓名/昵称", trigger: "blur" }],
-  position: [{ required: true, message: "请输入人员职位", trigger: "blur" }],
+  position: [{ required: true, message: "请选择人员职位", trigger: "change" }],
   avatar: [{ required: true, message: "请上传头像", trigger: "change" }],
   backgroundImages: [
     {
@@ -1177,8 +1275,13 @@ const handleTitleSubmit = async () => {
       params.id = titleEditId.value;
       res = await updateTitle(params);
     } else {
-      // 创建标题
-      res = await createTitle(params);
+      try {
+        res = await createTitle(params, { hideBusinessErrorMessage: true });
+      } catch {
+        ElMessage.error("此名称已存在");
+        titleSubmitLoading.value = false;
+        return;
+      }
     }
 
     if (res && (res.code === 200 || res.code === "200")) {
@@ -1351,6 +1454,7 @@ const openCreateDialog = async () => {
     editId.value = null;
     resetForm();
     uploadComponentKey.value += 1;
+    await loadStaffPositionOptions();
     dialogVisible.value = true;
   } catch (error: any) {
     // 如果检查标题时出错,记录错误但允许继续创建人员(避免阻塞用户)
@@ -1369,6 +1473,7 @@ const openCreateDialog = async () => {
     editId.value = null;
     resetForm();
     uploadComponentKey.value += 1;
+    await loadStaffPositionOptions();
     dialogVisible.value = true;
   }
 };
@@ -1433,6 +1538,8 @@ const editPersonnel = async (person: Personnel, index: number) => {
 
       // 强制背景上传组件重新挂载,避免沿用上次的 _fileList 导致数量显示错误
       uploadComponentKey.value += 1;
+      await loadStaffPositionOptions();
+      mergeLegacyPositionOption(formData.position);
       // 打开对话框
       dialogVisible.value = true;
     } else {
@@ -2247,31 +2354,40 @@ onMounted(async () => {
   min-height: 100%;
   padding: 20px;
   background-color: white;
-  .action-section {
+  .personnel-table-toolbar {
     display: flex;
+    flex-wrap: wrap;
     gap: 12px;
+    align-items: center;
     margin-bottom: 16px;
+    .title-dropdown-trigger {
+      display: inline-flex;
+      align-items: center;
+    }
+  }
+
+  /* 列表:在线状态 / 审核状态 文字颜色 */
+  :deep(.personnel-cell-online-up) {
+    color: var(--el-color-success);
+  }
+  :deep(.personnel-cell-dash) {
+    color: var(--el-text-color-secondary);
+  }
+  :deep(.personnel-cell-pass) {
+    color: var(--el-color-success);
+  }
+  :deep(.personnel-cell-reject) {
+    color: var(--el-color-danger);
+  }
+  :deep(.personnel-cell-pending) {
+    color: var(--el-color-warning);
   }
   .table-box {
-    .table-header-title {
+    :deep(.personnel-table-header-actions) {
       display: flex;
       align-items: center;
-      margin-bottom: 16px;
-      font-size: 16px;
-      font-weight: 500;
-      color: var(--el-text-color-primary);
-      .table-title {
-        margin-right: 8px;
-      }
-      .table-icon {
-        font-size: 16px;
-        color: var(--el-text-color-regular);
-        cursor: pointer;
-        transition: transform 0.3s;
-        &:hover {
-          color: var(--el-color-primary);
-        }
-      }
+      width: 100%;
+      padding: 12px 0;
     }
 
     /* 列表为空时去掉空状态残留图形/背景,仅显示「暂无数据」文字 */
@@ -2346,8 +2462,16 @@ onMounted(async () => {
         .detail-avatar {
           width: 100px;
           height: 100px;
+          cursor: pointer;
           object-fit: cover;
           border-radius: 8px;
+          transition:
+            transform 0.2s,
+            box-shadow 0.2s;
+          &:hover {
+            box-shadow: 0 2px 8px rgb(0 0 0 / 15%);
+            transform: scale(1.05);
+          }
         }
         .background-images {
           display: grid;
@@ -2419,16 +2543,4 @@ onMounted(async () => {
     display: contents;
   }
 }
-.video-preview-wrap {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  min-height: 200px;
-  background: #000000;
-  .video-preview-player {
-    width: 100%;
-    max-width: 100%;
-    max-height: 75vh;
-  }
-}
 </style>

+ 269 - 0
src/views/storeDecoration/positionManagement/index.vue

@@ -0,0 +1,269 @@
+<template>
+  <div class="table-box position-management">
+    <div class="filter-bar">
+      <div class="filter-row">
+        <span class="filter-label">职位</span>
+        <el-input
+          v-model="filterPositionName"
+          clearable
+          placeholder="请输入"
+          style="width: 220px"
+          maxlength="50"
+          @keyup.enter="handleSearch"
+        />
+        <el-button type="primary" @click="handleSearch"> 搜索 </el-button>
+        <el-button @click="handleReset"> 重置 </el-button>
+      </div>
+    </div>
+
+    <ProTable
+      ref="proTable"
+      :columns="columns"
+      :request-api="getTableList"
+      :init-param="initParam"
+      :data-callback="dataCallback"
+      :tool-button="false"
+    >
+      <template #tableHeader>
+        <div class="table-header">
+          <el-button type="primary" @click="openCreate"> 新增职位 </el-button>
+        </div>
+      </template>
+      <template #operation="scope">
+        <el-button link type="primary" @click="openEdit(scope.row)"> 编辑 </el-button>
+        <el-button link type="danger" @click="onDeleteClick(scope.row)"> 删除 </el-button>
+      </template>
+    </ProTable>
+
+    <p v-if="deleteHint" class="delete-hint">
+      {{ deleteHint }}
+    </p>
+
+    <el-dialog
+      v-model="dialogVisible"
+      :title="editId == null ? '新增职位' : '编辑职位'"
+      width="420px"
+      append-to-body
+      destroy-on-close
+      @closed="onDialogClosed"
+    >
+      <el-form ref="formRef" :model="form" :rules="formRules" label-width="72px" @submit.prevent>
+        <el-form-item label="职位" prop="positionName">
+          <el-input v-model="form.positionName" placeholder="请输入" maxlength="50" clearable show-word-limit />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false"> 取消 </el-button>
+        <el-button type="primary" :loading="submitLoading" @click="submitForm"> 确定 </el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts" name="positionManagement">
+import { ref, reactive } from "vue";
+import { ElMessage, ElMessageBox } from "element-plus";
+import type { FormInstance, FormRules } from "element-plus";
+import ProTable from "@/components/ProTable/index.vue";
+import type { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
+import { localGet } from "@/utils";
+import { queryStaffTitle, createTitle, updateTitle, deleteTitle } from "@/api/modules/storeDecoration";
+
+const proTable = ref<ProTableInstance>();
+const formRef = ref<FormInstance>();
+const filterPositionName = ref("");
+const listPositionName = ref("");
+const deleteHint = ref("");
+const dialogVisible = ref(false);
+const submitLoading = ref(false);
+const editId = ref<string | number | null>(null);
+
+const initParam = reactive({
+  storeId: localGet("geeker-user")?.userInfo?.storeId || localGet("createdId")
+});
+
+const form = reactive({
+  positionName: ""
+});
+
+const formRules: FormRules = {
+  positionName: [{ required: true, message: "请输入职位名称", trigger: "blur" }]
+};
+
+const columns = reactive<ColumnProps[]>([
+  { type: "index", label: "序号", width: 80 },
+  { prop: "positionName", label: "职位", minWidth: 200 },
+  { prop: "operation", label: "操作", fixed: "right", width: 160 }
+]);
+
+const dataCallback = (data: any) => {
+  const raw = data?.records ?? data?.list ?? (Array.isArray(data) ? data : []);
+  const list = (Array.isArray(raw) ? raw : []).map((item: any) => ({
+    id: item.id ?? item.titleId,
+    positionName: item.titleName ?? item.positionName ?? item.name ?? item.position ?? "",
+    staffCount: Number(item.staffCount ?? item.bindStaffCount ?? item.personCount ?? 0)
+  }));
+  const total = data?.total ?? data?.totalCount ?? list.length;
+  return { list, total: Number(total) || 0 };
+};
+
+const getTableList = async (params: any) => {
+  const storeId = params.storeId ?? initParam.storeId;
+  if (!storeId) {
+    return Promise.resolve({ data: { list: [], total: 0 } });
+  }
+  const res: any = await queryStaffTitle({ storeId });
+  const inner = res?.data;
+  const raw = Array.isArray(inner) ? inner : (inner?.records ?? inner?.list ?? []);
+  const mapped = (Array.isArray(raw) ? raw : []).map((item: any) => ({
+    id: item.id ?? item.titleId,
+    positionName: item.titleName ?? item.positionName ?? item.name ?? item.position ?? "",
+    staffCount: Number(item.staffCount ?? item.bindStaffCount ?? item.personCount ?? 0)
+  }));
+  const kw = listPositionName.value?.trim().toLowerCase();
+  const filtered = kw
+    ? mapped.filter((row: { positionName: string }) => (row.positionName || "").toLowerCase().includes(kw))
+    : mapped;
+  const pageNum = params.pageNum ?? 1;
+  const pageSize = params.pageSize ?? 10;
+  const total = filtered.length;
+  const start = (pageNum - 1) * pageSize;
+  const list = filtered.slice(start, start + pageSize);
+  return { data: { list, total } };
+};
+
+const handleSearch = () => {
+  listPositionName.value = filterPositionName.value?.trim() ?? "";
+  proTable.value?.getTableList();
+};
+
+const handleReset = () => {
+  filterPositionName.value = "";
+  listPositionName.value = "";
+  proTable.value?.getTableList();
+};
+
+const clearDeleteHint = () => {
+  deleteHint.value = "";
+};
+
+const onDeleteBlocked = () => {
+  deleteHint.value = "此职位下有所属人员,不可删除";
+};
+
+const onDeleteClick = (row: { id: string | number; positionName: string; staffCount?: number }) => {
+  if (Number(row.staffCount) > 0) onDeleteBlocked();
+  else handleDelete(row);
+};
+
+const openCreate = () => {
+  clearDeleteHint();
+  editId.value = null;
+  form.positionName = "";
+  dialogVisible.value = true;
+};
+
+const openEdit = (row: { id: string | number; positionName: string }) => {
+  clearDeleteHint();
+  editId.value = row.id;
+  form.positionName = row.positionName || "";
+  dialogVisible.value = true;
+};
+
+const onDialogClosed = () => {
+  editId.value = null;
+  form.positionName = "";
+  formRef.value?.resetFields();
+};
+
+const handleDelete = (row: { id: string | number; positionName: string }) => {
+  clearDeleteHint();
+  const storeId = initParam.storeId;
+  if (!storeId) {
+    ElMessage.warning("缺少门店信息");
+    return;
+  }
+  ElMessageBox.confirm(`确定删除职位「${row.positionName || ""}」吗?`, "提示", {
+    confirmButtonText: "确定",
+    cancelButtonText: "取消",
+    type: "warning"
+  })
+    .then(async () => {
+      try {
+        await deleteTitle({ id: row.id });
+        ElMessage.success("删除成功");
+        proTable.value?.getTableList();
+      } catch {
+        /* 错误提示由请求拦截器统一处理 */
+      }
+    })
+    .catch(() => {});
+};
+
+const submitForm = async () => {
+  if (!formRef.value) return;
+  try {
+    await formRef.value.validate();
+  } catch {
+    return;
+  }
+  const storeId = initParam.storeId;
+  if (!storeId) {
+    ElMessage.warning("缺少门店信息");
+    return;
+  }
+  submitLoading.value = true;
+  try {
+    const titleName = form.positionName.trim();
+    if (editId.value != null) {
+      await updateTitle({ storeId, id: editId.value, titleName });
+    } else {
+      try {
+        await createTitle({ storeId, titleName }, { hideBusinessErrorMessage: true });
+      } catch {
+        ElMessage.error("此名称已存在");
+        return;
+      }
+    }
+    ElMessage.success(editId.value == null ? "新增成功" : "保存成功");
+    dialogVisible.value = false;
+    proTable.value?.getTableList();
+  } catch {
+    /* 错误提示由请求拦截器统一处理 */
+  } finally {
+    submitLoading.value = false;
+  }
+};
+</script>
+
+<style scoped lang="scss">
+.position-management {
+  padding: 0;
+}
+.filter-bar {
+  padding: 0 4px;
+  margin-bottom: 12px;
+}
+.filter-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+  align-items: center;
+}
+.filter-label {
+  min-width: 36px;
+  font-size: 14px;
+  color: #606266;
+}
+.table-header {
+  display: flex;
+  align-items: center;
+  width: 100%;
+  padding: 12px 0;
+}
+.delete-hint {
+  margin: 8px 4px 0;
+  font-size: 13px;
+  color: var(--el-color-danger);
+}
+</style>

+ 10 - 78
src/views/storeDecoration/storeCoverMap/index.vue

@@ -112,45 +112,31 @@
       </div>
     </div>
 
-    <!-- 图片预览:与 storeHeadMap 一致(根级挂载,同级于内容区) -->
-    <el-image-viewer
-      v-if="coverImagePreviewVisible"
+    <PcImagePreviewViewer
+      v-model:visible="coverImagePreviewVisible"
       :url-list="coverImagePreviewUrls"
       :initial-index="coverImagePreviewInitialIndex"
-      @close="coverImagePreviewVisible = false"
     />
 
-    <!-- 视频无法用 image-viewer,保留弹窗 -->
-    <el-dialog
+    <PcVideoPreviewDialog
       v-model="coverVideoPreviewVisible"
-      title="查看"
-      width="min(92vw, 720px)"
-      class="cover-preview-dialog"
-      destroy-on-close
-      append-to-body
+      :src="previewTarget?.url || ''"
       @closed="onCoverVideoPreviewClosed"
-    >
-      <video
-        v-if="previewTarget?.url"
-        :src="previewTarget.url"
-        class="cover-preview-dialog__media cover-preview-dialog__video"
-        controls
-        playsinline
-      />
-    </el-dialog>
+    />
   </div>
 </template>
 
 <script setup lang="ts">
 import { ref, computed, onMounted } from "vue";
 import { ArrowRight, Plus, InfoFilled, View, Delete } from "@element-plus/icons-vue";
-import { ElLoading, ElMessage } from "element-plus";
+import { ElMessage } from "element-plus";
 import { getStoreHeadImg, saveStoreHeadImg } from "@/api/modules/storeDecoration";
 import { getDetail } from "@/api/modules/homeEntry";
 import { localGet } from "@/utils";
 import { uploadFilesToOss, isUploadUserCancelledError, isUploadApiErrorAlreadyMessaged } from "@/api/upload.js";
-import { getCoverAuditData } from "@/api/modules/coverAudit";
 import previewDemoImg from "@/assets/images/uDianShiImg4.svg";
+import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
+import PcVideoPreviewDialog from "@/components/pcMediaPreview/PcVideoPreviewDialog.vue";
 
 /** 与商户端封面 imgType 一致 */
 const IMG_TYPE_COVER = 38;
@@ -333,37 +319,6 @@ function onCoverVideoPreviewClosed() {
   previewTarget.value = null;
 }
 
-/**
- * 与 App `checkImageCompliance` 中封面分支一致:`getCoverAuditData({ url })`,
- * code 成功且非 `overall_match === false` 视为通过。仅在页面层弹一次 toast,此处不调用 ElMessage。
- */
-async function runCoverComplianceAudit(url: string): Promise<{ ok: true } | { ok: false; message: string }> {
-  try {
-    const res = (await getCoverAuditData({ url })) as {
-      code?: number | string;
-      msg?: string;
-      message?: string;
-      data?: { overall_match?: boolean; match_reason?: string };
-    };
-    const c = res.code;
-    const okCode = c === 200 || c === 0 || c === "200" || c === "0";
-    if (!okCode) {
-      return { ok: false, message: String(res.msg || res.message || "素材合规性检查失败") };
-    }
-    const d = res.data;
-    if (d && d.overall_match === false) {
-      return { ok: false, message: String(d.match_reason || "素材不符合规范,请重新上传") };
-    }
-    return { ok: true };
-  } catch (e: unknown) {
-    const msg =
-      typeof e === "object" && e && "message" in e && typeof (e as { message?: unknown }).message === "string"
-        ? String((e as Error).message)
-        : "素材合规性检查失败,请重试";
-    return { ok: false, message: msg };
-  }
-}
-
 async function ingestPickedFile(file: File): Promise<boolean> {
   const name = String(file.name || "").toLowerCase();
   const type = String(file.type || "");
@@ -385,27 +340,14 @@ async function ingestPickedFile(file: File): Promise<boolean> {
   }
 
   uploading.value = true;
-  let auditLoading;
   try {
     const urls = await uploadFilesToOss([file], isVideo ? "video" : "image", {
-      /** 保留全局 PopupLoading 进度;上传完成不 toast,审核通过后再提示 */
+      /** 保留全局 PopupLoading 进度;上传完成不 toast,由本页统一提示(上传服务已含合规校验) */
       uploadSuccessMessage: null
     });
     const url = urls[0];
     if (!url) throw new Error("上传成功但未返回地址");
 
-    auditLoading = ElLoading.service({
-      lock: true,
-      text: "素材合规校验中…",
-      background: "rgba(0, 0, 0, 0.35)"
-    });
-
-    const audit = await runCoverComplianceAudit(url);
-    if (!audit.ok) {
-      ElMessage.error(audit.message);
-      return false;
-    }
-
     let posterUrl = "";
     if (isVideo) {
       posterUrl = tryOssVideoSnapshotCoverUrl(url);
@@ -417,7 +359,7 @@ async function ingestPickedFile(file: File): Promise<boolean> {
       isVideo,
       ...(posterUrl ? { posterUrl } : {})
     };
-    ElMessage.success("已上传并通过合规校验,请点击保存写入门店");
+    ElMessage.success("上传成功");
     return true;
   } catch (e: unknown) {
     if (isUploadUserCancelledError(e)) {
@@ -433,7 +375,6 @@ async function ingestPickedFile(file: File): Promise<boolean> {
     ElMessage.error(msg);
     return false;
   } finally {
-    auditLoading?.close();
     uploading.value = false;
   }
 }
@@ -811,15 +752,6 @@ onMounted(async () => {
   cursor: not-allowed !important;
   opacity: 1 !important;
 }
-.cover-preview-dialog__media {
-  display: block;
-  width: 100%;
-  max-height: 70vh;
-  object-fit: contain;
-}
-.cover-preview-dialog__video {
-  background: #000000;
-}
 
 @media screen and (width <= 640px) {
   .upload-layout {

+ 2 - 6
src/views/storeDecoration/storeHeadMap/index.vue

@@ -47,12 +47,7 @@
       </div>
     </div>
 
-    <el-image-viewer
-      v-if="headPreviewVisible"
-      :url-list="imageUrls"
-      :initial-index="headPreviewInitialIndex"
-      @close="headPreviewVisible = false"
-    />
+    <PcImagePreviewViewer v-model:visible="headPreviewVisible" :url-list="imageUrls" :initial-index="headPreviewInitialIndex" />
   </div>
 </template>
 
@@ -63,6 +58,7 @@ import { getStoreHeadImg } from "@/api/modules/storeDecoration";
 import { getDetail } from "@/api/modules/homeEntry";
 import { localGet } from "@/utils";
 import previewDemoImg from "@/assets/images/uDianShiImg4.svg";
+import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
 
 /** 与商户端头图 imgType 一致 */
 const IMG_TYPE_HEAD = 22;