zhuli 3 hete
szülő
commit
3d09c2fd00

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

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

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

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

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

@@ -0,0 +1,443 @@
+<template>
+  <div class="table-box appointment-info">
+    <!-- 筛选:点击搜索后生效(与翻页联动) -->
+    <div class="filter-bar">
+      <div class="filter-row">
+        <span class="filter-label">姓名</span>
+        <el-input v-model="searchForm.name" placeholder="请输入" clearable style="width: 160px" />
+        <span class="filter-label">状态</span>
+        <el-select v-model="searchForm.status" placeholder="请选择" clearable style="width: 160px">
+          <el-option v-for="item in statusOptions" :key="String(item.value)" :label="item.label" :value="item.value" />
+        </el-select>
+        <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: 260px"
+        />
+        <el-button type="primary" @click="handleSearch"> 搜索 </el-button>
+        <el-button @click="handleReset"> 重置 </el-button>
+      </div>
+    </div>
+
+    <ProTable ref="proTable" :columns="columns" :request-api="getTableList" :init-param="initParam">
+      <template #tableHeader>
+        <div class="table-header">
+          <el-button type="primary" @click="handleNew"> 新建 </el-button>
+        </div>
+      </template>
+      <template #statusText="{ row }">
+        <el-tag :type="getStatusTagType(row.status)" size="small">
+          {{ row.statusText }}
+        </el-tag>
+      </template>
+      <template #remark="{ row }">
+        <el-tooltip v-if="row.remark" :content="row.remark" placement="top" :show-after="200">
+          <span class="remark-cell">{{ row.remark }}</span>
+        </el-tooltip>
+        <span v-else>—</span>
+      </template>
+      <template #operation="scope">
+        <template v-if="scope.row.status === 0 || scope.row.status === '0'">
+          <el-button link type="primary" @click="handleCancel(scope.row)"> 取消 </el-button>
+        </template>
+        <template v-else-if="scope.row.status === 1 || scope.row.status === '1'">
+          <el-button link type="primary" @click="handleAddTime(scope.row)"> 加时 </el-button>
+          <el-button link type="primary" @click="handleDelete(scope.row)"> 删除 </el-button>
+        </template>
+        <template v-else-if="scope.row.status === 2 || scope.row.status === '2'">
+          <el-button v-if="scope.row.canRefund !== false" 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="scope.row.status === 3 || scope.row.status === '3'">
+          <el-button link type="primary" @click="handleViewReason(scope.row)"> 查看原因 </el-button>
+          <el-button link type="primary" @click="handleDelete(scope.row)"> 删除 </el-button>
+        </template>
+        <template v-else-if="scope.row.status === 4 || scope.row.status === '4'">
+          <el-button v-if="scope.row.canRefund !== false" 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="scope.row.status === 5 || scope.row.status === '5'">
+          <el-button link type="primary" @click="handleViewReason(scope.row)"> 查看原因 </el-button>
+        </template>
+        <template v-else-if="scope.row.status === 6 || scope.row.status === '6'">
+          <el-button link type="primary" @click="handleViewReason(scope.row)"> 查看原因 </el-button>
+          <el-button link type="primary" @click="handleDelete(scope.row)"> 删除 </el-button>
+        </template>
+        <template v-else>
+          <el-button link type="primary" @click="handleDelete(scope.row)"> 删除 </el-button>
+        </template>
+      </template>
+    </ProTable>
+
+    <!-- 加时弹窗 -->
+    <el-dialog v-model="addTimeVisible" title="加时" width="400px" append-to-body>
+      <el-form :model="addTimeForm" label-width="80px">
+        <el-form-item label="加时时长">
+          <el-input-number v-model="addTimeForm.minutes" :min="1" :max="480" placeholder="分钟" style="width: 100%" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="addTimeVisible = false"> 取消 </el-button>
+        <el-button type="primary" :loading="addTimeLoading" @click="confirmAddTime"> 确定 </el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 查看原因弹窗 -->
+    <el-dialog v-model="reasonVisible" title="查看原因" width="400px" append-to-body>
+      <p class="reason-text">
+        {{ currentReason || "—" }}
+      </p>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive } from "vue";
+import { ElMessage, ElMessageBox } from "element-plus";
+import ProTable from "@/components/ProTable/index.vue";
+import type { ProTableInstance, ColumnProps } from "@/components/ProTable/interface";
+import {
+  reservationList,
+  reservationAddTime,
+  reservationCancel,
+  reservationDelete,
+  reservationRefund
+} from "@/api/modules/scheduledService";
+import { localGet } from "@/utils";
+
+export interface ReservationRow {
+  id: number | string;
+  _seq?: number;
+  date: string;
+  weekDay: string;
+  location: string;
+  peopleCount: string | number;
+  tableNo: string;
+  timeRange: string;
+  amount: string | number;
+  customerName: string;
+  phone: string;
+  status: number | string;
+  statusText: string;
+  remark: string;
+  cancelReason?: string;
+  refundReason?: string;
+  canRefund?: boolean;
+}
+
+const proTable = ref<ProTableInstance>();
+
+const columns: ColumnProps<ReservationRow>[] = [
+  { prop: "_seq", label: "序号", width: 60, align: "center" },
+  { prop: "date", label: "日期", width: 100, align: "center" },
+  { prop: "weekDay", label: "星期", width: 70, align: "center" },
+  { prop: "location", label: "位置", minWidth: 90, showOverflowTooltip: true },
+  { prop: "peopleCount", label: "人数", width: 70, align: "center" },
+  { prop: "tableNo", label: "桌号", minWidth: 90, showOverflowTooltip: true },
+  { prop: "timeRange", label: "时间", width: 120, align: "center" },
+  { prop: "amount", label: "预订金额(元)", width: 110, align: "center" },
+  { prop: "customerName", label: "姓名", width: 100, showOverflowTooltip: true },
+  { prop: "phone", label: "电话", width: 120, showOverflowTooltip: true },
+  { prop: "statusText", label: "状态", width: 96, align: "center" },
+  { prop: "remark", label: "备注", minWidth: 100, showOverflowTooltip: true },
+  { prop: "operation", label: "操作", fixed: "right", width: 170, align: "center" }
+];
+
+const statusOptions = [
+  { label: "全部", value: "" },
+  { label: "待使用", value: 0 },
+  { label: "已完成", value: 1 },
+  { label: "退款", value: 5 }
+];
+
+const STATUS_MAP: Record<number, string> = {
+  0: "待使用",
+  1: "已完成",
+  2: "用户取消",
+  3: "商家取消",
+  4: "已过期",
+  5: "退款中",
+  6: "退款成功"
+};
+
+const initParam = reactive({
+  storeId: localGet("geeker-user")?.userInfo?.storeId ?? localGet("createdId") ?? ""
+});
+
+/** 已生效的查询条件(点击搜索后写入) */
+const listFilter = reactive({
+  name: "",
+  status: "" as number | string,
+  startDate: "",
+  endDate: ""
+});
+
+const searchForm = reactive({
+  name: "",
+  status: "" as number | string,
+  dateRange: [] as string[]
+});
+
+const addTimeVisible = ref(false);
+const addTimeLoading = ref(false);
+const addTimeForm = reactive({ minutes: 30 });
+const currentAddTimeRow = ref<ReservationRow | null>(null);
+
+const reasonVisible = ref(false);
+const currentReason = ref("");
+
+function getStoreId(): number | string | null {
+  return localGet("geeker-user")?.userInfo?.storeId ?? localGet("createdId") ?? null;
+}
+
+function getStatusTagType(status: number | string): "primary" | "success" | "warning" | "danger" | "info" {
+  const s = Number(status);
+  if (s === 0) return "primary";
+  if (s === 1) return "success";
+  if (s === 2 || s === 3 || s === 4) return "info";
+  if (s === 5) return "warning";
+  if (s === 6) return "success";
+  return "info";
+}
+
+function formatDateCol(val: string): string {
+  if (!val) return "—";
+  const d = new Date(val);
+  if (isNaN(d.getTime())) return String(val);
+  const m = d.getMonth() + 1;
+  const day = d.getDate();
+  return `${String(m).padStart(2, "0")}月${String(day).padStart(2, "0")}日`;
+}
+
+function getWeekDay(val: string): string {
+  if (!val) return "—";
+  const d = new Date(val);
+  if (isNaN(d.getTime())) return "—";
+  const week = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
+  return week[d.getDay()];
+}
+
+function mapReservationRow(item: any): ReservationRow {
+  const status = item.status ?? item.reservationStatus ?? 0;
+  const date = item.reservationDate ?? item.bookingDate ?? item.date ?? "";
+  const timeStart = item.startTime ?? item.bookingStartTime ?? "";
+  const timeEnd = item.endTime ?? item.bookingEndTime ?? "";
+  const customerName = item.name ?? item.userName ?? item.contactName ?? "—";
+  return {
+    id: item.id,
+    date: formatDateCol(date),
+    weekDay: item.weekDay ?? getWeekDay(date),
+    location: item.locationName ?? item.categoryName ?? item.location ?? "—",
+    peopleCount: item.peopleCount ?? item.personCount ?? item.guestCount ?? "—",
+    tableNo: item.tableNo ?? item.tableNumber ?? item.tableNumbers ?? "—",
+    timeRange: timeStart && timeEnd ? `${timeStart}-${timeEnd}` : (item.timeRange ?? "—"),
+    amount: item.amount != null && item.amount !== "" ? item.amount : "—",
+    customerName,
+    phone: item.phone ?? item.mobile ?? item.contactPhone ?? "—",
+    status,
+    statusText: STATUS_MAP[Number(status)] ?? item.statusText ?? "—",
+    remark: item.remark ?? item.remarkDesc ?? "",
+    cancelReason: item.cancelReason ?? item.reason ?? "",
+    refundReason: item.refundReason,
+    canRefund: item.canRefund
+  };
+}
+
+/** ProTable / useTable 约定:返回 { data: { list, total } } */
+async function getTableList(params: any) {
+  const storeId = params.storeId ?? initParam.storeId;
+  if (!storeId) {
+    return { data: { list: [] as ReservationRow[], total: 0 } };
+  }
+  const pageNum = params.pageNum ?? 1;
+  const pageSize = params.pageSize ?? 10;
+  const req: Record<string, any> = {
+    storeId: Number(storeId),
+    pageNum,
+    pageSize
+  };
+  if (listFilter.name?.trim()) req.name = listFilter.name.trim();
+  if (listFilter.status !== undefined && listFilter.status !== "" && listFilter.status !== null) {
+    req.status = listFilter.status;
+  }
+  if (listFilter.startDate && listFilter.endDate) {
+    req.startDate = listFilter.startDate;
+    req.endDate = listFilter.endDate;
+  }
+  try {
+    const res: any = await reservationList(req);
+    const body = res?.data ?? res;
+    const inner = body?.data ?? body;
+    const list = inner?.list ?? inner?.records ?? (Array.isArray(inner) ? inner : []);
+    const arr = Array.isArray(list) ? list : [];
+    const total = inner?.total ?? body?.total ?? res?.total ?? 0;
+    const rows = arr.map((item: any, i: number) => ({
+      ...mapReservationRow(item),
+      _seq: (pageNum - 1) * pageSize + i + 1
+    }));
+    return { data: { list: rows, total: Number(total) || 0 } };
+  } catch (e: any) {
+    ElMessage.error(e?.message || "加载失败");
+    return { data: { list: [] as ReservationRow[], total: 0 } };
+  }
+}
+
+function handleSearch() {
+  listFilter.name = searchForm.name;
+  listFilter.status = searchForm.status;
+  if (searchForm.dateRange?.length === 2) {
+    listFilter.startDate = searchForm.dateRange[0];
+    listFilter.endDate = searchForm.dateRange[1];
+  } else {
+    listFilter.startDate = "";
+    listFilter.endDate = "";
+  }
+  proTable.value?.getTableList();
+}
+
+function handleReset() {
+  searchForm.name = "";
+  searchForm.status = "";
+  searchForm.dateRange = [];
+  listFilter.name = "";
+  listFilter.status = "";
+  listFilter.startDate = "";
+  listFilter.endDate = "";
+  proTable.value?.getTableList();
+}
+
+function handleNew() {
+  ElMessage.info("新建预约功能可在此处跳转或打开弹窗");
+}
+
+function handleCancel(row: ReservationRow) {
+  ElMessageBox.confirm("确认取消该预约?", "提示", {
+    confirmButtonText: "确定",
+    cancelButtonText: "取消",
+    type: "warning"
+  })
+    .then(async () => {
+      try {
+        await reservationCancel({ id: row.id });
+        ElMessage.success("已取消");
+        proTable.value?.getTableList();
+      } catch (e: any) {
+        ElMessage.error(e?.message || "取消失败");
+      }
+    })
+    .catch(() => {});
+}
+
+function handleAddTime(row: ReservationRow) {
+  currentAddTimeRow.value = row;
+  addTimeForm.minutes = 30;
+  addTimeVisible.value = true;
+}
+
+async function confirmAddTime() {
+  const row = currentAddTimeRow.value;
+  if (!row) return;
+  addTimeLoading.value = true;
+  try {
+    await reservationAddTime({ id: row.id, addMinutes: addTimeForm.minutes });
+    ElMessage.success("加时成功");
+    addTimeVisible.value = false;
+    proTable.value?.getTableList();
+  } catch (e: any) {
+    ElMessage.error(e?.message || "加时失败");
+  } finally {
+    addTimeLoading.value = false;
+  }
+}
+
+function handleDelete(row: ReservationRow) {
+  ElMessageBox.confirm("确认删除该预约记录?", "提示", {
+    confirmButtonText: "确定",
+    cancelButtonText: "取消",
+    type: "warning"
+  })
+    .then(async () => {
+      try {
+        await reservationDelete({ id: row.id });
+        ElMessage.success("删除成功");
+        proTable.value?.getTableList();
+      } catch (e: any) {
+        ElMessage.error(e?.message || "删除失败");
+      }
+    })
+    .catch(() => {});
+}
+
+function handleRefund(row: ReservationRow) {
+  ElMessageBox.confirm("确认对该预约发起退款?", "提示", {
+    confirmButtonText: "确定",
+    cancelButtonText: "取消",
+    type: "warning"
+  })
+    .then(async () => {
+      try {
+        await reservationRefund({ id: row.id });
+        ElMessage.success("退款申请已提交");
+        proTable.value?.getTableList();
+      } catch (e: any) {
+        ElMessage.error(e?.message || "退款失败");
+      }
+    })
+    .catch(() => {});
+}
+
+function handleViewReason(row: ReservationRow) {
+  currentReason.value = row.cancelReason || row.refundReason || "暂无原因说明";
+  reasonVisible.value = true;
+}
+</script>
+
+<style scoped lang="scss">
+.appointment-info {
+  padding: 0;
+}
+.filter-bar {
+  padding: 0 4px;
+  margin-bottom: 12px;
+}
+.filter-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+  align-items: center;
+}
+.filter-label {
+  font-size: 14px;
+  color: #606266;
+  white-space: nowrap;
+}
+.table-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: 100%;
+  padding: 12px 0;
+}
+.tip-text {
+  font-size: 12px;
+  color: #909399;
+}
+.remark-cell {
+  display: inline-block;
+  max-width: 120px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  cursor: default;
+}
+.reason-text {
+  margin: 0;
+  color: #606266;
+  word-break: break-all;
+}
+</style>

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

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

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

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

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

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

