Переглянути джерело

Merge branch 'development' into uat

LuTong 1 місяць тому
батько
коміт
8a102e0d14

+ 184 - 0
src/api/modules/scheduledService.ts

@@ -0,0 +1,184 @@
+import httpApi from "@/api/indexApi";
+
+const BASE_BOOKING = "/alienStore/store/booking/category";
+
+/** 分类列表(预约/桌位分类,支持分页) */
+export const scheduleList = (params: { storeId: number; pageNum?: number; pageSize?: number; [key: string]: any }) => {
+  return httpApi.get(`${BASE_BOOKING}/list`, params);
+};
+
+/** 新增分类 */
+export const scheduleAddSave = (data: {
+  storeId?: number | string;
+  categoryName?: string;
+  isDisplay?: number;
+  maxBookingTime?: number;
+  [key: string]: any;
+}) => {
+  return httpApi.post(`${BASE_BOOKING}/add`, data);
+};
+
+/** 删除分类(application/x-www-form-urlencoded) */
+export const scheduleDel = (params: { id: number | string }) => {
+  const formData = Object.keys(params)
+    .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(String(params[key as keyof typeof params]))}`)
+    .join("&");
+  return httpApi.post(`${BASE_BOOKING}/delete`, formData, {
+    headers: { "Content-Type": "application/x-www-form-urlencoded" }
+  });
+};
+
+/** 分类排序 */
+export const scheduleSort = (data: { categoryIds: (number | string)[]; storeId: number | string }) => {
+  return httpApi.post(`${BASE_BOOKING}/sort`, data);
+};
+
+/** 新增/编辑分类(保存或更新) */
+export const scheduleAdd = (data: any) => {
+  return httpApi.post(`${BASE_BOOKING}/add`, data);
+};
+
+/** 新增/编辑分类(保存或更新) */
+export const scheduleEdit = (data: any) => {
+  return httpApi.post(`${BASE_BOOKING}/update`, data);
+};
+//编辑详情
+export const scheduleDetail = (params: { id: number | string }) => {
+  return httpApi.get(`${BASE_BOOKING}/detail`, params);
+};
+
+// ==================== 桌位管理 ====================
+const BASE_TABLE = "/alienStore/store/booking/table";
+
+/** 桌位列表(可按位置/分类筛选,支持分页) */
+export const tableList = (params: {
+  storeId: number;
+  categoryId?: number | string;
+  pageNum?: number;
+  pageSize?: number;
+  [key: string]: any;
+}) => {
+  return httpApi.get(`${BASE_TABLE}/list`, params);
+};
+
+/** 桌位详情(编辑回显) */
+export const tableDetail = (params: { id: number | string }) => {
+  return httpApi.get(`${BASE_TABLE}/detail`, params);
+};
+
+/** 新增桌位(与 merchant addTableNumber 一致:storeId + categoryId + tables[]) */
+export const tableAdd = (data: {
+  storeId?: number | string;
+  categoryId?: number | string;
+  tables?: { tableNumber: string; seatingCapacity: number }[];
+  [key: string]: any;
+}) => {
+  return httpApi.post(`${BASE_TABLE}/add`, data);
+};
+
+/** 编辑桌位(与 merchant 一致:id + categoryId + storeId + tableNumber + seatingCapacity) */
+export const tableEdit = (data: {
+  id: number | string;
+  storeId?: number | string;
+  categoryId?: number | string;
+  tableNumber?: string;
+  seatingCapacity?: number;
+  [key: string]: any;
+}) => {
+  return httpApi.post(`${BASE_TABLE}/update`, data);
+};
+
+/** 删除桌位 */
+export const tableDel = (params: { id: number | string }) => {
+  const formData = Object.keys(params)
+    .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(String(params[key as keyof typeof params]))}`)
+    .join("&");
+  return httpApi.post(`${BASE_TABLE}/delete`, formData, {
+    headers: { "Content-Type": "application/x-www-form-urlencoded" }
+  });
+};
+
+// ==================== 预约信息 ====================
+const BASE_RESERVATION = "/alienStore/store/reservation";
+
+/** 预约列表(分页,支持姓名、状态、预订日期筛选) */
+export const reservationList = (params: {
+  storeId?: number | string;
+  name?: string;
+  status?: number | string;
+  startDate?: string;
+  endDate?: string;
+  pageNum?: number;
+  pageSize?: number;
+  [key: string]: any;
+}) => {
+  return httpApi.get(`${BASE_RESERVATION}/list`, params);
+};
+
+/** 加时 */
+export const reservationAddTime = (params: { id: number | string; [key: string]: any }) => {
+  const formData = Object.keys(params)
+    .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`)
+    .join("&");
+  return httpApi.post(`${BASE_RESERVATION}/addTime`, formData, {
+    headers: { "Content-Type": "application/x-www-form-urlencoded" }
+  });
+};
+
+/** 取消预约 */
+export const reservationCancel = (params: { id: number | string; [key: string]: any }) => {
+  const formData = Object.keys(params)
+    .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`)
+    .join("&");
+  return httpApi.post(`${BASE_RESERVATION}/cancel`, formData, {
+    headers: { "Content-Type": "application/x-www-form-urlencoded" }
+  });
+};
+
+/** 删除预约 */
+export const reservationDelete = (params: { id: number | string }) => {
+  const formData = Object.keys(params)
+    .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(String(params[key as keyof typeof params]))}`)
+    .join("&");
+  return httpApi.post(`${BASE_RESERVATION}/delete`, formData, {
+    headers: { "Content-Type": "application/x-www-form-urlencoded" }
+  });
+};
+
+/** 退款 */
+export const reservationRefund = (params: { id: number | string; [key: string]: any }) => {
+  const formData = Object.keys(params)
+    .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`)
+    .join("&");
+  return httpApi.post("/alienStore/user/reservation/payment/refund", formData, {
+    headers: { "Content-Type": "application/x-www-form-urlencoded" }
+  });
+};
+
+// ==================== 预约信息设置 ====================
+const BASE_SETTINGS = "/alienStore/store/booking/settings";
+
+/** 信息设置详情(回显) */
+export const bookingSettingsDetail = (params: { storeId: number | string }) => {
+  return httpApi.get(`${BASE_SETTINGS}/detail`, params);
+};
+
+/** 信息设置保存 */
+export const bookingSettingsSave = (data: Record<string, any>) => {
+  return httpApi.post(`${BASE_SETTINGS}/save`, data);
+};
+
+/** 预约营业时间(正常/特殊,用于回显预订时间) */
+export const getBookingBusinessHours = (params: { settingsId?: number | string }) => {
+  return httpApi.get("/alienStore/store/reservation/getBookingBusinessHours", params);
+};
+
+/** 分类下桌号是否有预定(true 则不可编辑/删除分类) */
+export const getHasReservation = (params: { id: number | string }) => {
+  return httpApi.get(`/store/booking/category/hasReservation`, params);
+};
+
+/** 桌位是否有预定(true 则不可编辑/删除该桌) */
+export const getTableHasReservation = (params: { id: number | string }) => {
+  return httpApi.get(`/store/booking/table/hasReservation`, params);
+};

+ 39 - 1
src/api/modules/storeDecoration.ts

@@ -255,7 +255,45 @@ export const importServiceExcel = (formData: FormData, storeId: string | number)
     data: formData
   });
 };
-// 设施管理相关接口
+// ==================== 设施区域管理(与商家端 facilityOrService 一致,alienStore 前缀) ====================
+// 获取设施区域列表(标签) /alienStore/sports/facility/area/list
+export const getSportsFacilityAreaList = (params: { storeId: number }) => {
+  return httpApi.get(`/alienStore/sports/facility/area/list`, params, { loading: false });
+};
+// 新建设施区域
+export const createSportsFacilityArea = (params: { storeId: number; facilityCategoryName: string }) => {
+  return httpApi.post(`/alienStore/sports/facility/area/create`, params);
+};
+// 编辑设施区域
+export const updateSportsFacilityArea = (params: { areaId: number; areaName: string }) => {
+  return httpApi.post(`/alienStore/sports/facility/area/update`, params);
+};
+// 更新设施区域实景图片
+export const updateSportsFacilityAreaHeadUrl = (params: { areaId: number; areaHeadUrl: string }) => {
+  return httpApi.post(`/alienStore/sports/facility/area/updateHeadUrl`, params);
+};
+// 批量删除设施区域
+export const batchDeleteSportsFacilityArea = (params: { storeId: number; areaIds: (string | number)[] }) => {
+  return httpApi.post(`/alienStore/sports/facility/area/batchDelete`, params);
+};
+// 根据区域ID获取设施列表 /alienStore/sports/equipment/facility/listByArea
+export const getSportsEquipmentFacilityListByArea = (params: { storeId: number; areaId: number }) => {
+  return httpApi.get(`/alienStore/sports/equipment/facility/listByArea`, params);
+};
+// 新增运动设施
+export const saveSportsEquipmentFacility = (params: any) => {
+  return httpApi.post(`/alienStore/sports/equipment/facility/save`, params);
+};
+// 修改运动设施
+export const updateSportsEquipmentFacility = (params: any) => {
+  return httpApi.post(`/alienStore/sports/equipment/facility/update`, params);
+};
+// 删除运动设施
+export const deleteSportsEquipmentFacility = (params: { equipmentId: number }) => {
+  return httpApi.post(`/alienStore/sports/equipment/facility/delete`, params);
+};
+
+// 设施管理相关接口(兼容旧接口,使用 facilityCategory)
 //获取健身设施列表
 export const getFacilityList = (params: any) => {
   return httpApi.get(`/alienStorePlatform/sportsEquipmentFacility/getListByStoreIdAndCategory`, params);

+ 189 - 0
src/api/upload.js

@@ -0,0 +1,189 @@
+import { useUserStore } from "@/stores/modules/user";
+import { ElMessage } from "element-plus";
+
+/**
+ * 门店/文件相关接口基址(与 src/api/modules/upload.ts 中 httpStoreAlienStore 一致)
+ * 若签名接口部署在其它域名,可改为 VITE_API_URL_PLATFORM 等
+ */
+function getStoreApiBase() {
+  return String(import.meta.env.VITE_API_URL_STORE || "").replace(/\/$/, "");
+}
+
+/** 从路径或 fileType 解析文件后缀(如 jpg、mp4),用于生成带格式的文件名 */
+export function getFileExtension(filePath, fileType) {
+  const pathPart = (filePath || "").split("?")[0];
+  const lastSegment = pathPart.split("/").pop() || "";
+  const dot = lastSegment.lastIndexOf(".");
+  if (dot > 0) {
+    const ext = lastSegment.slice(dot + 1).toLowerCase();
+    if (/^[a-z0-9]+$/i.test(ext) && ext.length <= 5) return ext;
+  }
+  if (fileType === "video") return "mp4";
+  if (fileType === "image") return "jpg";
+  return "jpg";
+}
+
+/**
+ * GET 获取 OSS 直传签名(与 uni 版 1.js 一致)
+ * @returns {Promise<Record<string, any>>}
+ */
+async function fetchOssSignature() {
+  const base = getStoreApiBase();
+  if (!base) {
+    throw new Error("未配置 VITE_API_URL_STORE");
+  }
+  const userStore = useUserStore();
+  const res = await fetch(`${base}/oss/direct/new/signature`, {
+    method: "GET",
+    headers: {
+      Authorization: userStore.token || ""
+    },
+    credentials: "include"
+  });
+  if (!res.ok) {
+    throw new Error("获取签名失败");
+  }
+  const body = await res.json();
+  const data = body.data ?? body;
+  if (!data || !data.host) {
+    throw new Error(body.msg || "获取签名失败");
+  }
+  return data;
+}
+
+/**
+ * 将入参统一为 File[]
+ * @param {File | File[] | FileList} input
+ * @returns {File[]}
+ */
+function normalizeFiles(input) {
+  if (input == null) return [];
+  if (input instanceof File) return [input];
+  if (typeof FileList !== "undefined" && input instanceof FileList) {
+    return Array.from(input);
+  }
+  if (Array.isArray(input)) {
+    return input.filter(item => item instanceof File);
+  }
+  throw new Error("请传入 File、File[] 或 FileList");
+}
+
+/**
+ * 单个 File 上传到 OSS(POST multipart,字段与 1.js / OSS4 直传一致)
+ * @param {Record<string, any>} signRes 签名接口返回
+ * @param {File} file
+ * @param {string} key 对象 key(含 dir 前缀)
+ * @returns {Promise<string>} 文件访问 URL
+ */
+async function postFileToOss(signRes, file, key) {
+  const formData = new FormData();
+  formData.append("success_action_status", "200");
+  formData.append("policy", signRes.policy);
+  formData.append("x-oss-signature", signRes.signature);
+  formData.append("x-oss-signature-version", "OSS4-HMAC-SHA256");
+  formData.append("x-oss-credential", signRes.x_oss_credential || signRes["x-oss-credential"] || "");
+  formData.append("x-oss-date", signRes.x_oss_date || signRes["x-oss-date"] || "");
+  formData.append("key", key);
+  if (signRes.security_token != null) {
+    formData.append("x-oss-security-token", signRes.security_token);
+  }
+  formData.append("file", file, file.name);
+
+  const res = await fetch(signRes.host, {
+    method: "POST",
+    body: formData
+  });
+
+  if (res.status !== 200) {
+    let msg = "上传失败";
+    try {
+      const t = await res.text();
+      if (t) msg = t.slice(0, 200);
+    } catch (_) {
+      /* ignore */
+    }
+    throw new Error(msg);
+  }
+
+  const text = await res.text();
+  if (text && typeof text === "string" && text.trim().startsWith("http")) {
+    return text.trim();
+  }
+  return signRes.host.replace(/\/$/, "") + "/" + key;
+}
+
+/**
+ * 上传文件到 OSS:先 GET 获取签名,再 POST 到 OSS host(与 src/api/1.js 逻辑一致,Web 使用 File / FormData)
+ * @param {File | File[] | FileList} files 浏览器文件对象;支持单个 File、数组或 FileList
+ * @param {string} [fileType] 文件类型(如 'image' | 'video'),用于在文件名无后缀时推断格式
+ * @param {{ showLoading?: boolean }} [options] showLoading 为 true 时用 ElMessage 提示上传中(非阻塞)
+ * @returns {Promise<string[]>} 上传成功后的文件 URL 列表
+ */
+export async function uploadFilesToOss(files, fileType, options = {}) {
+  const { showLoading = false } = options;
+  const fileArr = normalizeFiles(files);
+  if (fileArr.length === 0) {
+    throw new Error("请选择要上传的文件");
+  }
+
+  let closeLoading = () => {};
+  if (showLoading) {
+    const loading = ElMessage({ message: "上传中...", type: "info", duration: 0, showClose: false });
+    closeLoading = () => loading.close();
+  }
+
+  try {
+    const signRes = await fetchOssSignature();
+    const uploadedUrls = [];
+
+    for (let i = 0; i < fileArr.length; i++) {
+      const file = fileArr[i];
+      const filePath = file.name || "";
+      const ext = getFileExtension(filePath, fileType);
+      const rawBase = filePath.split("/").pop() || "";
+      const baseName = rawBase.replace(/\.[^.]+$/, "") || `file_${Date.now()}_${i}`;
+      const fileName = baseName.includes(".") ? baseName : `${baseName}.${ext}`;
+      const key = (signRes.dir ? signRes.dir.replace(/\/$/, "") + "/" : "") + fileName;
+
+      const url = await postFileToOss(signRes, file, key);
+      uploadedUrls.push(url);
+    }
+
+    closeLoading();
+    return uploadedUrls;
+  } catch (e) {
+    closeLoading();
+    console.error("上传失败", e);
+    const msg = e?.message || "上传失败";
+    ElMessage.error(msg);
+    throw e;
+  }
+}
+
+/**
+ * 上传单个文件,返回 URL 字符串
+ * @param {File} file
+ * @param {string} [fileType]
+ * @param {{ showLoading?: boolean }} [options]
+ * @returns {Promise<string>}
+ */
+export async function uploadFileToOss(file, fileType, options) {
+  const urls = await uploadFilesToOss(file, fileType, options);
+  return urls[0];
+}
+
+/**
+ * 兼容 UploadImg 等组件的 api 格式:接收 FormData,返回 { data: { fileUrl } }
+ * @param {FormData} formData 包含 file 字段
+ * @param {string} [fileType] 文件类型,默认 'image'
+ * @param {{ showLoading?: boolean }} [options]
+ * @returns {Promise<{ data: { fileUrl: string } }>}
+ */
+export async function uploadFormDataToOss(formData, fileType = "image", options = {}) {
+  const file = formData.get("file");
+  if (!file || !(file instanceof File)) {
+    throw new Error("请选择要上传的文件");
+  }
+  const url = await uploadFileToOss(file, fileType, options);
+  return { data: { fileUrl: url } };
+}

+ 72 - 0
src/assets/json/authMenuList.json

@@ -1260,6 +1260,78 @@
           }
         }
       ]
+    },
+    {
+      "path": "/appoinmentManagement",
+      "name": "classifyManagement",
+      "component": "/appoinmentManagement",
+      "meta": {
+        "icon": "ChatDotSquare",
+        "title": "预约服务",
+        "isLink": "",
+        "isHide": false,
+        "isFull": false,
+        "isAffix": false,
+        "isKeepAlive": false
+      },
+      "children": [
+        {
+          "path": "/appoinmentManagement/classifyManagement",
+          "name": "classifyManagement",
+          "component": "/appoinmentManagement/classifyManagement",
+          "meta":{
+            "icon": "ChatDotSquare",
+            "title": "分类管理",
+            "isLink": "",
+            "isHide": false,
+            "isFull": false,
+            "isAffix": false,
+            "isKeepAlive": false
+          }
+        },
+        {
+          "path": "/appoinmentManagement/tableManagement",
+          "name": "tableManagement",
+          "component": "/appoinmentManagement/tableManagement",
+          "meta":{
+            "icon": "ChatDotSquare",
+            "title": "桌号管理",
+            "isLink": "",
+            "isHide": false,
+            "isFull": false,
+            "isAffix": false,
+            "isKeepAlive": false
+          }
+        },
+        {
+          "path": "/appoinmentManagement/appoinmentInfo",
+          "name": "appoinmentInfo",
+          "component": "/appoinmentManagement/appoinmentInfo",
+          "meta":{
+            "icon": "ChatDotSquare",
+            "title": "预定信息",
+            "isLink": "",
+            "isHide": false,
+            "isFull": false,
+            "isAffix": false,
+            "isKeepAlive": false
+          }
+        },
+        {
+          "path": "/appoinmentManagement/infoManagement",
+          "name": "infoManagement",
+          "component": "/appoinmentManagement/infoManagement",
+          "meta":{
+            "icon": "ChatDotSquare",
+            "title": "信息设置",
+            "isLink": "",
+            "isHide": false,
+            "isFull": false,
+            "isAffix": false,
+            "isKeepAlive": false
+          }
+        }
+      ]
     }
   ],
   "msg": "成功"

+ 526 - 0
src/views/appoinmentManagement/appoinmentInfo.vue

