| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526 |
- <template>
- <div class="table-box classify-management">
- <ProTable
- ref="proTable"
- :columns="columns"
- :request-api="getTableList"
- :init-param="initParam"
- :pagination="false"
- @drag-sort="onDragSortEnd"
- >
- <template #tableHeader>
- <div class="table-header">
- <div class="table-header-left">
- <el-button type="primary" @click="handleNew"> 新建 </el-button>
- <span v-if="sortSaving" class="sort-saving-tip">正在保存排序…</span>
- <span v-else class="sort-tip">拖动「排序」列图标可调整顺序,松手后自动保存</span>
- </div>
- </div>
- </template>
- <template #isDisplay="{ row }">
- <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"
- class="classify-edit-dialog"
- append-to-body
- destroy-on-close
- @close="onDialogClose"
- >
- <el-form
- ref="formRef"
- :model="form"
- :rules="rules"
- label-width="160px"
- require-asterisk-position="right"
- v-loading="detailLoading"
- >
- <el-form-item label="分类名称" prop="name" required>
- <el-input v-model="form.name" placeholder="请输入" clearable maxlength="20" show-word-limit />
- </el-form-item>
- <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"
- :on-preview="onPlaneImagePreview"
- 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>
- <PcImagePreviewViewer
- v-model:visible="planeImageViewerVisible"
- :url-list="planeImageViewerUrlList"
- :initial-index="planeImageViewerInitialIndex"
- />
- </div>
- </template>
- <script setup lang="ts">
- import { ref, reactive } from "vue";
- import { ElMessage, ElMessageBox } from "element-plus";
- import { useDebounceFn } from "@vueuse/core";
- import type { FormInstance, FormRules } from "element-plus";
- import type { UploadFile, UploadRequestOptions, UploadUserFile } from "element-plus";
- import { Plus } from "@element-plus/icons-vue";
- import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
- import ProTable from "@/components/ProTable/index.vue";
- import type { ProTableInstance, ColumnProps } from "@/components/ProTable/interface";
- import {
- scheduleList,
- scheduleDel,
- scheduleAdd,
- scheduleEdit,
- scheduleDetail,
- getHasReservation,
- scheduleSort
- } from "@/api/modules/scheduledService";
- import { uploadFileToOss } from "@/api/upload.js";
- 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 sortSaving = ref(false);
- const columns: ColumnProps<CategoryRow>[] = [
- { type: "sort", label: "排序", width: 64, align: "center" },
- { prop: "_seq", label: "序号", width: 60, align: "center" },
- { prop: "name", label: "名称" },
- { prop: "maxBookingTime", label: "最长预订时间(分钟)", align: "center" },
- { prop: "isDisplay", label: "状态", align: "center" },
- { prop: "operation", label: "操作", fixed: "right", width: 140, align: "center" }
- ];
- /** 关闭分页并一次拉全量,拖拽排序后提交的 categoryIds 与门店顺序一致 */
- const initParam = reactive({
- storeId: localGet("geeker-user")?.userInfo?.storeId ?? localGet("createdId") ?? "",
- pageNum: 1,
- pageSize: 500
- });
- 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: []
- });
- /** picture-card 放大镜:EP2 默认 onPreview 为空,需自行打开预览(挂 body,高于弹窗/表格) */
- const planeImageViewerVisible = ref(false);
- const planeImageViewerUrlList = ref<string[]>([]);
- const planeImageViewerInitialIndex = ref(0);
- 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 uploadFileItem = options.file as UploadUserFile;
- const raw = uploadFileItem.raw || uploadFileItem;
- const file = raw instanceof File ? raw : null;
- if (!file) return;
- uploadFileItem.status = "uploading";
- try {
- const fileUrl = await uploadFileToOss(file, "image");
- if (fileUrl) {
- uploadFileItem.status = "success";
- uploadFileItem.url = fileUrl;
- uploadFileItem.response = { url: fileUrl };
- if (!form.planeImageUrls.includes(fileUrl)) form.planeImageUrls.push(fileUrl);
- } else {
- uploadFileItem.status = "fail";
- ElMessage.error("上传失败,未返回地址");
- }
- } catch {
- uploadFileItem.status = "fail";
- // OSS 签名/网络错误已在 upload.js 中提示
- }
- 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 onPlaneImagePreview(file: UploadFile) {
- const urls = form.planeImageFileList.map(f => String((f as UploadUserFile).url || "").trim()).filter(Boolean);
- if (!urls.length) {
- ElMessage.warning("暂无可预览的图片");
- return;
- }
- const u = String(file.url || "").trim();
- const idx = u ? urls.indexOf(u) : 0;
- planeImageViewerUrlList.value = urls;
- planeImageViewerInitialIndex.value = idx >= 0 ? idx : 0;
- planeImageViewerVisible.value = true;
- }
- function getStoreId(): number | string | null {
- return localGet("geeker-user")?.userInfo?.storeId ?? localGet("createdId") ?? null;
- }
- function getTableRowsFromProTable(): CategoryRow[] {
- const p = proTable.value as any;
- const td = p?.tableData;
- const list = td && typeof td === "object" && "value" in td ? td.value : td;
- return Array.isArray(list) ? list : [];
- }
- /** 拖拽结束后按当前表格顺序保存(防抖,避免连续误触) */
- const persistSortOrder = useDebounceFn(async () => {
- const storeId = getStoreId();
- if (!storeId) return;
- const rows = getTableRowsFromProTable();
- const categoryIds = rows.map(r => r.id).filter(id => id != null && id !== "");
- if (!categoryIds.length) return;
- sortSaving.value = true;
- try {
- await scheduleSort({ categoryIds, storeId });
- rows.forEach((row, i) => {
- row._seq = i + 1;
- });
- ElMessage.success("排序已保存");
- } catch (e: any) {
- ElMessage.error(e?.message || "排序保存失败");
- proTable.value?.getTableList();
- } finally {
- sortSaving.value = false;
- }
- }, 400);
- function onDragSortEnd() {
- persistSortOrder();
- }
- /** 接口返回 true 表示该分类下桌号存在预定,不可编辑/删除 */
- function parseHasReservationResponse(res: any): boolean {
- const body = res?.data ?? res;
- const val = body?.data !== undefined ? body.data : body;
- if (typeof val === "boolean") return val;
- if (val != null && typeof val === "object" && "hasReservation" in val) {
- return Boolean((val as { hasReservation?: boolean }).hasReservation);
- }
- return Boolean(val);
- }
- async function categoryHasReservation(categoryId: number | string): Promise<boolean> {
- const res: any = await getHasReservation({ categoryId: categoryId, storeId: getStoreId() });
- return parseHasReservationResponse(res);
- }
- 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) {
- try {
- const has = await categoryHasReservation(row.id);
- if (has) {
- ElMessage.warning("此分类所属桌号有预订信息,不可修改");
- return;
- }
- } catch (e: any) {
- ElMessage.error(e?.message || "校验失败,请稍后重试");
- return;
- }
- 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;
- }
- }
- async function handleDelete(row: CategoryRow) {
- try {
- const has = await categoryHasReservation(row.id);
- if (has) {
- ElMessage.warning("此分类所属桌号有预订信息,不可修改");
- return;
- }
- } catch (e: any) {
- ElMessage.error(e?.message || "校验失败,请稍后重试");
- return;
- }
- 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() {
- planeImageViewerVisible.value = false;
- 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>
- <!-- append-to-body 时弹窗在 body 下,不是 .classify-management 子节点,需用 dialog 自身 class 命中 -->
- <style lang="scss">
- .classify-edit-dialog .el-dialog__body {
- box-sizing: border-box;
- height: 400px;
- }
- </style>
- <style scoped lang="scss">
- .classify-management {
- padding: 0;
- }
- .table-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- width: 100%;
- padding: 12px 0;
- }
- .table-header-left {
- display: flex;
- flex-wrap: wrap;
- gap: 12px;
- align-items: center;
- }
- .sort-tip,
- .sort-saving-tip {
- font-size: 12px;
- color: #909399;
- }
- .sort-saving-tip {
- color: #6c8ff8;
- }
- .status-show {
- color: #67c23a;
- }
- .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>
|