feat(订单核销): 恢复移动端核销页与 order_verific 接口,核销后进入待评价

仅开放给核销员/配送员,并补齐 page_link 与个人中心菜单契约路径。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
吴昊天 2026-10-26 12:04:39 +08:00
parent 38c398c109
commit 2444d60cc4
13 changed files with 548 additions and 44 deletions

View File

@ -103,7 +103,8 @@ CRMEB 开源版(KY)移动端,基于 **uni-app(Vue 3 + `<script setup>`
## v8 相对 v6 的移除项
- `pages/admin/`(移动端商家管理)已整体移除,`api/admin.js` 一并删除,勿再引用。
- `pages/admin/` 商家管理整包已移除(订单/商品/用户/统计等),`api/admin.js` 一并删除,勿再引用。
- 例外:仅恢复核销页 `pages/admin/order_cancellation/index` + `api/writeoff.js`(接口 `order/order_verific`),路径为 DIY/`page_link`/个人中心菜单硬契约,勿改名。
- Vue2 遗留的 `mixins/`(color/SendVerifyCode/sharePoster/skuSelect/debounce)已被 composables 取代或内联,勿再引用。
## Vue3 迁移注记

View File

@ -171,6 +171,10 @@ class PublicController
$auth['/pages/users/user_invoice_list/index'] = $invoiceStatus;
$auth['/pages/annex/vip_paid/index'] = $svipOpen;
$auth['/kefu/mobile_list'] = $userService;
// 移动端核销页仅对核销员/配送员开放;已删除的商家管理页强制隐藏
$auth['/pages/admin/order_cancellation/index'] = $userVerifyStatus || $deliveryUser;
$auth['/pages/admin/order/index'] = false;
$auth['/pages/admin/manage/index'] = false;
foreach ($menusInfo as $key => &$value) {
if (isset($value['is_show']) && $value['is_show'] == 0) {
unset($menusInfo[$key]);
@ -971,6 +975,10 @@ class PublicController
$auth['/pages/users/user_invoice_list/index'] = $invoiceOpen;
$auth['/pages/annex/vip_paid/index'] = $sVipOpen;
$auth['/kefu/mobile_list'] = $userIsService;
// 移动端核销页仅对核销员/配送员开放;已删除的商家管理页强制隐藏
$auth['/pages/admin/order_cancellation/index'] = $userIsVerify || $userIsDelivery;
$auth['/pages/admin/order/index'] = false;
$auth['/pages/admin/manage/index'] = false;
// 处理主题菜单配置
if ($themeInfo) {

View File

@ -0,0 +1,62 @@
<?php
// +----------------------------------------------------------------------
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\api\controller\v1\order;
use app\Request;
use app\services\order\OrderWriteOffServices;
/**
* 移动端订单核销(店员/配送员)
*/
class OrderWriteOffController
{
/**
* 查询或执行核销
* is_confirm=0 返回订单确认信息;=1 执行核销
* @param Request $request
* @param OrderWriteOffServices $services
* @return mixed
*/
public function orderVerific(Request $request, OrderWriteOffServices $services)
{
[$verifyCode, $isConfirm] = $request->postMore([
['verify_code', ''],
['is_confirm', 0],
], true);
$verifyCode = trim((string)$verifyCode);
if ($verifyCode === '') {
return app('json')->fail('请输入核销码或者扫描核销二维码');
}
$uid = (int)$request->uid();
// 不允许客户端跳过权限校验(auth 固定 0)
$orderInfo = $services->writeOffOrder($verifyCode, (int)$isConfirm, $uid, 0);
if ((int)$isConfirm === 0) {
return app('json')->success([
'order_id' => (string)($orderInfo['order_id'] ?? $orderInfo['order_no'] ?? ''),
'order_no' => (string)($orderInfo['order_no'] ?? ''),
'image' => (string)($orderInfo['image'] ?? ''),
'title' => (string)($orderInfo['title'] ?? ''),
'nickname' => (string)($orderInfo['nickname'] ?? ''),
'verify_code' => (string)($orderInfo['verify_code'] ?? $verifyCode),
'shipping_type' => (int)($orderInfo['shipping_type'] ?? 0),
'pay_price' => $orderInfo['pay_price'] ?? 0,
'total_num' => (int)($orderInfo['total_num'] ?? 0),
]);
}
return app('json')->success('核销成功');
}
}

View File

@ -0,0 +1,44 @@
<?php
// +----------------------------------------------------------------------
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
namespace app\api\middleware;
use app\services\personnel\ServicePersonnelServices;
use app\services\system\store\SystemStoreStaffServices;
use crmeb\interfaces\MiddlewareInterface;
use think\Request;
/**
* 移动端核销权限:核销员或配送员其一即可
*/
class WriteOffAuthMiddleware implements MiddlewareInterface
{
public function handle(Request $request, \Closure $next)
{
$uid = (int)$request->uid();
if ($uid <= 0) {
return app('json')->fail('请先登录');
}
/** @var SystemStoreStaffServices $storeStaffServices */
$storeStaffServices = app()->make(SystemStoreStaffServices::class);
if ($storeStaffServices->verifyStatus($uid)) {
return $next($request);
}
/** @var ServicePersonnelServices $deliveryServices */
$deliveryServices = app()->make(ServicePersonnelServices::class);
if ($deliveryServices->checkoutIsService($uid)) {
return $next($request);
}
return app('json')->fail('您无核销权限,请联系管理员');
}
}

View File

@ -262,12 +262,20 @@ Route::group(function () {
// 代理商相关路由已迁移至 modules/distribution/route/user.php
/** 移动端订单核销(核销员/配送员,商城用户登录态) */
Route::group(function () {
Route::post('order/order_verific', [\app\api\controller\v1\order\OrderWriteOffController::class, 'orderVerific'])
->name('orderVerific')
->middleware(\app\api\middleware\WriteOffAuthMiddleware::class)
->option(['real_name' => '订单核销']);
})->option(['mark' => 'order_verific', 'mark_name' => '移动端订单核销']);
Route::group(function () {
/** 佣金相关 */
Route::get('commission', [\app\api\controller\v1\user\UserBrokerageController::class, 'commission'])->name('commission')->option(['real_name' => '推广数据']);//推广数据 昨天的佣金 累计提现金额 当前佣金
Route::get('brokerage_rank', [\app\api\controller\v1\user\UserBrokerageController::class, 'brokerageRank'])->name('brokerageRank')->option(['real_name' => '佣金排行']);//佣金排行
/** 用户注销 */
Route::get('user_cancel', [\app\api\controller\v1\user\UserController::class, 'SetUserCancel'])->name('SetUserCancel')->option(['real_name' => '用户注销']);//用户注销
Route::get('user_cancel', [\app\api\controller\v1\user\UserController::class, 'SetUserCancel'])->name('SetUserCancel')->option(['real_name' => '用户注销']);
/** 用户浏览记录 */
Route::get('user/visit_list', [\app\api\controller\v1\user\UserController::class, 'visitList'])->name('visitList')->option(['real_name' => '商品浏览列表']);//商品浏览列表
Route::delete('user/visit', [\app\api\controller\v1\user\UserController::class, 'visitDelete'])->name('visitDelete')->option(['real_name' => '商品浏览记录删除']);//商品浏览记录删除

View File

@ -83,7 +83,11 @@ class OrderWriteOffServices extends BaseService
}
$statusCode = (string)($orderInfo['status_code'] ?? '');
if ($statusCode === OrderCenter::STATUS_COMPLETED) {
// 核销后进入待评价,待评价/已完成均视为已核销
if (in_array($statusCode, [
OrderCenter::STATUS_PENDING_REVIEW,
OrderCenter::STATUS_COMPLETED,
], true)) {
throw new ApiException('该订单已被核销');
}
if (in_array($statusCode, [
@ -159,50 +163,43 @@ class OrderWriteOffServices extends BaseService
$orderInfo['order_id'] = $orderInfo['order_no'] ?? ($orderInfo['order_id'] ?? '');
// 8. 执行核销:到店自提直接进入完成态,并补齐核销时间
// 8. 执行核销:与确认收货一致,进入待评价(评价后才到已完成)
$now = time();
if ((int)($orderInfo['shipping_type'] ?? 0) === 2) {
$orderPayload = is_object($orderInfo) ? $orderInfo->toArray() : (array)$orderInfo;
$orderPayload['delivery_time'] = $now;
$orderPayload['take_time'] = $now;
$targetStatus = OrderCenter::STATUS_PENDING_REVIEW;
$orderPayload = is_object($orderInfo) ? $orderInfo->toArray() : (array)$orderInfo;
$orderPayload['delivery_time'] = $now;
$orderPayload['take_time'] = $now;
/** @var OrderLifecycleServices $lifecycleServices */
$lifecycleServices = app()->make(OrderLifecycleServices::class);
$lifecycleServices->handleTake(
$orderPayload,
OrderCenter::STATUS_COMPLETED,
'到店核销',
'admin',
$uid > 0 ? $uid : 0
);
/** @var OrderLifecycleServices $lifecycleServices */
$lifecycleServices = app()->make(OrderLifecycleServices::class);
$lifecycleServices->handleTake(
$orderPayload,
$targetStatus,
'到店核销',
'admin',
$uid > 0 ? $uid : 0
);
try {
OrderTypeRegistry::getAdapter((string)($orderInfo['source_type'] ?? ''))
->advance((string)($orderInfo['order_no'] ?? ''), OrderCenter::STATUS_COMPLETED);
} catch (\Throwable $e) {
$this->dao->update($orderInfo['id'], [
'status_code' => OrderCenter::STATUS_COMPLETED,
]);
}
/** @var OrderCenterServices $centerServices */
$centerServices = app()->make(OrderCenterServices::class);
$centerServices->updateRecord((string)($orderInfo['order_no'] ?? ''), [
'delivery_time' => $now,
'take_time' => $now,
]);
$orderInfo['status_code'] = OrderCenter::STATUS_COMPLETED;
$orderInfo['delivery_time'] = $now;
$orderInfo['take_time'] = $now;
} else {
// 其他核销码订单:保持原有完成逻辑
try {
OrderTypeRegistry::getAdapter((string)($orderInfo['source_type'] ?? ''))
->advance((string)($orderInfo['order_no'] ?? ''), $targetStatus);
} catch (\Throwable $e) {
$this->dao->update($orderInfo['id'], [
'status_code' => OrderCenter::STATUS_COMPLETED,
'status_code' => $targetStatus,
]);
$orderInfo['status_code'] = OrderCenter::STATUS_COMPLETED;
}
/** @var OrderCenterServices $centerServices */
$centerServices = app()->make(OrderCenterServices::class);
$centerServices->updateRecord((string)($orderInfo['order_no'] ?? ''), [
'delivery_time' => $now,
'take_time' => $now,
]);
$orderInfo['status_code'] = $targetStatus;
$orderInfo['delivery_time'] = $now;
$orderInfo['take_time'] = $now;
// 9. 核销后续处理(赠送积分、发放佣金等)
/** @var OrderTakeServices $storeOrderTask */
$storeOrderTask = app()->make(OrderTakeServices::class);

View File

@ -917,7 +917,7 @@ CREATE TABLE IF NOT EXISTS `eb_page_link` (
`sort` smallint NOT NULL DEFAULT '0' COMMENT '排序',
`add_time` int NOT NULL DEFAULT '0' COMMENT '添加时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=53 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='页面链接';
) ENGINE=InnoDB AUTO_INCREMENT=55 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='页面链接';
-- eb_pay_channel:支付方式字典表
CREATE TABLE IF NOT EXISTS `eb_pay_channel` (

View File

@ -28,4 +28,5 @@ INSERT INTO `eb_page_link` (`id`, `cate_id`, `type`, `name`, `url`, `param`, `ex
(39, 20, 1, '我的收藏', '/pages/users/user_goods_collection/index', '', '', 1, 0, 1772520919),
(40, 20, 1, '发票管理', '/pages/users/user_invoice_list/index', '', '', 1, 0, 1772520919),
(41, 20, 1, '每日签到', '/pages/users/user_sgin/index', '', '', 1, 0, 1772520919),
(42, 20, 1, '客服接待', '/kefu/mobile_list', '', '', 1, 0, 1772520919);
(42, 20, 1, '客服接待', '/kefu/mobile_list', '', '', 1, 0, 1772520919),
(54, 20, 1, '订单核销', '/pages/admin/order_cancellation/index', '', '', 1, 0, 1772520919);

View File

@ -0,0 +1,23 @@
// +----------------------------------------------------------------------
// | 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";
/**
* 订单核销(店员/配送员)
* @param {string} verify_code 核销码
* @param {number} is_confirm 0=查询确认信息 1=执行核销
*/
export function orderVerific(verify_code, is_confirm = 0) {
return request.post("order/order_verific", {
verify_code,
is_confirm,
});
}

View File

@ -1549,7 +1549,24 @@
"navigationBarTitleText": ""
}
}]
}
},
{
"root": "pages/admin",
"name": "adminWriteOff",
"pages": [{
"path": "order_cancellation/index",
"style": {
"navigationBarTitleText": "订单核销",
"app-plus": {
// #ifdef APP-PLUS
"titleNView": {
"type": "default"
}
// #endif
}
}
}]
}
],
"tabBar": { // 底部菜单

View File

@ -0,0 +1,340 @@
<template>
<view :style="colorStyle">
<view class="OrderCancellation">
<view class="header">
<view class="header-title">{{ $t(`订单核销`) }}</view>
</view>
<view class="whiteBg">
<view class="input">
<input
type="number"
:placeholder="$t(`请输入核销码`)"
v-model="verify_code"
/>
</view>
<view class="bnt" @click="codeChange">{{ $t(`立即核销`) }}</view>
</view>
<!-- #ifdef MP || APP-PLUS -->
<view class="scan" @click="scanCode">
<image src="../static/scan.gif"></image>
</view>
<!-- #endif -->
<!-- #ifdef H5 -->
<view v-if="isWeixin" class="scan" @click="scanCode">
<image src="../static/scan.gif"></image>
</view>
<!-- #endif -->
</view>
<view v-if="iShidden">
<view class="WriteOff">
<view class="pictrue">
<image :src="orderInfo.image" mode="aspectFill" />
</view>
<view class="num acea-row row-center-wrapper">
<text>{{ orderInfo.order_id || orderInfo.order_no }}</text>
</view>
<view v-if="orderInfo.title" class="title line1">{{ orderInfo.title }}</view>
<view class="tip">{{ $t(`确定要核销此订单吗`) }}</view>
<view class="btn sure" @click="confirm">{{ $t(`确定核销`) }}</view>
<view class="btn cancel" @click="cancel">{{ $t(`取消`) }}</view>
</view>
<view class="mask"></view>
</view>
<!-- #ifndef MP -->
<home></home>
<!-- #endif -->
</view>
</template>
<script setup>
import { ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { storeToRefs } from 'pinia';
import { orderVerific } from '@/api/writeoff.js';
import home from '@/components/home/index.vue';
import { toLogin } from '@/libs/login.js';
import util from '@/utils/util.js';
import i18n from '@/utils/lang.js';
import { useColorTheme } from '@/composables/useColorTheme';
import { useAppStore } from '@/store/modules/app';
// #ifdef H5
import wechat from '@/libs/wechat.js';
// #endif
const { colorStyle } = useColorTheme();
const appStore = useAppStore();
const { isLogin } = storeToRefs(appStore);
const iShidden = ref(false);
const verify_code = ref('');
const isWeixin = ref(false);
const orderInfo = ref({});
const submitting = ref(false);
/**
* 从扫码结果解析核销码(兼容纯数字码 / URL 参数)
*/
function parseVerifyCode(raw) {
const s = decodeURIComponent(String(raw || '')).trim();
if (/^\d{8,12}$/.test(s)) return s;
const byParam = s.match(/(?:verify_code|code)=(\d{8,12})/i);
if (byParam) return byParam[1];
const digits = s.match(/\d{8,12}/);
return digits ? digits[0] : '';
}
onLoad((options = {}) => {
if (!isLogin.value) {
toLogin();
return;
}
// #ifdef H5
isWeixin.value = wechat.isWeixin();
// #endif
if (options.verify_code) {
verify_code.value = String(options.verify_code);
}
// #ifdef MP
if (options.scene) {
const value = util.getUrlParams(decodeURIComponent(options.scene));
verify_code.value = value.verify_code || verify_code.value;
}
// #endif
});
function codeChange() {
if (submitting.value) return;
const code = String(verify_code.value || '').trim();
if (!code) {
return util.Tips({ title: i18n.t(`请输入核销码`) });
}
if (!/^\d{8,12}$/.test(code)) {
return util.Tips({ title: i18n.t(`请输入正确的核销码`) });
}
submitting.value = true;
util.Tips({ title: i18n.t(`查询中`) });
orderVerific(code, 0)
.then((res) => {
orderInfo.value = res.data || {};
iShidden.value = true;
})
.catch((err) => {
verify_code.value = '';
util.Tips({ title: err });
})
.finally(() => {
submitting.value = false;
});
}
function scanCode() {
// #ifdef MP || APP-PLUS
uni.scanCode({
success(res) {
const code = parseVerifyCode(res.path || res.result);
if (!code) {
return util.Tips({ title: i18n.t(`请输入正确的核销码`) });
}
verify_code.value = code;
codeChange();
},
});
// #endif
// #ifdef H5
wechat
.wechatEvevt('scanQRCode', {
needResult: 1,
scanType: ['qrCode', 'barCode'],
})
.then((res) => {
let raw = '';
if (res.scanType == 'WX_CODE') {
raw = res.path || '';
} else {
raw = res.resultStr || res.result || '';
}
const code = parseVerifyCode(raw);
if (!code) {
return util.Tips({ title: i18n.t(`请输入正确的核销码`) });
}
verify_code.value = code;
codeChange();
})
.catch(() => {});
// #endif
}
function confirm() {
if (submitting.value) return;
const code = String(verify_code.value || '').trim();
if (!code) return;
submitting.value = true;
orderVerific(code, 1)
.then((res) => {
verify_code.value = '';
iShidden.value = false;
orderInfo.value = {};
util.Tips({ title: res.msg || i18n.t(`核销成功`) });
})
.catch((err) => {
util.Tips({ title: err });
})
.finally(() => {
submitting.value = false;
});
}
function cancel() {
iShidden.value = false;
}
</script>
<style lang="scss">
page {
background-color: #fff;
}
.OrderCancellation {
width: 100%;
height: 100%;
background: #fff;
.header {
width: 100%;
height: 300rpx;
background-color: var(--view-theme);
background-image: linear-gradient(180deg, var(--view-theme) 0%, #fff 100%);
display: flex;
align-items: flex-start;
justify-content: flex-start;
padding: 72rpx 48rpx 0;
box-sizing: border-box;
.header-title {
font-size: 56rpx;
font-weight: 600;
color: #fff;
letter-spacing: 4rpx;
line-height: 1.2;
text-align: left;
}
}
.whiteBg {
width: 690rpx;
background: #fff;
border-radius: 20rpx;
margin: -120rpx auto 0;
padding: 50rpx 40rpx;
box-shadow: 0 10rpx 40rpx rgba(0, 0, 0, 0.06);
position: relative;
z-index: 2;
.input {
background: #f5f5f5;
border-radius: 12rpx;
padding: 24rpx 28rpx;
input {
font-size: 30rpx;
text-align: center;
}
}
.bnt {
margin-top: 40rpx;
height: 86rpx;
line-height: 86rpx;
text-align: center;
border-radius: 43rpx;
background: var(--view-theme);
color: #fff;
font-size: 30rpx;
font-weight: 500;
}
}
.scan {
margin: 80rpx auto 0;
width: 280rpx;
height: 280rpx;
image {
width: 100%;
height: 100%;
}
}
}
.WriteOff {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 560rpx;
background: #fff;
border-radius: 20rpx;
z-index: 100;
padding: 40rpx 36rpx 48rpx;
text-align: center;
.pictrue {
width: 160rpx;
height: 160rpx;
margin: 0 auto 24rpx;
border-radius: 12rpx;
overflow: hidden;
image {
width: 100%;
height: 100%;
}
}
.num {
font-size: 28rpx;
color: #333;
font-weight: 500;
}
.title {
margin-top: 12rpx;
font-size: 26rpx;
color: #666;
}
.tip {
margin: 36rpx 0 28rpx;
font-size: 30rpx;
color: #333;
}
.btn {
height: 80rpx;
line-height: 80rpx;
border-radius: 40rpx;
font-size: 28rpx;
&.sure {
background: var(--view-theme);
color: #fff;
}
&.cancel {
margin-top: 20rpx;
background: #f5f5f5;
color: #666;
}
}
}
.mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 99;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

View File

@ -524,7 +524,10 @@ function getMyMenus() {
let myMenu = [];
res.data.routine_my_menus.forEach((el, index, arr) => {
// el.name 为后端下发的菜单名,保持中文原文比较,不可包 $t
if (el.name == "客服接待") {
if (
el.name == "客服接待" ||
el.url == "/pages/admin/order_cancellation/index"
) {
storeMenuLocal.push(el);
} else {
myMenu.push(el);