classifyManagement.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. <template>
  2. <div class="table-box classify-management">
  3. <ProTable
  4. ref="proTable"
  5. :columns="columns"
  6. :request-api="getTableList"
  7. :init-param="initParam"
  8. :pagination="false"
  9. @drag-sort="onDragSortEnd"
  10. >
  11. <template #tableHeader>
  12. <div class="table-header">
  13. <div class="table-header-left">
  14. <el-button type="primary" @click="handleNew"> 新建 </el-button>
  15. <span v-if="sortSaving" class="sort-saving-tip">正在保存排序…</span>
  16. <span v-else class="sort-tip">拖动「排序」列图标可调整顺序,松手后自动保存</span>
  17. </div>
  18. </div>
  19. </template>
  20. <template #isDisplay="{ row }">
  21. <span :class="row.isDisplay === 1 ? 'status-show' : 'status-hide'">
  22. {{ row.isDisplay === 1 ? "显示" : "隐藏" }}
  23. </span>
  24. </template>
  25. <template #operation="scope">
  26. <el-button link type="primary" @click="handleEdit(scope.row)"> 编辑 </el-button>
  27. <el-button link type="primary" @click="handleDelete(scope.row)"> 删除 </el-button>
  28. </template>
  29. </ProTable>
  30. <!-- 新建/编辑弹窗 -->
  31. <el-dialog
  32. v-model="dialogVisible"
  33. :title="editId == null ? '新建分类' : '编辑分类'"
  34. width="480px"
  35. class="classify-edit-dialog"
  36. append-to-body
  37. destroy-on-close
  38. @close="onDialogClose"
  39. >
  40. <el-form
  41. ref="formRef"
  42. :model="form"
  43. :rules="rules"
  44. label-width="160px"
  45. require-asterisk-position="right"
  46. v-loading="detailLoading"
  47. >
  48. <el-form-item label="分类名称" prop="name" required>
  49. <el-input v-model="form.name" placeholder="请输入" clearable maxlength="20" show-word-limit />
  50. </el-form-item>
  51. <el-form-item label="平面图" prop="planeImageUrls" required>
  52. <div class="plane-upload-wrap">
  53. <el-upload
  54. v-model:file-list="form.planeImageFileList"
  55. list-type="picture-card"
  56. :limit="9"
  57. :http-request="handlePlaneImageUpload"
  58. :on-remove="onPlaneImageRemove"
  59. :on-preview="onPlaneImagePreview"
  60. accept="image/*"
  61. multiple
  62. >
  63. <el-icon><Plus /></el-icon>
  64. </el-upload>
  65. <div class="upload-tip">({{ form.planeImageUrls.length }}/9)</div>
  66. </div>
  67. </el-form-item>
  68. <el-form-item label="是否显示">
  69. <el-radio-group v-model="form.isDisplay">
  70. <el-radio :value="1"> 显示 </el-radio>
  71. <el-radio :value="0"> 隐藏 </el-radio>
  72. </el-radio-group>
  73. </el-form-item>
  74. <el-form-item label="最长预订时间(分钟)" prop="maxBookingTime" required>
  75. <el-input
  76. :model-value="String(form.maxBookingTime || '')"
  77. placeholder="请输入"
  78. style="width: 100%"
  79. @update:model-value="
  80. (val: string) => {
  81. const n = parseInt(val, 10);
  82. form.maxBookingTime = isNaN(n) ? 0 : n;
  83. }
  84. "
  85. />
  86. </el-form-item>
  87. </el-form>
  88. <template #footer>
  89. <el-button @click="dialogVisible = false"> 取消 </el-button>
  90. <el-button type="primary" :loading="submitLoading" @click="submitForm"> 确定 </el-button>
  91. </template>
  92. </el-dialog>
  93. <PcImagePreviewViewer
  94. v-model:visible="planeImageViewerVisible"
  95. :url-list="planeImageViewerUrlList"
  96. :initial-index="planeImageViewerInitialIndex"
  97. />
  98. </div>
  99. </template>
  100. <script setup lang="ts">
  101. import { ref, reactive } from "vue";
  102. import { ElMessage, ElMessageBox } from "element-plus";
  103. import { useDebounceFn } from "@vueuse/core";
  104. import type { FormInstance, FormRules } from "element-plus";
  105. import type { UploadFile, UploadRequestOptions, UploadUserFile } from "element-plus";
  106. import { Plus } from "@element-plus/icons-vue";
  107. import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
  108. import ProTable from "@/components/ProTable/index.vue";
  109. import type { ProTableInstance, ColumnProps } from "@/components/ProTable/interface";
  110. import {
  111. scheduleList,
  112. scheduleDel,
  113. scheduleAdd,
  114. scheduleEdit,
  115. scheduleDetail,
  116. getHasReservation,
  117. scheduleSort
  118. } from "@/api/modules/scheduledService";
  119. import { uploadFileToOss } from "@/api/upload.js";
  120. import { localGet } from "@/utils";
  121. export interface CategoryRow {
  122. id: number | string;
  123. _seq?: number;
  124. name: string;
  125. maxBookingTime?: number;
  126. isDisplay: number;
  127. planeImageUrls?: string[];
  128. }
  129. const proTable = ref<ProTableInstance>();
  130. const sortSaving = ref(false);
  131. const columns: ColumnProps<CategoryRow>[] = [
  132. { type: "sort", label: "排序", width: 64, align: "center" },
  133. { prop: "_seq", label: "序号", width: 60, align: "center" },
  134. { prop: "name", label: "名称" },
  135. { prop: "maxBookingTime", label: "最长预订时间(分钟)", align: "center" },
  136. { prop: "isDisplay", label: "状态", align: "center" },
  137. { prop: "operation", label: "操作", fixed: "right", width: 140, align: "center" }
  138. ];
  139. /** 关闭分页并一次拉全量,拖拽排序后提交的 categoryIds 与门店顺序一致 */
  140. const initParam = reactive({
  141. storeId: localGet("geeker-user")?.userInfo?.storeId ?? localGet("createdId") ?? "",
  142. pageNum: 1,
  143. pageSize: 500
  144. });
  145. function mapCategoryItem(item: any): CategoryRow {
  146. const isDisplay = item.isDisplay !== undefined ? item.isDisplay : item.show ? 1 : 0;
  147. return {
  148. id: item.id,
  149. name: item.categoryName ?? item.name ?? "",
  150. maxBookingTime: item.maxBookingTime ?? item.bookingTime ?? 0,
  151. isDisplay: Number(isDisplay),
  152. planeImageUrls: item.planeImageUrls ?? item.floorPlanUrls ?? []
  153. };
  154. }
  155. /** ProTable / useTable 约定:返回 { data: { list, total } } */
  156. async function getTableList(params: any) {
  157. const storeId = params.storeId ?? initParam.storeId;
  158. if (!storeId) {
  159. return { data: { list: [] as CategoryRow[], total: 0 } };
  160. }
  161. const pageNum = params.pageNum ?? 1;
  162. const pageSize = params.pageSize ?? 10;
  163. try {
  164. const res: any = await scheduleList({
  165. storeId: Number(storeId),
  166. pageNum,
  167. pageSize
  168. });
  169. const body = res?.data ?? res;
  170. const inner = body?.data ?? body;
  171. const raw = inner?.records ?? inner?.list ?? (Array.isArray(inner) ? inner : []);
  172. const arr = Array.isArray(raw) ? raw : [];
  173. const total = inner?.total ?? body?.total ?? inner?.totalCount ?? 0;
  174. const list = arr.map((item: any, i: number) => ({
  175. ...mapCategoryItem(item),
  176. _seq: (pageNum - 1) * pageSize + i + 1
  177. }));
  178. return { data: { list, total: Number(total) || 0 } };
  179. } catch (e: any) {
  180. ElMessage.error(e?.message || "加载失败");
  181. return { data: { list: [] as CategoryRow[], total: 0 } };
  182. }
  183. }
  184. const dialogVisible = ref(false);
  185. const editId = ref<number | string | null>(null);
  186. const submitLoading = ref(false);
  187. const detailLoading = ref(false);
  188. const formRef = ref<FormInstance>();
  189. const form = reactive<{
  190. name: string;
  191. maxBookingTime: number;
  192. isDisplay: number;
  193. planeImageUrls: string[];
  194. planeImageFileList: UploadUserFile[];
  195. }>({
  196. name: "",
  197. maxBookingTime: 0,
  198. isDisplay: 1,
  199. planeImageUrls: [],
  200. planeImageFileList: []
  201. });
  202. /** picture-card 放大镜:EP2 默认 onPreview 为空,需自行打开预览(挂 body,高于弹窗/表格) */
  203. const planeImageViewerVisible = ref(false);
  204. const planeImageViewerUrlList = ref<string[]>([]);
  205. const planeImageViewerInitialIndex = ref(0);
  206. const rules: FormRules = {
  207. name: [{ required: true, message: "请输入分类名称", trigger: "blur" }],
  208. maxBookingTime: [{ required: true, message: "请输入最长预订时间", trigger: "blur" }],
  209. planeImageUrls: [
  210. {
  211. required: true,
  212. validator: (_rule: any, _value: any, callback: (e?: Error) => void) => {
  213. if (!form.planeImageUrls || form.planeImageUrls.length < 1) {
  214. callback(new Error("请至少上传一张平面图(包含桌号)"));
  215. } else {
  216. callback();
  217. }
  218. },
  219. trigger: "change"
  220. }
  221. ]
  222. };
  223. async function handlePlaneImageUpload(options: UploadRequestOptions) {
  224. const uploadFileItem = options.file as UploadUserFile;
  225. const raw = uploadFileItem.raw || uploadFileItem;
  226. const file = raw instanceof File ? raw : null;
  227. if (!file) return;
  228. uploadFileItem.status = "uploading";
  229. try {
  230. const fileUrl = await uploadFileToOss(file, "image");
  231. if (fileUrl) {
  232. uploadFileItem.status = "success";
  233. uploadFileItem.url = fileUrl;
  234. uploadFileItem.response = { url: fileUrl };
  235. if (!form.planeImageUrls.includes(fileUrl)) form.planeImageUrls.push(fileUrl);
  236. } else {
  237. uploadFileItem.status = "fail";
  238. ElMessage.error("上传失败,未返回地址");
  239. }
  240. } catch {
  241. uploadFileItem.status = "fail";
  242. // OSS 签名/网络错误已在 upload.js 中提示
  243. }
  244. formRef.value?.validateField("planeImageUrls").catch(() => {});
  245. }
  246. function onPlaneImageRemove(_file: UploadUserFile, fileList: UploadUserFile[]) {
  247. const urls = fileList.map(f => (f as any).url).filter(Boolean);
  248. form.planeImageUrls = urls;
  249. formRef.value?.validateField("planeImageUrls").catch(() => {});
  250. }
  251. function onPlaneImagePreview(file: UploadFile) {
  252. const urls = form.planeImageFileList.map(f => String((f as UploadUserFile).url || "").trim()).filter(Boolean);
  253. if (!urls.length) {
  254. ElMessage.warning("暂无可预览的图片");
  255. return;
  256. }
  257. const u = String(file.url || "").trim();
  258. const idx = u ? urls.indexOf(u) : 0;
  259. planeImageViewerUrlList.value = urls;
  260. planeImageViewerInitialIndex.value = idx >= 0 ? idx : 0;
  261. planeImageViewerVisible.value = true;
  262. }
  263. function getStoreId(): number | string | null {
  264. return localGet("geeker-user")?.userInfo?.storeId ?? localGet("createdId") ?? null;
  265. }
  266. function getTableRowsFromProTable(): CategoryRow[] {
  267. const p = proTable.value as any;
  268. const td = p?.tableData;
  269. const list = td && typeof td === "object" && "value" in td ? td.value : td;
  270. return Array.isArray(list) ? list : [];
  271. }
  272. /** 拖拽结束后按当前表格顺序保存(防抖,避免连续误触) */
  273. const persistSortOrder = useDebounceFn(async () => {
  274. const storeId = getStoreId();
  275. if (!storeId) return;
  276. const rows = getTableRowsFromProTable();
  277. const categoryIds = rows.map(r => r.id).filter(id => id != null && id !== "");
  278. if (!categoryIds.length) return;
  279. sortSaving.value = true;
  280. try {
  281. await scheduleSort({ categoryIds, storeId });
  282. rows.forEach((row, i) => {
  283. row._seq = i + 1;
  284. });
  285. ElMessage.success("排序已保存");
  286. } catch (e: any) {
  287. ElMessage.error(e?.message || "排序保存失败");
  288. proTable.value?.getTableList();
  289. } finally {
  290. sortSaving.value = false;
  291. }
  292. }, 400);
  293. function onDragSortEnd() {
  294. persistSortOrder();
  295. }
  296. /** 接口返回 true 表示该分类下桌号存在预定,不可编辑/删除 */
  297. function parseHasReservationResponse(res: any): boolean {
  298. const body = res?.data ?? res;
  299. const val = body?.data !== undefined ? body.data : body;
  300. if (typeof val === "boolean") return val;
  301. if (val != null && typeof val === "object" && "hasReservation" in val) {
  302. return Boolean((val as { hasReservation?: boolean }).hasReservation);
  303. }
  304. return Boolean(val);
  305. }
  306. async function categoryHasReservation(categoryId: number | string): Promise<boolean> {
  307. const res: any = await getHasReservation({ categoryId: categoryId, storeId: getStoreId() });
  308. return parseHasReservationResponse(res);
  309. }
  310. function handleNew() {
  311. editId.value = null;
  312. form.name = "";
  313. form.maxBookingTime = 0;
  314. form.isDisplay = 1;
  315. form.planeImageUrls = [];
  316. form.planeImageFileList = [];
  317. dialogVisible.value = true;
  318. }
  319. async function handleEdit(row: CategoryRow) {
  320. try {
  321. const has = await categoryHasReservation(row.id);
  322. if (has) {
  323. ElMessage.warning("此分类所属桌号有预订信息,不可修改");
  324. return;
  325. }
  326. } catch (e: any) {
  327. ElMessage.error(e?.message || "校验失败,请稍后重试");
  328. return;
  329. }
  330. editId.value = row.id;
  331. dialogVisible.value = true;
  332. detailLoading.value = true;
  333. try {
  334. const res: any = await scheduleDetail({ id: row.id });
  335. const data = res?.data ?? res ?? {};
  336. form.name = data.categoryName ?? data.name ?? row.name ?? "";
  337. form.maxBookingTime = data.maxBookingTime ?? data.bookingTime ?? row.maxBookingTime ?? 0;
  338. form.isDisplay = data.isDisplay !== undefined ? Number(data.isDisplay) : data.show ? 1 : (row.isDisplay ?? 1);
  339. const raw = data.floorPlanImages ?? data.planeImageUrls ?? data.floorPlanUrls ?? data.planeImages ?? [];
  340. const urls = Array.isArray(raw)
  341. ? raw
  342. : typeof raw === "string"
  343. ? raw
  344. .split(",")
  345. .map((s: string) => s.trim())
  346. .filter(Boolean)
  347. : [];
  348. form.planeImageUrls = [...urls];
  349. form.planeImageFileList = form.planeImageUrls.map((url: string, i: number) => ({
  350. name: `plane-${i}`,
  351. url
  352. })) as UploadUserFile[];
  353. } catch (e: any) {
  354. ElMessage.error(e?.message || "获取详情失败");
  355. form.name = row.name;
  356. form.maxBookingTime = row.maxBookingTime ?? 0;
  357. form.isDisplay = row.isDisplay;
  358. const urls = (row as any).planeImageUrls ?? (row as any).floorPlanUrls ?? [];
  359. form.planeImageUrls = Array.isArray(urls) ? [...urls] : [];
  360. form.planeImageFileList = form.planeImageUrls.map((url, i) => ({
  361. name: `plane-${i}`,
  362. url
  363. })) as UploadUserFile[];
  364. } finally {
  365. detailLoading.value = false;
  366. }
  367. }
  368. async function handleDelete(row: CategoryRow) {
  369. try {
  370. const has = await categoryHasReservation(row.id);
  371. if (has) {
  372. ElMessage.warning("此分类所属桌号有预订信息,不可修改");
  373. return;
  374. }
  375. } catch (e: any) {
  376. ElMessage.error(e?.message || "校验失败,请稍后重试");
  377. return;
  378. }
  379. ElMessageBox.confirm("是否确认删除?", "提示", {
  380. confirmButtonText: "确定",
  381. cancelButtonText: "取消",
  382. type: "warning"
  383. })
  384. .then(async () => {
  385. try {
  386. await scheduleDel({ id: row.id });
  387. ElMessage.success("删除成功");
  388. proTable.value?.getTableList();
  389. } catch (e: any) {
  390. ElMessage.error(e?.message || "删除失败");
  391. }
  392. })
  393. .catch(() => {});
  394. }
  395. function onDialogClose() {
  396. planeImageViewerVisible.value = false;
  397. editId.value = null;
  398. form.name = "";
  399. form.maxBookingTime = 0;
  400. form.isDisplay = 1;
  401. form.planeImageUrls = [];
  402. form.planeImageFileList = [];
  403. }
  404. async function submitForm() {
  405. if (!formRef.value) return;
  406. try {
  407. await formRef.value.validate();
  408. } catch {
  409. return;
  410. }
  411. const storeId = getStoreId();
  412. if (!storeId) {
  413. ElMessage.warning("未找到门店信息");
  414. return;
  415. }
  416. submitLoading.value = true;
  417. try {
  418. const payload = {
  419. storeId,
  420. categoryName: form.name.trim(),
  421. isDisplay: form.isDisplay,
  422. maxBookingTime: form.maxBookingTime,
  423. floorPlanImages: form.planeImageUrls.join(",")
  424. };
  425. if (editId.value != null) {
  426. await scheduleEdit({ ...payload, id: editId.value });
  427. } else {
  428. await scheduleAdd(payload);
  429. }
  430. ElMessage.success(editId.value == null ? "添加成功" : "修改成功");
  431. dialogVisible.value = false;
  432. onDialogClose();
  433. proTable.value?.getTableList();
  434. } catch (e: any) {
  435. ElMessage.error(e?.message || "保存失败");
  436. } finally {
  437. submitLoading.value = false;
  438. }
  439. }
  440. </script>
  441. <!-- append-to-body 时弹窗在 body 下,不是 .classify-management 子节点,需用 dialog 自身 class 命中 -->
  442. <style lang="scss">
  443. .classify-edit-dialog .el-dialog__body {
  444. box-sizing: border-box;
  445. height: 400px;
  446. }
  447. </style>
  448. <style scoped lang="scss">
  449. .classify-management {
  450. padding: 0;
  451. }
  452. .table-header {
  453. display: flex;
  454. align-items: center;
  455. justify-content: space-between;
  456. width: 100%;
  457. padding: 12px 0;
  458. }
  459. .table-header-left {
  460. display: flex;
  461. flex-wrap: wrap;
  462. gap: 12px;
  463. align-items: center;
  464. }
  465. .sort-tip,
  466. .sort-saving-tip {
  467. font-size: 12px;
  468. color: #909399;
  469. }
  470. .sort-saving-tip {
  471. color: #6c8ff8;
  472. }
  473. .status-show {
  474. color: #67c23a;
  475. }
  476. .status-hide {
  477. color: #909399;
  478. }
  479. .plane-upload-wrap {
  480. display: flex;
  481. flex-direction: column;
  482. align-items: flex-start;
  483. }
  484. .upload-tip {
  485. display: block;
  486. margin-top: 8px;
  487. font-size: 12px;
  488. color: #909399;
  489. }
  490. :deep(.el-upload--picture-card) {
  491. width: 100px;
  492. height: 100px;
  493. }
  494. :deep(.el-upload-list--picture-card .el-upload-list__item) {
  495. width: 100px;
  496. height: 100px;
  497. }
  498. </style>