@@ -0,0 +1,526 @@
+<template>
+  <div class="table-box appointment-info">
+    <!-- 筛选:点击搜索后生效(与翻页联动) -->
+    <div class="filter-bar">
+      <div class="filter-row">
+        <span class="filter-label">预订日期</span>
+        <el-date-picker
+          v-model="searchForm.dateRange"
+          type="daterange"
+          range-separator="至"
+          start-placeholder="请选择"
+          end-placeholder="请选择"
+          value-format="YYYY-MM-DD"
+          style="width: 160px"
+        />
+        <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">
+      <template #tableHeader>
+        <div class="table-header">
+          <el-button type="primary" @click="handleNew"> 新建 </el-button>
+        </div>
+      </template>
+      <template #statusText="{ row }">
+        <el-tag :type="getStatusTagType(row.orderStatus)" size="small">
+          {{ row.statusText }}
+        </el-tag>
+      </template>
+      <template #remark="{ row }">
+        <el-tooltip v-if="row.remark" :content="row.remark" placement="top" :show-after="200">
+          <span class="remark-cell">{{ row.remark }}</span>
+        </el-tooltip>
+        <span v-else>—</span>
+      </template>
+      <!-- 操作按钮与商家端 1.vue 一致:orderStatus 0待支付 1待使用 2已完成 3已过期 4已取消 5已关闭 6退款中 7已退款 8商家预订 -->
+      <template #operation="scope">
+        <template v-if="isOrderStatus(scope.row, 1)">
+          <el-button link type="primary" @click="handleCancel(scope.row)"> 取消 </el-button>
+        </template>
+        <template v-else-if="isOrderStatus(scope.row, 7)">
+          <el-button link type="primary" @click="handleDelete(scope.row)"> 删除 </el-button>
+        </template>
+        <template
+          v-else-if="
+            (isOrderStatus(scope.row, 2) || isOrderStatus(scope.row, 7) || isOrderStatus(scope.row, 3)) &&
+            isMoreThanThreeHoursAfterEnd(scope.row)
+          "
+        >
+          <el-button link type="primary" @click="handleDelete(scope.row)"> 删除 </el-button>
+        </template>
+        <template
+          v-else-if="isOrderStatus(scope.row, 3) && !isMoreThanThreeHoursAfterEnd(scope.row) && orderCostTypeIsPaid(scope.row)"
+        >
+          <el-button link type="primary" @click="handleRefund(scope.row)"> 退款 </el-button>
+          <el-button link type="primary" @click="handleDelete(scope.row)"> 删除 </el-button>
+        </template>
+        <template v-else-if="isOrderStatus(scope.row, 4) && !orderCostTypeIsPaid(scope.row)">
+          <el-button link type="primary" @click="handleDelete(scope.row)"> 删除 </el-button>
+        </template>
+        <template v-else-if="orderCostTypeIsPaid(scope.row) && isOrderStatus(scope.row, 4) && cancelReasonIsEmpty(scope.row)">
+          <el-button link type="primary" @click="handleRefund(scope.row)"> 退款 </el-button>
+          <el-button link type="primary" @click="handleDelete(scope.row)"> 删除 </el-button>
+        </template>
+        <template v-else-if="isOrderStatus(scope.row, 2) && !isMoreThanThreeHoursAfterEnd(scope.row)">
+          <el-button link type="primary" @click="handleAddTime(scope.row)"> 加时 </el-button>
+        </template>
+      </template>
+    </ProTable>
+
+    <!-- 加时弹窗 -->
+    <el-dialog v-model="addTimeVisible" title="加时" width="400px" append-to-body>
+      <el-form :model="addTimeForm" label-width="80px">
+        <el-form-item label="加时时长">
+          <el-input-number v-model="addTimeForm.minutes" :min="1" :max="480" placeholder="分钟" style="width: 100%" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="addTimeVisible = false"> 取消 </el-button>
+        <el-button type="primary" :loading="addTimeLoading" @click="confirmAddTime"> 确定 </el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive } from "vue";
+import { ElMessage, ElMessageBox } from "element-plus";
+import ProTable from "@/components/ProTable/index.vue";
+import type { ProTableInstance, ColumnProps } from "@/components/ProTable/interface";
+import {
+  reservationList,
+  reservationAddTime,
+  reservationCancel,
+  reservationDelete,
+  reservationRefund
+} from "@/api/modules/scheduledService";
+import { localGet } from "@/utils";
+
+export interface ReservationRow {
+  id: number | string;
+  _seq?: number;
+  date: string;
+  weekDay: string;
+  location: string;
+  peopleCount: string | number;
+  tableNo: string;
+  timeRange: string;
+  amount: string | number;
+  customerName: string;
+  phone: string;
+  /** 兼容旧字段展示/筛选 */
+  status: number | string;
+  statusText: string;
+  /** 与商家端 1.vue 一致:订单状态 */
+  orderStatus: number;
+  /** 0 免费 1 付费(退款按钮用) */
+  orderCostType: number;
+  /** 与 1.vue item.reason 一致:空=用户取消,非空=商家取消等 */
+  reason: string;
+  reservationDateRaw: string;
+  timeSlotRaw: string;
+  endTimeRaw?: string | Date;
+  remark: string;
+  cancelReason?: string;
+  refundReason?: string;
+  canRefund?: boolean;
+}
+
+const proTable = ref<ProTableInstance>();
+
+const columns: ColumnProps<ReservationRow>[] = [
+  { prop: "_seq", label: "序号", width: 60, align: "center" },
+  { prop: "date", label: "日期", width: 100, align: "center" },
+  { prop: "weekDay", label: "星期", width: 70, align: "center" },
+  { prop: "location", label: "位置", minWidth: 90, showOverflowTooltip: true },
+  { prop: "peopleCount", label: "人数", width: 70, align: "center" },
+  { prop: "tableNo", label: "桌号", minWidth: 90, showOverflowTooltip: true },
+  { prop: "timeRange", label: "时间", width: 120, align: "center" },
+  { prop: "amount", label: "预订金额(元)", width: 110, align: "center" },
+  { prop: "customerName", label: "姓名", width: 100, showOverflowTooltip: true },
+  { prop: "phone", label: "电话", width: 120, showOverflowTooltip: true },
+  { prop: "statusText", label: "状态", width: 96, align: "center" },
+  { prop: "remark", label: "备注", minWidth: 100, showOverflowTooltip: true },
+  { prop: "operation", label: "操作", fixed: "right", width: 200, align: "center" }
+];
+
+/** 与商家端 1.vue 筛选、列表 status 一致(orderStatus) */
+const statusOptions = [
+  { label: "全部", value: "" },
+  { label: "待使用", value: 1 },
+  { label: "已完成", value: 2 },
+  { label: "退款", value: 6 } // 退款 tab:6退款中(接口若合并 7 需后端支持)
+];
+
+/** orderStatus 文案,与 1.vue ORDER_STATUS_TEXT 一致 */
+const ORDER_STATUS_TEXT: Record<number, string> = {
+  0: "待支付",
+  1: "待使用",
+  2: "已完成",
+  3: "已过期",
+  4: "已取消",
+  5: "已关闭",
+  6: "退款中",
+  7: "已退款",
+  8: "商家预订"
+};
+
+/** 旧版 Web 列表 status → orderStatus(无 orderStatus 字段时的兜底) */
+const LEGACY_STATUS_TO_ORDER: Record<number, number> = {
+  0: 1, // 待使用
+  1: 2, // 已完成
+  2: 4, // 用户取消 → 已取消
+  3: 4, // 商家取消 → 已取消
+  4: 3, // 已过期
+  5: 6, // 退款中
+  6: 7 // 退款成功 → 已退款
+};
+
+const THREE_HOURS_MS = 3 * 60 * 60 * 1000;
+
+const initParam = reactive({
+  storeId: localGet("geeker-user")?.userInfo?.storeId ?? localGet("createdId") ?? ""
+});
+
+/** 已生效的查询条件(点击搜索后写入) */
+const listFilter = reactive({
+  name: "",
+  status: "" as number | string,
+  startDate: "",
+  endDate: ""
+});
+
+const searchForm = reactive({
+  name: "",
+  status: "" as number | string,
+  dateRange: [] as string[]
+});
+
+const addTimeVisible = ref(false);
+const addTimeLoading = ref(false);
+const addTimeForm = reactive({ minutes: 30 });
+const currentAddTimeRow = ref<ReservationRow | null>(null);
+
+function getStoreId(): number | string | null {
+  return localGet("geeker-user")?.userInfo?.storeId ?? localGet("createdId") ?? null;
+}
+
+function getStatusTagType(orderStatus: number | string): "primary" | "success" | "warning" | "danger" | "info" {
+  const s = Number(orderStatus);
+  if (s === 0 || s === 1 || s === 8) return "primary";
+  if (s === 2) return "success";
+  if (s === 6) return "warning";
+  if (s === 7) return "success";
+  if (s === 3 || s === 4 || s === 5) return "info";
+  return "info";
+}
+
+function isOrderStatus(row: ReservationRow, n: number) {
+  return Number(row.orderStatus) === n;
+}
+
+function orderCostTypeIsPaid(row: ReservationRow) {
+  return Number(row.orderCostType) === 1;
+}
+
+/** 与 1.vue 一致:reason === '' 时视为用户取消(付费可退款+删除) */
+function cancelReasonIsEmpty(row: ReservationRow) {
+  return String(row.reason ?? "").trim() === "";
+}
+
+function parseRowEndDateTime(row: ReservationRow): Date | null {
+  const et = row.endTimeRaw;
+  if (et != null && et !== "") {
+    const d = et instanceof Date ? et : new Date(et as string);
+    if (!isNaN(d.getTime())) return d;
+  }
+  const date = String(row.reservationDateRaw || "")
+    .trim()
+    .replace(/\//g, "-");
+  const slot = String(row.timeSlotRaw || "").trim();
+  const parts = slot.split("-");
+  const endTimeStr = parts.length > 1 ? parts[parts.length - 1].trim() : "";
+  if (date && endTimeStr) {
+    const normalized = endTimeStr.length <= 5 ? `${date} ${endTimeStr}:00` : `${date} ${endTimeStr}`;
+    const parsed = new Date(normalized.replace(/-/g, "/"));
+    if (!isNaN(parsed.getTime())) return parsed;
+  }
+  return null;
+}
+
+/** 结束时间已过且当前时间超过结束 3 小时(与 1.vue 一致) */
+function isMoreThanThreeHoursAfterEnd(row: ReservationRow) {
+  const end = parseRowEndDateTime(row);
+  if (!end) return false;
+  return Date.now() > end.getTime() + THREE_HOURS_MS;
+}
+
+function formatDateCol(val: string): string {
+  if (!val) return "—";
+  const d = new Date(val);
+  if (isNaN(d.getTime())) return String(val);
+  const m = d.getMonth() + 1;
+  const day = d.getDate();
+  return `${String(m).padStart(2, "0")}月${String(day).padStart(2, "0")}日`;
+}
+
+function getWeekDay(val: string): string {
+  if (!val) return "—";
+  const d = new Date(val);
+  if (isNaN(d.getTime())) return "—";
+  const week = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
+  return week[d.getDay()];
+}
+
+function mapReservationRow(item: any): ReservationRow {
+  const date = item.reservationDate ?? item.bookingDate ?? item.date ?? "";
+  const timeStart = item.startTime ?? item.bookingStartTime ?? "";
+  const timeEnd = item.bookingEndTime ?? item.endTime ?? "";
+  const customerName =
+    item.customerName != null && String(item.customerName).trim() !== ""
+      ? String(item.customerName).trim()
+      : (item.name ?? item.userName ?? item.contactName ?? "—");
+
+  const hasOrderStatusField =
+    (item.orderStatus != null && item.orderStatus !== "") || (item.statusType != null && item.statusType !== "");
+  const legacySt = Number(item.status ?? item.reservationStatus ?? NaN);
+
+  let orderStatus: number;
+  if (item.orderStatus != null && item.orderStatus !== "") {
+    orderStatus = Number(item.orderStatus);
+  } else if (item.statusType != null && item.statusType !== "") {
+    orderStatus = Number(item.statusType);
+  } else if (Number.isFinite(legacySt) && LEGACY_STATUS_TO_ORDER[legacySt] !== undefined) {
+    orderStatus = LEGACY_STATUS_TO_ORDER[legacySt];
+  } else if (Number.isFinite(legacySt)) {
+    orderStatus = legacySt;
+  } else {
+    orderStatus = 1;
+  }
+
+  let reason = String(item.reason ?? item.cancelReason ?? "").trim();
+  if (!hasOrderStatusField && legacySt === 2) reason = "";
+  if (!hasOrderStatusField && legacySt === 3 && !reason) reason = "商家取消";
+
+  const orderCostType = Number(item.orderCostType ?? item.costType ?? item.reservationCostType ?? 0);
+
+  const timeSlotRaw = item.timeSlot ?? (timeStart && timeEnd ? `${String(timeStart).trim()}-${String(timeEnd).trim()}` : "");
+
+  return {
+    id: item.id,
+    date: formatDateCol(date),
+    weekDay: item.weekDay ?? getWeekDay(date),
+    location: item.locationName ?? item.categoryName ?? item.location ?? "—",
+    peopleCount: item.peopleCount ?? item.personCount ?? item.guestCount ?? "—",
+    tableNo: item.tableNo ?? item.tableNumber ?? item.tableNumbers ?? "—",
+    timeRange: timeStart && timeEnd ? `${timeStart}-${timeEnd}` : (item.timeRange ?? "—"),
+    amount:
+      item.depositAmount != null && item.depositAmount !== ""
+        ? item.depositAmount
+        : item.amount != null && item.amount !== ""
+          ? item.amount
+          : "—",
+    customerName,
+    phone: item.phone ?? item.mobile ?? item.contactPhone ?? "—",
+    status: orderStatus,
+    orderStatus,
+    orderCostType,
+    reason,
+    reservationDateRaw: String(date).trim(),
+    timeSlotRaw,
+    endTimeRaw: item.endTime ?? item.bookingEndTime ?? item.reservationEndTime,
+    statusText:
+      (item.orderStatusText && String(item.orderStatusText).trim()) || ORDER_STATUS_TEXT[orderStatus] || item.statusText || "—",
+    remark: item.remark ?? item.remarkDesc ?? "",
+    cancelReason: item.cancelReason ?? item.reason ?? "",
+    refundReason: item.refundReason,
+    canRefund: item.canRefund
+  };
+}
+
+/** ProTable / useTable 约定:返回 { data: { list, total } } */
+async function getTableList(params: any) {
+  const storeId = params.storeId ?? initParam.storeId;
+  if (!storeId) {
+    return { data: { list: [] as ReservationRow[], total: 0 } };
+  }
+  const pageNum = params.pageNum ?? 1;
+  const pageSize = params.pageSize ?? 10;
+  const req: Record<string, any> = {
+    storeId: Number(storeId),
+    pageNum,
+    pageSize
+  };
+  if (listFilter.name?.trim()) req.name = listFilter.name.trim();
+  if (listFilter.status !== undefined && listFilter.status !== "" && listFilter.status !== null) {
+    req.status = listFilter.status;
+  }
+  if (listFilter.startDate && listFilter.endDate) {
+    req.startDate = listFilter.startDate;
+    req.endDate = listFilter.endDate;
+  }
+  try {
+    const res: any = await reservationList(req);
+    const body = res?.data ?? res;
+    const inner = body?.data ?? body;
+    const list = inner?.list ?? inner?.records ?? (Array.isArray(inner) ? inner : []);
+    const arr = Array.isArray(list) ? list : [];
+    const total = inner?.total ?? body?.total ?? res?.total ?? 0;
+    const rows = arr.map((item: any, i: number) => ({
+      ...mapReservationRow(item),
+      _seq: (pageNum - 1) * pageSize + i + 1
+    }));
+    return { data: { list: rows, total: Number(total) || 0 } };
+  } catch (e: any) {
+    ElMessage.error(e?.message || "加载失败");
+    return { data: { list: [] as ReservationRow[], total: 0 } };
+  }
+}
+
+function handleSearch() {
+  listFilter.name = searchForm.name;
+  listFilter.status = searchForm.status;
+  if (searchForm.dateRange?.length === 2) {
+    listFilter.startDate = searchForm.dateRange[0];
+    listFilter.endDate = searchForm.dateRange[1];
+  } else {
+    listFilter.startDate = "";
+    listFilter.endDate = "";
+  }
+  proTable.value?.getTableList();
+}
+
+function handleReset() {
+  searchForm.name = "";
+  searchForm.status = "";
+  searchForm.dateRange = [];
+  listFilter.name = "";
+  listFilter.status = "";
+  listFilter.startDate = "";
+  listFilter.endDate = "";
+  proTable.value?.getTableList();
+}
+
+function handleNew() {
+  ElMessage.info("新建预约功能可在此处跳转或打开弹窗");
+}
+
+function handleCancel(row: ReservationRow) {
+  ElMessageBox.confirm("确认取消该预约?", "提示", {
+    confirmButtonText: "确定",
+    cancelButtonText: "取消",
+    type: "warning"
+  })
+    .then(async () => {
+      try {
+        await reservationCancel({ id: row.id });
+        ElMessage.success("已取消");
+        proTable.value?.getTableList();
+      } catch (e: any) {
+        ElMessage.error(e?.message || "取消失败");
+      }
+    })
+    .catch(() => {});
+}
+
+function handleAddTime(row: ReservationRow) {
+  currentAddTimeRow.value = row;
+  addTimeForm.minutes = 30;
+  addTimeVisible.value = true;
+}
+
+async function confirmAddTime() {
+  const row = currentAddTimeRow.value;
+  if (!row) return;
+  addTimeLoading.value = true;
+  try {
+    await reservationAddTime({ id: row.id, addMinutes: addTimeForm.minutes });
+    ElMessage.success("加时成功");
+    addTimeVisible.value = false;
+    proTable.value?.getTableList();
+  } catch (e: any) {
+    ElMessage.error(e?.message || "加时失败");
+  } finally {
+    addTimeLoading.value = false;
+  }
+}
+
+function handleDelete(row: ReservationRow) {
+  ElMessageBox.confirm("确认删除该预约记录?", "提示", {
+    confirmButtonText: "确定",
+    cancelButtonText: "取消",
+    type: "warning"
+  })
+    .then(async () => {
+      try {
+        await reservationDelete({ id: row.id });
+        ElMessage.success("删除成功");
+        proTable.value?.getTableList();
+      } catch (e: any) {
+        ElMessage.error(e?.message || "删除失败");
+      }
+    })
+    .catch(() => {});
+}
+
+function handleRefund(row: ReservationRow) {
+  ElMessageBox.confirm("确认对该预约发起退款?", "提示", {
+    confirmButtonText: "确定",
+    cancelButtonText: "取消",
+    type: "warning"
+  })
+    .then(async () => {
+      try {
+        await reservationRefund({ id: row.id });
+        ElMessage.success("退款申请已提交");
+        proTable.value?.getTableList();
+      } catch (e: any) {
+        ElMessage.error(e?.message || "退款失败");
+      }
+    })
+    .catch(() => {});
+}
+</script>
+
+<style scoped lang="scss">
+.appointment-info {
+  padding: 0;
+}
+.filter-bar {
+  padding: 0 4px;
+  margin-bottom: 12px;
+}
+.filter-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+  align-items: center;
+}
+.filter-label {
+  font-size: 14px;
+  color: #606266;
+  white-space: nowrap;
+}
+.table-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: 100%;
+  padding: 12px 0;
+}
+.tip-text {
+  font-size: 12px;
+  color: #909399;
+}
+.remark-cell {
+  display: inline-block;
+  max-width: 120px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  cursor: default;
+}
+</style>

+ 423 - 0
src/views/appoinmentManagement/classifyManagement.vue

@@ -0,0 +1,423 @@
+<template>
+  <div class="table-box classify-management">
+    <ProTable ref="proTable" :columns="columns" :request-api="getTableList" :init-param="initParam">
+      <template #tableHeader>
+        <div class="table-header">
+          <el-button type="primary" @click="handleNew"> 新建 </el-button>
+        </div>
+      </template>
+      <template #isDisplay="{ row }">
+        <span :class="row.isDisplay === 1 ? 'status-show' : 'status-hide'">
+          {{ row.isDisplay === 1 ? "显示" : "隐藏" }}
+        </span>
+      </template>
+      <template #operation="scope">
+        <el-button link type="primary" @click="handleEdit(scope.row)"> 编辑 </el-button>
+        <el-button link type="primary" @click="handleDelete(scope.row)"> 删除 </el-button>
+      </template>
+    </ProTable>
+
+    <!-- 新建/编辑弹窗 -->
+    <el-dialog
+      v-model="dialogVisible"
+      :title="editId == null ? '新建分类' : '编辑分类'"
+      width="480px"
+      append-to-body
+      destroy-on-close
+      @close="onDialogClose"
+    >
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="160px" v-loading="detailLoading">
+        <el-form-item label="分类名称" prop="name" required>
+          <el-input v-model="form.name" placeholder="请输入" clearable maxlength="20" show-word-limit />
+        </el-form-item>
+        <el-form-item label="平面图" prop="planeImageUrls" required>
+          <div class="plane-upload-wrap">
+            <el-upload
+              v-model:file-list="form.planeImageFileList"
+              list-type="picture-card"
+              :limit="9"
+              :http-request="handlePlaneImageUpload"
+              :on-remove="onPlaneImageRemove"
+              accept="image/*"
+              multiple
+            >
+              <el-icon><Plus /></el-icon>
+            </el-upload>
+            <div class="upload-tip">({{ form.planeImageUrls.length }}/9)</div>
+          </div>
+        </el-form-item>
+        <el-form-item label="是否显示">
+          <el-radio-group v-model="form.isDisplay">
+            <el-radio :value="1"> 显示 </el-radio>
+            <el-radio :value="0"> 隐藏 </el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="最长预订时间(分钟)" prop="maxBookingTime" required>
+          <el-input
+            :model-value="String(form.maxBookingTime || '')"
+            placeholder="请输入"
+            style="width: 100%"
+            @update:model-value="
+              (val: string) => {
+                const n = parseInt(val, 10);
+                form.maxBookingTime = isNaN(n) ? 0 : n;
+              }
+            "
+          />
+        </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">
+import { ref, reactive } from "vue";
+import { ElMessage, ElMessageBox } from "element-plus";
+import type { FormInstance, FormRules } from "element-plus";
+import type { UploadRequestOptions, UploadUserFile } from "element-plus";
+import { Plus } from "@element-plus/icons-vue";
+import ProTable from "@/components/ProTable/index.vue";
+import type { ProTableInstance, ColumnProps } from "@/components/ProTable/interface";
+import {
+  scheduleList,
+  scheduleDel,
+  scheduleAdd,
+  scheduleEdit,
+  scheduleDetail,
+  getHasReservation
+} from "@/api/modules/scheduledService";
+import { uploadFileToOss } from "@/api/upload.js";
+import { localGet } from "@/utils";
+
+export interface CategoryRow {
+  id: number | string;
+  _seq?: number;
+  name: string;
+  maxBookingTime?: number;
+  isDisplay: number;
+  planeImageUrls?: string[];
+}
+
+const proTable = ref<ProTableInstance>();
+
+const columns: ColumnProps<CategoryRow>[] = [
+  { prop: "_seq", label: "序号", width: 60, align: "center" },
+  { prop: "name", label: "名称" },
+  { prop: "maxBookingTime", label: "最长预订时间(分钟)", align: "center" },
+  { prop: "isDisplay", label: "状态", align: "center" },
+  { prop: "operation", label: "操作", fixed: "right", width: 140, align: "center" }
+];
+
+const initParam = reactive({
+  storeId: localGet("geeker-user")?.userInfo?.storeId ?? localGet("createdId") ?? ""
+});
+
+function mapCategoryItem(item: any): CategoryRow {
+  const isDisplay = item.isDisplay !== undefined ? item.isDisplay : item.show ? 1 : 0;
+  return {
+    id: item.id,
+    name: item.categoryName ?? item.name ?? "",
+    maxBookingTime: item.maxBookingTime ?? item.bookingTime ?? 0,
+    isDisplay: Number(isDisplay),
+    planeImageUrls: item.planeImageUrls ?? item.floorPlanUrls ?? []
+  };
+}
+
+/** ProTable / useTable 约定:返回 { data: { list, total } } */
+async function getTableList(params: any) {
+  const storeId = params.storeId ?? initParam.storeId;
+  if (!storeId) {
+    return { data: { list: [] as CategoryRow[], total: 0 } };
+  }
+  const pageNum = params.pageNum ?? 1;
+  const pageSize = params.pageSize ?? 10;
+  try {
+    const res: any = await scheduleList({
+      storeId: Number(storeId),
+      pageNum,
+      pageSize
+    });
+    const body = res?.data ?? res;
+    const inner = body?.data ?? body;
+    const raw = inner?.records ?? inner?.list ?? (Array.isArray(inner) ? inner : []);
+    const arr = Array.isArray(raw) ? raw : [];
+    const total = inner?.total ?? body?.total ?? inner?.totalCount ?? 0;
+    const list = arr.map((item: any, i: number) => ({
+      ...mapCategoryItem(item),
+      _seq: (pageNum - 1) * pageSize + i + 1
+    }));
+    return { data: { list, total: Number(total) || 0 } };
+  } catch (e: any) {
+    ElMessage.error(e?.message || "加载失败");
+    return { data: { list: [] as CategoryRow[], total: 0 } };
+  }
+}
+
+const dialogVisible = ref(false);
+const editId = ref<number | string | null>(null);
+const submitLoading = ref(false);
+const detailLoading = ref(false);
+const formRef = ref<FormInstance>();
+
+const form = reactive<{
+  name: string;
+  maxBookingTime: number;
+  isDisplay: number;
+  planeImageUrls: string[];
+  planeImageFileList: UploadUserFile[];
+}>({
+  name: "",
+  maxBookingTime: 0,
+  isDisplay: 1,
+  planeImageUrls: [],
+  planeImageFileList: []
+});
+
+const rules: FormRules = {
+  name: [{ required: true, message: "请输入分类名称", trigger: "blur" }],
+  maxBookingTime: [{ required: true, message: "请输入最长预订时间", trigger: "blur" }],
+  planeImageUrls: [
+    {
+      required: true,
+      validator: (_rule: any, _value: any, callback: (e?: Error) => void) => {
+        if (!form.planeImageUrls || form.planeImageUrls.length < 1) {
+          callback(new Error("请至少上传一张平面图(包含桌号)"));
+        } else {
+          callback();
+        }
+      },
+      trigger: "change"
+    }
+  ]
+};
+
+async function handlePlaneImageUpload(options: UploadRequestOptions) {
+  const uploadFileItem = options.file as UploadUserFile;
+  const raw = uploadFileItem.raw || uploadFileItem;
+  const file = raw instanceof File ? raw : null;
+  if (!file) return;
+
+  uploadFileItem.status = "uploading";
+  try {
+    const fileUrl = await uploadFileToOss(file, "image");
+    if (fileUrl) {
+      uploadFileItem.status = "success";
+      uploadFileItem.url = fileUrl;
+      uploadFileItem.response = { url: fileUrl };
+      if (!form.planeImageUrls.includes(fileUrl)) form.planeImageUrls.push(fileUrl);
+    } else {
+      uploadFileItem.status = "fail";
+      ElMessage.error("上传失败,未返回地址");
+    }
+  } catch {
+    uploadFileItem.status = "fail";
+    // OSS 签名/网络错误已在 upload.js 中提示
+  }
+  formRef.value?.validateField("planeImageUrls").catch(() => {});
+}
+
+function onPlaneImageRemove(_file: UploadUserFile, fileList: UploadUserFile[]) {
+  const urls = fileList.map(f => (f as any).url).filter(Boolean);
+  form.planeImageUrls = urls;
+  formRef.value?.validateField("planeImageUrls").catch(() => {});
+}
+
+function getStoreId(): number | string | null {
+  return localGet("geeker-user")?.userInfo?.storeId ?? localGet("createdId") ?? null;
+}
+
+/** 接口返回 true 表示该分类下桌号存在预定,不可编辑/删除 */
+function parseHasReservationResponse(res: any): boolean {
+  const body = res?.data ?? res;
+  const val = body?.data !== undefined ? body.data : body;
+  if (typeof val === "boolean") return val;
+  if (val != null && typeof val === "object" && "hasReservation" in val) {
+    return Boolean((val as { hasReservation?: boolean }).hasReservation);
+  }
+  return Boolean(val);
+}
+
+async function categoryHasReservation(categoryId: number | string): Promise<boolean> {
+  const res: any = await getHasReservation({ id: categoryId });
+  return parseHasReservationResponse(res);
+}
+
+function handleNew() {
+  editId.value = null;
+  form.name = "";
+  form.maxBookingTime = 0;
+  form.isDisplay = 1;
+  form.planeImageUrls = [];
+  form.planeImageFileList = [];
+  dialogVisible.value = true;
+}
+
+async function handleEdit(row: CategoryRow) {
+  try {
+    const has = await categoryHasReservation(row.id);
+    if (has) {
+      ElMessage.warning("此分类所属桌号有预定信息,不可修改");
+      return;
+    }
+  } catch (e: any) {
+    ElMessage.error(e?.message || "校验失败,请稍后重试");
+    return;
+  }
+  editId.value = row.id;
+  dialogVisible.value = true;
+  detailLoading.value = true;
+  try {
+    const res: any = await scheduleDetail({ id: row.id });
+    const data = res?.data ?? res ?? {};
+    form.name = data.categoryName ?? data.name ?? row.name ?? "";
+    form.maxBookingTime = data.maxBookingTime ?? data.bookingTime ?? row.maxBookingTime ?? 0;
+    form.isDisplay = data.isDisplay !== undefined ? Number(data.isDisplay) : data.show ? 1 : (row.isDisplay ?? 1);
+    const raw = data.floorPlanImages ?? data.planeImageUrls ?? data.floorPlanUrls ?? data.planeImages ?? [];
+    const urls = Array.isArray(raw)
+      ? raw
+      : typeof raw === "string"
+        ? raw
+            .split(",")
+            .map((s: string) => s.trim())
+            .filter(Boolean)
+        : [];
+    form.planeImageUrls = [...urls];
+    form.planeImageFileList = form.planeImageUrls.map((url: string, i: number) => ({
+      name: `plane-${i}`,
+      url
+    })) as UploadUserFile[];
+  } catch (e: any) {
+    ElMessage.error(e?.message || "获取详情失败");
+    form.name = row.name;
+    form.maxBookingTime = row.maxBookingTime ?? 0;
+    form.isDisplay = row.isDisplay;
+    const urls = (row as any).planeImageUrls ?? (row as any).floorPlanUrls ?? [];
+    form.planeImageUrls = Array.isArray(urls) ? [...urls] : [];
+    form.planeImageFileList = form.planeImageUrls.map((url, i) => ({
+      name: `plane-${i}`,
+      url
+    })) as UploadUserFile[];
+  } finally {
+    detailLoading.value = false;
+  }
+}
+
+async function handleDelete(row: CategoryRow) {
+  try {
+    const has = await categoryHasReservation(row.id);
+    if (has) {
+      ElMessage.warning("此分类所属桌号有预定信息,不可修改");
+      return;
+    }
+  } catch (e: any) {
+    ElMessage.error(e?.message || "校验失败,请稍后重试");
+    return;
+  }
+  ElMessageBox.confirm("是否确认删除?", "提示", {
+    confirmButtonText: "确定",
+    cancelButtonText: "取消",
+    type: "warning"
+  })
+    .then(async () => {
+      try {
+        await scheduleDel({ id: row.id });
+        ElMessage.success("删除成功");
+        proTable.value?.getTableList();
+      } catch (e: any) {
+        ElMessage.error(e?.message || "删除失败");
+      }
+    })
+    .catch(() => {});
+}
+
+function onDialogClose() {
+  editId.value = null;
+  form.name = "";
+  form.maxBookingTime = 0;
+  form.isDisplay = 1;
+  form.planeImageUrls = [];
+  form.planeImageFileList = [];
+}
+
+async function submitForm() {
+  if (!formRef.value) return;
+  try {
+    await formRef.value.validate();
+  } catch {
+    return;
+  }
+  const storeId = getStoreId();
+  if (!storeId) {
+    ElMessage.warning("未找到门店信息");
+    return;
+  }
+  submitLoading.value = true;
+  try {
+    const payload = {
+      storeId,
+      categoryName: form.name.trim(),
+      isDisplay: form.isDisplay,
+      maxBookingTime: form.maxBookingTime,
+      floorPlanImages: form.planeImageUrls.join(",")
+    };
+    if (editId.value != null) {
+      await scheduleEdit({ ...payload, id: editId.value });
+    } else {
+      await scheduleAdd(payload);
+    }
+    ElMessage.success(editId.value == null ? "添加成功" : "修改成功");
+    dialogVisible.value = false;
+    onDialogClose();
+    proTable.value?.getTableList();
+  } catch (e: any) {
+    ElMessage.error(e?.message || "保存失败");
+  } finally {
+    submitLoading.value = false;
+  }
+}
+</script>
+
+<style scoped lang="scss">
+:deep(.el-dialog__body) {
+  height: 400px;
+}
+.classify-management {
+  padding: 0;
+}
+.table-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: 100%;
+  padding: 12px 0;
+}
+.status-show {
+  color: #67c23a;
+}
+.status-hide {
+  color: #909399;
+}
+.plane-upload-wrap {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+}
+.upload-tip {
+  display: block;
+  margin-top: 8px;
+  font-size: 12px;
+  color: #909399;
+}
+:deep(.el-upload--picture-card) {
+  width: 100px;
+  height: 100px;
+}
+:deep(.el-upload-list--picture-card .el-upload-list__item) {
+  width: 100px;
+  height: 100px;
+}
+</style>

