refactor(goods): 重构商品管理模块的API接口和页面结构

- 移除manager端的预约改期日志相关API接口
- 移除seller端的预约改期相关API接口
- 更新seller端API请求方法,将putRequest替换为putRequestWithNoForm
- 修改服务人员更新接口,使用POST方式并传入ID参数
- 重构seller端商品搜索表单,增加SKU名称、SKU货号、店铺分类等搜索条件
- 新增商品创建时间范围选择器和库存状态筛选功能
- 优化商品表格列结构,调整销售模式与商品类型显示逻辑
- 添加商品复制功能按钮
- 重构manager端商品详情页面,实现新的详情展示布局
- 新增商品审核弹窗和时段可约数量抽屉组件
- 优化商品详情页的商品参数展示逻辑
- 添加预约商品相关的特殊处理逻辑
This commit is contained in:
pikachu1995@126.com
2026-09-28 14:24:24 +08:00
parent 1bad9a14db
commit 037fd0caa0
24 changed files with 3438 additions and 593 deletions

View File

@@ -147,6 +147,7 @@ LILISHOP 是基于 Spring Boot / Spring Cloud / Vue / Uniapp 开发的 Java 开
- ✅ 卡密商品
- ✅ 二级分销
- ✅ 定时任务管理
- ✅ 服务商品
- 🔜 主题色管理
- 🔜 店铺结算汇总
- 🔜 支付方式汇总

View File

