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
*/
export const E_COUPON_GOODS_TYPE = "E_COUPON";
export const APPOINTMENT_GOODS_TYPE = "APPOINTMENT_GOODS";
export function isECoupon(goodsType) {
return goodsType === E_COUPON_GOODS_TYPE;
}
export function isAppointmentGoods(goodsType) {
return goodsType === APPOINTMENT_GOODS_TYPE;
}
export function goodsTypeLabel(goodsType) {
const map = {
E_COUPON: "电子卡券",
APPOINTMENT_GOODS: "预约商品",
VIRTUAL_GOODS: "虚拟商品",
PHYSICAL_GOODS: "实物商品",
};
@@ -21,6 +27,7 @@ export function goodsTypeLabel(goodsType) {
export function goodsTypeTagType(goodsType) {
if (goodsType === E_COUPON_GOODS_TYPE) return "warning";
if (goodsType === APPOINTMENT_GOODS_TYPE) return "success";
if (goodsType === "VIRTUAL_GOODS") return "info";
return "";
}

View File

@@ -128,6 +128,18 @@ export const otherRouter = {
name: "order-detail",
component: () => import("@/views/order/order/orderDetail.vue")
},
{
path: "appointment/work-orders",
title: "预约工单",
name: "manager-appointment-work-orders",
component: () => import("@/views/appointment/workOrderList.vue")
},
{
path: "appointment/order-settings",
title: "预约订单设置",
name: "manager-appointment-order-settings",
component: () => import("@/views/appointment/orderSettings.vue")
},
{
path: "after-order-detail",
title: "售后单详情",

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

View File

@@ -6,6 +6,8 @@
<el-button v-if="allowOperation.editPrice" @click="modifyPrice">调整价格</el-button>
<el-button v-if="allowOperation.editConsignee" plain type="primary" @click="editAddress">修改收货地址</el-button>
<el-button v-if="allowOperation.cancel" plain type="warning" @click="orderCancel">订单取消</el-button>
<el-button v-if="isAppointmentOrder && canPlatformRefund" plain type="danger" @click="openPlatformRefund">平台退款</el-button>
<el-button v-if="isAppointmentOrder && canNoShowRefund" plain type="danger" @click="openNoShowRefund">爽约退款</el-button>
<el-button v-if="orderInfo.order.orderStatus === 'UNPAID'" type="primary" @click="confirmPrice">收款</el-button>
<el-button plain @click="orderLog">订单日志</el-button>
<el-button v-if="!isNonPhysicalOrder" plain type="primary" style="float:right;" @click="printOrder">打印发货单</el-button>
@@ -37,6 +39,20 @@
{{ orderInfo.order.createTime }}
</div>
</div>
<div class="div-item" v-if="isAppointmentOrder && appointmentSlotText">
<div class="div-item-left">预约时段</div>
<div class="div-item-right">{{ appointmentSlotText }}</div>
</div>
<div
class="div-item"
v-for="(rows, gi) in appointmentFormGroups"
:key="'form-' + gi"
>
<div class="div-item-left">{{ appointmentFormGroups.length > 1 ? `补充信息${gi + 1}` : '补充信息:' }}</div>
<div class="div-item-right">
<div v-for="row in rows" :key="row.fieldKey">{{ row.fieldLabel }}{{ row.value }}</div>
</div>
</div>
</div>
<div style="width: 30%; float: left; margin-left: 20px">
<div class="div-item" v-if="orderInfo.order.needReceipt == false">
@@ -463,11 +479,48 @@
<multipleMap ref="map" @callback="selectedRegion" />
<el-dialog v-model="platformRefundModal" title="平台预约退款" width="520px">
<el-form :model="platformRefundForm" label-width="100px">
<el-form-item label="退款类型">
<el-radio-group v-model="platformRefundForm.refundType">
<el-radio label="UNSERVED">未服务</el-radio>
<el-radio label="SERVED">已服务</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="退款金额">
<el-input-number v-model="platformRefundForm.refundAmount" :min="0.01" :precision="2" />
</el-form-item>
<el-form-item label="退款原因">
<el-input v-model="platformRefundForm.reason" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="platformRefundModal = false">取消</el-button>
<el-button type="primary" :loading="refundLoading" @click="submitPlatformRefund">确认退款</el-button>
</template>
</el-dialog>
<el-dialog v-model="noShowRefundModal" title="上门爽约退款" width="520px">
<el-form :model="noShowRefundForm" label-width="100px">
<el-form-item label="退款金额">
<el-input-number v-model="noShowRefundForm.refundAmount" :min="0.01" :precision="2" />
</el-form-item>
<el-form-item label="退款原因">
<el-input v-model="noShowRefundForm.reason" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="noShowRefundModal = false">取消</el-button>
<el-button type="primary" :loading="refundLoading" @click="submitNoShowRefund">确认退款</el-button>
</template>
</el-dialog>
</div>
</template>
<script>
import * as API_Order from "@/api/order";
import { platformRefund, noShowRefund, getAppointmentOrderDetail } from "@/api/appointment";
import * as RegExp from "@/libs/RegExp.js";
import multipleMap from "@/components/map/multiple-map";
@@ -494,7 +547,28 @@ export default {
return t === "VIRTUAL" || this.$route.query.orderType === "VIRTUAL";
},
isNonPhysicalOrder() {
return this.isVirtualOrder || this.isECouponOrder;
return this.isVirtualOrder || this.isECouponOrder || this.isAppointmentOrder;
},
isAppointmentOrder() {
const t = this.orderInfo?.order?.orderType;
return t === "APPOINTMENT" || this.$route.query.orderType === "APPOINTMENT";
},
appointmentSlotText() {
const v = this.appointmentExt || {};
if (!v.slotDate) return "";
const date = String(v.slotDate).slice(0, 10);
const slot = v.slotStartTime && v.slotEndTime ? `${v.slotStartTime}-${v.slotEndTime}` : "";
return [date, slot].filter(Boolean).join(" ");
},
appointmentFormGroups() {
return this.parseFormAnswerGroups(this.appointmentExt?.formAnswerJson);
},
canPlatformRefund() {
const order = this.orderInfo?.order;
return order && order.payStatus === "PAID" && order.orderStatus !== "CANCELLED";
},
canNoShowRefund() {
return this.canPlatformRefund;
},
ecouponCardKeyDelivered() {
return (this.data || []).some((item) => item.cardKeyDelivered);
@@ -529,6 +603,7 @@ export default {
priceDetailDTO: {},
},
},
appointmentExt: {},
modal: false, //弹出调整价格框
searchForm: {
pageNumber: 1, // 当前页数
@@ -568,6 +643,18 @@ export default {
},
addressModal: false, //弹出修改收件信息框
printModal: false,
platformRefundModal: false,
noShowRefundModal: false,
refundLoading: false,
platformRefundForm: {
refundType: "UNSERVED",
refundAmount: 0,
reason: "",
},
noShowRefundForm: {
refundAmount: 0,
reason: "",
},
//收件地址表单
addressForm: {
consigneeName: "",
@@ -690,9 +777,51 @@ export default {
this.typeList = JSON.parse(JSON.stringify(res.result.order.priceDetailDTO.discountPriceDetail));
this.getContentPrice()
this.getOrderPrice()
if (this.isAppointmentOrder) {
this.loadAppointmentExt();
}
}
});
},
loadAppointmentExt() {
getAppointmentOrderDetail(this.sn)
.then((res) => {
if (res.success) {
this.appointmentExt = res.result || {};
}
})
.catch(() => {
this.appointmentExt = {};
});
},
parseFormAnswerGroups(formAnswerJson) {
if (!formAnswerJson) return [];
let data = formAnswerJson;
if (typeof data === "string") {
try {
data = JSON.parse(data);
} catch (e) {
return [];
}
}
const fields = Array.isArray(data?.fields) ? data.fields : [];
const answers = Array.isArray(data?.answers) ? data.answers : [];
return answers
.map((group) => {
const map = group && typeof group === "object" && !Array.isArray(group) ? group : {};
const source = fields.length
? fields
: Object.keys(map).map((key) => ({ fieldKey: key, fieldLabel: key }));
return source
.map((field) => ({
fieldKey: field.fieldKey,
fieldLabel: field.fieldLabel || field.fieldKey,
value: map[field.fieldKey] ?? "",
}))
.filter((row) => row.value !== "" && row.value != null);
})
.filter((rows) => rows.length);
},
modifyPrice () {
//默认要修改的金额为订单总金额
this.modifyPriceForm.price = this.orderInfo.order.flowPrice;
@@ -744,6 +873,57 @@ export default {
}
});
},
openPlatformRefund() {
this.platformRefundForm = {
refundType: "UNSERVED",
refundAmount: this.orderInfo?.order?.flowPrice || 0,
reason: "",
};
this.platformRefundModal = true;
},
submitPlatformRefund() {
if (!this.platformRefundForm.reason) {
ElMessage.warning("请填写退款原因");
return;
}
this.refundLoading = true;
platformRefund(this.sn, this.platformRefundForm)
.then((res) => {
if (res.success) {
ElMessage.success("退款成功");
this.platformRefundModal = false;
this.getDataList();
}
})
.finally(() => {
this.refundLoading = false;
});
},
openNoShowRefund() {
this.noShowRefundForm = {
refundAmount: this.orderInfo?.order?.flowPrice || 0,
reason: "",
};
this.noShowRefundModal = true;
},
submitNoShowRefund() {
if (!this.noShowRefundForm.reason) {
ElMessage.warning("请填写退款原因");
return;
}
this.refundLoading = true;
noShowRefund(this.sn, this.noShowRefundForm)
.then((res) => {
if (res.success) {
ElMessage.success("退款成功");
this.noShowRefundModal = false;
this.getDataList();
}
})
.finally(() => {
this.refundLoading = false;
});
},
//订单日志
orderLog () {
this.orderLogModal = true;

View File

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

View File

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