+ 975 - 0
src/views/appoinmentManagement/infoManagement.vue

@@ -0,0 +1,975 @@
+<template>
+  <div class="info-management">
+    <div class="page-title">信息设置</div>
+
+    <!-- 基础信息 -->
+    <div class="form-card">
+      <div class="section-title">
+        <span class="section-bar" />
+        <span>基础信息</span>
+      </div>
+      <el-form :model="form.base" label-width="450px" class="section-form">
+        <el-form-item label="未按时到店">
+          <el-radio-group v-model="form.base.keepPosition">
+            <el-radio label="keep"> 保留位置 </el-radio>
+            <el-radio label="notKeep"> 不保留位置 </el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item v-if="form.base.keepPosition === 'keep'" label="保留时长(分钟)" required>
+          <el-input
+            :model-value="String(form.base.retentionMinutes ?? '')"
+            placeholder="请输入"
+            clearable
+            maxlength="2"
+            class="form-input"
+            @update:model-value="(v: string) => onIntInput(v, 2, n => (form.base.retentionMinutes = n))"
+          />
+        </el-form-item>
+        <el-form-item label="预订日期显示(天)" required>
+          <el-input
+            :model-value="String(form.base.bookDateDays ?? '')"
+            placeholder="请输入"
+            clearable
+            maxlength="2"
+            class="form-input"
+            @update:model-value="(v: string) => onIntInput(v, 2, n => (form.base.bookDateDays = n))"
+          />
+        </el-form-item>
+        <el-form-item label="单时段最大容纳人数" required>
+          <el-input
+            :model-value="String(form.base.maxCapacityPerSlot ?? '')"
+            placeholder="请输入"
+            clearable
+            maxlength="4"
+            class="form-input"
+            @update:model-value="(v: string) => onIntInput(v, 4, n => (form.base.maxCapacityPerSlot = n))"
+          />
+        </el-form-item>
+      </el-form>
+    </div>
+
+    <!-- 预订设置 -->
+    <div class="form-card">
+      <div class="section-title">
+        <span class="section-bar" />
+        <span>预订设置</span>
+      </div>
+      <el-form :model="form.booking" label-width="450px" class="section-form">
+        <el-form-item label="预订">
+          <el-radio-group v-model="form.booking.feeType">
+            <el-radio label="free"> 免费 </el-radio>
+            <el-radio label="paid"> 付费 </el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <template v-if="form.booking.feeType === 'paid'">
+          <el-form-item label="预订金额(元)" required>
+            <el-input
+              :model-value="String(form.booking.bookAmount ?? '')"
+              placeholder="请输入"
+              clearable
+              maxlength="3"
+              class="form-input"
+              @update:model-value="(v: string) => onIntInput(v, 3, n => (form.booking.bookAmount = n))"
+            />
+          </el-form-item>
+          <el-form-item label="取消预订退费时长设置,需提前(小时)" required>
+            <el-input
+              :model-value="String(form.booking.refundAdvanceHours ?? '')"
+              placeholder="请输入"
+              clearable
+              maxlength="3"
+              class="form-input"
+              @update:model-value="(v: string) => onIntInput(v, 3, n => (form.booking.refundAdvanceHours = n))"
+            />
+          </el-form-item>
+        </template>
+        <el-form-item label="营业时间结束前多少分钟不可预订" required>
+          <el-input
+            :model-value="String(form.booking.noBookMinutesBeforeClose ?? '')"
+            placeholder="请输入"
+            clearable
+            maxlength="3"
+            class="form-input"
+            @update:model-value="(v: string) => onIntInput(v, 3, n => (form.booking.noBookMinutesBeforeClose = n))"
+          />
+        </el-form-item>
+      </el-form>
+    </div>
+
+    <!-- 正常营业 -->
+    <div class="form-card">
+      <div class="section-title">
+        <span class="section-bar" />
+        <span>正常营业</span>
+      </div>
+      <el-form :model="form.normalBook" label-width="450px" class="section-form">
+        <el-form-item label="预订时间">
+          <el-radio-group v-model="form.normalBook.timeType">
+            <el-radio label="allDay" :disabled="normalAllDayDisabled"> 全天 </el-radio>
+            <el-radio label="notAllDay"> 非全天 </el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <template v-if="form.normalBook.timeType === 'notAllDay'">
+          <el-form-item label="时间设置">
+            <div class="time-row">
+              <el-time-picker
+                v-model="normalStartTimeValue"
+                format="HH:mm"
+                value-format="HH:mm"
+                placeholder="选择开始时间"
+                style="width: 160px"
+                :disabled-hours="normalStartDisabledHours"
+                :disabled-minutes="normalStartDisabledMinutes"
+                @visible-change="v => v && initNormalStartPickerRange()"
+                @change="onNormalStartTimeChange"
+              />
+              <el-time-picker
+                v-model="normalEndTimeValue"
+                format="HH:mm"
+                value-format="HH:mm"
+                placeholder="选择结束时间(次日)"
+                style="width: 160px"
+                :disabled-hours="normalEndDisabledHours"
+                :disabled-minutes="normalEndDisabledMinutes"
+                @visible-change="v => v && initNormalEndPickerRange()"
+                @change="onNormalEndTimeChange"
+              />
+            </div>
+          </el-form-item>
+        </template>
+      </el-form>
+    </div>
+
+    <!-- 特殊营业 -->
+    <div class="form-card">
+      <div class="section-title">
+        <span class="section-bar" />
+        <span>特殊营业</span>
+      </div>
+      <el-form label-width="450px" class="section-form">
+        <el-form-item label="选择节日">
+          <el-select
+            v-model="selectedHolidayNames"
+            multiple
+            collapse-tags
+            collapse-tags-tooltip
+            placeholder="下拉菜单"
+            style="width: 40%"
+            :disabled="holidayOptions.length === 0"
+          >
+            <el-option
+              v-for="item in holidayOptions"
+              :key="item.name"
+              :label="item.name"
+              :value="item.name"
+              :disabled="!allowedFestivalSet.has(item.name)"
+            />
+          </el-select>
+        </el-form-item>
+        <template v-for="(item, idx) in form.specialList" :key="item.name + '-' + idx">
+          <template v-if="showSpecialRowTimeUI(item)">
+            <el-form-item :label="item.name">
+              <el-radio-group v-model="item.allDay">
+                <el-radio label="allDay" :disabled="!!specialAllDayDisabledMap[item.name]"> 全天 </el-radio>
+                <el-radio label="notAllDay"> 非全天 </el-radio>
+              </el-radio-group>
+            </el-form-item>
+            <div v-if="item.allDay === 'notAllDay'" class="special-time-row" style="display: flex">
+              <el-form-item label="时间设置" label-width="450px">
+                <el-time-picker
+                  v-model="item.startTime"
+                  format="HH:mm"
+                  value-format="HH:mm"
+                  placeholder="选择开始时间"
+                  style="width: 160px"
+                  :disabled-hours="() => specialDisabledHours()"
+                  :disabled-minutes="(h: number) => specialDisabledMinutes(h)"
+                  @visible-change="v => v && initSpecialPickerRange(item.name, 'start')"
+                  @change="() => onSpecialTimeChange(item.name, 'start')"
+                />
+              </el-form-item>
+              <el-form-item label="" label-width="10px">
+                <el-time-picker
+                  v-model="item.endTime"
+                  format="HH:mm"
+                  value-format="HH:mm"
+                  placeholder="选择结束时间"
+                  style="width: 160px"
+                  :disabled-hours="() => specialDisabledHours()"
+                  :disabled-minutes="(h: number) => specialDisabledMinutes(h)"
+                  @visible-change="v => v && initSpecialPickerRange(item.name, 'end')"
+                  @change="() => onSpecialTimeChange(item.name, 'end')"
+                />
+              </el-form-item>
+            </div>
+          </template>
+        </template>
+      </el-form>
+    </div>
+    <div style="display: flex; justify-content: center; width: 100%">
+      <el-button type="primary" :loading="saveLoading" @click="onSave"> 保存 </el-button>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, computed, onMounted, watch } from "vue";
+import { ElMessage } from "element-plus";
+import { InfoFilled } from "@element-plus/icons-vue";
+import { bookingSettingsDetail, bookingSettingsSave, getBookingBusinessHours } from "@/api/modules/scheduledService";
+import { getStoreInfoBusinessHours, getHolidayList } from "@/api/modules/storeDecoration";
+import { localGet } from "@/utils";
+
+const saveLoading = ref(false);
+
+/** 数字输入:仅保留数字、限制位数,清空为 0 */
+function onIntInput(raw: string, maxLen: number, set: (n: number) => void) {
+  const d = String(raw).replace(/\D/g, "").slice(0, maxLen);
+  set(d === "" ? 0 : parseInt(d, 10));
+}
+
+const form = reactive({
+  base: {
+    id: undefined as number | string | undefined,
+    keepPosition: "keep" as "keep" | "notKeep",
+    retentionMinutes: 30 as number,
+    bookDateDays: 7 as number,
+    maxCapacityPerSlot: 10 as number
+  },
+  booking: {
+    feeType: "free" as "free" | "paid",
+    bookAmount: 0 as number,
+    refundAdvanceHours: 24 as number,
+    noBookMinutesBeforeClose: 30 as number
+  },
+  normalBook: {
+    timeType: "allDay" as "allDay" | "notAllDay",
+    startTime: "" as string,
+    endTime: "" as string,
+    normalId: 0 as number
+  },
+  specialList: [] as {
+    name: string;
+    allDay: "allDay" | "notAllDay";
+    startTime: string;
+    endTime: string;
+    id?: number;
+    essentialId?: number | string;
+    /** 与商家端:用户在选择节日中勾选的行才展示时间配置;接口回显匹配上的也为 true */
+    userPickedSpecial?: boolean;
+  }[]
+});
+
+const listFromStoreInfo = ref<any[]>([]);
+const listBooking = ref<any[]>([]);
+const holidayOptions = ref<{ name: string }[]>([]);
+
+const normalStartTimeValue = ref<string>("");
+const normalEndTimeValue = ref<string>("");
+
+watch(
+  () => form.normalBook.startTime,
+  v => {
+    normalStartTimeValue.value = v || "";
+  },
+  { immediate: true }
+);
+watch(
+  () => form.normalBook.endTime,
+  v => {
+    normalEndTimeValue.value = v || "";
+  },
+  { immediate: true }
+);
+watch(normalStartTimeValue, v => {
+  form.normalBook.startTime = v || "";
+});
+watch(normalEndTimeValue, v => {
+  form.normalBook.endTime = v || "";
+});
+
+watch(
+  () => form.normalBook.startTime,
+  v => {
+    if (form.normalBook.timeType !== "notAllDay" || !v || !form.normalBook.endTime) return;
+    const st = timeStrToMinutes(v);
+    const et = timeStrToMinutes(form.normalBook.endTime);
+    if (et >= st) return;
+    const s = minutesToTimeStr(st);
+    normalEndTimeValue.value = s;
+    form.normalBook.endTime = s;
+  }
+);
+
+const allowedFestivalSet = computed(() => {
+  const set = new Set<string>();
+  listFromStoreInfo.value
+    .filter(item => item.businessType === 2)
+    .forEach(item => {
+      const name =
+        (item.holidayInfo && item.holidayInfo.festivalName != null ? String(item.holidayInfo.festivalName).trim() : "") ||
+        (item.festivalName != null ? String(item.festivalName).trim() : "") ||
+        (item.businessDate != null ? String(item.businessDate).trim() : "") ||
+        (item.holidayType != null ? String(item.holidayType).trim() : "");
+      if (name) set.add(name);
+    });
+  return set;
+});
+
+/** 门店单条 businessType=2:均为 00:00 为全天,否则非全天;元旦固定非全天(与商家端 infoManagement) */
+function allDayFromStoreSpecialRow(storeRow: any): "allDay" | "notAllDay" {
+  if (!storeRow || Number(storeRow.businessType) !== 2) return "notAllDay";
+  const nm = String(
+    (storeRow.holidayInfo && storeRow.holidayInfo.festivalName) || storeRow.businessDate || storeRow.holidayType || ""
+  ).trim();
+  if (nm === "元旦") return "notAllDay";
+  const st = String(storeRow.startTime ?? "").trim();
+  const et = String(storeRow.endTime ?? "").trim();
+  return st === "00:00" && et === "00:00" ? "allDay" : "notAllDay";
+}
+
+function allDayFromStoreByHolidayName(holidayName: string): "allDay" | "notAllDay" {
+  const n = String(holidayName || "").trim();
+  if (!n) return "allDay";
+  const list = Array.isArray(listFromStoreInfo.value) ? listFromStoreInfo.value : [];
+  const row = list.find((item: any) => {
+    if (Number(item.businessType) !== 2) return false;
+    const fn = (item.holidayInfo && item.holidayInfo.festivalName != null ? String(item.holidayInfo.festivalName) : "").trim();
+    const bd = item.businessDate != null ? String(item.businessDate).trim() : "";
+    const ht = item.holidayType != null ? String(item.holidayType).trim() : "";
+    return fn === n || bd === n || ht === n;
+  });
+  return row ? allDayFromStoreSpecialRow(row) : "allDay";
+}
+
+function getStoreSpecialRowMeta(name: string): { id?: number; essentialId?: number | string } {
+  const n = String(name || "").trim();
+  const list = Array.isArray(listFromStoreInfo.value) ? listFromStoreInfo.value : [];
+  const row = list.find((item: any) => {
+    if (Number(item.businessType) !== 2) return false;
+    const fn = (item.holidayInfo && item.holidayInfo.festivalName != null ? String(item.holidayInfo.festivalName) : "").trim();
+    const bd = item.businessDate != null ? String(item.businessDate).trim() : "";
+    const ht = item.holidayType != null ? String(item.holidayType).trim() : "";
+    return fn === n || bd === n || ht === n;
+  });
+  if (!row) return {};
+  return { id: row.id != null ? Number(row.id) : undefined, essentialId: row.essentialId };
+}
+
+function rowHasSpecialTimeConfigured(s: (typeof form.specialList)[0]): boolean {
+  const st = String(s.startTime ?? "").trim();
+  const et = String(s.endTime ?? "").trim();
+  if (s.allDay === "allDay" && st === "00:00" && et === "00:00") return true;
+  return st !== "" && et !== "";
+}
+
+function showSpecialRowTimeUI(s: (typeof form.specialList)[0]): boolean {
+  return rowHasSpecialTimeConfigured(s) || s.userPickedSpecial === true;
+}
+
+const selectedHolidayNames = computed({
+  get: () => form.specialList.map(s => s.name),
+  set: (val: string[]) => {
+    const existingMap: Record<string, (typeof form.specialList)[0]> = {};
+    form.specialList.forEach(s => {
+      existingMap[s.name] = s;
+    });
+    form.specialList = val.map(name => {
+      const ex = existingMap[name];
+      const ad = allDayFromStoreByHolidayName(name);
+      const meta = getStoreSpecialRowMeta(name);
+      if (ex) {
+        return { ...ex, allDay: ad, userPickedSpecial: true };
+      }
+      return {
+        name,
+        allDay: ad,
+        startTime: "",
+        endTime: "",
+        userPickedSpecial: true,
+        id: meta.id,
+        essentialId: meta.essentialId
+      };
+    });
+  }
+});
+
+function getStoreId(): number | string | null {
+  return localGet("geeker-user")?.userInfo?.storeId ?? localGet("createdId") ?? null;
+}
+
+function subtractMinutesFromTime(timeStr: string, minutes: number): string {
+  const [h, m] = (timeStr || "00:00").split(":").map(Number);
+  let total = (h || 0) * 60 + (m || 0);
+  total = Math.max(0, total - (Number(minutes) || 0));
+  const nh = Math.floor(total / 60) % 24;
+  const nm = total % 60;
+  return `${String(nh).padStart(2, "0")}:${String(nm).padStart(2, "0")}`;
+}
+
+function timeStrToMinutes(timeStr: string): number {
+  const [h, m] = String(timeStr || "00:00")
+    .split(":")
+    .map(Number);
+  return Math.max(0, Math.min(23 * 60 + 59, (h || 0) * 60 + (m || 0)));
+}
+
+function minutesToTimeStr(total: number): string {
+  const t = Math.max(0, Math.min(23 * 60 + 59, total));
+  const h = Math.floor(t / 60);
+  const m = t % 60;
+  return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
+}
+
+/** 与 merchant:填写了「结束前不可预订」则结束时间可选上限 = 营业 end − 该分钟数 */
+function getNoBookMinutesForPicker(): number {
+  const v = form.booking.noBookMinutesBeforeClose;
+  if (v == null) return 0;
+  const n = Number(String(v).trim());
+  return n > 0 && !isNaN(n) ? n : 0;
+}
+
+function computePickerEndMinutes(endTimeRaw: string): number {
+  const noBook = getNoBookMinutesForPicker();
+  return timeStrToMinutes(subtractMinutesFromTime(endTimeRaw || "23:59", noBook));
+}
+
+function getNormalWindowForPicker() {
+  const store = (Array.isArray(listFromStoreInfo.value) ? listFromStoreInfo.value : []).find((x: any) => x.businessType === 1);
+  let startTime = store?.startTime != null && String(store.startTime).trim() !== "" ? String(store.startTime).trim() : "00:00";
+  let endTimeRaw = store?.endTime != null && String(store.endTime).trim() !== "" ? String(store.endTime).trim() : "23:59";
+  if (startTime === "00:00" && endTimeRaw === "00:00") {
+    endTimeRaw = "23:59";
+  }
+  return { startTime, endTimeRaw };
+}
+
+function getSpecialWindowForPicker(holidayName: string) {
+  const n = String(holidayName || "").trim();
+  const storeList = Array.isArray(listFromStoreInfo.value) ? listFromStoreInfo.value : [];
+  const storeRow = storeList.find(
+    (s: any) =>
+      s.businessType === 2 &&
+      ((s.holidayInfo && String(s.holidayInfo.festivalName || "").trim()) === n ||
+        String(s.businessDate || "").trim() === n ||
+        String(s.holidayType || "").trim() === n)
+  );
+  let startTime =
+    storeRow?.startTime != null && String(storeRow.startTime).trim() !== "" ? String(storeRow.startTime).trim() : "00:00";
+  let endTimeRaw =
+    storeRow?.endTime != null && String(storeRow.endTime).trim() !== "" ? String(storeRow.endTime).trim() : "23:59";
+  if (startTime === "00:00" && endTimeRaw === "00:00") {
+    endTimeRaw = "23:59";
+  }
+  return { startTime, endTimeRaw };
+}
+
+function buildDisabledHours(sm: number, em: number): number[] {
+  const arr: number[] = [];
+  for (let h = 0; h < 24; h++) {
+    const lo = h * 60;
+    const hi = h * 60 + 59;
+    if (hi < sm || lo > em) arr.push(h);
+  }
+  return arr;
+}
+
+function buildDisabledMinutes(hour: number, sm: number, em: number): number[] {
+  const arr: number[] = [];
+  const startH = Math.floor(sm / 60);
+  const endH = Math.floor(em / 60);
+  if (hour < startH || hour > endH) {
+    return Array.from({ length: 60 }, (_, i) => i);
+  }
+  let minM = 0;
+  let maxM = 59;
+  if (startH === endH) {
+    minM = sm % 60;
+    maxM = em % 60;
+  } else if (hour === startH) {
+    minM = sm % 60;
+  } else if (hour === endH) {
+    maxM = em % 60;
+  }
+  for (let m = 0; m < 60; m++) {
+    if (m < minM || m > maxM) arr.push(m);
+  }
+  return arr;
+}
+
+const normalStartPickSm = ref(0);
+const normalStartPickEm = ref(23 * 60 + 59);
+const normalEndPickSm = ref(0);
+const normalEndPickEm = ref(23 * 60 + 59);
+const specialPickSm = ref(0);
+const specialPickEm = ref(23 * 60 + 59);
+
+function initNormalStartPickerRange() {
+  const { startTime, endTimeRaw } = getNormalWindowForPicker();
+  let sm = timeStrToMinutes(startTime || "00:00");
+  let em = computePickerEndMinutes(endTimeRaw);
+  if (em < sm) em = 23 * 60 + 59;
+  normalStartPickSm.value = sm;
+  normalStartPickEm.value = em;
+}
+
+function initNormalEndPickerRange() {
+  const { startTime, endTimeRaw } = getNormalWindowForPicker();
+  let sm = timeStrToMinutes(startTime || "00:00");
+  let em = computePickerEndMinutes(endTimeRaw);
+  if (em < sm) em = 23 * 60 + 59;
+  const stSel = timeStrToMinutes(form.normalBook.startTime || "00:00");
+  sm = Math.max(sm, stSel);
+  if (sm > em) em = sm;
+  normalEndPickSm.value = sm;
+  normalEndPickEm.value = em;
+}
+
+function initSpecialPickerRange(holidayName: string, key: "start" | "end") {
+  const { startTime, endTimeRaw } = getSpecialWindowForPicker(holidayName);
+  let sm = timeStrToMinutes(startTime || "00:00");
+  let em = computePickerEndMinutes(endTimeRaw);
+  if (em < sm) em = 23 * 60 + 59;
+  if (key === "end") {
+    const item = form.specialList.find(s => s.name === holidayName);
+    const stSel = timeStrToMinutes(item?.startTime || "00:00");
+    sm = Math.max(sm, stSel);
+    if (sm > em) em = sm;
+  }
+  specialPickSm.value = sm;
+  specialPickEm.value = em;
+}
+
+function normalStartDisabledHours() {
+  return buildDisabledHours(normalStartPickSm.value, normalStartPickEm.value);
+}
+function normalStartDisabledMinutes(hour: number) {
+  return buildDisabledMinutes(hour, normalStartPickSm.value, normalStartPickEm.value);
+}
+function normalEndDisabledHours() {
+  return buildDisabledHours(normalEndPickSm.value, normalEndPickEm.value);
+}
+function normalEndDisabledMinutes(hour: number) {
+  return buildDisabledMinutes(hour, normalEndPickSm.value, normalEndPickEm.value);
+}
+function specialDisabledHours() {
+  return buildDisabledHours(specialPickSm.value, specialPickEm.value);
+}
+function specialDisabledMinutes(hour: number) {
+  return buildDisabledMinutes(hour, specialPickSm.value, specialPickEm.value);
+}
+
+function onNormalStartTimeChange(val: string | null) {
+  if (val == null || val === "") return;
+  let vm = timeStrToMinutes(val);
+  vm = Math.max(normalStartPickSm.value, Math.min(normalStartPickEm.value, vm));
+  const s = minutesToTimeStr(vm);
+  normalStartTimeValue.value = s;
+  form.normalBook.startTime = s;
+}
+
+function onNormalEndTimeChange(val: string | null) {
+  if (val == null || val === "") return;
+  let vm = timeStrToMinutes(val);
+  vm = Math.max(normalEndPickSm.value, Math.min(normalEndPickEm.value, vm));
+  const s = minutesToTimeStr(vm);
+  normalEndTimeValue.value = s;
+  form.normalBook.endTime = s;
+}
+
+function onSpecialTimeChange(holidayName: string, key: "start" | "end") {
+  const item = form.specialList.find(s => s.name === holidayName);
+  if (!item) return;
+  const field = key === "start" ? "startTime" : "endTime";
+  const val = item[field];
+  if (val == null || val === "") return;
+  let vm = timeStrToMinutes(val);
+  vm = Math.max(specialPickSm.value, Math.min(specialPickEm.value, vm));
+  item[field] = minutesToTimeStr(vm);
+}
+
+/** 门店正常营业非 00:00–00:00 时不可选「全天」(与 merchant) */
+const normalAllDayDisabled = computed(() => {
+  const list = Array.isArray(listFromStoreInfo.value) ? listFromStoreInfo.value : [];
+  const normal = list.find((item: any) => item.businessType === 1 || item.businessType === 0);
+  if (!normal) return false;
+  const st = String(normal.startTime || "").trim();
+  const et = String(normal.endTime || "").trim();
+  return st !== "00:00" || et !== "00:00";
+});
+
+/** 各节日门店配置非 00:00–00:00 时该行不可选「全天」 */
+const specialAllDayDisabledMap = computed(() => {
+  const list = Array.isArray(listFromStoreInfo.value) ? listFromStoreInfo.value : [];
+  const map: Record<string, boolean> = {};
+  list
+    .filter((item: any) => item.businessType === 2)
+    .forEach((s: any) => {
+      const name = (s.holidayInfo && s.holidayInfo.festivalName) || s.businessDate || s.holidayType || "";
+      if (name) {
+        const st = String(s.startTime || "").trim();
+        const et = String(s.endTime || "").trim();
+        map[String(name)] = st !== "00:00" || et !== "00:00";
+      }
+    });
+  return map;
+});
+
+async function loadHolidayOptions() {
+  try {
+    const year = new Date().getFullYear();
+    const res: any = await getHolidayList({
+      holidayName: "",
+      openFlag: 1,
+      year,
+      page: 1,
+      size: 100
+    });
+    const records = res?.data?.records ?? res?.records ?? [];
+    holidayOptions.value = records
+      .map((record: any) => ({
+        name: record.festivalName ?? record.holidayName ?? record.name ?? ""
+      }))
+      .filter((x: { name: string }) => x.name);
+  } catch {
+    holidayOptions.value = [];
+  }
+}
+
+async function fetchStoreInfoBusinessHours() {
+  const storeId = getStoreId();
+  if (!storeId) return;
+  try {
+    const res: any = await getStoreInfoBusinessHours({ id: storeId });
+    const list = Array.isArray(res?.data) ? res.data : [];
+    listFromStoreInfo.value = list;
+    const normal = list.find((item: any) => item.businessType === 1);
+    /** 与商家端:特殊营业由 getBookingBusinessHours + applyListBookingToForm 回填,此处不占位 */
+    form.specialList = [];
+    if (normal && !form.normalBook.startTime && !form.normalBook.endTime) {
+      form.normalBook.startTime = normal.startTime || "";
+      form.normalBook.endTime = normal.endTime || "23:59";
+      const isAllDay = form.normalBook.startTime === "00:00" && form.normalBook.endTime === "00:00";
+      form.normalBook.timeType = isAllDay ? "allDay" : "notAllDay";
+    }
+  } catch {
+    listFromStoreInfo.value = [];
+  }
+}
+
+function applyListBookingToForm() {
+  const list = Array.isArray(listBooking.value) ? listBooking.value : [];
+  const noBookMin = Number(form.booking.noBookMinutesBeforeClose) || 0;
+
+  if (list.length > 0) {
+    const normal = list.find((item: any) => item.businessType === 1 || item.businessType === 0);
+    if (normal) {
+      const st = normal.startTime || "";
+      const et = normal.endTime || "23:59";
+      form.normalBook.timeType = st === "00:00" && et === "00:00" ? "allDay" : "notAllDay";
+      form.normalBook.startTime = st;
+      form.normalBook.endTime = subtractMinutesFromTime(et, noBookMin);
+      form.normalBook.normalId = Number(normal.id) || 0;
+    }
+  }
+
+  const storeInfoList = Array.isArray(listFromStoreInfo.value) ? listFromStoreInfo.value : [];
+  const specialFromStore = storeInfoList.filter((item: any) => item.businessType === 2);
+  const specialFromBooking = list.filter((item: any) => item.businessType === 2);
+  const bookingEmpty = !list.length;
+  const useStoreInfoForSpecial = !list.length || !specialFromBooking.length;
+
+  /** 预约接口返回的 holidayType 集合;仅在此集合内的门店节日才回显预订时间(与商家端 applyListBookingToForm) */
+  const bookingHolidayTypeSet = new Set(
+    specialFromBooking.map((b: any) => (b.holidayType != null ? String(b.holidayType).trim() : "")).filter(Boolean)
+  );
+
+  if (useStoreInfoForSpecial) {
+    if (bookingEmpty) {
+      form.specialList = [];
+      return;
+    }
+    /** 有预约数据但未配置特殊营业行:不占位,用户先选节日 */
+    form.specialList = [];
+    return;
+  }
+
+  /** 仅 getBookingBusinessHours 中 holidayType 与门店节日匹配时回显时间;allDay 以门店营业时间为准 */
+  form.specialList = specialFromStore.map((s: any, index: number) => {
+    const name = (s.holidayInfo && s.holidayInfo.festivalName) || s.businessDate || s.holidayType || `特殊营业${index + 1}`;
+    const storeFestivalName = (
+      s.holidayInfo && s.holidayInfo.festivalName != null ? String(s.holidayInfo.festivalName) : ""
+    ).trim();
+    const shouldEchoTime = bookingHolidayTypeSet.has(storeFestivalName);
+    const match = specialFromBooking.find(
+      (b: any) =>
+        String(b.id) === String(s.id) ||
+        String(b.essentialId) === String(s.essentialId) ||
+        (storeFestivalName !== "" && (b.holidayType != null ? String(b.holidayType) : "").trim() === storeFestivalName)
+    );
+    if (!match || !shouldEchoTime) {
+      return {
+        name,
+        allDay: allDayFromStoreSpecialRow(s),
+        startTime: "",
+        endTime: "",
+        id: s.id,
+        essentialId: s.essentialId,
+        userPickedSpecial: false
+      };
+    }
+    const startTime = match.startTime || "";
+    const endTimeRaw = match.endTime || "23:59";
+    const endTime = endTimeRaw;
+    return {
+      name,
+      allDay: allDayFromStoreSpecialRow(s),
+      startTime,
+      endTime,
+      id: s.id,
+      essentialId: s.essentialId,
+      userPickedSpecial: true
+    };
+  });
+}
+
+async function fetchBookingBusinessHours() {
+  const settingsId = form.base.id;
+  if (settingsId == null || settingsId === undefined) return;
+  try {
+    const res: any = await getBookingBusinessHours({ settingsId });
+    const list = Array.isArray(res?.data) ? res.data : [];
+    listBooking.value = list;
+    applyListBookingToForm();
+  } catch {
+    listBooking.value = [];
+  }
+}
+
+async function getInfoSettings() {
+  const storeId = getStoreId();
+  if (!storeId) return;
+  try {
+    const res: any = await bookingSettingsDetail({ storeId: Number(storeId) });
+    const d = res?.data ?? res ?? {};
+    form.base.id = d.id;
+    form.base.keepPosition = d.retainPositionFlag === 1 ? "keep" : "notKeep";
+    form.base.retentionMinutes = Number(d.retentionDuration) || 30;
+    form.base.bookDateDays = Number(d.bookingDateDisplayDays) || 7;
+    form.base.maxCapacityPerSlot = Number(d.maxCapacityPerSlot) || 10;
+
+    form.booking.feeType = d.reservation === "1" ? "paid" : "free";
+    form.booking.bookAmount = Number(d.reservationMoney) || 0;
+    form.booking.refundAdvanceHours = Number(d.offUnsubscribeHours) || 24;
+    form.booking.noBookMinutesBeforeClose = Number(d.bookingNotAvailableTime) || 30;
+  } catch {
+    // 无数据时使用默认值
+  }
+}
+
+function validate(): boolean {
+  if (form.base.keepPosition === "keep") {
+    const n = Number(form.base.retentionMinutes);
+    if (!n || n < 1 || n > 99) {
+      ElMessage.warning("保留时长(分钟)为1-99的整数");
+      return false;
+    }
+  }
+  const bookDateNum = Number(form.base.bookDateDays);
+  if (!bookDateNum || bookDateNum < 1 || bookDateNum > 99) {
+    ElMessage.warning("预订日期显示(天)为1-99的整数");
+    return false;
+  }
+  const maxCap = Number(form.base.maxCapacityPerSlot);
+  if (!maxCap || maxCap < 1 || maxCap > 9999) {
+    ElMessage.warning("单时段最大容纳人数为1-9999的整数");
+    return false;
+  }
+  if (form.booking.feeType === "paid") {
+    if (!Number(form.booking.bookAmount) || Number(form.booking.bookAmount) < 1 || Number(form.booking.bookAmount) > 999) {
+      ElMessage.warning("预订金额(元)为1-999的整数");
+      return false;
+    }
+    if (
+      !Number(form.booking.refundAdvanceHours) ||
+      Number(form.booking.refundAdvanceHours) < 1 ||
+      Number(form.booking.refundAdvanceHours) > 999
+    ) {
+      ElMessage.warning("取消预订退费需提前(小时)为1-999的整数");
+      return false;
+    }
+  }
+  if (
+    !Number(form.booking.noBookMinutesBeforeClose) ||
+    Number(form.booking.noBookMinutesBeforeClose) < 1 ||
+    Number(form.booking.noBookMinutesBeforeClose) > 999
+  ) {
+    ElMessage.warning("营业时间结束前多少分钟不可预订为1-999的整数");
+    return false;
+  }
+  if (form.normalBook.timeType === "notAllDay") {
+    if (!form.normalBook.startTime || !form.normalBook.endTime) {
+      ElMessage.warning("请设置正常营业时间");
+      return false;
+    }
+  }
+  return true;
+}
+
+async function onSave() {
+  if (!validate()) return;
+  const storeId = getStoreId();
+  if (!storeId) {
+    ElMessage.warning("未找到门店信息");
+    return;
+  }
+
+  saveLoading.value = true;
+  try {
+    const businessDate = listFromStoreInfo.value.find((item: any) => item.businessType === 1)?.businessDate;
+    const specialListBuilt: any[] = [];
+    let sort = 0;
+    for (const cur of form.specialList) {
+      if (!showSpecialRowTimeUI(cur)) continue;
+      const isAllDay = cur.allDay === "allDay";
+      const st = (cur.startTime || "").trim();
+      const et = (cur.endTime || "").trim();
+      if (!isAllDay && !st && !et) continue;
+      specialListBuilt.push({
+        bookingTimeType: isAllDay ? 1 : 0,
+        businessType: 2,
+        holidayType: cur.name || "",
+        startTime: cur.startTime || "00:00",
+        endTime: cur.endTime || "00:00",
+        sort: sort++,
+        essentialId: cur.essentialId
+      });
+    }
+    const params: Record<string, any> = {
+      storeId: Number(storeId),
+      retainPositionFlag: form.base.keepPosition === "keep" ? 1 : 0,
+      retentionDuration: Number(form.base.retentionMinutes) || 0,
+      bookingDateDisplayDays: Number(form.base.bookDateDays) || 0,
+      maxCapacityPerSlot: Number(form.base.maxCapacityPerSlot) || 0,
+      reservation: form.booking.feeType === "free" ? "0" : "1",
+      reservationMoney: form.booking.feeType === "paid" ? Number(form.booking.bookAmount) || 0 : 0,
+      offUnsubscribeHours: Number(form.booking.refundAdvanceHours) || 0,
+      bookingNotAvailableTime: Number(form.booking.noBookMinutesBeforeClose) || 0,
+      normalBusinessHours: {
+        businessType: 1,
+        holidayType: businessDate,
+        bookingTimeType: form.normalBook.timeType === "allDay" ? 1 : 0,
+        startTime: form.normalBook.startTime || "",
+        endTime: form.normalBook.endTime || ""
+      }
+    };
+    if (specialListBuilt.length > 0) {
+      params.specialBusinessHoursList = specialListBuilt;
+    }
+    await bookingSettingsSave(params);
+    ElMessage.success("保存成功");
+  } catch (e: any) {
+    ElMessage.error(e?.message || "保存失败");
+  } finally {
+    saveLoading.value = false;
+  }
+}
+
+onMounted(async () => {
+  await loadHolidayOptions();
+  await getInfoSettings();
+  await fetchStoreInfoBusinessHours();
+  await fetchBookingBusinessHours();
+});
+</script>
+
+<style scoped lang="scss">
+.info-management {
+  max-width: 100%;
+  padding: 16px 20px 80px;
+  margin: 0 auto;
+}
+.page-title {
+  margin-bottom: 20px;
+  font-size: 18px;
+  font-weight: 600;
+  color: #303133;
+}
+.form-card {
+  padding: 20px 24px;
+  margin-bottom: 16px;
+  background: #ffffff;
+  border-radius: 8px;
+  box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
+}
+.section-title {
+  display: flex;
+  align-items: center;
+  margin-bottom: 16px;
+  font-size: 15px;
+  font-weight: 600;
+  color: #303133;
+  .section-bar {
+    width: 4px;
+    height: 16px;
+    margin-right: 10px;
+    background: #6c8ff8;
+    border-radius: 2px;
+  }
+}
+.section-form {
+  :deep(.el-form-item) {
+    margin-bottom: 18px;
+  }
+  :deep(.el-form-item__label) {
+    align-items: center;
+    font-weight: 400;
+    line-height: 20px;
+    color: #606266;
+  }
+}
+.form-input {
+  width: 40%;
+  max-width: 100%;
+}
+.time-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+  align-items: center;
+  .time-tip {
+    margin-left: 4px;
+    color: #909399;
+  }
+}
+.special-time-row {
+  padding-left: 0;
+  margin-bottom: 12px;
+  margin-left: 0;
+  .info-icon {
+    margin-left: 6px;
+    color: #909399;
+    vertical-align: middle;
+  }
+  .next-day-tag {
+    margin-left: 8px;
+    font-size: 12px;
+    color: #909399;
+  }
+}
+.footer-actions {
+  position: fixed;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  padding: 12px 20px;
+  background: #ffffff;
+  box-shadow: 0 -1px 4px rgb(0 0 0 / 6%);
+  .el-button {
+    display: block;
+    width: 100%;
+    max-width: 720px;
+    margin: 0 auto;
+  }
+}
+</style>

