feat(appointment): 预约商品发布改为可视化配置,并接入表单设计器

商家端补齐预约规则、时段库存与系统表单设计;运营端增加预约工单与订单设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pikachu1995@126.com
2026-09-20 17:06:16 +08:00
parent 50ebc7d6df
commit 1bad9a14db
29 changed files with 4933 additions and 230 deletions

View 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}`);

View File

@@ -5,14 +5,20 @@
* @date 2026-08-02 * @date 2026-08-02
*/ */
export const E_COUPON_GOODS_TYPE = "E_COUPON"; export const E_COUPON_GOODS_TYPE = "E_COUPON";
export const APPOINTMENT_GOODS_TYPE = "APPOINTMENT_GOODS";
export function isECoupon(goodsType) { export function isECoupon(goodsType) {
return goodsType === E_COUPON_GOODS_TYPE; return goodsType === E_COUPON_GOODS_TYPE;
} }
export function isAppointmentGoods(goodsType) {
return goodsType === APPOINTMENT_GOODS_TYPE;
}
export function goodsTypeLabel(goodsType) { export function goodsTypeLabel(goodsType) {
const map = { const map = {
E_COUPON: "电子卡券", E_COUPON: "电子卡券",
APPOINTMENT_GOODS: "预约商品",
VIRTUAL_GOODS: "虚拟商品", VIRTUAL_GOODS: "虚拟商品",
PHYSICAL_GOODS: "实物商品", PHYSICAL_GOODS: "实物商品",
}; };
@@ -21,6 +27,7 @@ export function goodsTypeLabel(goodsType) {
export function goodsTypeTagType(goodsType) { export function goodsTypeTagType(goodsType) {
if (goodsType === E_COUPON_GOODS_TYPE) return "warning"; if (goodsType === E_COUPON_GOODS_TYPE) return "warning";
if (goodsType === APPOINTMENT_GOODS_TYPE) return "success";
if (goodsType === "VIRTUAL_GOODS") return "info"; if (goodsType === "VIRTUAL_GOODS") return "info";
return ""; return "";
} }

View File

@@ -128,6 +128,18 @@ export const otherRouter = {
name: "order-detail", name: "order-detail",
component: () => import("@/views/order/order/orderDetail.vue") 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", path: "after-order-detail",
title: "售后单详情", title: "售后单详情",

View 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>

View 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>

View File

@@ -55,6 +55,7 @@
<el-option label="虚拟商品" value="VIRTUAL_GOODS" /> <el-option label="虚拟商品" value="VIRTUAL_GOODS" />
<!-- E_COUPON卡密商品平台无卡池管理权限S-04 --> <!-- E_COUPON卡密商品平台无卡池管理权限S-04 -->
<el-option label="电子卡券" value="E_COUPON" /> <el-option label="电子卡券" value="E_COUPON" />
<el-option label="预约商品" value="APPOINTMENT_GOODS" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="商品分组" prop="groupId"> <el-form-item label="商品分组" prop="groupId">
@@ -388,6 +389,7 @@ export default {
if (v === "PHYSICAL_GOODS") return "实物商品"; if (v === "PHYSICAL_GOODS") return "实物商品";
if (v === "VIRTUAL_GOODS") return "虚拟商品"; if (v === "VIRTUAL_GOODS") return "虚拟商品";
if (v === "E_COUPON") return "电子卡券"; // 卡密商品 if (v === "E_COUPON") return "电子卡券"; // 卡密商品
if (v === "APPOINTMENT_GOODS") return "预约商品";
return v || "—"; return v || "—";
}, },
marketEnableText(v) { marketEnableText(v) {

View File

@@ -6,6 +6,8 @@
<el-button v-if="allowOperation.editPrice" @click="modifyPrice">调整价格</el-button> <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.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="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 v-if="orderInfo.order.orderStatus === 'UNPAID'" type="primary" @click="confirmPrice">收款</el-button>
<el-button plain @click="orderLog">订单日志</el-button> <el-button plain @click="orderLog">订单日志</el-button>
<el-button v-if="!isNonPhysicalOrder" plain type="primary" style="float:right;" @click="printOrder">打印发货单</el-button> <el-button v-if="!isNonPhysicalOrder" plain type="primary" style="float:right;" @click="printOrder">打印发货单</el-button>
@@ -37,6 +39,20 @@
{{ orderInfo.order.createTime }} {{ orderInfo.order.createTime }}
</div> </div>
</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>
<div style="width: 30%; float: left; margin-left: 20px"> <div style="width: 30%; float: left; margin-left: 20px">
<div class="div-item" v-if="orderInfo.order.needReceipt == false"> <div class="div-item" v-if="orderInfo.order.needReceipt == false">
@@ -463,11 +479,48 @@
<multipleMap ref="map" @callback="selectedRegion" /> <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> </div>
</template> </template>
<script> <script>
import * as API_Order from "@/api/order"; import * as API_Order from "@/api/order";
import { platformRefund, noShowRefund, getAppointmentOrderDetail } from "@/api/appointment";
import * as RegExp from "@/libs/RegExp.js"; import * as RegExp from "@/libs/RegExp.js";
import multipleMap from "@/components/map/multiple-map"; import multipleMap from "@/components/map/multiple-map";
@@ -494,7 +547,28 @@ export default {
return t === "VIRTUAL" || this.$route.query.orderType === "VIRTUAL"; return t === "VIRTUAL" || this.$route.query.orderType === "VIRTUAL";
}, },
isNonPhysicalOrder() { 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() { ecouponCardKeyDelivered() {
return (this.data || []).some((item) => item.cardKeyDelivered); return (this.data || []).some((item) => item.cardKeyDelivered);
@@ -529,6 +603,7 @@ export default {
priceDetailDTO: {}, priceDetailDTO: {},
}, },
}, },
appointmentExt: {},
modal: false, //弹出调整价格框 modal: false, //弹出调整价格框
searchForm: { searchForm: {
pageNumber: 1, // 当前页数 pageNumber: 1, // 当前页数
@@ -568,6 +643,18 @@ export default {
}, },
addressModal: false, //弹出修改收件信息框 addressModal: false, //弹出修改收件信息框
printModal: false, printModal: false,
platformRefundModal: false,
noShowRefundModal: false,
refundLoading: false,
platformRefundForm: {
refundType: "UNSERVED",
refundAmount: 0,
reason: "",
},
noShowRefundForm: {
refundAmount: 0,
reason: "",
},
//收件地址表单 //收件地址表单
addressForm: { addressForm: {
consigneeName: "", consigneeName: "",
@@ -690,9 +777,51 @@ export default {
this.typeList = JSON.parse(JSON.stringify(res.result.order.priceDetailDTO.discountPriceDetail)); this.typeList = JSON.parse(JSON.stringify(res.result.order.priceDetailDTO.discountPriceDetail));
this.getContentPrice() this.getContentPrice()
this.getOrderPrice() 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 () { modifyPrice () {
//默认要修改的金额为订单总金额 //默认要修改的金额为订单总金额
this.modifyPriceForm.price = this.orderInfo.order.flowPrice; 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 () { orderLog () {
this.orderLogModal = true; this.orderLogModal = true;

View File

@@ -29,7 +29,7 @@
<el-form-item label="收货人" prop="shipName"> <el-form-item label="收货人" prop="shipName">
<el-input v-model="searchForm.shipName" placeholder="请输入收货人姓名" clearable style="width: 240px" /> <el-input v-model="searchForm.shipName" placeholder="请输入收货人姓名" clearable style="width: 240px" />
</el-form-item> </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-select v-model="searchForm.orderPromotionType" placeholder="请选择" clearable style="width: 240px">
<el-option label="普通订单" value="NORMAL" /> <el-option label="普通订单" value="NORMAL" />
<el-option label="拼团订单" value="PINTUAN" /> <el-option label="拼团订单" value="PINTUAN" />
@@ -38,6 +38,11 @@
<el-option label="砍价订单" value="KANJIA" /> <el-option label="砍价订单" value="KANJIA" />
</el-select> </el-select>
</el-form-item> </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-form-item label="支付方式" prop="paymentMethod">
<el-select v-model="searchForm.paymentMethod" placeholder="请选择支付方式" clearable style="width: 240px"> <el-select v-model="searchForm.paymentMethod" placeholder="请选择支付方式" clearable style="width: 240px">
<el-option label="微信支付" value="WECHAT" /> <el-option label="微信支付" value="WECHAT" />

View File

@@ -19,7 +19,7 @@
<el-input type="number" v-model="formValidate.autoEvaluation"> <el-input type="number" v-model="formValidate.autoEvaluation">
<template #append></template> <template #append></template>
</el-input> </el-input>
<span class="desc">订单发货多少天后自动好评</span> <span class="desc">订单完成多少天后自动好评预约订单同样适用</span>
</el-form-item> </el-form-item>
<el-form-item label="已完成订单允许退单" prop="closeAfterSale"> <el-form-item label="已完成订单允许退单" prop="closeAfterSale">
<el-input type="number" v-model="formValidate.closeAfterSale"> <el-input type="number" v-model="formValidate.closeAfterSale">

View 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);

View 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);
}

View File

@@ -5,14 +5,20 @@
* @date 2026-08-02 * @date 2026-08-02
*/ */
export const E_COUPON_GOODS_TYPE = "E_COUPON"; export const E_COUPON_GOODS_TYPE = "E_COUPON";
export const APPOINTMENT_GOODS_TYPE = "APPOINTMENT_GOODS";
export function isECoupon(goodsType) { export function isECoupon(goodsType) {
return goodsType === E_COUPON_GOODS_TYPE; return goodsType === E_COUPON_GOODS_TYPE;
} }
export function isAppointmentGoods(goodsType) {
return goodsType === APPOINTMENT_GOODS_TYPE;
}
export function goodsTypeLabel(goodsType) { export function goodsTypeLabel(goodsType) {
const map = { const map = {
E_COUPON: "电子卡券", E_COUPON: "电子卡券",
APPOINTMENT_GOODS: "预约商品",
VIRTUAL_GOODS: "虚拟商品", VIRTUAL_GOODS: "虚拟商品",
PHYSICAL_GOODS: "实物商品", PHYSICAL_GOODS: "实物商品",
}; };
@@ -21,6 +27,7 @@ export function goodsTypeLabel(goodsType) {
export function goodsTypeTagType(goodsType) { export function goodsTypeTagType(goodsType) {
if (goodsType === E_COUPON_GOODS_TYPE) return "warning"; if (goodsType === E_COUPON_GOODS_TYPE) return "warning";
if (goodsType === APPOINTMENT_GOODS_TYPE) return "success";
if (goodsType === "VIRTUAL_GOODS") return "info"; if (goodsType === "VIRTUAL_GOODS") return "info";
return ""; return "";
} }

View 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);
}

View File

@@ -71,6 +71,55 @@ export const otherRouter = {
name: "card-key-pool", name: "card-key-pool",
component: () => import("@/views/goods/card-key/cardKeyPool.vue") 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", path: "add-coupon",
title: "店铺优惠券", title: "店铺优惠券",

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -55,6 +55,7 @@
<el-option label="虚拟商品" value="VIRTUAL_GOODS" /> <el-option label="虚拟商品" value="VIRTUAL_GOODS" />
<!-- E_COUPON卡密商品可跳转卡池管理 --> <!-- E_COUPON卡密商品可跳转卡池管理 -->
<el-option label="电子卡券" value="E_COUPON" /> <el-option label="电子卡券" value="E_COUPON" />
<el-option label="预约商品" value="APPOINTMENT_GOODS" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
@@ -261,6 +262,7 @@ export default {
if (v === "PHYSICAL_GOODS") return "实物商品"; if (v === "PHYSICAL_GOODS") return "实物商品";
if (v === "VIRTUAL_GOODS") return "虚拟商品"; if (v === "VIRTUAL_GOODS") return "虚拟商品";
if (v === "E_COUPON") return "电子卡券"; // 卡密商品 if (v === "E_COUPON") return "电子卡券"; // 卡密商品
if (v === "APPOINTMENT_GOODS") return "预约商品";
return v || "—"; return v || "—";
}, },
/** 跳转卡池管理;单 SKU 商品默认取第一个 SKUcard-key-pool 需 skuId */ /** 跳转卡池管理;单 SKU 商品默认取第一个 SKUcard-key-pool 需 skuId */

View File

@@ -1,6 +1,7 @@
<template> <template>
<div class="goods-operation"> <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-steps :active="activestep" align-center style="height: 60px; margin-top: 10px">
<el-step title="选择商品品类" /> <el-step title="选择商品品类" />
<el-step title="填写商品详情" /> <el-step title="填写商品详情" />

View File

@@ -1,21 +1,29 @@
<template> <template>
<div> <div>
<!-- 选择商品类型 --> <el-dialog
<el-dialog v-model="selectGoodsType" width="550px" :show-close="false"> v-model="selectGoodsType"
<div class="goods-type-list"> title="选择商品类型"
<div width="720px"
class="goods-type-item" :show-close="false"
:class="{ 'active-goods-type': item.check }" :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)" @click="handleClickGoodsType(item)"
v-for="(item, index) in goodsTypeWay"
:key="index"
> >
<img :src="item.img" /> <div class="card-icon" :class="item.tone">
<div> <el-icon :size="26"><component :is="item.icon" /></el-icon>
<h2>{{ item.title }}</h2> </div>
<div class="card-copy">
<h3>{{ item.title }}</h3>
<p>{{ item.desc }}</p> <p>{{ item.desc }}</p>
</div> </div>
</div> </button>
</div> </div>
<template #footer> <template #footer>
<div class="goods-type-actions"> <div class="goods-type-actions">
@@ -24,7 +32,7 @@
</div> </div>
</template> </template>
</el-dialog> </el-dialog>
<!-- 商品分类 -->
<div class="content-goods-publish"> <div class="content-goods-publish">
<div class="goods-category"> <div class="goods-category">
<ul v-if="categoryListLevel1.length > 0"> <ul v-if="categoryListLevel1.length > 0">
@@ -61,13 +69,14 @@
</ul> </ul>
</div> </div>
<p class="current-goods-category"> <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[1].name">> {{ category[1].name }}</span>
<span v-show="category[2].name">> {{ category[2].name }}</span> <span v-show="category[2].name">> {{ category[2].name }}</span>
</p> </p>
</div> </div>
<!-- 底部按钮 -->
<div class="footer"> <div class="footer">
<div class="footer-btns"> <div class="footer-btns">
<el-button type="primary" @click="openGoodsTypeDialog">商品类型</el-button> <el-button type="primary" @click="openGoodsTypeDialog">商品类型</el-button>
@@ -78,53 +87,61 @@
</template> </template>
<script> <script>
import * as API_GOODS from "@/api/goods"; import * as API_GOODS from "@/api/goods";
import goodsType1Img from "@/assets/goodsType1.png"; import { Box, Calendar, CreditCard, Ticket } from "@element-plus/icons-vue";
import goodsType2Img from "@/assets/goodsType2.png";
export default { export default {
components: { Box, Calendar, CreditCard, Ticket },
data() { data() {
return { return {
selectGoodsType: false, // 展示选择商品分类modal selectGoodsType: false,
/** 商品类型选项E_COUPON 为卡密商品(与 VIRTUAL_GOODS 核销型区分,见 FR-S-01 */
goodsTypeWay: [ goodsTypeWay: [
{ {
title: "实物商品", title: "实物商品",
img: goodsType1Img,
desc: "零售批发,物流配送", desc: "零售批发,物流配送",
type: "PHYSICAL_GOODS", type: "PHYSICAL_GOODS",
check: false, icon: "Box",
tone: "tone-physical",
}, },
{ {
title: "虚拟商品", title: "虚拟商品",
img: goodsType2Img,
desc: "虚拟核验,无需物流", desc: "虚拟核验,无需物流",
type: "VIRTUAL_GOODS", type: "VIRTUAL_GOODS",
check: false, icon: "Ticket",
tone: "tone-virtual",
}, },
{ {
title: "电子卡券", title: "电子卡券",
img: goodsType2Img,
desc: "卡密自动发卡,无需物流", desc: "卡密自动发卡,无需物流",
type: "E_COUPON", // goodsType库存由卡池同步非手动填写 type: "E_COUPON",
check: false, icon: "CreditCard",
tone: "tone-coupon",
},
{
title: "预约商品",
desc: "时段预约,到店或上门",
type: "APPOINTMENT_GOODS",
icon: "Calendar",
tone: "tone-appointment",
}, },
], ],
// 商品分类选择数组
category: [ category: [
{ name: "", id: "" }, { name: "", id: "" },
{ name: "", id: "" }, { name: "", id: "" },
{ name: "", id: "" }, { name: "", id: "" },
], ],
// 商品类型
goodsType: "", goodsType: "",
pendingGoodsType: "", pendingGoodsType: "",
/** 1级分类列表*/
categoryListLevel1: [], categoryListLevel1: [],
/** 2级分类列表*/
categoryListLevel2: [], categoryListLevel2: [],
/** 3级分类列表*/
categoryListLevel3: [], categoryListLevel3: [],
}; };
}, },
computed: {
selectedTypeTitle() {
const hit = this.goodsTypeWay.find((item) => item.type === this.goodsType);
return hit ? hit.title : "";
},
},
watch: { watch: {
selectGoodsType(val) { selectGoodsType(val) {
if (val) { if (val) {
@@ -135,19 +152,12 @@ export default {
methods: { methods: {
syncGoodsTypeSelection() { syncGoodsTypeSelection() {
this.pendingGoodsType = this.goodsType; this.pendingGoodsType = this.goodsType;
this.goodsTypeWay.forEach((item) => {
item.check = item.type === this.goodsType;
});
}, },
openGoodsTypeDialog() { openGoodsTypeDialog() {
this.selectGoodsType = true; this.selectGoodsType = true;
}, },
// 点击商品类型(仅临时选中,确认后才生效) handleClickGoodsType(item) {
handleClickGoodsType(val) { this.pendingGoodsType = item.type;
this.goodsTypeWay.forEach((item) => {
item.check = item.type === val.type;
});
this.pendingGoodsType = val.type;
}, },
cancelGoodsType() { cancelGoodsType() {
this.syncGoodsTypeSelection(); this.syncGoodsTypeSelection();
@@ -161,7 +171,6 @@ export default {
this.goodsType = this.pendingGoodsType; this.goodsType = this.pendingGoodsType;
this.selectGoodsType = false; this.selectGoodsType = false;
}, },
/** 选择商城商品分类 */
handleSelectCategory(row, index, level) { handleSelectCategory(row, index, level) {
if (level === 1) { if (level === 1) {
this.category.forEach((cate) => { this.category.forEach((cate) => {
@@ -182,20 +191,18 @@ export default {
this.category[2].id = row.id; this.category[2].id = row.id;
} }
}, },
/** 查询下一级 商城商品分类*/ GET_NextLevelCategory() {
GET_NextLevelCategory(row) {
const _id = row && row.id !== 0 ? row.id : 0;
API_GOODS.getGoodsCategoryAll().then((res) => { API_GOODS.getGoodsCategoryAll().then((res) => {
if (res.success && res.result) { if (res.success && res.result) {
this.categoryListLevel1 = res.result; this.categoryListLevel1 = res.result;
} }
}); });
}, },
// 下一步
next() { next() {
window.scrollTo(0, 0); window.scrollTo(0, 0);
if (!this.goodsType) { if (!this.goodsType) {
this.$Message.error("请选择商品类型"); this.$Message.error("请选择商品类型");
this.openGoodsTypeDialog();
return; return;
} }
if (!this.category[0].name) { if (!this.category[0].name) {
@@ -204,13 +211,11 @@ export default {
} else if (!this.category[2].name) { } else if (!this.category[2].name) {
this.$Message.error("必须选择到三级分类"); this.$Message.error("必须选择到三级分类");
return; 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() { mounted() {
@@ -246,4 +251,78 @@ export default {
color: #fff; 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> </style>

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,9 @@
@click="checkLogistics" @click="checkLogistics"
>查看物流</el-button> >查看物流</el-button>
<el-button type="primary" @click="orderLog">订单日志</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 v-if="canShip" type="primary" @click="openFulfill()">发货</el-button>
<el-button type="primary" @click="modifyRemark">添加备注</el-button> <el-button type="primary" @click="modifyRemark">添加备注</el-button>
<el-button <el-button
@@ -65,10 +67,33 @@
{{ orderInfo.order.createTime }} {{ orderInfo.order.createTime }}
</div> </div>
</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-left">核验码</div>
<div class="div-item-right">{{ orderInfo.order.verificationCode }}</div> <div class="div-item-right">{{ orderInfo.order.verificationCode }}</div>
</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>
<div style="width: 30%; float: left; margin-left: 20px"> <div style="width: 30%; float: left; margin-left: 20px">
<div class="div-item" v-if="orderInfo.order.needReceipt == false"> <div class="div-item" v-if="orderInfo.order.needReceipt == false">
@@ -805,15 +830,76 @@
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="orderTakeModal" title="订单核销" width="530px"> <el-dialog
<el-form ref="orderTakeForm" :model="orderTakeForm" label-width="100px" :rules="orderTakeValidate"> 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-form-item label="核销码" prop="qrCode">
<el-input v-model="orderTakeForm.qrCode" placeholder="请输入核销码" maxlength="10" clearable /> <el-input v-model="orderTakeForm.qrCode" placeholder="请输入核销码" maxlength="10" clearable />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="orderTakeModal = false">取消</el-button> <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> </template>
</el-dialog> </el-dialog>
@@ -844,6 +930,12 @@ import {
resolveDefaultFulfillmentMode, resolveDefaultFulfillmentMode,
canShowShipButton, canShowShipButton,
} from "@/utils/orderFulfill"; } from "@/utils/orderFulfill";
import {
getEnabledServiceStaff,
getAppointmentVerificationByOrderSn,
verifyAppointmentCode,
} from "@/api/appointment";
import { WORK_ORDER_STATUS_LABEL, parseFormAnswerGroups } from "@/constants/appointment";
export default { export default {
name: "orderDetail", name: "orderDetail",
components: { components: {
@@ -921,6 +1013,23 @@ export default {
orderTakeForm: { orderTakeForm: {
qrCode: "", 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: { orderTakeValidate: {
qrCode: [{ required: true, message: "订单核销码不能为空", trigger: "blur" }], qrCode: [{ required: true, message: "订单核销码不能为空", trigger: "blur" }],
}, },
@@ -967,7 +1076,27 @@ export default {
return t === "VIRTUAL" || this.$route.query.orderType === "VIRTUAL"; return t === "VIRTUAL" || this.$route.query.orderType === "VIRTUAL";
}, },
isNonPhysicalOrder() { 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() { hasCardKeySection() {
return orderHasCardKeySection({ return orderHasCardKeySection({
@@ -997,6 +1126,12 @@ export default {
}); });
}, },
canOrderTake() { 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; if (this.allowOperation.take) return true;
const order = this.orderInfo?.order; const order = this.orderInfo?.order;
if (!order) return false; if (!order) return false;
@@ -1299,6 +1434,9 @@ export default {
: []; : [];
this.getContentPrice(); this.getContentPrice();
this.getOrderPrice(); this.getOrderPrice();
if (this.isAppointmentOrder) {
this.loadAppointmentVerification();
}
if ( if (
this.autoOpenFulfill && this.autoOpenFulfill &&
this.canShip && this.canShip &&
@@ -1487,13 +1625,73 @@ export default {
handelCancel () { handelCancel () {
this.orderLogModal = false; 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() { 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.orderTakeForm.qrCode = this.orderInfo.order.verificationCode || "";
this.orderTakeModal = true; this.orderTakeModal = true;
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.orderTakeForm?.clearValidate(); 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() { orderTakeSubmit() {
this.$refs.orderTakeForm.validate((valid) => { this.$refs.orderTakeForm.validate((valid) => {
if (!valid) return; if (!valid) return;