mirror of
https://gitee.com/beijing_hongye_huicheng/lilishop-ui.git
synced 2026-09-20 20:02:05 +08:00
feat(appointment): 预约商品发布改为可视化配置,并接入表单设计器
商家端补齐预约规则、时段库存与系统表单设计;运营端增加预约工单与订单设置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
24
manager/src/api/appointment.js
Normal file
24
manager/src/api/appointment.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 运营端预约服务 API
|
||||
*/
|
||||
import { getRequest, postRequestWithNoForm, putRequestWithNoForm } from "@/libs/axios";
|
||||
|
||||
export const getManagerWorkOrderPage = (params) =>
|
||||
getRequest("/appointment/work-orders", params);
|
||||
|
||||
export const getManagerRescheduleLogs = (params) =>
|
||||
getRequest("/appointment/reschedule-logs", params);
|
||||
|
||||
export const getAppointmentOrderSettings = () => getRequest("/appointment/settings");
|
||||
|
||||
export const saveAppointmentOrderSettings = (params) =>
|
||||
putRequestWithNoForm("/appointment/settings", params);
|
||||
|
||||
export const platformRefund = (orderSn, params) =>
|
||||
postRequestWithNoForm(`/appointment/orders/${orderSn}/platform-refund`, params);
|
||||
|
||||
export const noShowRefund = (orderSn, params) =>
|
||||
postRequestWithNoForm(`/appointment/orders/${orderSn}/no-show-refund`, params);
|
||||
|
||||
export const getAppointmentOrderDetail = (orderSn) =>
|
||||
getRequest(`/appointment/orders/${orderSn}`);
|
||||
@@ -5,14 +5,20 @@
|
||||
* @date 2026-08-02
|
||||
*/
|
||||
export const E_COUPON_GOODS_TYPE = "E_COUPON";
|
||||
export const APPOINTMENT_GOODS_TYPE = "APPOINTMENT_GOODS";
|
||||
|
||||
export function isECoupon(goodsType) {
|
||||
return goodsType === E_COUPON_GOODS_TYPE;
|
||||
}
|
||||
|
||||
export function isAppointmentGoods(goodsType) {
|
||||
return goodsType === APPOINTMENT_GOODS_TYPE;
|
||||
}
|
||||
|
||||
export function goodsTypeLabel(goodsType) {
|
||||
const map = {
|
||||
E_COUPON: "电子卡券",
|
||||
APPOINTMENT_GOODS: "预约商品",
|
||||
VIRTUAL_GOODS: "虚拟商品",
|
||||
PHYSICAL_GOODS: "实物商品",
|
||||
};
|
||||
@@ -21,6 +27,7 @@ export function goodsTypeLabel(goodsType) {
|
||||
|
||||
export function goodsTypeTagType(goodsType) {
|
||||
if (goodsType === E_COUPON_GOODS_TYPE) return "warning";
|
||||
if (goodsType === APPOINTMENT_GOODS_TYPE) return "success";
|
||||
if (goodsType === "VIRTUAL_GOODS") return "info";
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -128,6 +128,18 @@ export const otherRouter = {
|
||||
name: "order-detail",
|
||||
component: () => import("@/views/order/order/orderDetail.vue")
|
||||
},
|
||||
{
|
||||
path: "appointment/work-orders",
|
||||
title: "预约工单",
|
||||
name: "manager-appointment-work-orders",
|
||||
component: () => import("@/views/appointment/workOrderList.vue")
|
||||
},
|
||||
{
|
||||
path: "appointment/order-settings",
|
||||
title: "预约订单设置",
|
||||
name: "manager-appointment-order-settings",
|
||||
component: () => import("@/views/appointment/orderSettings.vue")
|
||||
},
|
||||
{
|
||||
path: "after-order-detail",
|
||||
title: "售后单详情",
|
||||
|
||||
76
manager/src/views/appointment/orderSettings.vue
Normal file
76
manager/src/views/appointment/orderSettings.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="search">
|
||||
<el-card>
|
||||
<el-form :model="form" label-width="220px" style="max-width: 760px" v-loading="loading">
|
||||
<el-form-item label="服务开始前提醒买家(小时)">
|
||||
<el-input-number v-model="form.serviceStartRemindMemberHours" :min="0" :max="168" />
|
||||
<span class="hint">0 表示关闭</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="逾期未核销自动完结(小时)">
|
||||
<el-input-number v-model="form.inStoreNoShowAutoCompleteHours" :min="0" :max="720" />
|
||||
<span class="hint">0 表示关闭</span>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
<el-button @click="loadData">刷新</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getAppointmentOrderSettings, saveAppointmentOrderSettings } from "@/api/appointment";
|
||||
import { ElMessage } from "element-plus";
|
||||
|
||||
export default {
|
||||
name: "appointmentOrderSettings",
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
saving: false,
|
||||
form: {
|
||||
serviceStartRemindMemberHours: 2,
|
||||
inStoreNoShowAutoCompleteHours: 24,
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.loadData();
|
||||
},
|
||||
methods: {
|
||||
loadData() {
|
||||
this.loading = true;
|
||||
getAppointmentOrderSettings()
|
||||
.then((res) => {
|
||||
if (res.success && res.result) {
|
||||
this.form = { ...this.form, ...res.result };
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
handleSave() {
|
||||
this.saving = true;
|
||||
saveAppointmentOrderSettings(this.form)
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
ElMessage.success("保存成功");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.saving = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hint {
|
||||
margin-left: 12px;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
80
manager/src/views/appointment/workOrderList.vue
Normal file
80
manager/src/views/appointment/workOrderList.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="search">
|
||||
<el-card>
|
||||
<el-form :model="searchForm" inline @keyup.enter="handleSearch">
|
||||
<el-form-item label="订单编号">
|
||||
<el-input v-model="searchForm.orderSn" clearable style="width: 200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="工单编号">
|
||||
<el-input v-model="searchForm.workOrderSn" clearable style="width: 200px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card class="mt_10">
|
||||
<el-table v-loading="loading" :data="data" border>
|
||||
<el-table-column prop="workOrderSn" label="工单编号" min-width="180" />
|
||||
<el-table-column prop="orderSn" label="订单编号" min-width="180" />
|
||||
<el-table-column prop="storeName" label="店铺" min-width="140" />
|
||||
<el-table-column label="服务方式" width="100">
|
||||
<template #default="{ row }">{{ row.serviceMode === 'ON_SITE' ? '上门' : '到店' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="appointmentTime" label="预约时间" min-width="170" />
|
||||
<el-table-column prop="serviceStatus" label="状态" width="120" />
|
||||
</el-table>
|
||||
<el-pagination
|
||||
class="mt_10"
|
||||
v-model:current-page="searchForm.pageNumber"
|
||||
v-model:page-size="searchForm.pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="getData"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getManagerWorkOrderPage } from "@/api/appointment";
|
||||
|
||||
export default {
|
||||
name: "managerAppointmentWorkOrders",
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
data: [],
|
||||
total: 0,
|
||||
searchForm: {
|
||||
pageNumber: 1,
|
||||
pageSize: 10,
|
||||
orderSn: "",
|
||||
workOrderSn: "",
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.getData();
|
||||
},
|
||||
methods: {
|
||||
handleSearch() {
|
||||
this.searchForm.pageNumber = 1;
|
||||
this.getData();
|
||||
},
|
||||
getData() {
|
||||
this.loading = true;
|
||||
getManagerWorkOrderPage(this.searchForm)
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
this.data = res.result?.records || [];
|
||||
this.total = res.result?.total || 0;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -55,6 +55,7 @@
|
||||
<el-option label="虚拟商品" value="VIRTUAL_GOODS" />
|
||||
<!-- E_COUPON:卡密商品;平台无卡池管理权限(S-04) -->
|
||||
<el-option label="电子卡券" value="E_COUPON" />
|
||||
<el-option label="预约商品" value="APPOINTMENT_GOODS" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="商品分组" prop="groupId">
|
||||
@@ -388,6 +389,7 @@ export default {
|
||||
if (v === "PHYSICAL_GOODS") return "实物商品";
|
||||
if (v === "VIRTUAL_GOODS") return "虚拟商品";
|
||||
if (v === "E_COUPON") return "电子卡券"; // 卡密商品
|
||||
if (v === "APPOINTMENT_GOODS") return "预约商品";
|
||||
return v || "—";
|
||||
},
|
||||
marketEnableText(v) {
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<el-button v-if="allowOperation.editPrice" @click="modifyPrice">调整价格</el-button>
|
||||
<el-button v-if="allowOperation.editConsignee" plain type="primary" @click="editAddress">修改收货地址</el-button>
|
||||
<el-button v-if="allowOperation.cancel" plain type="warning" @click="orderCancel">订单取消</el-button>
|
||||
<el-button v-if="isAppointmentOrder && canPlatformRefund" plain type="danger" @click="openPlatformRefund">平台退款</el-button>
|
||||
<el-button v-if="isAppointmentOrder && canNoShowRefund" plain type="danger" @click="openNoShowRefund">爽约退款</el-button>
|
||||
<el-button v-if="orderInfo.order.orderStatus === 'UNPAID'" type="primary" @click="confirmPrice">收款</el-button>
|
||||
<el-button plain @click="orderLog">订单日志</el-button>
|
||||
<el-button v-if="!isNonPhysicalOrder" plain type="primary" style="float:right;" @click="printOrder">打印发货单</el-button>
|
||||
@@ -37,6 +39,20 @@
|
||||
{{ orderInfo.order.createTime }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="div-item" v-if="isAppointmentOrder && appointmentSlotText">
|
||||
<div class="div-item-left">预约时段:</div>
|
||||
<div class="div-item-right">{{ appointmentSlotText }}</div>
|
||||
</div>
|
||||
<div
|
||||
class="div-item"
|
||||
v-for="(rows, gi) in appointmentFormGroups"
|
||||
:key="'form-' + gi"
|
||||
>
|
||||
<div class="div-item-left">{{ appointmentFormGroups.length > 1 ? `补充信息${gi + 1}:` : '补充信息:' }}</div>
|
||||
<div class="div-item-right">
|
||||
<div v-for="row in rows" :key="row.fieldKey">{{ row.fieldLabel }}:{{ row.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width: 30%; float: left; margin-left: 20px">
|
||||
<div class="div-item" v-if="orderInfo.order.needReceipt == false">
|
||||
@@ -463,11 +479,48 @@
|
||||
|
||||
|
||||
<multipleMap ref="map" @callback="selectedRegion" />
|
||||
|
||||
<el-dialog v-model="platformRefundModal" title="平台预约退款" width="520px">
|
||||
<el-form :model="platformRefundForm" label-width="100px">
|
||||
<el-form-item label="退款类型">
|
||||
<el-radio-group v-model="platformRefundForm.refundType">
|
||||
<el-radio label="UNSERVED">未服务</el-radio>
|
||||
<el-radio label="SERVED">已服务</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="退款金额">
|
||||
<el-input-number v-model="platformRefundForm.refundAmount" :min="0.01" :precision="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="退款原因">
|
||||
<el-input v-model="platformRefundForm.reason" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="platformRefundModal = false">取消</el-button>
|
||||
<el-button type="primary" :loading="refundLoading" @click="submitPlatformRefund">确认退款</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="noShowRefundModal" title="上门爽约退款" width="520px">
|
||||
<el-form :model="noShowRefundForm" label-width="100px">
|
||||
<el-form-item label="退款金额">
|
||||
<el-input-number v-model="noShowRefundForm.refundAmount" :min="0.01" :precision="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="退款原因">
|
||||
<el-input v-model="noShowRefundForm.reason" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="noShowRefundModal = false">取消</el-button>
|
||||
<el-button type="primary" :loading="refundLoading" @click="submitNoShowRefund">确认退款</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as API_Order from "@/api/order";
|
||||
import { platformRefund, noShowRefund, getAppointmentOrderDetail } from "@/api/appointment";
|
||||
import * as RegExp from "@/libs/RegExp.js";
|
||||
|
||||
import multipleMap from "@/components/map/multiple-map";
|
||||
@@ -494,7 +547,28 @@ export default {
|
||||
return t === "VIRTUAL" || this.$route.query.orderType === "VIRTUAL";
|
||||
},
|
||||
isNonPhysicalOrder() {
|
||||
return this.isVirtualOrder || this.isECouponOrder;
|
||||
return this.isVirtualOrder || this.isECouponOrder || this.isAppointmentOrder;
|
||||
},
|
||||
isAppointmentOrder() {
|
||||
const t = this.orderInfo?.order?.orderType;
|
||||
return t === "APPOINTMENT" || this.$route.query.orderType === "APPOINTMENT";
|
||||
},
|
||||
appointmentSlotText() {
|
||||
const v = this.appointmentExt || {};
|
||||
if (!v.slotDate) return "";
|
||||
const date = String(v.slotDate).slice(0, 10);
|
||||
const slot = v.slotStartTime && v.slotEndTime ? `${v.slotStartTime}-${v.slotEndTime}` : "";
|
||||
return [date, slot].filter(Boolean).join(" ");
|
||||
},
|
||||
appointmentFormGroups() {
|
||||
return this.parseFormAnswerGroups(this.appointmentExt?.formAnswerJson);
|
||||
},
|
||||
canPlatformRefund() {
|
||||
const order = this.orderInfo?.order;
|
||||
return order && order.payStatus === "PAID" && order.orderStatus !== "CANCELLED";
|
||||
},
|
||||
canNoShowRefund() {
|
||||
return this.canPlatformRefund;
|
||||
},
|
||||
ecouponCardKeyDelivered() {
|
||||
return (this.data || []).some((item) => item.cardKeyDelivered);
|
||||
@@ -529,6 +603,7 @@ export default {
|
||||
priceDetailDTO: {},
|
||||
},
|
||||
},
|
||||
appointmentExt: {},
|
||||
modal: false, //弹出调整价格框
|
||||
searchForm: {
|
||||
pageNumber: 1, // 当前页数
|
||||
@@ -568,6 +643,18 @@ export default {
|
||||
},
|
||||
addressModal: false, //弹出修改收件信息框
|
||||
printModal: false,
|
||||
platformRefundModal: false,
|
||||
noShowRefundModal: false,
|
||||
refundLoading: false,
|
||||
platformRefundForm: {
|
||||
refundType: "UNSERVED",
|
||||
refundAmount: 0,
|
||||
reason: "",
|
||||
},
|
||||
noShowRefundForm: {
|
||||
refundAmount: 0,
|
||||
reason: "",
|
||||
},
|
||||
//收件地址表单
|
||||
addressForm: {
|
||||
consigneeName: "",
|
||||
@@ -690,9 +777,51 @@ export default {
|
||||
this.typeList = JSON.parse(JSON.stringify(res.result.order.priceDetailDTO.discountPriceDetail));
|
||||
this.getContentPrice()
|
||||
this.getOrderPrice()
|
||||
if (this.isAppointmentOrder) {
|
||||
this.loadAppointmentExt();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
loadAppointmentExt() {
|
||||
getAppointmentOrderDetail(this.sn)
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
this.appointmentExt = res.result || {};
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.appointmentExt = {};
|
||||
});
|
||||
},
|
||||
parseFormAnswerGroups(formAnswerJson) {
|
||||
if (!formAnswerJson) return [];
|
||||
let data = formAnswerJson;
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const fields = Array.isArray(data?.fields) ? data.fields : [];
|
||||
const answers = Array.isArray(data?.answers) ? data.answers : [];
|
||||
return answers
|
||||
.map((group) => {
|
||||
const map = group && typeof group === "object" && !Array.isArray(group) ? group : {};
|
||||
const source = fields.length
|
||||
? fields
|
||||
: Object.keys(map).map((key) => ({ fieldKey: key, fieldLabel: key }));
|
||||
return source
|
||||
.map((field) => ({
|
||||
fieldKey: field.fieldKey,
|
||||
fieldLabel: field.fieldLabel || field.fieldKey,
|
||||
value: map[field.fieldKey] ?? "",
|
||||
}))
|
||||
.filter((row) => row.value !== "" && row.value != null);
|
||||
})
|
||||
.filter((rows) => rows.length);
|
||||
},
|
||||
modifyPrice () {
|
||||
//默认要修改的金额为订单总金额
|
||||
this.modifyPriceForm.price = this.orderInfo.order.flowPrice;
|
||||
@@ -744,6 +873,57 @@ export default {
|
||||
}
|
||||
});
|
||||
},
|
||||
openPlatformRefund() {
|
||||
this.platformRefundForm = {
|
||||
refundType: "UNSERVED",
|
||||
refundAmount: this.orderInfo?.order?.flowPrice || 0,
|
||||
reason: "",
|
||||
};
|
||||
this.platformRefundModal = true;
|
||||
},
|
||||
submitPlatformRefund() {
|
||||
if (!this.platformRefundForm.reason) {
|
||||
ElMessage.warning("请填写退款原因");
|
||||
return;
|
||||
}
|
||||
this.refundLoading = true;
|
||||
platformRefund(this.sn, this.platformRefundForm)
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
ElMessage.success("退款成功");
|
||||
this.platformRefundModal = false;
|
||||
this.getDataList();
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.refundLoading = false;
|
||||
});
|
||||
},
|
||||
openNoShowRefund() {
|
||||
this.noShowRefundForm = {
|
||||
refundAmount: this.orderInfo?.order?.flowPrice || 0,
|
||||
reason: "",
|
||||
};
|
||||
this.noShowRefundModal = true;
|
||||
},
|
||||
submitNoShowRefund() {
|
||||
if (!this.noShowRefundForm.reason) {
|
||||
ElMessage.warning("请填写退款原因");
|
||||
return;
|
||||
}
|
||||
this.refundLoading = true;
|
||||
noShowRefund(this.sn, this.noShowRefundForm)
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
ElMessage.success("退款成功");
|
||||
this.noShowRefundModal = false;
|
||||
this.getDataList();
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.refundLoading = false;
|
||||
});
|
||||
},
|
||||
//订单日志
|
||||
orderLog () {
|
||||
this.orderLogModal = true;
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<el-form-item label="收货人" prop="shipName">
|
||||
<el-input v-model="searchForm.shipName" placeholder="请输入收货人姓名" clearable style="width: 240px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="订单类型" prop="orderType">
|
||||
<el-form-item label="促销类型" prop="orderPromotionType">
|
||||
<el-select v-model="searchForm.orderPromotionType" placeholder="请选择" clearable style="width: 240px">
|
||||
<el-option label="普通订单" value="NORMAL" />
|
||||
<el-option label="拼团订单" value="PINTUAN" />
|
||||
@@ -38,6 +38,11 @@
|
||||
<el-option label="砍价订单" value="KANJIA" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="订单类型" prop="orderType">
|
||||
<el-select v-model="searchForm.orderType" placeholder="请选择" clearable style="width: 240px">
|
||||
<el-option label="预约商品" value="APPOINTMENT" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="支付方式" prop="paymentMethod">
|
||||
<el-select v-model="searchForm.paymentMethod" placeholder="请选择支付方式" clearable style="width: 240px">
|
||||
<el-option label="微信支付" value="WECHAT" />
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<el-input type="number" v-model="formValidate.autoEvaluation">
|
||||
<template #append>天</template>
|
||||
</el-input>
|
||||
<span class="desc">订单发货后,多少天后自动好评</span>
|
||||
<span class="desc">订单完成后,多少天后自动好评,预约订单同样适用</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="已完成订单允许退单" prop="closeAfterSale">
|
||||
<el-input type="number" v-model="formValidate.closeAfterSale">
|
||||
|
||||
81
seller/src/api/appointment.js
Normal file
81
seller/src/api/appointment.js
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 预约服务 — 商家端 HTTP 封装
|
||||
*
|
||||
* Base Path:/store/appointment/*(axios 已带 /store 前缀)
|
||||
*/
|
||||
import {
|
||||
getRequest,
|
||||
postRequest,
|
||||
putRequest,
|
||||
deleteRequest,
|
||||
postRequestWithNoForm,
|
||||
} from "@/libs/axios";
|
||||
|
||||
/** 店铺预约通用配置(改期审核等) */
|
||||
export const getAppointmentSettings = () => getRequest("/appointment/settings");
|
||||
export const saveAppointmentSettings = (params) =>
|
||||
putRequest("/appointment/settings", params);
|
||||
|
||||
/** 到店服务设置 */
|
||||
export const getInStoreSettings = () => getRequest("/appointment/settings/in-store");
|
||||
export const saveInStoreSettings = (params) =>
|
||||
putRequest("/appointment/settings/in-store", params);
|
||||
|
||||
/** 上门服务设置 */
|
||||
export const getOnSiteSettings = () => getRequest("/appointment/settings/on-site");
|
||||
export const saveOnSiteSettings = (params) =>
|
||||
putRequest("/appointment/settings/on-site", params);
|
||||
|
||||
/** 服务人员 §14 */
|
||||
export const getServiceStaffPage = (params) =>
|
||||
getRequest("/appointment/service-staff", params);
|
||||
export const createServiceStaff = (params) =>
|
||||
postRequestWithNoForm("/appointment/service-staff", params);
|
||||
export const updateServiceStaff = (id, params) =>
|
||||
putRequest(`/appointment/service-staff/${id}`, params);
|
||||
export const updateServiceStaffStatus = (id, status) =>
|
||||
putRequest(`/appointment/service-staff/${id}/status`, { status });
|
||||
export const deleteServiceStaff = (id) =>
|
||||
deleteRequest(`/appointment/service-staff/${id}`);
|
||||
|
||||
/** 工单管理 §15 */
|
||||
export const getWorkOrderPage = (params) =>
|
||||
getRequest("/appointment/work-orders", params);
|
||||
export const getWorkOrderStatusCounts = () =>
|
||||
getRequest("/appointment/work-orders/status-counts");
|
||||
export const claimWorkOrder = (id) =>
|
||||
postRequest(`/appointment/work-orders/${id}/claim`);
|
||||
export const reassignWorkOrder = (id, params) =>
|
||||
postRequestWithNoForm(`/appointment/work-orders/${id}/reassign`, params);
|
||||
export const endWorkOrderService = (id) =>
|
||||
postRequest(`/appointment/work-orders/${id}/end-service`);
|
||||
|
||||
/** 预约核销 §17 */
|
||||
export const getEnabledServiceStaff = () =>
|
||||
getRequest("/appointment/service-staff/enabled");
|
||||
export const getAppointmentVerificationByOrderSn = (orderSn) =>
|
||||
getRequest(`/appointment/verification/order-by-sn/${orderSn}`);
|
||||
export const getAppointmentOrderByCode = (code) =>
|
||||
getRequest(`/appointment/verification/order/${code}`);
|
||||
export const verifyAppointmentCode = (params) =>
|
||||
postRequestWithNoForm("/appointment/verification/verify", params);
|
||||
|
||||
/** 服务月历 §16 */
|
||||
export const getServiceCalendarMonth = (month) =>
|
||||
getRequest("/appointment/calendar", { month });
|
||||
export const getServiceCalendarDay = (params) =>
|
||||
getRequest("/appointment/calendar/day", params);
|
||||
|
||||
/** 预约单列表 §24 */
|
||||
export const getAppointmentBookPage = (params) =>
|
||||
getRequest("/appointment/books", params);
|
||||
|
||||
/** 改期日志 */
|
||||
export const getRescheduleLogPage = (params) =>
|
||||
getRequest("/appointment/reschedule-logs", params);
|
||||
export const approveReschedule = (logId) =>
|
||||
postRequest(`/appointment/reschedule/${logId}/approve`);
|
||||
export const rejectReschedule = (logId, params) =>
|
||||
postRequestWithNoForm(`/appointment/reschedule/${logId}/reject`, params);
|
||||
export const merchantRescheduleBook = (bookId, params) =>
|
||||
postRequestWithNoForm(`/appointment/books/${bookId}/reschedule`, params);
|
||||
135
seller/src/constants/appointment.js
Normal file
135
seller/src/constants/appointment.js
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 预约商品(APPOINTMENT_GOODS)— 商家端常量
|
||||
*/
|
||||
export const APPOINTMENT_GOODS_TYPE = "APPOINTMENT_GOODS";
|
||||
|
||||
export const SERVICE_MODE = {
|
||||
IN_STORE: "IN_STORE",
|
||||
ON_SITE: "ON_SITE",
|
||||
};
|
||||
|
||||
export const SERVICE_MODE_LABEL = {
|
||||
IN_STORE: "到店服务",
|
||||
ON_SITE: "上门服务",
|
||||
};
|
||||
|
||||
export const SLOT_DIVISION_MODE = {
|
||||
AUTO: "AUTO",
|
||||
CUSTOM: "CUSTOM",
|
||||
};
|
||||
|
||||
export const BOOKABLE_DATE_MODE = {
|
||||
EVERY_DAY: "EVERY_DAY",
|
||||
CUSTOM: "CUSTOM",
|
||||
};
|
||||
|
||||
export const ADVANCE_MODE = {
|
||||
NONE: "NONE",
|
||||
REQUIRE_HOURS: "REQUIRE_HOURS",
|
||||
};
|
||||
|
||||
export const CANCEL_MODE = {
|
||||
NOT_ALLOWED: "NOT_ALLOWED",
|
||||
ALLOW_BEFORE_HOURS: "ALLOW_BEFORE_HOURS",
|
||||
};
|
||||
|
||||
export const FORM_SUBMIT_MODE = {
|
||||
PER_APPOINTMENT: "PER_APPOINTMENT",
|
||||
PER_ORDER: "PER_ORDER",
|
||||
};
|
||||
|
||||
export const WORK_ORDER_STATUS = {
|
||||
PENDING_CLAIM: "PENDING_CLAIM",
|
||||
WAIT_VERIFY: "WAIT_VERIFY",
|
||||
CLAIMED: "CLAIMED",
|
||||
IN_SERVICE: "IN_SERVICE",
|
||||
SERVICE_ENDED: "SERVICE_ENDED",
|
||||
NO_SHOW: "NO_SHOW",
|
||||
REFUNDED: "REFUNDED",
|
||||
};
|
||||
|
||||
export const WORK_ORDER_STATUS_LABEL = {
|
||||
PENDING_CLAIM: "待领取",
|
||||
WAIT_VERIFY: "待核销",
|
||||
CLAIMED: "已领取",
|
||||
IN_SERVICE: "服务中",
|
||||
SERVICE_ENDED: "服务结束",
|
||||
NO_SHOW: "逾期未到店",
|
||||
REFUNDED: "已退款",
|
||||
};
|
||||
|
||||
export const WEEKDAY_OPTIONS = [
|
||||
{ label: "周一", value: 1 },
|
||||
{ label: "周二", value: 2 },
|
||||
{ label: "周三", value: 3 },
|
||||
{ label: "周四", value: 4 },
|
||||
{ label: "周五", value: 5 },
|
||||
{ label: "周六", value: 6 },
|
||||
{ label: "周日", value: 7 },
|
||||
];
|
||||
|
||||
export function isAppointmentGoods(goodsType) {
|
||||
return goodsType === APPOINTMENT_GOODS_TYPE;
|
||||
}
|
||||
|
||||
export function defaultAppointmentSettings() {
|
||||
return {
|
||||
serviceModes: [SERVICE_MODE.IN_STORE],
|
||||
showRemainingQty: true,
|
||||
bookableDateMode: BOOKABLE_DATE_MODE.EVERY_DAY,
|
||||
bookableDays: 1,
|
||||
bookableCustomStartDate: "",
|
||||
bookableCustomEndDate: "",
|
||||
bookableWeekdays: [1, 2, 3, 4, 5, 6, 7],
|
||||
advanceMode: ADVANCE_MODE.NONE,
|
||||
advanceHours: 1,
|
||||
cancelMode: CANCEL_MODE.ALLOW_BEFORE_HOURS,
|
||||
cancelAheadHours: 2,
|
||||
formSubmitMode: FORM_SUBMIT_MODE.PER_APPOINTMENT,
|
||||
systemFormId: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultSlotDivision() {
|
||||
return {
|
||||
slotDivisionMode: SLOT_DIVISION_MODE.AUTO,
|
||||
serviceDurationMinutes: 60,
|
||||
autoStartTime: "08:00",
|
||||
autoEndTime: "18:00",
|
||||
confirmedSlots: [],
|
||||
customDrafts: [{ startTime: "08:00", endTime: "09:00" }],
|
||||
};
|
||||
}
|
||||
|
||||
export function slotKey(slot) {
|
||||
return `${slot.startTime}-${slot.endTime}`;
|
||||
}
|
||||
|
||||
export function parseFormAnswerGroups(formAnswerJson) {
|
||||
if (!formAnswerJson) return [];
|
||||
let data = formAnswerJson;
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const fields = Array.isArray(data?.fields) ? data.fields : [];
|
||||
const answers = Array.isArray(data?.answers) ? data.answers : [];
|
||||
return answers
|
||||
.map((group) => {
|
||||
const map = group && typeof group === "object" && !Array.isArray(group) ? group : {};
|
||||
const source = fields.length
|
||||
? fields
|
||||
: Object.keys(map).map((key) => ({ fieldKey: key, fieldLabel: key }));
|
||||
return source
|
||||
.map((field) => ({
|
||||
fieldKey: field.fieldKey,
|
||||
fieldLabel: field.fieldLabel || field.fieldKey,
|
||||
value: map[field.fieldKey] ?? "",
|
||||
}))
|
||||
.filter((row) => row.value !== "" && row.value != null);
|
||||
})
|
||||
.filter((rows) => rows.length);
|
||||
}
|
||||
@@ -5,14 +5,20 @@
|
||||
* @date 2026-08-02
|
||||
*/
|
||||
export const E_COUPON_GOODS_TYPE = "E_COUPON";
|
||||
export const APPOINTMENT_GOODS_TYPE = "APPOINTMENT_GOODS";
|
||||
|
||||
export function isECoupon(goodsType) {
|
||||
return goodsType === E_COUPON_GOODS_TYPE;
|
||||
}
|
||||
|
||||
export function isAppointmentGoods(goodsType) {
|
||||
return goodsType === APPOINTMENT_GOODS_TYPE;
|
||||
}
|
||||
|
||||
export function goodsTypeLabel(goodsType) {
|
||||
const map = {
|
||||
E_COUPON: "电子卡券",
|
||||
APPOINTMENT_GOODS: "预约商品",
|
||||
VIRTUAL_GOODS: "虚拟商品",
|
||||
PHYSICAL_GOODS: "实物商品",
|
||||
};
|
||||
@@ -21,6 +27,7 @@ export function goodsTypeLabel(goodsType) {
|
||||
|
||||
export function goodsTypeTagType(goodsType) {
|
||||
if (goodsType === E_COUPON_GOODS_TYPE) return "warning";
|
||||
if (goodsType === APPOINTMENT_GOODS_TYPE) return "success";
|
||||
if (goodsType === "VIRTUAL_GOODS") return "info";
|
||||
return "";
|
||||
}
|
||||
|
||||
327
seller/src/constants/systemForm.js
Normal file
327
seller/src/constants/systemForm.js
Normal file
@@ -0,0 +1,327 @@
|
||||
/** 预约系统表单 · 字段类型与 schema(§9.1.1.2) */
|
||||
|
||||
export const FORM_FIELD_TYPE = {
|
||||
CHECKBOX: "CHECKBOX",
|
||||
CITY: "CITY",
|
||||
RADIO: "RADIO",
|
||||
DATE: "DATE",
|
||||
DATE_RANGE: "DATE_RANGE",
|
||||
SELECT: "SELECT",
|
||||
TEXT: "TEXT",
|
||||
TIME: "TIME",
|
||||
TIME_RANGE: "TIME_RANGE",
|
||||
RICH_TEXT: "RICH_TEXT",
|
||||
IMAGE: "IMAGE",
|
||||
};
|
||||
|
||||
export const FORM_FIELD_PALETTE = [
|
||||
{ type: FORM_FIELD_TYPE.CHECKBOX, label: "多选框" },
|
||||
{ type: FORM_FIELD_TYPE.CITY, label: "城市" },
|
||||
{ type: FORM_FIELD_TYPE.RADIO, label: "单选框" },
|
||||
{ type: FORM_FIELD_TYPE.DATE, label: "日期" },
|
||||
{ type: FORM_FIELD_TYPE.DATE_RANGE, label: "日期范围" },
|
||||
{ type: FORM_FIELD_TYPE.SELECT, label: "下拉框" },
|
||||
{ type: FORM_FIELD_TYPE.TEXT, label: "文本框" },
|
||||
{ type: FORM_FIELD_TYPE.TIME, label: "时间" },
|
||||
{ type: FORM_FIELD_TYPE.TIME_RANGE, label: "时间范围" },
|
||||
{ type: FORM_FIELD_TYPE.RICH_TEXT, label: "富文本" },
|
||||
{ type: FORM_FIELD_TYPE.IMAGE, label: "图片" },
|
||||
];
|
||||
|
||||
export const FORM_FIELD_LABEL = Object.fromEntries(
|
||||
FORM_FIELD_PALETTE.map((item) => [item.type, item.label])
|
||||
);
|
||||
|
||||
export const CITY_LEVEL = {
|
||||
PROVINCE_CITY: "PROVINCE_CITY",
|
||||
PROVINCE_CITY_DISTRICT: "PROVINCE_CITY_DISTRICT",
|
||||
PROVINCE_CITY_DISTRICT_STREET: "PROVINCE_CITY_DISTRICT_STREET",
|
||||
};
|
||||
|
||||
export const TEXT_CONTENT_TYPE = {
|
||||
TEXT: "TEXT",
|
||||
MOBILE: "MOBILE",
|
||||
ID_CARD: "ID_CARD",
|
||||
EMAIL: "EMAIL",
|
||||
NUMBER: "NUMBER",
|
||||
};
|
||||
|
||||
export const OPTION_MAX = 20;
|
||||
/** 标题/选项/提示语均最多十字 */
|
||||
export const FIELD_LABEL_MAX = 10;
|
||||
export const OPTION_LABEL_MAX = 10;
|
||||
export const HINT_TEXT_MAX = 10;
|
||||
export const RADIO_OPTION_MAX_LEN = OPTION_LABEL_MAX;
|
||||
|
||||
export function createFieldKey() {
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `f_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function defaultOptions() {
|
||||
return [
|
||||
{ label: "选项一", value: createFieldKey() },
|
||||
{ label: "选项二", value: createFieldKey() },
|
||||
];
|
||||
}
|
||||
|
||||
export function createField(type) {
|
||||
const fieldType = String(type || FORM_FIELD_TYPE.TEXT).toUpperCase();
|
||||
const base = {
|
||||
field_key: createFieldKey(),
|
||||
field_type: fieldType,
|
||||
field_label: FORM_FIELD_LABEL[fieldType] || "文本框",
|
||||
required: true,
|
||||
};
|
||||
switch (fieldType) {
|
||||
case FORM_FIELD_TYPE.CHECKBOX:
|
||||
return { ...base, options: defaultOptions() };
|
||||
case FORM_FIELD_TYPE.RADIO:
|
||||
return {
|
||||
field_key: base.field_key,
|
||||
field_type: fieldType,
|
||||
field_label: "单选框",
|
||||
options: defaultOptions(),
|
||||
default_option_index: 0, // 默认选项一
|
||||
};
|
||||
case FORM_FIELD_TYPE.SELECT:
|
||||
return {
|
||||
...base,
|
||||
field_label: "下拉框",
|
||||
options: defaultOptions(),
|
||||
default_option_index: 0,
|
||||
};
|
||||
case FORM_FIELD_TYPE.CITY:
|
||||
return {
|
||||
...base,
|
||||
field_label: "城市",
|
||||
city_level: CITY_LEVEL.PROVINCE_CITY_DISTRICT_STREET,
|
||||
hint_text: "请选择",
|
||||
};
|
||||
case FORM_FIELD_TYPE.DATE:
|
||||
return {
|
||||
...base,
|
||||
field_label: "日期",
|
||||
default_visibility: "SHOW",
|
||||
default_date_mode: "CURRENT_DATE",
|
||||
default_date: null,
|
||||
hint_text: "请选择",
|
||||
};
|
||||
case FORM_FIELD_TYPE.DATE_RANGE:
|
||||
return {
|
||||
...base,
|
||||
field_label: "日期范围",
|
||||
default_visibility: "SHOW",
|
||||
default_date_mode: "CURRENT_DATE",
|
||||
default_date_start: null,
|
||||
default_date_end: null,
|
||||
hint_text: "请选择",
|
||||
};
|
||||
case FORM_FIELD_TYPE.TIME:
|
||||
return {
|
||||
...base,
|
||||
field_label: "时间",
|
||||
default_visibility: "SHOW",
|
||||
default_time_mode: "CURRENT_TIME",
|
||||
default_time: null,
|
||||
hint_text: "请选择",
|
||||
};
|
||||
case FORM_FIELD_TYPE.TIME_RANGE:
|
||||
return {
|
||||
...base,
|
||||
field_label: "时间范围",
|
||||
default_visibility: "SHOW",
|
||||
default_time_mode: "CURRENT_TIME",
|
||||
default_time_start: null,
|
||||
default_time_end: null,
|
||||
hint_text: "请选择",
|
||||
};
|
||||
case FORM_FIELD_TYPE.TEXT:
|
||||
return {
|
||||
...base,
|
||||
field_label: "文本框",
|
||||
content_type: TEXT_CONTENT_TYPE.TEXT,
|
||||
default_value: "",
|
||||
hint_text: "请填写",
|
||||
};
|
||||
case FORM_FIELD_TYPE.RICH_TEXT:
|
||||
return {
|
||||
field_key: base.field_key,
|
||||
field_type: fieldType,
|
||||
field_label: "富文本",
|
||||
rich_text_content: "",
|
||||
};
|
||||
case FORM_FIELD_TYPE.IMAGE:
|
||||
return {
|
||||
...base,
|
||||
field_label: "上传图片",
|
||||
max_upload_count: 1,
|
||||
};
|
||||
default:
|
||||
return { ...base, field_type: FORM_FIELD_TYPE.TEXT, content_type: TEXT_CONTENT_TYPE.TEXT, hint_text: "请填写" };
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeType(raw) {
|
||||
return String(raw || FORM_FIELD_TYPE.TEXT)
|
||||
.toUpperCase()
|
||||
.replace(/-/g, "_");
|
||||
}
|
||||
|
||||
function normalizeOptions(rawOptions) {
|
||||
if (!Array.isArray(rawOptions) || !rawOptions.length) {
|
||||
return defaultOptions();
|
||||
}
|
||||
return rawOptions.map((item) => {
|
||||
if (typeof item === "string") {
|
||||
return { label: item, value: createFieldKey() };
|
||||
}
|
||||
return {
|
||||
label: item?.label ?? item?.name ?? "",
|
||||
value: item?.value || createFieldKey(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeField(raw, index = 0) {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return createField(FORM_FIELD_TYPE.TEXT);
|
||||
}
|
||||
const type = normalizeType(raw.field_type || raw.fieldType || raw.type);
|
||||
const field = createField(type);
|
||||
field.field_key = raw.field_key || raw.fieldKey || raw.id || raw.key || field.field_key;
|
||||
field.field_label = raw.field_label || raw.fieldLabel || raw.label || raw.title || field.field_label;
|
||||
if (raw.required != null) {
|
||||
field.required = !!raw.required;
|
||||
}
|
||||
if (raw.hint_text != null || raw.hintText != null || raw.hint != null || raw.placeholder != null) {
|
||||
field.hint_text = raw.hint_text || raw.hintText || raw.hint || raw.placeholder || "";
|
||||
}
|
||||
if (field.options) {
|
||||
field.options = normalizeOptions(raw.options);
|
||||
}
|
||||
if (raw.default_option_index != null || raw.defaultOptionIndex != null) {
|
||||
field.default_option_index = Number(raw.default_option_index ?? raw.defaultOptionIndex) || 0;
|
||||
}
|
||||
if (raw.city_level || raw.cityLevel) {
|
||||
field.city_level = raw.city_level || raw.cityLevel;
|
||||
}
|
||||
if (raw.content_type || raw.contentType) {
|
||||
field.content_type = raw.content_type || raw.contentType;
|
||||
}
|
||||
if (raw.default_value != null || raw.defaultValue != null) {
|
||||
field.default_value = raw.default_value ?? raw.defaultValue ?? "";
|
||||
}
|
||||
if (raw.default_visibility || raw.defaultVisibility) {
|
||||
field.default_visibility = raw.default_visibility || raw.defaultVisibility;
|
||||
}
|
||||
if (raw.default_date_mode || raw.defaultDateMode) {
|
||||
field.default_date_mode = raw.default_date_mode || raw.defaultDateMode;
|
||||
}
|
||||
if (raw.default_date || raw.defaultDate) {
|
||||
field.default_date = raw.default_date || raw.defaultDate;
|
||||
}
|
||||
if (raw.default_date_start || raw.defaultDateStart) {
|
||||
field.default_date_start = raw.default_date_start || raw.defaultDateStart;
|
||||
}
|
||||
if (raw.default_date_end || raw.defaultDateEnd) {
|
||||
field.default_date_end = raw.default_date_end || raw.defaultDateEnd;
|
||||
}
|
||||
if (raw.default_time_mode || raw.defaultTimeMode) {
|
||||
field.default_time_mode = raw.default_time_mode || raw.defaultTimeMode;
|
||||
}
|
||||
if (raw.default_time || raw.defaultTime) {
|
||||
field.default_time = raw.default_time || raw.defaultTime;
|
||||
}
|
||||
if (raw.default_time_start || raw.defaultTimeStart) {
|
||||
field.default_time_start = raw.default_time_start || raw.defaultTimeStart;
|
||||
}
|
||||
if (raw.default_time_end || raw.defaultTimeEnd) {
|
||||
field.default_time_end = raw.default_time_end || raw.defaultTimeEnd;
|
||||
}
|
||||
if (raw.rich_text_content != null || raw.richTextContent != null || raw.content != null) {
|
||||
field.rich_text_content = raw.rich_text_content || raw.richTextContent || raw.content || "";
|
||||
}
|
||||
if (raw.max_upload_count != null || raw.maxUploadCount != null) {
|
||||
field.max_upload_count = Math.min(10, Math.max(1, Number(raw.max_upload_count ?? raw.maxUploadCount) || 1));
|
||||
}
|
||||
field.sort_order = raw.sort_order ?? raw.sortOrder ?? index;
|
||||
return field;
|
||||
}
|
||||
|
||||
export function parseFormSchema(schemaJson) {
|
||||
if (!schemaJson) {
|
||||
return [];
|
||||
}
|
||||
let data = schemaJson;
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const list = Array.isArray(data) ? data : data?.fields || [];
|
||||
return list.map((item, index) => normalizeField(item, index));
|
||||
}
|
||||
|
||||
export function serializeFormSchema(fields) {
|
||||
return JSON.stringify(
|
||||
(fields || []).map((field, index) => ({
|
||||
...field,
|
||||
sort_order: index,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export function countFormFields(schemaJson) {
|
||||
return parseFormSchema(schemaJson).length;
|
||||
}
|
||||
|
||||
export function addOption(options, { radio } = {}) {
|
||||
const list = [...(options || [])];
|
||||
if (list.length >= OPTION_MAX) {
|
||||
return list;
|
||||
}
|
||||
list.push({
|
||||
label: radio ? "" : `选项${list.length + 1}`,
|
||||
value: createFieldKey(),
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
export function removeOption(options, index) {
|
||||
const list = [...(options || [])];
|
||||
if (index < 2 || list.length <= 2) {
|
||||
return list;
|
||||
}
|
||||
list.splice(index, 1);
|
||||
return list;
|
||||
}
|
||||
|
||||
export function batchAddOptions(options, text, { radio } = {}) {
|
||||
const list = [...(options || [])];
|
||||
const lines = String(text || "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => line.slice(0, OPTION_LABEL_MAX));
|
||||
for (const label of lines) {
|
||||
if (list.length >= OPTION_MAX) {
|
||||
break;
|
||||
}
|
||||
list.push({ label, value: createFieldKey() });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
export function hasRequiredSwitch(type) {
|
||||
// 单选、富文本不配必填
|
||||
return ![FORM_FIELD_TYPE.RADIO, FORM_FIELD_TYPE.RICH_TEXT].includes(type);
|
||||
}
|
||||
|
||||
export function hasOptions(type) {
|
||||
return [FORM_FIELD_TYPE.CHECKBOX, FORM_FIELD_TYPE.RADIO, FORM_FIELD_TYPE.SELECT].includes(type);
|
||||
}
|
||||
@@ -71,6 +71,55 @@ export const otherRouter = {
|
||||
name: "card-key-pool",
|
||||
component: () => import("@/views/goods/card-key/cardKeyPool.vue")
|
||||
},
|
||||
/** 预约服务 · 到店/上门设置、服务人员、工单、月历(菜单由后端配置,路由在此注册) */
|
||||
{
|
||||
path: "appointment/in-store-settings",
|
||||
title: "到店服务",
|
||||
name: "appointment-in-store-settings",
|
||||
component: () => import("@/views/appointment/inStoreSettings.vue")
|
||||
},
|
||||
{
|
||||
path: "appointment/on-site-settings",
|
||||
title: "上门服务",
|
||||
name: "appointment-on-site-settings",
|
||||
component: () => import("@/views/appointment/onSiteSettings.vue")
|
||||
},
|
||||
{
|
||||
path: "appointment/service-staff",
|
||||
title: "服务人员管理",
|
||||
name: "appointment-service-staff",
|
||||
component: () => import("@/views/appointment/serviceStaffList.vue")
|
||||
},
|
||||
{
|
||||
path: "appointment/work-orders",
|
||||
title: "工单管理",
|
||||
name: "appointment-work-orders",
|
||||
component: () => import("@/views/appointment/workOrderList.vue")
|
||||
},
|
||||
{
|
||||
path: "appointment/service-calendar",
|
||||
title: "服务月历",
|
||||
name: "appointment-service-calendar",
|
||||
component: () => import("@/views/appointment/serviceCalendar.vue")
|
||||
},
|
||||
{
|
||||
path: "appointment/system-forms",
|
||||
title: "系统表单",
|
||||
name: "appointment-system-forms",
|
||||
component: () => import("@/views/appointment/systemFormList.vue")
|
||||
},
|
||||
{
|
||||
path: "appointment/system-forms/designer",
|
||||
title: "表单设计器",
|
||||
name: "appointment-system-form-designer",
|
||||
component: () => import("@/views/appointment/systemFormDesigner.vue")
|
||||
},
|
||||
{
|
||||
path: "appointment/reschedule-logs",
|
||||
title: "改期记录",
|
||||
name: "appointment-reschedule-logs",
|
||||
component: () => import("@/views/appointment/rescheduleLogList.vue")
|
||||
},
|
||||
{
|
||||
path: "add-coupon",
|
||||
title: "店铺优惠券",
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
<template>
|
||||
<div class="field-preview" :class="{ 'is-inline': isInlineOptions }">
|
||||
<template v-if="field.field_type === 'RICH_TEXT'">
|
||||
<div class="rich-preview" v-html="field.rich_text_content || '富文本说明'" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="field-label">
|
||||
<span v-if="field.required" class="required">*</span>
|
||||
<span class="label-text">{{ field.field_label }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="field.field_type === 'CHECKBOX'"
|
||||
class="preview-options preview-options-end"
|
||||
:class="{ 'is-stack': optionListStacked }"
|
||||
>
|
||||
<div class="opt-pack">
|
||||
<div v-for="opt in field.options" :key="opt.value" class="opt-item">
|
||||
<span class="fake-checkbox" />
|
||||
<span class="opt-text">{{ opt.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="field.field_type === 'RADIO'"
|
||||
class="preview-options preview-options-end"
|
||||
:class="{ 'is-stack': optionListStacked }"
|
||||
>
|
||||
<div class="opt-pack">
|
||||
<div v-for="(opt, idx) in field.options" :key="opt.value" class="opt-item">
|
||||
<span class="fake-radio" :class="{ 'is-checked': idx === field.default_option_index }" />
|
||||
<span class="opt-text">{{ opt.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="field.field_type === 'SELECT'" class="city-row">
|
||||
<span class="city-hint">{{ field.hint_text || "请选择" }}</span>
|
||||
<span class="arrow">›</span>
|
||||
</div>
|
||||
<div v-else-if="field.field_type === 'CITY'" class="city-row">
|
||||
<span class="city-hint">{{ field.hint_text || "请选择" }}</span>
|
||||
<span class="arrow">›</span>
|
||||
</div>
|
||||
<div v-else-if="field.field_type === 'DATE'" class="city-row">
|
||||
<span :class="['city-hint', { 'is-value': !!singleDefault }]">
|
||||
{{ singleDefault || field.hint_text || "请选择" }}
|
||||
</span>
|
||||
<span class="arrow">›</span>
|
||||
</div>
|
||||
<div v-else-if="field.field_type === 'TIME'" class="city-row">
|
||||
<span :class="['city-hint', { 'is-value': !!singleDefault }]">
|
||||
{{ singleDefault || field.hint_text || "请选择" }}
|
||||
</span>
|
||||
<span class="arrow">›</span>
|
||||
</div>
|
||||
<div v-else-if="field.field_type === 'DATE_RANGE'" class="city-row">
|
||||
<span :class="['city-hint', { 'is-value': !!(rangeStart && rangeEnd) }]">
|
||||
{{ dateRangeText }}
|
||||
</span>
|
||||
<span class="arrow">›</span>
|
||||
</div>
|
||||
<div v-else-if="field.field_type === 'TIME_RANGE'" class="range-row">
|
||||
<div class="picker-row">
|
||||
<span :class="['picker-text', { 'is-placeholder': !rangeStart }]">
|
||||
{{ rangeStart || "开始时间" }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="range-sep">至</span>
|
||||
<div class="picker-row">
|
||||
<span :class="['picker-text', { 'is-placeholder': !rangeEnd }]">
|
||||
{{ rangeEnd || "结束时间" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="field.field_type === 'IMAGE'" class="image-row">
|
||||
<div v-for="n in imageSlotCount" :key="n" class="image-add">+</div>
|
||||
</div>
|
||||
<div v-else-if="field.field_type === 'TEXT'" class="city-row text-value-row">
|
||||
<span :class="['city-hint', { 'is-value': !!field.default_value }]">
|
||||
{{ field.default_value || field.hint_text || "请填写" }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="text-box">
|
||||
<span :class="{ 'is-placeholder': !field.default_value }">
|
||||
{{ field.default_value || field.hint_text || "请填写" }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
function pad(n) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
function todayText() {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
function nowTimeText() {
|
||||
const d = new Date();
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "SystemFormFieldPreview",
|
||||
props: {
|
||||
field: { type: Object, required: true },
|
||||
},
|
||||
computed: {
|
||||
isInlineOptions() {
|
||||
return [
|
||||
"CHECKBOX",
|
||||
"RADIO",
|
||||
"CITY",
|
||||
"DATE",
|
||||
"DATE_RANGE",
|
||||
"SELECT",
|
||||
"TEXT",
|
||||
"TIME",
|
||||
"TIME_RANGE",
|
||||
].includes(this.field.field_type);
|
||||
},
|
||||
imageSlotCount() {
|
||||
const n = Number(this.field.max_upload_count);
|
||||
return Math.min(10, Math.max(1, Number.isFinite(n) ? n : 1));
|
||||
},
|
||||
optionListStacked() {
|
||||
// 一行放不下两个选项时改为纵向堆叠
|
||||
const labels = (this.field.options || []).map((item) => item.label || "");
|
||||
if (labels.length < 2) return false;
|
||||
const itemWidth = (text) => 24 + Array.from(text).length * 14;
|
||||
const available = 230;
|
||||
for (let i = 0; i < labels.length; i += 2) {
|
||||
const next = labels[i + 1];
|
||||
if (next == null) break;
|
||||
if (itemWidth(labels[i]) + 12 + itemWidth(next) > available) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
showDefault() {
|
||||
return this.field.default_visibility === "SHOW";
|
||||
},
|
||||
singleDefault() {
|
||||
if (!this.showDefault) return "";
|
||||
if (this.field.field_type === "DATE") {
|
||||
return this.field.default_date_mode === "SPECIFIED_DATE"
|
||||
? this.field.default_date
|
||||
: todayText();
|
||||
}
|
||||
return this.field.default_time_mode === "SPECIFIED_TIME"
|
||||
? this.field.default_time
|
||||
: nowTimeText();
|
||||
},
|
||||
dateRangeText() {
|
||||
if (this.rangeStart && this.rangeEnd) {
|
||||
return `${this.rangeStart} - ${this.rangeEnd}`;
|
||||
}
|
||||
return this.field.hint_text || "请选择";
|
||||
},
|
||||
rangeStart() {
|
||||
if (!this.showDefault) return "";
|
||||
if (this.field.field_type === "DATE_RANGE") {
|
||||
return this.field.default_date_mode === "SPECIFIED_DATE"
|
||||
? this.field.default_date_start
|
||||
: todayText();
|
||||
}
|
||||
return this.field.default_time_mode === "SPECIFIED_TIME"
|
||||
? this.field.default_time_start
|
||||
: nowTimeText();
|
||||
},
|
||||
rangeEnd() {
|
||||
if (!this.showDefault) return "";
|
||||
if (this.field.field_type === "DATE_RANGE") {
|
||||
return this.field.default_date_mode === "SPECIFIED_DATE"
|
||||
? this.field.default_date_end
|
||||
: todayText();
|
||||
}
|
||||
return this.field.default_time_mode === "SPECIFIED_TIME"
|
||||
? this.field.default_time_end
|
||||
: nowTimeText();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field-preview {
|
||||
pointer-events: none; /* 画布只展示,不可填 */
|
||||
user-select: none;
|
||||
}
|
||||
.field-preview.is-inline {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.field-label {
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
color: #303133;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.label-text {
|
||||
display: inline-block;
|
||||
max-width: 6em; /* 标题每行最多六字 */
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 22px;
|
||||
}
|
||||
.is-inline .field-label {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 0;
|
||||
line-height: 22px;
|
||||
padding-top: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
.is-inline .picker-row {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.is-inline .range-row {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.is-inline .text-box {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.is-inline .city-row {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
.is-inline .text-value-row .city-hint {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
line-height: 22px;
|
||||
}
|
||||
.required {
|
||||
color: #f56c6c;
|
||||
margin-right: 2px;
|
||||
}
|
||||
.preview-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.is-inline .preview-options {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
.is-inline .preview-options-end {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
grid-template-columns: none;
|
||||
}
|
||||
.opt-pack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
.is-stack .opt-pack {
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
align-items: stretch;
|
||||
width: max-content;
|
||||
}
|
||||
.opt-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
color: #303133;
|
||||
font-size: 14px;
|
||||
}
|
||||
.opt-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fake-checkbox,
|
||||
.fake-radio {
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #dcdfe6;
|
||||
background: #fff;
|
||||
}
|
||||
.fake-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.fake-radio {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.fake-radio.is-checked {
|
||||
border-color: var(--el-color-primary);
|
||||
background: radial-gradient(circle, var(--el-color-primary) 0 5px, #fff 6px);
|
||||
}
|
||||
.picker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.picker-text {
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.is-placeholder {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
.arrow {
|
||||
color: #c0c4cc;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.city-hint {
|
||||
color: #c0c4cc;
|
||||
font-size: 14px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.city-hint.is-value {
|
||||
color: #303133;
|
||||
}
|
||||
.range-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.range-sep {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.text-box {
|
||||
min-height: 36px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
line-height: 20px;
|
||||
}
|
||||
.image-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.image-add {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border: 1px dashed #dcdfe6;
|
||||
border-radius: 4px;
|
||||
background: #fafafa;
|
||||
color: #909399;
|
||||
font-size: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.rich-preview {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
min-height: 24px;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
87
seller/src/views/appointment/inStoreSettings.vue
Normal file
87
seller/src/views/appointment/inStoreSettings.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="search appointment-settings-page">
|
||||
<el-card>
|
||||
<el-alert type="info" show-icon :closable="false" class="mb_10">
|
||||
配置到店服务履约节点;改期审核等通用项见上门服务设置。
|
||||
</el-alert>
|
||||
<el-form v-loading="loading" :model="form" label-width="140px" style="max-width: 720px">
|
||||
<el-form-item label="用户预约">
|
||||
<el-switch v-model="form.userBooking" disabled />
|
||||
<span class="hint">必要节点,不可关闭</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="工单展示">
|
||||
<el-switch v-model="form.workOrder" />
|
||||
<span class="hint">关闭后 §15 不可见,工单仍会生成</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="结束服务">
|
||||
<el-switch v-model="form.endService" disabled />
|
||||
<span class="hint">展示态须在工单上结束服务</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="客户改期审核">
|
||||
<el-switch v-model="form.customerRescheduleAuditEnabled" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
<el-button @click="loadData">刷新</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getInStoreSettings, saveInStoreSettings } from "@/api/appointment";
|
||||
|
||||
export default {
|
||||
name: "appointmentInStoreSettings",
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
saving: false,
|
||||
form: {
|
||||
userBooking: true,
|
||||
workOrder: true,
|
||||
endService: true,
|
||||
customerRescheduleAuditEnabled: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.loadData();
|
||||
},
|
||||
methods: {
|
||||
loadData() {
|
||||
this.loading = true;
|
||||
getInStoreSettings()
|
||||
.then((res) => {
|
||||
if (res.success && res.result) {
|
||||
this.form = { ...this.form, ...res.result };
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
handleSave() {
|
||||
this.saving = true;
|
||||
saveInStoreSettings(this.form)
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
this.$message.success("保存成功");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.saving = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hint {
|
||||
margin-left: 8px;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
103
seller/src/views/appointment/onSiteSettings.vue
Normal file
103
seller/src/views/appointment/onSiteSettings.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="search appointment-settings-page">
|
||||
<el-card>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="上门服务流程" name="flow">
|
||||
<el-form v-loading="loading" :model="form" label-width="160px" style="max-width: 760px">
|
||||
<el-form-item label="用户预约">
|
||||
<el-switch v-model="form.userBooking" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="接单">
|
||||
<el-switch v-model="form.acceptOrder" />
|
||||
</el-form-item>
|
||||
<el-form-item label="上门打卡">
|
||||
<el-switch v-model="form.onSiteCheckin" />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务过程留凭">
|
||||
<el-switch v-model="form.serviceEvidence" />
|
||||
</el-form-item>
|
||||
<el-form-item label="结束服务">
|
||||
<el-switch v-model="form.endService" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="客户改期审核">
|
||||
<el-switch v-model="form.customerRescheduleAuditEnabled" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="服务区域" name="area">
|
||||
<el-form v-loading="loading" :model="form" label-width="140px" style="max-width: 760px">
|
||||
<el-form-item label="开启服务区域">
|
||||
<el-switch v-model="form.serviceAreaEnabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="区域说明">
|
||||
<el-input
|
||||
v-model="form.serviceAreaGeo"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="地图选点数据(JSON),后端接入后替换为地图组件"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<div style="margin-top: 12px">
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
<el-button @click="loadData">刷新</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getOnSiteSettings, saveOnSiteSettings } from "@/api/appointment";
|
||||
|
||||
export default {
|
||||
name: "appointmentOnSiteSettings",
|
||||
data() {
|
||||
return {
|
||||
activeTab: "flow",
|
||||
loading: false,
|
||||
saving: false,
|
||||
form: {
|
||||
userBooking: true,
|
||||
acceptOrder: true,
|
||||
onSiteCheckin: true,
|
||||
serviceEvidence: false,
|
||||
endService: true,
|
||||
customerRescheduleAuditEnabled: false,
|
||||
serviceAreaEnabled: false,
|
||||
serviceAreaGeo: "",
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.loadData();
|
||||
},
|
||||
methods: {
|
||||
loadData() {
|
||||
this.loading = true;
|
||||
getOnSiteSettings()
|
||||
.then((res) => {
|
||||
if (res.success && res.result) {
|
||||
this.form = { ...this.form, ...res.result };
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
handleSave() {
|
||||
this.saving = true;
|
||||
saveOnSiteSettings(this.form)
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
this.$message.success("保存成功");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.saving = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
95
seller/src/views/appointment/rescheduleLogList.vue
Normal file
95
seller/src/views/appointment/rescheduleLogList.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="search">
|
||||
<el-card>
|
||||
<el-table v-loading="loading" :data="data" border>
|
||||
<el-table-column prop="orderSn" label="订单编号" min-width="180" />
|
||||
<el-table-column label="原时段" min-width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatSlot(row.fromSlotDate, row.fromStartTime, row.fromEndTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="新时段" min-width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatSlot(row.toSlotDate, row.toStartTime, row.toEndTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="action" label="动作" width="120" />
|
||||
<el-table-column prop="createTime" label="时间" min-width="170" />
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.action === 'APPLY'">
|
||||
<el-button link type="primary" @click="handleApprove(row)">通过</el-button>
|
||||
<el-button link type="danger" @click="handleReject(row)">拒绝</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
class="mt_10"
|
||||
v-model:current-page="pageNumber"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getRescheduleLogPage, approveReschedule, rejectReschedule } from "@/api/appointment";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
|
||||
export default {
|
||||
name: "appointmentRescheduleLogs",
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
data: [],
|
||||
total: 0,
|
||||
pageNumber: 1,
|
||||
pageSize: 10,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.loadData();
|
||||
},
|
||||
methods: {
|
||||
loadData() {
|
||||
this.loading = true;
|
||||
getRescheduleLogPage({ pageNumber: this.pageNumber, pageSize: this.pageSize })
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
this.data = res.result?.records || [];
|
||||
this.total = res.result?.total || 0;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
formatSlot(date, start, end) {
|
||||
const day = date ? String(date).slice(0, 10) : "";
|
||||
return `${day} ${start || ""}-${end || ""}`.trim();
|
||||
},
|
||||
handleApprove(row) {
|
||||
approveReschedule(row.id).then((res) => {
|
||||
if (res.success) {
|
||||
ElMessage.success("已通过");
|
||||
this.loadData();
|
||||
}
|
||||
});
|
||||
},
|
||||
handleReject(row) {
|
||||
ElMessageBox.prompt("拒绝原因(选填)", "拒绝改期", { inputType: "textarea" }).then(({ value }) => {
|
||||
rejectReschedule(row.id, { remark: value || "" }).then((res) => {
|
||||
if (res.success) {
|
||||
ElMessage.success("已拒绝");
|
||||
this.loadData();
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
127
seller/src/views/appointment/serviceCalendar.vue
Normal file
127
seller/src/views/appointment/serviceCalendar.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div class="search appointment-calendar-page">
|
||||
<el-card>
|
||||
<div class="calendar-toolbar">
|
||||
<el-button @click="changeMonth(-1)">上月</el-button>
|
||||
<span class="month-label">{{ monthLabel }}</span>
|
||||
<el-button @click="changeMonth(1)">下月</el-button>
|
||||
<el-button type="primary" link @click="goToday">今天</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="monthDays" border style="width: 100%">
|
||||
<el-table-column prop="date" label="日期" width="140" />
|
||||
<el-table-column prop="headcount" label="预约人数" width="120" />
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDay(row.date)">查看明细</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-drawer v-model="dayVisible" :title="`${activeDate} 预约明细`" size="520px">
|
||||
<el-select v-model="dayStatus" style="width: 180px; margin-bottom: 12px" @change="loadDay">
|
||||
<el-option label="全部" value="ALL" />
|
||||
<el-option label="待到店" value="WAITING_ARRIVAL" />
|
||||
<el-option label="已到店" value="ARRIVED" />
|
||||
<el-option label="已取消" value="CANCELED" />
|
||||
</el-select>
|
||||
<el-table v-loading="dayLoading" :data="dayRows" border size="small">
|
||||
<el-table-column prop="appointmentSn" label="预约单号" min-width="150" />
|
||||
<el-table-column prop="customerName" label="客户" min-width="90" />
|
||||
<el-table-column prop="slotTime" label="时段" min-width="110" />
|
||||
<el-table-column prop="staffName" label="服务人员" min-width="90" />
|
||||
</el-table>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getServiceCalendarMonth, getServiceCalendarDay } from "@/api/appointment";
|
||||
|
||||
function formatMonth(date) {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||
return `${y}-${m}`;
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "appointmentServiceCalendar",
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
currentMonth: formatMonth(new Date()),
|
||||
monthDays: [],
|
||||
dayVisible: false,
|
||||
dayLoading: false,
|
||||
activeDate: "",
|
||||
dayStatus: "ALL",
|
||||
dayRows: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
monthLabel() {
|
||||
return this.currentMonth;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.loadMonth();
|
||||
},
|
||||
methods: {
|
||||
goToday() {
|
||||
this.currentMonth = formatMonth(new Date());
|
||||
this.loadMonth();
|
||||
},
|
||||
changeMonth(delta) {
|
||||
const [y, m] = this.currentMonth.split("-").map(Number);
|
||||
const date = new Date(y, m - 1 + delta, 1);
|
||||
this.currentMonth = formatMonth(date);
|
||||
this.loadMonth();
|
||||
},
|
||||
loadMonth() {
|
||||
this.loading = true;
|
||||
getServiceCalendarMonth(this.currentMonth)
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
this.monthDays = res.result?.days || res.result || [];
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
openDay(date) {
|
||||
this.activeDate = date;
|
||||
this.dayStatus = "ALL";
|
||||
this.dayVisible = true;
|
||||
this.loadDay();
|
||||
},
|
||||
loadDay() {
|
||||
if (!this.activeDate) return;
|
||||
this.dayLoading = true;
|
||||
getServiceCalendarDay({ date: this.activeDate, status: this.dayStatus })
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
this.dayRows = res.result?.records || res.result || [];
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.dayLoading = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.calendar-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.month-label {
|
||||
font-weight: 600;
|
||||
min-width: 90px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
146
seller/src/views/appointment/serviceStaffList.vue
Normal file
146
seller/src/views/appointment/serviceStaffList.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="search">
|
||||
<el-card>
|
||||
<el-form ref="searchForm" :model="searchForm" inline label-width="80px" @keyup.enter="handleSearch">
|
||||
<el-form-item label="员工姓名" prop="staffName">
|
||||
<el-input v-model="searchForm.staffName" clearable placeholder="模糊搜索" style="width: 220px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="searchForm.status" clearable placeholder="全部" style="width: 160px">
|
||||
<el-option label="开启" value="ENABLED" />
|
||||
<el-option label="关闭" value="DISABLED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button type="success" @click="openCreate">新增服务人员</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<el-table v-loading="loading" :data="data" border style="width: 100%">
|
||||
<el-table-column prop="staffName" label="员工姓名" min-width="120" />
|
||||
<el-table-column prop="nickname" label="昵称" min-width="120" />
|
||||
<el-table-column prop="contactPhone" label="联系电话" min-width="130" />
|
||||
<el-table-column prop="sortOrder" label="排序" width="80" />
|
||||
<el-table-column label="人员状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
:model-value="row.status === 'ENABLED'"
|
||||
@change="(val) => toggleStatus(row, val)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" width="170" />
|
||||
</el-table>
|
||||
<div class="mt_10" style="display: flex; justify-content: flex-end">
|
||||
<el-pagination
|
||||
v-model:current-page="searchForm.pageNumber"
|
||||
v-model:page-size="searchForm.pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
size="small"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="createVisible" title="新增服务人员" width="480px" destroy-on-close>
|
||||
<el-form :model="createForm" label-width="100px">
|
||||
<el-form-item label="店员用户ID">
|
||||
<el-input v-model="createForm.clerkUserId" placeholder="关联店员 clerk_user_id" />
|
||||
</el-form-item>
|
||||
<el-form-item label="员工姓名">
|
||||
<el-input v-model="createForm.staffName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系电话">
|
||||
<el-input v-model="createForm.contactPhone" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="createLoading" @click="submitCreate">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
getServiceStaffPage,
|
||||
createServiceStaff,
|
||||
updateServiceStaffStatus,
|
||||
} from "@/api/appointment";
|
||||
|
||||
export default {
|
||||
name: "appointmentServiceStaffList",
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
data: [],
|
||||
total: 0,
|
||||
searchForm: {
|
||||
pageNumber: 1,
|
||||
pageSize: 20,
|
||||
staffName: "",
|
||||
status: "",
|
||||
},
|
||||
createVisible: false,
|
||||
createLoading: false,
|
||||
createForm: {
|
||||
clerkUserId: "",
|
||||
staffName: "",
|
||||
contactPhone: "",
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.loadData();
|
||||
},
|
||||
methods: {
|
||||
handleSearch() {
|
||||
this.searchForm.pageNumber = 1;
|
||||
this.loadData();
|
||||
},
|
||||
loadData() {
|
||||
this.loading = true;
|
||||
getServiceStaffPage(this.searchForm)
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
this.data = res.result?.records || [];
|
||||
this.total = res.result?.total || 0;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
openCreate() {
|
||||
this.createForm = { clerkUserId: "", staffName: "", contactPhone: "" };
|
||||
this.createVisible = true;
|
||||
},
|
||||
submitCreate() {
|
||||
this.createLoading = true;
|
||||
createServiceStaff(this.createForm)
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
this.$message.success("创建成功");
|
||||
this.createVisible = false;
|
||||
this.loadData();
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.createLoading = false;
|
||||
});
|
||||
},
|
||||
toggleStatus(row, enabled) {
|
||||
updateServiceStaffStatus(row.id, enabled ? "ENABLED" : "DISABLED").then((res) => {
|
||||
if (res.success) {
|
||||
row.status = enabled ? "ENABLED" : "DISABLED";
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
849
seller/src/views/appointment/systemFormDesigner.vue
Normal file
849
seller/src/views/appointment/systemFormDesigner.vue
Normal file
@@ -0,0 +1,849 @@
|
||||
<template>
|
||||
<div class="form-designer" v-loading="loading">
|
||||
<div class="designer-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="current-page">表单名称:</span>
|
||||
<el-input
|
||||
v-model="formName"
|
||||
class="form-name-input"
|
||||
maxlength="40"
|
||||
placeholder="请输入表单名称"
|
||||
/>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存并发布</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="designer-body">
|
||||
<aside class="palette">
|
||||
<div class="panel-title">组件</div>
|
||||
<div class="palette-grid">
|
||||
<button
|
||||
v-for="item in palette"
|
||||
:key="item.type"
|
||||
type="button"
|
||||
class="palette-item"
|
||||
@click="addField(item.type)"
|
||||
>
|
||||
<el-icon class="palette-icon" :size="16">
|
||||
<component :is="paletteIcons[item.type]" />
|
||||
</el-icon>
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="canvas-wrap">
|
||||
<div class="phone">
|
||||
<div class="phone-bar">{{ formName || "标题" }}</div>
|
||||
<div
|
||||
v-for="(field, index) in fields"
|
||||
:key="field.field_key"
|
||||
class="canvas-item"
|
||||
:class="{ 'is-selected': selectedKey === field.field_key }"
|
||||
@click="selectedKey = field.field_key"
|
||||
>
|
||||
<div class="canvas-item-body">
|
||||
<system-form-field-preview :field="field" />
|
||||
</div>
|
||||
<div v-if="selectedKey === field.field_key" class="item-actions" @click.stop>
|
||||
<button type="button" title="删除" @click="removeField(index)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
<button type="button" title="复制" @click="copyField(index)">
|
||||
<el-icon><CopyDocument /></el-icon>
|
||||
</button>
|
||||
<button type="button" title="上移" :disabled="index === 0" @click="moveField(index, -1)">
|
||||
<el-icon><ArrowUp /></el-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="下移"
|
||||
:disabled="index === fields.length - 1"
|
||||
@click="moveField(index, 1)"
|
||||
>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="props-panel">
|
||||
<template v-if="selected">
|
||||
<div class="panel-title">{{ fieldTypeLabel(selected.field_type) }}</div>
|
||||
<el-form label-width="88px" label-position="left" class="props-form">
|
||||
<el-form-item v-if="selected.field_type !== 'RICH_TEXT'" label="标题">
|
||||
<el-input v-model="selected.field_label" :maxlength="FIELD_LABEL_MAX" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="hasOptions(selected.field_type)" label="选项">
|
||||
<div class="option-list">
|
||||
<div
|
||||
v-for="(opt, oi) in selected.options"
|
||||
:key="opt.value"
|
||||
class="option-row"
|
||||
>
|
||||
<el-input
|
||||
v-model="opt.label"
|
||||
:maxlength="OPTION_LABEL_MAX"
|
||||
:placeholder="oi >= 2 ? '选填,不超过十个字' : '选项文案'"
|
||||
/>
|
||||
<el-button
|
||||
v-if="oi >= 2"
|
||||
link
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
@click="selected.options = removeOption(selected.options, oi)"
|
||||
/>
|
||||
</div>
|
||||
<div class="option-actions">
|
||||
<el-button
|
||||
class="option-action-btn"
|
||||
:disabled="(selected.options || []).length >= OPTION_MAX"
|
||||
@click="selected.options = addOption(selected.options, { radio: selected.field_type === 'RADIO' })"
|
||||
>添加单个选项</el-button>
|
||||
<span class="option-action-btn">
|
||||
<el-popover
|
||||
v-model:visible="batchVisible"
|
||||
placement="bottom"
|
||||
:width="280"
|
||||
trigger="click"
|
||||
:disabled="(selected.options || []).length >= OPTION_MAX"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button :disabled="(selected.options || []).length >= OPTION_MAX">
|
||||
批量添加选项
|
||||
</el-button>
|
||||
</template>
|
||||
<div class="batch-title">批量添加选项</div>
|
||||
<p class="batch-hint">可按回车键添加多个选项</p>
|
||||
<el-input v-model="batchText" type="textarea" :rows="6" />
|
||||
<div class="batch-footer">
|
||||
<el-button @click="closeBatch">取消</el-button>
|
||||
<el-button type="primary" @click="confirmBatch">确定</el-button>
|
||||
</div>
|
||||
</el-popover>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="selected.field_type === 'CITY'" label="默认值">
|
||||
<el-radio-group v-model="selected.city_level">
|
||||
<el-radio value="PROVINCE_CITY">省市</el-radio>
|
||||
<el-radio value="PROVINCE_CITY_DISTRICT">省市区</el-radio>
|
||||
<el-radio value="PROVINCE_CITY_DISTRICT_STREET">省市区街道</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="selected.field_type === 'TEXT'" label="内容">
|
||||
<el-radio-group v-model="selected.content_type" class="content-type-group">
|
||||
<el-radio value="TEXT">文本</el-radio>
|
||||
<el-radio value="MOBILE">手机号</el-radio>
|
||||
<el-radio value="ID_CARD">身份证号</el-radio>
|
||||
<el-radio value="EMAIL">邮箱</el-radio>
|
||||
<el-radio value="NUMBER">数字</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="selected.field_type === 'TEXT'" label="默认值">
|
||||
<el-input v-model="selected.default_value" placeholder="请输入默认值" />
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="['DATE', 'DATE_RANGE', 'TIME', 'TIME_RANGE'].includes(selected.field_type)">
|
||||
<el-form-item label="默认值">
|
||||
<el-radio-group v-model="selected.default_visibility">
|
||||
<el-radio value="SHOW">显示</el-radio>
|
||||
<el-radio value="HIDE">隐藏</el-radio>
|
||||
</el-radio-group>
|
||||
<div v-if="selected.default_visibility === 'SHOW'" class="sub-radios">
|
||||
<el-radio-group
|
||||
v-if="selected.field_type === 'DATE' || selected.field_type === 'DATE_RANGE'"
|
||||
v-model="selected.default_date_mode"
|
||||
>
|
||||
<el-radio value="CURRENT_DATE">当前日期</el-radio>
|
||||
<el-radio value="SPECIFIED_DATE">指定日期</el-radio>
|
||||
</el-radio-group>
|
||||
<el-radio-group v-else v-model="selected.default_time_mode">
|
||||
<el-radio value="CURRENT_TIME">当前时间</el-radio>
|
||||
<el-radio value="SPECIFIED_TIME">指定时间</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div
|
||||
v-if="selected.default_visibility === 'SHOW' && selected.default_date_mode === 'SPECIFIED_DATE'"
|
||||
class="sub-radios"
|
||||
>
|
||||
<el-date-picker
|
||||
v-if="selected.field_type === 'DATE'"
|
||||
v-model="selected.default_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="指定日期"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<el-date-picker
|
||||
v-else-if="selected.field_type === 'DATE_RANGE'"
|
||||
v-model="dateRangeValue"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="selected.default_visibility === 'SHOW' && selected.default_time_mode === 'SPECIFIED_TIME'"
|
||||
class="sub-radios"
|
||||
>
|
||||
<el-time-picker
|
||||
v-if="selected.field_type === 'TIME'"
|
||||
v-model="selected.default_time"
|
||||
value-format="HH:mm"
|
||||
format="HH:mm"
|
||||
placeholder="指定时间"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<el-time-picker
|
||||
v-else-if="selected.field_type === 'TIME_RANGE'"
|
||||
v-model="timeRangeValue"
|
||||
is-range
|
||||
value-format="HH:mm"
|
||||
format="HH:mm"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-form-item
|
||||
v-if="['CITY', 'DATE', 'DATE_RANGE', 'TIME', 'TIME_RANGE', 'TEXT'].includes(selected.field_type)"
|
||||
label="提示语"
|
||||
>
|
||||
<el-input
|
||||
v-model="selected.hint_text"
|
||||
:maxlength="HINT_TEXT_MAX"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="selected.field_type === 'IMAGE'" label="最多上传">
|
||||
<el-input-number
|
||||
v-model="selected.max_upload_count"
|
||||
:min="1"
|
||||
:max="10"
|
||||
controls-position="right"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<div v-if="selected.field_type === 'RICH_TEXT'" class="rich-props">
|
||||
<div class="rich-props-label">富文本内容</div>
|
||||
<editor v-model="selected.rich_text_content" height="360px" :key="selected.field_key" />
|
||||
</div>
|
||||
|
||||
<el-form-item v-if="hasRequiredSwitch(selected.field_type)" label="是否必填">
|
||||
<el-switch
|
||||
v-model="selected.required"
|
||||
class="required-switch"
|
||||
inline-prompt
|
||||
active-text="是"
|
||||
inactive-text="否"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import {
|
||||
Delete,
|
||||
ArrowUp,
|
||||
CopyDocument,
|
||||
Checked,
|
||||
Location,
|
||||
CircleCheck,
|
||||
Calendar,
|
||||
ArrowDown,
|
||||
EditPen,
|
||||
Clock,
|
||||
Timer,
|
||||
Document,
|
||||
Picture,
|
||||
Tickets,
|
||||
} from "@element-plus/icons-vue";
|
||||
import { getRequest, postRequestWithNoForm } from "@/libs/axios";
|
||||
import editor from "@/views/lili-components/editor/index.vue";
|
||||
import SystemFormFieldPreview from "./components/SystemFormFieldPreview.vue";
|
||||
import {
|
||||
FORM_FIELD_PALETTE,
|
||||
FORM_FIELD_LABEL,
|
||||
OPTION_MAX,
|
||||
FIELD_LABEL_MAX,
|
||||
OPTION_LABEL_MAX,
|
||||
HINT_TEXT_MAX,
|
||||
createField,
|
||||
createFieldKey,
|
||||
parseFormSchema,
|
||||
serializeFormSchema,
|
||||
addOption,
|
||||
removeOption,
|
||||
batchAddOptions,
|
||||
hasRequiredSwitch,
|
||||
hasOptions,
|
||||
} from "@/constants/systemForm";
|
||||
|
||||
export default {
|
||||
name: "appointmentSystemFormDesigner",
|
||||
components: { editor, SystemFormFieldPreview, Delete, CopyDocument, ArrowUp, ArrowDown },
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
saving: false,
|
||||
formId: "",
|
||||
formName: "",
|
||||
fields: [],
|
||||
selectedKey: "",
|
||||
baseline: "",
|
||||
batchVisible: false,
|
||||
batchText: "",
|
||||
palette: FORM_FIELD_PALETTE,
|
||||
paletteIcons: {
|
||||
CHECKBOX: Checked,
|
||||
CITY: Location,
|
||||
RADIO: CircleCheck,
|
||||
DATE: Calendar,
|
||||
DATE_RANGE: Tickets,
|
||||
SELECT: ArrowDown,
|
||||
TEXT: EditPen,
|
||||
TIME: Clock,
|
||||
TIME_RANGE: Timer,
|
||||
RICH_TEXT: Document,
|
||||
IMAGE: Picture,
|
||||
},
|
||||
OPTION_MAX,
|
||||
FIELD_LABEL_MAX,
|
||||
OPTION_LABEL_MAX,
|
||||
HINT_TEXT_MAX,
|
||||
Delete,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
selected() {
|
||||
return this.fields.find((item) => item.field_key === this.selectedKey) || null;
|
||||
},
|
||||
dateRangeValue: {
|
||||
get() {
|
||||
const field = this.selected;
|
||||
if (!field) return null;
|
||||
return field.default_date_start && field.default_date_end
|
||||
? [field.default_date_start, field.default_date_end]
|
||||
: null;
|
||||
},
|
||||
set(val) {
|
||||
if (!this.selected) return;
|
||||
this.selected.default_date_start = val?.[0] || null;
|
||||
this.selected.default_date_end = val?.[1] || null;
|
||||
},
|
||||
},
|
||||
timeRangeValue: {
|
||||
get() {
|
||||
const field = this.selected;
|
||||
if (!field) return null;
|
||||
return field.default_time_start && field.default_time_end
|
||||
? [field.default_time_start, field.default_time_end]
|
||||
: null;
|
||||
},
|
||||
set(val) {
|
||||
if (!this.selected) return;
|
||||
this.selected.default_time_start = val?.[0] || null;
|
||||
this.selected.default_time_end = val?.[1] || null;
|
||||
},
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selectedKey() {
|
||||
this.closeBatch();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.formId = this.$route.query.id || "";
|
||||
if (this.formId) {
|
||||
this.loadForm();
|
||||
} else {
|
||||
this.captureBaseline();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addOption,
|
||||
removeOption,
|
||||
hasRequiredSwitch,
|
||||
hasOptions,
|
||||
fieldTypeLabel(type) {
|
||||
return FORM_FIELD_LABEL[type] || type;
|
||||
},
|
||||
captureBaseline() {
|
||||
this.baseline = JSON.stringify({
|
||||
formName: this.formName,
|
||||
fields: this.fields,
|
||||
});
|
||||
},
|
||||
loadForm() {
|
||||
this.loading = true;
|
||||
getRequest(`/appointment/system-forms/${this.formId}`)
|
||||
.then((res) => {
|
||||
if (!res.success || !res.result) {
|
||||
ElMessage.error("表单不存在");
|
||||
this.$router.push({ name: "appointment-system-forms" });
|
||||
return;
|
||||
}
|
||||
this.formName = res.result.formName || "";
|
||||
this.fields = parseFormSchema(res.result.formSchema);
|
||||
this.selectedKey = this.fields[0]?.field_key || "";
|
||||
this.captureBaseline();
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessage.error("加载表单失败");
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
addField(type) {
|
||||
const field = createField(type);
|
||||
this.fields.push(field);
|
||||
this.selectedKey = field.field_key;
|
||||
},
|
||||
removeField(index) {
|
||||
this.fields.splice(index, 1);
|
||||
this.selectedKey = this.fields[index]?.field_key || this.fields[index - 1]?.field_key || "";
|
||||
},
|
||||
copyField(index) {
|
||||
const source = this.fields[index];
|
||||
if (!source) return;
|
||||
const cloned = JSON.parse(JSON.stringify(source));
|
||||
cloned.field_key = createFieldKey();
|
||||
if (Array.isArray(cloned.options)) {
|
||||
cloned.options = cloned.options.map((opt) => ({
|
||||
...opt,
|
||||
value: createFieldKey(), // 避免与原字段共用 option value
|
||||
}));
|
||||
}
|
||||
this.fields.splice(index + 1, 0, cloned);
|
||||
this.selectedKey = cloned.field_key;
|
||||
},
|
||||
moveField(index, step) {
|
||||
const target = index + step;
|
||||
if (target < 0 || target >= this.fields.length) return;
|
||||
const list = [...this.fields];
|
||||
const [item] = list.splice(index, 1);
|
||||
list.splice(target, 0, item);
|
||||
this.fields = list;
|
||||
},
|
||||
closeBatch() {
|
||||
this.batchVisible = false;
|
||||
this.batchText = "";
|
||||
},
|
||||
confirmBatch() {
|
||||
if (!this.selected) return;
|
||||
this.selected.options = batchAddOptions(this.selected.options, this.batchText, {
|
||||
radio: this.selected.field_type === "RADIO",
|
||||
});
|
||||
this.closeBatch();
|
||||
},
|
||||
handleReset() {
|
||||
ElMessageBox.confirm("将撤销本次未保存的编辑,恢复进入页面时的内容?", "重置", {
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
const snap = JSON.parse(this.baseline || "{}");
|
||||
this.formName = snap.formName || "";
|
||||
this.fields = JSON.parse(JSON.stringify(snap.fields || []));
|
||||
this.selectedKey = this.fields[0]?.field_key || "";
|
||||
}).catch(() => {});
|
||||
},
|
||||
validate() {
|
||||
if (!this.formName.trim()) {
|
||||
ElMessage.error("请填写表单名称");
|
||||
return false;
|
||||
}
|
||||
if (!this.fields.length) {
|
||||
ElMessage.error("请至少添加一个表单组件");
|
||||
return false;
|
||||
}
|
||||
for (const field of this.fields) {
|
||||
if (field.field_type !== "RICH_TEXT") {
|
||||
const label = (field.field_label || "").trim();
|
||||
if (!label) {
|
||||
ElMessage.error("请填写组件标题");
|
||||
return false;
|
||||
}
|
||||
if (label.length > FIELD_LABEL_MAX) {
|
||||
ElMessage.error(`组件标题最多 ${FIELD_LABEL_MAX} 个字`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (hasOptions(field.field_type)) {
|
||||
const labels = (field.options || []).map((item) => item.label?.trim()).filter(Boolean);
|
||||
if (labels.length < 2) {
|
||||
ElMessage.error(`${field.field_label || "选项组件"}至少需要 2 个选项`);
|
||||
return false;
|
||||
}
|
||||
if ((field.options || []).some((item) => (item.label || "").length > OPTION_LABEL_MAX)) {
|
||||
ElMessage.error("选项最多 10 个字");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (field.hint_text && field.hint_text.length > HINT_TEXT_MAX) {
|
||||
ElMessage.error("提示语最多 10 个字");
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
field.default_visibility === "SHOW" &&
|
||||
field.default_date_mode === "SPECIFIED_DATE" &&
|
||||
field.field_type === "DATE" &&
|
||||
!field.default_date
|
||||
) {
|
||||
ElMessage.error("请为日期组件指定默认日期");
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
field.default_visibility === "SHOW" &&
|
||||
field.default_date_mode === "SPECIFIED_DATE" &&
|
||||
field.field_type === "DATE_RANGE"
|
||||
) {
|
||||
if (!field.default_date_start || !field.default_date_end) {
|
||||
ElMessage.error("请为日期范围指定起止日期");
|
||||
return false;
|
||||
}
|
||||
if (field.default_date_start > field.default_date_end) {
|
||||
ElMessage.error("日期范围的结束日期不能早于开始日期");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (
|
||||
field.default_visibility === "SHOW" &&
|
||||
field.default_time_mode === "SPECIFIED_TIME" &&
|
||||
field.field_type === "TIME" &&
|
||||
!field.default_time
|
||||
) {
|
||||
ElMessage.error("请为时间组件指定默认时间");
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
field.default_visibility === "SHOW" &&
|
||||
field.default_time_mode === "SPECIFIED_TIME" &&
|
||||
field.field_type === "TIME_RANGE"
|
||||
) {
|
||||
if (!field.default_time_start || !field.default_time_end) {
|
||||
ElMessage.error("请为时间范围指定起止时间");
|
||||
return false;
|
||||
}
|
||||
if (field.default_time_start > field.default_time_end) {
|
||||
ElMessage.error("时间范围的结束时间不能早于开始时间");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
handleSave() {
|
||||
if (!this.validate()) return;
|
||||
this.saving = true;
|
||||
postRequestWithNoForm("/appointment/system-forms", {
|
||||
id: this.formId || undefined,
|
||||
formName: this.formName.trim(),
|
||||
formSchema: serializeFormSchema(this.fields),
|
||||
status: "ENABLED",
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
ElMessage.success("已保存并发布");
|
||||
this.formId = res.result?.id || this.formId;
|
||||
this.captureBaseline();
|
||||
this.$router.push({ name: "appointment-system-forms" });
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.saving = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-designer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 126px); /* 顶栏 106px + 页边距 */
|
||||
min-height: calc(100vh - 126px);
|
||||
background: #f5f6f8;
|
||||
margin: -10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.designer-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.current-page {
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.form-name-input {
|
||||
width: 240px;
|
||||
}
|
||||
.designer-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
.palette {
|
||||
width: 280px;
|
||||
background: #fff;
|
||||
overflow: auto;
|
||||
padding: 12px 16px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: stretch;
|
||||
}
|
||||
.props-panel {
|
||||
width: 400px;
|
||||
background: #fff;
|
||||
overflow: auto;
|
||||
padding: 12px 16px 24px;
|
||||
align-self: stretch;
|
||||
}
|
||||
.panel-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.palette-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.palette-item {
|
||||
height: 40px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
color: #303133;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.palette-icon {
|
||||
flex-shrink: 0;
|
||||
color: inherit;
|
||||
}
|
||||
.palette-item:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.canvas-wrap {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24px 64px 24px 16px;
|
||||
}
|
||||
.phone {
|
||||
width: 375px;
|
||||
min-height: 640px;
|
||||
background: #fff;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.08);
|
||||
padding-bottom: 24px;
|
||||
overflow: visible;
|
||||
}
|
||||
.phone-bar {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.canvas-item {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.canvas-item.is-selected {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
.item-actions {
|
||||
position: absolute;
|
||||
left: calc(100% + 12px);
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--el-color-primary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.28);
|
||||
}
|
||||
.item-actions button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.item-actions button + button {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
.item-actions button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.props-form :deep(.el-form-item) {
|
||||
margin-bottom: 14px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.props-form :deep(.el-form-item__label) {
|
||||
line-height: 32px;
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
padding-left: 0;
|
||||
}
|
||||
.option-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.option-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.option-row :deep(.el-input) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.option-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
.option-action-btn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.option-action-btn :deep(.el-button) {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.batch-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.batch-hint {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.batch-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.sub-radios {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.props-form :deep(.el-form-item__content) {
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
.content-type-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
justify-items: start;
|
||||
column-gap: 8px;
|
||||
row-gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.content-type-group :deep(.el-radio) {
|
||||
margin-right: 0;
|
||||
}
|
||||
.required-switch {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
}
|
||||
.required-switch :deep(.el-switch__core) {
|
||||
min-width: 56px;
|
||||
height: 28px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.required-switch :deep(.el-switch__core .el-switch__action) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.required-switch.is-checked :deep(.el-switch__core .el-switch__action) {
|
||||
left: calc(100% - 25px);
|
||||
}
|
||||
.required-switch :deep(.el-switch__inner) {
|
||||
font-size: 13px;
|
||||
padding: 0 8px 0 22px;
|
||||
}
|
||||
.required-switch.is-checked :deep(.el-switch__inner) {
|
||||
padding: 0 22px 0 8px;
|
||||
}
|
||||
.rich-props {
|
||||
width: 100%;
|
||||
}
|
||||
.rich-props-label {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
line-height: 22px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.rich-props :deep(.tinymce-container),
|
||||
.rich-props :deep(.tox-tinymce) {
|
||||
width: 100% !important;
|
||||
}
|
||||
.rich-props :deep(.tox-menubar) {
|
||||
display: none; /* 侧栏放不下完整菜单 */
|
||||
}
|
||||
.rich-props :deep(.tox-toolbar-overlord),
|
||||
.rich-props :deep(.tox-toolbar) {
|
||||
width: 100%;
|
||||
}
|
||||
.rich-props :deep(.tox-editor-header) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
137
seller/src/views/appointment/systemFormList.vue
Normal file
137
seller/src/views/appointment/systemFormList.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div class="search">
|
||||
<el-card>
|
||||
<el-form :model="searchForm" inline label-width="80px" @keyup.enter="handleSearch">
|
||||
<el-form-item label="表单名称">
|
||||
<el-input v-model="searchForm.formName" clearable placeholder="模糊匹配" style="width: 220px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="searchForm.status" clearable placeholder="全部" style="width: 160px">
|
||||
<el-option label="启用" value="ENABLED" />
|
||||
<el-option label="停用" value="DISABLED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
<el-button type="success" @click="goDesigner()">新建</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="mt_10">
|
||||
<el-table v-loading="loading" :data="data" border>
|
||||
<el-table-column prop="formName" label="表单名称" min-width="180" />
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
:key="`${row.id}-${row.status}`"
|
||||
:model-value="row.status === 'ENABLED'"
|
||||
@change="(val) => toggleStatus(row, val)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="字段数" width="90">
|
||||
<template #default="{ row }">{{ fieldCount(row.formSchema) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updateTime" label="更新时间" min-width="170" />
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="goDesigner(row.id)">编辑</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
class="mt_10"
|
||||
v-model:current-page="searchForm.pageNumber"
|
||||
v-model:page-size="searchForm.pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getRequest, postRequestWithNoForm, deleteRequest } from "@/libs/axios";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { countFormFields } from "@/constants/systemForm";
|
||||
|
||||
export default {
|
||||
name: "appointmentSystemFormList",
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
data: [],
|
||||
total: 0,
|
||||
searchForm: {
|
||||
formName: "",
|
||||
status: "",
|
||||
pageNumber: 1,
|
||||
pageSize: 10,
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.loadData();
|
||||
},
|
||||
methods: {
|
||||
fieldCount: countFormFields,
|
||||
handleSearch() {
|
||||
this.searchForm.pageNumber = 1;
|
||||
this.loadData();
|
||||
},
|
||||
handleReset() {
|
||||
this.searchForm.formName = "";
|
||||
this.searchForm.status = "";
|
||||
this.searchForm.pageNumber = 1;
|
||||
this.loadData();
|
||||
},
|
||||
loadData() {
|
||||
this.loading = true;
|
||||
getRequest("/appointment/system-forms", this.searchForm)
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
this.data = res.result?.records || [];
|
||||
this.total = res.result?.total || 0;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
goDesigner(id) {
|
||||
this.$router.push({
|
||||
name: "appointment-system-form-designer",
|
||||
query: id ? { id } : {},
|
||||
});
|
||||
},
|
||||
toggleStatus(row, enabled) {
|
||||
const status = enabled ? "ENABLED" : "DISABLED";
|
||||
postRequestWithNoForm("/appointment/system-forms", {
|
||||
id: row.id,
|
||||
formName: row.formName,
|
||||
formSchema: row.formSchema || "[]",
|
||||
status,
|
||||
}).then((res) => {
|
||||
if (res.success) {
|
||||
row.status = status;
|
||||
ElMessage.success(enabled ? "已启用" : "已停用");
|
||||
}
|
||||
});
|
||||
},
|
||||
handleDelete(row) {
|
||||
ElMessageBox.confirm("确认删除该表单?", "提示", { type: "warning" }).then(() => {
|
||||
deleteRequest(`/appointment/system-forms/${row.id}`).then((res) => {
|
||||
if (res.success) {
|
||||
ElMessage.success("已删除");
|
||||
this.loadData();
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
184
seller/src/views/appointment/workOrderList.vue
Normal file
184
seller/src/views/appointment/workOrderList.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div class="search">
|
||||
<el-card>
|
||||
<el-form ref="searchForm" :model="searchForm" inline label-width="80px" @keyup.enter="handleSearch">
|
||||
<el-form-item label="工单编号" prop="workOrderSn">
|
||||
<el-input v-model="searchForm.workOrderSn" clearable style="width: 200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="订单编号" prop="orderSn">
|
||||
<el-input v-model="searchForm.orderSn" clearable style="width: 200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="预约类型" prop="serviceMode">
|
||||
<el-select v-model="searchForm.serviceMode" clearable style="width: 160px">
|
||||
<el-option label="到店" value="IN_STORE" />
|
||||
<el-option label="上门" value="ON_SITE" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<el-tabs v-model="statusTab" @tab-click="onTabClick">
|
||||
<el-tab-pane
|
||||
v-for="item in statusTabs"
|
||||
:key="item.value"
|
||||
:label="tabLabel(item)"
|
||||
:name="item.value"
|
||||
/>
|
||||
</el-tabs>
|
||||
<el-table v-loading="loading" :data="data" border class="mt_10" style="width: 100%">
|
||||
<el-table-column prop="workOrderSn" label="工单编号" min-width="180" />
|
||||
<el-table-column prop="orderSn" label="订单编号" min-width="180" />
|
||||
<el-table-column label="预约类型" width="100">
|
||||
<template #default="{ row }">{{ serviceModeText(row.serviceMode) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="appointmentTime" label="预约时间" min-width="170" />
|
||||
<el-table-column prop="staffName" label="服务人员" min-width="110" />
|
||||
<el-table-column label="服务状态" width="110">
|
||||
<template #default="{ row }">{{ statusText(row.serviceStatus) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.serviceStatus === 'PENDING_CLAIM'"
|
||||
link
|
||||
type="primary"
|
||||
@click="handleClaim(row)"
|
||||
>
|
||||
领取
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.serviceStatus === 'IN_SERVICE' && row.serviceMode === 'IN_STORE'"
|
||||
link
|
||||
type="primary"
|
||||
@click="handleEnd(row)"
|
||||
>
|
||||
结束服务
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="mt_10" style="display: flex; justify-content: flex-end">
|
||||
<el-pagination
|
||||
v-model:current-page="searchForm.pageNumber"
|
||||
v-model:page-size="searchForm.pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
size="small"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
getWorkOrderPage,
|
||||
getWorkOrderStatusCounts,
|
||||
claimWorkOrder,
|
||||
endWorkOrderService,
|
||||
} from "@/api/appointment";
|
||||
import { SERVICE_MODE_LABEL, WORK_ORDER_STATUS_LABEL } from "@/constants/appointment";
|
||||
|
||||
export default {
|
||||
name: "appointmentWorkOrderList",
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
data: [],
|
||||
total: 0,
|
||||
statusTab: "ALL",
|
||||
statusCounts: {},
|
||||
searchForm: {
|
||||
pageNumber: 1,
|
||||
pageSize: 20,
|
||||
serviceStatus: "",
|
||||
workOrderSn: "",
|
||||
orderSn: "",
|
||||
serviceMode: "",
|
||||
},
|
||||
statusTabs: [
|
||||
{ value: "ALL", label: "全部" },
|
||||
{ value: "PENDING_CLAIM", label: "待领取" },
|
||||
{ value: "WAIT_VERIFY", label: "待核销" },
|
||||
{ value: "IN_SERVICE", label: "服务中" },
|
||||
{ value: "SERVICE_ENDED", label: "服务结束" },
|
||||
{ value: "REFUNDED", label: "已退款" },
|
||||
],
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
if (this.$route.query.tab) {
|
||||
this.statusTab = this.$route.query.tab;
|
||||
this.searchForm.serviceStatus = this.$route.query.tab === "ALL" ? "" : this.$route.query.tab;
|
||||
}
|
||||
this.loadCounts();
|
||||
this.loadData();
|
||||
},
|
||||
methods: {
|
||||
serviceModeText(v) {
|
||||
return SERVICE_MODE_LABEL[v] || v || "—";
|
||||
},
|
||||
statusText(v) {
|
||||
return WORK_ORDER_STATUS_LABEL[v] || v || "—";
|
||||
},
|
||||
tabLabel(item) {
|
||||
const count = this.statusCounts[item.value];
|
||||
return count != null ? `${item.label}(${count})` : item.label;
|
||||
},
|
||||
onTabClick(tab) {
|
||||
this.searchForm.serviceStatus = tab.paneName === "ALL" ? "" : tab.paneName;
|
||||
this.searchForm.pageNumber = 1;
|
||||
this.loadData();
|
||||
},
|
||||
handleSearch() {
|
||||
this.searchForm.pageNumber = 1;
|
||||
this.loadData();
|
||||
},
|
||||
loadCounts() {
|
||||
getWorkOrderStatusCounts()
|
||||
.then((res) => {
|
||||
if (res.success && res.result) {
|
||||
this.statusCounts = res.result;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
loadData() {
|
||||
this.loading = true;
|
||||
getWorkOrderPage(this.searchForm)
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
this.data = res.result?.records || [];
|
||||
this.total = res.result?.total || 0;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
handleClaim(row) {
|
||||
claimWorkOrder(row.id).then((res) => {
|
||||
if (res.success) {
|
||||
this.$message.success("领取成功");
|
||||
this.loadCounts();
|
||||
this.loadData();
|
||||
}
|
||||
});
|
||||
},
|
||||
handleEnd(row) {
|
||||
endWorkOrderService(row.id).then((res) => {
|
||||
if (res.success) {
|
||||
this.$message.success("已结束服务");
|
||||
this.loadCounts();
|
||||
this.loadData();
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,836 @@
|
||||
<template>
|
||||
<div class="appointment-publish-panel">
|
||||
<div class="form-item-view">
|
||||
<el-form-item label="服务模式" required>
|
||||
<div class="service-mode-field">
|
||||
<el-checkbox-group v-model="localSettings.serviceModes">
|
||||
<el-checkbox :value="SERVICE_MODE.IN_STORE">到店服务</el-checkbox>
|
||||
<el-checkbox :value="SERVICE_MODE.ON_SITE">上门服务</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<span class="field-hint block-hint">用户购买此预约服务商品,可以选择的服务方式</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="剩余可约数量" required>
|
||||
<div class="service-mode-field">
|
||||
<el-switch v-model="localSettings.showRemainingQty" />
|
||||
<span class="field-hint block-hint">关闭后,用户无法查看各时段的剩余预约数量</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="可预约日期" required>
|
||||
<div class="service-mode-field">
|
||||
<el-radio-group v-model="localSettings.bookableDateMode">
|
||||
<el-radio :value="BOOKABLE_DATE_MODE.EVERY_DAY">每天</el-radio>
|
||||
<el-radio :value="BOOKABLE_DATE_MODE.CUSTOM">自定义</el-radio>
|
||||
</el-radio-group>
|
||||
<span
|
||||
v-if="localSettings.bookableDateMode !== BOOKABLE_DATE_MODE.CUSTOM"
|
||||
class="field-hint block-hint"
|
||||
>设置预约服务的可预约日期。</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="localSettings.bookableDateMode === BOOKABLE_DATE_MODE.CUSTOM">
|
||||
<div class="bookable-custom-row">
|
||||
<el-form-item class="bookable-range-item" label-width="0">
|
||||
<div class="appointment-range-wrap">
|
||||
<el-date-picker
|
||||
v-model="customDateRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item class="bookable-weekdays-item" label-width="0">
|
||||
<el-checkbox-group v-model="localSettings.bookableWeekdays" class="bookable-weekdays-group">
|
||||
<el-checkbox-button
|
||||
v-for="item in WEEKDAY_OPTIONS"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</el-checkbox-button>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<p class="field-hint block-hint bookable-custom-hint">{{ bookableDateHint }}</p>
|
||||
</template>
|
||||
|
||||
<el-form-item label="预约日期范围" required>
|
||||
<div class="service-mode-field">
|
||||
<div class="inline-setting-line">
|
||||
<span class="inline-setting-text">对用户展示</span>
|
||||
<el-input-number
|
||||
v-model="localSettings.bookableDays"
|
||||
:min="1"
|
||||
:max="365"
|
||||
controls-position="right"
|
||||
class="bookable-days-input"
|
||||
/>
|
||||
<span class="inline-setting-text">天内的可预约日期</span>
|
||||
</div>
|
||||
<span class="field-hint block-hint">用户端可以看到的可预约日期。示例:设置1天,则代表只可以预约当天</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="提前预约" required>
|
||||
<div class="service-mode-field">
|
||||
<el-radio-group v-model="localSettings.advanceMode" class="stacked-radio-group">
|
||||
<el-radio :value="ADVANCE_MODE.NONE">无需提前预约</el-radio>
|
||||
<el-radio :value="ADVANCE_MODE.REQUIRE_HOURS">
|
||||
<span class="radio-inline-row">
|
||||
用户要求提前
|
||||
<el-input-number
|
||||
v-model="localSettings.advanceHours"
|
||||
:min="1"
|
||||
:max="720"
|
||||
controls-position="right"
|
||||
class="bookable-days-input"
|
||||
@click.stop
|
||||
@focus="localSettings.advanceMode = ADVANCE_MODE.REQUIRE_HOURS"
|
||||
/>
|
||||
<span class="radio-accent-text">小时进行预约</span>
|
||||
</span>
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
<span class="field-hint block-hint">用户只能预约间隔时间后的时段。示例:当前10:00,设置2h,则用户只可预约12:00往后的时段</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="取消订单" required>
|
||||
<div class="service-mode-field">
|
||||
<el-radio-group v-model="localSettings.cancelMode" class="stacked-radio-group">
|
||||
<el-radio :value="CANCEL_MODE.NOT_ALLOWED">不允许取消</el-radio>
|
||||
<el-radio :value="CANCEL_MODE.ALLOW_BEFORE_HOURS">
|
||||
<span class="radio-inline-row">
|
||||
服务开始
|
||||
<el-input-number
|
||||
v-model="localSettings.cancelAheadHours"
|
||||
:min="1"
|
||||
:max="720"
|
||||
controls-position="right"
|
||||
class="bookable-days-input"
|
||||
@click.stop
|
||||
@focus="localSettings.cancelMode = CANCEL_MODE.ALLOW_BEFORE_HOURS"
|
||||
/>
|
||||
<span class="radio-accent-text">小时之前,允许取消并自动退款</span>
|
||||
</span>
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
<span class="field-hint block-hint">设置用户最晚可以取消预约的时间。示例:设置2h,用户预约12:00-14:00,则当天10:00之前允许用户取消预约</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="表单信息" required>
|
||||
<div class="service-mode-field">
|
||||
<el-radio-group v-model="localSettings.formSubmitMode">
|
||||
<el-radio :value="FORM_SUBMIT_MODE.PER_APPOINTMENT">每个预约提交一次</el-radio>
|
||||
<el-radio :value="FORM_SUBMIT_MODE.PER_ORDER">每单提交一次</el-radio>
|
||||
</el-radio-group>
|
||||
<span class="field-hint block-hint">一次购买2件预约商品,选择每个预约提交一次则需要填写两遍关联系统表单,选择每单提交一次则只需要填写一遍关联系统表单</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="关联表单">
|
||||
<div class="service-mode-field">
|
||||
<div class="inline-setting-line">
|
||||
<el-select
|
||||
v-model="localSettings.systemFormId"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="不关联"
|
||||
style="width: 260px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in systemFormOptions"
|
||||
:key="item.id"
|
||||
:label="item.formName"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button link type="primary" @click="goAddSystemForm">添加表单</el-button>
|
||||
<el-button link type="primary" @click="loadSystemForms">刷新</el-button>
|
||||
</div>
|
||||
<span class="field-hint block-hint">用户购买此商品时,必须填写关联表单中设置的字段内容才能够进行订单支付,例如:部分商品购买必须填写身份证号、预约时间等</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 时段划分挂到价格库存区,单实例避免 v-model 互相覆盖 -->
|
||||
<Teleport
|
||||
v-if="!divisionTarget || divisionReady"
|
||||
:to="divisionTarget || 'body'"
|
||||
:disabled="!divisionTarget"
|
||||
>
|
||||
<div class="appointment-publish-panel">
|
||||
<h4 v-if="!divisionTarget">时段划分</h4>
|
||||
<div class="form-item-view slot-division-panel">
|
||||
<el-form-item label="时段划分" required>
|
||||
<el-radio-group v-model="localDivision.slotDivisionMode">
|
||||
<el-radio :value="SLOT_DIVISION_MODE.AUTO">自动划分</el-radio>
|
||||
<el-radio :value="SLOT_DIVISION_MODE.CUSTOM">自定义划分</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="localDivision.slotDivisionMode === SLOT_DIVISION_MODE.AUTO">
|
||||
<el-form-item label="起止时间" required>
|
||||
<div class="appointment-range-wrap">
|
||||
<el-time-picker
|
||||
v-model="autoTimeRange"
|
||||
is-range
|
||||
format="HH:mm"
|
||||
value-format="HH:mm"
|
||||
range-separator="至"
|
||||
start-placeholder="开始"
|
||||
end-placeholder="结束"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="服务时长" required>
|
||||
<el-input-number
|
||||
v-model="localDivision.serviceDurationMinutes"
|
||||
:min="15"
|
||||
:max="480"
|
||||
controls-position="right"
|
||||
style="width: 160px"
|
||||
/>
|
||||
<span class="field-hint">分钟(本商品全部 SKU 相同)</span>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="localDivision.slotDivisionMode === SLOT_DIVISION_MODE.CUSTOM">
|
||||
<el-alert type="info" show-icon :closable="false" class="mb_10">
|
||||
请自行填写每条时段的开始和结束时间;最多 24 条;不支持跨天。
|
||||
</el-alert>
|
||||
<el-form-item
|
||||
v-for="(item, index) in localDivision.customDrafts"
|
||||
:key="index"
|
||||
:label="index === 0 ? '自定义时段' : ' '"
|
||||
>
|
||||
<div class="custom-slot-row">
|
||||
<el-time-picker
|
||||
v-model="item.startTime"
|
||||
format="HH:mm"
|
||||
value-format="HH:mm"
|
||||
placeholder="开始"
|
||||
style="width: 140px"
|
||||
/>
|
||||
<span class="slot-sep">-</span>
|
||||
<el-time-picker
|
||||
v-model="item.endTime"
|
||||
format="HH:mm"
|
||||
value-format="HH:mm"
|
||||
placeholder="结束"
|
||||
style="width: 140px"
|
||||
/>
|
||||
<el-button v-if="index > 0" link type="danger" @click="removeCustomDraft(index)">删除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item class="slot-confirm-item">
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
:disabled="localDivision.customDrafts.length >= 24"
|
||||
@click="addCustomDraft"
|
||||
>
|
||||
添加时段 ({{ localDivision.customDrafts.length }}/24)
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-form-item class="slot-confirm-item">
|
||||
<el-button type="primary" @click="confirmSlots">确认时间段</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<div v-if="localDivision.confirmedSlots.length" class="confirmed-slots">
|
||||
<div v-if="localDivision.slotDivisionMode === SLOT_DIVISION_MODE.AUTO" class="slot-select-all">
|
||||
<el-checkbox
|
||||
:indeterminate="slotIndeterminate"
|
||||
:model-value="allSlotsChecked"
|
||||
@change="toggleAllSlots"
|
||||
>
|
||||
全选
|
||||
</el-checkbox>
|
||||
</div>
|
||||
<el-checkbox-group v-model="enabledSlotKeys">
|
||||
<el-checkbox
|
||||
v-for="slot in localDivision.confirmedSlots"
|
||||
:key="slotKey(slot)"
|
||||
:value="slotKey(slot)"
|
||||
:disabled="localDivision.slotDivisionMode === SLOT_DIVISION_MODE.CUSTOM"
|
||||
>
|
||||
{{ slot.startTime }}-{{ slot.endTime }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<el-dialog v-model="capacityDialogVisible" title="SKU 预约规则" width="520px" destroy-on-close>
|
||||
<el-table :data="activeCapacityRows" border size="small">
|
||||
<el-table-column label="时段" prop="label" min-width="140" />
|
||||
<el-table-column label="可预约数量(0=不限制)" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-model="capacityDraft[row.key]"
|
||||
:min="0"
|
||||
controls-position="right"
|
||||
style="width: 140px"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="capacityDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveSkuCapacities">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getRequest } from "@/libs/axios";
|
||||
import {
|
||||
SERVICE_MODE,
|
||||
BOOKABLE_DATE_MODE,
|
||||
ADVANCE_MODE,
|
||||
CANCEL_MODE,
|
||||
FORM_SUBMIT_MODE,
|
||||
SLOT_DIVISION_MODE,
|
||||
WEEKDAY_OPTIONS,
|
||||
defaultAppointmentSettings,
|
||||
defaultSlotDivision,
|
||||
slotKey,
|
||||
} from "@/constants/appointment";
|
||||
|
||||
function parseMinutes(timeStr) {
|
||||
if (!timeStr) return 0;
|
||||
const [h, m] = timeStr.split(":").map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
function formatMinutes(total) {
|
||||
const h = Math.floor(total / 60);
|
||||
const m = total % 60;
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "AppointmentPublishPanel",
|
||||
props: {
|
||||
settings: { type: Object, default: () => defaultAppointmentSettings() },
|
||||
division: { type: Object, default: () => defaultSlotDivision() },
|
||||
skuCapacities: { type: Object, default: () => ({}) },
|
||||
skuInfo: { type: Array, default: () => [] },
|
||||
divisionTarget: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
emits: ["update:settings", "update:division", "update:skuCapacities"],
|
||||
data() {
|
||||
return {
|
||||
SERVICE_MODE,
|
||||
BOOKABLE_DATE_MODE,
|
||||
ADVANCE_MODE,
|
||||
CANCEL_MODE,
|
||||
FORM_SUBMIT_MODE,
|
||||
SLOT_DIVISION_MODE,
|
||||
WEEKDAY_OPTIONS,
|
||||
localSettings: defaultAppointmentSettings(),
|
||||
localDivision: defaultSlotDivision(),
|
||||
enabledSlotKeys: [],
|
||||
systemFormOptions: [],
|
||||
capacityDialogVisible: false,
|
||||
capacityDraft: {},
|
||||
activeSkuRowKey: "",
|
||||
syncing: false,
|
||||
divisionReady: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
bookableDateHint() {
|
||||
return "设置预约服务的可预约日期。例如:周六、周天不选中,则在可预约日期中的周一到周五可用。";
|
||||
},
|
||||
customDateRange: {
|
||||
get() {
|
||||
const { bookableCustomStartDate, bookableCustomEndDate } = this.localSettings;
|
||||
if (bookableCustomStartDate && bookableCustomEndDate) {
|
||||
return [bookableCustomStartDate, bookableCustomEndDate];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
set(val) {
|
||||
if (val && val.length === 2) {
|
||||
this.localSettings.bookableCustomStartDate = val[0];
|
||||
this.localSettings.bookableCustomEndDate = val[1];
|
||||
} else {
|
||||
this.localSettings.bookableCustomStartDate = "";
|
||||
this.localSettings.bookableCustomEndDate = "";
|
||||
}
|
||||
},
|
||||
},
|
||||
autoTimeRange: {
|
||||
get() {
|
||||
if (this.localDivision.autoStartTime && this.localDivision.autoEndTime) {
|
||||
return [this.localDivision.autoStartTime, this.localDivision.autoEndTime];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
set(val) {
|
||||
if (val && val.length === 2) {
|
||||
this.localDivision.autoStartTime = val[0];
|
||||
this.localDivision.autoEndTime = val[1];
|
||||
}
|
||||
},
|
||||
},
|
||||
activeCapacityRows() {
|
||||
return this.localDivision.confirmedSlots
|
||||
.filter((slot) => this.isSlotEnabled(slot))
|
||||
.map((slot) => ({
|
||||
key: slotKey(slot),
|
||||
label: `${slot.startTime}-${slot.endTime}`,
|
||||
}));
|
||||
},
|
||||
allSlotsChecked() {
|
||||
const enabled = this.localDivision.confirmedSlots.filter((s) => this.isSlotEnabled(s));
|
||||
return enabled.length > 0 && enabled.length === this.enabledSlotKeys.length;
|
||||
},
|
||||
slotIndeterminate() {
|
||||
const enabled = this.localDivision.confirmedSlots.filter((s) => this.isSlotEnabled(s));
|
||||
return this.enabledSlotKeys.length > 0 && this.enabledSlotKeys.length < enabled.length;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
settings: {
|
||||
immediate: true,
|
||||
deep: true,
|
||||
handler(val) {
|
||||
if (this.syncing) return;
|
||||
this.localSettings = { ...defaultAppointmentSettings(), ...(val || {}) };
|
||||
},
|
||||
},
|
||||
division: {
|
||||
immediate: true,
|
||||
deep: true,
|
||||
handler(val) {
|
||||
if (this.syncing) return;
|
||||
this.localDivision = { ...defaultSlotDivision(), ...(val || {}) };
|
||||
this.enabledSlotKeys = (this.localDivision.confirmedSlots || [])
|
||||
.filter((s) => s.slotEnabled !== false)
|
||||
.map((s) => slotKey(s));
|
||||
},
|
||||
},
|
||||
localSettings: {
|
||||
deep: true,
|
||||
handler(val) {
|
||||
this.syncing = true;
|
||||
this.$emit("update:settings", { ...val });
|
||||
this.$nextTick(() => {
|
||||
this.syncing = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
localDivision: {
|
||||
deep: true,
|
||||
handler(val) {
|
||||
this.syncing = true;
|
||||
this.$emit("update:division", { ...val });
|
||||
this.$nextTick(() => {
|
||||
this.syncing = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
enabledSlotKeys(val) {
|
||||
this.localDivision.confirmedSlots = (this.localDivision.confirmedSlots || []).map((slot) => ({
|
||||
...slot,
|
||||
slotEnabled: val.includes(slotKey(slot)),
|
||||
}));
|
||||
},
|
||||
divisionTarget: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.prepareDivisionTarget();
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.loadSystemForms();
|
||||
this.prepareDivisionTarget();
|
||||
},
|
||||
methods: {
|
||||
prepareDivisionTarget() {
|
||||
this.divisionReady = false;
|
||||
if (!this.divisionTarget) {
|
||||
return;
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
this.divisionReady = !!document.querySelector(this.divisionTarget);
|
||||
});
|
||||
},
|
||||
slotKey,
|
||||
isSlotEnabled(slot) {
|
||||
return slot.slotEnabled !== false;
|
||||
},
|
||||
toggleAllSlots(checked) {
|
||||
if (checked) {
|
||||
this.enabledSlotKeys = this.localDivision.confirmedSlots.map((s) => slotKey(s));
|
||||
} else {
|
||||
this.enabledSlotKeys = [];
|
||||
}
|
||||
},
|
||||
loadSystemForms() {
|
||||
getRequest("/appointment/system-forms/enabled")
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
this.systemFormOptions = res.result || [];
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
goAddSystemForm() {
|
||||
// 主布局无 keep-alive,新开页避免发品表单被销毁
|
||||
const route = this.$router.resolve({ name: "appointment-system-form-designer" });
|
||||
window.open(route.href, "_blank");
|
||||
},
|
||||
addCustomDraft() {
|
||||
const last = this.localDivision.customDrafts[this.localDivision.customDrafts.length - 1];
|
||||
const start = last?.endTime || "08:00";
|
||||
const endMinutes = parseMinutes(start) + 60;
|
||||
const end = endMinutes >= 24 * 60 ? "23:59" : formatMinutes(endMinutes);
|
||||
this.localDivision.customDrafts.push({ startTime: start, endTime: end });
|
||||
},
|
||||
removeCustomDraft(index) {
|
||||
this.localDivision.customDrafts.splice(index, 1);
|
||||
},
|
||||
confirmSlots() {
|
||||
let slots = [];
|
||||
if (this.localDivision.slotDivisionMode === SLOT_DIVISION_MODE.AUTO) {
|
||||
const duration = Number(this.localDivision.serviceDurationMinutes);
|
||||
if (!duration || duration < 15) {
|
||||
this.$Message.error("请填写有效的服务时长(15-480 分钟)");
|
||||
return false;
|
||||
}
|
||||
const start = parseMinutes(this.localDivision.autoStartTime);
|
||||
const end = parseMinutes(this.localDivision.autoEndTime);
|
||||
if (!this.localDivision.autoStartTime || !this.localDivision.autoEndTime || end <= start) {
|
||||
this.$Message.error("请选择有效的起止时间(同一天内,结束大于开始)");
|
||||
return false;
|
||||
}
|
||||
for (let t = start; t + duration <= end; t += duration) {
|
||||
slots.push({
|
||||
startTime: formatMinutes(t),
|
||||
endTime: formatMinutes(t + duration),
|
||||
slotEnabled: true,
|
||||
});
|
||||
}
|
||||
if (!slots.length) {
|
||||
this.$Message.error("未生成有效时段,请检查起止时间与服务时长");
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const drafts = this.localDivision.customDrafts || [];
|
||||
if (!drafts.length) {
|
||||
this.$Message.error("请至少添加一条自定义时段");
|
||||
return false;
|
||||
}
|
||||
let prevEnd = -1;
|
||||
for (const row of drafts) {
|
||||
if (!row.startTime || !row.endTime) {
|
||||
this.$Message.error("请完整填写每条时段的开始和结束时间");
|
||||
return false;
|
||||
}
|
||||
const s = parseMinutes(row.startTime);
|
||||
const e = parseMinutes(row.endTime);
|
||||
if (e <= s) {
|
||||
this.$Message.error("结束时间须大于开始时间,且不支持跨天");
|
||||
return false;
|
||||
}
|
||||
if (s < prevEnd) {
|
||||
this.$Message.error("自定义时段不得重叠,须按时间顺序添加");
|
||||
return false;
|
||||
}
|
||||
prevEnd = e;
|
||||
slots.push({ startTime: row.startTime, endTime: row.endTime, slotEnabled: true });
|
||||
}
|
||||
}
|
||||
this.localDivision.confirmedSlots = slots;
|
||||
this.enabledSlotKeys = slots.map((s) => slotKey(s));
|
||||
this.$Message.success(`已确认 ${slots.length} 个时段`);
|
||||
return true;
|
||||
},
|
||||
openSkuCapacityDialog(skuRow) {
|
||||
this.activeSkuRowKey = this.getSkuRowKey(skuRow);
|
||||
const draft = {};
|
||||
this.activeCapacityRows.forEach((row) => {
|
||||
draft[row.key] = this.skuCapacities?.[this.activeSkuRowKey]?.[row.key] ?? 0;
|
||||
});
|
||||
this.capacityDraft = draft;
|
||||
this.capacityDialogVisible = true;
|
||||
},
|
||||
saveSkuCapacities() {
|
||||
const next = { ...(this.skuCapacities || {}) };
|
||||
next[this.activeSkuRowKey] = { ...this.capacityDraft };
|
||||
this.$emit("update:skuCapacities", next);
|
||||
this.capacityDialogVisible = false;
|
||||
},
|
||||
validateRules() {
|
||||
const s = this.localSettings;
|
||||
if (!s.serviceModes?.length) {
|
||||
this.$Message.error("预约规则:请至少选择一种服务模式");
|
||||
return false;
|
||||
}
|
||||
if (s.bookableDateMode === BOOKABLE_DATE_MODE.CUSTOM) {
|
||||
if (!s.bookableCustomStartDate || !s.bookableCustomEndDate) {
|
||||
this.$Message.error("预约规则:请选择自定义可预约日期范围");
|
||||
return false;
|
||||
}
|
||||
if (!s.bookableWeekdays?.length) {
|
||||
this.$Message.error("预约规则:请至少勾选一天可约星期");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!s.bookableDays || s.bookableDays < 1) {
|
||||
this.$Message.error("预约规则:预约日期范围须为正整数");
|
||||
return false;
|
||||
}
|
||||
if (s.advanceMode === ADVANCE_MODE.REQUIRE_HOURS && (!s.advanceHours || s.advanceHours < 1)) {
|
||||
this.$Message.error("预约规则:请填写提前预约小时数");
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
s.cancelMode === CANCEL_MODE.ALLOW_BEFORE_HOURS &&
|
||||
(!s.cancelAheadHours || s.cancelAheadHours < 1)
|
||||
) {
|
||||
this.$Message.error("预约规则:请填写取消政策小时数");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
validateDivision() {
|
||||
const enabledSlots = (this.localDivision.confirmedSlots || []).filter((slot) =>
|
||||
this.isSlotEnabled(slot)
|
||||
);
|
||||
if (!enabledSlots.length) {
|
||||
this.$Message.error("时段划分:请确认时间段并至少启用 1 个时段");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
validate() {
|
||||
if (this.part === "rules") {
|
||||
return this.validateRules();
|
||||
}
|
||||
if (this.part === "division") {
|
||||
return this.validateDivision();
|
||||
}
|
||||
return this.validateRules() && this.validateDivision();
|
||||
},
|
||||
buildSettingsPayload() {
|
||||
return {
|
||||
appointmentSettings: { ...this.localSettings },
|
||||
};
|
||||
},
|
||||
buildDivisionPayload() {
|
||||
const enabledSlots = (this.localDivision.confirmedSlots || []).filter((slot) =>
|
||||
this.isSlotEnabled(slot)
|
||||
);
|
||||
return {
|
||||
appointmentSlotTemplate: {
|
||||
slotDivisionMode: this.localDivision.slotDivisionMode,
|
||||
serviceDurationMinutes: this.localDivision.serviceDurationMinutes,
|
||||
autoStartTime: this.localDivision.autoStartTime,
|
||||
autoEndTime: this.localDivision.autoEndTime,
|
||||
slots: enabledSlots.map((slot) => ({
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
slotEnabled: true,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
buildSubmitPayload() {
|
||||
return {
|
||||
...this.buildSettingsPayload(),
|
||||
...this.buildDivisionPayload(),
|
||||
};
|
||||
},
|
||||
buildSkuSlotCapacities(skuRow) {
|
||||
const rowKey = this.getSkuRowKey(skuRow);
|
||||
const map = this.skuCapacities?.[rowKey] || {};
|
||||
return (this.localDivision.confirmedSlots || [])
|
||||
.filter((slot) => this.isSlotEnabled(slot))
|
||||
.map((slot) => ({
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
slotCapacity: map[slotKey(slot)] ?? 0,
|
||||
}));
|
||||
},
|
||||
getSkuRowKey(row) {
|
||||
return this.skuInfo
|
||||
.map((info) => row[info.name])
|
||||
.filter(Boolean)
|
||||
.join("::");
|
||||
},
|
||||
hydrateFromGoods(result) {
|
||||
if (result.appointmentSettings) {
|
||||
this.localSettings = { ...defaultAppointmentSettings(), ...result.appointmentSettings };
|
||||
}
|
||||
if (result.appointmentSlotTemplate) {
|
||||
const tpl = result.appointmentSlotTemplate;
|
||||
this.localDivision = {
|
||||
...defaultSlotDivision(),
|
||||
slotDivisionMode: tpl.slotDivisionMode || SLOT_DIVISION_MODE.AUTO,
|
||||
serviceDurationMinutes: tpl.serviceDurationMinutes || 60,
|
||||
autoStartTime: tpl.autoStartTime || "08:00",
|
||||
autoEndTime: tpl.autoEndTime || "18:00",
|
||||
confirmedSlots: tpl.slots || [],
|
||||
customDrafts: tpl.slots?.length
|
||||
? tpl.slots.map((s) => ({ startTime: s.startTime, endTime: s.endTime }))
|
||||
: defaultSlotDivision().customDrafts,
|
||||
};
|
||||
this.enabledSlotKeys = (tpl.slots || [])
|
||||
.filter((s) => s.slotEnabled !== false)
|
||||
.map((s) => slotKey(s));
|
||||
}
|
||||
if (result.skuList?.length) {
|
||||
const cap = {};
|
||||
result.skuList.forEach((sku) => {
|
||||
const specKey = (sku.specList || []).map((s) => s.specValue).filter(Boolean).join("::");
|
||||
const keys = [specKey, sku.id, sku.sn].filter(Boolean);
|
||||
const slotMap = {};
|
||||
(sku.slotCapacities || []).forEach((item) => {
|
||||
slotMap[slotKey(item)] = item.slotCapacity ?? 0;
|
||||
});
|
||||
keys.forEach((key) => {
|
||||
cap[key] = { ...slotMap };
|
||||
});
|
||||
});
|
||||
this.$emit("update:skuCapacities", cap);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.appointment-publish-panel {
|
||||
.field-hint {
|
||||
margin-left: 8px;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
.block-hint {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
margin-left: 0;
|
||||
}
|
||||
.service-mode-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.inline-setting-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.inline-setting-text {
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bookable-days-input {
|
||||
width: 100px;
|
||||
}
|
||||
.stacked-radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.stacked-radio-group :deep(.el-radio) {
|
||||
margin-right: 0;
|
||||
height: auto;
|
||||
align-items: center;
|
||||
}
|
||||
.radio-inline-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.radio-accent-text {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.bookable-custom-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
column-gap: 32px;
|
||||
padding-left: 120px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.bookable-custom-hint {
|
||||
padding-left: 120px;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
.bookable-custom-row :deep(.el-form-item) {
|
||||
width: auto !important;
|
||||
margin-right: 0;
|
||||
}
|
||||
.bookable-weekdays-item :deep(.el-form-item__content),
|
||||
.bookable-range-item :deep(.el-form-item__content) {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
.bookable-weekdays-group :deep(.el-checkbox-button) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.bookable-weekdays-group :deep(.el-checkbox-button__inner) {
|
||||
border-radius: 16px !important;
|
||||
border-left: 1px solid var(--el-border-color) !important;
|
||||
box-shadow: none !important;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
.bookable-weekdays-group :deep(.el-checkbox-button.is-checked .el-checkbox-button__inner) {
|
||||
border-color: var(--el-color-primary) !important;
|
||||
}
|
||||
.appointment-range-wrap {
|
||||
width: 260px;
|
||||
max-width: 260px;
|
||||
flex: 0 0 260px;
|
||||
}
|
||||
.appointment-range-wrap :deep(.el-date-editor) {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
flex-grow: 0 !important;
|
||||
}
|
||||
.slot-division-panel {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.custom-slot-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.slot-sep {
|
||||
color: #909399;
|
||||
}
|
||||
.slot-confirm-item {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.confirmed-slots {
|
||||
padding: 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.slot-select-all {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -55,6 +55,7 @@
|
||||
<el-option label="虚拟商品" value="VIRTUAL_GOODS" />
|
||||
<!-- E_COUPON:卡密商品,可跳转卡池管理 -->
|
||||
<el-option label="电子卡券" value="E_COUPON" />
|
||||
<el-option label="预约商品" value="APPOINTMENT_GOODS" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
@@ -261,6 +262,7 @@ export default {
|
||||
if (v === "PHYSICAL_GOODS") return "实物商品";
|
||||
if (v === "VIRTUAL_GOODS") return "虚拟商品";
|
||||
if (v === "E_COUPON") return "电子卡券"; // 卡密商品
|
||||
if (v === "APPOINTMENT_GOODS") return "预约商品";
|
||||
return v || "—";
|
||||
},
|
||||
/** 跳转卡池管理;单 SKU 商品默认取第一个 SKU(card-key-pool 需 skuId) */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="goods-operation">
|
||||
<div class="step-list">
|
||||
<!-- 填详情/成功页不再展示顶部步骤条 -->
|
||||
<div class="step-list" v-show="activestep === 0">
|
||||
<el-steps :active="activestep" align-center style="height: 60px; margin-top: 10px">
|
||||
<el-step title="选择商品品类" />
|
||||
<el-step title="填写商品详情" />
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 选择商品类型 -->
|
||||
<el-dialog v-model="selectGoodsType" width="550px" :show-close="false">
|
||||
<div class="goods-type-list">
|
||||
<div
|
||||
class="goods-type-item"
|
||||
:class="{ 'active-goods-type': item.check }"
|
||||
<el-dialog
|
||||
v-model="selectGoodsType"
|
||||
title="选择商品类型"
|
||||
width="720px"
|
||||
:show-close="false"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="goods-type-cards">
|
||||
<button
|
||||
type="button"
|
||||
class="goods-type-card"
|
||||
:class="{ active: pendingGoodsType === item.type }"
|
||||
v-for="item in goodsTypeWay"
|
||||
:key="item.type"
|
||||
@click="handleClickGoodsType(item)"
|
||||
v-for="(item, index) in goodsTypeWay"
|
||||
:key="index"
|
||||
>
|
||||
<img :src="item.img" />
|
||||
<div>
|
||||
<h2>{{ item.title }}</h2>
|
||||
<div class="card-icon" :class="item.tone">
|
||||
<el-icon :size="26"><component :is="item.icon" /></el-icon>
|
||||
</div>
|
||||
<div class="card-copy">
|
||||
<h3>{{ item.title }}</h3>
|
||||
<p>{{ item.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="goods-type-actions">
|
||||
@@ -24,7 +32,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<!-- 商品分类 -->
|
||||
|
||||
<div class="content-goods-publish">
|
||||
<div class="goods-category">
|
||||
<ul v-if="categoryListLevel1.length > 0">
|
||||
@@ -61,13 +69,14 @@
|
||||
</ul>
|
||||
</div>
|
||||
<p class="current-goods-category">
|
||||
您当前选择的商品类别是:
|
||||
<span>{{ category[0].name }}</span>
|
||||
您当前选择:
|
||||
<span v-if="selectedTypeTitle">{{ selectedTypeTitle }}</span>
|
||||
<span v-else>未选择商品类型</span>
|
||||
<span v-show="category[0].name"> / {{ category[0].name }}</span>
|
||||
<span v-show="category[1].name">> {{ category[1].name }}</span>
|
||||
<span v-show="category[2].name">> {{ category[2].name }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- 底部按钮 -->
|
||||
<div class="footer">
|
||||
<div class="footer-btns">
|
||||
<el-button type="primary" @click="openGoodsTypeDialog">商品类型</el-button>
|
||||
@@ -78,53 +87,61 @@
|
||||
</template>
|
||||
<script>
|
||||
import * as API_GOODS from "@/api/goods";
|
||||
import goodsType1Img from "@/assets/goodsType1.png";
|
||||
import goodsType2Img from "@/assets/goodsType2.png";
|
||||
import { Box, Calendar, CreditCard, Ticket } from "@element-plus/icons-vue";
|
||||
|
||||
export default {
|
||||
components: { Box, Calendar, CreditCard, Ticket },
|
||||
data() {
|
||||
return {
|
||||
selectGoodsType: false, // 展示选择商品分类modal
|
||||
/** 商品类型选项;E_COUPON 为卡密商品(与 VIRTUAL_GOODS 核销型区分,见 FR-S-01) */
|
||||
selectGoodsType: false,
|
||||
goodsTypeWay: [
|
||||
{
|
||||
title: "实物商品",
|
||||
img: goodsType1Img,
|
||||
desc: "零售批发,物流配送",
|
||||
type: "PHYSICAL_GOODS",
|
||||
check: false,
|
||||
icon: "Box",
|
||||
tone: "tone-physical",
|
||||
},
|
||||
{
|
||||
title: "虚拟商品",
|
||||
img: goodsType2Img,
|
||||
desc: "虚拟核验,无需物流",
|
||||
type: "VIRTUAL_GOODS",
|
||||
check: false,
|
||||
icon: "Ticket",
|
||||
tone: "tone-virtual",
|
||||
},
|
||||
{
|
||||
title: "电子卡券",
|
||||
img: goodsType2Img,
|
||||
desc: "卡密自动发卡,无需物流",
|
||||
type: "E_COUPON", // goodsType;库存由卡池同步,非手动填写
|
||||
check: false,
|
||||
type: "E_COUPON",
|
||||
icon: "CreditCard",
|
||||
tone: "tone-coupon",
|
||||
},
|
||||
{
|
||||
title: "预约商品",
|
||||
desc: "时段预约,到店或上门",
|
||||
type: "APPOINTMENT_GOODS",
|
||||
icon: "Calendar",
|
||||
tone: "tone-appointment",
|
||||
},
|
||||
],
|
||||
// 商品分类选择数组
|
||||
category: [
|
||||
{ name: "", id: "" },
|
||||
{ name: "", id: "" },
|
||||
{ name: "", id: "" },
|
||||
],
|
||||
// 商品类型
|
||||
goodsType: "",
|
||||
pendingGoodsType: "",
|
||||
/** 1级分类列表*/
|
||||
categoryListLevel1: [],
|
||||
/** 2级分类列表*/
|
||||
categoryListLevel2: [],
|
||||
/** 3级分类列表*/
|
||||
categoryListLevel3: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
selectedTypeTitle() {
|
||||
const hit = this.goodsTypeWay.find((item) => item.type === this.goodsType);
|
||||
return hit ? hit.title : "";
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selectGoodsType(val) {
|
||||
if (val) {
|
||||
@@ -135,19 +152,12 @@ export default {
|
||||
methods: {
|
||||
syncGoodsTypeSelection() {
|
||||
this.pendingGoodsType = this.goodsType;
|
||||
this.goodsTypeWay.forEach((item) => {
|
||||
item.check = item.type === this.goodsType;
|
||||
});
|
||||
},
|
||||
openGoodsTypeDialog() {
|
||||
this.selectGoodsType = true;
|
||||
},
|
||||
// 点击商品类型(仅临时选中,确认后才生效)
|
||||
handleClickGoodsType(val) {
|
||||
this.goodsTypeWay.forEach((item) => {
|
||||
item.check = item.type === val.type;
|
||||
});
|
||||
this.pendingGoodsType = val.type;
|
||||
handleClickGoodsType(item) {
|
||||
this.pendingGoodsType = item.type;
|
||||
},
|
||||
cancelGoodsType() {
|
||||
this.syncGoodsTypeSelection();
|
||||
@@ -161,7 +171,6 @@ export default {
|
||||
this.goodsType = this.pendingGoodsType;
|
||||
this.selectGoodsType = false;
|
||||
},
|
||||
/** 选择商城商品分类 */
|
||||
handleSelectCategory(row, index, level) {
|
||||
if (level === 1) {
|
||||
this.category.forEach((cate) => {
|
||||
@@ -182,20 +191,18 @@ export default {
|
||||
this.category[2].id = row.id;
|
||||
}
|
||||
},
|
||||
/** 查询下一级 商城商品分类*/
|
||||
GET_NextLevelCategory(row) {
|
||||
const _id = row && row.id !== 0 ? row.id : 0;
|
||||
GET_NextLevelCategory() {
|
||||
API_GOODS.getGoodsCategoryAll().then((res) => {
|
||||
if (res.success && res.result) {
|
||||
this.categoryListLevel1 = res.result;
|
||||
}
|
||||
});
|
||||
},
|
||||
// 下一步
|
||||
next() {
|
||||
window.scrollTo(0, 0);
|
||||
if (!this.goodsType) {
|
||||
this.$Message.error("请选择商品类型");
|
||||
this.openGoodsTypeDialog();
|
||||
return;
|
||||
}
|
||||
if (!this.category[0].name) {
|
||||
@@ -204,13 +211,11 @@ export default {
|
||||
} else if (!this.category[2].name) {
|
||||
this.$Message.error("必须选择到三级分类");
|
||||
return;
|
||||
} else if (this.category[2].name) {
|
||||
let params = {
|
||||
category: this.category,
|
||||
goodsType: this.goodsType,
|
||||
};
|
||||
this.$emit("change", params);
|
||||
}
|
||||
this.$emit("change", {
|
||||
category: this.category,
|
||||
goodsType: this.goodsType,
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
@@ -246,4 +251,78 @@ export default {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.goods-type-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.goods-type-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 18px 16px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.goods-type-card:hover {
|
||||
border-color: #c6e2ff;
|
||||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.08);
|
||||
}
|
||||
|
||||
.goods-type-card.active {
|
||||
border-color: #409eff;
|
||||
background: #f5faff;
|
||||
box-shadow: 0 0 0 1px #409eff inset;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
flex: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tone-physical {
|
||||
background: #409eff;
|
||||
}
|
||||
|
||||
.tone-virtual {
|
||||
background: #909399;
|
||||
}
|
||||
|
||||
.tone-coupon {
|
||||
background: #e6a23c;
|
||||
}
|
||||
|
||||
.tone-appointment {
|
||||
background: #67c23a;
|
||||
}
|
||||
|
||||
.card-copy h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 22px;
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-copy p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,9 @@
|
||||
@click="checkLogistics"
|
||||
>查看物流</el-button>
|
||||
<el-button type="primary" @click="orderLog">订单日志</el-button>
|
||||
<el-button v-if="canOrderTake" type="primary" @click="orderTake">订单核销</el-button>
|
||||
<el-button v-if="canOrderTake" type="primary" @click="orderTake">
|
||||
{{ isAppointmentOrder ? "预约核销" : "订单核销" }}
|
||||
</el-button>
|
||||
<el-button v-if="canShip" type="primary" @click="openFulfill()">发货</el-button>
|
||||
<el-button type="primary" @click="modifyRemark">添加备注</el-button>
|
||||
<el-button
|
||||
@@ -65,10 +67,33 @@
|
||||
{{ orderInfo.order.createTime }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="div-item" v-if="orderInfo.order.verificationCode">
|
||||
<div class="div-item" v-if="orderInfo.order.verificationCode && !isAppointmentOrder">
|
||||
<div class="div-item-left">核验码:</div>
|
||||
<div class="div-item-right">{{ orderInfo.order.verificationCode }}</div>
|
||||
</div>
|
||||
<div class="div-item" v-if="isAppointmentOrder && appointmentVerification.totalCount">
|
||||
<div class="div-item-left">核销进度:</div>
|
||||
<div class="div-item-right">
|
||||
{{ appointmentVerification.verifiedCount || 0 }}/{{ appointmentVerification.totalCount }}
|
||||
<span v-if="appointmentVerification.workOrderStatus" style="margin-left: 8px; color: #909399">
|
||||
工单:{{ appointmentWorkOrderStatusText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="div-item" v-if="isAppointmentOrder && appointmentSlotText">
|
||||
<div class="div-item-left">预约时段:</div>
|
||||
<div class="div-item-right">{{ appointmentSlotText }}</div>
|
||||
</div>
|
||||
<div
|
||||
class="div-item"
|
||||
v-for="(rows, gi) in appointmentFormGroups"
|
||||
:key="'form-' + gi"
|
||||
>
|
||||
<div class="div-item-left">{{ appointmentFormGroups.length > 1 ? `补充信息${gi + 1}:` : '补充信息:' }}</div>
|
||||
<div class="div-item-right">
|
||||
<div v-for="row in rows" :key="row.fieldKey">{{ row.fieldLabel }}:{{ row.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width: 30%; float: left; margin-left: 20px">
|
||||
<div class="div-item" v-if="orderInfo.order.needReceipt == false">
|
||||
@@ -805,15 +830,76 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="orderTakeModal" title="订单核销" width="530px">
|
||||
<el-form ref="orderTakeForm" :model="orderTakeForm" label-width="100px" :rules="orderTakeValidate">
|
||||
<el-dialog
|
||||
v-model="orderTakeModal"
|
||||
:title="isAppointmentOrder ? '预约核销' : '订单核销'"
|
||||
width="560px"
|
||||
>
|
||||
<template v-if="isAppointmentOrder">
|
||||
<el-alert
|
||||
v-if="appointmentVerification.verifiedCount > 0"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb_10"
|
||||
>
|
||||
已核销 {{ appointmentVerification.verifiedCount }}/{{ appointmentVerification.totalCount }},
|
||||
请继续输入剩余核销码直至全部完成。
|
||||
</el-alert>
|
||||
<el-form ref="appointmentTakeForm" :model="appointmentTakeForm" label-width="100px" :rules="appointmentTakeValidate">
|
||||
<el-form-item label="服务人员" prop="staffId">
|
||||
<el-select
|
||||
v-model="appointmentTakeForm.staffId"
|
||||
placeholder="请选择启用中的服务人员"
|
||||
style="width: 100%"
|
||||
filterable
|
||||
>
|
||||
<el-option
|
||||
v-for="item in enabledStaffList"
|
||||
:key="item.id"
|
||||
:label="item.staffName"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="核销码" prop="code">
|
||||
<el-input
|
||||
v-model="appointmentTakeForm.code"
|
||||
placeholder="请输入预约核销码"
|
||||
maxlength="16"
|
||||
clearable
|
||||
@keyup.enter="appointmentVerifySubmit"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-table
|
||||
v-if="appointmentVerification.codes && appointmentVerification.codes.length"
|
||||
:data="appointmentVerification.codes"
|
||||
border
|
||||
size="small"
|
||||
class="mt_10"
|
||||
>
|
||||
<el-table-column prop="seqNo" label="序号" width="70" />
|
||||
<el-table-column prop="code" label="核销码" min-width="120" />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">{{ row.status === "USED" ? "已核销" : "待核销" }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<el-form v-else ref="orderTakeForm" :model="orderTakeForm" label-width="100px" :rules="orderTakeValidate">
|
||||
<el-form-item label="核销码" prop="qrCode">
|
||||
<el-input v-model="orderTakeForm.qrCode" placeholder="请输入核销码" maxlength="10" clearable />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="orderTakeModal = false">取消</el-button>
|
||||
<el-button type="primary" @click="orderTakeSubmit">核销</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="appointmentVerifyLoading"
|
||||
@click="isAppointmentOrder ? appointmentVerifySubmit() : orderTakeSubmit()"
|
||||
>
|
||||
核销
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -844,6 +930,12 @@ import {
|
||||
resolveDefaultFulfillmentMode,
|
||||
canShowShipButton,
|
||||
} from "@/utils/orderFulfill";
|
||||
import {
|
||||
getEnabledServiceStaff,
|
||||
getAppointmentVerificationByOrderSn,
|
||||
verifyAppointmentCode,
|
||||
} from "@/api/appointment";
|
||||
import { WORK_ORDER_STATUS_LABEL, parseFormAnswerGroups } from "@/constants/appointment";
|
||||
export default {
|
||||
name: "orderDetail",
|
||||
components: {
|
||||
@@ -921,6 +1013,23 @@ export default {
|
||||
orderTakeForm: {
|
||||
qrCode: "",
|
||||
},
|
||||
appointmentVerification: {
|
||||
codes: [],
|
||||
totalCount: 0,
|
||||
verifiedCount: 0,
|
||||
workOrderStatus: "",
|
||||
workOrderVisible: false,
|
||||
},
|
||||
enabledStaffList: [],
|
||||
appointmentTakeForm: {
|
||||
staffId: "",
|
||||
code: "",
|
||||
},
|
||||
appointmentVerifyLoading: false,
|
||||
appointmentTakeValidate: {
|
||||
staffId: [{ required: true, message: "请选择服务人员", trigger: "change" }],
|
||||
code: [{ required: true, message: "核销码不能为空", trigger: "blur" }],
|
||||
},
|
||||
orderTakeValidate: {
|
||||
qrCode: [{ required: true, message: "订单核销码不能为空", trigger: "blur" }],
|
||||
},
|
||||
@@ -967,7 +1076,27 @@ export default {
|
||||
return t === "VIRTUAL" || this.$route.query.orderType === "VIRTUAL";
|
||||
},
|
||||
isNonPhysicalOrder() {
|
||||
return this.isVirtualOrder || this.isECouponOrder;
|
||||
return this.isVirtualOrder || this.isECouponOrder || this.isAppointmentOrder;
|
||||
},
|
||||
isAppointmentOrder() {
|
||||
const t = this.orderInfo?.order?.orderType;
|
||||
// 详情未回时用路由 query 兜底
|
||||
return t === "APPOINTMENT" || this.$route.query.orderType === "APPOINTMENT";
|
||||
},
|
||||
appointmentWorkOrderStatusText() {
|
||||
return WORK_ORDER_STATUS_LABEL[this.appointmentVerification.workOrderStatus]
|
||||
|| this.appointmentVerification.workOrderStatus
|
||||
|| "—";
|
||||
},
|
||||
appointmentSlotText() {
|
||||
const v = this.appointmentVerification || {};
|
||||
if (!v.slotDate) return "";
|
||||
const date = String(v.slotDate).slice(0, 10);
|
||||
const slot = v.slotStartTime && v.slotEndTime ? `${v.slotStartTime}-${v.slotEndTime}` : "";
|
||||
return [date, slot].filter(Boolean).join(" ");
|
||||
},
|
||||
appointmentFormGroups() {
|
||||
return parseFormAnswerGroups(this.appointmentVerification?.formAnswerJson);
|
||||
},
|
||||
hasCardKeySection() {
|
||||
return orderHasCardKeySection({
|
||||
@@ -997,6 +1126,12 @@ export default {
|
||||
});
|
||||
},
|
||||
canOrderTake() {
|
||||
if (this.isAppointmentOrder) {
|
||||
const order = this.orderInfo?.order;
|
||||
if (!order || order.orderStatus !== "TAKE") return false;
|
||||
return (this.appointmentVerification.verifiedCount || 0)
|
||||
< (this.appointmentVerification.totalCount || 0);
|
||||
}
|
||||
if (this.allowOperation.take) return true;
|
||||
const order = this.orderInfo?.order;
|
||||
if (!order) return false;
|
||||
@@ -1299,6 +1434,9 @@ export default {
|
||||
: [];
|
||||
this.getContentPrice();
|
||||
this.getOrderPrice();
|
||||
if (this.isAppointmentOrder) {
|
||||
this.loadAppointmentVerification();
|
||||
}
|
||||
if (
|
||||
this.autoOpenFulfill &&
|
||||
this.canShip &&
|
||||
@@ -1487,13 +1625,73 @@ export default {
|
||||
handelCancel () {
|
||||
this.orderLogModal = false;
|
||||
},
|
||||
loadAppointmentVerification() {
|
||||
getAppointmentVerificationByOrderSn(this.sn)
|
||||
.then((res) => {
|
||||
if (res.success && res.result) {
|
||||
this.appointmentVerification = res.result;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
loadEnabledStaff() {
|
||||
getEnabledServiceStaff()
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
this.enabledStaffList = res.result || [];
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
orderTake() {
|
||||
if (this.isAppointmentOrder) {
|
||||
this.appointmentTakeForm = { staffId: "", code: "" };
|
||||
this.loadEnabledStaff();
|
||||
this.loadAppointmentVerification();
|
||||
this.orderTakeModal = true;
|
||||
this.$nextTick(() => {
|
||||
this.$refs.appointmentTakeForm?.clearValidate();
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.orderTakeForm.qrCode = this.orderInfo.order.verificationCode || "";
|
||||
this.orderTakeModal = true;
|
||||
this.$nextTick(() => {
|
||||
this.$refs.orderTakeForm?.clearValidate();
|
||||
});
|
||||
},
|
||||
appointmentVerifySubmit() {
|
||||
this.$refs.appointmentTakeForm.validate((valid) => {
|
||||
if (!valid) return;
|
||||
this.appointmentVerifyLoading = true;
|
||||
verifyAppointmentCode({
|
||||
code: this.appointmentTakeForm.code,
|
||||
staffId: this.appointmentTakeForm.staffId,
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.success) return;
|
||||
const result = res.result || {};
|
||||
this.appointmentTakeForm.code = "";
|
||||
this.loadAppointmentVerification();
|
||||
if (result.allVerified) {
|
||||
if (result.orderCompleted) {
|
||||
this.$Message.success("全部核销完成,订单已完成");
|
||||
this.orderTakeModal = false;
|
||||
this.getDataDetail();
|
||||
} else {
|
||||
this.$Message.success("全部核销完成,请在工单中结束服务");
|
||||
this.orderTakeModal = false;
|
||||
this.getDataDetail();
|
||||
}
|
||||
} else {
|
||||
this.$Message.success(`核销成功(${result.verifiedCount}/${result.totalCount})`);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.appointmentVerifyLoading = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
orderTakeSubmit() {
|
||||
this.$refs.orderTakeForm.validate((valid) => {
|
||||
if (!valid) return;
|
||||
|
||||
Reference in New Issue
Block a user