+ 402 - 0
src/views/appoinmentManagement/tableManagement.vue

@@ -0,0 +1,402 @@
+<template>
+  <div class="table-box table-management">
+    <!-- 筛选(与原先一致:搜索/重置后再请求列表) -->
+    <div class="filter-bar">
+      <div class="filter-row">
+        <span class="filter-label">位置</span>
+        <el-select v-model="filterLocation" placeholder="请选择" clearable style="width: 200px">
+          <el-option v-for="item in categoryOptions" :key="item.id" :label="item.name" :value="item.id" />
+        </el-select>
+        <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">
+      <template #tableHeader>
+        <div class="table-header">
+          <el-button type="primary" @click="handleNew"> 新建 </el-button>
+        </div>
+      </template>
+      <template #operation="scope">
+        <el-button link type="primary" @click="handleEdit(scope.row)"> 编辑 </el-button>
+        <el-button link type="primary" @click="handleDelete(scope.row)"> 删除 </el-button>
+      </template>
+    </ProTable>
+
+    <!-- 新建/编辑弹窗 -->
+    <el-dialog
+      v-model="dialogVisible"
+      :title="editId == null ? '新建桌位' : '编辑桌位'"
+      width="480px"
+      append-to-body
+      destroy-on-close
+      @close="onDialogClose"
+    >
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="80px" v-loading="detailLoading">
+        <el-form-item label="桌号" prop="tableNo" required>
+          <el-input v-model="form.tableNo" placeholder="请输入桌号" clearable maxlength="20" />
+        </el-form-item>
+        <el-form-item label="座位数" prop="seatCount" required>
+          <el-input
+            :model-value="String(form.seatCount || '')"
+            placeholder="请输入"
+            style="width: 100%"
+            @update:model-value="
+              (val: string) => {
+                const n = parseInt(val, 10);
+                form.seatCount = isNaN(n) ? 0 : n;
+              }
+            "
+          />
+        </el-form-item>
+        <el-form-item label="位置" prop="categoryId" required>
+          <el-select v-model="form.categoryId" placeholder="请选择位置" clearable style="width: 100%">
+            <el-option v-for="item in categoryOptions" :key="item.id" :label="item.name" :value="item.id" />
+          </el-select>
+        </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">
+import { ref, reactive, onMounted } from "vue";
+import { ElMessage, ElMessageBox } from "element-plus";
+import type { FormInstance, FormRules } from "element-plus";
+import ProTable from "@/components/ProTable/index.vue";
+import type { ProTableInstance, ColumnProps } from "@/components/ProTable/interface";
+import {
+  scheduleList,
+  tableList,
+  tableDetail,
+  tableAdd,
+  tableEdit,
+  tableDel,
+  getTableHasReservation
+} from "@/api/modules/scheduledService";
+import { localGet } from "@/utils";
+
+export interface TableRow {
+  id: number | string;
+  _seq?: number;
+  tableNo: string;
+  seatCount: number;
+  categoryId?: number | string;
+  locationName?: string;
+}
+
+const proTable = ref<ProTableInstance>();
+
+const columns: ColumnProps<TableRow>[] = [
+  { prop: "_seq", label: "序号", width: 60, align: "center" },
+  { prop: "tableNo", label: "桌号" },
+  { prop: "seatCount", label: "座位数", align: "center" },
+  { prop: "locationName", label: "位置" },
+  { prop: "operation", label: "操作", fixed: "right", width: 140, align: "center" }
+];
+
+const initParam = reactive({
+  storeId: localGet("geeker-user")?.userInfo?.storeId ?? localGet("createdId") ?? ""
+});
+
+/** 列表请求时使用的位置筛选(仅点击「搜索」后生效) */
+const listFilterCategoryId = ref<number | string | undefined>(undefined);
+const filterLocation = ref<number | string | undefined>(undefined);
+const categoryOptions = ref<{ id: number | string; name: string }[]>([]);
+
+function getStoreId(): number | string | null {
+  return localGet("geeker-user")?.userInfo?.storeId ?? localGet("createdId") ?? null;
+}
+
+/** 接口返回 true 表示该桌有预定,不可编辑/删除 */
+function parseHasReservationResponse(res: any): boolean {
+  const body = res?.data ?? res;
+  const val = body?.data !== undefined ? body.data : body;
+  if (typeof val === "boolean") return val;
+  if (val != null && typeof val === "object" && "hasReservation" in val) {
+    return Boolean((val as { hasReservation?: boolean }).hasReservation);
+  }
+  return Boolean(val);
+}
+
+async function tableHasReservation(tableId: number | string): Promise<boolean> {
+  const res: any = await getTableHasReservation({ id: tableId });
+  return parseHasReservationResponse(res);
+}
+
+async function loadCategories() {
+  const storeId = getStoreId();
+  if (!storeId) {
+    categoryOptions.value = [];
+    return;
+  }
+  try {
+    const res: any = await scheduleList({ storeId: Number(storeId), pageNum: 1, pageSize: 500 });
+    const raw = res?.data?.records ?? res?.data ?? res?.list ?? res ?? [];
+    const arr = Array.isArray(raw) ? raw : [];
+    categoryOptions.value = arr.map((item: any) => ({
+      id: item.id,
+      name: item.categoryName ?? item.name ?? ""
+    }));
+  } catch {
+    categoryOptions.value = [];
+  }
+}
+
+function mapTableRow(item: any, categoryMap: Record<string, string>): TableRow {
+  return {
+    id: item.id,
+    tableNo: item.tableNo ?? item.tableNumber ?? item.deskNo ?? "",
+    seatCount: item.seatCount ?? item.seats ?? 0,
+    categoryId: item.categoryId ?? item.category_id,
+    locationName: item.locationName ?? item.categoryName ?? categoryMap[String(item.categoryId ?? item.category_id)] ?? ""
+  };
+}
+
+/** ProTable / useTable 约定:返回 { data: { list, total } } */
+async function getTableList(params: any) {
+  const storeId = params.storeId ?? initParam.storeId;
+  if (!storeId) {
+    return { data: { list: [] as TableRow[], total: 0 } };
+  }
+  if (categoryOptions.value.length === 0) {
+    await loadCategories();
+  }
+  const pageNum = params.pageNum ?? 1;
+  const pageSize = params.pageSize ?? 10;
+  const categoryMap = Object.fromEntries(categoryOptions.value.map(c => [String(c.id), c.name]));
+  const req: {
+    storeId: number;
+    pageNum: number;
+    pageSize: number;
+    categoryId?: number | string;
+  } = {
+    storeId: Number(storeId),
+    pageNum,
+    pageSize
+  };
+  if (listFilterCategoryId.value != null && listFilterCategoryId.value !== "") {
+    req.categoryId = listFilterCategoryId.value;
+  }
+  try {
+    const res: any = await tableList(req);
+    const body = res?.data ?? res;
+    const inner = body?.data ?? body;
+    const raw = inner?.records ?? inner?.list ?? (Array.isArray(inner) ? inner : []);
+    const arr = Array.isArray(raw) ? raw : [];
+    const total = inner?.total ?? body?.total ?? inner?.totalCount ?? 0;
+    const list = arr.map((item: any, i: number) => ({
+      ...mapTableRow(item, categoryMap),
+      _seq: (pageNum - 1) * pageSize + i + 1
+    }));
+    return { data: { list, total: Number(total) || 0 } };
+  } catch (e: any) {
+    ElMessage.error(e?.message || "加载失败");
+    return { data: { list: [] as TableRow[], total: 0 } };
+  }
+}
+
+function handleSearch() {
+  listFilterCategoryId.value = filterLocation.value;
+  proTable.value?.getTableList();
+}
+
+function handleReset() {
+  filterLocation.value = undefined;
+  listFilterCategoryId.value = undefined;
+  proTable.value?.getTableList();
+}
+
+const dialogVisible = ref(false);
+const editId = ref<number | string | null>(null);
+const submitLoading = ref(false);
+const detailLoading = ref(false);
+const formRef = ref<FormInstance>();
+
+const form = reactive<{
+  tableNo: string;
+  seatCount: number;
+  categoryId: number | string | undefined;
+}>({
+  tableNo: "",
+  seatCount: 0,
+  categoryId: undefined
+});
+
+const rules: FormRules = {
+  tableNo: [
+    { required: true, message: "请输入桌号", trigger: "blur" },
+    {
+      pattern: /^[A-Z][0-9]{2}$/,
+      message: "桌号格式为:大写字母+2位数字,如 A01",
+      trigger: "blur"
+    }
+  ],
+  seatCount: [
+    { required: true, message: "请输入座位数", trigger: "blur" },
+    { type: "number", min: 1, max: 99, message: "请输入1-99的整数", trigger: "blur" }
+  ],
+  categoryId: [{ required: true, message: "请选择位置", trigger: "change" }]
+};
+
+function handleNew() {
+  editId.value = null;
+  form.tableNo = "";
+  form.seatCount = 0;
+  form.categoryId = undefined;
+  dialogVisible.value = true;
+}
+
+async function handleEdit(row: TableRow) {
+  try {
+    const has = await tableHasReservation(row.id);
+    if (has) {
+      ElMessage.warning("此分类所属桌号有预定信息,不可修改");
+      return;
+    }
+  } catch (e: any) {
+    ElMessage.error(e?.message || "校验失败,请稍后重试");
+    return;
+  }
+  editId.value = row.id;
+  dialogVisible.value = true;
+  detailLoading.value = true;
+  try {
+    const res: any = await tableDetail({ id: row.id });
+    const d = res?.data ?? res ?? {};
+    const categoryId = d.categoryId ?? d.classifyId ?? undefined;
+    form.categoryId = categoryId != null ? categoryId : (row.categoryId ?? undefined);
+    form.tableNo = d.tableNumber ?? d.tableNo ?? row.tableNo ?? "";
+    form.seatCount = d.seatingCapacity ?? d.seatCount ?? row.seatCount ?? 0;
+    if (typeof form.seatCount === "string") form.seatCount = parseInt(form.seatCount, 10) || 0;
+    if (
+      categoryId != null &&
+      categoryOptions.value.length > 0 &&
+      !categoryOptions.value.find(c => String(c.id) === String(categoryId))
+    ) {
+      await loadCategories();
+    }
+  } catch (e: any) {
+    ElMessage.error(e?.message || "获取详情失败");
+    form.tableNo = row.tableNo;
+    form.seatCount = row.seatCount;
+    form.categoryId = row.categoryId ?? undefined;
+  } finally {
+    detailLoading.value = false;
+  }
+}
+
+async function handleDelete(row: TableRow) {
+  try {
+    const has = await tableHasReservation(row.id);
+    if (has) {
+      ElMessage.warning("此分类所属桌号有预定信息,不可修改");
+      return;
+    }
+  } catch (e: any) {
+    ElMessage.error(e?.message || "校验失败,请稍后重试");
+    return;
+  }
+  ElMessageBox.confirm(`是否确认删除?`, "提示", {
+    confirmButtonText: "确定",
+    cancelButtonText: "取消",
+    type: "warning"
+  })
+    .then(async () => {
+      try {
+        await tableDel({ id: row.id });
+        ElMessage.success("删除成功");
+        proTable.value?.getTableList();
+      } catch (e: any) {
+        ElMessage.error(e?.message || "删除失败");
+      }
+    })
+    .catch(() => {});
+}
+
+function onDialogClose() {
+  editId.value = null;
+  form.tableNo = "";
+  form.seatCount = 0;
+  form.categoryId = undefined;
+}
+
+async function submitForm() {
+  if (!formRef.value) return;
+  try {
+    await formRef.value.validate();
+  } catch {
+    return;
+  }
+  const storeId = getStoreId();
+  if (!storeId) {
+    ElMessage.warning("未找到门店信息");
+    return;
+  }
+  submitLoading.value = true;
+  try {
+    const tableNumber = form.tableNo.trim();
+    const seatingCapacity = Number(form.seatCount) || 0;
+    if (editId.value != null) {
+      await tableEdit({
+        id: editId.value,
+        storeId,
+        categoryId: form.categoryId,
+        tableNumber,
+        seatingCapacity
+      });
+    } else {
+      await tableAdd({
+        storeId,
+        categoryId: form.categoryId,
+        tables: [{ tableNumber, seatingCapacity }]
+      });
+    }
+    ElMessage.success(editId.value == null ? "添加成功" : "修改成功");
+    dialogVisible.value = false;
+    onDialogClose();
+    proTable.value?.getTableList();
+  } catch (e: any) {
+    ElMessage.error(e?.message || "保存失败");
+  } finally {
+    submitLoading.value = false;
+  }
+}
+
+onMounted(() => {
+  loadCategories();
+});
+</script>
+
+<style scoped lang="scss">
+.table-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: 32px;
+  font-size: 14px;
+  color: #606266;
+}
+.table-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: 100%;
+  padding: 12px 0;
+}
+</style>