@@ -30,13 +30,16 @@
>
<div class="order-header">
<div>
<div>{{ filterOrderStatus(order.orderStatus) }}</div>
<div>
<div>{{ order.isAfterSale ? filterAfterSaleStatus(order.serviceStatus) : filterOrderStatus(order.orderStatus) }}</div>
<div v-if="order.isAfterSale">
售后单号:{{ order.serviceSn }} &nbsp; &nbsp; &nbsp;{{ order.createTime }}
</div>
<div v-else>
订单号:{{ order.sn }} &nbsp; &nbsp; &nbsp;{{order.createTime}}
</div>
</div>
<div>
<el-button v-if="order.orderStatus === 'COMPLETED'" @click="delOrder(order.sn)" class="del-btn mr_10 fontsize_16" style="margin-top:-5px;" type="text" icon="ios-trash-outline" size="small"></el-button>
<el-button v-if="!order.isAfterSale && order.orderStatus === 'COMPLETED'" @click="delOrder(order.sn)" class="del-btn mr_10 fontsize_16" style="margin-top:-5px;" type="text" icon="ios-trash-outline" size="small"></el-button>
<span>{{ $filters.unitPrice(order.flowPrice, "¥") }}</span>
</div>
</div>
@@ -66,7 +69,10 @@
<div>
<span @click="shopPage(order.storeId)">{{ order.storeName }}</span>
</div>
<div class="order-actions">
<div class="order-actions" v-if="order.isAfterSale">
<el-button @click="goAfterSaleDetail(order.serviceSn)" type="warning" size="small">售后详情</el-button>
</div>
<div class="order-actions" v-else>
<!-- 订单基础操作 -->
<el-button @click="orderDetail(order.sn)" type="warning" size="small">订单详情</el-button>
<el-button @click="handleCancelOrder(order.sn)" type="danger" v-if="order.allowOperationVO.cancel" size="small">取消订单</el-button>
@@ -99,13 +105,16 @@
>
<div class="order-header">
<div>
<div>{{ filterOrderStatus(order.orderStatus) }}</div>
<div>
<div>{{ order.isAfterSale ? filterAfterSaleStatus(order.serviceStatus) : filterOrderStatus(order.orderStatus) }}</div>
<div v-if="order.isAfterSale">
售后单号:{{ order.serviceSn }} &nbsp; &nbsp; &nbsp;{{ order.createTime }}
</div>
<div v-else>
订单号:{{ order.sn }} &nbsp; &nbsp; &nbsp;{{order.createTime}}
</div>
</div>
<div>
<el-button v-if="order.orderStatus === 'COMPLETED'" @click="delOrder(order.sn)" class="del-btn mr_10 fontsize_16" style="margin-top:-5px;" type="text" icon="ios-trash-outline" size="small"></el-button>
<el-button v-if="!order.isAfterSale && order.orderStatus === 'COMPLETED'" @click="delOrder(order.sn)" class="del-btn mr_10 fontsize_16" style="margin-top:-5px;" type="text" icon="ios-trash-outline" size="small"></el-button>
<span>{{ $filters.unitPrice(order.flowPrice, "¥") }}</span>
</div>
</div>
@@ -135,7 +144,10 @@
<div>
<span @click="shopPage(order.storeId)">{{ order.storeName }}</span>
</div>
<div class="order-actions">
<div class="order-actions" v-if="order.isAfterSale">
<el-button @click="goAfterSaleDetail(order.serviceSn)" type="warning" size="small">售后详情</el-button>
</div>
<div class="order-actions" v-else>
<!-- 订单基础操作 -->
<el-button @click="orderDetail(order.sn)" type="warning" size="small">订单详情</el-button>
<el-button @click="handleCancelOrder(order.sn)" type="danger" v-if="order.allowOperationVO.cancel" size="small">取消订单</el-button>
@@ -178,8 +190,8 @@
<script>
import { Message, Modal } from "@/utils/message";
import { getOrderList, sureReceived, cancelOrder, delOrder } from '@/api/order';
import { afterSaleReason } from '@/api/member';
import { orderStatusList } from '../enumeration.js'
import { afterSaleReason, afterSaleList } from '@/api/member';
import { orderStatusList, afterSaleStatusList } from '../enumeration.js'
export default {
name: 'MyOrder',
props: {
@@ -204,7 +216,8 @@ export default {
},
// 状态数组
orderStatusList,
changeWay: ['全部订单', '待付款', '待收货', '已完成'], // 订单状态
afterSaleStatusList,
changeWay: ['全部', '待支付', '待发货', '待收货/使用', '评价', '售后'],
activeOrderTab: 0,
total: 0, // 数据总数
spinShow: false, // 加载状态
@@ -246,19 +259,11 @@ export default {
change (index) {
this.activeOrderTab = index;
this.params.pageNumber = 1;
switch (index) {
case 0:
this.params.tag = 'ALL'
break;
case 1:
this.params.tag = 'WAIT_PAY'
break;
case 2:
this.params.tag = 'WAIT_ROG'
break;
case 3:
this.params.tag = 'COMPLETE'
break;
delete this.params.commentStatus;
const tags = ['ALL', 'WAIT_PAY', 'WAIT_SHIP', 'WAIT_USE', 'ALL', 'AFTER_SALE'];
this.params.tag = tags[index] || 'ALL';
if (index === 4) {
this.params.commentStatus = 'UNFINISHED';
}
this.getList()
},
@@ -271,9 +276,11 @@ export default {
window.open(routeUrl.href, '_blank');
},
orderDetail (sn) {
// 跳转订单详情
this.$router.push({ name: 'OrderDetail', query: {sn} });
},
goAfterSaleDetail (sn) {
this.$router.push({ name: 'AfterSaleDetail', query: { sn } });
},
received (sn) { // 确认收货
sureReceived(sn).then(res => {
if (res.success) {
@@ -324,12 +331,31 @@ export default {
onCancel: () => {}
});
},
getList () { // 获取订单列表
getList () {
this.spinShow = true;
if (this.activeOrderTab === 5) {
afterSaleList({
pageNumber: this.params.pageNumber,
pageSize: this.params.pageSize,
keywords: this.params.keywords,
sort: 'createTime',
order: 'desc',
}).then(res => {
this.spinShow = false
if (res.success) {
this.orderList = (res.result.records || []).map(item => this.mapAfterSaleOrder(item));
this.total = res.result.total;
}
});
return;
}
let params = JSON.parse(JSON.stringify(this.params))
if (params.orderStatus === 'ALL') {
delete params.orderStatus
}
if (params.tag === 'AFTER_SALE') {
delete params.tag
}
getOrderList(params).then(res => {
this.spinShow = false
if (res.success) {
@@ -338,6 +364,27 @@ export default {
}
});
},
mapAfterSaleOrder (item) {
return {
...item,
isAfterSale: true,
serviceSn: item.sn,
sn: item.orderSn,
orderStatus: item.serviceStatus,
flowPrice: item.applyRefundPrice,
orderItems: [
{
image: item.goodsImage,
name: item.goodsName,
skuId: item.skuId,
goodsId: item.goodsId,
goodsPrice: item.flowPrice,
num: item.num,
},
],
allowOperationVO: {},
}
},
changePageNum (val) { // 修改页码
this.params.pageNumber = val;
this.getList()
@@ -370,6 +417,10 @@ export default {
filterOrderStatus (status) { // 获取订单状态中文
const ob = this.orderStatusList.filter(e => { return e.status === status });
return ob && ob[0] ? ob[0].name : status
},
filterAfterSaleStatus (status) {
const ob = this.afterSaleStatusList.filter(e => e.status === status);
return ob && ob[0] ? ob[0].name : status
}
}
};

View File

@@ -6,9 +6,6 @@ import { getRequest, postRequestWithNoForm, putRequestWithNoForm } from "@/libs/
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) =>

File diff suppressed because it is too large Load Diff

View File

@@ -722,7 +722,7 @@ export default {
if (type.indexOf("FULL_DISCOUNT") != -1) resultText += "满减 ";
if (type.indexOf("POINTS_GOODS") != -1) resultText += "积分商品 ";
}
return resultText === "" ? "暂无未参与任何促销" : resultText;
return resultText === "" ? "未参与" : resultText;
},
gotoHomes () {
return false

View File

@@ -9,9 +9,10 @@ import {
putRequest,
deleteRequest,
postRequestWithNoForm,
putRequestWithNoForm,
} from "@/libs/axios";
/** 店铺预约通用配置(改期审核等) */
/** 店铺预约通用配置 */
export const getAppointmentSettings = () => getRequest("/appointment/settings");
export const saveAppointmentSettings = (params) =>
putRequest("/appointment/settings", params);
@@ -19,12 +20,12 @@ export const saveAppointmentSettings = (params) =>
/** 到店服务设置 */
export const getInStoreSettings = () => getRequest("/appointment/settings/in-store");
export const saveInStoreSettings = (params) =>
putRequest("/appointment/settings/in-store", params);
putRequestWithNoForm("/appointment/settings/in-store", params);
/** 上门服务设置 */
export const getOnSiteSettings = () => getRequest("/appointment/settings/on-site");
export const saveOnSiteSettings = (params) =>
putRequest("/appointment/settings/on-site", params);
putRequestWithNoForm("/appointment/settings/on-site", params);
/** 服务人员 §14 */
export const getServiceStaffPage = (params) =>
@@ -32,7 +33,7 @@ export const getServiceStaffPage = (params) =>
export const createServiceStaff = (params) =>
postRequestWithNoForm("/appointment/service-staff", params);
export const updateServiceStaff = (id, params) =>
putRequest(`/appointment/service-staff/${id}`, params);
postRequestWithNoForm("/appointment/service-staff", { ...params, id });
export const updateServiceStaffStatus = (id, status) =>
putRequest(`/appointment/service-staff/${id}/status`, { status });
export const deleteServiceStaff = (id) =>
@@ -69,13 +70,3 @@ export const getServiceCalendarDay = (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

@@ -55,6 +55,11 @@ export const userInfo = params => {
export const userMsg = params => {
return getRequest('/settings/storeSettings', params)
}
/** 当前店铺首页小程序葵花码 */
export const getStoreMpQrcode = () => {
return getRequest("/settings/storeSettings/mp-qrcode");
};
//验证短信验证码
export const validateCode = params => {
return postRequestWithNoToken("/passport/login/resetByMobile", params);

View File

@@ -126,6 +126,18 @@ export const orderTake = (sn, verificationCode) => {
return putRequest(`/order/order/take/${sn}/${verificationCode}`);
};
export const previewVerification = (code) => {
return getRequest(`/order/order/verification/preview`, { code });
};
export const confirmVerification = (code) => {
return postRequestWithNoForm(`/order/order/verification/confirm`, { code });
};
export const getVerificationRecords = (params) => {
return getRequest(`/order/order/verification/records`, params);
};
// 售后服务单
export const afterSaleOrderPage = params => {
return getRequest(`/order/afterSale/page`, params);

View File

@@ -15,6 +15,12 @@ module.exports = {
//FIXME 请检查当前高德key创建的日期,如果2021年12月02日之前申请的 无需填写安全密钥
aMapSecurityJsCode: "2bd0fbf621881f4c77be74f0e76495f3", // 高德web端js申请的安全密钥
aMapKey: "7f11113750315d8543daaf5c3ba353ca", //高德web端js申请的api key
/**
* 腾讯位置服务 JavaScript API GL,用于上门服务电子围栏
* https://lbs.qq.com/webApi/javascriptGL/glGuide/glOverview
* 与后端 lili.lbs.key 同一把 Key,控制台需开通 JavaScript API GL
*/
tMapKey: "ACCBZ-MINKJ-CZ2FN-DJFLN-GKORV-B3BIL",
website: "https://www.pickmall.cn", //官网地址
aMapSwitch:false, //是否开启高德定位
enableCDN: true, //生产环境 是否启用cdn加载 vue等js

View File

@@ -92,6 +92,16 @@ export const result = [{
path: "virtualOrderList",
component: "order/order/virtualOrderList",
children: null
},
{
name: "orderVerification",
level: 2,
type: 0,
keepAlive: true,
title: "订单核销",
path: "orderVerification",
component: "order/order/orderVerification",
children: null
}
]
},

View File

@@ -114,12 +114,6 @@ export const otherRouter = {
name: "appointment-system-form-designer",
component: () => import("@/views/appointment/systemFormDesigner.vue")
},
{
path: "appointment/reschedule-logs",
title: "改期记录",
name: "appointment-reschedule-logs",
component: () => import("@/views/appointment/rescheduleLogList.vue")
},
{
path: "add-coupon",
title: "店铺优惠券",
@@ -162,6 +156,12 @@ export const otherRouter = {
name: "order-detail",
component: () => import("@/views/order/order/orderDetail.vue")
},
{
path: "orderVerification",
title: "订单核销",
name: "orderVerification",
component: () => import("@/views/order/order/orderVerification.vue")
},
{
path: "/floorList/main",
title: "移动装修",

View File

@@ -20,6 +20,7 @@
align="middle"
class="user-dropdown-innercon"
>
<store-qrcode />
<ul class="nav-list">
<li class="nav-item" @click="im">
<el-tooltip content="联系客服" placement="bottom">
@@ -84,6 +85,7 @@ import shrinkableMenu from "./main-components/shrinkable-menu/shrinkable-menu.vu
import tagsPageOpened from "./main-components/tags-page-opened.vue";
import circleLoading from "@/views/my-components/lili/circle-loading.vue";
import configDrawer from "@/views/main-components/config-drawer.vue";
import storeQrcode from "@/views/main-components/store-qrcode.vue";
import Cookies from "js-cookie";
import util from "@/libs/util.js";
import { logout } from "@/api/index";
@@ -100,6 +102,7 @@ export default {
tagsPageOpened,
circleLoading,
configDrawer,
storeQrcode,
},
data() {
return {

View File

@@ -0,0 +1,878 @@
<template>
<div class="fence">
<div class="fence-toolbar">
<el-input
v-model="keyword"
placeholder="搜索地点,定位后再绘制"
clearable
@keyup.enter="search"
/>
<el-button @click="search">搜索</el-button>
<el-button :type="mode === 'draw' ? 'primary' : ''" @click="drawing && pointCount ? finishDraw() : startDraw()">
{{ drawing && pointCount ? "完成绘制" : "绘制模式" }}
</el-button>
<el-button :type="mode === 'edit' ? 'primary' : ''" @click="startEdit">编辑模式</el-button>
<el-button :disabled="mode !== 'edit'" @click="deleteSelected">删除选中</el-button>
</div>
<ul v-if="tips.length" class="fence-tips">
<li v-for="(tip, index) in tips" :key="index" @click="selectTip(tip)">
<p>{{ tip.name }}</p>
<p>{{ tip.address }}</p>
</li>
</ul>
<div ref="mapEl" class="fence-map"></div>
<div v-if="drawing && pointCount" class="fence-hint">当前图形 {{ pointCount }} 个顶点。</div>
<div v-else-if="areaCount" class="fence-hint">已绘制 {{ areaCount }} 个区域。</div>
<ul class="fence-desc">
<li><span>绘制模式</span>鼠标左键点击及移动即可绘制图形,双击结束绘制。可以绘制多个区域</li>
<li><span>编辑模式</span>可以拖拽顶点调整多边形形状,点击边线添加新顶点,地图会自动定位到绘制区域中心</li>
<li><span>删除功能</span>编辑模式下选中多边形后点击「删除选中」删除指定区域</li>
<li><span>多区域支持</span>支持绘制和编辑多个独立的服务区域,每个区域可以单独编辑和删除</li>
<li><span>其他操作</span>ESC 键中断绘制,Ctrl+Z 撤销操作,右键撤销绘制点</li>
</ul>
</div>
</template>
<script>
import { markRaw } from "vue";
import config from "@/config/index";
const OVERLAY_ID = "polygon";
export default {
name: "ServiceAreaFence",
props: {
modelValue: {
type: String,
default: "",
},
visible: {
type: Boolean,
default: true,
},
},
emits: ["update:modelValue"],
data() {
return {
keyword: "",
tips: [],
pointCount: 0,
areaCount: 0,
drawing: false,
mode: "edit",
ready: false,
};
},
created() {
this.map = null;
this.editor = null;
this.polygon = null;
this.suggestion = null;
this.TMap = null;
this.geometryId = "";
this.draftRing = [];
this.clickRing = [];
this.areas = [];
this.booting = false;
this.alive = true;
},
watch: {
visible(val) {
if (!val) {
this.destroyMap();
return;
}
this.$nextTick(() => this.scheduleInit());
},
modelValue(val) {
if (!this.ready || val === this.currentJson()) {
return;
}
this.renderPolygons(parsePolygons(val));
},
},
mounted() {
this.observer = new ResizeObserver(() => {
if (this.map) {
this.resizeMap();
return;
}
this.scheduleInit();
});
if (this.$refs.mapEl) {
this.observer.observe(this.$refs.mapEl);
}
this.scheduleInit();
},
beforeUnmount() {
this.alive = false;
if (this.observer) {
this.observer.disconnect();
}
this.destroyMap();
},
methods: {
scheduleInit() {
if (this.map || this.booting || !this.visible || !this.alive) {
return;
}
const el = this.$refs.mapEl;
if (!el || el.clientWidth < 10 || el.clientHeight < 10) {
return;
}
this.initMap();
},
initMap() {
if (!config.tMapKey) {
this.$message.error("未配置腾讯地图 Key");
return;
}
const el = this.$refs.mapEl;
if (!el || el.clientWidth < 10 || el.clientHeight < 10) {
return;
}
this.booting = true;
loadTMap(config.tMapKey)
.then((TMap) => {
if (!this.alive || !this.visible) {
this.booting = false;
return;
}
const box = this.$refs.mapEl;
if (!box || box.clientWidth < 10 || box.clientHeight < 10) {
this.booting = false;
return;
}
this.TMap = markRaw(TMap);
this.map = markRaw(
new TMap.Map(box, {
zoom: 11,
center: new TMap.LatLng(39.90923, 116.397428),
doubleClickZoom: false,
})
);
if (typeof this.map.setDoubleClickZoom === "function") {
this.map.setDoubleClickZoom(false);
}
this.bindFinishGesture(box);
const polygonStyle = new TMap.PolygonStyle({
color: "rgba(64,158,255,0.25)",
showBorder: true,
borderColor: "#409EFF",
borderWidth: 2,
});
this.polygon = markRaw(
new TMap.MultiPolygon({
map: this.map,
styles: {
default: polygonStyle,
highlight: polygonStyle,
},
geometries: [],
})
);
this.editor = markRaw(
new TMap.tools.GeometryEditor({
map: this.map,
overlayList: [
{
id: OVERLAY_ID,
overlay: this.polygon,
drawingStyleId: "highlight",
selectedStyleId: "highlight",
},
],
actionMode: TMap.tools.constants.EDITOR_ACTION.INTERACT,
activeOverlayId: OVERLAY_ID,
selectable: true,
snappable: true,
enableKeyboardEsc: true,
enableUndoDraw: true,
})
);
if (typeof this.editor.setSnapPolyStartPointOnDrawing === "function") {
this.editor.setSnapPolyStartPointOnDrawing(true);
}
if (typeof this.editor.setUndoDrawEnable === "function") {
this.editor.setUndoDrawEnable(true);
}
if (typeof this.editor.setKeyboardEscEnable === "function") {
this.editor.setKeyboardEscEnable(true);
}
this.map.on("click", (event) => this.onMapClick(event));
this.editor.on("drawing", (evt) => this.onDrawing(evt));
this.editor.on("draw_complete", (geometry) => this.onDrawComplete(geometry));
this.editor.on("draw_error", () => {
this.$message.warning("围栏不能自相交,请调整顶点后再结束");
});
this.editor.on("adjust_complete", (geometry) => this.onAdjustComplete(geometry));
this.editor.on("delete_complete", (list) => this.onDeleteComplete(list));
this.suggestion = markRaw(new TMap.service.Suggestion({ pageSize: 8 }));
this.ready = true;
this.booting = false;
this.renderPolygons(parsePolygons(this.modelValue));
this.$nextTick(() => this.resizeMap());
})
.catch((error) => {
this.destroyMap();
const text = String(error && error.message ? error.message : error);
if (text.includes("far <= 0") || text.includes("near <= 0")) {
return;
}
this.$message.error("腾讯地图加载失败,请检查 Key 是否开通 JavaScript API GL");
});
},
resizeMap() {
const el = this.$refs.mapEl;
if (!this.map || !el || el.clientWidth < 10 || el.clientHeight < 10) {
return;
}
if (typeof this.map.resize === "function") {
this.map.resize();
}
},
search() {
const keyword = (this.keyword || "").trim();
if (!this.suggestion || !keyword) {
this.tips = [];
return;
}
this.suggestion
.getSuggestions({ keyword })
.then((result) => {
this.tips = (result.data || [])
.filter((item) => item.location)
.map((item) => ({
name: item.title,
address: item.address || "",
location: item.location,
}));
})
.catch(() => {
this.tips = [];
});
},
selectTip(tip) {
if (!tip || !tip.location || !this.map) {
return;
}
this.map.setCenter(tip.location);
this.map.setZoom(15);
this.tips = [];
},
bindFinishGesture(box) {
this.unbindFinishGesture();
this.mapBox = box;
this.onMapDblClick = (event) => {
if (!this.drawing || this.pickedCount() < 3) {
return;
}
event.preventDefault();
event.stopPropagation();
this.finishDraw();
};
this.onMapContextMenu = (event) => {
if (this.mode !== "draw") {
return;
}
event.preventDefault();
event.stopPropagation();
this.undoDrawPoint();
};
box.addEventListener("dblclick", this.onMapDblClick, true);
box.addEventListener("contextmenu", this.onMapContextMenu, true);
},
unbindFinishGesture() {
if (this.mapBox && this.onMapDblClick) {
this.mapBox.removeEventListener("dblclick", this.onMapDblClick, true);
}
if (this.mapBox && this.onMapContextMenu) {
this.mapBox.removeEventListener("contextmenu", this.onMapContextMenu, true);
}
this.mapBox = null;
this.onMapDblClick = null;
this.onMapContextMenu = null;
},
undoDrawPoint() {
if (!this.editor) {
return;
}
if (typeof this.editor.undo === "function") {
this.editor.undo();
return;
}
const event = new KeyboardEvent("keydown", {
key: "z",
code: "KeyZ",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
if (this.mapBox) {
this.mapBox.dispatchEvent(event);
}
document.dispatchEvent(event);
},
onMapClick(event) {
if (!this.drawing) {
return;
}
const point = latLngOf(event && (event.latLng || event.latlng));
if (!point) {
return;
}
this.clickRing = dedupeRing((this.clickRing || []).concat([point]));
this.pointCount = this.pickedCount();
},
onDrawing(evt) {
const ring = dedupeRing(ringFromPaths(evt && evt.geometry && evt.geometry.paths));
if (!ring.length) {
return;
}
this.draftRing = ring;
this.pointCount = this.pickedCount();
},
pickedCount() {
return Math.max((this.draftRing || []).length, (this.clickRing || []).length);
},
pickedRing() {
const editorRing = dedupeRing(this.draftRing || []);
const clickRing = dedupeRing(this.clickRing || []);
if (clickRing.length >= 3) {
return clickRing;
}
return editorRing.length >= clickRing.length ? editorRing : clickRing;
},
startDraw() {
if (!this.editor || !this.polygon) {
return;
}
this.mode = "draw";
this.drawing = true;
this.pointCount = 0;
this.draftRing = [];
this.clickRing = [];
this.pendingRing = null;
this.editor.setActiveOverlay(OVERLAY_ID);
if (typeof this.editor.setSelectable === "function") {
this.editor.setSelectable(false);
}
this.editor.setActionMode(this.TMap.tools.constants.EDITOR_ACTION.DRAW);
},
startEdit() {
if (!this.editor) {
return;
}
if (this.drawing && this.pickedCount() >= 3) {
this.finishDraw();
} else if (typeof this.editor.stop === "function") {
try {
this.editor.stop();
} catch (e) {
// 没有进行中的绘制时停止会抛错
}
}
this.mode = "edit";
this.drawing = false;
this.pointCount = 0;
this.draftRing = [];
this.clickRing = [];
this.pendingRing = null;
if (typeof this.editor.setSelectable === "function") {
this.editor.setSelectable(true);
}
this.editor.setActionMode(this.TMap.tools.constants.EDITOR_ACTION.INTERACT);
this.fitAll();
},
finishDraw() {
if (!this.editor || !this.polygon || !this.drawing) {
return;
}
const ring = this.pickedRing();
if (ring.length < 3) {
this.$message.warning("至少点击 3 个顶点后再结束");
return;
}
this.pendingRing = ring;
const before = (this.areas || []).length;
if (typeof this.editor.stop === "function") {
try {
this.editor.stop();
} catch (e) {
// 编辑器中断绘制时可能抛错,随后用已选顶点落图
}
}
if ((this.areas || []).length === before && this.pendingRing) {
this.appendArea("fence-" + Date.now(), this.pendingRing);
}
this.pendingRing = null;
},
deleteSelected() {
if (!this.editor || this.mode !== "edit") {
this.$message.warning("请先进入编辑模式并选中区域");
return;
}
const selected = typeof this.editor.getSelectedList === "function" ? this.editor.getSelectedList() || [] : [];
if (!selected.length) {
this.$message.warning("请先选中要删除的区域");
return;
}
const ids = selected.map((item) => item && item.id).filter(Boolean);
if (typeof this.editor.delete === "function") {
this.editor.delete();
}
this.areas = (this.areas || []).filter((area) => !ids.includes(area.id));
this.redrawAreas();
this.publishAreas();
},
onDrawComplete(geometry) {
if (!this.polygon) {
return;
}
let ring = ringFromGeometry(geometry);
if (ring.length < 3) {
ring = this.pendingRing || this.pickedRing();
}
this.pendingRing = null;
if (ring.length < 3) {
return;
}
this.appendArea((geometry && geometry.id) || "fence-" + Date.now(), ring);
},
onAdjustComplete(geometry) {
const ring = ringFromGeometry(geometry);
const id = geometry && geometry.id;
if (!id || ring.length < 3) {
return;
}
const areas = (this.areas || []).slice();
const index = areas.findIndex((item) => item.id === id);
if (index >= 0) {
areas[index] = { id, ring };
} else {
areas.push({ id, ring });
}
this.areas = areas;
this.publishAreas();
},
onDeleteComplete(list) {
const ids = (list || []).map((item) => item && item.id).filter(Boolean);
if (!ids.length) {
return;
}
this.areas = (this.areas || []).filter((area) => !ids.includes(area.id));
this.publishAreas();
},
appendArea(id, ring) {
const clean = dedupeRing(ring || []);
if (clean.length < 3) {
return;
}
const areas = (this.areas || []).filter((area) => area.id !== id);
areas.push({ id, ring: clean });
this.areas = areas;
this.redrawAreas();
this.publishAreas();
this.draftRing = [];
this.clickRing = [];
this.pointCount = 0;
this.fitAll();
if (this.mode === "draw") {
this.drawing = true;
if (typeof this.editor.setSelectable === "function") {
this.editor.setSelectable(false);
}
this.editor.setActionMode(this.TMap.tools.constants.EDITOR_ACTION.DRAW);
}
},
redrawAreas() {
if (!this.polygon || !this.TMap) {
return;
}
this.polygon.setGeometries(
(this.areas || []).map((area) => ({
id: area.id,
styleId: "highlight",
paths: area.ring.map((point) => new this.TMap.LatLng(point.lat, point.lng)),
}))
);
},
publishAreas() {
const areas = this.areas || [];
this.areaCount = areas.length;
if (!areas.length) {
this.committedJson = "";
if (this.modelValue) {
this.$emit("update:modelValue", "");
}
return;
}
const json = JSON.stringify({
paths: areas.map((area) =>
area.ring.map((point) => [
Number(Number(point.lng).toFixed(6)),
Number(Number(point.lat).toFixed(6)),
])
),
});
this.committedJson = json;
if (json !== (this.modelValue || "")) {
this.$emit("update:modelValue", json);
}
},
renderPolygons(polygons) {
if (!this.polygon || !this.TMap || !this.editor) {
return;
}
if (typeof this.editor.stop === "function") {
try {
this.editor.stop();
} catch (e) {
// 初始化时没有进行中的绘制
}
}
this.areas = (polygons || []).map((ring, index) => ({
id: "fence-" + index,
ring,
}));
this.redrawAreas();
this.areaCount = this.areas.length;
this.committedJson = this.modelValue || "";
this.mode = "edit";
this.drawing = false;
this.pointCount = 0;
this.editor.setActiveOverlay(OVERLAY_ID);
if (typeof this.editor.setSelectable === "function") {
this.editor.setSelectable(true);
}
this.editor.setActionMode(this.TMap.tools.constants.EDITOR_ACTION.INTERACT);
this.fitAll();
},
fitAll() {
if (!this.map || !this.TMap) {
return;
}
const points = [];
(this.areas || []).forEach((area) => {
(area.ring || []).forEach((point) => points.push(point));
});
if (points.length < 2) {
return;
}
const bounds = new this.TMap.LatLngBounds();
points.forEach((point) => bounds.extend(new this.TMap.LatLng(point.lat, point.lng)));
this.map.fitBounds(bounds, { padding: 48 });
},
currentJson() {
return this.committedJson || "";
},
destroyMap() {
this.ready = false;
this.booting = false;
this.drawing = false;
this.unbindFinishGesture();
const editor = this.editor;
const map = this.map;
this.editor = null;
this.map = null;
this.polygon = null;
this.suggestion = null;
try {
if (editor && typeof editor.destroy === "function") {
editor.destroy();
}
} catch (e) {
// 容器已隐藏时,腾讯地图会在销毁过程中抛 far <= 0
}
try {
if (map && typeof map.destroy === "function") {
map.destroy();
}
} catch (e) {
// 同上
}
},
},
};
function ensureProcessShim() {
const current = window.process;
if (!current || typeof current !== "object") {
window.process = {
env: { NODE_ENV: "production" },
browser: true,
stderr: { isTTY: false },
emitWarning() {},
};
return;
}
if (!current.env) {
current.env = { NODE_ENV: "production" };
}
if (!current.stderr) {
current.stderr = { isTTY: false };
}
if (typeof current.emitWarning !== "function") {
current.emitWarning = function () {};
}
}
function loadTMap(key) {
if (window.TMap && window.TMap.tools && window.TMap.service) {
return Promise.resolve(window.TMap);
}
if (!loadTMap.pending) {
loadTMap.pending = new Promise((resolve, reject) => {
ensureProcessShim();
const script = document.createElement("script");
script.charset = "utf-8";
script.async = false;
script.src =
"https://map.qq.com/api/gljs?v=1.exp&libraries=tools,service&key=" +
encodeURIComponent(key);
script.onload = () => {
if (window.TMap) {
resolve(window.TMap);
} else {
reject(new Error("TMap missing"));
}
};
script.onerror = () => reject(new Error("TMap load failed"));
document.head.appendChild(script);
});
}
return loadTMap.pending;
}
function dedupeRing(ring) {
const result = [];
ring.forEach((point) => {
const last = result[result.length - 1];
if (last && Math.abs(last.lat - point.lat) < 1e-6 && Math.abs(last.lng - point.lng) < 1e-6) {
return;
}
result.push(point);
});
if (result.length > 3) {
const first = result[0];
const end = result[result.length - 1];
if (Math.abs(first.lat - end.lat) < 1e-6 && Math.abs(first.lng - end.lng) < 1e-6) {
result.pop();
}
}
return result;
}
function asArray(value) {
if (!value) {
return [];
}
if (Array.isArray(value)) {
return value;
}
if (typeof value.getLength === "function" && typeof value.getAt === "function") {
const list = [];
const size = value.getLength();
for (let i = 0; i < size; i += 1) {
list.push(value.getAt(i));
}
return list;
}
return [];
}
function ringFromGeometry(geometry) {
if (!geometry) {
return [];
}
const ring = longestRing(geometry.paths || geometry.path || geometry.points || geometry);
return dedupeRing(ring);
}
function longestRing(value, depth) {
if (!value || depth > 5) {
return [];
}
const point = latLngOf(value);
if (point) {
return [point];
}
const list = asArray(value);
if (list.length) {
const flat = list.map(latLngOf).filter(Boolean);
if (flat.length >= 3) {
return flat;
}
let best = flat;
list.forEach((item) => {
const nested = longestRing(item, (depth || 0) + 1);
if (nested.length > best.length) {
best = nested;
}
});
return best;
}
if (typeof value === "object") {
return longestRing(value.paths || value.path || value.points, (depth || 0) + 1);
}
return [];
}
function ringFromPaths(paths) {
return dedupeRing(longestRing(paths, 0));
}
function latLngOf(point) {
if (!point || typeof point !== "object") {
return null;
}
if (Array.isArray(point)) {
return latLngFromPair(point[0], point[1]);
}
const lat = readNumber(point, ["getLat", "getLatitude"], ["lat", "latitude"]);
const lng = readNumber(point, ["getLng", "getLongitude"], ["lng", "longitude"]);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
return null;
}
return { lat, lng };
}
function latLngFromPair(first, second) {
const a = Number(first);
const b = Number(second);
if (!Number.isFinite(a) || !Number.isFinite(b)) {
return null;
}
if (Math.abs(a) > 90 && Math.abs(b) <= 90) {
return { lng: a, lat: b };
}
if (Math.abs(b) > 90 && Math.abs(a) <= 90) {
return { lng: b, lat: a };
}
return { lng: a, lat: b };
}
function readNumber(point, methods, fields) {
for (let i = 0; i < methods.length; i += 1) {
const name = methods[i];
if (typeof point[name] === "function") {
const value = Number(point[name]());
if (Number.isFinite(value)) {
return value;
}
}
}
for (let i = 0; i < fields.length; i += 1) {
const value = Number(point[fields[i]]);
if (Number.isFinite(value)) {
return value;
}
}
return NaN;
}
function parsePolygons(raw) {
if (!raw) {
return [];
}
try {
const data = typeof raw === "string" ? JSON.parse(raw) : raw;
if (!data) {
return [];
}
const source = Array.isArray(data.paths) ? data.paths : Array.isArray(data.path) ? [data.path] : [];
return source.map(readRing).filter((ring) => ring.length >= 3);
} catch (e) {
return [];
}
}
function readRing(path) {
if (!Array.isArray(path)) {
return [];
}
return dedupeRing(
path
.map((point) => {
if (!Array.isArray(point) || point.length < 2) {
return null;
}
const lng = Number(point[0]);
const lat = Number(point[1]);
if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
return null;
}
return { lng, lat };
})
.filter(Boolean)
);
}
</script>
<style scoped>
.fence {
position: relative;
width: 720px;
max-width: 100%;
}
.fence-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.fence-toolbar .el-input {
width: 280px;
}
.fence-tips {
position: absolute;
z-index: 1001;
left: 0;
top: 40px;
width: 280px;
max-height: 240px;
overflow: auto;
margin: 0;
padding: 0;
list-style: none;
background: #fff;
border: 1px solid #e4e7ed;
}
.fence-tips li {
padding: 6px 10px;
cursor: pointer;
}
.fence-tips li:hover {
background: #f5f7fa;
}
.fence-tips p {
margin: 0;
}
.fence-tips p:last-child {
color: #909399;
font-size: 12px;
}
.fence-map {
width: 100%;
height: 480px;
border: 1px solid #e4e7ed;
}
.fence-hint {
margin-top: 8px;
color: #909399;
font-size: 12px;
line-height: 1.6;
}
.fence-desc {
margin: 8px 0 0;
padding: 0;
list-style: none;
color: #909399;
font-size: 12px;
line-height: 1.7;
}
.fence-desc span {
margin-right: 6px;
color: #606266;
}
</style>

View File

@@ -1,24 +1,18 @@
<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>
<span class="hint">用户预约为必要节点,不可关闭</span>
</el-form-item>
<el-form-item label="工单展示">
<el-form-item label="工单">
<el-switch v-model="form.workOrder" />
<span class="hint">关闭后 §15 不可见,工单仍会生成</span>
<span class="hint">工单开启核销后会生成服务工单,关闭不生成工单,订单状态直接流转已收货、已服务</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" />
<span class="hint">服务人员结束服务</span>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
@@ -42,7 +36,6 @@ export default {
userBooking: true,
workOrder: true,
endService: true,
customerRescheduleAuditEnabled: false,
},
};
},

View File

@@ -6,36 +6,38 @@
<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 />
<span class="hint">用户预约为必要节点,不可关闭</span>
</el-form-item>
<el-form-item label="接单">
<el-switch v-model="form.acceptOrder" />
<span class="hint">接单开启,服务人员可以抢单</span>
</el-form-item>
<el-form-item label="上门打卡">
<el-switch v-model="form.onSiteCheckin" />
<span class="hint">上门打卡关闭之后,则无需开始服务直接进入下一环节</span>
</el-form-item>
<el-form-item label="服务过程留凭">
<el-switch v-model="form.serviceEvidence" />
<span class="hint">服务过程留凭根据关联系统表单填写提交,服务过程选填可跳过,关闭则直接进入下一环节</span>
</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" />
<span class="hint">服务人员结束服务</span>
</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-tab-pane label="服务区域" name="area" lazy>
<el-form v-loading="loading" :model="form" label-width="140px">
<el-form-item label="是否开启">
<el-switch v-model="form.serviceAreaEnabled" />
</el-form-item>
<el-form-item label="区域说明">
<el-input
<el-form-item label="服务围栏">
<service-area-fence
v-if="form.serviceAreaEnabled"
v-model="form.serviceAreaGeo"
type="textarea"
:rows="4"
placeholder="地图选点数据(JSON),后端接入后替换为地图组件"
:visible="activeTab === 'area'"
/>
<span v-else class="hint">开启后在地图上绘制上门范围</span>
</el-form-item>
</el-form>
</el-tab-pane>
@@ -50,9 +52,11 @@
<script>
import { getOnSiteSettings, saveOnSiteSettings } from "@/api/appointment";
import ServiceAreaFence from "./components/ServiceAreaFence.vue";
export default {
name: "appointmentOnSiteSettings",
components: { ServiceAreaFence },
data() {
return {
activeTab: "flow",
@@ -64,7 +68,6 @@ export default {
onSiteCheckin: true,
serviceEvidence: false,
endService: true,
customerRescheduleAuditEnabled: false,
serviceAreaEnabled: false,
serviceAreaGeo: "",
},
@@ -101,3 +104,11 @@ export default {
},
};
</script>
<style scoped>
.hint {
margin-left: 8px;
color: #909399;
font-size: 12px;
}
</style>

View File

@@ -1,95 +0,0 @@
<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

@@ -19,10 +19,9 @@
</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 v-loading="loading" :data="data" border :fit="false">
<el-table-column prop="staffName" label="员工姓名" width="160" />
<el-table-column prop="contactPhone" label="联系电话" width="140" />
<el-table-column prop="sortOrder" label="排序" width="80" />
<el-table-column label="人员状态" width="110">
<template #default="{ row }">
@@ -33,6 +32,13 @@
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="170" />
<el-table-column label="操作" width="140" align="center">
<template #default="{ row }">
<a class="link-text" @click="openEdit(row)">编辑</a>
<span class="op-split">|</span>
<a class="link-text" @click="handleDelete(row)">删除</a>
</template>
</el-table-column>
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<el-pagination
@@ -46,16 +52,34 @@
</div>
</el-card>
<el-dialog v-model="createVisible" title="新增服务人员" width="480px" destroy-on-close>
<el-dialog v-model="createVisible" :title="createForm.id ? '编辑服务人员' : '新增服务人员'" 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 label="店员">
<el-select
v-model="createForm.clerkUserId"
filterable
clearable
placeholder="请选择店员"
style="width: 100%"
:loading="clerkLoading"
@change="onClerkChange"
>
<el-option
v-for="clerk in clerkOptions"
:key="clerk.memberId"
:label="clerkOptionLabel(clerk)"
:value="clerk.memberId"
/>
</el-select>
</el-form-item>
<el-form-item label="员工姓名">
<el-input v-model="createForm.staffName" />
<el-input v-model="createForm.staffName" placeholder="选择店员后自动填入" />
</el-form-item>
<el-form-item label="联系电话">
<el-input v-model="createForm.contactPhone" />
<el-input v-model="createForm.contactPhone" placeholder="选择店员后自动填入" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="createForm.sortOrder" :min="0" :max="9999" controls-position="right" />
</el-form-item>
</el-form>
<template #footer>
@@ -67,10 +91,14 @@
</template>
<script>
import { ElMessageBox } from "element-plus";
import { getUserListData } from "@/api/index";
import {
getServiceStaffPage,
createServiceStaff,
updateServiceStaff,
updateServiceStaffStatus,
deleteServiceStaff,
} from "@/api/appointment";
export default {
@@ -88,10 +116,15 @@ export default {
},
createVisible: false,
createLoading: false,
clerkLoading: false,
clerkOptions: [],
createForm: {
id: "",
clerkUserId: "",
staffName: "",
contactPhone: "",
sortOrder: 0,
status: "ENABLED",
},
};
},
@@ -116,16 +149,89 @@ export default {
this.loading = false;
});
},
openCreate() {
this.createForm = { clerkUserId: "", staffName: "", contactPhone: "" };
this.createVisible = true;
emptyForm() {
return {
id: "",
clerkUserId: "",
staffName: "",
contactPhone: "",
sortOrder: 0,
status: "ENABLED",
};
},
submitCreate() {
this.createLoading = true;
createServiceStaff(this.createForm)
openCreate() {
this.createForm = this.emptyForm();
this.createVisible = true;
this.loadClerks();
},
openEdit(row) {
this.createForm = {
id: row.id,
clerkUserId: row.clerkUserId || "",
staffName: row.staffName || "",
contactPhone: row.contactPhone || "",
sortOrder: row.sortOrder == null ? 0 : row.sortOrder,
status: row.status || "ENABLED",
};
this.createVisible = true;
this.loadClerks();
},
loadClerks() {
this.clerkLoading = true;
getUserListData({ pageNumber: 1, pageSize: 200 })
.then((res) => {
if (res.success) {
this.$message.success("创建成功");
const records = res.result?.records || [];
const usedIds = new Set(
(this.data || [])
.filter((staff) => staff.id !== this.createForm.id)
.map((staff) => staff.clerkUserId)
.filter(Boolean)
);
const options = records.filter(
(clerk) => clerk.memberId && clerk.status !== false && !usedIds.has(clerk.memberId)
);
const currentId = this.createForm.clerkUserId;
if (currentId && !options.some((clerk) => clerk.memberId === currentId)) {
options.unshift({
memberId: currentId,
clerkName: this.createForm.staffName || "当前店员",
});
}
this.clerkOptions = options;
}
})
.finally(() => {
this.clerkLoading = false;
});
},
clerkOptionLabel(clerk) {
return clerk.clerkName || "未命名店员";
},
onClerkChange(memberId) {
const clerk = this.clerkOptions.find((item) => item.memberId === memberId);
if (!clerk) {
return;
}
this.createForm.staffName = clerk.clerkName || "";
this.createForm.contactPhone = clerk.mobile || "";
},
submitCreate() {
if (!this.createForm.clerkUserId) {
this.$message.warning("请选择店员");
return;
}
if (!this.createForm.staffName) {
this.$message.warning("请填写员工姓名");
return;
}
const editing = !!this.createForm.id;
const request = editing ? updateServiceStaff(this.createForm.id, this.createForm) : createServiceStaff(this.createForm);
this.createLoading = true;
request
.then((res) => {
if (res.success) {
this.$message.success(editing ? "已保存" : "创建成功");
this.createVisible = false;
this.loadData();
}
@@ -134,6 +240,17 @@ export default {
this.createLoading = false;
});
},
handleDelete(row) {
ElMessageBox.confirm(`确认删除服务人员「${row.staffName || ""}」?`, "提示", { type: "warning" })
.then(() => deleteServiceStaff(row.id))
.then((res) => {
if (res && res.success) {
this.$message.success("已删除");
this.loadData();
}
})
.catch(() => {});
},
toggleStatus(row, enabled) {
updateServiceStaffStatus(row.id, enabled ? "ENABLED" : "DISABLED").then((res) => {
if (res.success) {

View File

@@ -9,54 +9,137 @@
class="search-form"
@keyup.enter="handleSearch"
>
<el-form-item label="商品名称" prop="goodsName">
<el-input
v-model="searchForm.goodsName"
placeholder="请输入商品名称"
clearable
style="width: 240px"
<div class="search-form-row">
<el-form-item label="商品名称" prop="goodsName">
<el-input
v-model="searchForm.goodsName"
placeholder="请输入商品名称"
clearable
/>
</el-form-item>
<el-form-item label="SKU名称" prop="skuName">
<el-input
v-model="searchForm.skuName"
placeholder="请输入SKU名称"
clearable
/>
</el-form-item>
<el-form-item label="SKU货号" prop="skuSn">
<el-input
v-model="searchForm.skuSn"
placeholder="请输入SKU货号"
clearable
/>
</el-form-item>
<el-form-item label="商品ID" prop="id">
<el-input
v-model="searchForm.id"
placeholder="请输入商品ID"
clearable
/>
</el-form-item>
</div>
<div class="search-form-row">
<el-form-item label="店铺分类" prop="storeCategoryPath">
<el-cascader
v-model="searchForm.storeCategoryPath"
:options="shopCategoryList"
:props="shopCategoryProps"
clearable
filterable
placeholder="请选择店铺分类"
/>
</el-form-item>
<el-form-item label="平台分类" prop="categoryPath">
<el-cascader
v-model="searchForm.categoryPath"
:options="platformCategoryList"
:props="platformCategoryProps"
clearable
filterable
placeholder="请选择平台商品分类"
/>
</el-form-item>
<el-form-item label="商品品牌" prop="brandId">
<el-select
v-model="searchForm.brandId"
placeholder="请选择商品品牌"
clearable
filterable
>
<el-option
v-for="item in brandList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="商品类型" prop="goodsType">
<el-select
v-model="searchForm.goodsType"
placeholder="请选择"
clearable
>
<el-option label="实物商品" value="PHYSICAL_GOODS" />
<el-option label="虚拟商品" value="VIRTUAL_GOODS" />
<!-- E_COUPON:卡密商品,可跳转卡池管理 -->
<el-option label="电子卡券" value="E_COUPON" />
<el-option label="预约商品" value="APPOINTMENT_GOODS" />
</el-select>
</el-form-item>
</div>
<el-form-item label="创建时间">
<el-date-picker
v-model="createRange"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始"
end-placeholder="结束"
style="width: 360px"
/>
</el-form-item>
<el-form-item label="商品编号" prop="id">
<el-input
v-model="searchForm.id"
placeholder="请输入商品编号"
clearable
style="width: 240px"
/>
</el-form-item>
<el-form-item label="店铺名称" prop="storeName">
<el-input
v-model="searchForm.storeName"
placeholder="请输入店铺名称"
clearable
style="width: 240px"
/>
</el-form-item>
<el-form-item label="销售模式" prop="salesModel">
<el-form-item label="库存状态" prop="stockStatus">
<el-select
v-model="searchForm.salesModel"
placeholder="请选择"
v-model="searchForm.stockStatus"
placeholder="全部"
clearable
style="width: 240px"
@change="onStockStatusChange"
>
<el-option label="零售" value="RETAIL" />
<el-option label="批发" value="WHOLESALE" />
<el-option label="有货" value="IN_STOCK" />
<el-option label="无货" value="OUT_OF_STOCK" />
</el-select>
</el-form-item>
<el-form-item label="商品类型" prop="goodsType">
<el-select
v-model="searchForm.goodsType"
placeholder="请选择"
<el-form-item v-if="searchForm.stockStatus === 'IN_STOCK'" label="库存区间">
<el-input
v-model="searchForm.stockMin"
placeholder="≥"
clearable
style="width: 240px"
>
<el-option label="实物商品" value="PHYSICAL_GOODS" />
<el-option label="虚拟商品" value="VIRTUAL_GOODS" />
<!-- E_COUPON:卡密商品,可跳转卡池管理 -->
<el-option label="电子卡券" value="E_COUPON" />
<el-option label="预约商品" value="APPOINTMENT_GOODS" />
</el-select>
style="width: 110px"
/>
<span style="margin: 0 6px">~</span>
<el-input
v-model="searchForm.stockMax"
placeholder="≤"
clearable
style="width: 110px"
/>
</el-form-item>
<el-form-item label="售价区间">
<el-input
v-model="searchForm.priceMin"
placeholder="≥"
clearable
style="width: 110px"
/>
<span style="margin: 0 6px">~</span>
<el-input
v-model="searchForm.priceMax"
placeholder="≤"
clearable
style="width: 110px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" class="search-btn" @click="handleSearch">搜索</el-button>
@@ -130,16 +213,16 @@
</el-table-column>
<el-table-column prop="buyCount" label="销量" width="90" />
<el-table-column prop="quantity" label="库存" width="90" />
<el-table-column label="销售模式" width="150">
<template #default="{ row }">
<span v-if="row">{{ salesModelText(row.salesModel) }}</span>
</template>
</el-table-column>
<el-table-column label="商品类型" width="150">
<template #default="{ row }">
<span v-if="row">{{ goodsTypeText(row.goodsType) }}</span>
</template>
</el-table-column>
<el-table-column label="商品分类" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
{{ platformCategoryText(row?.categoryPath) }}
</template>
</el-table-column>
<el-table-column label="状态" width="150">
<template #default="{ row }">
<span v-if="row">{{ marketEnableText(row.marketEnable) }}</span>
@@ -150,8 +233,8 @@
<span v-if="row">{{ authFlagText(row.authFlag) }}</span>
</template>
</el-table-column>
<el-table-column prop="storeName" label="店铺名称" width="200" show-overflow-tooltip />
<el-table-column label="操作" width="260" align="center" fixed="right">
<el-table-column prop="createTime" label="创建时间" width="170" show-overflow-tooltip />
<el-table-column label="操作" width="320" align="center" fixed="right">
<template #default="{ row }">
<template v-if="row.marketEnable === 'DOWN'">
<a class="link-text" @click="upper(row)">上架</a>
@@ -163,6 +246,8 @@
<span class="op-split">|</span>
<a class="link-text" @click="editGoods(row)">编辑</a>
</template>
<span class="op-split">|</span>
<a class="link-text" @click="copyGoods(row)">复制商品</a>
<!-- E_COUPON 专属:卡池管理入口(原型 P-03) -->
<template v-if="row.goodsType === 'E_COUPON'">
<span class="op-split">|</span>
@@ -202,9 +287,12 @@
<script>
import {
getBrandListData,
getCategoryTree,
getGoodsListData,
getGoodsNumerData,
getQueryGoodsIdGoodsList,
getShopGoodsLabelListSeller,
upGoods,
lowGoods,
} from "@/api/goods";
@@ -215,12 +303,40 @@ export default {
id: "", //要操作的id
loading: true, // 表单加载状态
modalVisible: false, // 添加或编辑显示
createRange: [],
searchForm: {
// 搜索框初始化对象
pageNumber: 1, // 当前页数
pageSize: 20, // 页面大小
sort: "create_time", // 默认排序字段
order: "desc", // 默认排序方式
storeCategoryPath: "",
categoryPath: "",
skuName: "",
skuSn: "",
brandId: "",
stockStatus: "",
stockMin: "",
stockMax: "",
priceMin: "",
priceMax: "",
},
brandList: [],
platformCategoryList: [],
shopCategoryList: [],
platformCategoryProps: {
value: "id",
label: "name",
children: "children",
checkStrictly: true,
emitPath: false,
},
shopCategoryProps: {
value: "value",
label: "label",
children: "children",
checkStrictly: true,
emitPath: false,
},
underForm: {
// 下架原因
@@ -253,11 +369,6 @@ export default {
onStatusTabClick(tab) {
this.goodsStatusClick(tab.paneName);
},
salesModelText(v) {
if (v === "RETAIL") return "零售";
if (v === "WHOLESALE") return "批发";
return "其他类型";
},
goodsTypeText(v) {
if (v === "PHYSICAL_GOODS") return "实物商品";
if (v === "VIRTUAL_GOODS") return "虚拟商品";
@@ -265,6 +376,25 @@ export default {
if (v === "APPOINTMENT_GOODS") return "预约商品";
return v || "—";
},
findTreeNodeLabel(tree, targetId, valueKey, labelKey) {
for (const node of tree || []) {
if (String(node[valueKey]) === String(targetId)) {
return node[labelKey];
}
const childLabel = this.findTreeNodeLabel(node.children, targetId, valueKey, labelKey);
if (childLabel) return childLabel;
}
return "";
},
platformCategoryText(path) {
if (!path) return "—";
const ids = String(path).split(",").filter(Boolean);
if (!ids.length) return "—";
const labels = ids
.map((id) => this.findTreeNodeLabel(this.platformCategoryList, id, "id", "name"))
.filter(Boolean);
return labels.length ? labels.join(" / ") : "—";
},
/** 跳转卡池管理;单 SKU 商品默认取第一个 SKU(card-key-pool 需 skuId) */
async goCardKeyPool(row) {
let skuId = row.skuId;
@@ -304,9 +434,77 @@ export default {
},
// 初始化数据
init() {
this.loadFilterOptions();
this.getDataList();
this.getNumberData();
},
loadFilterOptions() {
getBrandListData().then((res) => {
this.brandList = Array.isArray(res) ? res : res?.result || [];
});
getShopGoodsLabelListSeller().then((res) => {
if (res?.success) {
this.shopCategoryList = this.formatShopCategory(res.result || []);
}
});
getCategoryTree().then((res) => {
if (res?.success) {
this.platformCategoryList = res.result || [];
}
});
},
onStockStatusChange(value) {
if (value !== "IN_STOCK") {
this.searchForm.stockMin = "";
this.searchForm.stockMax = "";
}
},
buildSearchParams(includeGoodsStatus = true) {
const { stockStatus, stockMin, stockMax, priceMin, priceMax, ...rest } = this.searchForm;
const params = { ...rest };
if (!includeGoodsStatus) {
delete params.goodsStatus;
}
if (this.createRange && this.createRange.length === 2) {
params.startDate = this.createRange[0];
params.endDate = this.createRange[1];
}
if (stockStatus === "IN_STOCK") {
params.geQuantity = stockMin !== "" && stockMin != null ? Number(stockMin) : 1;
if (stockMax !== "" && stockMax != null) {
params.leQuantity = Number(stockMax);
}
} else if (stockStatus === "OUT_OF_STOCK") {
params.geQuantity = 0;
params.leQuantity = 0;
} else {
if (stockMin !== "" && stockMin != null) params.geQuantity = Number(stockMin);
if (stockMax !== "" && stockMax != null) params.leQuantity = Number(stockMax);
}
if (priceMin !== "" && priceMax !== "") {
params.price = `${priceMin}_${priceMax}`;
} else if (priceMin !== "") {
params.price = String(priceMin);
} else if (priceMax !== "") {
params.price = `0_${priceMax}`;
}
return params;
},
formatShopCategory(list) {
return (list || []).map((item) => ({
value: item.id,
label: item.labelName,
children:
item.children && item.children.length
? this.formatShopCategory(item.children)
: undefined,
}));
},
// 分页 改变页码
changePage(v) {
this.searchForm.pageNumber = v;
@@ -327,7 +525,7 @@ export default {
// 获取数据
getDataList() {
this.loading = true;
getGoodsListData(this.searchForm).then((res) => {
getGoodsListData(this.buildSearchParams()).then((res) => {
this.loading = false;
if (res.success) {
this.data = res.result.records;
@@ -336,9 +534,7 @@ export default {
});
},
getNumberData() {
// 创建一个不包含goodsStatus字段的搜索参数
const { goodsStatus, ...searchParams } = this.searchForm;
getGoodsNumerData(searchParams).then((res) => {
getGoodsNumerData(this.buildSearchParams(false)).then((res) => {
if (res.success) {
this.goodsNumerData = res.result;
}
@@ -400,6 +596,14 @@ export default {
});
},
// 复制商品
copyGoods(v) {
this.$filters.customRouterPush({
name: "goods-operation",
query: { copyId: v.id },
});
},
// 商品状态筛选
goodsStatusClick(name) {
if (name === "ALL" || name === "" || name === undefined || name === null) {
@@ -500,6 +704,31 @@ export default {
};
</script>
<style lang="scss" scoped>
.search-form-row {
display: flex;
flex-wrap: nowrap;
align-items: flex-start;
width: 100%;
margin-bottom: 18px;
:deep(.el-form-item) {
flex: 1;
min-width: 0;
margin-right: 12px;
margin-bottom: 0;
}
:deep(.el-form-item:last-child) {
margin-right: 0;
}
:deep(.el-input),
:deep(.el-select),
:deep(.el-cascader) {
width: 100%;
}
}
// Tab组件样式
.goods-tab {
:deep(.el-tabs__item) {

View File

@@ -0,0 +1,203 @@
<template>
<div class="store-qr-entry">
<el-button size="small" @click="open">店铺二维码</el-button>
<el-dialog
v-model="visible"
title="店铺二维码"
width="720px"
append-to-body
destroy-on-close
@opened="loadCodes"
>
<div class="store-qr-dialog">
<div class="store-qr-col">
<vue-qr
v-if="h5Url"
:text="h5Url"
:margin="0"
color-dark="#000"
color-light="#fff"
:size="180"
/>
<div v-else class="store-qr-tip">请先在系统设置中填写 wap 站点地址</div>
<div class="store-qr-title">H5 二维码</div>
</div>
<div class="store-qr-col">
<img v-if="mpQrSrc" class="store-qr-img" :src="mpQrSrc" alt="小程序葵花码" />
<div v-else class="store-qr-tip">{{ mpError || "小程序码生成中..." }}</div>
<div class="store-qr-title">小程序葵花码</div>
</div>
<div class="store-qr-col">
<el-button type="primary" :disabled="!pcUrl" @click="copyText(pcUrl)">复制链接</el-button>
<div class="store-qr-title">PC 店铺首页</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script>
import Cookies from "js-cookie";
import vueQr from "vue-qr";
import { getStoreMpQrcode } from "@/api/index";
import { getBaseSite } from "@/api/common.js";
export default {
name: "store-qrcode",
components: { "vue-qr": vueQr },
data() {
return {
visible: false,
storeId: "",
wapUrl: "",
pcUrlBase: "",
mpQrcode: "",
mpError: "",
mpLoaded: false,
};
},
computed: {
h5Url() {
if (!this.storeId || !this.wapUrl) return "";
return this.joinUrl(this.wapUrl, `/pages/product/shopPage?id=${this.storeId}`);
},
pcUrl() {
if (!this.storeId || !this.pcUrlBase) return "";
return this.joinUrl(this.pcUrlBase, `/merchant?id=${this.storeId}`);
},
mpQrSrc() {
if (!this.mpQrcode) return "";
const mime = String(this.mpQrcode).startsWith("/9j/")
? "image/jpeg"
: "image/png";
return `data:${mime};base64,${this.mpQrcode}`;
},
},
methods: {
joinUrl(base, path) {
return String(base || "").replace(/\/$/, "") + path;
},
parseSettingValue(raw) {
if (!raw) return {};
if (typeof raw === "object") return raw;
try {
return JSON.parse(raw);
} catch (e) {
return {};
}
},
initStore() {
try {
const userInfo = JSON.parse(Cookies.get("userInfoSeller") || "{}") || {};
this.storeId = userInfo.id || userInfo.storeId || "";
} catch (e) {
this.storeId = "";
}
},
open() {
this.initStore();
this.visible = true;
},
loadCodes() {
this.initStore();
getBaseSite()
.then((res) => {
const data = this.parseSettingValue(res && res.result && res.result.settingValue);
const fallback = typeof window !== "undefined" ? window.BASE || {} : {};
// wap/站点地址常配官网,H5、PC 店铺页用买家端域名
this.wapUrl = fallback.WAP_URL || data.staticPageWapAddress || "";
this.pcUrlBase = fallback.PC_URL || data.staticPageAddress || "";
})
.catch(() => {
const fallback = typeof window !== "undefined" ? window.BASE || {} : {};
this.wapUrl = fallback.WAP_URL || "";
this.pcUrlBase = fallback.PC_URL || "";
});
this.loadMpQrcode();
},
loadMpQrcode() {
if (!this.storeId || this.mpLoaded) return;
this.mpError = "";
getStoreMpQrcode()
.then((res) => {
if (res && res.success && res.result) {
if (String(res.result).startsWith("eyJ")) {
this.mpError = "请先配置微信小程序";
return;
}
this.mpQrcode = res.result;
this.mpLoaded = true;
this.mpError = "";
} else {
this.mpError = "请先配置微信小程序";
}
})
.catch(() => {
this.mpError = "请先配置微信小程序";
});
},
copyText(text) {
if (!text) return;
const ok = () => this.$Message.success("复制成功");
const fail = () => this.$Message.error("复制失败");
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(ok).catch(fail);
return;
}
const textArea = document.createElement("textarea");
textArea.value = text;
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand("copy");
ok();
} catch (e) {
fail();
}
document.body.removeChild(textArea);
},
},
};
</script>
<style scoped lang="scss">
.store-qr-entry {
margin-right: 10px;
}
.store-qr-dialog {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 8px 8px 16px;
}
.store-qr-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 240px;
gap: 12px;
}
.store-qr-img {
width: 180px;
height: 180px;
display: block;
}
.store-qr-title {
font-size: 14px;
color: #333;
}
.store-qr-tip {
width: 180px;
height: 180px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 13px;
color: #999;
line-height: 1.5;
}
</style>

View File

@@ -226,22 +226,25 @@
}
.single-page-con {
min-width: 740px;
position: relative;
position: absolute;
left: 180px;
top: 106px;
right: 0;
top: 106px;
bottom: 0;
height: calc(100% - 116px);
width: calc(100% - 180px);
overflow: auto;
width: auto;
min-width: 0;
overflow-x: hidden;
overflow-y: auto;
background-color: #f0f0f0;
z-index: 1;
transition: left 0.3s, width 0.3s;
transition: left 0.3s;
box-sizing: border-box;
.single-page {
position: relative;
margin: 10px;
max-width: 100%;
overflow-x: hidden;
}
}
@@ -256,7 +259,8 @@
.single-page-con {
left: 80px;
width: calc(100% - 80px);
right: 0;
width: auto;
}
}

View File

@@ -37,8 +37,9 @@
>打印发货单</el-button>
</div>
</el-card>
<el-card class="mt_10 clearfix">
<div style="width: 30%; float: left; margin-left: 20px">
<el-card class="mt_10">
<div class="order-info-grid">
<div class="order-info-col">
<div class="div-item">
<div class="div-item-left">订单号:</div>
<div class="div-item-right">{{ orderInfo.order.sn }}</div>
@@ -95,7 +96,7 @@
</div>
</div>
</div>
<div style="width: 30%; float: left; margin-left: 20px">
<div class="order-info-col">
<div class="div-item" v-if="orderInfo.order.needReceipt == false">
<div class="div-item-left">发票信息:</div>
<div class="div-item-right">暂无发票信息</div>
@@ -146,8 +147,8 @@
</div>
</div>
</div>
<div style="width: 36%; float: left">
<div class="div-item" v-if="!isECouponOrder && orderInfo.order.deliveryMethod != 'SELF_PICK_UP'">
<div class="order-info-col">
<div class="div-item" v-if="!isECouponOrder && !isAppointmentOrder && orderInfo.order.deliveryMethod != 'SELF_PICK_UP'">
<div class="div-item-left">收货信息:</div>
<div class="div-item-right">
{{ orderInfo.order.consigneeName }}
@@ -236,51 +237,86 @@
</div>
</div>
</div>
</div>
</el-card>
<el-card class="mt_10">
<el-table v-loading="loading" border :data="data" ref="table" style="width: 100%">
<el-table-column label="商品" min-width="200">
<el-card v-if="orderPackages.length" class="mt_10 order-goods-card">
<el-divider content-position="left">发货记录</el-divider>
<el-table :data="orderPackages" border style="width: 100%">
<el-table-column label="发货方式" width="120">
<template #default="{ row }">{{ getFulfillmentModeLabel(row.fulfillmentMode) }}</template>
</el-table-column>
<el-table-column label="物流信息" min-width="220">
<template #default="{ row }">
<div v-if="row" style="margin-top: 5px; height: 80px; display: flex">
<div>
<img :src="row.image" style="height: 60px; margin-top: 1px; width: 60px" />
</div>
<div style="margin-left: 13px">
<div class="div-zoom">
<a class="link-text" @click="linkTo(row.goodsId, row.skuId)">{{ row.goodsName }}</a>
</div>
<span v-for="(item, key) in parseOrderItemSpecs(row.specs)" :key="key">
<span v-show="key != 'images'" style="font-size: 12px; color: #999999">
{{ key }} : {{ item }}</span>
</span>
<el-popover trigger="hover" title="扫码在手机中查看" width="180">
<template #reference>
<img src="../../../assets/qrcode.svg" class="hover-pointer" width="20" height="20" alt="" style="display: block" />
</template>
<vue-qr :text="wapLinkTo(row.goodsId, row.skuId)" :margin="0" colorDark="#000" colorLight="#fff" :size="150" />
</el-popover>
</div>
<template v-if="isNoDeliveryPackage(row)">
<span>无需配送</span>
<span v-if="row.deliveryRemark" class="delivery-remark-text">({{ row.deliveryRemark }})</span>
</template>
<template v-else>
{{ row.logisticsName || "-" }} / {{ row.logisticsNo || "-" }}
</template>
</template>
</el-table-column>
<el-table-column prop="logisticsTime" label="发货时间" width="170" />
<el-table-column label="商品明细" min-width="260">
<template #default="{ row }">
<div v-for="(item, idx) in row.orderPackageItemList || []" :key="idx" class="package-item-line">
{{ item.goodsName }} × {{ item.deliverNumber }}
</div>
</template>
</el-table-column>
<el-table-column label="优惠" min-width="100">
<template #default="{ row }">
<span v-if="row">{{ getPromotionText(row) }}</span>
</template>
</el-table-column>
<el-table-column label="单价" min-width="100">
<template #default="{ row }">
<span v-if="row">{{ $filters.unitPrice(row.unitPrice || 0, "¥") }}</span>
</template>
</el-table-column>
<el-table-column prop="num" label="数量" min-width="80" />
<el-table-column prop="returnGoodsNumber" label="退款数量" min-width="80" />
<el-table-column label="小计" min-width="100">
<template #default="{ row }">
<span v-if="row">{{ $filters.unitPrice(row.flowPrice, "¥") }}</span>
</template>
</el-table-column>
</el-table>
</el-card>
<el-card class="mt_10 order-goods-card">
<div v-loading="loading" class="order-fit-wrap">
<table class="order-fit-table">
<colgroup>
<col style="width: 40%" />
<col style="width: 18%" />
<col style="width: 12%" />
<col style="width: 8%" />
<col style="width: 12%" />
<col style="width: 10%" />
</colgroup>
<thead>
<tr>
<th>商品</th>
<th>优惠</th>
<th>单价</th>
<th>数量</th>
<th>退款数量</th>
<th>小计</th>
</tr>
</thead>
<tbody>
<tr v-for="row in data" :key="row.sn || row.id">
<td>
<div class="order-goods-cell">
<img :src="row.image" class="order-goods-img" />
<div class="order-goods-meta">
<div class="div-zoom">
<a class="link-text" @click="linkTo(row.goodsId, row.skuId)">{{ row.goodsName }}</a>
</div>
<span v-for="(item, key) in parseOrderItemSpecs(row.specs)" :key="key">
<span v-show="key != 'images'" class="order-goods-spec">{{ key }} : {{ item }}</span>
</span>
<el-popover trigger="hover" title="扫码在手机中查看" width="180">
<template #reference>
<img src="../../../assets/qrcode.svg" class="hover-pointer" width="20" height="20" alt="" />
</template>
<vue-qr :text="wapLinkTo(row.goodsId, row.skuId)" :margin="0" colorDark="#000" colorLight="#fff" :size="150" />
</el-popover>
</div>
</div>
</td>
<td>{{ getPromotionText(row) }}</td>
<td>{{ $filters.unitPrice(row.unitPrice || 0, "¥") }}</td>
<td>{{ row.num }}</td>
<td>{{ row.returnGoodsNumber }}</td>
<td>{{ $filters.unitPrice(row.flowPrice, "¥") }}</td>
</tr>
</tbody>
</table>
</div>
<!-- 卡密信息(当前订单 orderItems;满赠赠品卡密在赠品子单详情查看) -->
<div v-if="hasCardKeySection" class="ecoupon-card-keys mt_10">
@@ -298,28 +334,40 @@
:title="resolveCardKeyFulfillMessage(line)"
class="mb_10"
/>
<el-table
v-else-if="line.cardKeys.length"
border
:data="lineCardKeyRows(line)"
style="width: 100%"
class="mb_10"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="cardNo" label="卡号" min-width="140" show-overflow-tooltip />
<el-table-column prop="cardSecret" label="卡密" min-width="120" show-overflow-tooltip />
<el-table-column prop="allocatedTime" label="发卡时间" width="170" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<span v-if="row">{{ formatCardKeyStatus(row.status || 'ALLOCATED') }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="{ row }">
<el-button v-if="row" link type="primary" @click="copyCardKey(row)">复制</el-button>
</template>
</el-table-column>
</el-table>
<div v-else-if="line.cardKeys.length" class="order-fit-wrap mb_10">
<table class="order-fit-table">
<colgroup>
<col style="width: 8%" />
<col style="width: 16%" />
<col style="width: 36%" />
<col style="width: 20%" />
<col style="width: 10%" />
<col style="width: 10%" />
</colgroup>
<thead>
<tr>
<th>序号</th>
<th>卡号</th>
<th>卡密</th>
<th>发卡时间</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in lineCardKeyRows(line)" :key="row.id || row.cardNo || index">
<td>{{ index + 1 }}</td>
<td>{{ row.cardNo }}</td>
<td class="card-secret-cell">{{ row.cardSecret }}</td>
<td>{{ row.allocatedTime }}</td>
<td>{{ formatCardKeyStatus(row.status || 'ALLOCATED') }}</td>
<td>
<el-button link type="primary" class="card-key-copy" @click="copyCardKey(row)">复制</el-button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
@@ -409,34 +457,6 @@
</ul>
</div>
</el-card>
<el-card v-if="orderPackages.length" class="mt_10">
<el-divider content-position="left">发货记录</el-divider>
<el-table :data="orderPackages" border style="width: 100%">
<el-table-column label="发货方式" width="120">
<template #default="{ row }">{{ getFulfillmentModeLabel(row.fulfillmentMode) }}</template>
</el-table-column>
<el-table-column label="物流信息" min-width="220">
<template #default="{ row }">
<template v-if="isNoDeliveryPackage(row)">
<span>无需配送</span>
<span v-if="row.deliveryRemark" class="delivery-remark-text">({{ row.deliveryRemark }})</span>
</template>
<template v-else>
{{ row.logisticsName || "-" }} / {{ row.logisticsNo || "-" }}
</template>
</template>
</el-table-column>
<el-table-column prop="logisticsTime" label="发货时间" width="170" />
<el-table-column label="商品明细" min-width="260">
<template #default="{ row }">
<div v-for="(item, idx) in row.orderPackageItemList || []" :key="idx" class="package-item-line">
{{ item.goodsName }} × {{ item.deliverNumber }}
</div>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
<el-dialog v-model="modal" title="修改金额" width="530px">
@@ -1179,7 +1199,7 @@ export default {
if (type.indexOf("FULL_DISCOUNT") != -1) resultText += "满减 ";
if (type.indexOf("POINTS_GOODS") != -1) resultText += "积分商品 ";
}
return resultText === "" ? "暂无未参与任何促销" : resultText;
return resultText === "" ? "未参与" : resultText;
},
gotoHomes () {
return false
@@ -1841,6 +1861,93 @@ export default {
}
}
.order-goods-card {
overflow-x: hidden;
max-width: 100%;
}
.order-info-grid {
display: flex;
flex-wrap: wrap;
gap: 8px 24px;
max-width: 100%;
}
.order-info-col {
flex: 1 1 280px;
min-width: 0;
}
.order-fit-wrap {
width: 100%;
max-width: 100%;
overflow: hidden;
}
.order-fit-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.order-fit-table th,
.order-fit-table td {
border: 1px solid #ebeef5;
padding: 10px 12px;
font-size: 14px;
color: #606266;
text-align: left;
vertical-align: middle;
word-break: break-all;
}
.order-fit-table th {
background: #f5f7fa;
color: #909399;
font-weight: 500;
}
.order-fit-table .is-center {
text-align: center;
}
.card-secret-cell {
word-break: break-all;
}
.card-key-copy {
padding: 0;
height: auto;
justify-content: flex-start;
}
.order-goods-cell {
display: flex;
align-items: flex-start;
min-width: 0;
}
.order-goods-img {
width: 60px;
height: 60px;
flex-shrink: 0;
object-fit: cover;
}
.order-goods-meta {
margin-left: 13px;
min-width: 0;
flex: 1;
}
.order-goods-spec {
display: inline-block;
max-width: 100%;
font-size: 12px;
color: #999;
word-break: break-all;
}
.delivery-empty {
color: #999;
}
@@ -1953,6 +2060,9 @@ export default {
}
.search {
max-width: 100%;
overflow-x: clip;
.operation {
margin-bottom: 2vh;
}
@@ -1990,32 +2100,37 @@ export default {
}
.goods-total {
box-sizing: border-box;
padding: 20px;
height: 220px;
height: auto;
width: 100%;
max-width: 100%;
overflow: hidden;
ul {
margin-right: 10px;
display: block;
float: right;
margin: 0 10px 0 auto;
display: flex;
flex-direction: column;
align-items: flex-end;
list-style-type: none;
max-width: 100%;
}
li {
text-align: -webkit-match-parent;
}
li {
display: flex;
justify-content: flex-end;
gap: 12px;
max-width: 100%;
}
.label {
float: left;
width: 500px;
font-size: 14px;
text-align: right;
}
.txt {
float: left;
min-width: 88px;
font-size: 14px;
width: 130px;
text-align: right;
font-family: verdana;
}

View File

@@ -4,33 +4,36 @@
<el-form
ref="searchForm"
:model="searchForm"
inline
label-width="70px"
class="search-form"
class="search-form order-search-form"
@keyup.enter="handleSearch"
>
<el-form-item label="关键字" prop="keywords" style="display: block; width: 100%">
<el-form-item label="关键字" prop="keywords">
<el-input
v-model="searchForm.keywords"
placeholder="请输入商品名称/收货人/收货人手机号/店铺名称"
placeholder="商品名称/收货人/手机号/店铺名称"
clearable
style="width: 500px"
/>
</el-form-item>
<el-form-item label="订单号" prop="orderSn">
<el-input v-model="searchForm.orderSn" placeholder="请输入订单号" clearable style="width: 240px" />
</el-form-item>
<el-form-item label="会员名称" prop="buyerName">
<el-input v-model="searchForm.buyerName" placeholder="请输入会员名称" clearable style="width: 240px" />
<el-input v-model="searchForm.orderSn" placeholder="请输入订单号" clearable />
</el-form-item>
<el-form-item label="商品名称" prop="goodsName">
<el-input v-model="searchForm.goodsName" placeholder="请输入商品名称" clearable style="width: 240px" />
<el-input v-model="searchForm.goodsName" placeholder="请输入商品名称" clearable />
</el-form-item>
<el-form-item label="商品类型" prop="goodsType">
<el-select v-model="searchForm.goodsType" placeholder="请选择商品类型" clearable>
<el-option label="实物商品" value="PHYSICAL_GOODS" />
<el-option label="虚拟商品" value="VIRTUAL_GOODS" />
<el-option label="卡密商品" value="E_COUPON" />
<el-option label="预约商品" value="APPOINTMENT_GOODS" />
</el-select>
</el-form-item>
<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 />
</el-form-item>
<el-form-item label="订单类型" prop="orderType">
<el-select v-model="searchForm.orderPromotionType" placeholder="请选择" clearable style="width: 240px">
<el-select v-model="searchForm.orderPromotionType" placeholder="请选择" clearable>
<el-option label="普通订单" value="NORMAL" />
<el-option label="拼团订单" value="PINTUAN" />
<el-option label="赠品订单" value="GIFT" />
@@ -39,7 +42,7 @@
</el-select>
</el-form-item>
<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>
<el-option label="微信支付" value="WECHAT" />
<el-option label="支付宝" value="ALIPAY" />
<el-option label="余额支付" value="WALLET" />
@@ -54,12 +57,11 @@
clearable
start-placeholder="开始时间"
end-placeholder="结束时间"
style="width: 360px"
@change="selectDateRange"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" class="search-btn" @click="handleSearch">搜索</el-button>
<el-form-item class="order-search-action">
<el-button type="primary" @click="handleSearch">搜索</el-button>
</el-form-item>
</el-form>
</el-card>
@@ -79,46 +81,71 @@
<el-button type="info" class="export" @click="exportOrder">导出订单</el-button>
</div>
<el-table v-loading="loading" :data="data" ref="table" class="mt_10" style="width: 100%">
<el-table-column prop="sn" label="订单号" min-width="240" show-overflow-tooltip />
<el-table-column label="订单来源" width="120">
<template #default="{ row }">{{ clientTypeText(row.clientType) }}</template>
</el-table-column>
<el-table-column label="订单类型" width="120">
<template #default="{ row }">
<el-tag :type="orderPromotionTagType(row.orderPromotionType)">
<div v-loading="loading" class="doudian-list mt_10">
<div class="doudian-head">
<div>商品信息</div>
<div>单价/数量</div>
<div>售后状态</div>
<div>应付</div>
<div>买家/收货人</div>
<div>订单状态</div>
<div>操作</div>
</div>
<div v-if="!data.length && !loading" class="doudian-empty">暂无订单</div>
<div v-for="row in data" :key="row.sn" class="doudian-order">
<div class="doudian-bar">
<span>下单时间:{{ row.createTime || "—" }}</span>
<span class="doudian-sn">
订单编号:{{ row.sn }}
<a class="copy-icon" title="复制订单编号" @click="copyText(row.sn)">
<el-icon><CopyDocument /></el-icon>
</a>
</span>
<span class="doudian-bar-extra">
{{ orderPromotionText(row.orderPromotionType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="memberName" label="买家名称" min-width="130" show-overflow-tooltip />
<el-table-column prop="memberId" label="会员ID" min-width="120" show-overflow-tooltip />
<el-table-column prop="storeName" label="店铺名称" min-width="150" show-overflow-tooltip />
<el-table-column label="订单金额" min-width="100">
<template #default="{ row }">
<span :style="{ color: $mainColor }">{{ $filters.unitPrice(row.flowPrice, '¥') }}</span>
</template>
</el-table-column>
<el-table-column label="支付方式" width="120">
<template #default="{ row }">{{ paymentMethodText(row.paymentMethod) }}</template>
</el-table-column>
<el-table-column label="订单状态" min-width="100">
<template #default="{ row }">
<el-tag :type="orderStatusTagType(row.orderStatus)">{{ orderStatusText(row.orderStatus) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="下单时间" width="170" />
<el-table-column label="操作" width="160" align="center" fixed="right">
<template #default="{ row }">
<a class="link-text" @click="detail(row)">查看</a>
<a
v-if="canShip(row)"
class="link-text continue-ship-link"
@click="detail(row, true)"
>{{ row.orderStatus === "PARTS_DELIVERED" ? "继续发货" : "发货" }}</a>
</template>
</el-table-column>
</el-table>
· {{ clientTypeText(row.clientType) }}
· {{ paymentMethodText(row.paymentMethod) }}
</span>
</div>
<div class="doudian-body">
<div class="goods-stack">
<div
v-for="(item, index) in orderGoods(row)"
:key="item.sn || index"
class="goods-row"
>
<div class="goods-info">
<img class="goods-img" :src="item.image" alt="" />
<div class="goods-text">
<div class="goods-name">{{ item.name || "—" }}</div>
<div v-if="item.specs" class="goods-spec">{{ item.specs }}</div>
</div>
</div>
<div class="goods-price">
<div>{{ item.goodsPrice != null ? $filters.unitPrice(item.goodsPrice, "¥") : "—" }}</div>
<div class="goods-num">x{{ item.num || 0 }}</div>
</div>
<div class="goods-after">{{ afterSaleText(item.afterSaleStatus) }}</div>
</div>
</div>
<div class="merge-cell pay-cell">{{ $filters.unitPrice(row.flowPrice, "¥") }}</div>
<div class="merge-cell buyer-cell">
<div>{{ row.memberName || "—" }}</div>
<div v-if="row.consigneeName">{{ row.consigneeName }}</div>
<div v-if="row.consigneeMobile" class="buyer-mobile">{{ row.consigneeMobile }}</div>
</div>
<div class="merge-cell">
<el-tag :type="orderStatusTagType(row.orderStatus)">{{ orderStatusText(row.orderStatus) }}</el-tag>
</div>
<div class="merge-cell action-cell">
<a class="link-text" @click="detail(row)">详情</a>
<a v-if="canShip(row)" class="link-text" @click="detail(row, true)">
{{ row.orderStatus === "PARTS_DELIVERED" ? "继续发货" : "发货" }}
</a>
</div>
</div>
</div>
</div>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<el-pagination
@@ -137,10 +164,12 @@
</template>
<script>
import { CopyDocument } from "@element-plus/icons-vue";
import * as API_Order from "@/api/order";
import { canShowShipButton, isNonShippableOrder } from "@/utils/orderFulfill";
export default {
name: "orderList",
components: { CopyDocument },
data() {
return {
fields: {
@@ -176,8 +205,8 @@ export default {
orderType: "",
orderSn: "",
keywords: "",
buyerName: "",
goodsName: "",
goodsType: "",
shipName: "",
orderStatus: "",
paymentMethod: "",
@@ -237,6 +266,40 @@ export default {
onStatusTabClick(tab) {
this.orderStatusClick(tab.paneName);
},
orderGoods(row) {
const items = row && row.orderItems;
return items && items.length ? items : [{}];
},
afterSaleText(status) {
const map = {
NOT_APPLIED: "—",
NEW: "—",
PART_AFTER_SALE: "部分售后",
ALREADY_APPLIED: "售后中",
EXPIRED: "不可售后",
};
return map[status] || "—";
},
copyText(text) {
if (!text) {
return;
}
const done = () => this.$Message.success("已复制订单编号");
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done).catch(() => this.copyTextFallback(text, done));
return;
}
this.copyTextFallback(text, done);
},
copyTextFallback(text, done) {
const input = document.createElement("textarea");
input.value = text;
document.body.appendChild(input);
input.select();
document.execCommand("copy");
document.body.removeChild(input);
done();
},
clientTypeText(v) {
const map = { H5: "移动端", PC: "PC端", WECHAT_MP: "小程序端", APP: "移动应用端" };
return map[v] || v || "-";
@@ -397,7 +460,7 @@ export default {
this.getDataList();
},
getOrderNumData() {
// orderNum 接口仅查 li_order,不含 order_item 关联;keywords/goodsName 会引用 oi 字段导致 SQL 报错
// orderNum 接口仅查 li_order;keywords/goodsName 会引用 oi 字段。goodsType 用 EXISTS,可以带上。
const { orderStatus, keywords, goodsName, ...searchParams } = this.searchForm;
API_Order.getOrderNum(searchParams)
.then((res) => {
@@ -422,6 +485,33 @@ export default {
</script>
<style lang="scss" scoped>
.order-search-form {
display: grid !important;
grid-template-columns: repeat(4, minmax(0, 1fr));
column-gap: 16px;
align-items: start;
> :deep(.el-form-item) {
display: flex;
width: auto;
min-width: 0;
margin: 8px 0 !important;
}
:deep(.el-form-item__content) {
flex: 1;
min-width: 0;
}
:deep(.el-input),
:deep(.el-select),
:deep(.el-date-editor) {
width: 100% !important;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
}
}
.export {
margin: 10px 20px 10px 0;
}
@@ -431,11 +521,163 @@ export default {
}
}
.link-text {
color: #409eff;
color: #1966ff;
cursor: pointer;
text-decoration: none;
}
.continue-ship-link {
margin-left: 8px;
.doudian-list {
min-height: 120px;
}
.doudian-head,
.doudian-body {
display: grid;
grid-template-columns:
minmax(240px, 2.4fr)
minmax(108px, 1fr)
minmax(96px, 0.95fr)
minmax(108px, 1fr)
minmax(148px, 1.2fr)
minmax(100px, 0.9fr)
88px;
}
.doudian-head {
background: #f7f8fa;
color: #86909c;
font-size: 13px;
line-height: 40px;
}
.doudian-head > div:nth-child(n + 6) {
text-align: center;
}
.doudian-head > div,
.goods-row > div,
.merge-cell {
padding: 0 12px;
box-sizing: border-box;
}
.doudian-empty {
padding: 48px 0;
text-align: center;
color: #909399;
}
.doudian-order {
margin-top: 12px;
border: 1px solid #eeeff0;
}
.doudian-bar {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 12px;
background: #f7f8fa;
color: #565960;
font-size: 12px;
}
.doudian-sn {
display: inline-flex;
align-items: center;
gap: 4px;
}
.copy-icon {
display: inline-flex;
color: #86909c;
cursor: pointer;
font-size: 14px;
}
.copy-icon:hover {
color: #1966ff;
}
.doudian-bar-extra {
margin-left: auto;
color: #86909c;
}
.doudian-body {
align-items: stretch;
}
.goods-stack {
grid-column: 1 / 4;
display: grid;
grid-template-columns: subgrid;
min-width: 0;
}
.goods-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
min-height: 84px;
align-items: center;
border-bottom: 1px solid #f2f3f5;
}
.goods-row:last-child {
border-bottom: none;
}
.goods-info {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 12px;
min-width: 0;
}
.goods-img {
width: 64px;
height: 64px;
object-fit: cover;
border-radius: 4px;
background: #f5f7fa;
flex: none;
}
.goods-text {
min-width: 0;
}
.goods-name {
color: #252931;
line-height: 20px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.goods-spec,
.goods-num,
.buyer-mobile {
margin-top: 4px;
color: #86909c;
font-size: 12px;
line-height: 18px;
}
.goods-price,
.goods-after,
.merge-cell {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
text-align: left;
color: #252931;
font-size: 13px;
}
.doudian-body > .merge-cell:nth-child(n + 4) {
align-items: center;
text-align: center;
}
.goods-price,
.goods-after {
min-height: 84px;
border-left: 1px solid #f2f3f5;
}
.merge-cell {
border-left: 1px solid #f2f3f5;
padding: 12px;
}
.pay-cell {
color: #252931;
font-weight: 500;
}
.buyer-cell {
align-items: flex-start;
text-align: left;
}
.action-cell .link-text {
line-height: 24px;
}
</style>

View File

@@ -0,0 +1,233 @@
<template>
<div class="search">
<el-card>
<el-tabs v-model="activeTab" @tab-change="onTabChange">
<el-tab-pane label="订单核销" name="verify">
<el-form class="verify-form" @submit.prevent>
<el-form-item label="核销码">
<el-input
v-model="code"
placeholder="请输入核销码"
clearable
maxlength="32"
style="width: 280px"
@keyup.enter="confirmVerify"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="lookingUp" @click="confirmVerify">确定</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="核销记录" name="records">
<el-table v-loading="loading" :data="records" border style="width: 100%">
<el-table-column prop="code" label="核销码" min-width="120" />
<el-table-column prop="orderSn" label="订单编号" min-width="200" show-overflow-tooltip />
<el-table-column prop="orderItemSn" label="订单货物编号" min-width="200" show-overflow-tooltip />
<el-table-column label="商品信息" min-width="260">
<template #default="{ row }">
<div v-if="row" class="goods-cell">
<img v-if="row.image" :src="row.image" class="goods-image" alt="" />
<div>
<div>{{ row.goodsName || "—" }}</div>
<div v-if="specText(row.specs)" class="goods-spec">{{ specText(row.specs) }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="verifiedTime" label="核销时间" width="170" />
<el-table-column prop="verifierName" label="核销员" min-width="120" show-overflow-tooltip />
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="pageNumber"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
size="small"
@current-change="loadRecords"
@size-change="onSizeChange"
/>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
<el-dialog v-model="confirmVisible" title="确认核销" width="720px" :close-on-click-modal="false">
<div class="preview-meta">
<span>核销码:{{ preview.code || "—" }}</span>
<span>订单编号:{{ preview.orderSn || "—" }}</span>
</div>
<el-table :data="preview.goods || []" border>
<el-table-column prop="orderItemSn" label="订单货物编号" min-width="180" show-overflow-tooltip />
<el-table-column label="商品信息" min-width="260">
<template #default="{ row }">
<div v-if="row" class="goods-cell">
<img v-if="row.image" :src="row.image" class="goods-image" alt="" />
<div>
<div>{{ row.goodsName || "—" }}</div>
<div v-if="specText(row.specs)" class="goods-spec">{{ specText(row.specs) }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="num" label="数量" width="80" />
</el-table>
<template #footer>
<el-button @click="confirmVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitVerify">确认核销</el-button>
</template>
</el-dialog>
</div>
</template>
<script>
import { confirmVerification, getVerificationRecords, previewVerification } from "@/api/order";
export default {
name: "orderVerification",
data() {
return {
activeTab: "verify",
code: "",
lookingUp: false,
submitting: false,
confirmVisible: false,
preview: {
code: "",
orderSn: "",
goods: [],
},
loading: false,
records: [],
total: 0,
pageNumber: 1,
pageSize: 20,
};
},
methods: {
specText(specs) {
if (!specs) return "";
try {
const obj = typeof specs === "string" ? JSON.parse(specs) : specs;
if (!obj || typeof obj !== "object") return "";
return Object.keys(obj)
.filter((key) => key !== "images")
.map((key) => `${key}:${obj[key]}`)
.join(" ");
} catch (e) {
return "";
}
},
confirmVerify() {
const code = (this.code || "").trim();
if (!code) {
this.$Message.warning("请输入核销码");
return;
}
this.lookingUp = true;
previewVerification(code)
.then((res) => {
this.lookingUp = false;
if (res.success && res.result) {
this.preview = {
code: res.result.code || code,
orderSn: res.result.orderSn || "",
goods: res.result.goods || [],
};
this.confirmVisible = true;
}
})
.catch(() => {
this.lookingUp = false;
});
},
submitVerify() {
const code = (this.preview.code || "").trim();
if (!code) {
this.$Message.warning("请输入核销码");
return;
}
this.submitting = true;
confirmVerification(code)
.then((res) => {
this.submitting = false;
if (res.success) {
this.$Message.success(res.result || "核销成功");
this.confirmVisible = false;
this.code = "";
this.preview = { code: "", orderSn: "", goods: [] };
this.pageNumber = 1;
this.loadRecords();
}
})
.catch(() => {
this.submitting = false;
});
},
onTabChange(name) {
if (name === "records") {
this.loadRecords();
}
},
onSizeChange() {
this.pageNumber = 1;
this.loadRecords();
},
loadRecords() {
this.loading = true;
getVerificationRecords({
pageNumber: this.pageNumber,
pageSize: this.pageSize,
})
.then((res) => {
this.loading = false;
if (res.success) {
this.records = res.result.records || [];
this.total = res.result.total || 0;
}
})
.catch(() => {
this.loading = false;
});
},
},
};
</script>
<style lang="scss" scoped>
.verify-form {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 0 4px;
}
.preview-meta {
display: flex;
gap: 24px;
margin-bottom: 12px;
color: #303133;
}
.goods-cell {
display: flex;
align-items: center;
gap: 10px;
}
.goods-image {
width: 48px;
height: 48px;
object-fit: cover;
flex-shrink: 0;
}
.goods-spec {
margin-top: 4px;
color: #909399;
font-size: 12px;
}
.pager {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
</style>

View File

@@ -39,6 +39,10 @@ module.exports = {
if (message.includes("ResizeObserver loop")) {
return false;
}
// 腾讯地图 GL 在跨域脚本里抛错时,浏览器只上报 Script error.
if (message === "Script error." || message.includes("far <= 0") || message.includes("near <= 0")) {
return false;
}
return true;
},
},