Browse Source

Merge branch 'development' into uat

LuTong 3 tuần trước cách đây
mục cha
commit
31ff1ae0a8

+ 1 - 0
.env.development

@@ -26,6 +26,7 @@ VITE_API_URL_PLATFORM = /api/alienStorePlatform
 # VITE_PROXY = [["/api","https://api.ailien.shop"]] #生产环境
 VITE_PROXY = [["/api","http://120.26.186.130:8000"]] # 邹建宇
 
+
 # WebSocket 基础地址(分享等能力,与商家端一致)
 VITE_WS_BASE = http://120.26.186.130:8000/alienStore/socket/
 

+ 29 - 31
src/api/modules/newLoginApi.ts

@@ -341,54 +341,52 @@ export const deleteDynamicsById = (params: any) => {
   return httpLogin.get(`/alienStore/userDynamics/deleteDynamicsById`, params);
 };
 
+/** 数据字典(如银行名称 type: bankName) */
+export const getDict = (params: { dictType: string }) => {
+  return httpLogin.get(`/alienStore/dict/getDict`, params);
+};
+
 //入驻   根据店铺用户ID查询支付配置(设置收款账号)
 export const getPaymentStoreUserId = (params: any) => {
   return httpLogin.get(`/alienStore/store/payment/config/getByStoreUserId`, params);
 };
 
 /**
- * 以 multipart/form-data 方式保存微信支付配置(公钥、私钥以文件上传,Web 端传 File)
- * @param formFields - 除证书外的表单字段(storeUserId、apiV3Key、wechatAppId 等)
- * @param privateKeyFile - 私钥证书文件(input type="file" 的 file)
- * @param publicKeyFile - 公钥证书文件
+ * 保存微信收款配置(multipart/form-data,仅三字段)
+ * storeUserId:店铺用户ID;storeWechatId:商家微信 appid;storeWechatName:商家微信名称
  */
