Initial commit: 积分兑换电商平台多商户版 MER-2.2

Made-with: Cursor
This commit is contained in:
apple
2026-03-08 20:07:52 +08:00
commit de02c8a3e1
4954 changed files with 703009 additions and 0 deletions

View File

@@ -0,0 +1,332 @@
<template>
<view class="relative" :style="cssVarStyle">
<!-- #ifdef MP || APP-PLUS -->
<NavBar navTitle="服务打卡" :iconColor="iconColor" :isBackgroundColor="false" :textColor="iconColor" :isScrolling="isScrolling" showBack>
</NavBar>
<!-- #endif -->
<TopHeaderfixed></TopHeaderfixed>
<view class="checkin-container" :style="{ height: screenHeight }">
<view class="checkin-body">
<OrderAddress :orderInfo="checkInConfig" v-if="checkInConfig" />
<view class="checkin-box">
<button class="checkin-btn" :disabled="!allowCheckin" @click="handleCheckin">
<view class="checkin-btn-text">{{ checkinBtnText }}</view>
<view class="checkin-btn-time">{{ formatTime }}</view>
</button>
<view class="checkin-box-tips">
{{
allowCheckin ? "您已进入服务打卡区域" : "您当前不在服务打卡区域"
}}
</view>
<view class="checkin-address-info borderPad">
<text class="iconfont icon-ic_location51"></text>
<text class="overflow-text checkin-address-info__text">{{ addressInfo }}</text>
<button class="checkin-address__refresh" @click="refreshLocation">刷新</button>
</view>
</view>
</view>
<!-- takePhoto: checkInConfig.clockInPhotoSwitch拍照开关 true是开启-->
<CheckinPopup v-if="checkinPopupVisible" :time="formatTime" :address="addressInfo" :workOrderNo="workOrderNo"
@cancel="handleCheckinCancel" :takePhoto="checkInConfig && checkInConfig.clockInPhotoSwitch" />
</view>
</view>
</template>
<script>
import OrderAddress from "../components/OrderAddress";
import NavBar from "@/components/navBar.vue"
import dayjs from "@/plugin/dayjs/dayjs.min.js";
import {clockInfoApi, getCoordinateAddressApi} from "./workOrder";
import { getDistanceFromLatLonInMeter} from "@/libs/order";
import CheckinPopup from "./components/CheckinPopup.vue";
import TopHeaderfixed from "../../../components/TopHeaderfixed";
export default {
components: {
TopHeaderfixed,
NavBar,
OrderAddress,
CheckinPopup
},
data() {
return {
iconColor: '#FFFFFF',
isScrolling: false,
workOrderNo: null,
customerLocation: {}, //商户经纬度
checkInConfig: {}, // 打卡配置
formatTime: "00:00:00",
inCheckinArea: false,
addressInfo: "获取位置中...",
location: null, // 当前定位经纬度
checkinLocation: null,
checkinPopupVisible: false,
distance: ''
};
},
onLoad(options) {
this._cssVarsHandler = vars => { this.$forceUpdate(); };
this.$eventHub.$on('css-vars:updated', this._cssVarsHandler);
this.workOrderNo = options.workOrderNo;
this.handleGetWorkOrderDetail();
this.getLocation(true);
this.timeDisplayTimer();
},
onUnload() {
this.$eventHub.$off('css-vars:updated', this._cssVarsHandler);
},
computed: {
// 打卡文字展示
checkinBtnText() {
if (this.hasClockRecord) return "已打卡";
if (!this.allowCheckin) return "无法打卡";
return "点击打卡";
},
locationDiff() {
return {
location: this.location,
customerLocation: this.customerLocation,
checkInConfig: this.checkInConfig
};
},
hasClockRecord() {
if (!this.checkInConfig) return false;
const { clock_in_info } = this.checkInConfig;
if (clock_in_info && clock_in_info.clock_time) {
return true;
}
return false;
},
allowCheckin() {
// 在这里判断是否需要在范围内进行定位打卡
// 必须获取到打卡配置和订单信息之后再判断是否允许打卡
if (!this.checkInConfig) return false;
return this.inCheckinArea;
},
cssVarStyle() {
return this.$getCssVarStyle();
},
screenHeight() {
const vars = this.$getCssVarStyle();
return vars['--screen-height'] || '100vh';
},
navBarHeight() {
const vars = this.$getCssVarStyle();
return vars['--nav-bar-height'] || '44px';
},
safeAreaBottom() {
const vars = this.$getCssVarStyle();
return vars['--safe-area-inset-bottom'] || '0px';
}
},
watch: {
locationDiff({ location, customerLocation, checkInConfig }) {
if (!checkInConfig) return;
// 如果不限制打卡地址,则设置为在范围内
if (!checkInConfig.clockInAddressSwitch) {
this.inCheckinArea = true;
return;
};
if (!location || !customerLocation) return;
// 单位是km
const distance = getDistanceFromLatLonInMeter(customerLocation.latitude, customerLocation.longitude);
this.distance = distance
this.inCheckinArea = distance * 1000 < checkInConfig.clockInDistance;
}
},
methods: {
// getMerStaffCheckinConfig() {
// this.checkInConfig = uni.getStorageSync('reservationConfig');
// },
// async getLocationByAddress(address) {
// try {
// const result = await geocoding(address);
// const { lng: longitude, lat: latitude } = result.data.location;
// this.customerLocation = {
// latitude: Number(latitude),
// longitude: Number(longitude)
// };
// } catch (error) {
// this.$util.Tips({
// title: error,
// icon: "none"
// });
// }
// },
// 打卡信息
async handleGetWorkOrderDetail() {
try {
const { data } = await clockInfoApi(this.workOrderNo);
this.checkInConfig = data;
this.customerLocation = {
latitude: Number(data.latitude),
longitude: Number(data.longitude)
};
// this.getLocationByAddress(this.orderInfo.user_address);
} catch (err) {
this.$util.Tips({
title: err,
icon: "none"
});
}
},
handleCheckinCancel() {
this.checkinPopupVisible = false;
},
//点击打卡
async handleCheckin() {
this.checkinPopupVisible = true;
},
async refreshLocation() {
await this.getLocation();
},
// 获取当前定位
async getLocation(firstCheck = false) {
if (this._checkLocationLoading) return;
this._checkLocationLoading = true;
try {
const { latitude, longitude } = await this.$util.$L.getLocation();
this.location = {
latitude,
longitude
};
const geoCoderRes = await getCoordinateAddressApi({
latitude: latitude,
longitude: longitude
});
this.addressInfo = geoCoderRes.data.address;
firstCheck !== true && this.$util.Tips({
title: "刷新位置成功",
icon: "none"
});
} catch (error) {
this.$util.Tips({
title: error,
icon: 'none'
});
} finally {
this._checkLocationLoading = false;
}
},
timeDisplayTimer() {
const setCurrentTime = () => {
this.formatTime = dayjs().format("HH:mm:ss");
};
setCurrentTime();
this._displayTimer = setInterval(setCurrentTime, 1000);
}
},
destroyed() {
clearInterval(this._displayTimer);
}
}
</script>
<style scoped lang="scss">
.checkin-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 256rpx; /* 44px + 212rpx */
background-image: linear-gradient(90deg, #2291F8 0%, #1CD1DC 100%);
}
.checkin-bg2 {
position: absolute;
top: 208rpx; /* 44px + 164rpx */
left: 0;
width: 100%;
height: 50rpx;
background-image: linear-gradient(0deg, #F5F5F5 0%, rgba(245, 245, 245, 0) 100%);
}
.checkin-container {
position: relative;
display: flex;
flex-direction: column;
}
.checkin-body {
padding: 36rpx 24rpx 24rpx;
display: flex;
flex-direction: column;
flex: 1;
gap: 10px;
&.safe-bottom-env {
padding-bottom: 0px; /* 移除 CSS 变量 */
}
}
.checkin-box {
flex: 1;
background-color: #fff;
border-radius: 24rpx;
}
.checkin-btn {
display: flex;
flex-flow: column;
align-items: center;
gap: 20rpx;
justify-content: center;
color: #fff;
width: 272rpx;
height: 272rpx;
border-radius: 50%;
margin: 164rpx auto 0;
background: linear-gradient(159deg, #1CD1DC -13%, #2291F8 43%), linear-gradient(139deg, #47B5FF 12%, #0F86F5 86%);
box-shadow: 0rpx 10rpx 32rpx 0rpx rgba(48, 139, 248, 0.5);
&[disabled] {
background: linear-gradient(139deg, #D0D3D9 12%, #C3C7CE 85%);
box-shadow: 0rpx 10rpx 32rpx 0rpx rgba(122, 140, 162, 0.3);
color: #fff;
}
}
.checkin-btn-text {
font-size: 40rpx;
font-weight: 500;
}
.checkin-btn-time {
font-size: 32rpx;
font-weight: 400;
opacity: 0.7;
font-variant-numeric: tabular-nums;
font-family: initial;
}
.checkin-box-tips {
text-align: center;
font-size: 28rpx;
margin-top: 60rpx;
margin-bottom: 18rpx;
}
.checkin-address-info {
margin: 0 auto;
overflow: hidden;
display: flex;
align-items: center;
color: #999;
justify-content: center;
.iconfont {
font-size: 28rpx;
margin-right: 4rpx;
}
.checkin-address__refresh {
flex-shrink: 0;
margin-left: 12rpx;
color: #308Bf8;
font-size: 12px;
}
}
</style>

View File

@@ -0,0 +1,91 @@
<template>
<view class="order-list-bottom-tabs">
<view class="tab-item" v-for="item of tabs" :key="item.index" :class="{ active: item.index === value }"
@click="$emit('input', item.index)">
<view class="iconfont f-s-40" :class="item.icon"></view>
<view class="tab-item-label">{{ item.label }}</view>
</view>
</view>
</template>
<script>
import {ASSIGNED, HOMEPAGE, UNASSIGNED} from "../config";
export default {
name: "BottomTabs",
model: {
prop: "value",
event: "input"
},
props: {
value: {
type: Number,
default: 2
}
},
data() {
return {
tabs: [
{
index: HOMEPAGE,
label: "工单",
icon: "icon-gongdan-weixuanzhong"
},
{
index: UNASSIGNED,
label: "接单",
icon: "icon-jiedan-weixuanzhong"
},
{
index: ASSIGNED,
label: "我的工单",
icon: "icon-dingdan-weixuanzhong"
}
]
}
}
}
</script>
<style scoped lang="scss">
//@import "@/static/iconfont/iconfont-next.css";
.order-list-bottom-tabs {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background-color: #ffffff;
padding-bottom: env(safe-area-inset-bottom);
display: flex;
z-index: 1;
}
.tab-item {
flex: 1;
display: flex;
flex-flow: column nowrap;
align-items: center;
justify-content: center;
height: 100rpx;
font-size: 20rpx;
line-height: 28rpx;
color: #333;
&.active {
color: #2A7EFB;
}
.iconfont-next {
font-size: 38rpx;
width: 40rpx;
height: 40rpx;
text-align: center;
line-height: 40rpx;
}
.tab-item-label {
margin-top: 6rpx;
}
}
</style>

View File

@@ -0,0 +1,380 @@
<template>
<view>
<view v-if="stage === STAGE.directSubmission"></view>
<view v-else-if="stage === STAGE.fillTheForm" class="popup-bg">
<view class="popup-form-container" :style="{ 'backgroundImage': `url(${urlDomain}crmebimage/presets/checkin-bg.png)` }">
<view class="popup-form-wrapper">
<view class="popup-form-title">您当前已在服务区域确定打卡</view>
<view class="popup-form-date">
<text class="iconfont icon-ic_clock" />
打卡时间
{{ time }}
</view>
<view class="popup-form-location">
<text class="iconfont icon-ic_location51" />
{{ address }}
</view>
<view class="popup-form-input__wrapper">
<view class="popup-form-input__inner_wrapper">
<textarea class="popup-form-input" placeholder="请输入打卡备注" fixed :maxlength="maxLength"
placeholder-style="color: #9e9e9e;" v-model="form.clockInRemark" />
<view class="popup-form-input__count">
<text>{{ form.clockInRemark.length }}/{{ maxLength }}</text>
</view>
</view>
<view class="popup-form-image-list" @click="handleImageListClick">
<view class="popup-form-image-item" v-for="(image, index) in form.images" :key="index">
<image class="popup-form-image-item__image" mode="aspectFill" :src="image" />
<button class="popup-form-image-item__delete" :data-event="EVENT.DELETE_IMAGE" :data-index="index">
<text class="iconfont icon-ic_close" :data-event="EVENT.DELETE_IMAGE" :data-index="index" />
</button>
</view>
<view class="popup-form-image-item popup-form-image-item__add"
:style="{ 'background': `url(${urlDomain}crmebimage/presets/checkin-form-camera.png) no-repeat center / 52rpx` }"
:data-event="EVENT.ADD_IMAGE" v-if="form.images.length < imageCountLimit">
<text class="iconfont icon-ic_add" />
</view>
</view>
</view>
</view>
<view class="popup-form-button-wrapper">
<button class="popup-form-button" @click="handleCheckinCancel">取消</button>
<button class="popup-form-button confirm" @click="handleCheckinConfirm">确认打卡</button>
</view>
</view>
</view>
<view v-else class="popup-bg popup-bg-success">
<view class="popup-result-container">
<image :src="`${urlDomain}crmebimage/presets/checkin-succ.png`" v-if="stage === STAGE.success" mode="widthFill"
class="popup-result-image" />
<image :src="`${urlDomain}crmebimage/presets/checkin-fail.png`" v-else mode="widthFill" class="popup-result-image" />
<view class="popup-result-title">
{{
stage === STAGE.success ? `打卡成功 ${form.checkInTime}` : "打卡失败"
}}
</view>
<view class="popup-result-description">
{{ stage === STAGE.success ? "感谢您的辛苦付出~" : "打卡失败,请再试一次" }}
</view>
<button class="popup-result-btn" v-if="stage === STAGE.success" @click="handleCheckinSuccess">我知道了</button>
<button class="popup-result-btn" v-else @click="handleRetryCheckin">重新打卡</button>
</view>
</view>
</view>
</template>
<script>
import dayjs from "@/plugin/dayjs/dayjs.min.js";
// import { STAFF_CHECKIN_SUCC_EVENT } from "@/utils/constants";
import {checkinStaffOrderApi} from "../workOrder";
const STAGE = {
directSubmission: 0,
fillTheForm: 1,
success: 2,
fail: 3
};
const EVENT = {
DELETE_IMAGE: 'delete-image',
ADD_IMAGE: 'add-image',
}
export default {
props: {
time: String,
address: String,
workOrderNo: String,
takePhoto: Boolean //拍照开关 true是开启
},
data() {
const { takePhoto } = this;
return {
urlDomain: this.$Cache.get("imgHost"),
STAGE,
EVENT,
maxLength: 100,
stage: takePhoto ? STAGE.fillTheForm : STAGE.directSubmission,
imageCountLimit: 4,
form: {
checkInTime: this.time,
clockInRemark: "",
images: []
}
};
},
async created() {
if (!this.takePhoto) {
const [err, { confirm }] = await uni.showModal({
title: "提示",
content: `确定要打卡吗?`,
});
if (err || !confirm) {
this.handleCheckinCancel();
} else {
this.handleCheckinConfirm();
}
}
},
methods: {
handleCheckinCancel() {
this.$emit("cancel");
},
async handleCheckinConfirm() {
const { clockInRemark, images } = this.form;
if( !images.length && this.stage === STAGE.fillTheForm ) return this.$util.Tips({ title: '请上传打卡照片', icon: "none" });
uni.showLoading({
mask: true
});
try {
await checkinStaffOrderApi({
workOrderNo: this.workOrderNo,
address: this.address,
clockInRemark,
clockInPhoto: images.length ? images.join(',') : ''
});
uni.hideLoading();
const checkinTime = dayjs().format("HH:mm:ss");
this.form.checkInTime = checkinTime;
this.stage = STAGE.success;
// uni.$emit(STAFF_CHECKIN_SUCC_EVENT, this.orderId);
} catch (err) {
uni.hideLoading();
this.stage = STAGE.fail;
this.$util.Tips({
title: err,
icon: "none"
});
}
},
handleUploadImage() {
this.$util.uploadImageOne({
url: 'upload/image',
name: 'multipart',
model: "staff",
pid: 1
}, (res)=> {
this.form.images.push(res);
});
},
handleImageListClick(e) {
const { event, index } = e.target.dataset;
if (event === undefined) return;
switch (event) {
case EVENT.DELETE_IMAGE:
this.form.images.splice(index, 1);
break;
case EVENT.ADD_IMAGE:
this.handleUploadImage();
break;
}
},
handleCheckinSuccess() {
uni.navigateBack(-1);
// uni.redirectTo({
// url: `/pages/admin/workOrder_manage/workOrder_detail?workOrderNo=${this.workOrderNo}`
// });
},
handleRetryCheckin() {
this.stage = STAGE.fillTheForm;
}
}
};
</script>
<style scoped lang="scss">
.popup-bg {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
}
.popup-bg-success {
background-color: rgba(0, 0, 0, 0.8);
}
.popup-form-container {
width: 654rpx;
border-radius: 12px;
background: none no-repeat left top / 100% auto #fff;
overflow: hidden;
.popup-form-wrapper {
padding: 40rpx 27rpx 40rpx;
.popup-form-title {
font-size: 32rpx;
font-weight: 500;
margin-bottom: 14rpx;
}
}
.popup-form-date,
.popup-form-location {
display: flex;
align-items: center;
font-size: 24rpx;
.iconfont {
font-size: 28rpx;
margin-right: 5rpx;
}
}
.popup-form-date {
color: #2291F8;
}
.popup-form-location {
margin-top: 12rpx;
color: #999;
}
.popup-form-input__wrapper {
margin-top: 28rpx;
background-color: #F9F9F9;
border-radius: 8px;
padding: 28rpx 20rpx 20rpx;
}
.popup-form-input__inner_wrapper {
position: relative;
}
.popup-form-input {
font-size: 14px;
line-height: 40rpx;
height: 200rpx;
width: 100%;
}
.popup-form-input__count {
text-align: right;
font-size: 28rpx;
}
.popup-form-image-list {
display: flex;
flex-flow: row wrap;
--gap: 20rpx;
--row-count: 4;
gap: var(--gap);
font-size: 0;
margin-top: 12px;
}
.popup-form-image-item {
--size: calc((100% - var(--gap) * (var(--row-count) - 1)) / var(--row-count));
width: var(--size);
aspect-ratio: 1 / 1;
border-radius: 6px;
background-color: #F9F9F9;
position: relative;
}
.popup-form-image-item__image {
width: 100%;
height: 100%;
border-radius: 6px;
}
.popup-form-image-item__delete {
position: absolute;
top: -16rpx;
right: -12rpx;
width: 32rpx;
height: 32rpx;
background-color: #ccc;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
.iconfont {
font-size: 24rpx;
color: #fff;
}
}
.popup-form-image-item__add {
border: 1rpx solid #ddd;
background-color: #f5f5f5;
}
.popup-form-button-wrapper {
border-top: 1rpx solid #eee;
display: flex;
}
.popup-form-button {
flex: 1;
height: 90rpx;
display: flex;
justify-content: center;
align-items: center;
font-size: 30rpx;
color: #666;
border-radius: 0;
}
.popup-form-button.confirm {
color: #2291F8;
border-left: 1rpx solid #eee;
}
}
.popup-result-container {
border-radius: 12px;
background: #FFFFFF;
width: 560rpx;
display: flex;
flex-direction: column;
align-items: center;
padding: 70rpx 0 86rpx;
.popup-result-image {
width: 350rpx;
height: 222rpx;
margin-bottom: 60rpx;
}
.popup-result-title {
font-size: 36rpx;
font-weight: 500;
}
.popup-result-description {
font-size: 28rpx;
color: #999999;
margin: 16rpx 0 68rpx;
}
.popup-result-btn {
width: 340rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50rpx;
background: #2291F8;
color: #fff;
font-size: 28rpx;
font-weight: 500;
}
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<view class="borderPad mt-28 mb20">
<view class="borRadius14 bg--w111-fff px-24 py-32 color28 f-s-30 f-w-500 relative">
<view class="mb-32">工单管理</view>
<view class="borRadius14 word_order flex-between-center">
<view class="list text-center">
<view class="flex-center mb-20">
<view class="w-36 h-36 rd-4rpx"><text class="iconfont icon-ic-gift3"></text></view>
<view class="title">待服务工单</view>
</view>
<view class="f-s-40 semiBold">{{serviceStaffInfo.awaitWorkNum}}</view>
</view>
<view class="line"></view>
<view class="list text-center">
<view class="flex-center mb-20">
<view class="w-36 h-36 rd-4rpx"><text class="iconfont icon-ic-gift3"></text></view>
<view class="title">已服务工单</view>
</view>
<view class="f-s-40 semiBold">{{serviceStaffInfo.workedNum}}</view>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: "statistics",
props: {
serviceStaffInfo:{
type: Object,
default: {}
}
}
}
</script>
<style scoped lang="scss">
.word_order{
width: 662rpx;
height: 182rpx;
background: #F9F9F9;
.list{
width: 330rpx;
}
.title{
font-size: 24rpx;
margin-left: 16rpx;
color: #999999FF;
}
.line{
width: 2rpx;
height: 72rpx;
background-color: #D8D8D8FF;
}
}
</style>

View File

@@ -0,0 +1,160 @@
// 已领取的工单
import {checkinStaffOrderApi, workOrderReceiveApi, workOrderServiceEndApi} from "./workOrder";
import util from "../../../utils/util";
export const ASSIGNED = 2;
// 待领取工单
export const UNASSIGNED = 1;
// 工单主页
export const HOMEPAGE = 3;
/**
* 工单服务类型
*/
export const serviceStatusEnum = {
Unabsorbed: 1, //未分配
WaitingService: 2, //已分配 待服务
inService: 3, // 服务中
ServiceEnd: 4, //服务结束
};
/**
* 工单操作按钮判断
* @type {{COMPLETE: string, SERVICE_RECORD: string, SIGN_IN: string, RUSH_ORDER: string, START: string}}
*/
const BTN_EVENT = {
SERVICE_RECORD: "serviceRecord",
SIGN_IN: "signIn",
COMPLETE: 'complete',
RUSH_ORDER: "rush_order",
START: "start",
}
export function workOrderBottomBar(workOrderNoInfo) {
const reservationConfig = uni.getStorageSync('reservationConfig'); // 商户预约设置
// if (!this.orderInfo || !this.merServiceConfig) return [];
const config = [];
if( workOrderNoInfo.refundStatus > 0 ) return [];
// 上门serviceType === 1,2到店
if (workOrderNoInfo.serviceType === 1) {
// allocateType 分配类型0-未分配1-派单2-抢单
if (workOrderNoInfo.allocateType === 0) {
config.push({
text: "领取工单",
type: "lang",
event: BTN_EVENT.RUSH_ORDER
});
}
if (workOrderNoInfo.serviceStatus === serviceStatusEnum.WaitingService) {
config.push({
text: "上门打卡",
type: "lang",
event: BTN_EVENT.SIGN_IN
});
}
if (workOrderNoInfo.serviceStatus === serviceStatusEnum.inService && reservationConfig
.serviceEvidenceSwitch && !workOrderNoInfo.serviceEvidenceFormId) {
config.push({
text: "服务留凭",
type: "lang",
event: BTN_EVENT.SERVICE_RECORD
});
}
}else{
if (workOrderNoInfo.serviceStatus === serviceStatusEnum.WaitingService) {
config.push({
text: "服务开始",
type: "lang",
event: BTN_EVENT.START
});
}
}
if (workOrderNoInfo.serviceStatus === serviceStatusEnum.inService && ((workOrderNoInfo.serviceEvidenceFormId >0 || !reservationConfig.serviceEvidenceSwitch) || workOrderNoInfo.serviceType === 2)) {
config.push({
text: "服务完成",
type: "lang",
event: BTN_EVENT.COMPLETE
});
}
return config;
}
/**
* 工单按钮操作
* @param event 操作名称
* @param workOrderNo 工单号
* @returns {Promise<unknown>}
*/
export async function handleWorkOrderBarAction(event, workOrderNo) {
switch (event) {
case BTN_EVENT.SERVICE_RECORD:
uni.navigateTo({
url: `/pages/goods/service_record/index?workOrderNo=${workOrderNo}`
});
break;
case BTN_EVENT.SIGN_IN:
util.navigateTo(`/pages/admin/workOrder_manage/checkin?workOrderNo=${workOrderNo}`)
break;
case BTN_EVENT.COMPLETE:
return new Promise(async (resolve) => {
const result = await uni.showModal({
content: "您确定要完成服务吗?",
});
if (result[0] || result[1].cancel) return;
let data = await workOrderServiceEndApi(workOrderNo)
if (data.code === 200) {
util.Tips({
title: '服务完成成功'
});
await resolve(BTN_EVENT.COMPLETE)
}
});
break;
case BTN_EVENT.RUSH_ORDER:
return new Promise(async (resolve) => {
const result = await uni.showModal({
content: "您确定要领取此工单服务吗?",
});
if (result[0] || result[1].cancel) return;
try {
await workOrderReceiveApi(workOrderNo);
util.Tips({
title: '领取成功',
icon: "success"
});
await resolve(BTN_EVENT.RUSH_ORDER)
// uni.navigateTo({
// url: `/pages/admin/workOrder_manage/workOrder_detail?workOrderNo=${workOrderNo}`
// });
} catch (err) {
util.Tips({
title: err,
icon: "none"
});
}
});
break;
case BTN_EVENT.START:
return new Promise(async (resolve) => {
const result = await uni.showModal({
content: "您确定要开始服务吗?",
});
if (result[0] || result[1].cancel) return;
try {
await checkinStaffOrderApi({workOrderNo: workOrderNo});
uni.hideLoading();
util.Tips({
title: '服务开始',
icon: "none"
});
await resolve(BTN_EVENT.START)
} catch (err) {
uni.hideLoading();
util.Tips({
title: err,
icon: "none"
});
}
});
break;
}
}

View File

@@ -0,0 +1,189 @@
<template>
<view :data-theme="theme">
<nav-bar :isScrolling="isScrolling" :iconColor='iconColor' :isShowMenu="false" :isBackgroundColor="false" ref="navBarRef" :navTitle="serviceStaffInfo.merName" goBack="/pages/user/index">
</nav-bar>
<TopHeaderfixed></TopHeaderfixed>
<view class="merchant-info relative borderPad mt-24">
<view class="merchant-info-wrapper">
<image :src="serviceStaffInfo.idPhoto" class="merchant-user-avatar" />
<view class="merchant-user-name overflow-text">{{ serviceStaffInfo.name }}</view>
</view>
</view>
<Statistics :serviceStaffInfo="serviceStaffInfo"></Statistics>
<view class="relative"> <OrderList :orderList="orderList" :orderType="orderType" /></view>
<view v-if="!loadOptions.loading">
<view class="order-list-empty text-center py-20 f-s-26 text--w111-ccc" v-if="loadOptions.loaded && orderList.length">
暂无更多
</view>
<view v-else-if="orderList.length === 0" class="nothing">
<emptyPage title="暂无订单~" mTop="14%" :imgSrc="urlDomain+'crmebimage/presets/nodingdan.png'" />
</view>
</view>
<view class="list-bottom-tab-placeholder"></view>
<BottomTabs v-model="orderType"/>
</view>
</template>
<script>
import NavBar from "@/components/navBar.vue"
import {reservationConfigApi, staffInfoApi, staffLoginApi, workOrderAwaitListApi, workOrderListApi} from "./workOrder";
import Cache from "@/utils/cache";
import BottomTabs from "./components/BottomTabs";
import { HOMEPAGE} from "./config";
import OrderList from "../components/OrderList";
import emptyPage from "@/components/emptyPage";
import Statistics from "./components/Statistics";
import TopHeaderfixed from "../../../components/TopHeaderfixed";
const app = getApp();
export default {
components: {
NavBar,
BottomTabs,
OrderList,
emptyPage,
Statistics,
TopHeaderfixed
},
data() {
return {
urlDomain: this.$Cache.get("imgHost"),
theme: app.globalData.theme,
orderType: HOMEPAGE,
serviceStaffInfo: {},
loadOptions: {
page: 1,
pageSize: 10,
total: 0,
loading: false,
loaded: false,
},
orderList: [],
iconColor: '#FFFFFF',
isScrolling: false,
}
},
watch: {
// 底部tab选项卡
orderType: {
handler(newValue) {
if (newValue === HOMEPAGE){
uni.redirectTo({
url: `/pages/admin/workOrder_manage/index`
})
}else{
uni.redirectTo({
url: `/pages/admin/workOrder_manage/workOrder_list?orderType=${newValue}`
})
}
}
},
},
async onPullDownRefresh() {
await this.handleForceRefetch();
uni.stopPullDownRefresh();
},
onPageScroll(e) {
if (e.scrollTop > 50) {
this.iconColor = '#333333';
this.isScrolling = true;
} else {
this.iconColor = '#FFFFFF';
this.isScrolling = false;
}
this.isScroll = e.scrollTop > 0;
},
onReachBottom() {
const { loading, loaded } = this.loadOptions;
if (loading || loaded) return;
this.loadOptions.page++;
this.handleGetOrderList();
},
onLoad() {
if(!Cache.get('workOrderToken')){
this.handleLogin();
}else{
this.getStaffInfo()
this.getReservationConfig()
this.handleGetOrderList()
}
},
methods: {
handleForceRefetch() {
this.orderList = [];
return this.handleGetOrderList();
},
async handleLogin(){
try {
const res = await staffLoginApi()
this.$store.commit('SET_WORK_ORDER_TOKEN', res.data.token);
await this.getReservationConfig()
await this.getStaffInfo()
await this.handleGetOrderList()
} catch (err) {}
},
async getReservationConfig(){
const { data } = await reservationConfigApi()
uni.setStorageSync('reservationConfig', data);
},
// 用户信息
async getStaffInfo(){
const { data } = await staffInfoApi()
this.serviceStaffInfo = data
},
// 列表
async handleGetOrderList() {
const { loading, loaded, page, pageSize } = this.loadOptions;
if (loading || loaded) return;
this.loadOptions.loading = true;
try {
const res = await workOrderListApi({
status : 3,
page,
limit: pageSize
})
this.orderList.push(...res.data.list);
this.loadOptions.total = res.data.total;
this.loadOptions.loaded = this.orderList.length >= this.loadOptions.total;
} catch (err) {
this.$util.Tips({
title: err,
icon: "none"
});
}
this.loadOptions.loading = false;
}
}
}
</script>
<style scoped lang="scss">
.business-bg {
height: calc(var(--nav-bar-height) + 290rpx);
background: linear-gradient(180deg, #2291F8 0%, #2291F8 var(--nav-bar-height), rgba(34, 145, 248, 0) 100%);
position: fixed;
width: 100%;
top: 0;
left: 0;
}
.merchant-info {
&-wrapper {
display: flex;
align-items: center;
}
.merchant-user-avatar {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
}
.merchant-user-name {
font-size: 30rpx;
font-weight: 500;
color: #fff;
max-width: 60 0rpx;
margin: 0 16rpx;
}
}
</style>

View File

@@ -0,0 +1,111 @@
// +----------------------------------------------------------------------
// | CRMEB [ CRMEB赋能开发者助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed CRMEB并不是自由软件未经许可不能去掉CRMEB相关版权
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
import request from "@/utils/request.js";
/**
* 服务员工登录
*/
export function staffLoginApi() {
return request.post(`staff/login/index`);
}
/**
* 待领取工单分页列表
*/
export function workOrderAwaitListApi(data) {
return request.get(`staff/work/order/await/receive/page`,data);
}
/**
* 我的工单分页列表
* @data
*/
export function workOrderListApi(data) {
return request.get(`staff/work/order/page`, data);
}
/**
* 商户预约设置信息
*/
export function reservationConfigApi() {
return request.get(`staff/merchant/reservation/config`);
}
/**
* 获取工单打卡页信息
*/
export function clockInfoApi(workOrderNo) {
return request.get(`staff/work/order/clock/in/page/info/${workOrderNo}`);
}
/**
* 工单打卡
*/
export function checkinStaffOrderApi(data) {
return request.post(`staff/work/order/clock/in`, data);
}
/**
* 通过坐标获取地址
*/
export function getCoordinateAddressApi(data) {
return request.get(`staff/address/get/coordinate/address`, data);
}
/**
* 工单详情
*/
export function getWorkOrderInfoApi(workOrderNo) {
return request.get(`staff/work/order/info/${workOrderNo}`);
}
/**
* 工单备注
*/
export function workOrderRemarkApi(data) {
return request.post(`staff/work/order/remark`,data);
}
/**
* 工单服务结束
*/
export function workOrderServiceEndApi(workOrderNo) {
return request.post(`staff/work/order/service/end/${workOrderNo}`);
}
/**
* 领取工单
*/
export function workOrderReceiveApi(workOrderNo) {
return request.post(`staff/work/order/receive/${workOrderNo}`);
}
/**
* 商户预约服务留凭表单信息
*/
export function serviceFormInfoApi() {
return request.get(`staff/merchant/reservation/service/evidence/form/info`);
}
/**
* 工单服务过程留凭
*/
export function serviceEvidenceApi(data) {
return request.post(`staff/work/order/service/evidence`, data);
}
/**
* 获取登录员工信息
*/
export function staffInfoApi() {
return request.get(`staff/login/staff/info`);
}

View File

@@ -0,0 +1,201 @@
<template>
<view>
<!-- #ifdef MP || APP-PLUS -->
<NavBar navTitle="工单详情" :iconColor="iconColor" :isShowMenu="false" :isBackgroundColor="false"
:textColor="iconColor" :isScrolling="isScrolling" showBack>
</NavBar>
<!-- #endif -->
<!-- 状态备注 -->
<OrderHeader :info="workOrderNoInfo" :remark="workOrderNoInfo.remark" @changeRemark="handleRemark('1')" title="工单"></OrderHeader>
<view class="relative borderPad mt20" :class="btnConfig.length?'pb-80':'mb-20'">
<!-- 地址信息 -->
<OrderAddress v-if="workOrderNoInfo.serviceType === 1" :orderInfo="workOrderNoInfo"></OrderAddress>
<!-- 商品信息用户信息 -->
<view class="borderPad bg--w111-fff borRadius14 mt20 pb-32">
<OrderGoods :cartInfo="[workOrderNoInfo]" :orderInfo="workOrderNoInfo" :isShowBtn="false"></OrderGoods>
<OrderTable :list="tableData" mini-gap :has-style="false"></OrderTable>
</view>
<!-- 系统表单信息 -->
<view v-if="reservationFormData.length" class="bg--w111-fff borRadius14 mt20 pt-32">
<view class="borderPad order-detail-table f-s-30 f-w-500" style="margin-bottom: -20rpx;">表单信息</view>
<systemFromInfo :orderExtend="reservationFormData"></systemFromInfo>
</view>
<!-- 服务信息打卡信息 -->
<OrderTable :list="item.list" :title="item.title" v-for="(item, index) in tableList" :key="index">
</OrderTable>
<!-- 服务过程留凭信息 -->
<view v-if="serviceEvidenceForm.length" class="bg--w111-fff borRadius14 mt20 pt-32 pb-32">
<view class="borderPad order-detail-table f-s-30 f-w-500">服务过程留凭</view>
<systemFromInfo :orderExtend="serviceEvidenceForm"></systemFromInfo>
</view>
<view class="list-bottom-tab-placeholder"></view>
</view>
<OrderBottomBar v-if="btnConfig.length" :config="btnConfig" @action="handleBottomBarAction"></OrderBottomBar>
<PriceChange :change="change" :orderInfo="workOrderNoInfo" v-on:closechange="changeClose($event)"
v-on:savePrice="getRemark" :status="status" title="工单" :remark="workOrderNoInfo.remark"></PriceChange>
</view>
</template>
<script>
// #ifdef MP || APP-PLUS
import NavBar from "@/components/navBar.vue";
// #endif
import PriceChange from "../components/PriceChange";
import OrderHeader from "../components/OrderDetail/OrderHeader.vue";
import OrderTable from "../components/OrderDetail/OrderTable.vue";
import systemFromInfo from '@/components/systemFromInfo';
import OrderGoods from '@/components/orderGoods'
import {
getWorkOrderInfoApi,
workOrderRemarkApi
} from "./workOrder";
import OrderAddress from "../components/OrderAddress";
import {
getTableList
} from "../order";
import {
handleWorkOrderBarAction,
workOrderBottomBar
} from "./config";
import OrderBottomBar from "../components/OrderBottomBar";
const BTN_EVENT = {
SERVICE_RECORD: "serviceRecord",
SIGN_IN: "signIn",
COMPLETE: 'complete'
}
export default {
name: "workOrder_detail",
components: {
OrderBottomBar,
OrderHeader,
PriceChange,
systemFromInfo,
OrderAddress,
OrderGoods,
OrderTable,
// #ifdef MP || APP-PLUS
NavBar,
// #endif
},
data() {
return {
iconColor: '#FFFFFF',
isScrolling: false,
workOrderNo: '',
workOrderNoInfo: {},
status: '',
change: false,
tableData: [],
reservationFormData: [], //系统表单数据
serviceEvidenceForm: [] //服务留凭
}
},
computed: {
tableList() {
return getTableList(this.workOrderNoInfo);
},
// 底部操作按钮
btnConfig() {
return workOrderBottomBar(this.workOrderNoInfo)
},
},
onShow(){
this.handleGetWorkOrderDetail();
},
onPageScroll(e) {
// #ifdef MP || APP-PLUS
if (e.scrollTop > 50) {
this.iconColor = '#333333';
this.isScrolling = true;
} else {
this.iconColor = '#FFFFFF';
this.isScrolling = false;
}
// #endif
},
onLoad(options) {
this.workOrderNo = options.workOrderNo;
},
methods: {
// 工单详情
async handleGetWorkOrderDetail() {
uni.showLoading({
title: '加载中...'
});
const {
data
} = await getWorkOrderInfoApi(this.workOrderNo);
this.workOrderNoInfo = data;
this.getTableData();
this.reservationFormData = data.reservationFormData ? [JSON.parse(data.reservationFormData)] :
[] // 表单信息
this.serviceEvidenceForm = data.serviceEvidenceForm ? [JSON.parse(data.serviceEvidenceForm)] :
[] // 服务留凭
uni.hideLoading();
},
// 联系人信息
getTableData() {
this.tableData = [{
label: "订单号",
value: this.workOrderNoInfo.orderNo,
copy: true,
},
{
label: "留言",
value: this.workOrderNoInfo.userRemark,
overflow: true,
}
]
},
// 操作按钮回调
async handleBottomBarAction(event) {
let data = await handleWorkOrderBarAction(event, this.workOrderNo)
// 服务完成 / 服务开始
if (data === 'complete' || data === 'start' || 'rush_order') await this.handleGetWorkOrderDetail()
},
// 备注
handleRemark: function(status) {
this.change = true;
this.status = status;
},
async getRemark(opt) {
if (!opt.remark) {
return this.$util.Tips({
title: '请输入备注'
})
} else {
this.toMark(opt.remark)
}
},
//备注
toMark(remark) {
workOrderRemarkApi({
workOrderNo: this.workOrderNo,
remark
}).then(res => {
res.code == 200 && (this.change = false);
this.handleGetWorkOrderDetail()
return this.$util.Tips({
title: '备注成功'
})
})
},
changeClose: function(msg) {
this.change = msg;
},
},
}
</script>
<style scoped>
.pb-80 {
padding-bottom: 80rpx;
}
.order-detail-table {
color: #333333FF;
}
</style>

View File

@@ -0,0 +1,332 @@
<template>
<view :data-theme="theme">
<!-- #ifdef MP || APP-PLUS -->
<NavBar titleText="工单管理" bagColor="#f5f5f5" iconColor='#333333' textColor="#333333" :isScrolling="isScrolling" showBack goBack="pages/user/index"></NavBar>
<!-- #endif -->
<view class="order-top-bar">
<view class="order-top-bar-content">
<SearchBar @confirm="handleSearch" placeholder="请输入工单号/订单号/商品名称">
<view v-show="orderType === ASSIGNED" class="calendar-btn" :style="{ 'background-image': `url(${urlDomain}crmebimage/presets/ic_calendar.png)` }">
<uni-datetime-picker ref="daterange" type="daterange" @change="handleChangeDateRange">
<view class="daterange-placeholder"></view>
<template #header>
<button hover-class="none" class="calendar-clear-btn" @click="handleClearDateRange">清空</button>
</template>
</uni-datetime-picker>
</view>
</SearchBar>
</view>
<view class="order-category-bg" v-if="orderType === ASSIGNED">
<OrderCategory v-model="orderStatus" :orderType="orderType"/>
</view>
</view>
<view class="order-list-container" :class="{ 'is-assign-order': orderType === UNASSIGNED }">
<OrderList :orderList="orderList" :orderType="orderType" />
</view>
<template v-if="!loadOptions.loading">
<view class="order-list-empty" v-if="loadOptions.loaded && orderList.length">
暂无更多
</view>
<view v-else-if="orderList.length === 0" class="nothing">
<emptyPage title="暂无订单~" mTop="25%" :imgSrc="urlDomain+'crmebimage/presets/nodingdan.png'" />
</view>
</template>
<view class="list-bottom-tab-placeholder"></view>
<BottomTabs v-model="orderType" />
</view>
</template>
<script>
// #ifdef MP || APP-PLUS
import NavBar from '../components/NavBar.vue';
// #endif
import BottomTabs from "./components/BottomTabs";
import {workOrderAwaitListApi, workOrderListApi} from "./workOrder";
import {ASSIGNED, HOMEPAGE, serviceStatusEnum, UNASSIGNED} from "./config";
import OrderCategory from "../components/OrderCategory";
import emptyPage from "@/components/emptyPage";
import OrderList from "../components/OrderList";
import SearchBar from "../components/SearchBar";
const app = getApp();
export default {
name: "workOrder_list",
components: {
SearchBar,
// #ifdef MP || APP-PLUS
NavBar,
// #endif
BottomTabs,
OrderCategory,
emptyPage,
OrderList
},
data() {
return {
ASSIGNED,
UNASSIGNED,
serviceStatusEnum,
urlDomain: this.$Cache.get("imgHost"),
theme: app.globalData.theme,
isScrolling: false,
orderType: null,
isScroll: false,
orderStatus: 0,
loadOptions: {
page: 1,
pageSize: 10,
total: 0,
loading: false,
loaded: false,
},
orderList: [],
dateLimit: '',
startDate: "",
endDate: "",
searchText: ''
}
},
async onPullDownRefresh() {
await this.handleForceRefetch();
uni.stopPullDownRefresh();
},
onPageScroll(e) {
this.isScroll = e.scrollTop > 0;
},
onReachBottom() {
const { loading, loaded } = this.loadOptions;
if (loading || loaded) return;
this.loadOptions.page++;
this.handleGetOrderList();
},
onLoad(options) {
this.orderType = Number(options.orderType);
},
onUnload() {
},
onShow(){
this.orderList = [];
this.handleForceRefetch();
},
computed: {
// 搜索条件
queryParams() {
const params = {
keywords: this.searchText,
status : this.orderStatus
};
if (this.orderType === 1) {
delete params.status
}
if (this.dateLimit) {
params.dateLimit = this.dateLimit
}
return params;
}
},
watch: {
// 底部tab选项卡
orderType: {
handler(newValue, oldValue) {
if (newValue === HOMEPAGE){
uni.redirectTo({
url: `/pages/admin/workOrder_manage/index`
})
}
if(oldValue) this.handleForceRefetch()
}
},
// 底部tab选项卡
orderStatus() {
this.handleForceRefetch()
}
},
methods: {
handleStaffCheckinSucc(orderId) {
if (this.orderType === ASSIGNED) {
const order = this.orderList.find(item => item.order_id === Number(orderId));
if (order) {
// 更新打卡后的订单状态为已上门打卡,待服务
order.status = 20;
}
}
},
// 选择时间
handleChangeDateRange(e) {
const [before, after] = e;
this.startDate = before;
this.endDate = after;
this.dateLimit = e.join(",")
this.orderList = [];
this.handleForceRefetch();
},
handleClearDateRange() {
this.$refs.daterange.close();
this.$refs.daterange.clear();
this.startDate = "";
this.endDate = "";
this.handleForceRefetch()
},
handleForceRefetch() {
this.handleResetLoadOptions();
this.orderList = [];
this.handleGetOrderList();
},
handleResetLoadOptions() {
this.loadOptions.page = 1;
this.loadOptions.total = 0;
this.loadOptions.loaded = false;
this.loadOptions.loading = false;
},
handleSearch(searchText) {
this.searchText = searchText;
this.handleForceRefetch();
},
// 列表
async handleGetOrderList() {
const { loading, loaded, page, pageSize } = this.loadOptions;
if (loading || loaded) return;
this.loadOptions.loading = true;
try {
const res = this.orderType === ASSIGNED ? await workOrderListApi({
...this.queryParams,
page,
limit: pageSize
}) : await workOrderAwaitListApi({
...this.queryParams,
page,
limit: pageSize
});
// res.data.list.forEach(item => {
// addBookingOrderType(item);
// });
this.orderList.push(...res.data.list);
this.loadOptions.total = res.data.total;
this.loadOptions.loaded = this.orderList.length >= this.loadOptions.total;
} catch (err) {
this.$util.Tips({
title: err,
icon: "none"
});
}
this.loadOptions.loading = false;
}
}
}
</script>
<style scoped lang="scss">
$bg-height: calc(238rpx + var(--safe-area-inset-top));
.order-top-bar-content{
padding: 20rpx 24rpx;
}
.body-bg1 {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: $bg-height;
background-image: linear-gradient(90deg, #2291F8 0%, #1CD1DC 100%);
}
.body-bg2 {
position: absolute;
bottom: -2rpx;
left: 0;
height: 48rpx;
width: 100%;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, #f5f5f5 100%);
&.unassign-order-and-scroll {
background: #f5f5f5;
}
}
.order-category-bg {
position: relative;
&::after,
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 48rpx;
}
&::before {
// background-image: linear-gradient(90deg, #2291F8 0%, #1CD1DC 100%);
}
&::after {
height: 50rpx;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, #f5f5f5 100%);
}
}
.order-top-bar {
position: sticky;
top: 0;
z-index: 10;
background-color: #f5f5f5;
//min-height: $bg-height;
}
.list-bottom-tab-placeholder {
height: calc(var(--safe-area-inset-bottom) + 100rpx);
}
.order-list-empty {
text-align: center;
margin: 20rpx 0 70rpx;
color: #CCCCCC;
font-size: 26rpx;
}
.nothing {
margin-top: 0;
/deep/ .empty-box {
margin-top: 0;
}
}
.calendar-btn {
height: var(--content-height);
aspect-ratio: 1;
margin-left: 16rpx;
background-color: #fff;
border-radius: 50%;
background: none no-repeat center / 36rpx #fff;
.daterange-placeholder {
height: var(--content-height);
}
.calendar-clear-btn {
position: absolute;
top: 0;
left: 0;
bottom: 0;
display: flex;
flex-direction: row;
align-items: center;
margin: 10px 25px 0;
font-size: 26rpx;
color: #737987;
}
}
.order-list-container {
position: relative;
&.is-assign-order {
//margin-top: -40rpx;
}
}
</style>