+ 26 - 19
src/views/storeDecoration/businessHours/index.vue

@@ -136,7 +136,7 @@
 <script setup lang="ts">
 import { ref, reactive, computed, onMounted } from "vue";
 import { ElMessage, ElNotification } from "element-plus";
-import { getBusinessTimeList, addOrEditBusinessTime } from "@/api/modules/storeDecoration";
+import { getBusinessTimeList, addOrEditBusinessTime, getHolidayList } from "@/api/modules/storeDecoration";
 import { localGet } from "@/utils";
 
 // 周一到周日
@@ -150,24 +150,30 @@ const weekDays = [
   { label: "周日", value: 7 }
 ];
 
-// 节假日列表
-// TODO: 如果节假日列表需要从接口获取,请调用 getHolidayList API 并在 onMounted 中初始化
-const holidays = ref([
-  { label: "元旦", value: "元旦" },
-  { label: "春节", value: "春节" },
-  { label: "情人节", value: "情人节" },
-  { label: "元宵节", value: "元宵节" },
-  { label: "清明节", value: "清明节" },
-  { label: "劳动节", value: "劳动节" },
-  { label: "儿童节", value: "儿童节" },
-  { label: "端午节", value: "端午节" },
-  { label: "七夕", value: "七夕" },
-  { label: "中秋节", value: "中秋节" },
-  { label: "国庆节", value: "国庆节" },
-  { label: "冬至", value: "冬至" },
-  { label: "平安夜", value: "平安夜" },
-  { label: "圣诞节", value: "圣诞节" }
-]);
+// 节假日列表(由 getHolidayList 接口加载)
+const holidays = ref<{ label: string; value: string }[]>([]);
+const year = new Date().getFullYear();
+const loadHolidays = async () => {
+  try {
+    const res: any = await getHolidayList({
+      holidayName: "",
+      openFlag: 1,
+      year: year,
+      page: 1,
+      size: 10
+    });
+    const records = res?.data?.records ?? res?.records ?? res?.data ?? [];
+    const list = Array.isArray(records) ? records : [];
+    holidays.value = list
+      .map((record: any) => {
+        const name = record.festivalName ?? record.holidayName ?? record.name ?? "";
+        return { label: name, value: name };
+      })
+      .filter((item: { label: string; value: string }) => item.value);
+  } catch {
+    holidays.value = [];
+  }
+};
 
 // 正常营业时间列表
 interface NormalHoursItem {
@@ -533,6 +539,7 @@ const getBusinessTimeData = async () => {
 
 // 页面初始化时获取数据
 onMounted(async () => {
+  await loadHolidays();
   await getBusinessTimeData();
 });