+ 16 - 39
src/views/dynamicManagement/publishDynamic.vue

@@ -132,7 +132,8 @@ import { ArrowLeft, Plus, Location, Search, ZoomIn, Delete } from "@element-plus
 import type { FormInstance, FormRules, UploadUserFile, UploadFile } from "element-plus";
 // import { publishDynamic, saveDraft, uploadDynamicImage } from "@/api/modules/dynamicManagement";
 // import { addOrUpdateDynamic } from "@/api/modules/dynamicManagement";
-import { uploadImg, addOrUpdateDynamic } from "@/api/modules/newLoginApi";
+import { addOrUpdateDynamic } from "@/api/modules/newLoginApi";
+import { uploadFilesToOss } from "@/api/upload.js";
 import { useUserStore } from "@/stores/modules/user";
 import { getUserDraftDynamics, getInputPrompt } from "@/api/modules/newLoginApi";
 
@@ -371,51 +372,27 @@ const uploadSingleFile = async (file: UploadFile) => {
   }
 
   const rawFile = file.raw as File;
-  const uploadFormData = new FormData();
-  uploadFormData.append("file", rawFile);
 
   file.status = "uploading";
   file.percentage = 0;
   uploading.value = true;
 
   try {
-    console.log("开始上传文件:", rawFile.name, "类型:", rawFile.type);
-
-    // 图片和视频都使用同一个上传接口
-    const result: any = await uploadImg(uploadFormData);
-
-    console.log("上传接口返回:", result);
-
-    if (result?.code === 200 && result.data) {
-      let fileUrl = "";
-
-      // 处理不同的返回格式
-      if (Array.isArray(result.data) && result.data.length > 0) {
-        fileUrl = result.data[0]; // 数组格式
-      } else if (typeof result.data === "string") {
-        fileUrl = result.data; // 字符串格式
-      } else if (result.data.url) {
-        fileUrl = result.data.url; // 对象格式
-      }
-
-      if (fileUrl) {
-        file.status = "success";
-        file.percentage = 100;
-        file.url = fileUrl;
-        file.response = { url: fileUrl };
+    const isVideo = rawFile.type.startsWith("video/");
+    const urls = await uploadFilesToOss(rawFile, isVideo ? "video" : "image");
+    const fileUrl = urls[0];
+    if (!fileUrl) {
+      ElMessage.error("上传失败,未返回文件地址");
+      throw new Error("上传失败,未返回文件地址");
+    }
 
-        console.log("上传成功,文件URL:", fileUrl);
+    file.status = "success";
+    file.percentage = 100;
+    file.url = fileUrl;
+    file.response = { url: fileUrl };
 
-        // 添加到 formData.images
-        if (!formData.images.includes(fileUrl)) {
-          formData.images.push(fileUrl);
-          console.log("添加到 images 数组,当前长度:", formData.images.length);
-        }
-      } else {
-        throw new Error("上传接口返回数据格式错误");
-      }
-    } else {
-      throw new Error(result?.msg || "文件上传失败");
+    if (!formData.images.includes(fileUrl)) {
+      formData.images.push(fileUrl);
     }
   } catch (error: any) {
     console.error("上传失败:", error);
@@ -427,7 +404,7 @@ const uploadSingleFile = async (file: UploadFile) => {
     if (index > -1) {
       fileList.value.splice(index, 1);
     }
-    ElMessage.error(error?.message || "文件上传失败");
+    // OSS 网络/签名错误已在 upload.js 中 ElMessage,避免重复弹窗
   } finally {
     uploading.value = false;
     fileList.value = [...fileList.value];

+ 16 - 25
src/views/dynamicManagement/reviewAppeal.vue

@@ -227,7 +227,8 @@ import { useRouter } from "vue-router";
 import { ElMessage } from "element-plus";
 import { User, Plus, VideoPlay } from "@element-plus/icons-vue";
 import type { FormInstance, FormRules, UploadUserFile } from "element-plus";
-import { getList, addAppealNew, saveComment2, uploadImg, getRatingCount } from "@/api/modules/newLoginApi";
+import { getList, addAppealNew, saveComment2, getRatingCount } from "@/api/modules/newLoginApi";
+import { uploadFileToOss } from "@/api/upload.js";
 import { localGet } from "@/utils";
 import { useUserStore } from "@/stores/modules/user";
 
@@ -277,8 +278,7 @@ const currentReviewId = ref("");
 
 const appealFormData = reactive({
   reason: "",
-  images: [] as string[],
-  files: [] as File[], // 保存原始的 File 对象
+  images: [] as string[], // 申诉凭证:OSS 图片 URL(与 fileList 顺序一致)
   fileList: [] as UploadUserFile[]
 });
 const hasStoreReply = (review: any) => {
@@ -558,12 +558,11 @@ const closeAppealDialog = () => {
   Object.assign(appealFormData, {
     reason: "",
     images: [],
-    files: [],
     fileList: []
   });
 };
 
-// 提交申诉(与商家端 addAppealNew 一致:FormData 含 appealReason、storeId、commentId、file_0/file_1...,响应 result: 0成功 1失败 2已存在 3敏感词 4超长
+// 提交申诉(与商家端 addAppealNew 一致:FormData 含 appealReason、storeId、commentId、file_0/file_1...;凭证已为 OSS 地址时按 URL 字符串提交
 const submitAppeal = async () => {
   if (!appealFormRef.value) return;
 
@@ -577,8 +576,8 @@ const submitAppeal = async () => {
       formData.append("appealReason", appealFormData.reason);
       formData.append("storeId", String(storeId));
       formData.append("commentId", String(currentReviewId.value));
-      appealFormData.files.forEach((file: File, index: number) => {
-        formData.append(`file_${index}`, file);
+      appealFormData.images.forEach((url: string, index: number) => {
+        formData.append(`file_${index}`, url);
       });
 
       const res: any = await addAppealNew(formData);
@@ -612,40 +611,32 @@ const handlePreview = (file: UploadUserFile) => {
 
 // 移除图片
 const handleRemove = (file: UploadUserFile, fileList: UploadUserFile[]) => {
-  console.log("remove", file);
-  // 找到对应的索引并删除
   const index = appealFormData.fileList.findIndex(f => f.uid === file.uid);
   if (index !== -1) {
     appealFormData.images.splice(index, 1);
-    appealFormData.files.splice(index, 1);
   }
   appealFormData.fileList = fileList;
 };
 
-// 自定义上传方法
+/** OSS 直传后交给 el-upload 展示网络地址 */
 const handleUpload = async (options: any) => {
   const { file, onSuccess, onError } = options;
 
   try {
-    // 保存原始的 File 对象用于提交时上传
-    appealFormData.files.push(file);
-
-    // 创建本地预览URL
-    const previewUrl = URL.createObjectURL(file);
-    onSuccess({ url: previewUrl });
-
-    // 将预览URL添加到images数组
-    appealFormData.images.push(previewUrl);
-    console.log("图片已添加:", file.name);
+    const url = await uploadFileToOss(file as File, "image");
+    if (!url) {
+      throw new Error("上传失败,未返回地址");
+    }
+    appealFormData.images.push(url);
+    onSuccess({ url });
   } catch (error: any) {
     onError(error);
-    ElMessage.error(error?.msg || "添加图片失败");
+    // 错误提示由 upload.js 内 ElMessage 处理
   }
 };
 
-// 上传成功回调
-const handleUploadSuccess = (response: any, file: any) => {
-  console.log("上传成功回调:", response, file);
+const handleUploadSuccess = (_response: any, _file: any) => {
+  // 列表与 images 已在 handleUpload 中维护
 };
 
 // 初始化

+ 14 - 26
src/views/licenseManagement/entertainmentLicense.vue

@@ -125,11 +125,11 @@ import { Picture, Plus } from "@element-plus/icons-vue";
 import type { UploadProps, UploadFile } from "element-plus";
 import {
   getEntertainmentBusinessLicense,
-  uploadContractImage,
   submitEntertainmentLicenseReview,
   getEntertainmentChangeRecords,
   getStoreEntertainmentLicenceStatus
 } from "@/api/modules/licenseManagement";
+import { uploadFileToOss } from "@/api/upload.js";
 import { localGet } from "@/utils";
 
 // 状态映射对象
@@ -404,38 +404,26 @@ const uploadSingleFile = async (file: UploadFile) => {
     return;
   }
   const rawFile = file.raw as File;
-  const formData = new FormData();
-  formData.append("file", rawFile);
-  formData.append("user", "text");
   file.status = "uploading";
   file.percentage = 0;
   uploading.value = true;
 
   try {
-    // 上传过程中保持进度为 0,避免接口异常时进度条误显示 100%
-    const result: any = await uploadContractImage(formData);
-    if (result?.code === 200 && result.data) {
-      // 处理单个文件的上传结果
-      let imageUrl = result.data[0];
-      if (!imageUrl) {
-        throw new Error("上传成功但未获取到图片URL");
-      }
+    const imageUrl = await uploadFileToOss(rawFile, "image");
+    if (!imageUrl) {
+      throw new Error("上传成功但未获取到图片URL");
+    }
 
-      file.status = "success";
-      file.percentage = 100;
-      // 保存图片URL到文件对象
-      file.url = imageUrl;
-      file.response = { url: imageUrl };
+    file.status = "success";
+    file.percentage = 100;
+    file.url = imageUrl;
+    file.response = { url: imageUrl };
 
-      // 保存图片URL
-      if (!Array.isArray(imageUrlList.value)) {
-        imageUrlList.value = [];
-      }
-      if (!imageUrlList.value.includes(imageUrl)) {
-        imageUrlList.value.push(imageUrl);
-      }
-    } else {
-      throw new Error(result?.msg || "图片上传失败");
+    if (!Array.isArray(imageUrlList.value)) {
+      imageUrlList.value = [];
+    }
+    if (!imageUrlList.value.includes(imageUrl)) {
+      imageUrlList.value.push(imageUrl);
     }
   } catch (error: any) {
     file.status = "fail";

+ 25 - 37
src/views/operationManagement/newActivity.vue

@@ -245,7 +245,7 @@ import { ElMessage, ElMessageBox } from "element-plus";
 import { Plus } from "@element-plus/icons-vue";
 import { useRoute, useRouter } from "vue-router";
 import { addActivity, getActivityDetail, getCouponList, updateActivity } from "@/api/modules/operationManagement";
-import { uploadContractImage } from "@/api/modules/licenseManagement";
+import { uploadFileToOss } from "@/api/upload.js";
 import { localGet } from "@/utils";
 
 // ==================== 响应式数据定义 ====================
@@ -920,49 +920,37 @@ const uploadSingleFile = async (file: UploadFile, uploadType: string) => {
     return;
   }
   const rawFile = file.raw as File;
-  const formData = new FormData();
-  formData.append("file", rawFile);
-  formData.append("user", "text");
   file.status = "uploading";
   file.percentage = 0;
   uploading.value = true;
 
   try {
-    const result: any = await uploadContractImage(formData);
-    if (result?.code === 200 && result.data) {
-      let imageUrl = result.data[0];
-      if (!imageUrl) {
-        throw new Error("上传成功但未获取到图片URL");
-      }
+    const imageUrl = await uploadFileToOss(rawFile, "image");
+    if (!imageUrl) {
+      throw new Error("上传成功但未获取到图片URL");
+    }
 
-      file.status = "success";
-      file.percentage = 100;
-      file.url = imageUrl;
-      file.response = { url: imageUrl };
+    file.status = "success";
+    file.percentage = 100;
+    file.url = imageUrl;
+    file.response = { url: imageUrl };
 
-      if (uploadType === "title") {
-        titleImageUrl.value = imageUrl;
-        activityModel.value.activityTitleImg = { url: imageUrl };
-        activityModel.value.activityTitleImage = imageUrl;
-        // 触发表单验证
-        nextTick(() => {
-          ruleFormRef.value?.validateField("activityTitleImage");
-        });
-      } else if (uploadType === "detail") {
-        // 支持多张图片,将所有成功上传的图片URL组合
-        const successFiles = detailFileList.value.filter((f: any) => f.status === "success" && f.url);
-        const imageUrls = successFiles.map((f: any) => f.url);
-        // 如果有多张图片,用逗号连接;如果只有一张,保持字符串格式
-        detailImageUrl.value = imageUrls.length > 0 ? imageUrls.join(",") : "";
-        activityModel.value.activityDetailImg = imageUrls.length > 0 ? { url: imageUrls.join(",") } : null;
-        activityModel.value.activityDetailImage = imageUrls.length > 0 ? imageUrls.join(",") : "";
-        // 触发表单验证
-        nextTick(() => {
-          ruleFormRef.value?.validateField("activityDetailImage");
-        });
-      }
-    } else {
-      throw new Error(result?.msg || "图片上传失败");
+    if (uploadType === "title") {
+      titleImageUrl.value = imageUrl;
+      activityModel.value.activityTitleImg = { url: imageUrl };
+      activityModel.value.activityTitleImage = imageUrl;
+      nextTick(() => {
+        ruleFormRef.value?.validateField("activityTitleImage");
+      });
+    } else if (uploadType === "detail") {
+      const successFiles = detailFileList.value.filter((f: any) => f.status === "success" && f.url);
+      const imageUrls = successFiles.map((f: any) => f.url);
+      detailImageUrl.value = imageUrls.length > 0 ? imageUrls.join(",") : "";
+      activityModel.value.activityDetailImg = imageUrls.length > 0 ? { url: imageUrls.join(",") } : null;
+      activityModel.value.activityDetailImage = imageUrls.length > 0 ? imageUrls.join(",") : "";
+      nextTick(() => {
+        ruleFormRef.value?.validateField("activityDetailImage");
+      });
     }
   } catch (error: any) {
     // 上传失败时保持进度条为 0

+ 20 - 40
src/views/priceList/edit.vue

@@ -240,7 +240,7 @@
             <div class="image-upload-wrap">
               <UploadImgs
                 v-model:file-list="detailImageFileList"
-                :api="uploadImgStore"
+                :api="handleCustomImageUpload"
                 :limit="9"
                 :file-size="5"
                 :width="'100px'"
@@ -375,7 +375,7 @@ import {
 } from "@/api/modules/priceList";
 import { localGet } from "@/utils";
 import UploadImgs from "@/components/Upload/Imgs.vue";
-import { uploadImgStore } from "@/api/modules/upload";
+import { uploadFilesToOss } from "@/api/upload.js";
 
 const router = useRouter();
 const route = useRoute();
@@ -550,46 +550,26 @@ function calcRecommendedPrice() {
   }
 }
 
-// 自定义上传函数,正确处理响应格式(参考 personnelConfig/index.vue)
+/** OSS 直传(UploadImgs 读取 response.fileUrl) */
 const handleCustomImageUpload = async (formData: FormData): Promise<any> => {
-  try {
-    const response: any = await uploadImgStore(formData);
-
-    // API 返回格式: { code: 200, success: true, data: ["https://..."], msg: "操作成功" }
-    // 需要提取 response.data[0] 作为图片 URL
-    let imageUrl = "";
-
-    if (response && (response.code === 200 || response.code === "200")) {
-      if (Array.isArray(response.data) && response.data.length > 0) {
-        imageUrl = response.data[0];
-      } else if (typeof response.data === "string") {
-        imageUrl = response.data;
-      } else if (response.data?.fileUrl) {
-        imageUrl = response.data.fileUrl;
-      } else if (response.fileUrl) {
-        imageUrl = response.fileUrl;
-      }
-    }
-
-    if (!imageUrl) {
-      throw new Error("无法提取图片URL");
-    }
-
-    // 返回格式需要兼容 Imgs.vue 组件的处理逻辑
-    // 返回一个对象,其中 fileUrl 是图片URL,这样 uploadSuccess 会使用 response.fileUrl
-    const result = {
-      fileUrl: imageUrl, // 供 uploadSuccess 使用(会设置 uploadFile.url = response.fileUrl)
-      data: [imageUrl], // 备用
-      code: response.code,
-      msg: response.msg,
-      success: response.success
-    };
-
-    return result;
-  } catch (error) {
-    console.error("自定义上传函数 - 上传失败:", error);
-    throw error;
+  const raw = formData.get("file");
+  const file = raw instanceof File ? raw : null;
+  if (!file) {
+    throw new Error("请选择文件");
+  }
+  const isVideo = typeof file.type === "string" && file.type.startsWith("video/");
+  const urls = await uploadFilesToOss(file, isVideo ? "video" : "image");
+  const fileUrl = urls[0];
+  if (!fileUrl) {
+    throw new Error("上传失败,未返回地址");
   }
+  return {
+    fileUrl,
+    data: [fileUrl],
+    code: 200,
+    success: true,
+    msg: "操作成功"
+  };
 };
 
 // 统一处理文件列表变化,更新 formModel.images(用逗号拼接)

+ 59 - 96
src/views/storeDecoration/add.vue

@@ -10,9 +10,9 @@
         <!-- 装修类型 -->
         <el-form-item label="装修类型" prop="renovationType">
           <el-radio-group v-model="formData.renovationType">
-            <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>
 
@@ -106,7 +106,9 @@
             @clear="handleCityClear"
           >
             <template #suffix>
-              <el-icon class="cursor-pointer" @click="showCityDialog = true"><ArrowDown /></el-icon>
+              <el-icon class="cursor-pointer" @click="showCityDialog = true">
+                <ArrowDown />
+              </el-icon>
             </template>
           </el-input>
         </el-form-item>
@@ -128,9 +130,9 @@
         <el-form-item prop="agreementConfirmed">
           <el-checkbox v-model="formData.agreementConfirmed" :true-label="1" :false-label="0">
             我已阅读并同意
-            <el-link type="primary" :underline="false" @click="handleShowAgreement">《用户服务协议》</el-link>
+            <el-link type="primary" :underline="false" @click="handleShowAgreement"> 《用户服务协议》 </el-link>
-            <el-link type="primary" :underline="false" @click="handleShowPrivacy">《隐私政策》</el-link>
+            <el-link type="primary" :underline="false" @click="handleShowPrivacy"> 《隐私政策》 </el-link>
             ,允许装修商家查看我的需求信息并与我联系
           </el-checkbox>
         </el-form-item>
@@ -138,8 +140,8 @@
 
       <template #footer>
         <div class="dialog-footer">
-          <el-button @click="handleClose">返回</el-button>
-          <el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
+          <el-button @click="handleClose"> 返回 </el-button>
+          <el-button type="primary" :loading="submitLoading" @click="handleSubmit"> 确定 </el-button>
         </div>
       </template>
     </el-dialog>
@@ -179,8 +181,8 @@
       </div>
       <template #footer>
         <div class="dialog-footer">
-          <el-button @click="showCityDialog = false">取消</el-button>
-          <el-button type="primary" @click="handleConfirmCity">确定</el-button>
+          <el-button @click="showCityDialog = false"> 取消 </el-button>
+          <el-button type="primary" @click="handleConfirmCity"> 确定 </el-button>
         </div>
       </template>
     </el-dialog>
@@ -462,7 +464,7 @@
       </div>
       <template #footer>
         <div class="dialog-footer-center">
-          <el-button type="primary" @click="agreementDialogVisible = false">已阅读并同意协议内容</el-button>
+          <el-button type="primary" @click="agreementDialogVisible = false"> 已阅读并同意协议内容 </el-button>
         </div>
       </template>
     </el-dialog>
@@ -539,7 +541,7 @@
       </div>
       <template #footer>
         <div class="dialog-footer-center">
-          <el-button type="primary" @click="privacyDialogVisible = false">已阅读并同意协议内容</el-button>
+          <el-button type="primary" @click="privacyDialogVisible = false"> 已阅读并同意协议内容 </el-button>
         </div>
       </template>
     </el-dialog>
@@ -549,9 +551,17 @@
 <script setup lang="ts" name="decorationAdd">
 import { ref, reactive, onMounted } from "vue";
 import { useRoute, useRouter } from "vue-router";
-import { ElMessage, type FormInstance, type UploadFile, type UploadProps, type UploadRequestOptions } from "element-plus";
+import {
+  ElMessage,
+  type FormInstance,
+  type UploadFile,
+  type UploadProps,
+  type UploadRequestOptions,
+  type UploadUserFile
+} from "element-plus";
 import { Plus, ArrowDown } from "@element-plus/icons-vue";
-import { saveOrUpdateDecoration, getDistrict, uploadDecorationImage } from "@/api/modules/storeDecoration";
+import { saveOrUpdateDecoration, getDistrict } from "@/api/modules/storeDecoration";
+import { uploadFileToOss } from "@/api/upload.js";
 import { localGet } from "@/utils";
 
 const route = useRoute();
@@ -600,7 +610,7 @@ const selectedCityName = ref("");
 const selectedDistrictName = ref("");
 
 // 禁用「当天之前」的日期(核心方法)
-const disabledBeforeToday = (date) => {
+const disabledBeforeToday = date => {
   // 初始化今天的日期(重置时分秒为0,避免时间戳干扰)
   const today = new Date();
   today.setHours(0, 0, 0, 0);
@@ -778,82 +788,50 @@ const beforeUpload: UploadProps["beforeUpload"] = (rawFile: File) => {
   return true;
 };
 
-// 图片上传 - 点击加号时调用 /file/uploadMore 接口
+// 图片上传:OSS 直传(@/api/upload.js)
 const handleImageUpload = async (options: UploadRequestOptions) => {
-  // 获取文件对象,可能是 options.file 或 options.file.raw
-  const file = options.file.raw || options.file;
-  
+  const uploadFileItem = options.file as UploadUserFile;
+  const raw = uploadFileItem.raw || uploadFileItem;
+  const file = raw instanceof File ? raw : null;
+
   if (!file) {
-    console.error("文件对象不存在");
     ElMessage.error("文件对象不存在");
     return;
   }
 
-  console.log("开始上传文件:", file.name, "类型:", file.type, "大小:", file.size);
-
-  const uploadFormData = new FormData();
-  uploadFormData.append("file", file);
-
-  options.file.status = "uploading";
-  options.file.percentage = 0;
+  uploadFileItem.status = "uploading";
+  uploadFileItem.percentage = 0;
 
   try {
-    console.log("调用 /alienStore/file/uploadMore 接口上传文件");
-    // 调用 /alienStore/file/uploadMore 接口上传文件
-    const result: any = await uploadDecorationImage(uploadFormData);
-    console.log("上传接口返回结果:", result);
-
-    if (result?.code === 200 || result?.code === 0) {
-      let fileUrl = "";
-
-      // 处理不同的返回格式
-      if (Array.isArray(result.data) && result.data.length > 0) {
-        fileUrl = result.data[0];
-      } else if (typeof result.data === "string") {
-        fileUrl = result.data;
-      } else if (result.data?.fileUrl) {
-        fileUrl = result.data.fileUrl;
-      } else if (result.data?.url) {
-        fileUrl = result.data.url;
-      } else if (result.fileUrl) {
-        fileUrl = result.fileUrl;
-      } else if (result.url) {
-        fileUrl = result.url;
-      }
+    const fileUrl = await uploadFileToOss(file, "image");
+    if (!fileUrl) {
+      throw new Error("上传失败,未返回文件地址");
+    }
 
-      if (fileUrl) {
-        options.file.status = "success";
-        options.file.percentage = 100;
-        options.file.url = fileUrl;
-        options.file.response = { url: fileUrl };
-
-        // 获取图片路径后,记录到 attachmentUrls 数组中,提交时会传递给保存接口
-        if (!formData.attachmentUrls.includes(fileUrl)) {
-          formData.attachmentUrls.push(fileUrl);
-          console.log("文件上传成功,路径已记录:", fileUrl);
-          console.log("当前附件列表:", formData.attachmentUrls);
-        }
-        options.onSuccess?.(result);
-        // 触发表单验证
-        formRef.value?.validateField("attachmentUrls");
-      } else {
-        console.error("上传接口返回数据格式错误:", result);
-        throw new Error("上传接口返回数据格式错误");
-      }
-    } else {
-      throw new Error(result?.msg || result?.message || "文件上传失败");
+    uploadFileItem.status = "success";
+    uploadFileItem.percentage = 100;
+    uploadFileItem.url = fileUrl;
+    uploadFileItem.response = { url: fileUrl };
+
+    if (!formData.attachmentUrls.includes(fileUrl)) {
+      formData.attachmentUrls.push(fileUrl);
     }
+    options.onSuccess?.({ code: 200, data: fileUrl, url: fileUrl });
+    formRef.value?.validateField("attachmentUrls");
   } catch (error: any) {
     console.error("文件上传失败:", error);
-    options.file.status = "fail";
-    if (options.file.url && options.file.url.startsWith("blob:")) {
-      URL.revokeObjectURL(options.file.url);
+    uploadFileItem.status = "fail";
+    if (uploadFileItem.url && uploadFileItem.url.startsWith("blob:")) {
+      URL.revokeObjectURL(uploadFileItem.url);
     }
-    const index = fileList.value.findIndex(f => f.uid === options.file.uid);
+    const index = fileList.value.findIndex(f => f.uid === uploadFileItem.uid);
     if (index > -1) {
       fileList.value.splice(index, 1);
     }
-    ElMessage.error(error?.message || "文件上传失败");
+    if (error?.message === "上传失败,未返回文件地址") {
+      ElMessage.error(error.message);
+    }
+    // 其余错误(含 OSS 签名/网络)已在 upload.js 中提示
     options.onError?.(error);
   }
 };
@@ -906,7 +884,7 @@ const handleSubmit = async () => {
     submitLoading.value = true;
     try {
       // 确保 attachmentUrls 是数组格式,包含所有上传成功的图片路径
-      const attachmentUrlsList = Array.isArray(formData.attachmentUrls) 
+      const attachmentUrlsList = Array.isArray(formData.attachmentUrls)
         ? formData.attachmentUrls.filter(url => url && url.trim() !== "")
         : [];
 
@@ -983,82 +961,67 @@ onMounted(() => {
 <style lang="scss" scoped>
 .add-container {
   :deep(.el-dialog__body) {
-    padding: 20px;
     max-height: 70vh;
+    padding: 20px;
     overflow-y: auto;
   }
-
   :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;
   }
-
   .dialog-footer {
-    text-align: center;
     padding: 20px 0 0;
+    text-align: center;
   }
-
   :deep(.el-upload--picture-card) {
     width: 100px;
     height: 100px;
   }
-
   :deep(.el-upload-list--picture-card .el-upload-list__item) {
     width: 100px;
     height: 100px;
   }
-
   .city-selector {
     padding: 10px 0;
   }
-
   .cursor-pointer {
     cursor: pointer;
   }
-
   .agreement-content {
     height: 50vh;
     padding: 10px;
     overflow-y: auto;
     font-size: 14px;
     line-height: 1.6;
-
     h3 {
       margin-top: 20px;
       margin-bottom: 10px;
       font-weight: 600;
     }
-
     p {
       margin-bottom: 10px;
     }
-
     ul {
-      margin-left: 20px;
       margin-bottom: 10px;
-
+      margin-left: 20px;
       li {
         margin-bottom: 5px;
       }
     }
   }
-
   .dialog-footer-center {
     display: flex;
     align-items: center;
     justify-content: center;
     width: 100%;
-
     .el-button {
       width: 406px;
       height: 60px;

Різницю між файлами не показано, бо вона завелика
+ 478 - 421
src/views/storeDecoration/businessHours/index.vue


+ 720 - 652
src/views/storeDecoration/facilitiesAndServices/components/FacilityManagement.vue

@@ -1,158 +1,206 @@
 <template>
   <div class="facility-management-container">
-    <!-- 标签页 -->
-    <div class="header-section">
-      <el-tabs v-model="activeTab" @tab-click="handleTabClick">
-        <el-tab-pane v-for="tab in tabs" :key="tab.value" :label="tab.label" :name="tab.value" />
-      </el-tabs>
-      <div class="action-buttons">
-        <el-button type="primary" @click="openCreateDialog"> 添加 </el-button>
-        <el-button type="primary" @click="handleBatchImport"> 批量导入 </el-button>
-      </div>
+    <!-- 空状态:没有区域数据时 -->
+    <div v-if="facilityAreas.length === 0" class="empty-page-state">
+      <el-empty description="暂无数据" />
+      <el-button type="primary" :loading="saving" @click="openCreateAreaDialog"> 新建区域 </el-button>
     </div>
 
-    <!-- 添加实景图片区域 -->
-    <div class="image-upload-section">
-      <div class="section-title">添加实景图片</div>
-      <div class="upload-area">
-        <el-upload
-          v-model:file-list="imageFileList"
-          list-type="picture-card"
-          :limit="20"
-          :on-preview="handlePictureCardPreview"
-          :on-remove="handleRemoveImage"
-          :before-upload="beforeImageUpload"
-          :http-request="handleImageUpload"
-          accept="image/*"
-        >
-          <el-icon><Plus /></el-icon>
-          <div class="upload-tip">({{ imageFileList.length }}/20)</div>
-        </el-upload>
+    <template v-else>
+      <!-- 区域选择与操作(标签样式 + 下拉菜单) -->
+      <div class="header-section">
+        <div class="area-tabs">
+          <div
+            v-for="(area, index) in facilityAreas"
+            :key="area.id"
+            class="area-tab-item"
+            :class="{ active: activeTab === index }"
+            @click="
+              activeTab = index;
+              handleAreaChange();
+            "
+          >
+            <span class="tab-text">{{ area.facilityAreaName || area.areaName }}</span>
+          </div>
+          <el-dropdown trigger="click" class="area-dropdown">
+            <span class="dropdown-trigger">
+              <el-icon><ArrowDown /></el-icon>
+            </span>
+            <template #dropdown>
+              <el-dropdown-menu>
+                <el-dropdown-item @click="openEditAreaDialog">
+                  <span class="dropdown-item-edit">编辑</span>
+                </el-dropdown-item>
+                <el-dropdown-item @click="openDeleteAreaDialog">
+                  <span class="dropdown-item-delete">删除</span>
+                </el-dropdown-item>
+              </el-dropdown-menu>
+            </template>
+          </el-dropdown>
+        </div>
+        <el-button type="primary" @click="openCreateAreaDialog"> 新建区域 </el-button>
+      </div>
+
+      <!-- 添加实景图片 -->
+      <div class="image-upload-section">
+        <div class="section-header">
+          <span class="section-title">添加实景图片</span>
+          <el-button type="primary" link @click="openImageUpload"> 上传 </el-button>
+        </div>
+        <div class="panorama-list">
+          <div v-if="panoramaImages.length > 0" class="panorama-images">
+            <div v-for="(img, idx) in panoramaImages" :key="idx" class="panorama-item" @click="previewImage(img)">
+              <el-image :src="img" fit="cover" class="panorama-img" />
+            </div>
+          </div>
+          <div class="upload-trigger" @click="openImageUpload">
+            <el-icon><Plus /></el-icon>
+            <span>上传图片 ({{ panoramaImages.length }}/20)</span>
+          </div>
+        </div>
+        <div v-if="panoramaImages.length > 0" class="image-actions">
+          <el-button @click="handleRemoveAllImages"> 删除 </el-button>
+          <el-button type="primary" :loading="saving" @click="handleSaveImages"> 保存 </el-button>
+        </div>
       </div>
-    </div>
 
-    <!-- 设施列表 -->
-    <div class="facility-list-section">
-      <div class="section-title">设施列表</div>
-      <el-table :data="paginatedList" border style="width: 100%">
-        <el-table-column type="index" label="序号" width="60" align="center" />
-        <el-table-column prop="facilityName" label="名称" min-width="120" />
-        <el-table-column prop="quantity" label="数量" width="100" align="center" />
-        <el-table-column prop="brand" label="品牌" min-width="120" />
-        <el-table-column prop="displayInStoreDetail" label="展示在店铺详情" width="150" align="center">
-          <template #default="{ row }">
-            <el-tag :type="row.displayInStoreDetail === 1 ? 'success' : 'info'">
-              {{ row.displayInStoreDetail === 1 ? "显示" : "隐藏" }}
-            </el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column label="操作" width="200" align="center" fixed="right">
-          <template #default="{ row }">
-            <el-button type="primary" link @click="editItem(row)"> 编辑 </el-button>
-            <el-button type="primary" link @click="deleteItem(row)"> 删除 </el-button>
-            <el-button type="primary" link @click="toggleDisplay(row)">
-              {{ row.displayInStoreDetail === 1 ? "隐藏" : "显示" }}
-            </el-button>
-          </template>
-        </el-table-column>
-      </el-table>
-
-      <!-- 分页 -->
-      <div class="pagination-section">
-        <el-pagination
-          v-model:current-page="pageable.pageNum"
-          v-model:page-size="pageable.pageSize"
-          :page-sizes="[10, 20, 50, 100]"
-          :total="pageable.total"
-          layout="total, sizes, prev, pager, next, jumper"
-          @size-change="handleSizeChange"
-          @current-change="handleCurrentChange"
-        />
+      <!-- 设施列表 -->
+      <div class="facility-list-section">
+        <div class="section-header">
+          <span class="section-title">设施列表</span>
+          <div class="add-hint">
+            <el-button type="primary" @click="openCreateFacilityDialog"> 新增 </el-button>
+            <span class="hint-text">(点击弹窗, 超过10条可滑动)</span>
+          </div>
+        </div>
+        <el-table :data="facilityList" border style="width: 100%">
+          <el-table-column type="index" label="序号" width="60" align="center" />
+          <el-table-column label="图片" width="100" align="center">
+            <template #default="{ row }">
+              <el-image
+                v-if="row.equipmentImage || row.imgUrl"
+                :src="row.equipmentImage || row.imgUrl"
+                fit="cover"
+                class="facility-thumb"
+                :preview-src-list="[row.equipmentImage || row.imgUrl]"
+              >
+                <template #error>
+                  <div class="image-placeholder">
+                    <el-icon><Picture /></el-icon>
+                  </div>
+                </template>
+              </el-image>
+              <div v-else class="image-placeholder">
+                <el-icon><Picture /></el-icon>
+              </div>
+            </template>
+          </el-table-column>
+          <el-table-column prop="facilityName" label="名称" min-width="120">
+            <template #default="{ row }">
+              {{ row.equipmentName || row.facilityName }}
+            </template>
+          </el-table-column>
+          <el-table-column label="数量" width="100" align="center">
+            <template #default="{ row }">
+              {{ row.equipmentNums ?? row.quantity ?? 0 }}
+            </template>
+          </el-table-column>
+          <el-table-column label="是否显示" width="100" align="center">
+            <template #default="{ row }">
+              {{ row.displayInStoreDetail == 1 ? "是" : "否" }}
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="150" align="center" fixed="right">
+            <template #default="{ row }">
+              <el-button type="primary" link @click="editFacility(row)"> 编辑 </el-button>
+              <el-button type="primary" link @click="deleteFacility(row)"> 删除 </el-button>
+            </template>
+          </el-table-column>
+        </el-table>
       </div>
-    </div>
+    </template>
 
-    <!-- 新建/编辑弹窗 -->
-    <el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" @close="resetForm">
-      <el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
-        <el-form-item label="选择分类*" prop="facilityCategory">
-          <el-select v-model="formData.facilityCategory" placeholder="请选择" style="width: 100%">
-            <el-option v-for="tab in tabs" :key="tab.value" :label="tab.label" :value="tab.value" />
-          </el-select>
+    <!-- 新建/编辑区域弹窗 -->
+    <el-dialog v-model="areaDialogVisible" :title="areaDialogTitle" width="400px" @close="resetAreaForm">
+      <el-form ref="areaFormRef" :model="areaFormData" :rules="areaRules" label-width="0">
+        <el-form-item prop="name">
+          <el-input v-model="areaFormData.name" placeholder="请输入区域名称" maxlength="20" show-word-limit clearable />
         </el-form-item>
-        <el-form-item label="名称*" prop="facilityName">
-          <el-input v-model="formData.facilityName" placeholder="请输入" maxlength="50" clearable />
-        </el-form-item>
-        <el-form-item label="数量" prop="quantity">
-          <el-input-number v-model="formData.quantity" :min="0" :max="9999" placeholder="请输入" style="width: 100%" />
+      </el-form>
+      <template #footer>
+        <el-button @click="areaDialogVisible = false"> 取消 </el-button>
+        <el-button type="primary" :loading="saving" @click="submitAreaForm"> 确定 </el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 新增/编辑设施弹窗 -->
+    <el-dialog v-model="facilityDialogVisible" :title="facilityDialogTitle" width="400px" @close="resetFacilityForm">
+      <el-form ref="facilityFormRef" :model="facilityFormData" :rules="facilityRules" label-width="120px">
+        <el-form-item label="名称*" prop="name">
+          <el-input v-model="facilityFormData.name" placeholder="请输入" maxlength="50" clearable />
         </el-form-item>
-        <el-form-item label="品牌" prop="brand">
-          <el-input v-model="formData.brand" placeholder="请输入" maxlength="50" clearable />
+        <el-form-item label="数量*" prop="quantity">
+          <el-input-number v-model="facilityFormData.quantity" :min="1" :max="99" style="width: 100%" />
         </el-form-item>
-        <el-form-item label="描述" prop="description">
-          <el-input
-            v-model="formData.description"
-            type="textarea"
-            :rows="4"
-            placeholder="请输入"
-            maxlength="500"
-            show-word-limit
-            clearable
+        <el-form-item label="图片*" prop="imageUrl">
+          <UploadImg
+            v-model:image-url="facilityFormData.imageUrl"
+            :width="'120px'"
+            :height="'120px'"
+            :file-size="20"
+            :api="formData => uploadFormDataToOss(formData, 'image')"
+            :file-type="['image/jpeg', 'image/png', 'image/gif', 'image/webp']"
           />
         </el-form-item>
-        <el-form-item label="展示在店铺详情" prop="displayInStoreDetail">
-          <el-radio-group v-model="formData.displayInStoreDetail">
+        <el-form-item label="显示在店铺详情" prop="showInDetail">
+          <el-radio-group v-model="facilityFormData.showInDetail">
             <el-radio :label="1"> 显示 </el-radio>
             <el-radio :label="0"> 隐藏 </el-radio>
           </el-radio-group>
         </el-form-item>
       </el-form>
       <template #footer>
-        <div class="dialog-footer">
-          <el-button @click="dialogVisible = false"> 取消 </el-button>
-          <el-button type="primary" :loading="submitLoading" @click="handleSubmit">
-            {{ editId ? "确定" : "添加" }}
-          </el-button>
-        </div>
+        <el-button @click="facilityDialogVisible = false"> 取消 </el-button>
+        <el-button type="primary" :loading="saving" @click="submitFacilityForm"> 确定 </el-button>
       </template>
     </el-dialog>
 
-    <!-- 批量导入弹窗 -->
-    <el-dialog v-model="batchImportVisible" title="批量导入" width="500px">
-      <div class="import-steps">
-        <div class="import-step">
-          <div class="step-header">
-            <div class="step-number">1</div>
-            <div class="step-title">下载模板</div>
-          </div>
-          <el-button type="primary" @click="downloadTemplate"> 下载excel模板 </el-button>
-        </div>
-        <div class="import-step">
-          <div class="step-header">
-            <div class="step-number">2</div>
-            <div class="step-title">上传文件</div>
-          </div>
-          <el-upload
-            ref="uploadRef"
-            :auto-upload="false"
-            :on-change="handleFileChange"
-            :on-remove="handleFileRemove"
-            :limit="1"
-            accept=".xlsx,.xls"
-            drag
-            class="import-upload"
-          >
-            <el-icon class="el-icon--upload">
-              <UploadFilled />
-            </el-icon>
-            <div class="el-upload__text">点击上传文件或拖拽上传文件</div>
-          </el-upload>
+    <!-- 删除区域弹窗 -->
+    <el-dialog v-model="deleteAreaDialogVisible" title="请选择需要删除的区域" width="400px">
+      <el-checkbox-group v-model="selectedAreaIds">
+        <div v-for="area in facilityAreas" :key="area.id" class="delete-item">
+          <el-checkbox :label="area.id">
+            {{ area.facilityAreaName || area.areaName }}
+          </el-checkbox>
         </div>
+      </el-checkbox-group>
+      <template #footer>
+        <el-button @click="deleteAreaDialogVisible = false"> 取消 </el-button>
+        <el-button type="primary" :loading="saving" @click="confirmDeleteAreas"> 确定 </el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 实景图片上传弹窗 -->
+    <el-dialog v-model="imageUploadDialogVisible" title="上传图片" width="600px" @close="resetImageUpload">
+      <div class="panorama-upload-dialog">
+        <el-upload
+          v-model:file-list="tempPanoramaFileList"
+          action="#"
+          list-type="picture-card"
+          :limit="20"
+          :on-preview="handlePanoramaPreview"
+          :on-remove="handlePanoramaRemove"
+          :before-upload="beforePanoramaUpload"
+          :http-request="handlePanoramaHttpUpload"
+          accept="image/*"
+        >
+          <el-icon><Plus /></el-icon>
+        </el-upload>
+        <div class="upload-tip">上传图片 ({{ tempPanoramaFileList.length }}/20)</div>
       </div>
       <template #footer>
-        <div class="dialog-footer">
-          <el-button @click="batchImportVisible = false"> 取消 </el-button>
-          <el-button type="primary" :loading="importLoading" @click="handleImportSubmit"> 导入 </el-button>
-        </div>
+        <el-button @click="imageUploadDialogVisible = false"> 取消 </el-button>
+        <el-button type="primary" :loading="saving" @click="confirmImageUpload"> 确定 </el-button>
       </template>
     </el-dialog>
 
@@ -169,661 +217,681 @@
 <script setup lang="ts">
 import { ref, reactive, computed, onMounted } from "vue";
 import { ElMessage, ElMessageBox } from "element-plus";
-import type { FormInstance, FormRules, UploadFile, UploadRequestOptions } from "element-plus";
-import { Plus, UploadFilled } from "@element-plus/icons-vue";
-import { uploadImg } from "@/api/modules/newLoginApi";
+import type { FormInstance, FormRules, UploadRequestOptions, UploadUserFile } from "element-plus";
+import { Plus, Picture, Delete, ArrowDown } from "@element-plus/icons-vue";
+import UploadImg from "@/components/Upload/Img.vue";
 import { localGet } from "@/utils";
+import { uploadFileToOss, uploadFormDataToOss } from "@/api/upload.js";
 import {
-  getFacilityList,
-  createOrUpdateFacility,
-  getFacilityDetail,
-  deleteFacility,
-  downloadFacilityTemplate,
-  importFacilityExcel,
-  getOfficialImgList,
-  saveOfficialImg
+  getSportsFacilityAreaList,
+  createSportsFacilityArea,
+  updateSportsFacilityArea,
+  updateSportsFacilityAreaHeadUrl,
+  batchDeleteSportsFacilityArea,
+  getSportsEquipmentFacilityListByArea,
+  saveSportsEquipmentFacility,
+  updateSportsEquipmentFacility,
+  deleteSportsEquipmentFacility
 } from "@/api/modules/storeDecoration";
 
-// 设施接口
-interface Facility {
-  id?: string | number;
-  facilityCategory: number; // 1:有氧区, 2:力量区, 3:单功能机械区
-  facilityName: string;
+// 区域类型
+interface FacilityArea {
+  id: number;
+  areaName?: string;
+  facilityAreaName?: string;
+  areaHeadUrl?: string;
+}
+
+// 设施类型(与商家端一致)
+interface FacilityItem {
+  id: number;
+  areaId?: number;
+  equipmentName?: string;
+  facilityName?: string;
+  equipmentNums?: number;
   quantity?: number;
-  brand?: string;
-  description?: string;
-  displayInStoreDetail: number; // 0:隐藏, 1:显示
+  equipmentImage?: string;
+  imgUrl?: string;
+  displayInStoreDetail?: number;
 }
 
-const dialogVisible = ref(false);
-const formRef = ref<FormInstance>();
-const submitLoading = ref(false);
-const activeTab = ref(1); // 有氧区
-const editId = ref<string | number | null>(null);
-const batchImportVisible = ref(false);
-const importLoading = ref(false);
-const uploadRef = ref();
-const importFile = ref<UploadFile | null>(null);
-const imageFileList = ref<any[]>([]);
+const storeId = ref<number | null>(null);
+const facilityAreas = ref<FacilityArea[]>([]);
+const activeTab = ref(0);
+const facilityList = ref<FacilityItem[]>([]);
+const panoramaImages = ref<string[]>([]);
+const saving = ref(false);
+
+// 区域弹窗
+const areaDialogVisible = ref(false);
+const areaFormRef = ref<FormInstance>();
+const areaFormData = reactive({ name: "" });
+const areaRules: FormRules = { name: [{ required: true, message: "请输入区域名称", trigger: "blur" }] };
+const isEditingArea = ref(false);
+const editingAreaId = ref<number | null>(null);
+const areaDialogTitle = computed(() => (isEditingArea.value ? "编辑" : "新建") + "区域");
+
+// 设施弹窗
+const facilityDialogVisible = ref(false);
+const facilityFormRef = ref<FormInstance>();
+const facilityFormData = reactive({
+  name: "",
+  quantity: 1,
+  imageUrl: "",
+  showInDetail: 1
+});
+const facilityRules: FormRules = {
+  name: [{ required: true, message: "请输入名称", trigger: "blur" }],
+  quantity: [{ required: true, message: "请输入数量", trigger: "blur" }],
+  imageUrl: [{ required: true, message: "请上传图片", trigger: "change" }]
+};
+const isEditingFacility = ref(false);
+const editingFacilityId = ref<number | null>(null);
+const facilityDialogTitle = computed(() => (isEditingFacility.value ? "编辑" : "新增"));
+
+// 删除区域弹窗
+const deleteAreaDialogVisible = ref(false);
+const selectedAreaIds = ref<(string | number)[]>([]);
+
+// 实景图片上传
+const imageUploadDialogVisible = ref(false);
+const tempPanoramaFileList = ref<UploadUserFile[]>([]);
+
+// 图片预览
 const imageViewerVisible = ref(false);
 const imageViewerUrlList = ref<string[]>([]);
 const imageViewerInitialIndex = ref(0);
 
-// 标签页配置
-const tabs = [
-  { label: "有氧区", value: 1 },
-  { label: "力量区", value: 2 },
-  { label: "单功能机械区", value: 3 }
-];
-
-// 设施列表
-const facilityList = ref<Facility[]>([]);
-
-// 分页数据
-const pageable = reactive({
-  pageNum: 1,
-  pageSize: 10,
-  total: 0
-});
-
-// 分页后的列表
-const paginatedList = computed(() => {
-  const list = facilityList.value.filter(item => item.facilityCategory === activeTab.value);
-  const start = (pageable.pageNum - 1) * pageable.pageSize;
-  const end = start + pageable.pageSize;
-  return list.slice(start, end);
-});
+// 当前区域
+const currentArea = computed(() => facilityAreas.value[activeTab.value]);
 
-// 弹窗标题
-const dialogTitle = computed(() => (editId.value !== null ? "编辑" : "添加"));
-
-// 表单数据
-const formData = reactive<Facility>({
-  facilityCategory: 1,
-  facilityName: "",
-  quantity: undefined,
-  brand: "",
-  description: "",
-  displayInStoreDetail: 1
-});
-
-// 表单校验规则
-const rules = reactive<FormRules>({
-  facilityCategory: [{ required: true, message: "请选择分类", trigger: "change" }],
-  facilityName: [{ required: true, message: "请输入名称", trigger: "blur" }]
-});
+// 获取区域列表
+const fetchFacilityAreas = async () => {
+  if (!storeId.value) return;
+  try {
+    const res: any = await getSportsFacilityAreaList({ storeId: storeId.value });
+    if (res?.code === 200 && res?.data) {
+      facilityAreas.value = (res.data || []).map((a: any) => ({
+        ...a,
+        facilityAreaName: a.areaName ?? a.facilityAreaName
+      }));
+      if (facilityAreas.value.length > 0 && activeTab.value >= facilityAreas.value.length) {
+        activeTab.value = 0;
+      }
+    } else {
+      facilityAreas.value = [];
+    }
+  } catch (e) {
+    console.error("获取设施区域列表失败:", e);
+    facilityAreas.value = [];
+  }
+};
 
-// Tab切换
-const handleTabClick = async () => {
-  pageable.pageNum = 1;
-  // 调用loadFacilityList获取当前选中分类的数据
-  await loadFacilityList(activeTab.value);
-  updatePagination();
-  // 重新获取当前分区的图片列表
-  getImageList();
+// 获取设施列表
+const fetchFacilityList = async () => {
+  if (!storeId.value || !currentArea.value) {
+    facilityList.value = [];
+    return;
+  }
+  try {
+    const res: any = await getSportsEquipmentFacilityListByArea({
+      storeId: storeId.value,
+      areaId: currentArea.value.id
+    });
+    if (res?.code === 200 && res?.data) {
+      facilityList.value = (res.data || []).map((item: any) => ({
+        ...item,
+        areaId: currentArea.value.id
+      }));
+    } else {
+      facilityList.value = [];
+    }
+  } catch (e) {
+    console.error("获取设施列表失败:", e);
+    facilityList.value = [];
+  }
 };
 
-// 更新分页总数
-const updatePagination = () => {
-  const list = facilityList.value.filter(item => item.facilityCategory === activeTab.value);
-  pageable.total = list.length;
+// 获取实景图片
+const fetchPanoramaImages = () => {
+  if (!currentArea.value) {
+    panoramaImages.value = [];
+    return;
+  }
+  const url = currentArea.value.areaHeadUrl;
+  if (url) {
+    panoramaImages.value = url
+      .split(",")
+      .map(s => s.trim())
+      .filter(Boolean);
+  } else {
+    panoramaImages.value = [];
+  }
 };
 
-// 分页大小改变
-const handleSizeChange = (size: number) => {
-  pageable.pageSize = size;
-  pageable.pageNum = 1;
-  updatePagination();
+// 切换区域
+const handleAreaChange = () => {
+  fetchFacilityList();
+  fetchPanoramaImages();
 };
 
-// 当前页改变
-const handleCurrentChange = (page: number) => {
-  pageable.pageNum = page;
+// 新建区域
+const openCreateAreaDialog = () => {
+  isEditingArea.value = false;
+  editingAreaId.value = null;
+  areaFormData.name = "";
+  areaDialogVisible.value = true;
 };
 
-// 打开新建弹窗
-const openCreateDialog = () => {
-  editId.value = null;
-  resetForm();
-  dialogVisible.value = true;
+// 编辑区域
+const openEditAreaDialog = () => {
+  if (!currentArea.value) return;
+  isEditingArea.value = true;
+  editingAreaId.value = currentArea.value.id;
+  areaFormData.name = currentArea.value.facilityAreaName || currentArea.value.areaName || "";
+  areaDialogVisible.value = true;
 };
 
-// 编辑
-const editItem = async (item: Facility) => {
-  if (!item.id) {
-    ElMessage.warning("设施ID不存在");
+// 提交区域表单
+const submitAreaForm = async () => {
+  if (!areaFormRef.value) return;
+  try {
+    await areaFormRef.value.validate();
+  } catch {
     return;
   }
-
+  if (!storeId.value) {
+    ElMessage.error("未找到店铺ID");
+    return;
+  }
+  saving.value = true;
   try {
-    const res: any = await getFacilityDetail({ id: item.id });
-    if (res && (res.code === 200 || res.code === "200") && res.data) {
-      const detail = res.data;
-      editId.value = detail.id;
-      formData.facilityCategory = detail.facilityCategory || 1;
-      formData.facilityName = detail.facilityName || "";
-      // 新API中没有这些字段,设置为默认值
-      formData.quantity = detail.quantity || 0;
-      formData.brand = detail.brand || "";
-      formData.description = detail.description || "";
-      formData.displayInStoreDetail = detail.displayInStoreDetail !== undefined ? detail.displayInStoreDetail : 1;
-      dialogVisible.value = true;
+    let res: any;
+    if (isEditingArea.value && editingAreaId.value) {
+      res = await updateSportsFacilityArea({
+        areaId: editingAreaId.value,
+        areaName: areaFormData.name.trim()
+      });
+    } else {
+      res = await createSportsFacilityArea({
+        storeId: storeId.value,
+        facilityCategoryName: areaFormData.name.trim()
+      });
+    }
+    if (res?.code === 200) {
+      ElMessage.success(isEditingArea.value ? "编辑成功" : "新增成功");
+      areaDialogVisible.value = false;
+      await fetchFacilityAreas();
+      if (!isEditingArea.value) {
+        activeTab.value = 0;
+      }
+      fetchFacilityList();
+      fetchPanoramaImages();
     } else {
-      ElMessage.error(res?.msg || "获取设施详情失败");
+      ElMessage.error(res?.msg || "操作失败");
     }
-  } catch (error: any) {
-    console.error("获取设施详情失败:", error);
-    ElMessage.error(error?.msg || "获取设施详情失败,请重试");
+  } catch (e) {
+    console.error("提交区域失败:", e);
+    ElMessage.error("操作失败");
+  } finally {
+    saving.value = false;
   }
 };
 
-// 删除
-const deleteItem = async (item: Facility) => {
+// 删除区域弹窗
+const openDeleteAreaDialog = () => {
+  selectedAreaIds.value = [];
+  deleteAreaDialogVisible.value = true;
+};
+
+// 确认删除区域
+const confirmDeleteAreas = async () => {
+  if (selectedAreaIds.value.length === 0) {
+    ElMessage.warning("请选择要删除的区域");
+    return;
+  }
+  if (!storeId.value) return;
+  saving.value = true;
   try {
-    await ElMessageBox.confirm("确认删除该设施吗?", "提示", {
-      confirmButtonText: "确定",
-      cancelButtonText: "取消",
-      type: "warning"
+    const res: any = await batchDeleteSportsFacilityArea({
+      storeId: storeId.value,
+      areaIds: selectedAreaIds.value
     });
-
-    if (!item.id) {
-      ElMessage.error("设施ID不存在");
-      return;
-    }
-
-    const res: any = await deleteFacility({ id: item.id });
-    if (res && (res.code === 200 || res.code === "200")) {
-      ElMessage.success("删除成功");
-      await loadFacilityList();
-      updatePagination();
+    if (res?.code === 200) {
+      ElMessage.success(`成功删除${selectedAreaIds.value.length}个区域`);
+      deleteAreaDialogVisible.value = false;
+      await fetchFacilityAreas();
+      if (activeTab.value >= facilityAreas.value.length) {
+        activeTab.value = Math.max(0, facilityAreas.value.length - 1);
+      }
+      fetchFacilityList();
+      fetchPanoramaImages();
     } else {
       ElMessage.error(res?.msg || "删除失败");
     }
-  } catch (error: any) {
-    if (error !== "cancel") {
-      console.error("删除设施失败:", error);
-      ElMessage.error(error?.msg || "删除失败,请重试");
-    }
+  } catch (e) {
+    console.error("删除区域失败:", e);
+    ElMessage.error("删除失败");
+  } finally {
+    saving.value = false;
   }
 };
 
-// 切换显示状态
-const toggleDisplay = async (item: Facility) => {
-  try {
-    if (!item.id) {
-      ElMessage.error("设施ID不存在");
-      return;
-    }
-
-    const newStatus = item.displayInStoreDetail === 1 ? 0 : 1;
-    const res: any = await createOrUpdateFacility({
-      ...item,
-      displayInStoreDetail: newStatus
-    });
-    if (res && (res.code === 200 || res.code === "200")) {
-      ElMessage.success(newStatus === 1 ? "已显示" : "已隐藏");
-      await loadFacilityList();
-      updatePagination();
-    } else {
-      ElMessage.error(res?.msg || "操作失败");
-    }
-  } catch (error: any) {
-    console.error("切换状态失败:", error);
-    ElMessage.error(error?.msg || "操作失败,请重试");
+// 实景图片上传
+const openImageUpload = () => {
+  if (!currentArea.value) {
+    ElMessage.warning("请先选择区域");
+    return;
   }
+  tempPanoramaFileList.value = panoramaImages.value.map((url, i) => ({
+    uid: Date.now() + i,
+    name: `img-${i}`,
+    status: "success",
+    url
+  })) as UploadUserFile[];
+  imageUploadDialogVisible.value = true;
 };
 
-// 图片上传前验证
-const beforeImageUpload = (file: File) => {
+const beforePanoramaUpload = (file: File) => {
   const isImage = file.type.startsWith("image/");
   const isLt10M = file.size / 1024 / 1024 < 10;
-
   if (!isImage) {
-    ElMessage.error("只能上传图片文件");
+    ElMessage.error("只能上传图片文件");
     return false;
   }
   if (!isLt10M) {
-    ElMessage.error("图片大小不能超过10MB!");
-    return false;
-  }
-  if (imageFileList.value.length >= 20) {
-    ElMessage.error("最多只能上传20张图片!");
+    ElMessage.error("图片大小不能超过10MB");
     return false;
   }
   return true;
 };
 
-// 图片上传
-const handleImageUpload = async (options: UploadRequestOptions) => {
+const handlePanoramaHttpUpload = async (options: UploadRequestOptions) => {
+  const rawFile = (options.file as any).raw || options.file;
+  if (!rawFile) {
+    options.onError(new Error("文件不存在") as any);
+    return;
+  }
   try {
-    const formData = new FormData();
-    formData.append("file", options.file);
-    const res: any = await uploadImg(formData);
-    if (res && res.code === 200 && res.data) {
-      const imageUrl = Array.isArray(res.data) ? res.data[0] : res.data;
-      options.onSuccess({ fileUrl: imageUrl });
-
-      // 调用saveOfficialImg接口保存图片信息
-      const userInfo: any = localGet("geeker-user")?.userInfo || {};
-      const storeId = userInfo.storeId;
-      if (storeId) {
-        const saveRes: any = await saveOfficialImg([
-          {
-            imgUrl: imageUrl,
-            imgType: 28,
-            businessId: activeTab.value, // 1:有氧区, 2:力量区, 3:单功能机械区
-            storeId: Number(storeId),
-            imgSort: 0 // 增加排序参数,默认值为0
-          }
-        ]);
-        if (!(saveRes && (saveRes.code === 200 || saveRes.code === "200"))) {
-          console.error("保存图片信息失败:", saveRes);
-        }
-      }
-
-      ElMessage.success("图片上传成功");
-    } else {
-      options.onError(new Error(res?.msg || "图片上传失败"));
+    const fileUrl = await uploadFileToOss(rawFile, "image");
+    // el-upload 列表项可能是 options.file(包装对象)或 raw,需同时设置 url 到列表项
+    const listItem = tempPanoramaFileList.value.find(f => (f as any).raw === rawFile || (f as any) === rawFile);
+    if (listItem) {
+      (listItem as any).url = fileUrl;
+      (listItem as any).status = "success";
     }
-  } catch (error: any) {
-    options.onError(error);
-    ElMessage.error(error?.msg || "图片上传失败");
+    (options.file as any).url = fileUrl;
+    (options.file as any).status = "success";
+    (options.file as any).response = { data: { fileUrl } };
+    options.onSuccess({ data: { fileUrl } });
+  } catch (e) {
+    options.onError(e as any);
   }
 };
 
-// 图片预览
-const handlePictureCardPreview = (file: any) => {
-  const url = file.url || file.response?.fileUrl;
-  if (url) {
-    imageViewerUrlList.value = imageFileList.value.map((item: any) => item.url || item.response?.fileUrl).filter(Boolean);
-    imageViewerInitialIndex.value = imageViewerUrlList.value.indexOf(url);
+const handlePanoramaPreview = (file: UploadUserFile) => {
+  if (file.url) {
+    imageViewerUrlList.value = tempPanoramaFileList.value.map(f => f.url).filter(Boolean) as string[];
+    imageViewerInitialIndex.value = imageViewerUrlList.value.indexOf(file.url);
     imageViewerVisible.value = true;
   }
 };
 
-// 删除图片
-const handleRemoveImage = async (file: any) => {
-  const index = imageFileList.value.findIndex(item => item.uid === file.uid);
-  if (index > -1) {
-    try {
-      const userInfo: any = localGet("geeker-user")?.userInfo || {};
-      const storeId = userInfo.storeId;
-      if (storeId) {
-        // 调用删除图片的API,这里假设API为deleteOfficialImg
-        // 注意:实际使用时需要替换为真实的API
-        // await deleteOfficialImg({ id: file.uid, storeId: Number(storeId) });
-      }
-    } catch (error) {
-      console.error("删除图片失败:", error);
-      ElMessage.error("删除图片失败,请重试");
-    }
-    imageFileList.value.splice(index, 1);
-  }
+const handlePanoramaRemove = () => {
+  // 删除由 el-upload 自动处理,无需额外逻辑
 };
 
-// 重置表单
-const resetForm = () => {
-  formData.facilityCategory = activeTab.value;
-  formData.facilityName = "";
-  formData.quantity = undefined;
-  formData.brand = "";
-  formData.description = "";
-  formData.displayInStoreDetail = 1;
-  editId.value = null;
-  formRef.value?.clearValidate();
-};
-
-// 提交表单
-const handleSubmit = async () => {
-  if (!formRef.value) return;
-
+const confirmImageUpload = async () => {
+  if (!currentArea.value || !storeId.value) return;
+  const urls = tempPanoramaFileList.value.map(f => f.url).filter((u): u is string => !!u && u.startsWith("http"));
   try {
-    await formRef.value.validate();
-  } catch (error) {
-    ElMessage.warning("请完善必填项");
-    return;
-  }
-
-  submitLoading.value = true;
-  try {
-    const userInfo: any = localGet("geeker-user")?.userInfo || {};
-    const storeId = userInfo.storeId;
-    if (!storeId) {
-      ElMessage.error("未找到店铺ID");
-      submitLoading.value = false;
-      return;
-    }
-
-    const params: any = {
-      storeId: Number(storeId),
-      facilityCategory: formData.facilityCategory,
-      facilityName: formData.facilityName,
-      quantity: formData.quantity,
-      brand: formData.brand || "",
-      description: formData.description || "",
-      displayInStoreDetail: formData.displayInStoreDetail
-    };
-
-    if (editId.value) {
-      params.id = editId.value;
-    }
-
-    const res: any = await createOrUpdateFacility(params);
-    if (res && (res.code === 200 || res.code === "200")) {
-      // 获取保存后的设施ID
-      const facilityId = res.data?.id || (editId.value ? editId.value : null);
-
-      // 如果有图片列表,调用saveOfficialImg接口上传文件信息
-      if (facilityId && imageFileList.value.length > 0) {
-        const userInfo: any = localGet("geeker-user")?.userInfo || {};
-        const storeId = userInfo.storeId;
-        if (storeId) {
-          // 准备图片数据
-          const imageData = imageFileList.value.map((file: any, index: number) => ({
-            imgUrl: file.url || file.response?.fileUrl,
-            imgType: 28, // 设施图片类型
-            businessId: facilityId, // 设施ID
-            storeId: Number(storeId),
-            imgSort: index // 图片排序
-          }));
-
-          // 调用saveOfficialImg接口
-          const saveRes: any = await saveOfficialImg(imageData);
-          if (!(saveRes && (saveRes.code === 200 || saveRes.code === "200"))) {
-            console.error("保存图片信息失败:", saveRes);
-            ElMessage.warning("图片信息保存失败,请稍后重试");
-          }
-        }
-      }
-
-      ElMessage.success(editId.value ? "编辑成功" : "添加成功");
-      dialogVisible.value = false;
-      resetForm();
-      await loadFacilityList();
-      updatePagination();
+    saving.value = true;
+    const res: any = await updateSportsFacilityAreaHeadUrl({
+      areaId: currentArea.value.id,
+      areaHeadUrl: urls.join(",")
+    });
+    if (res?.code === 200) {
+      panoramaImages.value = urls;
+      if (currentArea.value) currentArea.value.areaHeadUrl = urls.join(",");
+      ElMessage.success("保存成功");
+      imageUploadDialogVisible.value = false;
     } else {
-      ElMessage.error(res?.msg || (editId.value ? "编辑失败" : "添加失败"));
+      ElMessage.error(res?.msg || "保存失败");
     }
-  } catch (error: any) {
-    console.error("操作失败:", error);
-    ElMessage.error(error?.msg || "操作失败,请重试");
+  } catch (e) {
+    console.error("保存实景图片失败:", e);
+    ElMessage.error("保存失败");
   } finally {
-    submitLoading.value = false;
+    saving.value = false;
   }
 };
 
-// 加载设施列表
-const loadFacilityList = async (category?: number) => {
+const handleRemoveAllImages = async () => {
+  if (!currentArea.value) return;
   try {
-    const userInfo: any = localGet("geeker-user")?.userInfo || {};
-    const storeId = userInfo.storeId;
-    if (!storeId) {
-      console.warn("未找到店铺ID");
-      return;
-    }
-
-    // 如果传入了分类则使用传入的分类,否则使用当前选中的标签页分类
-    const facilityCategory = category !== undefined ? category : activeTab.value;
-
-    const res: any = await getFacilityList({
-      storeId: Number(storeId),
-      facilityCategory: facilityCategory
+    saving.value = true;
+    const res: any = await updateSportsFacilityAreaHeadUrl({
+      areaId: currentArea.value.id,
+      areaHeadUrl: ""
     });
-    if (res && (res.code === 200 || res.code === "200") && res.data) {
-      const dataList = Array.isArray(res.data) ? res.data : [];
-
-      // 如果是加载指定分类的数据,则只更新该分类的数据
-      if (category !== undefined) {
-        // 先移除该分类的旧数据
-        facilityList.value = facilityList.value.filter(item => item.facilityCategory !== category);
-        // 再添加新数据
-        facilityList.value.push(
-          ...dataList.map((item: any) => ({
-            id: item.id,
-            facilityCategory: item.facilityCategory || category,
-            facilityName: item.facilityName || "",
-            quantity: item.quantity,
-            brand: item.brand || "",
-            description: item.description || "",
-            displayInStoreDetail: item.displayInStoreDetail !== undefined ? item.displayInStoreDetail : 1
-          }))
-        );
-      } else {
-        // 如果是加载所有数据,则直接替换
-        facilityList.value = dataList.map((item: any) => ({
-          id: item.id,
-          facilityCategory: item.facilityCategory || 1,
-          facilityName: item.facilityName || "",
-          quantity: item.quantity,
-          brand: item.brand || "",
-          description: item.description || "",
-          displayInStoreDetail: item.displayInStoreDetail !== undefined ? item.displayInStoreDetail : 1
-        }));
-      }
+    if (res?.code === 200) {
+      panoramaImages.value = [];
+      if (currentArea.value) currentArea.value.areaHeadUrl = "";
+      ElMessage.success("删除成功");
+    } else {
+      ElMessage.error(res?.msg || "删除失败");
     }
-  } catch (error) {
-    console.error("获取设施列表失败:", error);
+  } catch (e) {
+    console.error("删除图片失败:", e);
+    ElMessage.error("删除失败");
+  } finally {
+    saving.value = false;
   }
 };
-
-// 批量导入
-const handleBatchImport = () => {
-  batchImportVisible.value = true;
+const handleSaveImages = () => {
+  if (!currentArea.value) return;
+  openImageUpload();
 };
 
-// 文件选择
-const handleFileChange = (file: UploadFile) => {
-  importFile.value = file;
+const resetImageUpload = () => {
+  tempPanoramaFileList.value = [];
 };
 
-// 文件移除
-const handleFileRemove = () => {
-  importFile.value = null;
+const previewImage = (url: string) => {
+  imageViewerUrlList.value = panoramaImages.value;
+  imageViewerInitialIndex.value = panoramaImages.value.indexOf(url);
+  imageViewerVisible.value = true;
 };
 
-// 下载模板
-const downloadTemplate = async () => {
-  try {
-    const res: any = await downloadFacilityTemplate();
-    const blob =
-      res instanceof Blob
-        ? res
-        : new Blob([res], {
-            type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
-          });
-    const url = window.URL.createObjectURL(blob);
-    const link = document.createElement("a");
-    link.href = url;
-    link.download = `设施导入模板_${new Date().getTime()}.xlsx`;
-    document.body.appendChild(link);
-    link.click();
-    document.body.removeChild(link);
-    window.URL.revokeObjectURL(url);
-    ElMessage.success("模板下载成功");
-  } catch (error: any) {
-    console.error("下载模板失败:", error);
-    ElMessage.error(error?.msg || "模板下载失败,请重试");
+// 设施 CRUD
+const openCreateFacilityDialog = () => {
+  if (!currentArea.value) {
+    ElMessage.warning("请先选择区域");
+    return;
   }
+  isEditingFacility.value = false;
+  editingFacilityId.value = null;
+  facilityFormData.name = "";
+  facilityFormData.quantity = 1;
+  facilityFormData.imageUrl = "";
+  facilityFormData.showInDetail = 1;
+  facilityDialogVisible.value = true;
+};
+
+const editFacility = (item: FacilityItem) => {
+  isEditingFacility.value = true;
+  editingFacilityId.value = item.id;
+  facilityFormData.name = item.equipmentName || item.facilityName || "";
+  facilityFormData.quantity = item.equipmentNums ?? item.quantity ?? 1;
+  facilityFormData.imageUrl = item.equipmentImage || item.imgUrl || "";
+  facilityFormData.showInDetail = item.displayInStoreDetail ?? 1;
+  facilityDialogVisible.value = true;
 };
 
-// 提交导入
-const handleImportSubmit = async () => {
-  if (!importFile.value || !importFile.value.raw) {
-    ElMessage.warning("请先选择文件");
+const submitFacilityForm = async () => {
+  if (!facilityFormRef.value) return;
+  try {
+    await facilityFormRef.value.validate();
+  } catch {
     return;
   }
-
-  const userInfo: any = localGet("geeker-user")?.userInfo || {};
-  const storeId = userInfo.storeId;
-  if (!storeId) {
-    ElMessage.error("未找到店铺ID");
+  if (!storeId.value || !currentArea.value) {
+    ElMessage.error("未找到店铺或区域");
     return;
   }
-
-  importLoading.value = true;
+  saving.value = true;
   try {
-    const formData = new FormData();
-    formData.append("file", importFile.value.raw);
-    const res: any = await importFacilityExcel(formData, storeId);
-    if (res && (res.code === 200 || res.code === "200")) {
-      ElMessage.success("导入成功");
-      batchImportVisible.value = false;
-      importFile.value = null;
-      if (uploadRef.value) {
-        uploadRef.value.clearFiles();
-      }
-      await loadFacilityList();
-      updatePagination();
+    const params: any = {
+      storeId: storeId.value,
+      areaId: currentArea.value.id,
+      equipmentName: facilityFormData.name.trim(),
+      equipmentNums: facilityFormData.quantity,
+      equipmentImage: facilityFormData.imageUrl,
+      displayInStoreDetail: facilityFormData.showInDetail
+    };
+    let res: any;
+    if (isEditingFacility.value && editingFacilityId.value) {
+      params.equipmentId = editingFacilityId.value;
+      res = await updateSportsEquipmentFacility(params);
+    } else {
+      res = await saveSportsEquipmentFacility(params);
+    }
+    if (res?.code === 200) {
+      ElMessage.success(isEditingFacility.value ? "编辑成功" : "添加成功");
+      facilityDialogVisible.value = false;
+      await fetchFacilityList();
     } else {
-      ElMessage.error(res?.msg || "导入失败");
+      ElMessage.error(res?.msg || "操作失败");
     }
-  } catch (error: any) {
-    console.error("导入失败:", error);
-    ElMessage.error(error?.msg || "导入失败,请重试");
+  } catch (e) {
+    console.error("提交设施失败:", e);
+    ElMessage.error("操作失败");
   } finally {
-    importLoading.value = false;
+    saving.value = false;
   }
 };
 
-// 获取图片列表
-const getImageList = async () => {
+const deleteFacility = async (item: FacilityItem) => {
   try {
-    const userInfo: any = localGet("geeker-user")?.userInfo || {};
-    const storeId = userInfo.storeId;
-    if (storeId) {
-      const res: any = await getOfficialImgList(activeTab.value, Number(storeId), 28);
-      if (res && res.code === 200 && Array.isArray(res.data)) {
-        // 将返回的图片数据转换为el-upload组件所需的格式
-        imageFileList.value = res.data.map(item => ({
-          name: item.imgUrl?.split("/").pop() || "",
-          url: item.imgUrl,
-          uid: item.id || Math.random().toString(36).substr(2, 9)
-        }));
-      }
+    await ElMessageBox.confirm("确定删除该设施?", "提示", {
+      confirmButtonText: "确定",
+      cancelButtonText: "取消",
+      type: "warning"
+    });
+  } catch {
+    return;
+  }
+  try {
+    const res: any = await deleteSportsEquipmentFacility({ equipmentId: item.id });
+    if (res?.code === 200) {
+      ElMessage.success("删除成功");
+      await fetchFacilityList();
+    } else {
+      ElMessage.error(res?.msg || "删除失败");
     }
-  } catch (error) {
-    console.error("获取图片列表失败:", error);
+  } catch (e) {
+    console.error("删除设施失败:", e);
+    ElMessage.error("删除失败");
   }
 };
 
-// 页面初始化
+const resetAreaForm = () => {
+  areaFormData.name = "";
+  areaFormRef.value?.clearValidate();
+};
+const resetFacilityForm = () => {
+  facilityFormData.name = "";
+  facilityFormData.quantity = 1;
+  facilityFormData.imageUrl = "";
+  facilityFormData.showInDetail = 1;
+  facilityFormRef.value?.clearValidate();
+};
+
 onMounted(async () => {
-  await loadFacilityList();
-  updatePagination();
-  getImageList();
+  const userInfo: any = localGet("geeker-user")?.userInfo || {};
+  storeId.value = userInfo.storeId ? Number(userInfo.storeId) : null;
+  if (!storeId.value) {
+    ElMessage.warning("未找到店铺ID");
+    return;
+  }
+  await fetchFacilityAreas();
+  if (facilityAreas.value.length > 0) {
+    await fetchFacilityList();
+    fetchPanoramaImages();
+  }
 });
 </script>
 
 <style scoped lang="scss">
 .facility-management-container {
+  .empty-page-state {
+    display: flex;
+    flex-direction: column;
+    gap: 20px;
+    align-items: center;
+    padding: 60px 0;
+  }
   .header-section {
     display: flex;
     align-items: center;
     justify-content: space-between;
     margin-bottom: 20px;
-    :deep(.el-tabs) {
-      flex: 1;
-      .el-tabs__header {
-        margin: 0;
-      }
-    }
-    .action-buttons {
+    .area-tabs {
       display: flex;
-      gap: 10px;
-    }
-  }
-  .image-upload-section {
-    padding: 20px;
-    margin-bottom: 30px;
-    background-color: var(--el-bg-color-page);
-    border-radius: 8px;
-    .section-title {
-      margin-bottom: 16px;
-      font-size: 16px;
-      font-weight: 500;
-      color: var(--el-text-color-primary);
-    }
-    .upload-area {
-      :deep(.el-upload--picture-card) {
+      gap: 8px;
+      align-items: center;
+      .area-tab-item {
         position: relative;
-        .upload-tip {
+        padding: 8px 12px;
+        cursor: pointer;
+        .tab-text {
+          font-size: 16px;
+          font-weight: 500;
+          color: #334154;
+        }
+        &.active .tab-text {
+          font-weight: 600;
+          color: #334154;
+        }
+        .tab-underline {
           position: absolute;
-          bottom: 8px;
+          bottom: 0;
           left: 50%;
-          font-size: 12px;
-          color: var(--el-text-color-secondary);
+          width: 24px;
+          height: 4px;
+          background: var(--el-color-primary);
+          border-radius: 2px;
           transform: translateX(-50%);
         }
       }
+      .area-dropdown {
+        margin-left: 4px;
+        .dropdown-trigger {
+          display: inline-flex;
+          align-items: center;
+          padding: 4px;
+          color: #909399;
+          cursor: pointer;
+          &:hover {
+            color: var(--el-color-primary);
+          }
+        }
+      }
     }
   }
-  .facility-list-section {
-    .section-title {
+  .image-upload-section {
+    padding: 20px;
+    margin-bottom: 20px;
+    background: var(--el-bg-color-page);
+    border-radius: 8px;
+    .section-header {
+      display: flex;
+      gap: 12px;
+      align-items: center;
       margin-bottom: 16px;
-      font-size: 16px;
-      font-weight: 500;
-      color: var(--el-text-color-primary);
+      .section-title {
+        font-size: 16px;
+        font-weight: 500;
+      }
     }
-    .pagination-section {
+    .panorama-list {
       display: flex;
-      justify-content: flex-end;
-      margin-top: 20px;
-    }
-  }
-  .dialog-footer {
-    display: flex;
-    gap: 12px;
-    justify-content: flex-end;
-  }
-  .import-steps {
-    .import-step {
-      margin-bottom: 30px;
-      &:last-child {
-        margin-bottom: 0;
-      }
-      .step-header {
+      flex-wrap: wrap;
+      gap: 12px;
+      .panorama-images {
         display: flex;
+        flex-wrap: wrap;
         gap: 12px;
-        align-items: center;
-        margin-bottom: 16px;
-        .step-number {
-          display: flex;
-          align-items: center;
-          justify-content: center;
-          width: 24px;
-          height: 24px;
-          font-size: 14px;
-          font-weight: 500;
-          color: white;
-          background-color: var(--el-color-primary);
-          border-radius: 50%;
+      }
+      .panorama-item {
+        width: 120px;
+        height: 90px;
+        overflow: hidden;
+        cursor: pointer;
+        border-radius: 8px;
+        .panorama-img {
+          width: 100%;
+          height: 100%;
         }
-        .step-title {
-          font-size: 16px;
-          font-weight: 500;
-          color: var(--el-text-color-primary);
+      }
+      .upload-trigger {
+        display: flex;
+        flex-direction: column;
+        gap: 8px;
+        align-items: center;
+        justify-content: center;
+        width: 120px;
+        height: 90px;
+        color: var(--el-text-color-placeholder);
+        cursor: pointer;
+        border: 2px dashed var(--el-border-color);
+        border-radius: 8px;
+        &:hover {
+          color: var(--el-color-primary);
+          border-color: var(--el-color-primary);
         }
       }
     }
-    .import-upload {
-      :deep(.el-upload-dragger) {
-        width: 100%;
-        padding: 40px 20px;
-        .el-icon--upload {
-          font-size: 48px;
-          color: var(--el-text-color-placeholder);
-        }
-        .el-upload__text {
-          margin-top: 16px;
-          font-size: 14px;
-          color: var(--el-text-color-regular);
-          em {
-            font-style: normal;
-            color: var(--el-color-primary);
-          }
+    .image-actions {
+      display: flex;
+      gap: 12px;
+      margin-top: 16px;
+    }
+  }
+  .facility-list-section {
+    .section-header {
+      display: flex;
+      gap: 12px;
+      align-items: center;
+      margin-bottom: 16px;
+      .section-title {
+        font-size: 16px;
+        font-weight: 500;
+      }
+      .add-hint {
+        display: flex;
+        gap: 8px;
+        align-items: center;
+        .hint-text {
+          font-size: 12px;
+          color: var(--el-text-color-secondary);
         }
       }
     }
+    .facility-thumb {
+      width: 60px;
+      height: 60px;
+      border-radius: 4px;
+    }
+    .image-placeholder {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      width: 60px;
+      height: 60px;
+      color: var(--el-text-color-placeholder);
+      background: var(--el-fill-color-light);
+      border-radius: 4px;
+    }
+  }
+  .delete-item {
+    padding: 8px 0;
+  }
+}
+.panorama-upload-dialog {
+  .upload-tip {
+    margin-top: 8px;
+    font-size: 12px;
+    color: var(--el-text-color-secondary);
+    text-align: center;
+  }
+  :deep(.el-upload--picture-card) {
+    width: 120px;
+    height: 120px;
+  }
+  :deep(.el-upload-list--picture-card .el-upload-list__item) {
+    width: 120px;
+    height: 120px;
   }
 }
 </style>

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

@@ -117,7 +117,7 @@
             :width="'150px'"
             :height="'150px'"
             :file-size="9999"
-            :api="uploadImg"
+            :api="formData => uploadFormDataToOss(formData, 'image')"
             :file-type="['image/jpeg', 'image/png', 'image/gif', 'image/webp']"
             :border-radius="'8px'"
           />
@@ -187,7 +187,7 @@ import { ElMessage, ElMessageBox } from "element-plus";
 import type { FormInstance, FormRules, UploadFile } from "element-plus";
 import { Picture, UploadFilled } from "@element-plus/icons-vue";
 import UploadImg from "@/components/Upload/Img.vue";
-import { uploadImg } from "@/api/modules/newLoginApi";
+import { uploadFormDataToOss } from "@/api/upload.js";
 import { localGet } from "@/utils";
 import {
   getServiceList,

+ 2 - 4
src/views/storeDecoration/facilitiesAndServices/index.vue

@@ -12,14 +12,12 @@ import { localGet } from "@/utils";
 import ServiceManagement from "./components/ServiceManagement.vue";
 import FacilityManagement from "./components/FacilityManagement.vue";
 
-// 根据用户业务类型判断使用哪个组件
-// 运动健身 -> FacilityManagement (设施管理)
+// 根据用户业务类型判断使用哪个组件(与商家端 facilityOrService 一致)
+// 运动健身 -> FacilityManagement (设施管理,动态区域)
 // 洗浴汗蒸 -> ServiceManagement (服务管理)
 const activeComponent = computed<"service" | "facility">(() => {
   const geekerUser = localGet("geeker-user");
-  console.log("完整geeker-user:", geekerUser);
   const userInfo: any = geekerUser?.userInfo || {};
-  console.log("userInfo.businessSection:", userInfo.businessSection);
   const businessSection = userInfo.businessSection || "";
 
   // 如果是"运动健身",显示设施管理

+ 27 - 33
src/views/storeDecoration/officialPhotoAlbum/index.vue

@@ -185,7 +185,7 @@ import {
   getOfficialImgList,
   deleteOfficialImg
 } from "@/api/modules/storeDecoration";
-import { uploadImg } from "@/api/modules/newLoginApi";
+import { uploadFilesToOss } from "@/api/upload.js";
 
 // 扩展图片类型
 interface AlbumImage extends UploadUserFile {
@@ -395,34 +395,31 @@ const validateImageDimensions = (file: File): Promise<boolean> => {
   });
 };
 
-// 自定义图片上传API
+// 自定义图片上传 API(OSS 直传)
 const customImageUploadApi = async (formData: FormData): Promise<any> => {
-  const file = formData.get("file") as File;
+  const raw = formData.get("file");
+  const file = raw instanceof File ? raw : null;
+  if (!file) {
+    throw new Error("请选择文件");
+  }
 
-  // 先进行尺寸校验
   try {
     await validateImageDimensions(file);
   } catch (error) {
     throw error;
   }
 
-  // 校验通过后调用实际上传接口
-  const result: any = await uploadImg(formData);
-
-  // 处理返回格式:可能是 { data: { fileUrl: string } } 或 { data: string[] }
-  const fileUrl = result?.data?.fileUrl || result?.data?.[0] || result?.fileUrl;
+  const urls = await uploadFilesToOss(file, "image");
+  const fileUrl = urls[0];
+  if (!fileUrl) {
+    throw new Error("上传失败,未返回地址");
+  }
 
-  // 返回组件期望的格式
   const response = {
-    data: {
-      fileUrl: fileUrl
-    },
-    fileUrl: fileUrl // 也提供 fileUrl 字段,确保组件能正确获取
+    data: { fileUrl },
+    fileUrl
   };
-
-  // 确保上传成功后立即更新视图
   await nextTick();
-
   return response;
 };
 
@@ -471,34 +468,31 @@ const handleVideoUploadSuccess = async (url: string) => {
   }
 };
 
-// 自定义视频上传API(使用与图片相同的上传接口
+// 自定义视频上传 API(OSS 直传
 const customVideoUploadApi = async (formData: FormData): Promise<any> => {
-  const file = formData.get("file") as File;
+  const raw = formData.get("file");
+  const file = raw instanceof File ? raw : null;
+  if (!file) {
+    throw new Error("请选择文件");
+  }
 
-  // 视频大小校验(500MB)
   const fileSizeMB = file.size / 1024 / 1024;
   if (fileSizeMB > 500) {
     ElMessage.warning(`视频大小不能超过 500MB,当前为 ${fileSizeMB.toFixed(2)}MB`);
     throw new Error("视频大小超过限制");
   }
 
-  // 调用与图片相同的上传接口
-  const result: any = await uploadImg(formData);
-
-  // 处理返回格式:可能是 { data: { fileUrl: string } } 或 { data: string[] }
-  const fileUrl = result?.data?.fileUrl || result?.data?.[0] || result?.fileUrl;
+  const urls = await uploadFilesToOss(file, "video");
+  const fileUrl = urls[0];
+  if (!fileUrl) {
+    throw new Error("上传失败,未返回地址");
+  }
 
-  // 返回组件期望的格式
   const response = {
-    data: {
-      fileUrl: fileUrl
-    },
-    fileUrl: fileUrl // 也提供 fileUrl 字段,确保组件能正确获取
+    data: { fileUrl },
+    fileUrl
   };
-
-  // 确保上传成功后立即更新视图
   await nextTick();
-
   return response;
 };
 

+ 35 - 60
src/views/storeDecoration/personnelConfig/index.vue

@@ -243,7 +243,7 @@
             :width="'150px'"
             :height="'150px'"
             :file-size="9999"
-            :api="uploadImg"
+            :api="personnelOssUploadAvatar"
             :file-type="['image/jpeg', 'image/png', 'image/gif', 'image/webp']"
             :border-radius="'8px'"
           />
@@ -333,7 +333,7 @@ import ProTable from "@/components/ProTable/index.vue";
 import { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
 import UploadImg from "@/components/Upload/Img.vue";
 import UploadImgs from "@/components/Upload/Imgs.vue";
-import { uploadImg } from "@/api/modules/newLoginApi";
+import { uploadFileToOss, uploadFilesToOss } from "@/api/upload.js";
 import type { UploadUserFile } from "element-plus";
 import { localGet } from "@/utils";
 import {
@@ -649,66 +649,41 @@ const formatTime = (time: string | null | undefined) => {
 // 弹窗标题
 const dialogTitle = computed(() => (editId.value !== null ? "编辑" : "新建"));
 
-// 自定义上传函数,正确处理响应格式
-const handleCustomUpload = async (formData: FormData): Promise<any> => {
-  try {
-    const response: any = await uploadImg(formData);
-    console.log("=== 自定义上传函数 ===");
-    console.log("完整响应:", response);
-
-    // API 返回格式: { code: 200, success: true, data: ["https://..."], msg: "操作成功" }
-    // 需要提取 response.data[0] 作为图片 URL
-    let imageUrl = "";
-
-    if (response && (response.code === 200 || response.code === "200")) {
-      if (Array.isArray(response.data) && response.data.length > 0) {
-        imageUrl = response.data[0];
-      } else if (typeof response.data === "string") {
-        imageUrl = response.data;
-      } else if (response.data?.fileUrl) {
-        imageUrl = response.data.fileUrl;
-      } else if (response.fileUrl) {
-        imageUrl = response.fileUrl;
-      }
-    }
-
-    console.log("提取的图片URL:", imageUrl);
-
-    if (!imageUrl) {
-      console.error("无法提取图片URL,响应格式可能不正确");
-      throw new Error("无法提取图片URL");
-    }
-
-    // 返回格式需要兼容 Imgs.vue 组件的处理逻辑
-    // Imgs.vue 的 handleHttpUpload 中:
-    // const { data } = await api(formData);
-    // if (props.onSuccess) {
-    //   props.onSuccess(data.fileUrl ? data.fileUrl : data[0]);
-    // }
-    // options.onSuccess(data);
-    //
-    // uploadSuccess 函数会检查 response 的类型:
-    // - 如果是字符串,直接使用
-    // - 如果是数组,使用 response[0]
-    // - 如果有 fileUrl,使用 response.fileUrl
-    //
-    // 所以需要返回一个对象,其中 fileUrl 是图片URL,这样 uploadSuccess 会使用 response.fileUrl
-    const result = {
-      fileUrl: imageUrl, // 供 uploadSuccess 使用(会设置 uploadFile.url = response.fileUrl)
-      data: [imageUrl], // 备用
-      code: response.code,
-      msg: response.msg,
-      success: response.success
-    };
-
-    console.log("返回给组件的数据:", result);
-    console.log("==================");
+/**
+ * 头像上传:兼容 UploadImg(Img.vue)里 `const { data } = await api(formData)` 的解构写法
+ */
+const personnelOssUploadAvatar = async (formData: FormData): Promise<{ data: { fileUrl: string } }> => {
+  const raw = formData.get("file");
+  const file = raw instanceof File ? raw : null;
+  if (!file) {
+    throw new Error("请选择文件");
+  }
+  const url = await uploadFileToOss(file, "image");
+  return { data: { fileUrl: url } };
+};
 
-    return result;
-  } catch (error) {
-    console.error("自定义上传函数 - 上传失败:", error);
-    throw error;
+/**
+ * 背景图/视频:OSS 直传,返回格式兼容 UploadImgs(读取 response.fileUrl)
+ */
+const handleCustomUpload = async (formData: FormData): Promise<any> => {
+  const raw = formData.get("file");
+  const file = raw instanceof File ? raw : null;
+  if (!file) {
+    throw new Error("请选择文件");
+  }
+  const isVideo = typeof file.type === "string" && file.type.startsWith("video/");
+  const urls = await uploadFilesToOss(file, isVideo ? "video" : "image");
+  const fileUrl = urls[0];
+  if (!fileUrl) {
+    throw new Error("上传失败,未返回地址");
   }
+  return {
+    fileUrl,
+    data: [fileUrl],
+    code: 200,
+    success: true,
+    msg: "操作成功"
+  };
 };
 
 // 表单数据

+ 9 - 17
src/views/storeDecoration/storeEntranceMap/index.vue

@@ -74,7 +74,7 @@ import { ref, reactive, onMounted } from "vue";
 import { ElMessage, ElNotification } from "element-plus";
 import type { FormInstance, FormRules } from "element-plus";
 import UploadImg from "@/components/Upload/Img.vue";
-import { uploadImg } from "@/api/modules/newLoginApi";
+import { uploadFileToOss } from "@/api/upload.js";
 import { getEntranceImg, saveEntranceImg } from "@/api/modules/storeDecoration";
 import { localGet } from "@/utils";
 
@@ -94,23 +94,15 @@ const validateImageDimensions = (file: File): Promise<boolean> => {
   });
 };
 
-// 自定义上传API(已移除尺寸校验)
-const customUploadApi = async (formData: FormData): Promise<any> => {
-  // 已移除尺寸校验,直接上传
-
-  // 校验通过后调用实际上传接口
-  const response: any = await uploadImg(formData);
-  // 处理返回格式:{ code, success, data: string[], msg }
-  // UploadImg组件期望的格式:{ data: { fileUrl: string } } 或 { data: string[] }
-  if (response && response.code === 200 && response.data && Array.isArray(response.data) && response.data.length > 0) {
-    // 返回组件期望的格式:组件会检查 data.fileUrl,如果没有则取 data[0]
-    return {
-      data: {
-        fileUrl: response.data[0] // 取数组第一个元素作为图片URL
-      }
-    };
+/** OSS 直传;UploadImg 内为 `const { data } = await api(formData)`,需返回 `{ data: { fileUrl } }` */
+const customUploadApi = async (formData: FormData): Promise<{ data: { fileUrl: string } }> => {
+  const raw = formData.get("file");
+  const file = raw instanceof File ? raw : null;
+  if (!file) {
+    throw new Error("请选择文件");
   }
-  throw new Error(response?.msg || "上传失败");
+  const url = await uploadFileToOss(file, "image");
+  return { data: { fileUrl: url } };
 };
 
 // 表单校验规则

+ 19 - 8
src/views/storeDecoration/storeHeadMap/index.vue

@@ -146,7 +146,8 @@ import { ArrowRight } from "@element-plus/icons-vue";
 import Sortable from "sortablejs";
 import UploadImg from "@/components/Upload/Img.vue";
 import UploadImgs from "@/components/Upload/Imgs.vue";
-import { uploadImg, getStoreOcrData } from "@/api/modules/newLoginApi";
+import { getStoreOcrData } from "@/api/modules/newLoginApi";
+import { uploadFilesToOss } from "@/api/upload.js";
 import { getStoreHeadImg, saveStoreHeadImg } from "@/api/modules/storeDecoration";
 import { getDetail } from "@/api/modules/homeEntry";
 import { localGet } from "@/utils";
@@ -237,15 +238,25 @@ const displayImages = computed(() => {
   return formData.multipleImages.slice(0, 3).map(item => item.url || "");
 });
 
-// 处理上传图片
+/**
+ * OSS 直传:同时满足 UploadImg(解构 data)与 UploadImgs(读 response.fileUrl)
+ */
 const uploadImageResult = async (formData: FormData): Promise<any> => {
-  const response: any = await uploadImg(formData);
-  if (response.code == 200) {
-    return {
-      fileUrl: response.data[0] // 取数组第一个元素作为图片URL,直接返回 fileUrl
-    };
+  const raw = formData.get("file");
+  const file = raw instanceof File ? raw : null;
+  if (!file) {
+    throw new Error("请选择文件");
+  }
+  const isVideo = typeof file.type === "string" && file.type.startsWith("video/");
+  const urls = await uploadFilesToOss(file, isVideo ? "video" : "image");
+  const url = urls[0];
+  if (!url) {
+    throw new Error("上传失败,未返回地址");
   }
-  throw new Error(response?.msg || "上传失败");
+  return {
+    fileUrl: url,
+    data: { fileUrl: url }
+  };
 };
 
 // 单图模式表单校验规则

Деякі файли не було показано, через те що забагато файлів було змінено