This commit is contained in:
dzc committed 2026-09-09 20:22:46 +08:00
1 parent f4dff79f5a
commit 18aa169b69
4 files changed
+708 -297

No files matched your search

+2 -2
View File
@@ -126,7 +126,7 @@ export interface HardDoorManualSettleItem {
/**
* 智能柜订单手动结算(第三方识别不可用时,人工核对视频后结算)
*/
export function manualSettle(data: { orderId: string | number; items: HardDoorManualSettleItem[] }) {
export function manualSettle(data: { orderId: string | number; items: HardDoorManualSettleItem[]; reason?: string }) {
return request({
url: "/hard/hard-order/manual-settle",
method: "post",
@@ -137,7 +137,7 @@ export function manualSettle(data: { orderId: string | number; items: HardDoorMa
/**
* 智能柜订单手动取消(用户未拿商品)
*/
export function manualCancel(data: { orderId: string | number }) {
export function manualCancel(data: { orderId: string | number; reason?: string }) {
return request({
url: "/hard/hard-order/manual-cancel",
method: "post",
+473 -161
View File
@@ -1,114 +1,115 @@
<template>
<div v-loading="loading">
<el-descriptions v-if="detail" title="智能柜日志">
<el-descriptions-item labelClassName="no-colon">
<el-timeline>
<el-timeline-item placement="top">
<div class="el-timeline-right-content">
<div>
<el-tag type="info" style="width: 120px">用户授权</el-tag>
<span class="color-primary ml-2">{{ detail.createTime }}</span>
</div>
<div>
<div class="flow_coll">
<span>授权用户:</span>
<span>{{ detail.username }}</span>
</div>
</div>
<div v-loading="loading" class="door-log">
<template v-if="detail && detail.id">
<div class="door-log__head">
<span class="door-log__title">智能柜流程日志</span>
<el-tag v-if="flowStatusText" size="small" :type="flowStatusTagType" effect="plain">当前节点:{{ flowStatusText }}</el-tag>
<el-tag v-if="detail.skuSubmitRecogId" size="small" type="info" effect="plain">识别ID:{{ detail.skuSubmitRecogId }}</el-tag>
</div>
<div class="door-log__flow">
<div
v-for="node in flowNodes"
:key="node.key"
class="flow-node"
:class="[`is-${node.status}`, { 'is-idle': !node.time }]"
>
<div class="flow-node__rail">
<span class="flow-node__dot" />
</div>
<div class="flow-node__card">
<div class="flow-node__head">
<span class="flow-node__title">{{ node.title }}</span>
<el-tag v-if="node.tagText" size="small" :type="node.tagType" effect="light">{{ node.tagText }}</el-tag>
<span class="flow-node__time">{{ node.time || "--" }}</span>
</div>
</el-timeline-item>
</el-timeline>
<el-timeline v-if="detail.requestOpenTime">
<el-timeline-item placement="top">
<div class="el-timeline-right-content">
<div>
<el-tag type="info" style="width: 120px">智能柜申请开门</el-tag>
<span class="color-primary ml-2">{{ detail.requestOpenTime }}</span>
</div>
<div v-if="node.desc" class="flow-node__desc">{{ node.desc }}</div>
<div v-if="node.hint" class="flow-node__hint">{{ node.hint }}</div>
<div v-if="node.payloads?.length" class="flow-node__payloads">
<el-button
v-for="payload in node.payloads"
:key="payload.label"
link
type="primary"
size="small"
@click="openPayload(node.title + ' - ' + payload.label, payload.json)"
>
{{ payload.label }}
</el-button>
</div>
</el-timeline-item>
</el-timeline>
<el-timeline v-if="detail.openCallbackTime">
<el-timeline-item placement="top">
<div class="el-timeline-right-content">
<div>
<el-tag type="info" style="width: 120px">智能柜开门响应</el-tag>
<span class="color-primary ml-2">{{ detail.openCallbackTime }}</span>
</div>
</div>
</el-timeline-item>
</el-timeline>
<el-timeline v-if="detail.videoCallbackTime">
<el-timeline-item placement="top">
<div class="el-timeline-right-content">
<div>
<el-tag type="info" style="width: 120px">智能柜视频回调</el-tag>
<span class="color-primary ml-2">{{ detail.videoCallbackTime }}</span>
</div>
</div>
</el-timeline-item>
</el-timeline>
<el-timeline v-if="detail.skuSubmitTime">
<el-timeline-item placement="top">
<div class="el-timeline-right-content">
<div>
<el-tag type="info" style="width: 120px">商品提交识别</el-tag>
<span class="color-primary ml-2">{{ detail.skuSubmitTime }}</span>
</div>
</div>
</el-timeline-item>
</el-timeline>
<el-timeline v-if="detail.skuCallbackTime">
<el-timeline-item placement="top">
<div class="el-timeline-right-content">
<div>
<el-tag type="info" style="width: 120px">商品识别响应</el-tag>
<span class="color-primary ml-2">{{ detail.skuCallbackTime }}</span>
</div>
</div>
</el-timeline-item>
</el-timeline>
<el-timeline v-if="detail.payTime">
<el-timeline-item placement="top">
<div class="el-timeline-right-content">
<div>
<el-tag type="info" style="width: 120px">订单结算</el-tag>
<span class="color-primary ml-2">{{ detail.payTime }}</span>
</div>
</div>
</el-timeline-item>
</el-timeline>
<el-timeline v-if="detail.error && detail.errorDesc">
<el-timeline-item placement="top">
<div class="el-timeline-right-content">
<el-tag type="danger" style="width: 120px">错误</el-tag>
<span class="color-danger ml-2">{{ detail.errorDesc }}</span>
<el-button v-if="!detail?.hardAuth?.manualChangeUserId" class="ml-2" link
@click="visibleErrorChangeDiaog = true">
<!-- 错误节点:人工处理入口 -->
<div v-if="node.key === 'error'" class="flow-node__actions">
<el-button v-if="!detail?.hardAuth?.manualChangeUserId" type="primary" link size="small" @click="visibleErrorChangeDiaog = true">
处理
</el-button>
</div>
</el-timeline-item>
</el-timeline>
<el-timeline v-if="detail?.hardAuth?.manualChangeUserId">
<el-timeline-item placement="top">
<div class="el-timeline-right-content">
<el-tag type="warning" style="width: 120px">手动处理智能柜订单</el-tag>
<span class="color-warning ml-2">{{ detail.hardAuth.manualChangeTime }}</span>
<span class="color-warning ml-2">{{ detail.hardAuth.manualChangeRemark }}</span>
<div class="ml-2" v-if="detail.hardAuth.manualChangePic">
<preview-image width="40px" height="40px" :src="detail.hardAuth.manualChangePic" />
</div>
</div>
<!-- 手动结算/取消操作快照 -->
<div v-if="manualVo" class="flow-node is-manual">
<div class="flow-node__rail">
<span class="flow-node__dot" />
</div>
<div class="flow-node__card">
<div class="flow-node__head">
<span class="flow-node__title">{{ manualVo.manualCancel ? "手动取消订单" : "手动结算" }}</span>
<el-tag size="small" type="warning" effect="light">{{ manualVo.manualCancel ? "人工取消" : "人工结算" }}</el-tag>
<span class="flow-node__time">{{ manualVo.time || "--" }}</span>
</div>
<div class="flow-node__desc">
操作人:{{ manualVo.operator || "--" }}
<template v-if="manualVo.reason"> 备注:{{ manualVo.reason }}</template>
</div>
<div v-if="!manualVo.manualCancel && manualItems.length" class="flow-node__cart">
<div v-for="(item, index) in manualItems" :key="index" class="flow-node__cart-row">
<span class="flow-node__cart-name">{{ item.name }}</span>
<span class="flow-node__cart-count">{{ displayPrice(item.price) }} × {{ item.productCount }}</span>
<span class="flow-node__cart-price">{{ displayPrice(item.totalPrice) }}</span>
</div>
<div class="flow-node__cart-total">
<span>合计:<span class="flow-node__cart-price">{{ displayPrice(manualVo.totalPrice) }}</span></span>
<span v-if="manualVo.payChannel" class="flow-node__cart-channel">({{ manualVo.payChannel }})</span>
</div>
</div>
</el-timeline-item>
</el-timeline>
</el-descriptions-item>
</el-descriptions>
<div class="flow-node__payloads">
<el-button link type="primary" size="small" @click="openPayload((manualVo.manualCancel ? '手动取消订单' : '手动结算') + ' - 快照报文', manualRaw)">
快照报文
</el-button>
</div>
</div>
</div>
<el-dialog title="手动处理智能柜订单" width="30%" @close="dialogClose" v-model="visibleErrorChangeDiaog"
:close-on-click-modal="false" draggable>
<el-form ref="formRef" :model="form" :rules="dataRules" formDialogRef label-width="90px"
v-loading="formLoading">
<!-- 手动处理智能柜订单记录 -->
<div v-if="detail?.hardAuth?.manualChangeUserId" class="flow-node is-manual">
<div class="flow-node__rail">
<span class="flow-node__dot" />
</div>
<div class="flow-node__card">
<div class="flow-node__head">
<span class="flow-node__title">手动处理智能柜订单</span>
<el-tag size="small" type="warning" effect="light">人工处理</el-tag>
<span class="flow-node__time">{{ detail.hardAuth.manualChangeTime }}</span>
</div>
<div v-if="detail.hardAuth.manualChangeRemark" class="flow-node__desc">{{ detail.hardAuth.manualChangeRemark }}</div>
<div v-if="detail.hardAuth.manualChangePic" class="flow-node__desc">
<preview-image width="40px" height="40px" :src="detail.hardAuth.manualChangePic" />
</div>
</div>
</div>
</div>
</template>
<el-empty v-else-if="!loading" description="暂无智能柜流程日志" :image-size="60" />
<!-- 报文查看抽屉 -->
<el-drawer v-model="payloadVisible" :title="payloadTitle" size="45%" append-to-body>
<vue-json-pretty v-if="payloadData !== null" :data="payloadData" showLength showLineNumber showIcon :collapsedOnClickBrackets="false" />
<pre v-else class="door-log__raw">{{ payloadRaw }}</pre>
</el-drawer>
<!-- 手动处理智能柜订单 -->
<el-dialog title="手动处理智能柜订单" width="30%" @close="dialogClose" v-model="visibleErrorChangeDiaog" :close-on-click-modal="false" draggable>
<el-form ref="formRef" :model="form" :rules="dataRules" formDialogRef label-width="90px" v-loading="formLoading">
<el-row :gutter="24">
<el-col :span="24" class="mb20">
<el-form-item label="设备名" prop="image">
@@ -117,8 +118,7 @@
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="处理原因" prop="remark">
<el-input v-model="form.remark" placeholder="请输入处理原因" type="textarea"
:autosize="{ minRows: 6 }" />
<el-input v-model="form.remark" placeholder="请输入处理原因" type="textarea" :autosize="{ minRows: 6 }" />
</el-form-item>
</el-col>
</el-row>
@@ -133,9 +133,12 @@
</div>
</template>
<script setup lang="ts" name="hard-door-log">
import VueJsonPretty from "vue-json-pretty"
import "vue-json-pretty/lib/styles.css"
import * as hardFlowManager from "/@/api/hard/hard-door-flow"
import { useMessage } from "/@/hooks/message"
import { auth, auths, authAll } from '/@/utils/authFunction';
import { auth } from "/@/utils/authFunction"
import { displayPrice } from "/@/utils/formatMoney"
type Props = {
orderId?: string
@@ -150,6 +153,12 @@ const detail = ref<any>({})
const loading = ref<boolean>(false)
const formLoading = ref<boolean>(false)
// 报文抽屉
const payloadVisible = ref(false)
const payloadTitle = ref("")
const payloadRaw = ref("")
const payloadData = ref<any>(null)
// 提交表单数据
const form = reactive({
flowId: "",
@@ -162,11 +171,181 @@ const dataRules = ref({
remark: [{ required: true, message: "处理原因不能为空", trigger: "blur" }],
})
/** 流程节点状态。 */
type FlowNodeStatus = "done" | "waiting" | "warning" | "error"
/** 一个流程节点;payloads 为可查看的请求/回调报文。 */
interface FlowNode {
key: string
title: string
time?: string
status: FlowNodeStatus
desc?: string
hint?: string
payloads?: { label: string; json?: string }[]
tagText?: string
tagType?: "success" | "info" | "warning" | "danger"
}
/** 流程状态编码 → 名称。 */
const FLOW_STATUS_MAP: Record<string, string> = {
"10": "用户授权",
"20": "申请开门",
"30": "开门响应",
"40": "视频回调",
"50": "已提交识别",
"60": "识别响应",
"70": "订单结算",
}
const flowStatusText = computed(() => FLOW_STATUS_MAP[String(detail.value?.flowStatus || "")] || "")
const flowStatusTagType = computed(() => {
if (detail.value?.payTime) return "success"
if (detail.value?.error) return "danger"
return "warning"
})
/** 手动结算/取消快照原始报文:优先独立快照字段,兼容旧版本写入 skuCallbackVo 的手动标记。 */
const manualRaw = computed<string>(() => {
const dedicated = detail.value?.manualSettleVo
if (dedicated) return dedicated
const legacy = detail.value?.skuCallbackVo
if (!legacy) return ""
try {
const vo = typeof legacy === "string" ? JSON.parse(legacy) : legacy
return vo?.manual === true ? (typeof legacy === "string" ? legacy : JSON.stringify(legacy)) : ""
} catch {
return ""
}
})
/** 手动结算/取消快照对象。 */
const manualVo = computed<any>(() => {
if (!manualRaw.value) return null
try {
const vo = typeof manualRaw.value === "string" ? JSON.parse(manualRaw.value) : manualRaw.value
return vo?.manual === true ? vo : null
} catch {
return null
}
})
/** 快照中的购物车明细。 */
const manualItems = computed<any[]>(() => (Array.isArray(manualVo.value?.items) ? manualVo.value.items : []))
const STATUS_TAGS: Record<FlowNodeStatus, { text: string; type: "success" | "info" | "warning" | "danger" }> = {
done: { text: "成功", type: "success" },
waiting: { text: "等待中", type: "info" },
warning: { text: "未响应", type: "warning" },
error: { text: "错误", type: "danger" },
}
/** 按数据到达情况推导各节点状态与可查看报文。 */
const flowNodes = computed<FlowNode[]>(() => {
const d = detail.value || {}
const withTag = (node: FlowNode): FlowNode => {
if (node.time) {
node.tagText = STATUS_TAGS[node.status].text
node.tagType = STATUS_TAGS[node.status].type
}
// 只保留有报文数据的入口
node.payloads = node.payloads?.filter((payload) => !!payload.json)
return node
}
const nodes: FlowNode[] = [
withTag({
key: "auth",
title: "用户授权",
time: d.createTime,
status: "done",
desc: d.username ? `授权用户:${d.username}` : "",
}),
withTag({
key: "apply",
title: "智能柜申请开门",
time: d.requestOpenTime,
status: d.requestOpenTime ? "done" : "waiting",
payloads: [
{ label: "请求参数", json: d.requestOpenDto },
{ label: "请求返回", json: d.requestOpenVo },
],
}),
withTag({
key: "open",
title: "智能柜开门响应",
time: d.openCallbackTime,
status: d.openCallbackTime ? "done" : "waiting",
desc: d.isOpen != null && d.isOpen !== "" ? `开门结果:${String(d.isOpen) === "1" ? "已开门" : "未开门"}` : "",
payloads: [{ label: "回调报文", json: d.openCallbackVo }],
}),
withTag({
key: "video",
title: "智能柜视频回调",
time: d.videoCallbackTime,
status: d.videoCallbackTime ? "done" : "waiting",
payloads: [{ label: "回调报文", json: d.videoCallbackVo }],
}),
withTag({
key: "submit",
title: "提交商品识别",
time: d.skuSubmitTime,
status: d.skuSubmitTime ? "done" : "warning",
payloads: [
{ label: "提交参数", json: d.skuSubmitDto },
{ label: "提交返回", json: d.skuSubmitVo },
],
}),
withTag({
key: "recog",
title: "商品识别响应",
time: d.skuCallbackTime,
status: d.skuCallbackTime ? "done" : d.skuSubmitTime ? "warning" : "waiting",
desc: !d.skuCallbackTime && manualVo.value ? `已通过${manualVo.value.manualCancel ? "手动取消" : "手动结算"}人工处理,见下方快照节点` : "",
hint:
!d.skuCallbackTime && d.skuSubmitTime && !manualVo.value
? "第三方识别服务未响应(服务可能不可用),可使用「手动结算」人工核对视频后结算"
: "",
payloads: [{ label: "识别结果", json: d.skuCallbackVo }],
}),
withTag({
key: "pay",
title: "订单结算",
time: d.payTime,
status: d.payTime ? "done" : "waiting",
}),
]
if (d.error && d.errorDesc) {
nodes.push({
key: "error",
title: "流程错误",
time: d.updateTime,
status: "error",
desc: d.errorDesc,
tagText: "错误",
tagType: "danger",
})
}
return nodes
})
const openPayload = (title: string, json?: string) => {
payloadTitle.value = title
payloadRaw.value = json || ""
payloadData.value = null
if (json) {
try {
payloadData.value = typeof json === "string" ? JSON.parse(json) : json
} catch {
payloadData.value = null
}
}
payloadVisible.value = true
}
watch(
() => props.orderId,
() => {
getFlow()
},
() => getFlow(),
)
const getFlow = async () => {
@@ -193,7 +372,7 @@ const dialogClose = async () => {
}
const onSubmit = async () => {
const valid = await formRef.value.validate().catch(() => { })
const valid = await formRef.value.validate().catch(() => {})
if (!valid) return false
try {
formLoading.value = true
@@ -213,77 +392,210 @@ onMounted(() => {
</script>
<style lang="scss" scoped>
.flow_coll {
display: flex;
align-items: center;
span {
display: inline-block;
.door-log {
&__head {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
}
span:nth-child(1) {
display: inline-block;
width: 80px;
text-align: left;
&__title {
font-weight: 600;
font-size: 14px;
}
span:nth-child(2) {
display: inline-block;
flex: 1;
min-width: 50px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&__raw {
padding: 12px;
margin: 0;
overflow: auto;
font-size: 12px;
line-height: 20px;
white-space: pre-wrap;
word-break: break-all;
background-color: var(--el-fill-color-light);
border-radius: 4px;
}
}
// 时间线样式调整
:deep(.el-timeline) {
margin: 10px 0 0 160px;
.flow-node {
display: flex;
gap: 12px;
.el-timeline-item__wrapper {
&:last-of-type .flow-node__rail::after {
display: none;
}
&__rail {
position: relative;
top: -20px;
.el-timeline-item__timestamp {
position: absolute !important;
top: 10px;
left: -150px;
}
}
.el-timeline-right-content {
display: flex;
flex: 0 0 14px;
flex-direction: column;
align-items: flex-start;
min-height: 30px;
padding: 10px;
background-color: #b0d8ff;
align-items: center;
justify-content: flex-start;
padding-top: 16px;
&::before {
&::after {
position: absolute;
top: 10px;
left: 13px;
border-color: transparent #b0d8ff transparent transparent;
border-style: solid;
border-width: 8px;
top: 30px;
bottom: -16px;
left: 50%;
width: 2px;
content: "";
background-color: var(--el-border-color-lighter);
transform: translateX(-50%);
}
}
.el-timeline-item__node {
position: absolute;
left: -5px;
top: -50%;
transform: translateY(100%);
display: flex;
width: 20px;
height: 20px;
font-size: 10px;
background: #b0d8ff;
&__dot {
z-index: 1;
box-sizing: content-box;
width: 8px;
height: 8px;
background-color: var(--el-color-info);
border: 3px solid #fff;
border-radius: 50%;
justify-content: center;
box-shadow: 0 0 0 2px currentColor;
color: var(--el-color-info);
}
&__card {
flex: 1;
min-width: 0;
margin-bottom: 12px;
padding: 10px 14px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
transition: box-shadow 0.2s;
&:hover {
box-shadow: var(--el-box-shadow-light);
}
}
&__head {
display: flex;
gap: 8px;
align-items: center;
}
&__title {
font-weight: 600;
font-size: 13px;
color: var(--el-text-color-primary);
}
&__time {
margin-left: auto;
font-size: 12px;
color: var(--el-text-color-secondary);
}
&__desc {
margin-top: 6px;
font-size: 13px;
line-height: 20px;
color: var(--el-text-color-regular);
}
&__hint {
margin-top: 6px;
padding: 6px 10px;
font-size: 12px;
line-height: 20px;
color: var(--el-color-warning);
background-color: var(--el-color-warning-light-9);
border-radius: 4px;
}
// 手动结算快照的购物车明细
&__cart {
margin-top: 6px;
padding: 8px 10px;
background-color: var(--el-fill-color-light);
border-radius: 4px;
}
&__cart-row {
display: flex;
gap: 8px;
align-items: center;
font-size: 12px;
line-height: 22px;
}
&__cart-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__cart-count {
color: var(--el-text-color-secondary);
}
&__cart-price {
min-width: 64px;
color: var(--el-color-primary);
font-weight: 600;
text-align: right;
}
&__cart-total {
display: flex;
gap: 4px;
align-items: center;
margin-top: 4px;
padding-top: 6px;
font-size: 13px;
border-top: 1px dashed var(--el-border-color-lighter);
}
&__cart-channel {
font-size: 12px;
color: var(--el-text-color-secondary);
}
&__payloads,
&__actions {
display: flex;
gap: 4px;
align-items: center;
margin-top: 4px;
}
// 未发生节点弱化
&.is-idle .flow-node__card {
opacity: 0.65;
}
&.is-done .flow-node__dot {
color: var(--el-color-success);
background-color: var(--el-color-success);
}
&.is-warning .flow-node__dot {
color: var(--el-color-warning);
background-color: var(--el-color-warning);
}
&.is-error {
.flow-node__dot {
color: var(--el-color-danger);
background-color: var(--el-color-danger);
}
.flow-node__card {
border-color: var(--el-color-danger-light-7);
background-color: var(--el-color-danger-light-9);
}
}
&.is-manual .flow-node__dot {
color: var(--el-color-warning);
background-color: var(--el-color-warning);
}
}
</style>
+225 -133
View File
@@ -1,97 +1,92 @@
<template>
<div v-loading="loading" class="manual-settlement">
<el-alert
type="warning"
:closable="false"
show-icon
title="第三方商品识别服务不可用,请核对开门视频,人工选择用户拿走的商品后手动结算;若用户未拿商品请取消订单。"
/>
<el-alert type="warning" :closable="false" show-icon
title="第三方商品识别服务不可用,请核对开门视频,人工选择用户拿走的商品后手动结算;若用户未拿商品请取消订单。" />
<div class="settle-layout">
<!-- 左侧:按柜层选择商品 -->
<!-- 左侧:柜内商品选择 -->
<div class="settle-goods">
<div class="settle-block__title">柜内商品({{ cabinetNoLabel }})</div>
<template v-if="floors.length">
<div v-for="floor in floors" :key="floor.floorNo" class="settle-floor">
<div class="settle-floor__title">第 {{ floor.floorNo }} 层</div>
<div class="settle-floor__list">
<div v-for="product in floor.products" :key="product.productId" class="settle-product">
<preview-image width="64px" height="56px" :src="product.pic" />
<div class="settle-head">
<span class="settle-head__title">柜内商品</span>
<el-tag v-if="cabinetNoLabel" size="small" type="info" effect="plain">{{ cabinetNoLabel }}</el-tag>
</div>
<div class="settle-goods__scroll">
<template v-if="floors.length">
<div v-for="floor in floors" :key="floor.floorNo" class="settle-floor">
<div class="settle-floor__tag">第 {{ floor.floorNo }} 层</div>
<div v-for="product in floor.products" :key="product.productId" class="settle-product"
:class="{ 'is-selected': Number(cartCount[product.productId] || 0) > 0 }">
<preview-image width="56px" height="56px" :src="product.pic" />
<div class="settle-product__info">
<el-tooltip effect="dark" :content="product.name">
<div class="settle-product__name">{{ product.name }}</div>
</el-tooltip>
<div class="settle-product__meta">
<span class="color-primary">{{ displayPrice(product.price) }}</span>
<span class="settle-product__price">{{ displayPrice(product.price) }}</span>
<span class="settle-product__stock">库存 {{ product.stock }}</span>
</div>
</div>
<el-input-number
v-model="cartCount[product.productId]"
:min="0"
:max="Math.max(product.stock, 1)"
size="small"
controls-position="right"
/>
<el-input-number v-model="cartCount[product.productId]" class="settle-product__stepper"
:min="0" controls-position="right" />
</div>
</div>
</div>
</template>
<el-empty v-else-if="!loading" description="未找到该柜的商品配置" :image-size="60" />
</div>
<!-- 右侧:已选购物车 + 视频 + 操作 -->
<div class="settle-panel">
<div class="settle-cart">
<div class="settle-block__title">已选商品</div>
<template v-if="cartItems.length">
<div class="settle-cart__list">
<div v-for="item in cartItems" :key="item.productId" class="settle-cart__item">
<span class="settle-cart__name">{{ item.name }}</span>
<span class="settle-cart__count">× {{ item.count }}</span>
<span class="color-primary">{{ displayPrice(item.price * item.count) }}</span>
</div>
</div>
<div class="settle-cart__total">
合计:<span class="color-primary">{{ displayPrice(cartTotalPrice) }}</span>
<span class="settle-cart__tip">(金额仅供参考,以实际结算结果为准)</span>
</div>
</template>
<el-empty v-else description="请从左侧选择用户拿走的商品" :image-size="60" />
<el-empty v-else-if="!loading" description="未找到该柜的商品配置" :image-size="60" />
</div>
</div>
<div class="settle-video">
<div class="settle-video__head">
<div class="settle-block__title">开门视频</div>
<el-radio-group v-if="videos.length > 1" v-model="currentVideoIndex" size="small">
<el-radio-button v-for="(video, index) in videos" :key="index" :label="index">
{{ video.label }}
</el-radio-button>
</el-radio-group>
<!-- 右侧:开门视频 + 已选购物车 + 操作 -->
<div class="settle-side">
<div class="settle-card">
<div class="settle-head">
<span class="settle-head__title">开门视频</span>
<span class="settle-head__extra">共 {{ videos.length }} 路,同步播放</span>
</div>
<div v-if="videos.length" class="settle-video__grid" :class="{ 'is-single': videos.length === 1 }">
<div v-for="(video, index) in videos" :key="index" class="settle-video__item">
<el-tag size="small" effect="plain" :type="index === 0 ? 'primary' : 'info'"
class="settle-video__label">
{{ video.label }}
</el-tag>
<video-player :src="video.url" width="100%" height="320px" />
</div>
</div>
<video-player v-if="currentVideoUrl" :src="currentVideoUrl" width="100%" height="240px" />
<el-empty v-else description="无开门视频(视频未回调或回调失败)" :image-size="60" />
</div>
<div class="settle-actions">
<el-button
v-auth="'hard_hardwareOrder_manualSettle'"
type="primary"
:disabled="!cartItems.length"
:loading="submitting"
@click="onSettle"
>
手动结算
</el-button>
<el-button
v-auth="'hard_hardwareOrder_manualSettle'"
type="danger"
plain
:loading="cancelling"
@click="onCancel"
>
用户未拿商品,取消订单
</el-button>
<div class="settle-card">
<div class="settle-head">
<span class="settle-head__title">已选商品</span>
<span v-if="cartItems.length" class="settle-head__extra">共 {{ cartCountTotal }} 件</span>
</div>
<div v-if="cartItems.length" class="settle-cart">
<div class="settle-cart__head">
<span>商品</span>
<span>数量</span>
<span>小计</span>
</div>
<div v-for="item in cartItems" :key="item.productId" class="settle-cart__row">
<span class="settle-cart__name">{{ item.name }}</span>
<span class="settle-cart__count">× {{ item.count }}</span>
<span class="settle-cart__price">{{ displayPrice(item.price * item.count) }}</span>
</div>
<div class="settle-cart__total">
<span>合计</span>
<span class="settle-cart__total-price">{{ displayPrice(cartTotalPrice) }}</span>
</div>
<div class="settle-cart__tip">金额仅供参考,以实际结算结果为准</div>
</div>
<el-empty v-else description="请从左侧选择用户拿走的商品" :image-size="60" />
<div class="settle-actions">
<el-button v-auth="'hard_hardwareOrder_manualSettle'" type="danger" plain :loading="cancelling"
@click="onCancel">
未拿商品,取消订单
</el-button>
<el-button v-auth="'hard_hardwareOrder_manualSettle'" type="primary"
:disabled="!cartItems.length" :loading="submitting" @click="onSettle">
手动结算
</el-button>
</div>
</div>
</div>
</div>
@@ -139,9 +134,6 @@ const cabinetNoLabel = ref("")
/** 商品 id -> 手动选择的数量。 */
const cartCount = reactive<Record<string, number>>({})
const videos = ref<{ label: string; url: string }[]>([])
const currentVideoIndex = ref(0)
const currentVideoUrl = computed(() => videos.value[currentVideoIndex.value]?.url || "")
const cartItems = computed(() => {
const items: { productId: string; name: string; price: number; count: number }[] = []
@@ -154,20 +146,15 @@ const cartItems = computed(() => {
return items
})
const cartTotalPrice = computed(() => cartItems.value.reduce((sum, item) => sum + item.price * item.count, 0))
const cartCountTotal = computed(() => cartItems.value.reduce((sum, item) => sum + item.count, 0))
watch(
() => [props.orderId, props.hardId],
() => load(),
{ immediate: true },
)
const cartTotalPrice = computed(() => cartItems.value.reduce((sum, item) => sum + item.price * item.count, 0))
const load = async () => {
if (!props.orderId || !props.hardId) return
loading.value = true
floors.value = []
videos.value = []
currentVideoIndex.value = 0
cabinetNoLabel.value = ""
Object.keys(cartCount).forEach((key) => delete cartCount[key])
try {
@@ -185,6 +172,12 @@ const load = async () => {
}
}
watch(
() => [props.orderId, props.hardId],
() => load(),
{ immediate: true },
)
/** 把 /hard-spu/get-by-cabinet-no 的层结构转成层数组,同层同商品多槽位合并、库存求和。 */
const parseFloors = (cabinetData: any): Floor[] => {
if (!Array.isArray(cabinetData)) return []
@@ -244,12 +237,17 @@ const onSettle = async () => {
h("div", null, `合计金额:${displayPrice(cartTotalPrice.value)}(实际金额以结算结果为准)`),
h("div", { style: "color:var(--el-color-danger)" }, "确认后将从用户钱包强制扣款或通过支付宝免密扣款,请谨慎操作。"),
])
let reason = ""
try {
await ElMessageBox.confirm(summary, "手动结算确认", {
const { value } = await ElMessageBox.prompt(summary, "手动结算确认", {
confirmButtonText: "确认结算",
cancelButtonText: "取消",
type: "warning",
inputPlaceholder: "操作备注(选填):记录判断依据,如 视频02:35拿走一瓶,便于事后复核",
inputPattern: /^[\s\S]{0,200}$/,
inputErrorMessage: "备注最多 200 字",
})
reason = (value || "").trim()
} catch {
return
}
@@ -258,6 +256,7 @@ const onSettle = async () => {
await manualSettle({
orderId: props.orderId,
items: items.map((item) => ({ productId: item.productId, productCount: item.count })),
reason,
})
useMessage().success("手动结算成功")
emit("refresh")
@@ -270,18 +269,23 @@ const onSettle = async () => {
const onCancel = async () => {
if (cancelling.value) return
let reason = ""
try {
await ElMessageBox.confirm("确认该订单用户未拿任何商品?取消后订单状态置为已取消,不可恢复。", "取消订单确认", {
const { value } = await ElMessageBox.prompt("确认该订单用户未拿任何商品?取消后订单状态置为已取消,不可恢复。", "取消订单确认", {
confirmButtonText: "确认取消订单",
cancelButtonText: "再想想",
type: "warning",
inputPlaceholder: "操作备注(选填):记录判断依据,便于事后复核",
inputPattern: /^[\s\S]{0,200}$/,
inputErrorMessage: "备注最多 200 字",
})
reason = (value || "").trim()
} catch {
return
}
cancelling.value = true
try {
await manualCancel({ orderId: props.orderId })
await manualCancel({ orderId: props.orderId, reason })
useMessage().success("订单已取消")
emit("refresh")
} catch (e: any) {
@@ -296,132 +300,219 @@ const onCancel = async () => {
.manual-settlement {
.settle-layout {
display: flex;
gap: 16px;
gap: 20px;
margin-top: 12px;
align-items: stretch;
}
.settle-block__title {
font-weight: 600;
line-height: 24px;
.settle-head {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
&__title {
font-weight: 600;
line-height: 24px;
}
&__extra {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
// 左侧商品列:适度固定宽,超出内部滚动,避免把视频挤小
.settle-goods {
flex: 1;
display: flex;
flex: 0 0 420px;
flex-direction: column;
min-width: 0;
padding: 12px;
padding: 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
&__scroll {
flex: 1;
max-height: 560px;
padding-right: 4px;
overflow-y: auto;
}
}
.settle-floor {
&:not(:first-child) {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--el-border-color-lighter);
margin-top: 12px;
}
&__title {
&__tag {
padding-left: 8px;
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-secondary);
}
&__list {
display: flex;
flex-direction: column;
gap: 8px;
border-left: 3px solid var(--el-color-primary);
line-height: 18px;
}
}
.settle-product {
display: flex;
gap: 10px;
gap: 12px;
align-items: center;
padding: 6px 8px;
padding: 10px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
&+.settle-product {
margin-top: 8px;
}
&.is-selected {
border-color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
}
&__info {
flex: 1;
min-width: 0;
}
&__name {
max-width: 220px;
max-width: 180px;
overflow: hidden;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
&__meta {
display: flex;
gap: 10px;
gap: 8px;
align-items: center;
margin-top: 2px;
font-size: 12px;
}
&__price {
color: var(--el-color-primary);
font-weight: 600;
}
&__stock {
color: var(--el-text-color-secondary);
}
// 数量步进器固定紧凑宽度,不随弹性布局拉伸
&__stepper {
flex-shrink: 0;
width: 112px;
}
}
.settle-panel {
// 右侧视频 + 购物车占满剩余宽度
.settle-side {
display: flex;
flex: 0 0 360px;
flex: 1;
flex-direction: column;
gap: 20px;
min-width: 0;
}
.settle-card {
padding: 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
}
// 多路视频同屏并排播放;单路时占满整行
.settle-video__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 12px;
}
.settle-cart {
padding: 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
.settle-video__item {
position: relative;
min-width: 0;
&__item {
display: flex;
.settle-video__label {
position: absolute;
top: 8px;
left: 8px;
z-index: 10;
}
}
.settle-cart {
&__head,
&__row,
&__total {
display: grid;
grid-template-columns: 1fr 72px 96px;
gap: 8px;
align-items: center;
padding: 4px 0;
line-height: 20px;
}
&__head {
padding-bottom: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
border-bottom: 1px solid var(--el-border-color-lighter);
span:nth-child(2),
span:nth-child(3) {
text-align: right;
}
}
&__row {
padding: 6px 0;
font-size: 14px;
border-bottom: 1px dashed var(--el-border-color-lighter);
}
&__name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__count {
&__count,
&__price {
color: var(--el-text-color-secondary);
text-align: right;
}
&__price {
color: var(--el-text-color-primary);
font-weight: 600;
}
&__total {
padding-top: 8px;
margin-top: 4px;
border-top: 1px solid var(--el-border-color-lighter);
font-weight: 600;
span:first-child {
color: var(--el-text-color-regular);
}
}
&__total-price {
grid-column: 3;
color: var(--el-color-primary);
font-size: 16px;
text-align: right;
}
&__tip {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.settle-video {
padding: 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
&__head {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
text-align: right;
}
}
@@ -429,6 +520,7 @@ const onCancel = async () => {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 12px;
}
}
</style>
@@ -779,7 +779,7 @@
</section>
<section v-if="isSmartCabinetOrder" id="section-cabinet" class="detail-section detail-anchor">
<hard-door-log v-auth="'dzc_hardDoorFlow_view'" :order-id="String(globalData.detail.id)" />
<hard-door-log v-auth="'dzc_hardDoorFlow_view'" :key="cabinetLogKey" :order-id="String(globalData.detail.id)" />
</section>
<section v-if="showManualSettlementSection" id="section-manual-settlement" class="detail-section detail-anchor">
@@ -1251,6 +1251,13 @@ const showManualSettlementSection = computed(
isSmartCabinetOrder.value &&
orderStatusCode.value === DOOR_ORDER_PENDING_SETTLE_STATUS,
)
/** 智能柜日志组件重建键:订单结算/取消(payTime/unfollowTime/updateTime 变化)后强制重新拉取流程,展示手动结算快照。 */
const cabinetLogKey = computed(
() =>
`${globalData.detail.id}-${globalData.detail.payTime || ""}-${globalData.detail.unfollowTime || ""}-${
globalData.detail.updateTime || ""
}`,
)
/** 当前订单在管理端的业务流程摘要。 */
const orderScenarioLabel = computed(() => getOrderScenarioLabel(orderTypeCode.value))