-export const saveWechatWithFiles = (
-  formFields: Record<string, string>,
-  privateKeyFile?: File | null,
-  publicKeyFile?: File | null
-) => {
+export const saveWechatWithFiles = (params: { storeUserId: string | number; storeWechatId: string; storeWechatName: string }) => {
   const fd = new FormData();
-  Object.entries(formFields).forEach(([key, value]) => {
-    if (value != null && value !== "") fd.append(key, String(value));
-  });
-  if (privateKeyFile) fd.append("wechatPrivateKeyFile", privateKeyFile);
-  if (publicKeyFile) fd.append("wechatPayPublicKeyFile", publicKeyFile);
+  fd.append("storeUserId", String(params.storeUserId));
+  fd.append("storeWechatId", params.storeWechatId.trim());
+  fd.append("storeWechatName", params.storeWechatName.trim());
   return httpLogin.post(`/alienStore/store/payment/config/saveWechatByStoreUserId`, fd);
 };
 
 /**
- * 以 multipart/form-data 方式保存支付宝支付配置(应用公钥、支付宝公钥、根证书以文件上传,Web 端传 File)
- * @param formFields - 除证书外的表单字段(storeUserId、appId、appSecretCert 等)
- * @param appPublicCertFile - 应用公钥证书文件
- * @param alipayPublicCertFile - 支付宝公钥证书文件
- * @param alipayRootCertFile - 支付宝根证书文件
+ * 保存支付宝收款配置(multipart/form-data,仅三字段)
+ * storeUserId:店铺用户ID;storeAliId:商家支付宝 appId;storeAliName:商家支付宝名称
  */
-export const saveAlipayWithFiles = (
-  formFields: Record<string, string>,
-  appPublicCertFile?: File | null,
-  alipayPublicCertFile?: File | null,
-  alipayRootCertFile?: File | null
-) => {
+export const saveAlipayWithFiles = (params: { storeUserId: string | number; storeAliId: string; storeAliName: string }) => {
   const fd = new FormData();
-  Object.entries(formFields).forEach(([key, value]) => {
-    if (value != null && value !== "") fd.append(key, String(value));
-  });
-  if (appPublicCertFile) fd.append("appPublicCert", appPublicCertFile);
-  if (alipayPublicCertFile) fd.append("alipayPublicCert", alipayPublicCertFile);
-  if (alipayRootCertFile) fd.append("alipayRootCert", alipayRootCertFile);
+  fd.append("storeUserId", String(params.storeUserId));
+  fd.append("storeAliId", params.storeAliId.trim());
+  fd.append("storeAliName", params.storeAliName.trim());
   return httpLogin.post(`/alienStore/store/payment/config/saveAlipayByStoreUserId`, fd);
 };
 
+/**
+ * 以 multipart/form-data 保存银行账户收款配置
+ * 字段:storeUserId、bankName、bankCardNo(与后端 @RequestParam / Multipart 对齐时可按需改名)
+ */
+export const saveBankAccountConfig = (params: { storeUserId: string | number; bankName: string; bankCardNo: string }) => {
+  const fd = new FormData();
+  fd.append("storeUserId", String(params.storeUserId));
+  fd.append("bankName", params.bankName);
+  fd.append("bankCardNo", params.bankCardNo);
+  return httpLogin.post(`/alienStore/store/payment/config/saveBankByStoreUserId`, fd);
+};
+
 // 根据手机号获取用户ID
 export const getUserByPhone = (params: {
   phone: string; // 手机号

+ 24 - 12
src/api/modules/scheduledService.ts

@@ -30,7 +30,7 @@ export const scheduleDel = (params: { id: number | string }) => {
 
 /** 分类排序 */
 export const scheduleSort = (data: { categoryIds: (number | string)[]; storeId: number | string }) => {
-  return httpApi.post(`${BASE_BOOKING}/sort`, data);
+  return httpApi.post(`${BASE_BOOKING}/updateSort`, data);
 };
 
 /** 新增/编辑分类(保存或更新) */
@@ -101,13 +101,15 @@ export const tableDel = (params: { id: number | string }) => {
 // ==================== 预约信息 ====================
 const BASE_RESERVATION = "/alienStore/store/reservation";
 
-/** 预约列表(分页,支持姓名、状态、预订日期筛选) */
+/** 预约列表(分页,支持预订人姓名、订单状态、预订日期筛选) */
 export const reservationList = (params: {
   storeId?: number | string;
-  name?: string;
-  status?: number | string;
+  reservationUserName?: string;
+  orderStatus?: number | string;
   startDate?: string;
   endDate?: string;
+  dateFrom?: string;
+  dateTo?: string;
   pageNum?: number;
   pageSize?: number;
   [key: string]: any;
@@ -115,8 +117,13 @@ export const reservationList = (params: {
   return httpApi.get(`${BASE_RESERVATION}/list`, params);
 };
 
-/** 加时 */
-export const reservationAddTime = (params: { id: number | string; [key: string]: any }) => {
+/** 加时(与 uni 商家端一致:reservationId、addTimeMinutes、addTimeStart) */
+export const reservationAddTime = (params: {
+  reservationId?: number | string;
+  addTimeMinutes?: number;
+  addTimeStart?: string;
+  [key: string]: any;
+}) => {
   const formData = Object.keys(params)
     .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`)
     .join("&");
@@ -125,8 +132,13 @@ export const reservationAddTime = (params: { id: number | string; [key: string]:
   });
 };
 
-/** 取消预约 */
-export const reservationCancel = (params: { id: number | string; [key: string]: any }) => {
+/** 取消预约(与 uni scheduledInfo:reservationId + cancelReason) */
+export const reservationCancel = (params: {
+  id?: number | string;
+  reservationId?: number | string;
+  cancelReason?: string;
+  [key: string]: any;
+}) => {
   const formData = Object.keys(params)
     .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`)
     .join("&");
@@ -174,11 +186,11 @@ export const getBookingBusinessHours = (params: { settingsId?: number | string }
 };
 
 /** 分类下桌号是否有预定(true 则不可编辑/删除分类) */
-export const getHasReservation = (params: { id: number | string }) => {
-  return httpApi.get(`/store/booking/category/hasReservation`, params);
+export const getHasReservation = (params: any) => {
+  return httpApi.get(`/alienStore/store/booking/category/hasReservation`, params);
 };
 
 /** 桌位是否有预定(true 则不可编辑/删除该桌) */
-export const getTableHasReservation = (params: { id: number | string }) => {
-  return httpApi.get(`/store/booking/table/hasReservation`, params);
+export const getTableHasReservation = (params: any) => {
+  return httpApi.get(`/alienStore/store/booking/table/hasReservation`, params);
 };

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

@@ -349,6 +349,20 @@
           }
         },
         {
+          "path": "/storeDecoration/receivingAccount",
+          "name": "receivingAccount",
+          "component": "/storeDecoration/receivingAccount/index",
+          "meta": {
+            "icon": "Wallet",
+            "title": "收款账号",
+            "isLink": "",
+            "isHide": false,
+            "isFull": false,
+            "isAffix": false,
+            "isKeepAlive": false
+          }
+        },
+        {
           "path": "/storeDecoration/businessHours",
           "name": "businessHours",
           "component": "/storeDecoration/businessHours/index",
@@ -1332,6 +1346,20 @@
           }
         }
       ]
+    },
+     {
+      "path": "/appoinmentManagement/appoinmentInfo",
+      "name": "appoinmentInfo",
+      "component": "/appoinmentManagement/appoinmentInfo",
+      "meta":{
+        "icon": "ChatDotSquare",
+        "title": "预定信息",
+        "isLink": "",
+        "isHide": false,
+        "isFull": false,
+        "isAffix": false,
+        "isKeepAlive": false
+      }
     }
   ],
   "msg": "成功"

+ 35 - 17
src/components/ProTable/index.vue

@@ -62,9 +62,9 @@
             <el-radio v-if="item.type == 'radio'" v-model="radio" :label="scope.row[rowKey]">
               <i />
             </el-radio>
-            <!-- sort -->
-            <el-tag v-if="item.type == 'sort' && item.condition && item.condition(scope, index)" class="move">
-              <el-icon> <DCaret /></el-icon>
+            <!-- sort:未配置 condition 时默认显示拖拽手柄 -->
+            <el-tag v-if="item.type == 'sort' && showSortHandle(item, scope, index)" class="move sort-drag-handle">
+              <el-icon><Rank /></el-icon>
             </el-tag>
           </template>
         </el-table-column>
@@ -104,13 +104,13 @@
 </template>
 
 <script setup lang="ts" name="ProTable">
-import { ref, watch, provide, onMounted, unref, computed, reactive } from "vue";
+import { ref, watch, provide, onMounted, unref, computed, reactive, nextTick } from "vue";
 import { ElTable } from "element-plus";
 import { useTable } from "@/hooks/useTable";
 import { useSelection } from "@/hooks/useSelection";
 import { BreakPoint } from "@/components/Grid/interface";
 import { ColumnProps, TypeProps } from "@/components/ProTable/interface";
-import { Refresh, Operation, Search } from "@element-plus/icons-vue";
+import { Refresh, Operation, Search, Rank } from "@element-plus/icons-vue";
 import { generateUUID, handleProp } from "@/utils";
 import SearchForm from "@/components/SearchForm/index.vue";
 import Pagination from "./components/Pagination.vue";
@@ -157,6 +157,12 @@ const uuid = ref("id-" + generateUUID());
 // column 列类型
 const columnTypes: TypeProps[] = ["selection", "radio", "index", "expand", "sort"];
 
+function showSortHandle(item: ColumnProps, scope: any, index: number) {
+  const fn = item.condition as ((a?: any, b?: number) => boolean) | undefined;
+  if (fn == null) return true;
+  return fn(scope, index);
+}
+
 // 是否显示搜索模块
 const isShowSearch = ref(true);
 
@@ -178,12 +184,7 @@ const { tableData, pageable, searchParam, searchInitParam, getTableList, search,
 // 清空选中数据列表
 const clearSelection = () => tableRef.value!.clearSelection();
 
-// 初始化表格数据 && 拖拽排序
-onMounted(() => {
-  dragSort();
-  props.requestAuto && getTableList();
-  props.data && (pageable.value.total = props.data.length);
-});
+let sortableInstance: Sortable | null = null;
 
 // 处理表格数据
 const processTableData = computed(() => {
@@ -297,20 +298,37 @@ const handleRefresh = () => {
   }
 };
 
-// 表格拖拽排序
-const dragSort = () => {
-  const tbody = document.querySelector(`#${uuid.value} tbody`) as HTMLElement;
-  Sortable.create(tbody, {
+// 表格拖拽排序(存在 sort 列时启用;数据更新后需重建实例)
+const dragSortInit = () => {
+  const hasSortCol = tableColumns.some((c: ColumnProps) => c.type === "sort");
+  if (!hasSortCol) return;
+  sortableInstance?.destroy();
+  sortableInstance = null;
+  const tbody = document.querySelector(`#${uuid.value} tbody`) as HTMLElement | null;
+  if (!tbody) return;
+  sortableInstance = Sortable.create(tbody, {
     handle: ".move",
     animation: 300,
     onEnd({ newIndex, oldIndex }) {
-      const [removedItem] = processTableData.value.splice(oldIndex!, 1);
-      processTableData.value.splice(newIndex!, 0, removedItem);
+      if (oldIndex == null || newIndex == null || oldIndex === newIndex) return;
+      const [removedItem] = processTableData.value.splice(oldIndex, 1);
+      processTableData.value.splice(newIndex, 0, removedItem);
       emit("dragSort", { newIndex, oldIndex });
     }
   });
 };
 
+onMounted(() => {
+  props.data && (pageable.value.total = props.data.length);
+  if (props.requestAuto) {
+    getTableList().then(() => nextTick(dragSortInit));
+  } else {
+    nextTick(dragSortInit);
+  }
+});
+
+watch(tableData, () => nextTick(dragSortInit));
+
 // 暴露给父组件的参数和方法 (外部需要什么,都可以从这里暴露出去)
 defineExpose({
   element: tableRef,

+ 1 - 1
src/components/ProTable/interface/index.ts

@@ -81,7 +81,7 @@ export interface ColumnProps<T = any>
   headerRender?: (scope: HeaderRenderScope<T>) => VNode; // 自定义表头内容渲染(tsx语法)
   render?: (scope: RenderScope<T>) => VNode | string; // 自定义单元格内容渲染(tsx语法)
   _children?: ColumnProps<T>[]; // 多级表头
-  condition?: (params?: any) => boolean;
+  condition?: (scope?: any, index?: number) => boolean;
 }
 
 export type ProTableInstance = Omit<InstanceType<typeof ProTable>, keyof ComponentPublicInstance | keyof ProTableProps>;

+ 5 - 3
src/hooks/useTable.ts

@@ -61,10 +61,12 @@ export const useTable = (
       Object.assign(state.totalParam, initParam, isPageable ? pageParam.value : {});
       let { data } = await api({ ...state.searchInitParam, ...state.totalParam });
       dataCallBack && (data = dataCallBack(data));
-      state.tableData = isPageable ? data.list : data;
-      // 解构后台返回的分页数据 (如果有分页更新分页信息)
+      // 无分页时 requestApi 仍常返回 { list, total },不能直接赋给表格(el-table 需要数组,否则会 data.reduce 报错)
       if (isPageable) {
-        state.pageable.total = data.total;
+        state.tableData = Array.isArray(data?.list) ? data.list : [];
+        state.pageable.total = data?.total ?? 0;
+      } else {
+        state.tableData = Array.isArray(data) ? data : Array.isArray(data?.list) ? data.list : [];
       }
     } catch (error) {
       requestError && requestError(error);

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

@@ -81,6 +81,7 @@ export const useAuthStore = defineStore({
               case "门店基础信息":
               case "门店头图":
               case "官方相册":
+              case "收款账号":
                 // 所有业务类型都显示
                 menu.meta.isHide = false;
                 break;

+ 350 - 99
src/views/appoinmentManagement/appoinmentInfo.vue

@@ -1,27 +1,40 @@
 <template>
   <div class="table-box appointment-info">
-    <!-- 筛选:点击搜索后生效(与翻页联动) -->
+    <!-- 筛选:点击搜索后生效(与翻页联动);列表参数 reservationUserName、orderStatus -->
     <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"
-        />
+        <span class="filter-label">姓名</span>
+        <el-input v-model="searchForm.reservationUserName" placeholder="请输入姓名" clearable style="width: 160px" />
+        <span class="filter-label">状态</span>
+        <el-select v-model="searchForm.orderStatus" placeholder="请选择" clearable style="width: 180px">
+          <el-option
+            v-for="opt in statusOptions"
+            :key="`${opt.label}-${String(opt.value)}`"
+            :label="opt.label"
+            :value="opt.value"
+          />
+        </el-select>
+        <div style="width: 600px">
+          <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: 80%; margin-left: 10px"
+          />
+        </div>
         <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">
+    <ProTable ref="proTable" :columns="columns" :request-api="getTableList" :init-param="initParam" :pagination="false">
       <template #tableHeader>
         <div class="table-header">
-          <el-button type="primary" @click="handleNew"> 新建 </el-button>
+          <!-- <el-button type="primary" @click="handleNew"> 新建 </el-button> -->
         </div>
       </template>
       <template #statusText="{ row }">
@@ -35,46 +48,116 @@
         </el-tooltip>
         <span v-else>—</span>
       </template>
-      <!-- 操作按钮与商家端 1.vue 一致:orderStatus 0待支付 1待使用 2已完成 3已过期 4已取消 5已关闭 6退款中 7已退款 8商家预订 -->
+      <!-- 操作按钮与 group_merchant scheduledInfo.vue 一致(orderStatus 判断顺序一致) -->
       <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>
+        <div class="op-actions">
+          <el-button v-if="hasReasonText(scope.row)" link type="primary" @click="openReasonDialog(scope.row)">
+            查看原因
+          </el-button>
+          <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, 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, 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, 7) && !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>
+        </div>
       </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-dialog v-model="reasonDialogVisible" title="原因" width="420px" append-to-body destroy-on-close>
+      <div class="reason-dialog-body">取消原因:</div>
+      <div class="reason-dialog-body">
+        {{ reasonDialogText || "—" }}
+      </div>
+      <template #footer>
+        <el-button type="primary" @click="reasonDialogVisible = false"> 知道了 </el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 取消原因(与 group_merchant scheduledInfo:reservationId + cancelReason) -->
+    <el-dialog
+      v-model="cancelReasonVisible"
+      title="取消原因"
+      width="480px"
+      append-to-body
+      destroy-on-close
+      class="cancel-reason-dialog"
+      @closed="resetCancelReasonDialog"
+    >
+      <el-form label-position="top" class="cancel-reason-form">
+        <el-form-item label="取消原因" required>
+          <el-input
+            v-model="cancelReasonText"
+            type="textarea"
+            :rows="4"
+            maxlength="500"
+            show-word-limit
+            placeholder="请输入取消原因"
+          />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="cancelReasonVisible = false"> 取消 </el-button>
+        <el-button type="primary" :loading="cancelReasonLoading" @click="confirmCancelReason"> 确定 </el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 加时弹窗(与 group_merchant scheduledInfo:1-99 分钟、reservationId + addTimeMinutes + addTimeStart) -->
+    <el-dialog
+      v-model="addTimeVisible"
+      title="加时"
+      width="480px"
+      append-to-body
+      destroy-on-close
+      class="add-time-dialog"
+      @closed="resetAddTimeDialog"
+    >
+      <el-form :model="addTimeForm" label-position="top" require-asterisk-position="right" class="add-time-form">
+        <el-form-item label="加时时长" required>
+          <el-input
+            :model-value="addTimeForm.minutesInput"
+            placeholder="请输入"
+            clearable
+            maxlength="2"
+            @update:model-value="onAddTimeMinutesInput"
+          >
+            <template #append>
+              <span class="add-time-append-unit">分钟</span>
+            </template>
+          </el-input>
         </el-form-item>
       </el-form>
       <template #footer>
@@ -101,6 +184,8 @@ import { localGet } from "@/utils";
 
 export interface ReservationRow {
   id: number | string;
+  /** 与商家端加时接口一致,缺省时用 id */
+  reservationId?: number | string;
   _seq?: number;
   date: string;
   weekDay: string;
@@ -139,20 +224,28 @@ const columns: ColumnProps<ReservationRow>[] = [
   { 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" }
+  { prop: "operation", label: "操作", fixed: "right", width: 240, align: "center" }
 ];
 
-/** 与商家端 1.vue 筛选、列表 status 一致(orderStatus) */
-const statusOptions = [
+/**
+ * 筛选状态:商家取消 / 用户取消 均为 orderStatus=4,列表展示区分见 mapReservationRow(reason 空=用户取消)
+ * 接口仅支持 orderStatus 时,请求 4 后在本地按 reason 再筛一层
+ */
+const STATUS_FILTER_MERCHANT_CANCEL = "merchant_cancel";
+const STATUS_FILTER_USER_CANCEL = "user_cancel";
+
+const statusOptions: { label: string; value: number | string }[] = [
   { label: "全部", value: "" },
   { label: "待使用", value: 1 },
-  { label: "已完成", value: 2 },
-  { label: "退款", value: 6 } // 退款 tab:6退款中(接口若合并 7 需后端支持)
+  { label: "退款成功", value: 7 },
+  { label: "商家取消", value: STATUS_FILTER_MERCHANT_CANCEL },
+  { label: "用户取消", value: STATUS_FILTER_USER_CANCEL },
+  { label: "已过期", value: 3 },
+  { label: "已完成", value: 2 }
 ];
 
 /** orderStatus 文案,与 1.vue ORDER_STATUS_TEXT 一致 */
@@ -185,36 +278,42 @@ const initParam = reactive({
   storeId: localGet("geeker-user")?.userInfo?.storeId ?? localGet("createdId") ?? ""
 });
 
-/** 已生效的查询条件(点击搜索后写入) */
+/** 已生效的查询条件(点击搜索后写入;接口字段 reservationUserName、orderStatus) */
 const listFilter = reactive({
-  name: "",
-  status: "" as number | string,
+  reservationUserName: "",
+  orderStatus: "" as number | string,
   startDate: "",
   endDate: ""
 });
 
 const searchForm = reactive({
-  name: "",
-  status: "" as number | string,
+  reservationUserName: "",
+  orderStatus: "" as number | string,
   dateRange: [] as string[]
 });
 
 const addTimeVisible = ref(false);
 const addTimeLoading = ref(false);
-const addTimeForm = reactive({ minutes: 30 });
+const addTimeForm = reactive({ minutesInput: "" });
 const currentAddTimeRow = ref<ReservationRow | null>(null);
 
+/** 取消预约:原因弹窗(对齐 scheduledInfo) */
+const cancelReasonVisible = ref(false);
+const cancelReasonLoading = ref(false);
+const cancelReasonText = ref("");
+const cancelCurrentRow = ref<ReservationRow | null>(null);
+
 function getStoreId(): number | string | null {
   return localGet("geeker-user")?.userInfo?.storeId ?? localGet("createdId") ?? null;
 }
 
+/** 与 scheduledInfo normalizeReservationItem:pending(0,1,8) | done(2) | refund(6,7) | closed(3,4,5) */
 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 === 6 || s === 7) return "warning";
   if (s === 3 || s === 4 || s === 5) return "info";
+  if (s === 0 || s === 1 || s === 8) return "primary";
   return "info";
 }
 
@@ -231,6 +330,18 @@ function cancelReasonIsEmpty(row: ReservationRow) {
   return String(row.reason ?? "").trim() === "";
 }
 
+function hasReasonText(row: ReservationRow) {
+  return String(row.reason ?? "").trim() !== "";
+}
+
+const reasonDialogVisible = ref(false);
+const reasonDialogText = ref("");
+
+function openReasonDialog(row: ReservationRow) {
+  reasonDialogText.value = String(row.reason ?? "").trim() || "—";
+  reasonDialogVisible.value = true;
+}
+
 function parseRowEndDateTime(row: ReservationRow): Date | null {
   const et = row.endTimeRaw;
   if (et != null && et !== "") {
@@ -311,6 +422,7 @@ function mapReservationRow(item: any): ReservationRow {
 
   return {
     id: item.id,
+    reservationId: item.reservationId ?? item.id,
     date: formatDateCol(date),
     weekDay: item.weekDay ?? getWeekDay(date),
     location: item.locationName ?? item.categoryName ?? item.location ?? "—",
@@ -332,8 +444,16 @@ function mapReservationRow(item: any): ReservationRow {
     reservationDateRaw: String(date).trim(),
     timeSlotRaw,
     endTimeRaw: item.endTime ?? item.bookingEndTime ?? item.reservationEndTime,
+    /** scheduledInfo:orderStatus==4 时 reason 空=用户取消,非空=商家取消 */
     statusText:
-      (item.orderStatusText && String(item.orderStatusText).trim()) || ORDER_STATUS_TEXT[orderStatus] || item.statusText || "—",
+      orderStatus === 4
+        ? reason === ""
+          ? "用户取消"
+          : "商家取消"
+        : (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,
@@ -354,13 +474,20 @@ async function getTableList(params: any) {
     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.reservationUserName?.trim()) {
+    req.reservationUserName = listFilter.reservationUserName.trim();
+  }
+  const sf = listFilter.orderStatus;
+  if (sf !== undefined && sf !== "" && sf !== null) {
+    if (sf === STATUS_FILTER_USER_CANCEL || sf === STATUS_FILTER_MERCHANT_CANCEL) {
+      req.orderStatus = 4;
+    } else {
+      req.orderStatus = sf;
+    }
   }
   if (listFilter.startDate && listFilter.endDate) {
-    req.startDate = listFilter.startDate;
-    req.endDate = listFilter.endDate;
+    req.dateFrom = listFilter.startDate;
+    req.dateTo = listFilter.endDate;
   }
   try {
     const res: any = await reservationList(req);
@@ -368,12 +495,19 @@ async function getTableList(params: any) {
     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),
+    const apiTotal = Number(inner?.total ?? body?.total ?? res?.total ?? 0) || 0;
+    let rows = arr.map((item: any) => mapReservationRow(item));
+    if (sf === STATUS_FILTER_USER_CANCEL) {
+      rows = rows.filter(r => r.orderStatus === 4 && cancelReasonIsEmpty(r));
+    } else if (sf === STATUS_FILTER_MERCHANT_CANCEL) {
+      rows = rows.filter(r => r.orderStatus === 4 && !cancelReasonIsEmpty(r));
+    }
+    const finalRows = rows.map((r, i) => ({
+      ...r,
       _seq: (pageNum - 1) * pageSize + i + 1
     }));
-    return { data: { list: rows, total: Number(total) || 0 } };
+    const total = sf === STATUS_FILTER_USER_CANCEL || sf === STATUS_FILTER_MERCHANT_CANCEL ? finalRows.length : apiTotal;
+    return { data: { list: finalRows, total } };
   } catch (e: any) {
     ElMessage.error(e?.message || "加载失败");
     return { data: { list: [] as ReservationRow[], total: 0 } };
@@ -381,8 +515,8 @@ async function getTableList(params: any) {
 }
 
 function handleSearch() {
-  listFilter.name = searchForm.name;
-  listFilter.status = searchForm.status;
+  listFilter.reservationUserName = searchForm.reservationUserName;
+  listFilter.orderStatus = searchForm.orderStatus;
   if (searchForm.dateRange?.length === 2) {
     listFilter.startDate = searchForm.dateRange[0];
     listFilter.endDate = searchForm.dateRange[1];
@@ -394,11 +528,11 @@ function handleSearch() {
 }
 
 function handleReset() {
-  searchForm.name = "";
-  searchForm.status = "";
+  searchForm.reservationUserName = "";
+  searchForm.orderStatus = "";
   searchForm.dateRange = [];
-  listFilter.name = "";
-  listFilter.status = "";
+  listFilter.reservationUserName = "";
+  listFilter.orderStatus = "";
   listFilter.startDate = "";
   listFilter.endDate = "";
   proTable.value?.getTableList();
@@ -409,40 +543,104 @@ function handleNew() {
 }
 
 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(() => {});
+  cancelCurrentRow.value = row;
+  cancelReasonText.value = "";
+  cancelReasonVisible.value = true;
+}
+
+function resetCancelReasonDialog() {
+  cancelCurrentRow.value = null;
+  cancelReasonText.value = "";
+}
+
+async function confirmCancelReason() {
+  const reason = (cancelReasonText.value || "").trim();
+  if (!reason) {
+    ElMessage.warning("请输入取消原因");
+    return;
+  }
+  const row = cancelCurrentRow.value;
+  const reservationId = row?.reservationId ?? row?.id;
+  if (reservationId == null || reservationId === "") {
+    ElMessage.warning("缺少预订ID");
+    return;
+  }
+  cancelReasonLoading.value = true;
+  try {
+    const apiRes: any = await reservationCancel({ reservationId, cancelReason: reason });
+    ElMessage.success(apiRes?.msg || "已取消");
+    cancelReasonVisible.value = false;
+    resetCancelReasonDialog();
+    proTable.value?.getTableList();
+  } catch (e: any) {
+    if (typeof e?.code !== "number") {
+      ElMessage.error(e?.msg || e?.data?.msg || e?.message || "取消失败");
+    }
+  } finally {
+    cancelReasonLoading.value = false;
+  }
+}
+
+/** 与 scheduledInfo confirmAddTime:当前时间 yyyy-MM-dd HH:mm */
+function formatAddTimeStartFromNow(): string {
+  const now = new Date();
+  return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
+}
+
+/** 与 scheduledInfo onAddTimeMinutesInput:最多 2 位数字 1-99 */
+function onAddTimeMinutesInput(val: string | number) {
+  const v = String(val ?? "")
+    .replace(/\D/g, "")
+    .slice(0, 2);
+  addTimeForm.minutesInput = v;
+}
+
+function resetAddTimeDialog() {
+  currentAddTimeRow.value = null;
+  addTimeForm.minutesInput = "";
 }
 
 function handleAddTime(row: ReservationRow) {
   currentAddTimeRow.value = row;
-  addTimeForm.minutes = 30;
+  addTimeForm.minutesInput = "";
   addTimeVisible.value = true;
 }
 
 async function confirmAddTime() {
   const row = currentAddTimeRow.value;
   if (!row) return;
+  const raw = String(addTimeForm.minutesInput ?? "").trim();
+  if (!raw) {
+    ElMessage.warning("请输入加时时长");
+    return;
+  }
+  const addTimeMinutesNum = parseInt(raw, 10);
+  if (isNaN(addTimeMinutesNum) || addTimeMinutesNum < 1 || addTimeMinutesNum > 99) {
+    ElMessage.warning("加时时长为1-99的整数");
+    return;
+  }
+  const reservationId = row.reservationId ?? row.id;
+  if (reservationId == null || reservationId === "") {
+    ElMessage.warning("缺少预订ID");
+    return;
+  }
+  const addTimeStart = formatAddTimeStartFromNow();
   addTimeLoading.value = true;
   try {
-    await reservationAddTime({ id: row.id, addMinutes: addTimeForm.minutes });
-    ElMessage.success("加时成功");
+    const apiRes: any = await reservationAddTime({
+      reservationId,
+      addTimeMinutes: addTimeMinutesNum,
+      addTimeStart
+    });
+    ElMessage.success(apiRes?.msg || "加时成功");
     addTimeVisible.value = false;
+    resetAddTimeDialog();
     proTable.value?.getTableList();
   } catch (e: any) {
-    ElMessage.error(e?.message || "加时失败");
+    // 业务 code≠200 时 indexApi 响应拦截器已提示 msg,避免重复 Toast
+    if (typeof e?.code !== "number") {
+      ElMessage.error(e?.msg || e?.data?.msg || e?.message || "加时失败");
+    }
   } finally {
     addTimeLoading.value = false;
   }
@@ -523,4 +721,57 @@ function handleRefund(row: ReservationRow) {
   white-space: nowrap;
   cursor: default;
 }
+.op-actions {
+  display: inline-flex;
+  flex-wrap: wrap;
+  gap: 4px 8px;
+  align-items: center;
+  justify-content: center;
+}
+.reason-dialog-body {
+  padding: 8px 4px;
+  font-size: 14px;
+  line-height: 1.6;
+  color: #303133;
+  word-break: break-word;
+  white-space: pre-wrap;
+}
+</style>
+
+<!-- append-to-body 时弹窗挂到 body,需非 scoped 命中 -->
+<style lang="scss">
+.add-time-dialog.el-dialog .el-dialog__body {
+  padding-top: 8px;
+}
+.add-time-form {
+  width: 100%;
+}
+.add-time-form .el-form-item {
+  margin-bottom: 8px;
+}
+.add-time-form .el-form-item__label {
+  font-weight: 500;
+  color: #303133;
+}
+.add-time-form .el-input-group {
+  width: 100%;
+}
+.add-time-form .el-input__wrapper {
+  flex: 1;
+  min-width: 0;
+}
+.add-time-append-unit {
+  padding: 0 4px;
+  font-size: 14px;
+  color: #606266;
+}
+.cancel-reason-dialog.el-dialog .el-dialog__body {
+  padding-top: 8px;
+}
+.cancel-reason-form {
+  width: 100%;
+}
+.cancel-reason-form .el-form-item {
+  margin-bottom: 0;
+}
 </style>

+ 80 - 7
src/views/appoinmentManagement/classifyManagement.vue

@@ -1,9 +1,20 @@
 <template>
   <div class="table-box classify-management">
-    <ProTable ref="proTable" :columns="columns" :request-api="getTableList" :init-param="initParam">
+    <ProTable
+      ref="proTable"
+      :columns="columns"
+      :request-api="getTableList"
+      :init-param="initParam"
+      :pagination="false"
+      @drag-sort="onDragSortEnd"
+    >
       <template #tableHeader>
         <div class="table-header">
-          <el-button type="primary" @click="handleNew"> 新建 </el-button>
+          <div class="table-header-left">
+            <el-button type="primary" @click="handleNew"> 新建 </el-button>
+            <span v-if="sortSaving" class="sort-saving-tip">正在保存排序…</span>
+            <span v-else class="sort-tip">拖动「排序」列图标可调整顺序,松手后自动保存</span>
+          </div>
         </div>
       </template>
       <template #isDisplay="{ row }">
@@ -26,7 +37,14 @@
       destroy-on-close
       @close="onDialogClose"
     >
-      <el-form ref="formRef" :model="form" :rules="rules" label-width="160px" v-loading="detailLoading">
+      <el-form
+        ref="formRef"
+        :model="form"
+        :rules="rules"
+        label-width="160px"
+        require-asterisk-position="right"
+        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>
@@ -77,6 +95,7 @@
 <script setup lang="ts">
 import { ref, reactive } from "vue";
 import { ElMessage, ElMessageBox } from "element-plus";
+import { useDebounceFn } from "@vueuse/core";
 import type { FormInstance, FormRules } from "element-plus";
 import type { UploadRequestOptions, UploadUserFile } from "element-plus";
 import { Plus } from "@element-plus/icons-vue";
@@ -88,7 +107,8 @@ import {
   scheduleAdd,
   scheduleEdit,
   scheduleDetail,
-  getHasReservation
+  getHasReservation,
+  scheduleSort
 } from "@/api/modules/scheduledService";
 import { uploadFileToOss } from "@/api/upload.js";
 import { localGet } from "@/utils";
@@ -103,8 +123,10 @@ export interface CategoryRow {
 }
 
 const proTable = ref<ProTableInstance>();
+const sortSaving = ref(false);
 
 const columns: ColumnProps<CategoryRow>[] = [
+  { type: "sort", label: "排序", width: 64, align: "center" },
   { prop: "_seq", label: "序号", width: 60, align: "center" },
   { prop: "name", label: "名称" },
   { prop: "maxBookingTime", label: "最长预订时间(分钟)", align: "center" },
@@ -112,8 +134,11 @@ const columns: ColumnProps<CategoryRow>[] = [
   { prop: "operation", label: "操作", fixed: "right", width: 140, align: "center" }
 ];
 
+/** 关闭分页并一次拉全量,拖拽排序后提交的 categoryIds 与门店顺序一致 */
 const initParam = reactive({
-  storeId: localGet("geeker-user")?.userInfo?.storeId ?? localGet("createdId") ?? ""
+  storeId: localGet("geeker-user")?.userInfo?.storeId ?? localGet("createdId") ?? "",
+  pageNum: 1,
+  pageSize: 500
 });
 
 function mapCategoryItem(item: any): CategoryRow {
@@ -230,6 +255,39 @@ function getStoreId(): number | string | null {
   return localGet("geeker-user")?.userInfo?.storeId ?? localGet("createdId") ?? null;
 }
 
+function getTableRowsFromProTable(): CategoryRow[] {
+  const p = proTable.value as any;
+  const td = p?.tableData;
+  const list = td && typeof td === "object" && "value" in td ? td.value : td;
+  return Array.isArray(list) ? list : [];
+}
+
+/** 拖拽结束后按当前表格顺序保存(防抖,避免连续误触) */
+const persistSortOrder = useDebounceFn(async () => {
+  const storeId = getStoreId();
+  if (!storeId) return;
+  const rows = getTableRowsFromProTable();
+  const categoryIds = rows.map(r => r.id).filter(id => id != null && id !== "");
+  if (!categoryIds.length) return;
+  sortSaving.value = true;
+  try {
+    await scheduleSort({ categoryIds, storeId });
+    rows.forEach((row, i) => {
+      row._seq = i + 1;
+    });
+    ElMessage.success("排序已保存");
+  } catch (e: any) {
+    ElMessage.error(e?.message || "排序保存失败");
+    proTable.value?.getTableList();
+  } finally {
+    sortSaving.value = false;
+  }
+}, 400);
+
+function onDragSortEnd() {
+  persistSortOrder();
+}
+
 /** 接口返回 true 表示该分类下桌号存在预定,不可编辑/删除 */
 function parseHasReservationResponse(res: any): boolean {
   const body = res?.data ?? res;
@@ -241,8 +299,8 @@ function parseHasReservationResponse(res: any): boolean {
   return Boolean(val);
 }
 
-async function categoryHasReservation(categoryId: number | string): Promise<boolean> {
-  const res: any = await getHasReservation({ id: categoryId });
+async function categoryHasReservation(categoryId: number | string, storeId: number | string): Promise<boolean> {
+  const res: any = await getHasReservation({ categoryId: categoryId, storeId: getStoreId() });
   return parseHasReservationResponse(res);
 }
 
@@ -384,6 +442,7 @@ async function submitForm() {
 <style scoped lang="scss">
 :deep(.el-dialog__body) {
   height: 400px;
+  border: 1px solid red;
 }
 .classify-management {
   padding: 0;
@@ -395,6 +454,20 @@ async function submitForm() {
   width: 100%;
   padding: 12px 0;
 }
+.table-header-left {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+  align-items: center;
+}
+.sort-tip,
+.sort-saving-tip {
+  font-size: 12px;
+  color: #909399;
+}
+.sort-saving-tip {
+  color: #6c8ff8;
+}
 .status-show {
   color: #67c23a;
 }

+ 72 - 29
src/views/appoinmentManagement/infoManagement.vue

@@ -421,19 +421,57 @@ function minutesToTimeStr(total: number): string {
   return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
 }
 
-/** 与 merchant:填写了「结束前不可预订」则结束时间可选上限 = 营业 end − 该分钟数 */
+/**
+ * 与 group_merchant infoManagement:只要填写了「结束前不可预订」分钟数,
+ * 正常/特殊营业的结束时间可选上限 = 营业 endTime − 该分钟数(picker 用)
+ */
 function getNoBookMinutesForPicker(): number {
   const v = form.booking.noBookMinutesBeforeClose;
   if (v == null) return 0;
-  const n = Number(String(v).trim());
+  const n = Number(v);
   return n > 0 && !isNaN(n) ? n : 0;
 }
 
+/**
+ * 门店营业结束时刻 → 回显「可预约结束时间」:营业 endTime − 「结束前不可预订」分钟(与 computePickerEndMinutes 一致)。
+ * 仅用于从 getStoreInfoBusinessHours 回填;预约接口已存的 endTime 勿再调用,避免重复相减。
+ */
+function echoBookableEndFromStoreEnd(storeEndTime: string | undefined | null): string {
+  const raw = String(storeEndTime ?? "23:59").trim() || "23:59";
+  const noBook = getNoBookMinutesForPicker();
+  if (!noBook) return raw;
+  return subtractMinutesFromTime(raw, noBook);
+}
+
+/** 正常营业「全天/非全天」仅按门店 getStoreInfoBusinessHours 中 businessType=1 或 0 的 startTime、endTime */
+function applyNormalTimeTypeFromStore() {
+  const storeInfoList = Array.isArray(listFromStoreInfo.value) ? listFromStoreInfo.value : [];
+  const normal = storeInfoList.find((item: any) => item.businessType === 1 || item.businessType === 0);
+  if (!normal) return;
+  const st = String(normal.startTime ?? "").trim();
+  const et = String(normal.endTime ?? "").trim();
+  form.normalBook.timeType = st === "00:00" && et === "00:00" ? "allDay" : "notAllDay";
+}
+
+/** 无预约数据时,修改「结束前不可预订」需同步更新「可预约结束时间」回显(与 echoBookableEndFromStoreEnd 一致) */
+watch(
+  () => form.booking.noBookMinutesBeforeClose,
+  () => {
+    const list = listBooking.value;
+    if (Array.isArray(list) && list.length) return;
+    const storeInfoList = listFromStoreInfo.value;
+    const storeNormal = storeInfoList.find((item: any) => item.businessType === 1 || item.businessType === 0);
+    if (!storeNormal) return;
+    form.normalBook.endTime = echoBookableEndFromStoreEnd(storeNormal.endTime);
+  }
+);
+
 function computePickerEndMinutes(endTimeRaw: string): number {
   const noBook = getNoBookMinutesForPicker();
   return timeStrToMinutes(subtractMinutesFromTime(endTimeRaw || "23:59", noBook));
 }
 
+/** 与商家端:正常营业时间选择器仅以 getStoreInfoBusinessHours(businessType=1)的 startTime、endTime 为可选范围 */
 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";
@@ -643,58 +681,64 @@ async function fetchStoreInfoBusinessHours() {
     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";
+    const normalStore = list.find((item: any) => item.businessType === 1 || item.businessType === 0);
+    if (normalStore) {
+      form.normalBook.normalId = Number(normalStore.id) || 0;
     }
+    /** 与商家端:正常/特殊营业时间由 getBookingBusinessHours + applyListBookingToForm 统一回显 */
+    form.specialList = [];
   } catch {
     listFromStoreInfo.value = [];
   }
 }
 
+/** 与 group_merchant scheduledService/infoManagement:listBooking + listFromStoreInfo 回显正常/特殊营业 */
 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)
   );
 
+  const storeNormal = storeInfoList.find((item: any) => item.businessType === 1 || item.businessType === 0);
+
+  /** 无预约配置:正常营业从门店营业时间回显,结束时间用 echoBookableEndFromStoreEnd(可预约结束) */
+  if (!list.length) {
+    if (storeNormal) {
+      form.normalBook.startTime = String(storeNormal.startTime ?? "").trim();
+      form.normalBook.endTime = echoBookableEndFromStoreEnd(storeNormal.endTime);
+      form.normalBook.normalId = Number(storeNormal.id) || 0;
+    } else {
+      form.normalBook.startTime = "";
+      form.normalBook.endTime = "";
+    }
+  } else {
+    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.startTime = st;
+      form.normalBook.endTime = et;
+      form.normalBook.normalId = Number(normal.id) || 0;
+    }
+  }
+  applyNormalTimeTypeFromStore();
+
   if (useStoreInfoForSpecial) {
     if (bookingEmpty) {
       form.specialList = [];
       return;
     }
-    /** 有预约数据但未配置特殊营业行:不占位,用户先选节日 */
     form.specialList = [];
     return;
   }
 
-  /** 仅 getBookingBusinessHours 中 holidayType 与门店节日匹配时回显时间;allDay 以门店营业时间为准 */
+  /** 仅 getBookingBusinessHours 返回的 holidayType 对应项回显;特殊营业结束时间按接口原样,不减「结束前不可预订」 */
   form.specialList = specialFromStore.map((s: any, index: number) => {
     const name = (s.holidayInfo && s.holidayInfo.festivalName) || s.businessDate || s.holidayType || `特殊营业${index + 1}`;
     const storeFestivalName = (
@@ -720,12 +764,11 @@ function applyListBookingToForm() {
     }
     const startTime = match.startTime || "";
     const endTimeRaw = match.endTime || "23:59";
-    const endTime = endTimeRaw;
     return {
       name,
       allDay: allDayFromStoreSpecialRow(s),
       startTime,
-      endTime,
+      endTime: endTimeRaw,
       id: s.id,
       essentialId: s.essentialId,
       userPickedSpecial: true

+ 266 - 45
src/views/appoinmentManagement/tableManagement.vue

@@ -27,34 +27,79 @@
     <!-- 新建/编辑弹窗 -->
     <el-dialog
       v-model="dialogVisible"
+      class="table-management-dialog"
       :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-form
+        ref="formRef"
+        class="table-dialog-form"
+        :model="form"
+        :rules="formRules"
+        label-width="80px"
+        v-loading="detailLoading"
+        require-asterisk-position="right"
+      >
+        <el-form-item label="选择分类" prop="categoryId" required>
+          <el-select v-model="form.categoryId" placeholder="请选择分类" clearable>
             <el-option v-for="item in categoryOptions" :key="item.id" :label="item.name" :value="item.id" />
           </el-select>
         </el-form-item>
+
+        <!-- 新建:可添加多组桌号/座位数(表单项与编辑态一致:作为 el-form 直接子节点,避免包一层 div 导致输入区宽度异常) -->
+        <template v-if="editId == null">
+          <div class="add-table-toolbar">
+            <el-button type="primary" link @click="addTableRow"> 添加 </el-button>
+          </div>
+          <template v-for="(row, index) in form.tableRows" :key="index">
+            <el-form-item label="桌号" :prop="'tableRows.' + index + '.tableNo'" :rules="tableNoFieldRules" required>
+              <el-input
+                :model-value="row.tableNo"
+                placeholder="如 A01"
+                maxlength="3"
+                @update:model-value="(val: string) => onTableNumberInput(row, val)"
+              />
+            </el-form-item>
+            <el-form-item label="座位数" :prop="'tableRows.' + index + '.seatCount'" :rules="seatCountFieldRulesRow" required>
+              <el-input
+                :model-value="row.seatCount"
+                placeholder="请输入"
+                maxlength="2"
+                inputmode="numeric"
+                @update:model-value="(val: string) => onSeatCountInput(row, val)"
+              />
+            </el-form-item>
+            <div v-if="form.tableRows.length > 1" class="table-row-delete">
+              <el-button type="danger" link @click="removeTableRow(index)"> 删除 </el-button>
+            </div>
+            <div v-if="index < form.tableRows.length - 1" class="table-row-sep" aria-hidden="true" />
+          </template>
+        </template>
+
+        <!-- 编辑:单条 -->
+        <template v-else>
+          <el-form-item label="桌号" prop="tableNo" required>
+            <el-input
+              :model-value="form.tableNo"
+              placeholder="如 A01"
+              clearable
+              maxlength="3"
+              @update:model-value="onTableNumberInputEdit"
+            />
+          </el-form-item>
+          <el-form-item label="座位数" prop="seatCount" required>
+            <el-input
+              :model-value="seatCountEditDisplay"
+              placeholder="请输入"
+              maxlength="2"
+              inputmode="numeric"
+              @update:model-value="onSeatCountInputEdit"
+            />
+          </el-form-item>
+        </template>
       </el-form>
       <template #footer>
         <el-button @click="dialogVisible = false"> 取消 </el-button>
@@ -65,9 +110,9 @@
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted } from "vue";
+import { ref, reactive, computed, onMounted } from "vue";
 import { ElMessage, ElMessageBox } from "element-plus";
-import type { FormInstance, FormRules } from "element-plus";
+import type { FormInstance, FormRules, FormItemRule } from "element-plus";
 import ProTable from "@/components/ProTable/index.vue";
 import type { ProTableInstance, ColumnProps } from "@/components/ProTable/interface";
 import {
@@ -85,7 +130,8 @@ export interface TableRow {
   id: number | string;
   _seq?: number;
   tableNo: string;
-  seatCount: number;
+  /** 列表接口返回的座位数字段 */
+  seatingCapacity: number;
   categoryId?: number | string;
   locationName?: string;
 }
@@ -95,7 +141,7 @@ const proTable = ref<ProTableInstance>();
 const columns: ColumnProps<TableRow>[] = [
   { prop: "_seq", label: "序号", width: 60, align: "center" },
   { prop: "tableNo", label: "桌号" },
-  { prop: "seatCount", label: "座位数", align: "center" },
+  { prop: "seatingCapacity", label: "座位数", align: "center" },
   { prop: "locationName", label: "位置" },
   { prop: "operation", label: "操作", fixed: "right", width: 140, align: "center" }
 ];
@@ -124,8 +170,8 @@ function parseHasReservationResponse(res: any): boolean {
   return Boolean(val);
 }
 
-async function tableHasReservation(tableId: number | string): Promise<boolean> {
-  const res: any = await getTableHasReservation({ id: tableId });
+async function tableHasReservation(tableId: number | string, storeId: number | string): Promise<boolean> {
+  const res: any = await getTableHasReservation({ tableId: tableId, storeId: getStoreId() });
   return parseHasReservationResponse(res);
 }
 
@@ -149,10 +195,11 @@ async function loadCategories() {
 }
 
 function mapTableRow(item: any, categoryMap: Record<string, string>): TableRow {
+  const cap = item.seatingCapacity ?? item.seatCount ?? item.seats;
   return {
     id: item.id,
     tableNo: item.tableNo ?? item.tableNumber ?? item.deskNo ?? "",
-    seatCount: item.seatCount ?? item.seats ?? 0,
+    seatingCapacity: cap != null && cap !== "" ? Number(cap) || 0 : 0,
     categoryId: item.categoryId ?? item.category_id,
     locationName: item.locationName ?? item.categoryName ?? categoryMap[String(item.categoryId ?? item.category_id)] ?? ""
   };
@@ -222,33 +269,152 @@ const form = reactive<{
   tableNo: string;
   seatCount: number;
   categoryId: number | string | undefined;
+  /** 与 App addTableNumber 一致:座位为字符串便于输入过滤 */
+  tableRows: { tableNo: string; seatCount: string }[];
 }>({
   tableNo: "",
   seatCount: 0,
-  categoryId: undefined
+  categoryId: undefined,
+  tableRows: [{ tableNo: "", seatCount: "" }]
 });
 
-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" }]
-};
+/** 与 group_merchant/pages/scheduledService/addTableNumber.vue 一致 */
+const TABLE_NUMBER_REG = /^[A-Z][0-9]{2}$/;
+
+const tableNoFieldRules: FormItemRule[] = [
+  {
+    validator: (_rule, value, callback) => {
+      const num = String(value ?? "").trim();
+      if (!num) {
+        callback(new Error("请输入桌号"));
+        return;
+      }
+      if (!TABLE_NUMBER_REG.test(num)) {
+        callback(new Error("桌号格式为:大写字母+2位数字,如 A01"));
+        return;
+      }
+      callback();
+    },
+    trigger: "blur"
+  }
+];
+
+const seatCountFieldRulesRow: FormItemRule[] = [
+  {
+    validator: (_rule, value, callback) => {
+      const seat = String(value ?? "").trim();
+      if (!seat) {
+        callback(new Error("请输入座位数"));
+        return;
+      }
+      const n = parseInt(seat, 10);
+      if (isNaN(n) || n < 1 || n > 99) {
+        callback(new Error("请输入1-99的整数"));
+        return;
+      }
+      callback();
+    },
+    trigger: "blur"
+  }
+];
+
+const seatCountFieldRulesEdit: FormItemRule[] = [
+  {
+    validator: (_rule, value, callback) => {
+      const n = Number(value);
+      if (value === undefined || value === null || value === "" || Number.isNaN(n) || n === 0) {
+        callback(new Error("请输入座位数"));
+        return;
+      }
+      if (n < 1 || n > 99) {
+        callback(new Error("请输入1-99的整数"));
+        return;
+      }
+      callback();
+    },
+    trigger: "blur"
+  }
+];
+
+/** 编辑座位展示:0 视为空(与 App 输入行为一致) */
+const seatCountEditDisplay = computed(() => {
+  const s = form.seatCount;
+  if (s === 0 || s === undefined || s === null || Number.isNaN(Number(s))) return "";
+  return String(s);
+});
+
+function onTableNumberInput(row: { tableNo: string }, val: string) {
+  let v = String(val ?? "");
+  v = v.toUpperCase().replace(/[^A-Z0-9]/g, "");
+  const letter = /[A-Z]/.test(v[0]) ? v[0] : "";
+  const digits = v
+    .slice(letter ? 1 : 0)
+    .replace(/[^0-9]/g, "")
+    .slice(0, 2);
+  row.tableNo = letter + digits;
+}
+
+function onSeatCountInput(row: { seatCount: string }, val: string) {
+  let v = String(val ?? "")
+    .replace(/\D/g, "")
+    .slice(0, 2);
+  const num = parseInt(v, 10);
+  if (v !== "" && !isNaN(num) && num > 99) v = "99";
+  row.seatCount = v;
+}
+
+function onTableNumberInputEdit(val: string) {
+  let v = String(val ?? "");
+  v = v.toUpperCase().replace(/[^A-Z0-9]/g, "");
+  const letter = /[A-Z]/.test(v[0]) ? v[0] : "";
+  const digits = v
+    .slice(letter ? 1 : 0)
+    .replace(/[^0-9]/g, "")
+    .slice(0, 2);
+  form.tableNo = letter + digits;
+}
+
+function onSeatCountInputEdit(val: string) {
+  let v = String(val ?? "")
+    .replace(/\D/g, "")
+    .slice(0, 2);
+  const num = parseInt(v, 10);
+  if (v !== "" && !isNaN(num) && num > 99) v = "99";
+  if (v === "") {
+    form.seatCount = 0;
+  } else {
+    const n = parseInt(v, 10);
+    form.seatCount = isNaN(n) ? 0 : n;
+  }
+}
+
+/** 新建只校验分类 + 各行(行规则在 el-form-item 上);编辑校验分类 + 单条桌号/座位数 */
+const formRules = computed<FormRules>(() => {
+  const base: FormRules = {
+    categoryId: [{ required: true, message: "请选择位置", trigger: "change" }]
+  };
+  if (editId.value != null) {
+    base.tableNo = tableNoFieldRules;
+    base.seatCount = seatCountFieldRulesEdit;
+  }
+  return base;
+});
+
+function addTableRow() {
+  form.tableRows.push({ tableNo: "", seatCount: "" });
+}
+
+function removeTableRow(index: number) {
+  if (form.tableRows.length <= 1) return;
+  form.tableRows.splice(index, 1);
+}
 
 function handleNew() {
   editId.value = null;
   form.tableNo = "";
   form.seatCount = 0;
   form.categoryId = undefined;
+  form.tableRows = [{ tableNo: "", seatCount: "" }];
   dialogVisible.value = true;
 }
 
@@ -264,6 +430,7 @@ async function handleEdit(row: TableRow) {
     return;
   }
   editId.value = row.id;
+  form.tableRows = [{ tableNo: "", seatCount: "" }];
   dialogVisible.value = true;
   detailLoading.value = true;
   try {
@@ -272,7 +439,7 @@ async function handleEdit(row: TableRow) {
     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;
+    form.seatCount = d.seatingCapacity ?? d.seatCount ?? row.seatingCapacity ?? 0;
     if (typeof form.seatCount === "string") form.seatCount = parseInt(form.seatCount, 10) || 0;
     if (
       categoryId != null &&
@@ -284,7 +451,7 @@ async function handleEdit(row: TableRow) {
   } catch (e: any) {
     ElMessage.error(e?.message || "获取详情失败");
     form.tableNo = row.tableNo;
-    form.seatCount = row.seatCount;
+    form.seatCount = row.seatingCapacity ?? 0;
     form.categoryId = row.categoryId ?? undefined;
   } finally {
     detailLoading.value = false;
@@ -324,6 +491,7 @@ function onDialogClose() {
   form.tableNo = "";
   form.seatCount = 0;
   form.categoryId = undefined;
+  form.tableRows = [{ tableNo: "", seatCount: "" }];
 }
 
 async function submitForm() {
@@ -351,10 +519,14 @@ async function submitForm() {
         seatingCapacity
       });
     } else {
+      const tables = form.tableRows.map(r => ({
+        tableNumber: r.tableNo.trim(),
+        seatingCapacity: parseInt(String(r.seatCount).trim(), 10) || 0
+      }));
       await tableAdd({
         storeId,
         categoryId: form.categoryId,
-        tables: [{ tableNumber, seatingCapacity }]
+        tables
       });
     }
     ElMessage.success(editId.value == null ? "添加成功" : "修改成功");
@@ -399,4 +571,53 @@ onMounted(() => {
   width: 100%;
   padding: 12px 0;
 }
+.add-table-toolbar {
+  position: relative;
+  bottom: 5px;
+  margin: 0 0 8px 80px;
+}
+.table-row-sep {
+  width: 100%;
+  margin: 0 0 12px;
+  border: none;
+  border-bottom: 1px solid var(--el-border-color-lighter);
+}
+.table-row-delete {
+  position: relative;
+  bottom: 5px;
+  margin: 0 0 4px 80px;
+  text-align: right;
+}
+</style>
+
+<!-- append-to-body 时弹窗挂到 body,需非 scoped 才能稳定命中 -->
+<style lang="scss">
+.table-management-dialog.el-dialog .el-dialog__body {
+  box-sizing: border-box;
+  max-height: min(420px, 65vh);
+  overflow: hidden auto;
+}
+
+/* 弹窗内表单:与 .table-search 一致,控件占满标签右侧(scoped 对 teleport 不生效) */
+.table-management-dialog .table-dialog-form {
+  width: 100%;
+  .el-form-item {
+    width: 100%;
+  }
+  .el-form-item__content {
+    flex: 1;
+    min-width: 0;
+  }
+  .el-form-item__content > .el-input,
+  .el-form-item__content > .el-select {
+    width: 100%;
+    max-width: 100%;
+  }
+  .el-input {
+    width: 100%;
+  }
+  .el-select {
+    width: 100%;
+  }
+}
 </style>

+ 0 - 57
src/views/home/components/go-flow.vue

@@ -219,11 +219,6 @@
                   <el-input v-model="step2Form.storePj[2]" placeholder="请输入" maxlength="2" clearable class="store-pj-input" />
                 </div>
               </el-form-item>
-              <el-form-item label="设置收款账号" required>
-                <div style="color: #6c8ff8; cursor: pointer" @click="addPayAccount">
-                  {{ hasPayAccountConfig ? "已添加" : "添加" }}
-                </div>
-              </el-form-item>
               <el-form-item label="预约服务">
                 <el-radio-group v-model="step2Form.appointment">
                   <el-radio label="提供"> 提供 </el-radio>
@@ -280,12 +275,6 @@
       </div>
     </div>
   </div>
-  <!-- 设置收款账号弹窗 -->
-  <GoReceivingAccount
-    v-model="setPayAccountDialogVisible"
-    @success="hasPayAccountConfig = true"
-    @has-config="(v: boolean) => (hasPayAccountConfig = v)"
-  />
   <!-- 营业时间弹窗(入驻:仅缓存,关闭按钮) -->
   <GoBusinessHours
     v-model="businessHoursDialogVisible"
@@ -309,7 +298,6 @@ import { Plus } from "@element-plus/icons-vue";
 
 import { applyStore, getMerchantByPhone, getThirdLevelList, verifyIdInfo } from "@/api/modules/homeEntry";
 import { getInputPrompt, getDistrict, uploadImg, ocrRequestUrl, getAiapprovestoreInfo } from "@/api/modules/newLoginApi";
-import GoReceivingAccount from "./go-receivingAccount.vue";
 import GoBusinessHours from "./go-businessHours.vue";
 import { localGet, localSet } from "@/utils/index";
 import { useAuthStore } from "@/stores/modules/auth";
@@ -322,9 +310,6 @@ const showDisportLicence = ref(false);
 const imageViewerVisible = ref(false);
 const imageViewerUrlList = ref<string[]>([]);
 const imageViewerInitialIndex = ref(0);
-// 设置收款账号
-const setPayAccountDialogVisible = ref(false);
-const hasPayAccountConfig = ref(false);
 // 营业时间
 const businessHoursDialogVisible = ref(false);
 const hasBusinessHoursConfig = ref(false);
@@ -630,9 +615,6 @@ watch(
   }
 );
 
-const addPayAccount = () => {
-  setPayAccountDialogVisible.value = true;
-};
 onMounted(() => {
   callGetUserInfo();
   if (currentStep.value === 3 && (storeApplicationStatus.value === 0 || storeApplicationStatus.value === 2)) {
@@ -1366,9 +1348,6 @@ const handleExceed = () => {
   height: 600px;
   overflow: scroll;
 }
-.set-pay-key-upload {
-  width: 100%;
-}
 </style>
 <style scoped lang="scss">
 // 店铺评价三个输入框纵向排列
@@ -1575,40 +1554,4 @@ const handleExceed = () => {
     }
   }
 }
-.set-pay-upload-box {
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-  align-items: center;
-  justify-content: center;
-  padding: 24px;
-  cursor: pointer;
-  background: #fafafa;
-  border: 1px dashed #dcdfe6;
-  border-radius: 8px;
-  transition: border-color 0.2s;
-  &:hover {
-    border-color: #6c8ff8;
-  }
-  p {
-    margin: 0;
-    font-size: 14px;
-    color: #606266;
-  }
-  span {
-    font-size: 12px;
-    color: #909399;
-  }
-}
-.set-pay-key-upload {
-  width: 100%;
-  border: 1px solid red !important;
-  :deep(.el-upload) {
-    width: 100%;
-  }
-  :deep(.el-upload-dragger) {
-    width: 100%;
-    padding: 32px 16px;
-  }
-}
 </style>

+ 0 - 369
src/views/home/components/go-receivingAccount.vue

@@ -1,369 +0,0 @@
-<template>
-  <el-dialog
-    :model-value="modelValue"
-    title="设置收款账号"
-    width="520px"
-    destroy-on-close
-    append-to-body
-    @update:model-value="emit('update:modelValue', $event)"
-  >
-    <el-form ref="formRef" :model="form" :rules="rules" label-width="140px" label-position="top">
-      <el-form-item label="账号类型" prop="accountType">
-        <el-radio-group v-model="form.accountType">
-          <el-radio value="wechat"> 微信 </el-radio>
-          <el-radio value="alipay"> 支付宝 </el-radio>
-        </el-radio-group>
-      </el-form-item>
-      <el-form-item label="appid" prop="wechatAppid" v-if="form.accountType === 'wechat'">
-        <el-input v-model="form.wechatAppid" placeholder="请输入" clearable />
-      </el-form-item>
-      <el-form-item label="mchld" prop="wechatMchld" v-if="form.accountType === 'wechat'">
-        <el-input v-model="form.wechatMchld" placeholder="请输入" clearable />
-      </el-form-item>
-      <el-form-item label="小程序appid" prop="wechatMiniAppId" v-if="form.accountType === 'wechat'">
-        <el-input v-model="form.wechatMiniAppId" placeholder="请输入" clearable />
-      </el-form-item>
-      <el-form-item label="API证书序列号" prop="wechatApiCertSerialNo" v-if="form.accountType === 'wechat'">
-        <el-input v-model="form.wechatApiCertSerialNo" placeholder="请输入" clearable />
-      </el-form-item>
-      <el-form-item label="APIv3 Key" prop="wechatApiV3Key" v-if="form.accountType === 'wechat'">
-        <el-input v-model="form.wechatApiV3Key" placeholder="请输入" clearable />
-      </el-form-item>
-      <el-form-item label="支付公钥ID" prop="wechatPayPublicKeyId" v-if="form.accountType === 'wechat'">
-        <el-input v-model="form.wechatPayPublicKeyId" placeholder="请输入" clearable />
-      </el-form-item>
-      <el-form-item label="私钥证书" prop="wechatPrivateKeyFile" v-if="form.accountType === 'wechat'">
-        <el-upload
-          v-model:file-list="form.wechatPrivateKeyFile"
-          class="set-pay-key-upload"
-          drag
-          :auto-upload="false"
-          :limit="1"
-          accept=".pem"
-          :on-exceed="() => ElMessage.warning('最多上传1个文件')"
-        >
-          <div class="set-pay-upload-box">
-            <el-icon :size="32" color="#6c8ff8">
-              <UploadFilled />
-            </el-icon>
-            <p>点击或拖拽文件至此处上传</p>
-            <span>支持.pem 格式文件,大小不超过2MB</span>
-          </div>
-        </el-upload>
-      </el-form-item>
-      <el-form-item label="公钥证书" prop="wechatPayPublicKeyFile" v-if="form.accountType === 'wechat'">
-        <el-upload
-          v-model:file-list="form.wechatPayPublicKeyFile"
-          class="set-pay-key-upload"
-          drag
-          :auto-upload="false"
-          :limit="1"
-          accept=".pem"
-          :on-exceed="() => ElMessage.warning('最多上传1个文件')"
-        >
-          <div class="set-pay-upload-box">
-            <el-icon :size="32" color="#6c8ff8">
-              <UploadFilled />
-            </el-icon>
-            <p>点击或拖拽文件至此处上传</p>
-            <span>支持.pem 格式文件,大小不超过2MB</span>
-          </div>
-        </el-upload>
-      </el-form-item>
-
-      <!-- 支付宝 -->
-      <el-form-item label="应用ID" prop="aliAppid" v-if="form.accountType === 'alipay'">
-        <el-input v-model="form.aliAppid" placeholder="请输入" clearable />
-      </el-form-item>
-      <el-form-item label="应用私钥" prop="aliPrivateId" v-if="form.accountType === 'alipay'">
-        <el-input v-model="form.aliPrivateId" placeholder="请输入" clearable />
-      </el-form-item>
-      <el-form-item label="应用公钥证书" prop="appPublicCertFile" v-if="form.accountType === 'alipay'">
-        <el-upload
-          v-model:file-list="form.appPublicCertFile"
-          class="set-pay-key-upload"
-          drag
-          :auto-upload="false"
-          :limit="1"
-          accept=".pem"
-          :on-exceed="() => ElMessage.warning('最多上传1个文件')"
-        >
-          <div class="set-pay-upload-box">
-            <el-icon :size="32" color="#6c8ff8">
-              <UploadFilled />
-            </el-icon>
-            <p>点击或拖拽文件至此处上传</p>
-            <span>支持.pem 格式文件,大小不超过2MB</span>
-          </div>
-        </el-upload>
-      </el-form-item>
-      <el-form-item label="支付宝公钥证书" prop="alipayPublicCertFile" v-if="form.accountType === 'alipay'">
-        <el-upload
-          v-model:file-list="form.alipayPublicCertFile"
-          class="set-pay-key-upload"
-          drag
-          :auto-upload="false"
-          :limit="1"
-          accept=".pem"
-          :on-exceed="() => ElMessage.warning('最多上传1个文件')"
-        >
-          <div class="set-pay-upload-box">
-            <el-icon :size="32" color="#6c8ff8">
-              <UploadFilled />
-            </el-icon>
-            <p>点击或拖拽文件至此处上传</p>
-            <span>支持.pem 格式文件,大小不超过2MB</span>
-          </div>
-        </el-upload>
-      </el-form-item>
-      <el-form-item label="支付宝根证书" prop="alipayRootCertFile" v-if="form.accountType === 'alipay'">
-        <el-upload
-          v-model:file-list="form.alipayRootCertFile"
-          class="set-pay-key-upload"
-          drag
-          :auto-upload="false"
-          :limit="1"
-          accept=".pem"
-          :on-exceed="() => ElMessage.warning('最多上传1个文件')"
-        >
-          <div class="set-pay-upload-box">
-            <el-icon :size="32" color="#6c8ff8">
-              <UploadFilled />
-            </el-icon>
-            <p>点击或拖拽文件至此处上传</p>
-            <span>支持.pem 格式文件,大小不超过2MB</span>
-          </div>
-        </el-upload>
-      </el-form-item>
-    </el-form>
-    <template #footer>
-      <el-button @click="emit('update:modelValue', false)"> 取消 </el-button>
-      <el-button type="primary" @click="submit"> 确定 </el-button>
-    </template>
-  </el-dialog>
-</template>
-
-<script setup lang="ts">
-import { ref, reactive, watch } from "vue";
-import { ElMessage, type FormInstance, type UploadUserFile } from "element-plus";
-import { UploadFilled } from "@element-plus/icons-vue";
-import { getPaymentStoreUserId, saveWechatWithFiles, saveAlipayWithFiles } from "@/api/modules/newLoginApi";
-import { localGet } from "@/utils/index";
-
-const props = defineProps<{
-  modelValue: boolean;
-}>();
-
-const emit = defineEmits<{
-  (e: "update:modelValue", value: boolean): void;
-  (e: "success"): void;
-  (e: "hasConfig", value: boolean): void;
-}>();
-
-const formRef = ref<FormInstance>();
-const form = reactive({
-  accountType: "wechat",
-  wechatAppid: "",
-  wechatMchld: "",
-  wechatMiniAppId: "",
-  wechatApiCertSerialNo: "",
-  wechatApiV3Key: "",
-  wechatPayPublicKeyId: null as string | null,
-  wechatPrivateKeyFile: [] as UploadUserFile[],
-  wechatPayPublicKeyFile: [] as UploadUserFile[],
-  aliAppid: "",
-  aliPrivateId: "",
-  alipayRootCertFile: [] as UploadUserFile[],
-  alipayPublicCertFile: [] as UploadUserFile[],
-  appPublicCertFile: [] as UploadUserFile[]
-});
-
-const rules = ref({
-  accountType: [{ required: true, message: "请选择账号类型", trigger: "change" }],
-  wechatAppid: [{ required: true, message: "请输入appid", trigger: "blur" }],
-  wechatMchld: [{ required: true, message: "请输入mchId", trigger: "blur" }],
-  wechatApiCertSerialNo: [{ required: true, message: "请输入API证书序列号", trigger: "blur" }],
-  wechatApiV3Key: [{ required: true, message: "请输入APIv3 Key", trigger: "blur" }],
-  wechatPayPublicKeyId: [{ required: true, message: "请输入支付公钥ID", trigger: "blur" }],
-  wechatMiniAppId: [{ required: true, message: "请输入小程序AppId", trigger: "blur" }],
-  wechatPrivateKeyFile: [{ required: true, type: "array" as const, min: 1, message: "请上传私钥证书", trigger: "change" }],
-  wechatPayPublicKeyFile: [{ required: true, type: "array" as const, min: 1, message: "请上传公钥证书", trigger: "change" }]
-});
-
-const base64ToFile = (base64Str: string, fileName: string): File => {
-  const bstr = atob(base64Str);
-  const u8arr = new Uint8Array(bstr.length);
-  for (let i = 0; i < bstr.length; i++) u8arr[i] = bstr.charCodeAt(i);
-  return new File([u8arr], fileName, { type: "application/x-pem-file" });
-};
-
-const fetchPayAccountConfig = async () => {
-  const storeUserId = localGet("geeker-user")?.userInfo?.id;
-  if (!storeUserId) return;
-  const res: any = await getPaymentStoreUserId({ storeUserId });
-  if (res?.code !== 200 || !res.data) {
-    emit("hasConfig", false);
-    return;
-  }
-  const data = res.data;
-  emit("hasConfig", !!(data.wechatAppId || data.appId));
-
-  if (data.wechatAppId) {
-    form.accountType = "wechat";
-    form.wechatAppid = data.wechatAppId ?? "";
-    form.wechatMchld = data.wechatMchId ?? "";
-    form.wechatMiniAppId = data.wechatMiniAppId ?? "";
-    form.wechatApiCertSerialNo = data.merchantSerialNumber ?? "";
-    form.wechatApiV3Key = data.apiV3Key ?? "";
-    form.wechatPayPublicKeyId = data.wechatPayPublicKeyId ?? null;
-    form.wechatPrivateKeyFile = [];
-    form.wechatPayPublicKeyFile = [];
-    if (data.wechatPrivateKeyFile && data.wechatPrivateKeyName) {
-      const file = base64ToFile(data.wechatPrivateKeyFile, data.wechatPrivateKeyName);
-      form.wechatPrivateKeyFile = [{ name: file.name, raw: file, status: "success", uid: Date.now() } as UploadUserFile];
-    }
-    if (data.wechatPayPublicKeyFile && data.wechatPayPublicKeyFileName) {
-      const file = base64ToFile(data.wechatPayPublicKeyFile, data.wechatPayPublicKeyFileName);
-      form.wechatPayPublicKeyFile = [{ name: file.name, raw: file, status: "success", uid: Date.now() + 1 } as UploadUserFile];
-    }
-  }
-
-  if (data.appId) {
-    form.aliAppid = data.appId ?? "";
-    form.aliPrivateId = data.appSecretCert ?? "";
-    form.appPublicCertFile = [];
-    form.alipayPublicCertFile = [];
-    form.alipayRootCertFile = [];
-    if (data.appPublicCert && data.appPublicCertName) {
-      const file = base64ToFile(data.appPublicCert, data.appPublicCertName);
-      form.appPublicCertFile = [{ name: file.name, raw: file, status: "success", uid: Date.now() } as UploadUserFile];
-    }
-    if (data.alipayPublicCert && data.alipayPublicCertName) {
-      const file = base64ToFile(data.alipayPublicCert, data.alipayPublicCertName);
-      form.alipayPublicCertFile = [{ name: file.name, raw: file, status: "success", uid: Date.now() + 1 } as UploadUserFile];
-    }
-    if (data.alipayRootCert && data.alipayRootCertName) {
-      const file = base64ToFile(data.alipayRootCert, data.alipayRootCertName);
-      form.alipayRootCertFile = [{ name: file.name, raw: file, status: "success", uid: Date.now() + 2 } as UploadUserFile];
-    }
-    if (!data.wechatAppId) form.accountType = "alipay";
-  }
-};
-
-const submit = async () => {
-  if (!formRef.value) return;
-  await formRef.value.validate(async valid => {
-    if (!valid) return;
-
-    const storeUserId = localGet("geeker-user")?.userInfo?.id;
-    if (!storeUserId) {
-      ElMessage.warning("请先登录");
-      return;
-    }
-
-    if (form.accountType === "alipay") {
-      const appPublicCertFile = (form.appPublicCertFile as UploadUserFile[])[0]?.raw as File | undefined;
-      const alipayPublicCertFile = (form.alipayPublicCertFile as UploadUserFile[])[0]?.raw as File | undefined;
-      const alipayRootCertFile = (form.alipayRootCertFile as UploadUserFile[])[0]?.raw as File | undefined;
-
-      if (!form.aliAppid?.trim()) {
-        ElMessage.warning("请输入应用ID");
-        return;
-      }
-      if (!form.aliPrivateId?.trim()) {
-        ElMessage.warning("请输入应用私钥");
-        return;
-      }
-      if (!appPublicCertFile || !alipayPublicCertFile || !alipayRootCertFile) {
-        ElMessage.warning("请上传应用公钥证书、支付宝公钥证书和支付宝根证书");
-        return;
-      }
-
-      try {
-        const res: any = await saveAlipayWithFiles(
-          {
-            storeUserId: String(storeUserId),
-            appId: form.aliAppid.trim(),
-            appSecretCert: form.aliPrivateId.trim()
-          },
-          appPublicCertFile,
-          alipayPublicCertFile,
-          alipayRootCertFile
-        );
-        if (res?.code === 200) {
-          ElMessage.success("保存成功");
-          emit("success");
-          emit("update:modelValue", false);
-        } else {
-          ElMessage.error(res?.msg || "保存失败");
-        }
-      } catch (e: any) {
-        ElMessage.error(e?.message || "保存失败");
-      }
-      return;
-    }
-
-    if (form.accountType === "wechat") {
-      const privateKeyUpload = (form.wechatPrivateKeyFile as UploadUserFile[])[0];
-      const publicKeyUpload = (form.wechatPayPublicKeyFile as UploadUserFile[])[0];
-      const privateKeyFile = privateKeyUpload?.raw as File | undefined;
-      const publicKeyFile = publicKeyUpload?.raw as File | undefined;
-
-      if (!privateKeyFile || !publicKeyFile) {
-        ElMessage.warning("请上传私钥证书和公钥证书");
-        return;
-      }
-
-      try {
-        const res: any = await saveWechatWithFiles(
-          {
-            storeUserId: String(storeUserId),
-            apiV3Key: form.wechatApiV3Key || "",
-            merchantSerialNumber: form.wechatApiCertSerialNo || "",
-            wechatAppId: form.wechatAppid || "",
-            wechatMchId: form.wechatMchld || "",
-            wechatMiniAppId: form.wechatMiniAppId || "",
-            wechatPayPublicKeyId: form.wechatPayPublicKeyId || "",
-            accountType: form.accountType || "wechat"
-          },
-          privateKeyFile,
-          publicKeyFile
-        );
-        if (res?.code === 200) {
-          ElMessage.success("保存成功");
-          emit("success");
-          emit("update:modelValue", false);
-        } else {
-          ElMessage.error(res?.msg || "保存失败");
-        }
-      } catch (e: any) {
-        ElMessage.error(e?.message || "保存失败");
-      }
-    }
-  });
-};
-
-watch(
-  () => props.modelValue,
-  visible => {
-    if (visible) fetchPayAccountConfig();
-  }
-);
-</script>
-
-<style scoped>
-.set-pay-key-upload :deep(.el-upload-dragger) {
-  padding: 20px;
-}
-.set-pay-upload-box {
-  color: var(--el-text-color-secondary);
-  text-align: center;
-}
-.set-pay-upload-box p {
-  margin: 8px 0 4px;
-  font-size: 14px;
-}
-.set-pay-upload-box span {
-  font-size: 12px;
-}
-</style>

+ 0 - 12
src/views/storeDecoration/basicStoreInformation/index.vue

@@ -200,9 +200,6 @@
           <el-form-item label="经营类目" prop="businessCategoryName">
             <el-input v-model="formData.businessCategoryName" placeholder="请输入经营类目" clearable maxlength="50" />
           </el-form-item>
-          <el-form-item label="设置收款账号">
-            <div style="color: #6c8ff8; cursor: pointer" @click="handleViewPayAccount">查看</div>
-          </el-form-item>
           <el-form-item label="营业时间">
             <div style="color: #6c8ff8; cursor: pointer" @click="handleViewBusinessHours">查看</div>
           </el-form-item>
@@ -235,8 +232,6 @@
         <el-button type="primary" size="large" :loading="loading" @click="handleSubmit"> 保存 </el-button>
       </div>
     </el-form>
-    <!-- 收款账号弹窗(查看/编辑) -->
-    <GoReceivingAccount v-model="payAccountDialogVisible" />
     <!-- 营业时间弹窗(查看/编辑) -->
     <el-dialog
       v-model="businessHoursDialogVisible"
@@ -394,13 +389,6 @@ import {
 import { getInputPrompt } from "@/api/modules/newLoginApi";
 import { useRoute } from "vue-router";
 import { localGet } from "@/utils";
-import GoReceivingAccount from "@/views/home/components/go-receivingAccount.vue";
-
-// 收款账号弹窗显隐
-const payAccountDialogVisible = ref(false);
-const handleViewPayAccount = () => {
-  payAccountDialogVisible.value = true;
-};
 
 // ========== 营业时间弹窗(参考 1.vue + businessHours 页) ==========
 const weekDays = [

+ 299 - 0
src/views/storeDecoration/receivingAccount/index.vue

@@ -0,0 +1,299 @@
+<template>
+  <div class="receiving-account-page">
+    <h2 class="page-title">收款账号</h2>
+    <p class="page-tip">
+      请选择收款方式并填写商户信息;微信/支付宝各填写商户ID与商户ID号码,或绑定借记卡银行账户,带 * 为必填项。修改后请保存。
+    </p>
+
+    <el-card shadow="never" class="form-card">
+      <el-form ref="formRef" :model="form" :rules="formRules" label-width="140px" label-position="top">
+        <el-form-item label="账号类型" prop="accountType">
+          <el-radio-group v-model="form.accountType">
+            <el-radio value="wechat"> 微信 </el-radio>
+            <el-radio value="alipay"> 支付宝 </el-radio>
+            <el-radio value="bank"> 银行账号 </el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item v-if="form.accountType !== 'bank'" label="授权指引">
+          <el-button link type="primary" @click="openAuthGuide">
+            查看{{ form.accountType === "wechat" ? "微信" : "支付宝" }}授权指引
+          </el-button>
+        </el-form-item>
+
+        <template v-if="form.accountType === 'wechat'">
+          <el-form-item label="商户ID" prop="wechatAppid">
+            <el-input v-model="form.wechatAppid" placeholder="请输入商户ID" clearable />
+          </el-form-item>
+          <el-form-item label="商户ID号码" prop="wechatMchld">
+            <el-input v-model="form.wechatMchld" placeholder="请输入商户ID号码" clearable />
+          </el-form-item>
+        </template>
+
+        <template v-else-if="form.accountType === 'alipay'">
+          <el-form-item label="商户ID" prop="aliAppid">
+            <el-input v-model="form.aliAppid" placeholder="请输入商户ID" clearable />
+          </el-form-item>
+          <el-form-item label="商户ID号码" prop="aliPrivateId">
+            <el-input v-model="form.aliPrivateId" placeholder="请输入商户ID号码" clearable />
+          </el-form-item>
+        </template>
+
+        <template v-else-if="form.accountType === 'bank'">
+          <el-form-item label="银行名称" prop="bankName">
+            <el-select v-model="form.bankName" filterable clearable placeholder="请选择" style="width: 100%">
+              <el-option v-for="b in bankSelectOptions" :key="b.value" :label="b.label" :value="b.value" />
+            </el-select>
+            <div class="form-item-tip">请选择开户银行的全称</div>
+          </el-form-item>
+          <el-form-item label="银行账号" prop="bankAccountNo">
+            <el-input v-model="form.bankAccountNo" placeholder="请输入" clearable maxlength="23" />
+            <div class="form-item-tip">请输入完整的银行卡号,仅支持借记卡,不支持信用卡</div>
+          </el-form-item>
+        </template>
+
+        <el-form-item>
+          <el-button type="primary" :loading="saving" @click="submit"> 保存 </el-button>
+          <el-button @click="fetchPayAccountConfig"> 取消 </el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted, computed, watch } from "vue";
+import { ElMessage } from "element-plus";
+import type { FormInstance, FormRules } from "element-plus";
+import {
+  getDict,
+  getPaymentStoreUserId,
+  saveWechatWithFiles,
+  saveAlipayWithFiles,
+  saveBankAccountConfig
+} from "@/api/modules/newLoginApi";
+import { localGet } from "@/utils/index";
+
+/** 银行名称下拉:仅展示 getDict 返回的数据 */
+const bankSelectOptions = ref<{ label: string; value: string }[]>([]);
+
+const normalizeDictToOptions = (raw: unknown): { label: string; value: string }[] => {
+  const list = Array.isArray(raw) ? raw : ((raw as any)?.records ?? (raw as any)?.list ?? []);
+  if (!Array.isArray(list)) return [];
+  return list
+    .map((item: any) => {
+      const label = item?.dictDetail ?? item?.dictLabel ?? item?.label ?? item?.name ?? item?.text ?? item?.bankName ?? "";
+      const value = item?.dictValue ?? item?.value ?? item?.code ?? item?.dictDetail ?? item?.dictLabel ?? label;
+      return { label: String(label).trim(), value: String(value).trim() };
+    })
+    .filter((x: { label: string; value: string }) => x.label && x.value);
+};
+
+const loadBankNameDict = async () => {
+  try {
+    const res: any = await getDict({ dictType: "bankName" });
+    if (res?.code !== 200 && res?.code !== "200") {
+      bankSelectOptions.value = [];
+      return;
+    }
+    bankSelectOptions.value = normalizeDictToOptions(res.data);
+  } catch {
+    bankSelectOptions.value = [];
+  }
+};
+
+const formRef = ref<FormInstance>();
+const saving = ref(false);
+
+const form = reactive({
+  accountType: "wechat",
+  wechatAppid: "",
+  wechatMchld: "",
+  aliAppid: "",
+  aliPrivateId: "",
+  bankName: "",
+  bankAccountNo: ""
+});
+
+const wechatRules: FormRules = {
+  accountType: [{ required: true, message: "请选择账号类型", trigger: "change" }],
+  wechatAppid: [{ required: true, message: "请输入商户ID", trigger: "blur" }],
+  wechatMchld: [{ required: true, message: "请输入商户ID号码", trigger: "blur" }]
+};
+
+const alipayRules: FormRules = {
+  accountType: [{ required: true, message: "请选择账号类型", trigger: "change" }],
+  aliAppid: [{ required: true, message: "请输入商户ID", trigger: "blur" }],
+  aliPrivateId: [{ required: true, message: "请输入商户ID号码", trigger: "blur" }]
+};
+
+const bankRules: FormRules = {
+  accountType: [{ required: true, message: "请选择账号类型", trigger: "change" }],
+  bankName: [{ required: true, message: "请选择银行名称", trigger: "change" }],
+  bankAccountNo: [
+    { required: true, message: "请输入银行账号", trigger: "blur" },
+    {
+      validator: (_rule, value, callback) => {
+        const s = String(value || "").replace(/\s/g, "");
+        if (!/^\d{16,19}$/.test(s)) {
+          callback(new Error("请输入16~19位数字借记卡卡号"));
+        } else {
+          callback();
+        }
+      },
+      trigger: "blur"
+    }
+  ]
+};
+
+const formRules = computed<FormRules>(() => {
+  if (form.accountType === "alipay") return alipayRules;
+  if (form.accountType === "bank") return bankRules;
+  return wechatRules;
+});
+
+watch(
+  () => form.accountType,
+  () => {
+    formRef.value?.clearValidate();
+  }
+);
+
+const openAuthGuide = () => {
+  const url =
+    form.accountType === "wechat"
+      ? "https://ossfile.ailien.shop/privacy/wechat-auth382471.html"
+      : "https://ossfile.ailien.shop/privacy/alipay-auth501249.html";
+  window.open(url, "_blank");
+};
+
+/** 从接口拉取收款配置,用于页面进入时初始化表单,以及「取消」时放弃本地修改并重新回填 */
+const fetchPayAccountConfig = async () => {
+  const storeUserId = localGet("geeker-user")?.userInfo?.id;
+  if (!storeUserId) {
+    ElMessage.warning("请先登录");
+    return;
+  }
+  const res: any = await getPaymentStoreUserId({ storeUserId });
+  if (res?.code !== 200 || !res.data) {
+    return;
+  }
+  const data = res.data;
+
+  form.wechatAppid = "";
+  form.wechatMchld = "";
+  form.aliAppid = "";
+  form.aliPrivateId = "";
+  form.bankName = "";
+  form.bankAccountNo = "";
+
+  // 后端可能同时返回三类配置,须分别回填;若用 else if 只会填到第一项,切换支付宝/银行会没有初始值
+  if (data.storeWechatId || data.wechatAppId) {
+    form.wechatAppid = String(data.storeWechatId ?? data.wechatAppId ?? "");
+    form.wechatMchld = String(data.storeWechatName ?? data.wechatMchId ?? "");
+  }
+  if (data.storeAliId || data.appId) {
+    form.aliAppid = String(data.storeAliId ?? data.appId ?? "");
+    form.aliPrivateId = String(data.storeAliName ?? data.appSecretCert ?? "");
+  }
+
+  const bankNo = data.bankAccountNo ?? data.bankCardNo ?? data.settlementAccount;
+  const bankNm = data.bankName ?? data.openingBankName ?? data.openingBank;
+  if (bankNo) {
+    form.bankAccountNo = String(bankNo).replace(/\s/g, "");
+    form.bankName = bankNm ? String(bankNm) : "";
+  }
+
+  // 单选「当前展示哪一类」:有数据时优先微信 > 支付宝 > 银行(与保存时三选一一致)
+  if (data.storeWechatId || data.wechatAppId) {
+    form.accountType = "wechat";
+  } else if (data.storeAliId || data.appId) {
+    form.accountType = "alipay";
+  } else if (bankNo) {
+    form.accountType = "bank";
+  }
+};
+
+const submit = async () => {
+  if (!formRef.value) return;
+  await formRef.value.validate(async valid => {
+    if (!valid) return;
+
+    const storeUserId = localGet("geeker-user")?.userInfo?.id;
+    if (!storeUserId) {
+      ElMessage.warning("请先登录");
+      return;
+    }
+
+    saving.value = true;
+    try {
+      if (form.accountType === "bank") {
+        const res: any = await saveBankAccountConfig({
+          storeUserId,
+          bankName: form.bankName.trim(),
+          bankCardNo: form.bankAccountNo.replace(/\s/g, "")
+        });
+        if (res?.code === 200 || res?.code === "200") ElMessage.success(res?.msg || "保存成功");
+        else ElMessage.error(res?.msg || "保存失败");
+        return;
+      }
+
+      if (form.accountType === "alipay") {
+        const res: any = await saveAlipayWithFiles({
+          storeUserId,
+          storeAliId: form.aliAppid.trim(),
+          storeAliName: form.aliPrivateId.trim()
+        });
+        if (res?.code === 200 || res?.code === "200") ElMessage.success(res?.msg || "保存成功");
+        else ElMessage.error(res?.msg || "保存失败");
+        return;
+      }
+
+      const res: any = await saveWechatWithFiles({
+        storeUserId,
+        storeWechatId: form.wechatAppid.trim(),
+        storeWechatName: form.wechatMchld.trim()
+      });
+      if (res?.code === 200 || res?.code === "200") ElMessage.success(res?.msg || "保存成功");
+      else ElMessage.error(res?.msg || "保存失败");
+    } catch (e: any) {
+      ElMessage.error(e?.message || "保存失败");
+    } finally {
+      saving.value = false;
+    }
+  });
+};
+
+onMounted(async () => {
+  await loadBankNameDict();
+  await fetchPayAccountConfig();
+});
+</script>
+
+<style scoped lang="scss">
+.receiving-account-page {
+  min-height: 100%;
+  padding: 20px;
+  background: #ffffff;
+  .page-title {
+    margin: 0 0 8px;
+    font-size: 18px;
+    font-weight: 600;
+    color: #303133;
+  }
+  .page-tip {
+    margin: 0 0 20px;
+    font-size: 14px;
+    line-height: 1.5;
+    color: #909399;
+  }
+  .form-card {
+    max-width: 720px;
+  }
+}
+.form-item-tip {
+  margin-top: 6px;
+  font-size: 12px;
+  line-height: 1.5;
+  color: #909399;
+}
+</style>