appoinmentInfo.vue 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777
  1. <template>
  2. <div class="table-box appointment-info">
  3. <!-- 筛选:点击搜索后生效(与翻页联动);列表参数 reservationUserName、orderStatus -->
  4. <div class="filter-bar">
  5. <div class="filter-row">
  6. <span class="filter-label">姓名</span>
  7. <el-input v-model="searchForm.reservationUserName" placeholder="请输入姓名" clearable style="width: 160px" />
  8. <span class="filter-label">状态</span>
  9. <el-select v-model="searchForm.orderStatus" placeholder="请选择" clearable style="width: 180px">
  10. <el-option
  11. v-for="opt in statusOptions"
  12. :key="`${opt.label}-${String(opt.value)}`"
  13. :label="opt.label"
  14. :value="opt.value"
  15. />
  16. </el-select>
  17. <div style="width: 600px">
  18. <span class="filter-label">预订日期</span>
  19. <el-date-picker
  20. v-model="searchForm.dateRange"
  21. type="daterange"
  22. range-separator="至"
  23. start-placeholder="请选择"
  24. end-placeholder="请选择"
  25. value-format="YYYY-MM-DD"
  26. style="width: 80%; margin-left: 10px"
  27. />
  28. </div>
  29. <el-button type="primary" @click="handleSearch"> 搜索 </el-button>
  30. <el-button @click="handleReset"> 重置 </el-button>
  31. </div>
  32. </div>
  33. <ProTable ref="proTable" :columns="columns" :request-api="getTableList" :init-param="initParam" :pagination="false">
  34. <template #tableHeader>
  35. <div class="table-header">
  36. <!-- <el-button type="primary" @click="handleNew"> 新建 </el-button> -->
  37. </div>
  38. </template>
  39. <template #statusText="{ row }">
  40. <el-tag :type="getStatusTagType(row.orderStatus)" size="small">
  41. {{ row.statusText }}
  42. </el-tag>
  43. </template>
  44. <template #remark="{ row }">
  45. <el-tooltip v-if="row.remark" :content="row.remark" placement="top" :show-after="200">
  46. <span class="remark-cell">{{ row.remark }}</span>
  47. </el-tooltip>
  48. <span v-else>—</span>
  49. </template>
  50. <!-- 操作按钮与 group_merchant scheduledInfo.vue 一致(orderStatus 判断顺序一致) -->
  51. <template #operation="scope">
  52. <div class="op-actions">
  53. <el-button v-if="hasReasonText(scope.row)" link type="primary" @click="openReasonDialog(scope.row)">
  54. 查看原因
  55. </el-button>
  56. <template v-if="isOrderStatus(scope.row, 1)">
  57. <el-button link type="primary" @click="handleCancel(scope.row)"> 取消 </el-button>
  58. </template>
  59. <template
  60. v-else-if="
  61. (isOrderStatus(scope.row, 2) || isOrderStatus(scope.row, 7) || isOrderStatus(scope.row, 3)) &&
  62. isMoreThanThreeHoursAfterEnd(scope.row)
  63. "
  64. >
  65. <el-button link type="primary" @click="handleDelete(scope.row)"> 删除 </el-button>
  66. </template>
  67. <template
  68. v-else-if="
  69. (isOrderStatus(scope.row, 2) || isOrderStatus(scope.row, 7) || isOrderStatus(scope.row, 3)) &&
  70. !isMoreThanThreeHoursAfterEnd(scope.row)
  71. "
  72. >
  73. <el-button link type="primary" @click="handleDelete(scope.row)"> 删除 </el-button>
  74. </template>
  75. <template v-else-if="isOrderStatus(scope.row, 7) && !isMoreThanThreeHoursAfterEnd(scope.row)">
  76. <el-button link type="primary" @click="handleDelete(scope.row)"> 删除 </el-button>
  77. </template>
  78. <template
  79. v-else-if="isOrderStatus(scope.row, 3) && !isMoreThanThreeHoursAfterEnd(scope.row) && orderCostTypeIsPaid(scope.row)"
  80. >
  81. <el-button link type="primary" @click="handleRefund(scope.row)"> 退款 </el-button>
  82. <el-button link type="primary" @click="handleDelete(scope.row)"> 删除 </el-button>
  83. </template>
  84. <template v-else-if="isOrderStatus(scope.row, 4) && !orderCostTypeIsPaid(scope.row)">
  85. <el-button link type="primary" @click="handleDelete(scope.row)"> 删除 </el-button>
  86. </template>
  87. <template v-else-if="orderCostTypeIsPaid(scope.row) && isOrderStatus(scope.row, 4) && cancelReasonIsEmpty(scope.row)">
  88. <el-button link type="primary" @click="handleRefund(scope.row)"> 退款 </el-button>
  89. <el-button link type="primary" @click="handleDelete(scope.row)"> 删除 </el-button>
  90. </template>
  91. <template v-else-if="isOrderStatus(scope.row, 2) && !isMoreThanThreeHoursAfterEnd(scope.row)">
  92. <el-button link type="primary" @click="handleAddTime(scope.row)"> 加时 </el-button>
  93. </template>
  94. </div>
  95. </template>
  96. </ProTable>
  97. <!-- 查看原因 -->
  98. <el-dialog v-model="reasonDialogVisible" title="原因" width="420px" append-to-body destroy-on-close>
  99. <div class="reason-dialog-body">取消原因:</div>
  100. <div class="reason-dialog-body">
  101. {{ reasonDialogText || "—" }}
  102. </div>
  103. <template #footer>
  104. <el-button type="primary" @click="reasonDialogVisible = false"> 知道了 </el-button>
  105. </template>
  106. </el-dialog>
  107. <!-- 取消原因(与 group_merchant scheduledInfo:reservationId + cancelReason) -->
  108. <el-dialog
  109. v-model="cancelReasonVisible"
  110. title="取消原因"
  111. width="480px"
  112. append-to-body
  113. destroy-on-close
  114. class="cancel-reason-dialog"
  115. @closed="resetCancelReasonDialog"
  116. >
  117. <el-form label-position="top" class="cancel-reason-form">
  118. <el-form-item label="取消原因" required>
  119. <el-input
  120. v-model="cancelReasonText"
  121. type="textarea"
  122. :rows="4"
  123. maxlength="500"
  124. show-word-limit
  125. placeholder="请输入取消原因"
  126. />
  127. </el-form-item>
  128. </el-form>
  129. <template #footer>
  130. <el-button @click="cancelReasonVisible = false"> 取消 </el-button>
  131. <el-button type="primary" :loading="cancelReasonLoading" @click="confirmCancelReason"> 确定 </el-button>
  132. </template>
  133. </el-dialog>
  134. <!-- 加时弹窗(与 group_merchant scheduledInfo:1-99 分钟、reservationId + addTimeMinutes + addTimeStart) -->
  135. <el-dialog
  136. v-model="addTimeVisible"
  137. title="加时"
  138. width="480px"
  139. append-to-body
  140. destroy-on-close
  141. class="add-time-dialog"
  142. @closed="resetAddTimeDialog"
  143. >
  144. <el-form :model="addTimeForm" label-position="top" require-asterisk-position="right" class="add-time-form">
  145. <el-form-item label="加时时长" required>
  146. <el-input
  147. :model-value="addTimeForm.minutesInput"
  148. placeholder="请输入"
  149. clearable
  150. maxlength="2"
  151. @update:model-value="onAddTimeMinutesInput"
  152. >
  153. <template #append>
  154. <span class="add-time-append-unit">分钟</span>
  155. </template>
  156. </el-input>
  157. </el-form-item>
  158. </el-form>
  159. <template #footer>
  160. <el-button @click="addTimeVisible = false"> 取消 </el-button>
  161. <el-button type="primary" :loading="addTimeLoading" @click="confirmAddTime"> 确定 </el-button>
  162. </template>
  163. </el-dialog>
  164. </div>
  165. </template>
  166. <script setup lang="ts">
  167. import { ref, reactive } from "vue";
  168. import { ElMessage, ElMessageBox } from "element-plus";
  169. import ProTable from "@/components/ProTable/index.vue";
  170. import type { ProTableInstance, ColumnProps } from "@/components/ProTable/interface";
  171. import {
  172. reservationList,
  173. reservationAddTime,
  174. reservationCancel,
  175. reservationDelete,
  176. reservationRefund
  177. } from "@/api/modules/scheduledService";
  178. import { localGet } from "@/utils";
  179. export interface ReservationRow {
  180. id: number | string;
  181. /** 与商家端加时接口一致,缺省时用 id */
  182. reservationId?: number | string;
  183. _seq?: number;
  184. date: string;
  185. weekDay: string;
  186. location: string;
  187. peopleCount: string | number;
  188. tableNo: string;
  189. timeRange: string;
  190. amount: string | number;
  191. customerName: string;
  192. phone: string;
  193. /** 兼容旧字段展示/筛选 */
  194. status: number | string;
  195. statusText: string;
  196. /** 与商家端 1.vue 一致:订单状态 */
  197. orderStatus: number;
  198. /** 0 免费 1 付费(退款按钮用) */
  199. orderCostType: number;
  200. /** 与 1.vue item.reason 一致:空=用户取消,非空=商家取消等 */
  201. reason: string;
  202. reservationDateRaw: string;
  203. timeSlotRaw: string;
  204. endTimeRaw?: string | Date;
  205. remark: string;
  206. cancelReason?: string;
  207. refundReason?: string;
  208. canRefund?: boolean;
  209. }
  210. const proTable = ref<ProTableInstance>();
  211. const columns: ColumnProps<ReservationRow>[] = [
  212. { prop: "_seq", label: "序号", width: 60, align: "center" },
  213. { prop: "date", label: "日期", width: 100, align: "center" },
  214. { prop: "weekDay", label: "星期", width: 70, align: "center" },
  215. { prop: "location", label: "位置", minWidth: 90, showOverflowTooltip: true },
  216. { prop: "peopleCount", label: "人数", width: 70, align: "center" },
  217. { prop: "tableNo", label: "桌号", minWidth: 90, showOverflowTooltip: true },
  218. { prop: "timeRange", label: "时间", width: 120, align: "center" },
  219. { prop: "customerName", label: "姓名", width: 100, showOverflowTooltip: true },
  220. { prop: "phone", label: "电话", width: 120, showOverflowTooltip: true },
  221. { prop: "statusText", label: "状态", width: 96, align: "center" },
  222. { prop: "remark", label: "备注", minWidth: 100, showOverflowTooltip: true },
  223. { prop: "operation", label: "操作", fixed: "right", width: 240, align: "center" }
  224. ];
  225. /**
  226. * 筛选状态:商家取消 / 用户取消 均为 orderStatus=4,列表展示区分见 mapReservationRow(reason 空=用户取消)
  227. * 接口仅支持 orderStatus 时,请求 4 后在本地按 reason 再筛一层
  228. */
  229. const STATUS_FILTER_MERCHANT_CANCEL = "merchant_cancel";
  230. const STATUS_FILTER_USER_CANCEL = "user_cancel";
  231. const statusOptions: { label: string; value: number | string }[] = [
  232. { label: "全部", value: "" },
  233. { label: "待使用", value: 1 },
  234. { label: "退款成功", value: 7 },
  235. { label: "商家取消", value: STATUS_FILTER_MERCHANT_CANCEL },
  236. { label: "用户取消", value: STATUS_FILTER_USER_CANCEL },
  237. { label: "已过期", value: 3 },
  238. { label: "已完成", value: 2 }
  239. ];
  240. /** orderStatus 文案,与 1.vue ORDER_STATUS_TEXT 一致 */
  241. const ORDER_STATUS_TEXT: Record<number, string> = {
  242. 0: "待支付",
  243. 1: "待使用",
  244. 2: "已完成",
  245. 3: "已过期",
  246. 4: "已取消",
  247. 5: "已关闭",
  248. 6: "退款中",
  249. 7: "已退款",
  250. 8: "商家预订"
  251. };
  252. /** 旧版 Web 列表 status → orderStatus(无 orderStatus 字段时的兜底) */
  253. const LEGACY_STATUS_TO_ORDER: Record<number, number> = {
  254. 0: 1, // 待使用
  255. 1: 2, // 已完成
  256. 2: 4, // 用户取消 → 已取消
  257. 3: 4, // 商家取消 → 已取消
  258. 4: 3, // 已过期
  259. 5: 6, // 退款中
  260. 6: 7 // 退款成功 → 已退款
  261. };
  262. const THREE_HOURS_MS = 3 * 60 * 60 * 1000;
  263. const initParam = reactive({
  264. storeId: localGet("geeker-user")?.userInfo?.storeId ?? localGet("createdId") ?? ""
  265. });
  266. /** 已生效的查询条件(点击搜索后写入;接口字段 reservationUserName、orderStatus) */
  267. const listFilter = reactive({
  268. reservationUserName: "",
  269. orderStatus: "" as number | string,
  270. startDate: "",
  271. endDate: ""
  272. });
  273. const searchForm = reactive({
  274. reservationUserName: "",
  275. orderStatus: "" as number | string,
  276. dateRange: [] as string[]
  277. });
  278. const addTimeVisible = ref(false);
  279. const addTimeLoading = ref(false);
  280. const addTimeForm = reactive({ minutesInput: "" });
  281. const currentAddTimeRow = ref<ReservationRow | null>(null);
  282. /** 取消预约:原因弹窗(对齐 scheduledInfo) */
  283. const cancelReasonVisible = ref(false);
  284. const cancelReasonLoading = ref(false);
  285. const cancelReasonText = ref("");
  286. const cancelCurrentRow = ref<ReservationRow | null>(null);
  287. function getStoreId(): number | string | null {
  288. return localGet("geeker-user")?.userInfo?.storeId ?? localGet("createdId") ?? null;
  289. }
  290. /** 与 scheduledInfo normalizeReservationItem:pending(0,1,8) | done(2) | refund(6,7) | closed(3,4,5) */
  291. function getStatusTagType(orderStatus: number | string): "primary" | "success" | "warning" | "danger" | "info" {
  292. const s = Number(orderStatus);
  293. if (s === 2) return "success";
  294. if (s === 6 || s === 7) return "warning";
  295. if (s === 3 || s === 4 || s === 5) return "info";
  296. if (s === 0 || s === 1 || s === 8) return "primary";
  297. return "info";
  298. }
  299. function isOrderStatus(row: ReservationRow, n: number) {
  300. return Number(row.orderStatus) === n;
  301. }
  302. function orderCostTypeIsPaid(row: ReservationRow) {
  303. return Number(row.orderCostType) === 1;
  304. }
  305. /** 与 1.vue 一致:reason === '' 时视为用户取消(付费可退款+删除) */
  306. function cancelReasonIsEmpty(row: ReservationRow) {
  307. return String(row.reason ?? "").trim() === "";
  308. }
  309. function hasReasonText(row: ReservationRow) {
  310. return String(row.reason ?? "").trim() !== "";
  311. }
  312. const reasonDialogVisible = ref(false);
  313. const reasonDialogText = ref("");
  314. function openReasonDialog(row: ReservationRow) {
  315. reasonDialogText.value = String(row.reason ?? "").trim() || "—";
  316. reasonDialogVisible.value = true;
  317. }
  318. function parseRowEndDateTime(row: ReservationRow): Date | null {
  319. const et = row.endTimeRaw;
  320. if (et != null && et !== "") {
  321. const d = et instanceof Date ? et : new Date(et as string);
  322. if (!isNaN(d.getTime())) return d;
  323. }
  324. const date = String(row.reservationDateRaw || "")
  325. .trim()
  326. .replace(/\//g, "-");
  327. const slot = String(row.timeSlotRaw || "").trim();
  328. const parts = slot.split("-");
  329. const endTimeStr = parts.length > 1 ? parts[parts.length - 1].trim() : "";
  330. if (date && endTimeStr) {
  331. const normalized = endTimeStr.length <= 5 ? `${date} ${endTimeStr}:00` : `${date} ${endTimeStr}`;
  332. const parsed = new Date(normalized.replace(/-/g, "/"));
  333. if (!isNaN(parsed.getTime())) return parsed;
  334. }
  335. return null;
  336. }
  337. /** 结束时间已过且当前时间超过结束 3 小时(与 1.vue 一致) */
  338. function isMoreThanThreeHoursAfterEnd(row: ReservationRow) {
  339. const end = parseRowEndDateTime(row);
  340. if (!end) return false;
  341. return Date.now() > end.getTime() + THREE_HOURS_MS;
  342. }
  343. function formatDateCol(val: string): string {
  344. if (!val) return "—";
  345. const d = new Date(val);
  346. if (isNaN(d.getTime())) return String(val);
  347. const m = d.getMonth() + 1;
  348. const day = d.getDate();
  349. return `${String(m).padStart(2, "0")}月${String(day).padStart(2, "0")}日`;
  350. }
  351. function getWeekDay(val: string): string {
  352. if (!val) return "—";
  353. const d = new Date(val);
  354. if (isNaN(d.getTime())) return "—";
  355. const week = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
  356. return week[d.getDay()];
  357. }
  358. function mapReservationRow(item: any): ReservationRow {
  359. const date = item.reservationDate ?? item.bookingDate ?? item.date ?? "";
  360. const timeStart = item.startTime ?? item.bookingStartTime ?? "";
  361. const timeEnd = item.bookingEndTime ?? item.endTime ?? "";
  362. const customerName =
  363. item.customerName != null && String(item.customerName).trim() !== ""
  364. ? String(item.customerName).trim()
  365. : (item.name ?? item.userName ?? item.contactName ?? "—");
  366. const hasOrderStatusField =
  367. (item.orderStatus != null && item.orderStatus !== "") || (item.statusType != null && item.statusType !== "");
  368. const legacySt = Number(item.status ?? item.reservationStatus ?? NaN);
  369. let orderStatus: number;
  370. if (item.orderStatus != null && item.orderStatus !== "") {
  371. orderStatus = Number(item.orderStatus);
  372. } else if (item.statusType != null && item.statusType !== "") {
  373. orderStatus = Number(item.statusType);
  374. } else if (Number.isFinite(legacySt) && LEGACY_STATUS_TO_ORDER[legacySt] !== undefined) {
  375. orderStatus = LEGACY_STATUS_TO_ORDER[legacySt];
  376. } else if (Number.isFinite(legacySt)) {
  377. orderStatus = legacySt;
  378. } else {
  379. orderStatus = 1;
  380. }
  381. let reason = String(item.reason ?? item.cancelReason ?? "").trim();
  382. if (!hasOrderStatusField && legacySt === 2) reason = "";
  383. if (!hasOrderStatusField && legacySt === 3 && !reason) reason = "商家取消";
  384. const orderCostType = Number(item.orderCostType ?? item.costType ?? item.reservationCostType ?? 0);
  385. const timeSlotRaw = item.timeSlot ?? (timeStart && timeEnd ? `${String(timeStart).trim()}-${String(timeEnd).trim()}` : "");
  386. return {
  387. id: item.id,
  388. reservationId: item.reservationId ?? item.id,
  389. date: formatDateCol(date),
  390. weekDay: item.weekDay ?? getWeekDay(date),
  391. location: item.locationName ?? item.categoryName ?? item.location ?? "—",
  392. peopleCount: item.peopleCount ?? item.personCount ?? item.guestCount ?? "—",
  393. tableNo: item.tableNo ?? item.tableNumber ?? item.tableNumbers ?? "—",
  394. timeRange: timeStart && timeEnd ? `${timeStart}-${timeEnd}` : (item.timeRange ?? "—"),
  395. amount:
  396. item.depositAmount != null && item.depositAmount !== ""
  397. ? item.depositAmount
  398. : item.amount != null && item.amount !== ""
  399. ? item.amount
  400. : "—",
  401. customerName,
  402. phone: item.phone ?? item.mobile ?? item.contactPhone ?? "—",
  403. status: orderStatus,
  404. orderStatus,
  405. orderCostType,
  406. reason,
  407. reservationDateRaw: String(date).trim(),
  408. timeSlotRaw,
  409. endTimeRaw: item.endTime ?? item.bookingEndTime ?? item.reservationEndTime,
  410. /** scheduledInfo:orderStatus==4 时 reason 空=用户取消,非空=商家取消 */
  411. statusText:
  412. orderStatus === 4
  413. ? reason === ""
  414. ? "用户取消"
  415. : "商家取消"
  416. : (item.orderStatusText && String(item.orderStatusText).trim()) ||
  417. ORDER_STATUS_TEXT[orderStatus] ||
  418. item.statusText ||
  419. "—",
  420. remark: item.remark ?? item.remarkDesc ?? "",
  421. cancelReason: item.cancelReason ?? item.reason ?? "",
  422. refundReason: item.refundReason,
  423. canRefund: item.canRefund
  424. };
  425. }
  426. /** ProTable / useTable 约定:返回 { data: { list, total } } */
  427. async function getTableList(params: any) {
  428. const storeId = params.storeId ?? initParam.storeId;
  429. if (!storeId) {
  430. return { data: { list: [] as ReservationRow[], total: 0 } };
  431. }
  432. const pageNum = params.pageNum ?? 1;
  433. const pageSize = params.pageSize ?? 10;
  434. const req: Record<string, any> = {
  435. storeId: Number(storeId),
  436. pageNum,
  437. pageSize
  438. };
  439. if (listFilter.reservationUserName?.trim()) {
  440. req.reservationUserName = listFilter.reservationUserName.trim();
  441. }
  442. const sf = listFilter.orderStatus;
  443. if (sf !== undefined && sf !== "" && sf !== null) {
  444. if (sf === STATUS_FILTER_USER_CANCEL || sf === STATUS_FILTER_MERCHANT_CANCEL) {
  445. req.orderStatus = 4;
  446. } else {
  447. req.orderStatus = sf;
  448. }
  449. }
  450. if (listFilter.startDate && listFilter.endDate) {
  451. req.dateFrom = listFilter.startDate;
  452. req.dateTo = listFilter.endDate;
  453. }
  454. try {
  455. const res: any = await reservationList(req);
  456. const body = res?.data ?? res;
  457. const inner = body?.data ?? body;
  458. const list = inner?.list ?? inner?.records ?? (Array.isArray(inner) ? inner : []);
  459. const arr = Array.isArray(list) ? list : [];
  460. const apiTotal = Number(inner?.total ?? body?.total ?? res?.total ?? 0) || 0;
  461. let rows = arr.map((item: any) => mapReservationRow(item));
  462. if (sf === STATUS_FILTER_USER_CANCEL) {
  463. rows = rows.filter(r => r.orderStatus === 4 && cancelReasonIsEmpty(r));
  464. } else if (sf === STATUS_FILTER_MERCHANT_CANCEL) {
  465. rows = rows.filter(r => r.orderStatus === 4 && !cancelReasonIsEmpty(r));
  466. }
  467. const finalRows = rows.map((r, i) => ({
  468. ...r,
  469. _seq: (pageNum - 1) * pageSize + i + 1
  470. }));
  471. const total = sf === STATUS_FILTER_USER_CANCEL || sf === STATUS_FILTER_MERCHANT_CANCEL ? finalRows.length : apiTotal;
  472. return { data: { list: finalRows, total } };
  473. } catch (e: any) {
  474. ElMessage.error(e?.message || "加载失败");
  475. return { data: { list: [] as ReservationRow[], total: 0 } };
  476. }
  477. }
  478. function handleSearch() {
  479. listFilter.reservationUserName = searchForm.reservationUserName;
  480. listFilter.orderStatus = searchForm.orderStatus;
  481. if (searchForm.dateRange?.length === 2) {
  482. listFilter.startDate = searchForm.dateRange[0];
  483. listFilter.endDate = searchForm.dateRange[1];
  484. } else {
  485. listFilter.startDate = "";
  486. listFilter.endDate = "";
  487. }
  488. proTable.value?.getTableList();
  489. }
  490. function handleReset() {
  491. searchForm.reservationUserName = "";
  492. searchForm.orderStatus = "";
  493. searchForm.dateRange = [];
  494. listFilter.reservationUserName = "";
  495. listFilter.orderStatus = "";
  496. listFilter.startDate = "";
  497. listFilter.endDate = "";
  498. proTable.value?.getTableList();
  499. }
  500. function handleNew() {
  501. ElMessage.info("新建预约功能可在此处跳转或打开弹窗");
  502. }
  503. function handleCancel(row: ReservationRow) {
  504. cancelCurrentRow.value = row;
  505. cancelReasonText.value = "";
  506. cancelReasonVisible.value = true;
  507. }
  508. function resetCancelReasonDialog() {
  509. cancelCurrentRow.value = null;
  510. cancelReasonText.value = "";
  511. }
  512. async function confirmCancelReason() {
  513. const reason = (cancelReasonText.value || "").trim();
  514. if (!reason) {
  515. ElMessage.warning("请输入取消原因");
  516. return;
  517. }
  518. const row = cancelCurrentRow.value;
  519. const reservationId = row?.reservationId ?? row?.id;
  520. if (reservationId == null || reservationId === "") {
  521. ElMessage.warning("缺少预订ID");
  522. return;
  523. }
  524. cancelReasonLoading.value = true;
  525. try {
  526. const apiRes: any = await reservationCancel({ reservationId, cancelReason: reason });
  527. ElMessage.success(apiRes?.msg || "已取消");
  528. cancelReasonVisible.value = false;
  529. resetCancelReasonDialog();
  530. proTable.value?.getTableList();
  531. } catch (e: any) {
  532. if (typeof e?.code !== "number") {
  533. ElMessage.error(e?.msg || e?.data?.msg || e?.message || "取消失败");
  534. }
  535. } finally {
  536. cancelReasonLoading.value = false;
  537. }
  538. }
  539. /** 与 scheduledInfo confirmAddTime:当前时间 yyyy-MM-dd HH:mm */
  540. function formatAddTimeStartFromNow(): string {
  541. const now = new Date();
  542. 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")}`;
  543. }
  544. /** 与 scheduledInfo onAddTimeMinutesInput:最多 2 位数字 1-99 */
  545. function onAddTimeMinutesInput(val: string | number) {
  546. const v = String(val ?? "")
  547. .replace(/\D/g, "")
  548. .slice(0, 2);
  549. addTimeForm.minutesInput = v;
  550. }
  551. function resetAddTimeDialog() {
  552. currentAddTimeRow.value = null;
  553. addTimeForm.minutesInput = "";
  554. }
  555. function handleAddTime(row: ReservationRow) {
  556. currentAddTimeRow.value = row;
  557. addTimeForm.minutesInput = "";
  558. addTimeVisible.value = true;
  559. }
  560. async function confirmAddTime() {
  561. const row = currentAddTimeRow.value;
  562. if (!row) return;
  563. const raw = String(addTimeForm.minutesInput ?? "").trim();
  564. if (!raw) {
  565. ElMessage.warning("请输入加时时长");
  566. return;
  567. }
  568. const addTimeMinutesNum = parseInt(raw, 10);
  569. if (isNaN(addTimeMinutesNum) || addTimeMinutesNum < 1 || addTimeMinutesNum > 99) {
  570. ElMessage.warning("加时时长为1-99的整数");
  571. return;
  572. }
  573. const reservationId = row.reservationId ?? row.id;
  574. if (reservationId == null || reservationId === "") {
  575. ElMessage.warning("缺少预订ID");
  576. return;
  577. }
  578. const addTimeStart = formatAddTimeStartFromNow();
  579. addTimeLoading.value = true;
  580. try {
  581. const apiRes: any = await reservationAddTime({
  582. reservationId,
  583. addTimeMinutes: addTimeMinutesNum,
  584. addTimeStart
  585. });
  586. ElMessage.success(apiRes?.msg || "加时成功");
  587. addTimeVisible.value = false;
  588. resetAddTimeDialog();
  589. proTable.value?.getTableList();
  590. } catch (e: any) {
  591. // 业务 code≠200 时 indexApi 响应拦截器已提示 msg,避免重复 Toast
  592. if (typeof e?.code !== "number") {
  593. ElMessage.error(e?.msg || e?.data?.msg || e?.message || "加时失败");
  594. }
  595. } finally {
  596. addTimeLoading.value = false;
  597. }
  598. }
  599. function handleDelete(row: ReservationRow) {
  600. ElMessageBox.confirm("确认删除该预约记录?", "提示", {
  601. confirmButtonText: "确定",
  602. cancelButtonText: "取消",
  603. type: "warning"
  604. })
  605. .then(async () => {
  606. try {
  607. await reservationDelete({ id: row.id });
  608. ElMessage.success("删除成功");
  609. proTable.value?.getTableList();
  610. } catch (e: any) {
  611. ElMessage.error(e?.message || "删除失败");
  612. }
  613. })
  614. .catch(() => {});
  615. }
  616. function handleRefund(row: ReservationRow) {
  617. ElMessageBox.confirm("确认对该预约发起退款?", "提示", {
  618. confirmButtonText: "确定",
  619. cancelButtonText: "取消",
  620. type: "warning"
  621. })
  622. .then(async () => {
  623. try {
  624. await reservationRefund({ id: row.id });
  625. ElMessage.success("退款申请已提交");
  626. proTable.value?.getTableList();
  627. } catch (e: any) {
  628. ElMessage.error(e?.message || "退款失败");
  629. }
  630. })
  631. .catch(() => {});
  632. }
  633. </script>
  634. <style scoped lang="scss">
  635. .appointment-info {
  636. padding: 0;
  637. }
  638. .filter-bar {
  639. padding: 0 4px;
  640. margin-bottom: 12px;
  641. }
  642. .filter-row {
  643. display: flex;
  644. flex-wrap: wrap;
  645. gap: 12px;
  646. align-items: center;
  647. }
  648. .filter-label {
  649. font-size: 14px;
  650. color: #606266;
  651. white-space: nowrap;
  652. }
  653. .table-header {
  654. display: flex;
  655. align-items: center;
  656. justify-content: space-between;
  657. width: 100%;
  658. padding: 12px 0;
  659. }
  660. .tip-text {
  661. font-size: 12px;
  662. color: #909399;
  663. }
  664. .remark-cell {
  665. display: inline-block;
  666. max-width: 120px;
  667. overflow: hidden;
  668. text-overflow: ellipsis;
  669. white-space: nowrap;
  670. cursor: default;
  671. }
  672. .op-actions {
  673. display: inline-flex;
  674. flex-wrap: wrap;
  675. gap: 4px 8px;
  676. align-items: center;
  677. justify-content: center;
  678. }
  679. .reason-dialog-body {
  680. padding: 8px 4px;
  681. font-size: 14px;
  682. line-height: 1.6;
  683. color: #303133;
  684. word-break: break-word;
  685. white-space: pre-wrap;
  686. }
  687. </style>
  688. <!-- append-to-body 时弹窗挂到 body,需非 scoped 命中 -->
  689. <style lang="scss">
  690. .add-time-dialog.el-dialog .el-dialog__body {
  691. padding-top: 8px;
  692. }
  693. .add-time-form {
  694. width: 100%;
  695. }
  696. .add-time-form .el-form-item {
  697. margin-bottom: 8px;
  698. }
  699. .add-time-form .el-form-item__label {
  700. font-weight: 500;
  701. color: #303133;
  702. }
  703. .add-time-form .el-input-group {
  704. width: 100%;
  705. }
  706. .add-time-form .el-input__wrapper {
  707. flex: 1;
  708. min-width: 0;
  709. }
  710. .add-time-append-unit {
  711. padding: 0 4px;
  712. font-size: 14px;
  713. color: #606266;
  714. }
  715. .cancel-reason-dialog.el-dialog .el-dialog__body {
  716. padding-top: 8px;
  717. }
  718. .cancel-reason-form {
  719. width: 100%;
  720. }
  721. .cancel-reason-form .el-form-item {
  722. margin-bottom: 0;
  723. }
  724. </style>