tableManagement.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628
  1. <template>
  2. <div class="table-box table-management">
  3. <!-- 筛选(与原先一致:搜索/重置后再请求列表) -->
  4. <div class="filter-bar">
  5. <div class="filter-row">
  6. <span class="filter-label">位置</span>
  7. <el-select v-model="filterLocation" placeholder="请选择" clearable style="width: 200px">
  8. <el-option v-for="item in categoryOptions" :key="item.id" :label="item.name" :value="item.id" />
  9. </el-select>
  10. <el-button type="primary" @click="handleSearch"> 搜索 </el-button>
  11. <el-button @click="handleReset"> 重置 </el-button>
  12. </div>
  13. </div>
  14. <ProTable ref="proTable" :columns="columns" :request-api="getTableList" :init-param="initParam">
  15. <template #tableHeader>
  16. <div class="table-header">
  17. <el-button type="primary" @click="handleNew"> 新建 </el-button>
  18. </div>
  19. </template>
  20. <template #operation="scope">
  21. <el-button link type="primary" @click="handleEdit(scope.row)"> 编辑 </el-button>
  22. <el-button link type="primary" @click="handleDelete(scope.row)"> 删除 </el-button>
  23. </template>
  24. </ProTable>
  25. <!-- 新建/编辑弹窗 -->
  26. <el-dialog
  27. v-model="dialogVisible"
  28. class="table-management-dialog"
  29. :title="editId == null ? '新建桌位' : '编辑桌位'"
  30. width="480px"
  31. append-to-body
  32. destroy-on-close
  33. @close="onDialogClose"
  34. >
  35. <el-form
  36. ref="formRef"
  37. class="table-dialog-form"
  38. :model="form"
  39. :rules="formRules"
  40. label-width="80px"
  41. v-loading="detailLoading"
  42. require-asterisk-position="right"
  43. >
  44. <el-form-item label="选择分类" prop="categoryId" required>
  45. <el-select v-model="form.categoryId" placeholder="请选择分类" clearable>
  46. <el-option v-for="item in categoryOptions" :key="item.id" :label="item.name" :value="item.id" />
  47. </el-select>
  48. </el-form-item>
  49. <!-- 新建:可添加多组桌号/座位数(表单项与编辑态一致:作为 el-form 直接子节点,避免包一层 div 导致输入区宽度异常) -->
  50. <template v-if="editId == null">
  51. <div class="add-table-toolbar">
  52. <el-button type="primary" link @click="addTableRow"> 添加 </el-button>
  53. </div>
  54. <template v-for="(row, index) in form.tableRows" :key="index">
  55. <el-form-item label="桌号" :prop="'tableRows.' + index + '.tableNo'" :rules="tableNoFieldRules" required>
  56. <el-input
  57. :model-value="row.tableNo"
  58. placeholder="如 A01"
  59. maxlength="3"
  60. @update:model-value="(val: string) => onTableNumberInput(row, val)"
  61. />
  62. </el-form-item>
  63. <el-form-item label="座位数" :prop="'tableRows.' + index + '.seatCount'" :rules="seatCountFieldRulesRow" required>
  64. <el-input
  65. :model-value="row.seatCount"
  66. placeholder="请输入"
  67. maxlength="2"
  68. inputmode="numeric"
  69. @update:model-value="(val: string) => onSeatCountInput(row, val)"
  70. />
  71. </el-form-item>
  72. <div v-if="form.tableRows.length > 1" class="table-row-delete">
  73. <el-button type="danger" link @click="removeTableRow(index)"> 删除 </el-button>
  74. </div>
  75. <div v-if="index < form.tableRows.length - 1" class="table-row-sep" aria-hidden="true" />
  76. </template>
  77. </template>
  78. <!-- 编辑:单条 -->
  79. <template v-else>
  80. <el-form-item label="桌号" prop="tableNo" required>
  81. <el-input
  82. :model-value="form.tableNo"
  83. placeholder="如 A01"
  84. clearable
  85. maxlength="3"
  86. @update:model-value="onTableNumberInputEdit"
  87. />
  88. </el-form-item>
  89. <el-form-item label="座位数" prop="seatCount" required>
  90. <el-input
  91. :model-value="seatCountEditDisplay"
  92. placeholder="请输入"
  93. maxlength="2"
  94. inputmode="numeric"
  95. @update:model-value="onSeatCountInputEdit"
  96. />
  97. </el-form-item>
  98. </template>
  99. </el-form>
  100. <template #footer>
  101. <el-button @click="dialogVisible = false"> 取消 </el-button>
  102. <el-button type="primary" :loading="submitLoading" @click="submitForm"> 确定 </el-button>
  103. </template>
  104. </el-dialog>
  105. </div>
  106. </template>
  107. <script setup lang="ts">
  108. import { ref, reactive, computed, onMounted } from "vue";
  109. import { ElMessage, ElMessageBox } from "element-plus";
  110. import type { FormInstance, FormRules, FormItemRule } from "element-plus";
  111. import ProTable from "@/components/ProTable/index.vue";
  112. import type { ProTableInstance, ColumnProps } from "@/components/ProTable/interface";
  113. import {
  114. scheduleList,
  115. tableList,
  116. tableDetail,
  117. tableAdd,
  118. tableEdit,
  119. tableDel,
  120. getTableHasReservation
  121. } from "@/api/modules/scheduledService";
  122. import { localGet } from "@/utils";
  123. export interface TableRow {
  124. id: number | string;
  125. _seq?: number;
  126. tableNo: string;
  127. /** 列表接口返回的座位数字段 */
  128. seatingCapacity: number;
  129. categoryId?: number | string;
  130. locationName?: string;
  131. }
  132. const proTable = ref<ProTableInstance>();
  133. const columns: ColumnProps<TableRow>[] = [
  134. { prop: "_seq", label: "序号", width: 60, align: "center" },
  135. { prop: "tableNo", label: "桌号" },
  136. { prop: "seatingCapacity", label: "座位数", align: "center" },
  137. { prop: "locationName", label: "位置" },
  138. { prop: "operation", label: "操作", fixed: "right", width: 140, align: "center" }
  139. ];
  140. const initParam = reactive({
  141. storeId: localGet("geeker-user")?.userInfo?.storeId ?? localGet("createdId") ?? ""
  142. });
  143. /** 列表请求时使用的位置筛选(仅点击「搜索」后生效) */
  144. const listFilterCategoryId = ref<number | string | undefined>(undefined);
  145. const filterLocation = ref<number | string | undefined>(undefined);
  146. const categoryOptions = ref<{ id: number | string; name: string }[]>([]);
  147. function getStoreId(): number | string | null {
  148. return localGet("geeker-user")?.userInfo?.storeId ?? localGet("createdId") ?? null;
  149. }
  150. /** 接口返回 true 表示该桌有预定,不可编辑/删除 */
  151. function parseHasReservationResponse(res: any): boolean {
  152. const body = res?.data ?? res;
  153. const val = body?.data !== undefined ? body.data : body;
  154. if (typeof val === "boolean") return val;
  155. if (val != null && typeof val === "object" && "hasReservation" in val) {
  156. return Boolean((val as { hasReservation?: boolean }).hasReservation);
  157. }
  158. return Boolean(val);
  159. }
  160. async function tableHasReservation(tableId: number | string, storeId: number | string): Promise<boolean> {
  161. const res: any = await getTableHasReservation({ tableId: tableId, storeId: getStoreId() });
  162. return parseHasReservationResponse(res);
  163. }
  164. async function loadCategories() {
  165. const storeId = getStoreId();
  166. if (!storeId) {
  167. categoryOptions.value = [];
  168. return;
  169. }
  170. try {
  171. const res: any = await scheduleList({ storeId: Number(storeId), pageNum: 1, pageSize: 500 });
  172. const raw = res?.data?.records ?? res?.data ?? res?.list ?? res ?? [];
  173. const arr = Array.isArray(raw) ? raw : [];
  174. categoryOptions.value = arr.map((item: any) => ({
  175. id: item.id,
  176. name: item.categoryName ?? item.name ?? ""
  177. }));
  178. } catch {
  179. categoryOptions.value = [];
  180. }
  181. }
  182. function mapTableRow(item: any, categoryMap: Record<string, string>): TableRow {
  183. const cap = item.seatingCapacity ?? item.seatCount ?? item.seats;
  184. return {
  185. id: item.id,
  186. tableNo: item.tableNo ?? item.tableNumber ?? item.deskNo ?? "",
  187. seatingCapacity: cap != null && cap !== "" ? Number(cap) || 0 : 0,
  188. categoryId: item.categoryId ?? item.category_id,
  189. locationName: item.locationName ?? item.categoryName ?? categoryMap[String(item.categoryId ?? item.category_id)] ?? ""
  190. };
  191. }
  192. /** ProTable / useTable 约定:返回 { data: { list, total } } */
  193. async function getTableList(params: any) {
  194. const storeId = params.storeId ?? initParam.storeId;
  195. if (!storeId) {
  196. return { data: { list: [] as TableRow[], total: 0 } };
  197. }
  198. if (categoryOptions.value.length === 0) {
  199. await loadCategories();
  200. }
  201. const pageNum = params.pageNum ?? 1;
  202. const pageSize = params.pageSize ?? 10;
  203. const categoryMap = Object.fromEntries(categoryOptions.value.map(c => [String(c.id), c.name]));
  204. const req: {
  205. storeId: number;
  206. pageNum: number;
  207. pageSize: number;
  208. categoryId?: number | string;
  209. } = {
  210. storeId: Number(storeId),
  211. pageNum,
  212. pageSize
  213. };
  214. if (listFilterCategoryId.value != null && listFilterCategoryId.value !== "") {
  215. req.categoryId = listFilterCategoryId.value;
  216. }
  217. try {
  218. const res: any = await tableList(req);
  219. const body = res?.data ?? res;
  220. const inner = body?.data ?? body;
  221. const raw = inner?.records ?? inner?.list ?? (Array.isArray(inner) ? inner : []);
  222. const arr = Array.isArray(raw) ? raw : [];
  223. const total = inner?.total ?? body?.total ?? inner?.totalCount ?? 0;
  224. const list = arr.map((item: any, i: number) => ({
  225. ...mapTableRow(item, categoryMap),
  226. _seq: (pageNum - 1) * pageSize + i + 1
  227. }));
  228. return { data: { list, total: Number(total) || 0 } };
  229. } catch (e: any) {
  230. ElMessage.error(e?.message || "加载失败");
  231. return { data: { list: [] as TableRow[], total: 0 } };
  232. }
  233. }
  234. function handleSearch() {
  235. listFilterCategoryId.value = filterLocation.value;
  236. proTable.value?.getTableList();
  237. }
  238. function handleReset() {
  239. filterLocation.value = undefined;
  240. listFilterCategoryId.value = undefined;
  241. proTable.value?.getTableList();
  242. }
  243. const dialogVisible = ref(false);
  244. const editId = ref<number | string | null>(null);
  245. const submitLoading = ref(false);
  246. const detailLoading = ref(false);
  247. const formRef = ref<FormInstance>();
  248. const form = reactive<{
  249. tableNo: string;
  250. seatCount: number;
  251. categoryId: number | string | undefined;
  252. /** 与 App addTableNumber 一致:座位为字符串便于输入过滤 */
  253. tableRows: { tableNo: string; seatCount: string }[];
  254. }>({
  255. tableNo: "",
  256. seatCount: 0,
  257. categoryId: undefined,
  258. tableRows: [{ tableNo: "", seatCount: "" }]
  259. });
  260. /** 与 group_merchant/pages/scheduledService/addTableNumber.vue 一致 */
  261. const TABLE_NUMBER_REG = /^[A-Z][0-9]{2}$/;
  262. const tableNoFieldRules: FormItemRule[] = [
  263. {
  264. validator: (_rule, value, callback) => {
  265. const num = String(value ?? "").trim();
  266. if (!num) {
  267. callback(new Error("请输入桌号"));
  268. return;
  269. }
  270. if (!TABLE_NUMBER_REG.test(num)) {
  271. callback(new Error("桌号格式为:大写字母+2位数字,如 A01"));
  272. return;
  273. }
  274. callback();
  275. },
  276. trigger: "blur"
  277. }
  278. ];
  279. const seatCountFieldRulesRow: FormItemRule[] = [
  280. {
  281. validator: (_rule, value, callback) => {
  282. const seat = String(value ?? "").trim();
  283. if (!seat) {
  284. callback(new Error("请输入座位数"));
  285. return;
  286. }
  287. const n = parseInt(seat, 10);
  288. if (isNaN(n) || n < 1 || n > 99) {
  289. callback(new Error("请输入1-99的整数"));
  290. return;
  291. }
  292. callback();
  293. },
  294. trigger: "blur"
  295. }
  296. ];
  297. const seatCountFieldRulesEdit: FormItemRule[] = [
  298. {
  299. validator: (_rule, value, callback) => {
  300. const n = Number(value);
  301. if (value === undefined || value === null || value === "" || Number.isNaN(n) || n === 0) {
  302. callback(new Error("请输入座位数"));
  303. return;
  304. }
  305. if (n < 1 || n > 99) {
  306. callback(new Error("请输入1-99的整数"));
  307. return;
  308. }
  309. callback();
  310. },
  311. trigger: "blur"
  312. }
  313. ];
  314. /** 编辑座位展示:0 视为空(与 App 输入行为一致) */
  315. const seatCountEditDisplay = computed(() => {
  316. const s = form.seatCount;
  317. if (s === 0 || s === undefined || s === null || Number.isNaN(Number(s))) return "";
  318. return String(s);
  319. });
  320. function onTableNumberInput(row: { tableNo: string }, val: string) {
  321. let v = String(val ?? "");
  322. v = v.toUpperCase().replace(/[^A-Z0-9]/g, "");
  323. const letter = /[A-Z]/.test(v[0]) ? v[0] : "";
  324. const digits = v
  325. .slice(letter ? 1 : 0)
  326. .replace(/[^0-9]/g, "")
  327. .slice(0, 2);
  328. row.tableNo = letter + digits;
  329. }
  330. function onSeatCountInput(row: { seatCount: string }, val: string) {
  331. let v = String(val ?? "")
  332. .replace(/\D/g, "")
  333. .slice(0, 2);
  334. const num = parseInt(v, 10);
  335. if (v !== "" && !isNaN(num) && num > 99) v = "99";
  336. row.seatCount = v;
  337. }
  338. function onTableNumberInputEdit(val: string) {
  339. let v = String(val ?? "");
  340. v = v.toUpperCase().replace(/[^A-Z0-9]/g, "");
  341. const letter = /[A-Z]/.test(v[0]) ? v[0] : "";
  342. const digits = v
  343. .slice(letter ? 1 : 0)
  344. .replace(/[^0-9]/g, "")
  345. .slice(0, 2);
  346. form.tableNo = letter + digits;
  347. }
  348. function onSeatCountInputEdit(val: string) {
  349. let v = String(val ?? "")
  350. .replace(/\D/g, "")
  351. .slice(0, 2);
  352. const num = parseInt(v, 10);
  353. if (v !== "" && !isNaN(num) && num > 99) v = "99";
  354. if (v === "") {
  355. form.seatCount = 0;
  356. } else {
  357. const n = parseInt(v, 10);
  358. form.seatCount = isNaN(n) ? 0 : n;
  359. }
  360. }
  361. /** 新建只校验分类 + 各行(行规则在 el-form-item 上);编辑校验分类 + 单条桌号/座位数 */
  362. const formRules = computed<FormRules>(() => {
  363. const base: FormRules = {
  364. categoryId: [{ required: true, message: "请选择位置", trigger: "change" }]
  365. };
  366. if (editId.value != null) {
  367. base.tableNo = tableNoFieldRules;
  368. base.seatCount = seatCountFieldRulesEdit;
  369. }
  370. return base;
  371. });
  372. function addTableRow() {
  373. form.tableRows.push({ tableNo: "", seatCount: "" });
  374. }
  375. function removeTableRow(index: number) {
  376. if (form.tableRows.length <= 1) return;
  377. form.tableRows.splice(index, 1);
  378. }
  379. function handleNew() {
  380. editId.value = null;
  381. form.tableNo = "";
  382. form.seatCount = 0;
  383. form.categoryId = undefined;
  384. form.tableRows = [{ tableNo: "", seatCount: "" }];
  385. dialogVisible.value = true;
  386. }
  387. async function handleEdit(row: TableRow) {
  388. try {
  389. const has = await tableHasReservation(row.id);
  390. if (has) {
  391. ElMessage.warning("此分类所属桌号有预定信息,不可修改");
  392. return;
  393. }
  394. } catch (e: any) {
  395. ElMessage.error(e?.message || "校验失败,请稍后重试");
  396. return;
  397. }
  398. editId.value = row.id;
  399. form.tableRows = [{ tableNo: "", seatCount: "" }];
  400. dialogVisible.value = true;
  401. detailLoading.value = true;
  402. try {
  403. const res: any = await tableDetail({ id: row.id });
  404. const d = res?.data ?? res ?? {};
  405. const categoryId = d.categoryId ?? d.classifyId ?? undefined;
  406. form.categoryId = categoryId != null ? categoryId : (row.categoryId ?? undefined);
  407. form.tableNo = d.tableNumber ?? d.tableNo ?? row.tableNo ?? "";
  408. form.seatCount = d.seatingCapacity ?? d.seatCount ?? row.seatingCapacity ?? 0;
  409. if (typeof form.seatCount === "string") form.seatCount = parseInt(form.seatCount, 10) || 0;
  410. if (
  411. categoryId != null &&
  412. categoryOptions.value.length > 0 &&
  413. !categoryOptions.value.find(c => String(c.id) === String(categoryId))
  414. ) {
  415. await loadCategories();
  416. }
  417. } catch (e: any) {
  418. ElMessage.error(e?.message || "获取详情失败");
  419. form.tableNo = row.tableNo;
  420. form.seatCount = row.seatingCapacity ?? 0;
  421. form.categoryId = row.categoryId ?? undefined;
  422. } finally {
  423. detailLoading.value = false;
  424. }
  425. }
  426. async function handleDelete(row: TableRow) {
  427. try {
  428. const has = await tableHasReservation(row.id);
  429. if (has) {
  430. ElMessage.warning("此分类所属桌号有预定信息,不可修改");
  431. return;
  432. }
  433. } catch (e: any) {
  434. ElMessage.error(e?.message || "校验失败,请稍后重试");
  435. return;
  436. }
  437. ElMessageBox.confirm(`是否确认删除?`, "提示", {
  438. confirmButtonText: "确定",
  439. cancelButtonText: "取消",
  440. type: "warning"
  441. })
  442. .then(async () => {
  443. try {
  444. await tableDel({ id: row.id });
  445. ElMessage.success("删除成功");
  446. proTable.value?.getTableList();
  447. } catch (e: any) {
  448. ElMessage.error(e?.message || "删除失败");
  449. }
  450. })
  451. .catch(() => {});
  452. }
  453. function onDialogClose() {
  454. editId.value = null;
  455. form.tableNo = "";
  456. form.seatCount = 0;
  457. form.categoryId = undefined;
  458. form.tableRows = [{ tableNo: "", seatCount: "" }];
  459. }
  460. async function submitForm() {
  461. if (!formRef.value) return;
  462. try {
  463. await formRef.value.validate();
  464. } catch {
  465. return;
  466. }
  467. const storeId = getStoreId();
  468. if (!storeId) {
  469. ElMessage.warning("未找到门店信息");
  470. return;
  471. }
  472. submitLoading.value = true;
  473. try {
  474. const tableNumber = form.tableNo.trim();
  475. const seatingCapacity = Number(form.seatCount) || 0;
  476. if (editId.value != null) {
  477. await tableEdit({
  478. id: editId.value,
  479. storeId,
  480. categoryId: form.categoryId,
  481. tableNumber,
  482. seatingCapacity
  483. });
  484. } else {
  485. const tables = form.tableRows.map(r => ({
  486. tableNumber: r.tableNo.trim(),
  487. seatingCapacity: parseInt(String(r.seatCount).trim(), 10) || 0
  488. }));
  489. await tableAdd({
  490. storeId,
  491. categoryId: form.categoryId,
  492. tables
  493. });
  494. }
  495. ElMessage.success(editId.value == null ? "添加成功" : "修改成功");
  496. dialogVisible.value = false;
  497. onDialogClose();
  498. proTable.value?.getTableList();
  499. } catch (e: any) {
  500. ElMessage.error(e?.message || "保存失败");
  501. } finally {
  502. submitLoading.value = false;
  503. }
  504. }
  505. onMounted(() => {
  506. loadCategories();
  507. });
  508. </script>
  509. <style lang="scss">
  510. .table-management-dialog .el-dialog__body {
  511. box-sizing: border-box;
  512. height: 300px;
  513. }
  514. </style>
  515. <style scoped lang="scss">
  516. .table-management {
  517. padding: 0;
  518. }
  519. .filter-bar {
  520. padding: 0 4px;
  521. margin-bottom: 12px;
  522. }
  523. .filter-row {
  524. display: flex;
  525. flex-wrap: wrap;
  526. gap: 12px;
  527. align-items: center;
  528. }
  529. .filter-label {
  530. min-width: 32px;
  531. font-size: 14px;
  532. color: #606266;
  533. }
  534. .table-header {
  535. display: flex;
  536. align-items: center;
  537. justify-content: space-between;
  538. width: 100%;
  539. padding: 12px 0;
  540. }
  541. .add-table-toolbar {
  542. position: relative;
  543. bottom: 5px;
  544. margin: 0 0 8px 80px;
  545. }
  546. .table-row-sep {
  547. width: 100%;
  548. margin: 0 0 12px;
  549. border: none;
  550. border-bottom: 1px solid var(--el-border-color-lighter);
  551. }
  552. .table-row-delete {
  553. position: relative;
  554. bottom: 5px;
  555. margin: 0 0 4px 80px;
  556. text-align: right;
  557. }
  558. </style>
  559. <!-- append-to-body 时弹窗挂到 body,需非 scoped 才能稳定命中 -->
  560. <style lang="scss">
  561. .table-management-dialog.el-dialog .el-dialog__body {
  562. box-sizing: border-box;
  563. max-height: min(420px, 65vh);
  564. overflow: hidden auto;
  565. }
  566. /* 弹窗内表单:与 .table-search 一致,控件占满标签右侧(scoped 对 teleport 不生效) */
  567. .table-management-dialog .table-dialog-form {
  568. width: 100%;
  569. .el-form-item {
  570. width: 100%;
  571. }
  572. .el-form-item__content {
  573. flex: 1;
  574. min-width: 0;
  575. }
  576. .el-form-item__content > .el-input,
  577. .el-form-item__content > .el-select {
  578. width: 100%;
  579. max-width: 100%;
  580. }
  581. .el-input {
  582. width: 100%;
  583. }
  584. .el-select {
  585. width: 100%;
  586. }
  587. }
  588. </style>