数据库分析和对应不同角色页面

This commit is contained in:
not-like-juvenile
2026-02-06 15:10:18 +08:00
parent 33030bd20b
commit 56ae71babf
30 changed files with 3510 additions and 21 deletions

View File

@@ -0,0 +1,291 @@
<template>
<view class="container">
<view class="header">
<text class="title">第三方物流 API 模拟发送器</text>
<text class="subtitle">模拟外部物流平台向后端推送 Webhook 轨迹数据</text>
</view>
<view class="section">
<text class="section-title">1. 选择目标订单 (已发货)</text>
<scroll-view class="order-list" direction="horizontal">
<view v-for="(item, index) in shippedOrders" :key="index"
:class="['order-card', selectedOrderIndex == index ? 'active' : '']"
@click="selectOrder(index)">
<text class="order-no">{{ item.order_no }}</text>
<text class="tracking-no">{{ item.carrier }}: {{ item.tracking_no }}</text>
</view>
</scroll-view>
</view>
<view class="section">
<text class="section-title">2. 构造回调数据 (JSON Payload)</text>
<view class="form-group">
<text class="label">运单号:</text>
<input class="input" v-model="form.tracking_no" placeholder="请输入运单号" />
</view>
<view class="form-group">
<text class="label">快递公司:</text>
<input class="input" v-model="form.carrier" placeholder="请输入快递公司" />
</view>
<view class="form-group">
<text class="label">物流状态:</text>
<picker :range="statusOptions" range-key="label" @change="onStatusChange">
<view class="picker-val">{{ currentStatusLabel }}</view>
</picker>
</view>
<view class="form-group">
<text class="label">轨迹描述文字:</text>
<textarea class="textarea" v-model="form.event_text" placeholder="描述当前的物流位置或状态..." />
</view>
</view>
<view class="payload-preview">
<text class="preview-title">接口发送原始数据预览:</text>
<view class="code-block">
<text class="code-text">{{ jsonString }}</text>
</view>
</view>
<button class="btn-send" type="primary" @click="sendWebhook">立即推送 API 数据</button>
<view class="footer-links">
<text class="link" @click="goToLogs">查看接收日志 (Webhook Logs)</text>
</view>
</view>
</template>
<script setup lang="uts">
import { mockService, MockOrder } from './mock-service.uts'
const shippedOrders = computed((): MockOrder[] => {
return mockService.getMockOrders().filter((o: MockOrder): boolean => o.status !== 'PENDING' && o.tracking_no !== '')
})
const selectedOrderIndex = ref(-1)
const form = reactive({
tracking_no: '',
carrier: '',
status_code: 'IN_TRANSIT',
event_text: '快件已到达【XX转运中心】准备发往下一站'
})
const statusOptions = [
{ label: '在途中 (IN_TRANSIT)', value: 'IN_TRANSIT' },
{ label: '派送中 (OUT_FOR_DELIVERY)', value: 'OUT_FOR_DELIVERY' },
{ label: '已签收 (DELIVERED)', value: 'DELIVERED' },
{ label: '异常 (EXCEPTION)', value: 'EXCEPTION' }
]
const currentStatusLabel = computed(() => {
const opt = statusOptions.find(o => o.value === form.status_code)
return opt ? opt.label : '请选择'
})
const jsonString = computed(() => {
return JSON.stringify(form, null, 2)
})
function selectOrder(index: number) {
selectedOrderIndex.value = index
const order = shippedOrders.value[index]
form.tracking_no = order.tracking_no
form.carrier = order.carrier
// 根据订单当前状态智能预设
if (order.status === 'SHIPPED') {
form.status_code = 'IN_TRANSIT'
form.event_text = '快件已揽收,正发往城市中心'
} else if (order.status === 'IN_TRANSIT') {
form.status_code = 'OUT_FOR_DELIVERY'
form.event_text = '派送员王师傅(13700008888)正在派件'
}
}
function onStatusChange(e: UniPickerChangeEvent) {
const idx = e.detail.value as number
form.status_code = statusOptions[idx].value as string
}
function sendWebhook() {
if (!form.tracking_no) {
uni.showToast({ title: '请先填写运单号', icon: 'none' })
return
}
// 执行模拟推送 (转换为普通对象以兼容 UTS)
const payload = {
tracking_no: form.tracking_no,
carrier: form.carrier,
status_code: form.status_code,
event_text: form.event_text
} as UTSJSONObject
const success = mockService.pushWebhookData(payload)
if (success) {
uni.showToast({ title: 'API 发送成功!', icon: 'success' })
// 可选:跳转到详情或日志预览
} else {
uni.showModal({
title: '发送失败',
content: '系统未找到该运单号,后端拒绝接收该数据。',
showCancel: false
})
}
}
function goToLogs() {
uni.navigateTo({ url: '/pages/mall/delivery/test/webhook-logs' })
}
</script>
<style scoped>
.container {
padding: 20px;
background-color: #f8f9fa;
min-height: 100vh;
}
.header {
margin-bottom: 25px;
}
.title {
font-size: 20px;
font-weight: bold;
color: #333;
display: block;
}
.subtitle {
font-size: 13px;
color: #666;
margin-top: 5px;
display: block;
}
.section {
margin-bottom: 25px;
background: #fff;
padding: 15px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #444;
margin-bottom: 12px;
display: block;
}
.order-list {
white-space: nowrap;
display: flex;
flex-direction: row;
}
.order-card {
display: inline-block;
width: 160px;
padding: 12px;
background: #f0f4f8;
border: 1.5px solid transparent;
border-radius: 8px;
margin-right: 12px;
}
.order-card.active {
border-color: #007aff;
background: #eef6ff;
}
.order-no {
font-size: 13px;
font-weight: bold;
display: block;
}
.tracking-no {
font-size: 11px;
color: #888;
margin-top: 4px;
display: block;
}
.form-group {
margin-bottom: 15px;
border-bottom: 1px solid #f0f0f0;
padding-bottom: 10px;
}
.label {
font-size: 13px;
color: #666;
margin-bottom: 5px;
display: block;
}
.input {
font-size: 14px;
color: #333;
height: 35px;
}
.picker-val {
font-size: 14px;
color: #007aff;
padding: 5px 0;
}
.textarea {
font-size: 14px;
width: 100%;
height: 80px;
background: #fafafa;
padding: 8px;
border-radius: 4px;
}
.payload-preview {
background: #2d2d2d;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
}
.preview-title {
color: #aaa;
font-size: 12px;
margin-bottom: 10px;
display: block;
}
.code-block {
font-family: monospace;
}
.code-text {
color: #69f0ae;
font-size: 12px;
line-height: 1.5;
}
.btn-send {
margin-top: 10px;
border-radius: 25px;
}
.footer-links {
text-align: center;
margin-top: 25px;
}
.link {
color: #007aff;
font-size: 14px;
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,494 @@
<template>
<view class="container">
<!-- 头部状态栏 (沉浸式) -->
<view class="status-header">
<view class="status-main">
<text class="status-icon">{{ getStatusIcon(order.status) }}</text>
<view class="status-text-wrap">
<text class="status-title">{{ getStatusText(order.status) }}</text>
<view class="status-desc-row">
<text v-for="(seg, i) in splitText(getLatestEventText())" :key="i" class="status-desc-text" :class="{ 'highlight': seg.isPhone }" @click="seg.isPhone ? makeCall(seg.raw as string) : null">{{ seg.text }}</text>
</view>
</view>
</view>
</view>
<!-- 承运信息卡片 -->
<view class="card carrier-card">
<view class="carrier-info">
<image class="carrier-logo" src="/static/carrier-default.png" mode="aspectFit" />
<view class="carrier-detail">
<text class="carrier-name">{{ order.carrier }}快递</text>
<text class="waybill-no">运单号: {{ order.tracking_no }}</text>
</view>
<button class="copy-btn" @click="copyTrackingNo">复制</button>
</view>
</view>
<!-- 物流轨迹时间线 -->
<view class="card timeline-card">
<view class="section-title">物流追踪</view>
<view class="timeline-container">
<view v-for="(event, index) in statusHistory" :key="index" class="timeline-item">
<!-- 时间轴线 -->
<view class="axis">
<view class="dot" :class="{active: index === 0}"></view>
<view class="line" v-if="index !== statusHistory.length - 1"></view>
</view>
<!-- 内容区 -->
<view class="content">
<view class="event-header">
<view class="status-time-row">
<text v-if="shouldShowStatus(index)" class="status-label" :class="{active: index === 0}">{{ getStatusLabel(event.status_code) }}</text>
<text class="event-time" :class="{active: index === 0}">{{ event.event_time }}</text>
</view>
<view class="event-title-row">
<text v-for="(seg, i) in splitText(event.event_text)" :key="i" class="event-title-text" :class="{ 'highlight': seg.isPhone, 'active': index === 0 }" @click="seg.isPhone ? makeCall(seg.raw as string) : null">{{ seg.text }}</text>
</view>
</view>
<view v-if="event.location" class="location-info">
<text class="loc-icon">📍</text>
<text class="loc-text">{{ event.location }}</text>
</view>
<!-- 证据/签收图片 (POD) -->
<view v-if="event.evidence_urls.length > 0" class="evidence-grid">
<image v-for="(img, idx) in event.evidence_urls" :key="idx" :src="img" class="evidence-img" mode="aspectFill" @click="previewImage(img, event.evidence_urls)" />
</view>
</view>
</view>
<view v-if="statusHistory.length === 0" class="empty-state">
<text class="empty-text">暂无物流动态</text>
</view>
</view>
</view>
<!-- 订单交易信息 -->
<view class="card order-info-card">
<view class="section-title">订单信息</view>
<view class="info-item">
<text class="info-label">订单编号</text>
<text class="info-value">{{ order.order_no }}</text>
</view>
<view class="info-item">
<text class="info-label">下单时间</text>
<text class="info-value">{{ order.created_at }}</text>
</view>
<view class="info-item">
<text class="info-label">实付金额</text>
<text class="info-value">¥{{ order.amount || '0.00' }}</text>
</view>
<view v-if="order.address" class="info-item">
<text class="info-label">收货地址</text>
<text class="info-value">{{ order.address }}</text>
</view>
</view>
<!-- 底部操作栏 -->
<view class="bottom-actions">
<button class="action-item" @click="refresh">🔄 刷新物流</button>
<button class="action-item" @click="showSupport">💬 物流客服</button>
</view>
</view>
</template>
<script uts>
import { mockService, MockOrder, MockTrackingEvent } from './mock-service.uts'
export default {
data() {
return {
orderNo: 'ORD20260205002',
order: {
order_no: '',
status: '',
created_at: '',
receiver_name: '',
receiver_masked_phone: '',
address: '',
amount: '',
carrier: '',
tracking_no: ''
} as MockOrder,
statusHistory: [] as MockTrackingEvent[]
}
},
onLoad(options: any) {
if (options['order_no'] != null) {
this.orderNo = options['order_no'] as string
}
},
onShow() {
this.loadData()
},
methods: {
loadData() {
const orders = mockService.getMockOrders()
const found = orders.find((o: MockOrder): boolean => o.order_no === this.orderNo)
if (found != null) {
// 确保对象引用更新以触发响应式
this.order = { ...found } as MockOrder
}
// 确保数组引用更新
this.statusHistory = [...mockService.getMockTracking(this.orderNo)]
},
getStatusText(status: string): string {
const maps = {
'SHIPPED': '运输中',
'DELIVERED': '已签收',
'OUT_FOR_DELIVERY': '派送中',
'PENDING': '待揽收',
'EXCEPTION': '包裹异常'
}
return (maps[status] != null) ? maps[status] : status
},
getStatusIcon(status: string): string {
const maps = { 'SHIPPED': '🚚', 'DELIVERED': '✅', 'OUT_FOR_DELIVERY': '🛵', 'PENDING': '📦', 'EXCEPTION': '❗' }
return (maps[status] != null) ? maps[status] : '📦'
},
getLatestEventText(): string {
if (this.statusHistory.length > 0) {
return this.statusHistory[0].event_text
}
return '暂无物流信息'
},
shouldShowStatus(index: number): boolean {
if (index == 0) return true
const current = this.statusHistory[index].status_code
const prev = this.statusHistory[index - 1].status_code
// 如果当前状态与上一个状态不同,或者是状态变更点,则显示标签
return current != '' && current != prev
},
copyTrackingNo() {
uni.setClipboardData({
data: this.order.tracking_no,
success: () => uni.showToast({ title: '单号已复制' })
})
},
callCourier() {
uni.makePhoneCall({ phoneNumber: '13800138000' })
},
async refresh() {
if (this.order.status === 'DELIVERED') {
uni.showToast({ title: '已签收包裹无需刷新', icon: 'none' })
return
}
uni.showLoading({ title: '同步中...' })
const success = await mockService.syncFromCloud(this.orderNo)
uni.hideLoading()
if (success) {
this.loadData()
uni.showToast({ title: '已同步最新动态' })
} else {
uni.showToast({ title: '暂无更多更新', icon: 'none' })
}
},
previewImage(url: string, urls: string[]) {
uni.previewImage({ current: url, urls: urls })
},
showSupport() {
uni.showToast({ title: '接入平台客服中...', icon: 'none' })
},
getStatusLabel(code: string): string {
const maps = {
'SHIPPED': '已发货',
'IN_TRANSIT': '运输中',
'ARRIVED_HUB': '中转中',
'OUT_FOR_DELIVERY': '派送中',
'DELIVERED': '已签收',
'EXCEPTION': '异常'
}
return (maps[code] != null) ? maps[code] : code
},
splitText(text: string): UTSJSONObject[] {
const res = [] as UTSJSONObject[]
const regex = /(1[3-9]\d{9})/g
const parts = text.split(regex)
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
if (i % 2 === 1) {
// 消费者端:脱敏显示,但保持可点击
const masked = part.substring(0, 3) + '****' + part.substring(7)
res.push({ text: masked, raw: part, isPhone: true } as UTSJSONObject)
} else if (part.length > 0) {
res.push({ text: part, raw: '', isPhone: false } as UTSJSONObject)
}
}
return res
},
makeCall(phone: string) {
// 获取原始电话拨打
uni.makePhoneCall({ phoneNumber: phone })
}
}
}
</script>
<style lang="scss">
.container {
background-color: #f3f4f6;
min-height: 100vh;
padding-bottom: 160rpx;
}
.status-header {
background: linear-gradient(135deg, #007AFF, #0056b3);
padding: 60rpx 40rpx;
color: #ffffff;
}
.status-main {
display: flex;
align-items: center;
gap: 30rpx;
}
.status-icon {
font-size: 80rpx;
}
.status-title {
font-size: 44rpx;
font-weight: bold;
display: block;
}
.status-desc-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-top: 10rpx;
}
.status-desc-text {
font-size: 26rpx;
color: #ffffff;
opacity: 0.9;
line-height: 1.4;
}
.status-desc-text.highlight {
color: #FFD700;
font-weight: bold;
text-decoration: underline;
opacity: 1;
}
.event-title-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.event-title-text {
font-size: 26rpx;
color: #666;
line-height: 1.6;
}
.event-title-text.active {
color: #1a1a1a;
font-weight: 500;
}
.event-title-text.highlight {
color: #007AFF;
font-weight: bold;
text-decoration: underline;
}
.header-call-btn {
/* Lines 258-267 omitted */
}
.card {
background-color: #ffffff;
margin: 20rpx;
border-radius: 20rpx;
padding: 30rpx;
}
.carrier-info {
display: flex;
align-items: center;
gap: 20rpx;
}
.carrier-logo {
width: 80rpx;
height: 80rpx;
background-color: #f0f0f0;
border-radius: 50%;
}
.carrier-detail {
flex: 1;
}
.carrier-name {
font-size: 30rpx;
font-weight: bold;
display: block;
}
.waybill-no {
font-size: 24rpx;
color: #666;
}
.copy-btn {
font-size: 22rpx;
padding: 0 20rpx;
height: 44rpx;
line-height: 44rpx;
border-radius: 22rpx;
background-color: #eef2f7;
color: #007AFF;
}
.courier-box {
margin-top: 30rpx;
padding-top: 30rpx;
border-top: 1rpx solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.courier-tip {
font-size: 26rpx;
color: #27ae60;
font-weight: bold;
}
.call-btn {
font-size: 24rpx;
background-color: #007AFF;
color: #fff;
margin: 0;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 30rpx;
}
.info-item {
display: flex;
justify-content: flex-start;
margin-bottom: 20rpx;
}
.info-label {
width: 160rpx;
font-size: 26rpx;
color: #999;
}
.info-value {
flex: 1;
font-size: 26rpx;
color: #333;
text-align: left;
word-break: break-all;
}
.empty-state {
padding: 40rpx;
text-align: center;
}
.empty-text {
color: #999;
font-size: 24rpx;
}
.timeline-item {
display: flex;
flex-direction: row;
}
.axis {
width: 30rpx;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
margin-right: 15rpx;
}
.line {
width: 2rpx;
flex: 1;
background-color: #eee;
}
.dot {
width: 12rpx;
height: 12rpx;
background-color: #ddd;
border-radius: 50%;
z-index: 1;
margin-top: 14rpx;
}
.dot.active {
background-color: #007AFF;
width: 16rpx;
height: 16rpx;
box-shadow: 0 0 8rpx rgba(0,122,255,0.3);
margin-top: 12rpx;
}
.content {
flex: 1;
padding-bottom: 24rpx;
}
.event-header {
margin-bottom: 4rpx;
}
.status-time-row {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: 12rpx;
margin-bottom: 6rpx;
}
.status-label {
font-size: 28rpx;
font-weight: bold;
color: #333;
margin-right: 10rpx;
}
.status-label.active {
color: #007AFF;
}
.event-title {
font-size: 26rpx;
color: #666;
display: block;
line-height: 1.6;
text-align: left;
}
.event-title.active {
color: #1a1a1a;
font-weight: 500;
}
.event-time {
font-size: 24rpx;
color: #999;
text-align: left;
}
.event-time.active {
color: #007AFF;
}
.event-time.active {
color: #666;
}
.location-info {
margin-top: 12rpx;
background-color: #f9fafb;
padding: 10rpx 16rpx;
border-radius: 8rpx;
display: flex;
align-items: center;
gap: 8rpx;
}
.loc-icon { font-size: 20rpx; }
.loc-text { font-size: 22rpx; color: #666; }
.evidence-grid {
display: flex;
gap: 16rpx;
margin-top: 20rpx;
}
.evidence-img {
width: 140rpx;
height: 140rpx;
border-radius: 12rpx;
}
.bottom-actions {
position: fixed;
bottom: 0; left: 0; right: 0;
background-color: #fff;
padding: 20rpx 30rpx;
display: flex;
justify-content: space-around;
box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.05);
}
.action-item {
font-size: 24rpx;
background-color: #f8fafc;
border: 1rpx solid #e2e8f0;
margin: 0;
padding: 0 30rpx;
}
</style>

View File

@@ -0,0 +1,225 @@
<template>
<view class="container">
<view class="header">
<text class="title">我的快递</text>
<text class="count">共 {{ orders.length }} 个包裹</text>
</view>
<view class="tabs">
<view class="tab-item" :class="{active: currentTab === 'all'}" @click="currentTab = 'all'">全部</view>
<view class="tab-item" :class="{active: currentTab === 'active'}" @click="currentTab = 'active'">进行中</view>
<view class="tab-item" :class="{active: currentTab === 'completed'}" @click="currentTab = 'completed'">已签收</view>
</view>
<scroll-view class="order-list" scroll-y="true">
<view v-for="(order, index) in filteredOrders" :key="order.order_no" class="order-card" @click="goDetail(order.order_no)">
<view class="card-header">
<view class="carrier-tag">{{ order.carrier }}</view>
<text class="status-text" :class="order.status">{{ getStatusText(order.status) }}</text>
</view>
<view class="card-body">
<view class="pkg-icon">📦</view>
<view class="pkg-info">
<text class="latest-msg">{{ getLatestMsg(order.order_no) }}</text>
<text class="order-meta">订单号: {{ order.order_no }}</text>
</view>
<text class="arrow">></text>
</view>
<view class="card-footer">
<text class="time">下单时间: {{ order.created_at }}</text>
</view>
</view>
<view v-if="filteredOrders.length === 0" class="empty-state">
<text class="empty-icon">📂</text>
<text class="empty-text">暂无相关包裹信息</text>
</view>
</scroll-view>
</view>
</template>
<script uts>
import { mockService, MockOrder } from './mock-service.uts'
export default {
data() {
return {
currentTab: 'all',
orders: [] as MockOrder[]
}
},
onShow() {
this.loadData()
},
computed: {
filteredOrders(): MockOrder[] {
if (this.currentTab === 'all') {
return this.orders
}
if (this.currentTab === 'completed') {
return this.orders.filter((o: MockOrder): boolean => o.status === 'DELIVERED')
} else {
return this.orders.filter((o: MockOrder): boolean => o.status !== 'DELIVERED')
}
}
},
methods: {
loadData() {
this.orders = [...mockService.getMockOrders()]
},
getStatusText(status: string): string {
const maps = {
'SHIPPED': '运输中',
'DELIVERED': '已签收',
'OUT_FOR_DELIVERY': '派送中',
'PENDING': '待揽收',
'EXCEPTION': '包裹异常'
}
return (maps[status] != null) ? maps[status] : '处理中'
},
getLatestMsg(orderNo: string): string {
const history = mockService.getMockTracking(orderNo)
if (history.length > 0) {
const text = history[0].event_text
// 消费者端列表页:脱敏处理手机号
return text.replace(/(1[3-9]\d{9})/g, (m : string) : string => {
return m.substring(0, 3) + '****' + m.substring(7)
})
}
return '包裹准备中'
},
goDetail(orderNo: string) {
uni.navigateTo({
url: `/pages/mall/delivery/test/consumer-logistics-detail?order_no=${orderNo}`
})
}
}
}
</script>
<style scoped>
.container {
background-color: #f7f8fa;
min-height: 100vh;
padding: 30rpx;
}
.header {
margin-bottom: 30rpx;
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.title {
font-size: 40rpx;
font-weight: bold;
color: #1a1a1a;
}
.count {
font-size: 24rpx;
color: #999;
}
.tabs {
display: flex;
margin-bottom: 30rpx;
gap: 40rpx;
}
.tab-item {
font-size: 30rpx;
color: #666;
padding-bottom: 10rpx;
}
.tab-item.active {
color: #007AFF;
font-weight: bold;
border-bottom: 4rpx solid #007AFF;
}
.order-card {
background-color: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin-bottom: 24rpx;
box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.02);
}
.card-header {
display: flex;
justify-content: space-between;
margin-bottom: 20rpx;
}
.carrier-tag {
background-color: #f0f7ff;
color: #007AFF;
font-size: 20rpx;
padding: 4rpx 12rpx;
border-radius: 6rpx;
}
.status-text.DELIVERED { color: #52c41a; }
.status-text.SHIPPED { color: #007AFF; }
.status-text.OUT_FOR_DELIVERY { color: #faad14; }
.status-text.EXCEPTION { color: #ff4d4f; }
.status-text.PENDING { color: #999; }
.status-text.DELIVERED { color: #999; }
.status-text.OUT_FOR_DELIVERY { color: #27ae60; }
.status-text.SHIPPED { color: #007AFF; }
.card-body {
display: flex;
align-items: center;
gap: 20rpx;
padding: 10rpx 0;
}
.pkg-icon {
font-size: 50rpx;
background-color: #f9f9f9;
width: 90rpx;
height: 90rpx;
display: flex;
justify-content: center;
align-items: center;
border-radius: 16rpx;
}
.pkg-info {
flex: 1;
}
.latest-msg {
font-size: 26rpx;
color: #333;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 8rpx;
}
.order-meta {
font-size: 22rpx;
color: #999;
}
.arrow {
color: #ccc;
font-size: 30rpx;
}
.card-footer {
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f2f2f2;
}
.time {
font-size: 22rpx;
color: #bbb;
}
.empty-state {
padding-top: 200rpx;
text-align: center;
}
.empty-icon {
font-size: 100rpx;
display: block;
margin-bottom: 20rpx;
}
.empty-text {
color: #999;
font-size: 28rpx;
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<view class="container">
<view class="header">
<text class="title">配送模块测试页面索引</text>
</view>
<view class="section">
<text class="section-title">商家后台 (Merchant)</text>
<view class="nav-card" @click="goto('/pages/mall/delivery/test/merchant-order-list')">
<text class="nav-name">订单列表与发货</text>
<text class="nav-desc">查看待发货订单、绑定运单号</text>
</view>
<view class="nav-card" @click="goto('/pages/mall/delivery/test/merchant-order-detail?order_no=ORD20260205001')">
<text class="nav-name">订单详情与物流轨迹</text>
<text class="nav-desc">物流时间线展示、签收凭证预览</text>
</view>
</view>
<view class="section">
<text class="section-title">平台后台 (Platform/Ops)</text>
<view class="nav-card" @click="goto('/pages/mall/delivery/test/platform-tracking-query')">
<text class="nav-name">运单/轨迹查询 (排障)</text>
<text class="nav-desc">按运单号查询、查看 Raw Payload、Mock 控制</text>
</view>
<view class="nav-card" @click="goto('/pages/mall/delivery/test/platform-webhook-logs')">
<text class="nav-name">Webhook 接收日志</text>
<text class="nav-desc">核对回调、验签状态、入库结果</text>
</view>
<view class="nav-card" @click="goto('/pages/mall/delivery/test/platform-config-center')">
<text class="nav-name">对接配置中心</text>
<text class="nav-desc">承运商管理、Mock 服务开关、密钥配置</text>
</view>
</view>
<view class="section">
<text class="section-title">消费者端 (Consumer/APP)</text>
<view class="nav-card" @click="goto('/pages/mall/delivery/test/consumer-order-list')">
<text class="nav-name">我的快递 (物流列表)</text>
<text class="nav-desc">查看多个包裹状态、实时动态摘要</text>
</view>
<view class="nav-card" @click="goto('/pages/mall/delivery/test/consumer-logistics-detail?order_no=ORD20260205002')">
<text class="nav-name">用户物流详情页</text>
<text class="nav-desc">单包裹高保真轨迹、联系派送员</text>
</view>
</view>
<view class="section">
<text class="section-title">模拟与开发工具 (Tools)</text>
<view class="nav-card" @click="goto('/pages/mall/delivery/test/api-simulator')">
<text class="nav-name">第三方 API 推送模拟</text>
<text class="nav-desc">手动构造 Webhook Payload 发送给系统</text>
</view>
</view>
</view>
</template>
<script uts>
export default {
methods: {
goto(url: string) {
uni.navigateTo({ url: url })
}
}
}
</script>
<style lang="scss">
.container {
padding: 30rpx;
background-color: #f4f7f9;
min-height: 100vh;
}
.header {
margin-bottom: 50rpx;
text-align: center;
}
.title {
font-size: 40rpx;
font-weight: bold;
color: #2c3e50;
display: block;
}
.desc {
font-size: 24rpx;
color: #95a5a6;
margin-top: 10rpx;
}
.section {
margin-bottom: 40rpx;
}
.section-title {
font-size: 28rpx;
font-weight: bold;
color: #7f8c8d;
margin-bottom: 20rpx;
padding-left: 10rpx;
}
.nav-card {
background-color: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.05);
}
.nav-name {
font-size: 32rpx;
font-weight: bold;
color: #34495e;
display: block;
}
.nav-desc {
font-size: 24rpx;
color: #95a5a6;
margin-top: 8rpx;
}
.footer {
margin-top: 100rpx;
text-align: center;
}
</style>

View File

@@ -0,0 +1,269 @@
<template>
<view class="container">
<!-- 状态卡片 -->
<view class="status-card">
<text class="status-title">{{ getStatusText(order.status) }}</text>
<text v-if="order.tracking_no" class="status-desc">{{ order.carrier }}: {{ order.tracking_no }}</text>
<text v-else class="status-desc">待商家发货</text>
</view>
<!-- 收货人信息 -->
<view class="section-card">
<view class="section-header">收货信息</view>
<view class="info-row">
<text class="label">收货人</text>
<text class="value">{{ order.receiver_name }}</text>
</view>
<view class="info-row">
<text class="label">手机号</text>
<text class="value">{{ order.receiver_masked_phone }}</text>
</view>
<view class="info-row">
<text class="label">地址</text>
<text class="value">{{ order.address }}</text>
</view>
</view>
<!-- 物流追踪 -->
<view v-if="order.tracking_no" class="section-card">
<view class="section-header">物流追踪</view>
<view class="timeline">
<view v-for="(event, index) in statusHistory" :key="index" class="timeline-item">
<view class="timeline-line" v-if="index !== statusHistory.length - 1"></view>
<view class="timeline-dot" :class="{active: index === 0}"></view>
<view class="timeline-content">
<view class="event-title-row">
<text v-for="(seg, i) in filterPhone(event.event_text)" :key="i" class="event-text" :class="{active: index === 0}">{{ seg }}</text>
</view>
<text class="event-time">{{ event.event_time }}</text>
<!-- 证据/签收图片 -->
<view v-if="event.evidence_urls.length > 0" class="evidence-grid">
<image v-for="(img, idx) in event.evidence_urls" :key="idx" :src="img" class="evidence-img" mode="aspectFill" @click="previewImage(img, event.evidence_urls)" />
</view>
</view>
</view>
<view v-if="statusHistory.length === 0" class="empty-timeline">
<text>暂无物流动态</text>
</view>
</view>
<view class="timeline-footer">
<text class="sync-time">最后更新: {{ order.last_synced_at || '尚未同步' }}</text>
<text class="refresh-link" @click="refreshLogistics">手动刷新</text>
</view>
</view>
<!-- 订单明细 -->
<view class="section-card">
<view class="section-header">订单详情</view>
<view class="info-row">
<text class="label">订单号</text>
<text class="value">{{ order.order_no }}</text>
</view>
<view class="info-row">
<text class="label">下单时间</text>
<text class="value">{{ order.created_at }}</text>
</view>
<view class="info-row">
<text class="label">支付金额</text>
<text class="value">¥{{ order.amount }}</text>
</view>
</view>
</view>
</template>
<script uts>
import { mockService, MockOrder, MockTrackingEvent } from './mock-service.uts'
export default {
data() {
return {
orderNo: '',
order: {
order_no: '',
status: 'SHIPPED',
carrier: 'YUNDA',
tracking_no: 'YD987654321',
receiver_name: '张三',
receiver_masked_phone: '138****8000',
address: '北京市朝阳区某某街道100号',
amount: '299.00',
created_at: '2026-02-05 10:00',
last_synced_at: '2026-02-05 14:35'
} as MockOrder,
statusHistory: [] as MockTrackingEvent[]
}
},
onLoad(options: any) {
this.orderNo = (options['order_no'] != null) ? options['order_no'] as string : ''
this.loadData()
},
methods: {
loadData() {
// 获取匹配的订单基本信息
const allOrders = mockService.getMockOrders()
const found = allOrders.find((o : MockOrder) : boolean => o.order_no === this.orderNo)
if (found != null) {
this.order = found
}
// 获取轨迹
this.statusHistory = mockService.getMockTracking(this.orderNo)
},
getStatusText(status: string) : string {
const maps = { 'PENDING': '待发货', 'SHIPPED': '待签收', 'DELIVERED': '已签收', 'EXCEPTION': '异常' }
return (maps[status] != null) ? maps[status] : status
},
async refreshLogistics() {
if (this.order.status === 'DELIVERED') {
uni.showToast({ title: '订单已完成', icon: 'none' })
return
}
uni.showLoading({ title: '查询中...' })
const success = await mockService.syncFromCloud(this.orderNo)
uni.hideLoading()
if (success) {
this.loadData()
uni.showToast({ title: '已获取最新轨迹' })
} else {
uni.showToast({ title: '数据已是最新', icon: 'none' })
}
},
previewImage(url: string, urls: string[]) {
uni.previewImage({
current: url,
urls: urls
})
},
filterPhone(text: string): string[] {
// 商家端:不展示手机号
return [text.replace(/(1[3-9]\d{9})/g, '')]
}
}
}
</script>
<style scoped>
.container {
padding: 20rpx;
background-color: #f8f8f8;
min-height: 100vh;
}
.status-card {
background: linear-gradient(to right, #007AFF, #00C6FF);
padding: 40rpx;
border-radius: 20rpx;
margin-bottom: 20rpx;
color: #fff;
}
.status-title {
font-size: 40rpx;
font-weight: bold;
display: block;
margin-bottom: 10rpx;
}
.status-desc {
font-size: 26rpx;
opacity: 0.9;
}
.section-card {
background-color: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 20rpx;
}
.section-header {
font-size: 30rpx;
font-weight: bold;
color: #333;
margin-bottom: 24rpx;
border-left: 8rpx solid #007AFF;
padding-left: 20rpx;
}
.info-row {
display: flex;
margin-bottom: 16rpx;
}
.label {
width: 160rpx;
font-size: 26rpx;
color: #999;
}
.value {
flex: 1;
font-size: 26rpx;
color: #333;
}
.timeline {
margin-top: 10rpx;
}
.timeline-item {
position: relative;
padding-left: 40rpx;
padding-bottom: 40rpx;
}
.timeline-line {
position: absolute;
left: 6rpx;
top: 10rpx;
bottom: -10rpx;
width: 2rpx;
background-color: #eee;
}
.timeline-dot {
position: absolute;
left: 0;
top: 10rpx;
width: 14rpx;
height: 14rpx;
border-radius: 50%;
background-color: #ccc;
z-index: 1;
}
.timeline-dot.active {
background-color: #007AFF;
box-shadow: 0 0 10rpx rgba(0,122,255,0.5);
}
.event-text {
font-size: 28rpx;
color: #666;
display: block;
margin-bottom: 8rpx;
}
.event-text.active {
color: #333;
font-weight: bold;
}
.event-time {
font-size: 24rpx;
color: #999;
}
.evidence-grid {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
margin-top: 10rpx;
}
.evidence-img {
width: 120rpx;
height: 120rpx;
border-radius: 8rpx;
}
.timeline-footer {
display: flex;
justify-content: space-between;
padding-top: 20rpx;
border-top: 1rpx solid #eee;
margin-top: 10rpx;
}
.sync-time {
font-size: 22rpx;
color: #999;
}
.refresh-link {
font-size: 22rpx;
color: #007AFF;
}
</style>

View File

@@ -0,0 +1,327 @@
<template>
<view class="container">
<view class="header">
<text class="title">商家发货管理</text>
</view>
<!-- 筛选栏 -->
<view class="filter-bar">
<view class="filter-item" :class="{active: currentFilter === 'all'}" @click="setFilter('all')">全部</view>
<view class="filter-item" :class="{active: currentFilter === 'pending'}" @click="setFilter('pending')">待发货</view>
<view class="filter-item" :class="{active: currentFilter === 'shipped'}" @click="setFilter('shipped')">已发货</view>
</view>
<scroll-view class="order-list" scroll-y="true">
<view v-for="(order, index) in filteredOrders" :key="order.order_no" class="order-card">
<view class="order-header">
<text class="order-no">订单号: {{ order.order_no }}</text>
<text class="order-status">{{ getStatusText(order.status) }}</text>
</view>
<view class="order-body">
<view class="info-row">
<text class="label">下单时间:</text>
<text class="value">{{ order.created_at }}</text>
</view>
<view class="info-row">
<text class="label">收件人:</text>
<text class="value">{{ order.receiver_name }} {{ order.receiver_masked_phone }}</text>
</view>
<view v-if="order.tracking_no" class="info-row">
<text class="label">运单号:</text>
<text class="value">{{ order.tracking_no }} ({{ order.carrier }})</text>
</view>
</view>
<view class="order-footer">
<button v-if="order.status === 'PENDING'" class="btn-primary" @click="openShipModal(order)">去发货</button>
<button class="btn-secondary" @click="viewDetail(order)">详情</button>
</view>
</view>
<view v-if="filteredOrders.length === 0" class="empty-state">
<text>暂无订单</text>
</view>
</scroll-view>
<!-- 发货弹窗 -->
<view v-if="showShipModal" class="modal-mask">
<view class="modal-content">
<view class="modal-header">
<text class="modal-title">订单发货</text>
<text class="close-btn" @click="showShipModal = false">×</text>
</view>
<view class="modal-body">
<view class="form-item">
<text class="label">选择承运商</text>
<picker :range="carriers" @change="onCarrierChange">
<view class="picker-val">{{ currentCarrier || '请选择' }}</view>
</picker>
</view>
<view class="form-item">
<text class="label">运单号</text>
<input v-model="trackingNo" placeholder="请输入或扫码录入" class="input" />
</view>
</view>
<view class="modal-footer">
<button class="btn-cancel" @click="showShipModal = false">取消</button>
<button class="btn-confirm" @click="confirmShip">确认发货</button>
</view>
</view>
</view>
</view>
</template>
<script uts>
import supa from '@/components/supadb/aksupainstance.uts'
import { mockService, MockOrder } from './mock-service.uts'
export default {
data() {
return {
currentFilter: 'all',
orders: [] as MockOrder[],
showShipModal: false,
selectedOrder: null as MockOrder | null,
carriers: ['YUNDA', 'YTO', 'ZTO', 'STO', 'SF'],
currentCarrier: '',
trackingNo: ''
}
},
onShow() {
this.loadData()
},
computed: {
filteredOrders() : MockOrder[] {
const list = this.orders
if (this.currentFilter === 'all') return list
if (this.currentFilter === 'pending') {
return list.filter((o : MockOrder) : boolean => o.status === 'PENDING')
}
if (this.currentFilter === 'shipped') {
// 除了待发货,其余所有状态(运输中、派送中、已签收、异常)都属于“已发货”范畴
return list.filter((o : MockOrder) : boolean => o.status !== 'PENDING')
}
return []
}
},
methods: {
loadData() {
// 使用展开运算符创建新数组引用,确保 Vue 响应式触发
this.orders = [...mockService.getMockOrders()]
},
setFilter(filter: string) {
this.currentFilter = filter
},
getStatusText(status: string) : string {
const maps = {
'PENDING': '待发货',
'SHIPPED': '已发货',
'DELIVERED': '已签收',
'OUT_FOR_DELIVERY': '派送中',
'EXCEPTION': '包裹异常'
}
return (maps[status] != null) ? maps[status] : status
},
openShipModal(order: MockOrder) {
this.selectedOrder = order
this.currentCarrier = 'YUNDA'
this.trackingNo = ''
this.showShipModal = true
},
onCarrierChange(e: any) {
const index = e.detail.value as number
this.currentCarrier = this.carriers[index]
},
async confirmShip() {
if (!this.trackingNo) {
uni.showToast({ title: '请输入运单号', icon: 'none' })
return
}
uni.showLoading({ title: '提交中...' })
// 调用 Mock 入库接口,实现同步修改
setTimeout(() => {
uni.hideLoading()
const orderNo = this.selectedOrder?.order_no
if (orderNo != null) {
// 1. 同步修改 Mock Service 中的原始数据
mockService.bindShipment(orderNo, this.currentCarrier, this.trackingNo)
// 2. 直接修改本地选中的订单对象状态,确保 Vue 响应式立即触发
if (this.selectedOrder != null) {
const target = this.selectedOrder!
target.status = 'SHIPPED'
target.carrier = this.currentCarrier
target.tracking_no = this.trackingNo
}
// 3. 重新加载数据(通过新引用触发 computed
this.loadData()
}
this.showShipModal = false
uni.showToast({ title: '发货成功' })
}, 1000)
},
viewDetail(order: MockOrder) {
uni.navigateTo({
url: `/pages/mall/delivery/test/merchant-order-detail?order_no=${order.order_no}`
})
}
}
}
</script>
<style scoped>
.container {
padding: 20px;
background-color: #f5f5f5;
height: 100vh;
display: flex;
flex-direction: column;
}
.order-list {
flex: 1;
overflow: hidden;
}
.header {
padding: 30rpx 0;
}
.title {
font-size: 36rpx;
font-weight: bold;
color: #333;
}
.filter-bar {
display: flex;
background-color: #fff;
border-radius: 12rpx;
margin-bottom: 20rpx;
}
.filter-item {
flex: 1;
text-align: center;
padding: 20rpx 0;
font-size: 28rpx;
color: #666;
}
.filter-item.active {
color: #007AFF;
font-weight: bold;
border-bottom: 4rpx solid #007AFF;
}
.order-card {
background-color: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
}
.order-header {
display: flex;
justify-content: space-between;
border-bottom: 1rpx solid #eee;
padding-bottom: 16rpx;
margin-bottom: 16rpx;
}
.order-no {
font-size: 26rpx;
color: #999;
}
.order-status {
font-size: 26rpx;
color: #f39c12;
}
.info-row {
display: flex;
margin-bottom: 12rpx;
}
.label {
width: 140rpx;
font-size: 26rpx;
color: #666;
}
.value {
flex: 1;
font-size: 26rpx;
color: #333;
}
.order-footer {
display: flex;
justify-content: flex-end;
margin-top: 20rpx;
gap: 20rpx;
}
.btn-primary {
background-color: #007AFF;
color: #fff;
font-size: 24rpx;
padding: 10rpx 30rpx;
border-radius: 30rpx;
}
.btn-secondary {
background-color: #fff;
color: #666;
border: 1rpx solid #ddd;
font-size: 24rpx;
padding: 10rpx 30rpx;
border-radius: 30rpx;
}
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0,0,0,0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
}
.modal-content {
width: 80%;
background-color: #fff;
border-radius: 20rpx;
padding: 40rpx;
}
.modal-header {
display: flex;
justify-content: space-between;
margin-bottom: 30rpx;
}
.modal-title {
font-size: 32rpx;
font-weight: bold;
}
.form-item {
margin-bottom: 30rpx;
}
.picker-val {
border: 1rpx solid #ddd;
padding: 16rpx;
border-radius: 8rpx;
margin-top: 10rpx;
}
.input {
border: 1rpx solid #ddd;
padding: 16rpx;
border-radius: 8rpx;
margin-top: 10rpx;
}
.modal-footer {
display: flex;
gap: 20rpx;
margin-top: 40rpx;
}
.btn-confirm {
flex: 1;
background-color: #007AFF;
color: #fff;
}
.btn-cancel {
flex: 1;
background-color: #eee;
color: #666;
}
</style>

View File

@@ -0,0 +1,496 @@
/**
* 配送模块模拟数据服务 (Mock Service)
* 依据: pages/mall/delivery/doc/需求文档/
* 涵盖: 商家端订单、平台端轨迹、Webhook 日志与场景模拟
*/
export interface MockOrder {
order_no: string
status: string
created_at: string
receiver_name: string
receiver_masked_phone: string
address: string
amount: string
carrier: string
tracking_no: string
last_synced_at?: string
}
export interface MockTrackingEvent {
event_id: string
event_time: string
event_code: string
event_text: string
status_code: string
node_name?: string
location?: string
evidence_urls: string[]
raw_payload?: string
}
export interface MockWebhookLog {
time: string
carrier: string
tracking_no: string
event_code: string
success: boolean
result_text: string
payload: UTSJSONObject
}
export interface MockCarrierOption {
code: string
name: string
}
class MockService {
private orders: MockOrder[] = [
{
order_no: 'ORD20260205001',
status: 'PENDING',
created_at: '2026-02-05 10:00',
receiver_name: '张三',
receiver_masked_phone: '138****8000',
address: '北京市朝阳区某某街道100号',
amount: '299.00',
tracking_no: '',
carrier: ''
},
{
order_no: 'ORD20260205002',
status: 'DELIVERED',
created_at: '2026-02-05 09:30',
receiver_name: '李四',
receiver_masked_phone: '139****1234',
address: '上海市浦东新区某某路200号',
amount: '158.50',
tracking_no: 'YD987654321',
carrier: '韵达',
last_synced_at: '2026-02-05 14:35'
},
{
order_no: 'ORD20260205003',
status: 'SHIPPED',
created_at: '2026-02-04 15:00',
receiver_name: '王五',
receiver_masked_phone: '137****5566',
address: '广州市天河区某某大厦15楼',
amount: '88.00',
tracking_no: 'ZT123456789',
carrier: '中通',
last_synced_at: '2026-02-05 10:00'
},
{
order_no: 'ORD20260205004',
status: 'OUT_FOR_DELIVERY',
created_at: '2026-02-05 08:00',
receiver_name: '赵六',
receiver_masked_phone: '135****0011',
address: '杭州市西湖区某某创意园',
amount: '450.00',
tracking_no: 'SF666888999',
carrier: '顺丰',
last_synced_at: '2026-02-06 09:00'
},
{
order_no: 'ORD20260205005',
status: 'EXCEPTION',
created_at: '2026-02-03 12:00',
receiver_name: '孙七',
receiver_masked_phone: '136****9988',
address: '成都市武侯区某某软件园',
amount: '120.00',
tracking_no: 'YT555444333',
carrier: '圆通',
last_synced_at: '2026-02-04 18:00'
}
]
// 全局 Mock 配置项,支持页面间同步
public isTestMode: boolean = true
public autoPush: boolean = true
public mockUrl: string = 'http://192.168.1.100:3000/mock/v1'
// 持久化存储轨迹:按订单号存储
private trackingHistory: Map<string, MockTrackingEvent[]> = new Map()
private webhookLogs: MockWebhookLog[] = []
constructor() {
this.initDefaultHistory()
this.initDefaultLogs()
}
private initDefaultLogs() {
this.webhookLogs = [
{
time: '14:35:22', carrier: '韵达', tracking_no: 'YD987654321',
event_code: 'DELIVERED', success: true, result_text: '成功入库',
payload: {
trackingNo: 'YD987654321',
status: 'DELIVERED',
msg: '您的快件已由本人签收'
} as UTSJSONObject
}
]
}
private initDefaultHistory() {
const tip = "【物流问题无需找商家或平台请致电95338专属热线400-811-1111更快解决】"
// 已签收订单轨迹
this.trackingHistory.set('ORD20260205002', [
{
event_id: 'e210',
event_time: '2026-02-05 18:30',
event_code: 'DELIVERED',
event_text: '您的快件已由本人签收。感谢使用韵达快递,期待再次为您服务!',
status_code: 'DELIVERED',
evidence_urls: ['https://img-shop.gmugmu.com/mock/pod_sample.png']
},
{
event_id: 'e209',
event_time: '2026-02-05 15:25',
event_code: 'OUT_FOR_DELIVERY',
event_text: '派送员张师傅(13800138000)正在派件(事先呼我,勿找商家)',
status_code: 'OUT_FOR_DELIVERY',
evidence_urls: []
},
{
event_id: 'e208',
event_time: '2026-02-05 15:24',
event_code: 'ARRIVED_HUB',
event_text: '【朝阳分部】已收入',
status_code: 'ARRIVED_HUB',
evidence_urls: []
},
{
event_id: 'e207',
event_time: '2026-02-05 15:23',
event_code: 'ARRIVED',
event_text: '您的快件已经到达【朝阳区】' + tip,
status_code: 'IN_TRANSIT',
evidence_urls: []
},
{
event_id: 'e200',
event_time: '2026-02-05 09:46',
event_code: 'SHIPPED',
event_text: '包裹正在等待揽收',
status_code: 'SHIPPED',
evidence_urls: []
}
])
// 运输中订单轨迹
this.trackingHistory.set('ORD20260205003', [
{
event_id: 'e305',
event_time: '2026-02-05 10:00',
event_code: 'TRANSIT',
event_text: '快件离开【广州分拣中心】,已发往【天河分部】',
status_code: 'IN_TRANSIT',
evidence_urls: []
},
{
event_id: 'e301',
event_time: '2026-02-04 15:30',
event_code: 'PICKED',
event_text: '包裹已揽收',
status_code: 'ARRIVED_HUB',
evidence_urls: []
}
])
// 派送中订单轨迹
this.trackingHistory.set('ORD20260205004', [
{
event_id: 'e405',
event_time: '2026-02-06 09:00',
event_code: 'OUT_FOR_DELIVERY',
event_text: '派送员王师傅(13700137000)正在派件',
status_code: 'OUT_FOR_DELIVERY',
evidence_urls: []
},
{
event_id: 'e401',
event_time: '2026-02-05 18:00',
event_code: 'ARRIVED',
event_text: '快件到达【杭州西湖分拨中心】',
status_code: 'IN_TRANSIT',
evidence_urls: []
}
])
// 异常订单轨迹
this.trackingHistory.set('ORD20260205005', [
{
event_id: 'e505',
event_time: '2026-02-04 18:00',
event_code: 'EXCEPTION',
event_text: '【包裹异常】由于天气原因,快件将在分拨中心稍作停留',
status_code: 'EXCEPTION',
evidence_urls: []
},
{
event_id: 'e501',
event_time: '2026-02-03 14:00',
event_code: 'PICKED',
event_text: '包裹已揽收',
status_code: 'ARRIVED_HUB',
evidence_urls: []
}
])
}
getAvailableCarriers(): MockCarrierOption[] {
return [
{ code: 'YUNDA', name: '韵达' },
{ code: 'YTO', name: '圆通' },
{ code: 'ZTO', name: '中通' },
{ code: 'STO', name: '申通' }
]
}
getMockOrders(): MockOrder[] {
return this.orders
}
bindShipment(orderNo: string, carrier: string, trackingNo: string): MockOrder | null {
const order = this.orders.find(o => o.order_no === orderNo)
if (!order) return null
order.carrier = carrier
order.tracking_no = trackingNo
order.status = 'SHIPPED'
order.last_synced_at = this.formatDate(new Date())
// 初始化轨迹
this.trackingHistory.set(orderNo, [
{
event_id: 'init_' + Date.now(),
event_time: order.last_synced_at,
event_code: 'CREATED',
event_text: '商家已发货,等待快递公司揽收',
status_code: '已发货',
evidence_urls: []
}
])
return order
}
getMockTracking(id: string): MockTrackingEvent[] {
const order = this.orders.find(o => o.order_no === id || o.tracking_no === id)
if (order != null && this.trackingHistory.has(order.order_no)) {
return this.trackingHistory.get(order.order_no)!
}
return []
}
/**
* 生成符合消费者端高保真展示的物流轨迹
*/
generateFullProcess(id: string) {
const order = this.orders.find(o => o.order_no === id || o.tracking_no === id)
if (!order) return
const now = new Date()
const getPastTime = (days: number, hours: number): string => {
const d = new Date(now.getTime() - (days * 24 + hours) * 3600 * 1000)
const YY = d.getFullYear()
const M = (d.getMonth() + 1).toString().padStart(2, '0')
const DD = d.getDate().toString().padStart(2, '0')
const h = d.getHours().toString().padStart(2, '0')
const m = d.getMinutes().toString().padStart(2, '0')
return `${YY}-${M}-${DD} ${h}:${m}`
}
const tip = "【物流问题无需找商家或平台请致电95338专属热线400-811-1111更快解决】"
const fullProcess: MockTrackingEvent[] = [
{
event_id: 'f7',
event_time: getPastTime(0, 1),
event_code: 'OUT_FOR_DELIVERY',
event_text: '派送员张师傅(13800138000)正在派件(事先呼我,勿找商家)',
status_code: 'OUT_FOR_DELIVERY',
evidence_urls: []
},
{
event_id: 'f6',
event_time: getPastTime(0, 4),
event_code: 'TRANSIT',
event_text: '【朝阳分部】已收入',
status_code: 'ARRIVED_HUB',
evidence_urls: []
},
{
event_id: 'f5',
event_time: getPastTime(0, 12),
event_code: 'ARRIVED',
event_text: '您的快件已经到达【北京朝阳区】' + tip,
status_code: 'IN_TRANSIT',
evidence_urls: []
},
{
event_id: 'f4',
event_time: getPastTime(1, 2),
event_code: 'DEPARTED',
event_text: '您的快件离开【顺义转运中心】,已发往【北京朝阳区】',
status_code: 'IN_TRANSIT',
evidence_urls: []
},
{
event_id: 'f3',
event_time: getPastTime(1, 10),
event_code: 'ARRIVED_HUB',
event_text: '您的快件已经到达【顺义转运中心】' + tip,
status_code: 'ARRIVED_HUB',
evidence_urls: []
},
{
event_id: 'f2',
event_time: getPastTime(1, 20),
event_code: 'PICKED',
event_text: '您的快件在【北京海淀区】已揽收,揽收人:李师傅(13911112222)' + tip,
status_code: 'ARRIVED_HUB',
evidence_urls: []
},
{
event_id: 'f1',
event_time: getPastTime(2, 1),
event_code: 'SHIPPED',
event_text: '包裹正在等待揽收',
status_code: 'SHIPPED',
evidence_urls: []
}
]
this.trackingHistory.set(order.order_no, fullProcess)
order.status = 'OUT_FOR_DELIVERY'
order.last_synced_at = this.formatDate(now)
}
runScenario(waybillNo: string, scenario: string) {
// 兼容逻辑:优先按运单号搜,搜不到按订单号搜
let order = this.orders.find(o => o.tracking_no === waybillNo)
if (!order) {
order = this.orders.find(o => o.order_no === waybillNo)
}
if (!order) return
if (scenario === 'full') {
this.generateFullProcess(order.order_no)
} else if (scenario === 'exception') {
const history = this.getMockTracking(order.order_no)
history.unshift({
event_id: 'ex_' + Date.now(),
event_time: this.formatDate(new Date()),
event_code: 'EXCEPTION',
event_text: '【包裹异常】由于天气原因,快件将在分拨中心稍作停留',
status_code: 'EXCEPTION',
evidence_urls: []
})
order.status = 'EXCEPTION'
} else {
// 默认:模拟一个新的在途节点
let history = this.getMockTracking(order.order_no)
// 如果该订单还没轨迹(比如刚发货),先初始化数组
if (!this.trackingHistory.has(order.order_no)) {
this.trackingHistory.set(order.order_no, [])
history = this.trackingHistory.get(order.order_no)!
}
const now = new Date()
history.unshift({
event_id: 'st_' + Date.now(),
event_time: this.formatDate(now),
event_code: 'TRANS_UPDATE',
event_text: '快件已到达新的中转场进行分拣,准备发往目的地',
status_code: 'IN_TRANSIT',
evidence_urls: []
})
order.last_synced_at = this.formatDate(now)
}
}
/**
* 模拟从云端同步最新轨迹(使刷新按钮生效)
*/
async syncFromCloud(orderNo: string): Promise<boolean> {
const order = this.orders.find(o => o.order_no === orderNo)
if (!order || order.status === 'DELIVERED') return false
// 模拟网络延迟
return new Promise((resolve) => {
setTimeout(() => {
this.runScenario(order.tracking_no, 'step')
resolve(true)
}, 800)
})
}
getMockWebhookLogs(): MockWebhookLog[] {
return this.webhookLogs
}
/**
* 核心功能:模拟第三方回调接口
* 模拟外部物流平台向本系统发送轨迹更新 API
*/
pushWebhookData(payload: UTSJSONObject): boolean {
const tracking_no = payload['tracking_no'] as string
const status_code = payload['status_code'] as string
const event_text = payload['event_text'] as string
const carrier = payload['carrier'] as string || '顺丰'
// 1. 记录原始日志
const now = new Date()
const log: MockWebhookLog = {
time: this.formatDate(now).split(' ')[1],
carrier: carrier,
tracking_no: tracking_no,
event_code: status_code,
success: true,
result_text: '接收成功',
payload: payload
}
this.webhookLogs.unshift(log)
// 2. 更新系统内部轨迹
const order = this.orders.find(o => o.tracking_no === tracking_no)
if (order != null) {
if (!this.trackingHistory.has(order.order_no)) {
this.trackingHistory.set(order.order_no, [])
}
const history = this.trackingHistory.get(order.order_no)!
history.unshift({
event_id: 'wb_' + Date.now(),
event_time: this.formatDate(now),
event_code: status_code,
event_text: event_text,
status_code: status_code,
evidence_urls: []
})
order.status = status_code
order.last_synced_at = this.formatDate(now)
return true
}
log.success = false
log.result_text = '未找到对应的运单号'
return false
}
private formatDate(date: Date): string {
const Y = date.getFullYear()
const M = (date.getMonth() + 1).toString().padStart(2, '0')
const D = date.getDate().toString().padStart(2, '0')
const h = date.getHours().toString().padStart(2, '0')
const m = date.getMinutes().toString().padStart(2, '0')
return `${Y}-${M}-${D} ${h}:${m}`
}
}
export const mockService = new MockService()

View File

@@ -0,0 +1,187 @@
<template>
<view class="container">
<view class="header">
<text class="title">配送/对接配置中心</text>
</view>
<!-- 环境选择 -->
<view class="card">
<view class="card-header">
<text class="card-title">运行环境</text>
<switch :checked="isTestMode" @change="toggleTestMode" color="#409eff" />
</view>
<view class="env-info">
<text class="env-tag" :class="isTestMode ? 'tag-orange' : 'tag-blue'">{{ isTestMode ? '测试 (MOCK 模式开启)' : '生产 (已连接真实承运商)' }}</text>
</view>
</view>
<!-- Mock Server 配置 -->
<view v-if="isTestMode" class="card">
<view class="card-title">MOCK 服务配置</view>
<view class="form-group">
<text class="label">Server 地址</text>
<input v-model="config.mockUrl" class="input" placeholder="http://192.168..." />
</view>
<view class="form-group">
<text class="label">Webhook 接收密钥 (Secret)</text>
<input v-model="config.webhookSecret" class="input" password placeholder="HMAC Secret" />
</view>
<view class="form-group">
<text class="label">自动推送场景</text>
<checkbox-group @change="onScenarioChange">
<label class="checkbox-item">
<checkbox value="auto" :checked="config.autoPush" /> 揽收后自动触发生命周期
</label>
</checkbox-group>
</view>
</view>
<!-- 承运商列表 -->
<view class="card">
<view class="card-header">
<text class="card-title">已接入承运商</text>
<text class="add-link">+ 新增</text>
</view>
<view v-for="(carrier, index) in carriers" :key="index" class="carrier-item">
<view class="carrier-info">
<text class="carrier-name">{{ carrier.name }} ({{ carrier.code }})</text>
<text class="carrier-meta">回调: {{ carrier.callback_status ? '在线' : '离线' }} | 轮询: {{ carrier.polling ? '开' : '关' }}</text>
</view>
<switch :checked="carrier.enabled" color="#409eff" />
</view>
</view>
<button class="save-btn" @click="saveConfig">保存配置</button>
</view>
</template>
<script uts>
import { mockService } from './mock-service.uts'
export default {
data() {
return {
isTestMode: mockService.isTestMode,
config: {
mockUrl: mockService.mockUrl,
webhookSecret: '********',
autoPush: mockService.autoPush
},
carriers: [
{ name: '韵达快递', code: 'YUNDA', enabled: true, callback_status: true, polling: true },
{ name: '圆通速递', code: 'YTO', enabled: true, callback_status: true, polling: false },
{ name: '中通快递', code: 'ZTO', enabled: false, callback_status: false, polling: false }
]
}
},
methods: {
toggleTestMode(e: any) {
const val = e.detail.value as boolean
this.isTestMode = val
mockService.isTestMode = val // 同步到 Mock 服务
},
onScenarioChange(e: any) {
const isAuto = e.detail.value.includes('auto')
this.config.autoPush = isAuto
mockService.autoPush = isAuto // 同步到 Mock 服务
},
saveConfig() {
uni.showLoading({ title: '保存中...' })
setTimeout(() => {
mockService.mockUrl = this.config.mockUrl
uni.hideLoading()
uni.showToast({ title: '全局配置已同步' })
}, 1000)
}
}
}
</script>
<style scoped>
.container {
padding: 24rpx;
background-color: #f5f7fa;
min-height: 100vh;
}
.header {
margin-bottom: 30rpx;
}
.title {
font-size: 34rpx;
font-weight: bold;
color: #303133;
}
.card {
background-color: #fff;
border-radius: 12rpx;
padding: 30rpx;
margin-bottom: 24rpx;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.card-title {
font-size: 28rpx;
font-weight: bold;
color: #606266;
}
.env-tag {
font-size: 24rpx;
padding: 6rpx 20rpx;
border-radius: 4rpx;
}
.tag-orange { background-color: #fdf6ec; color: #e6a23c; }
.tag-blue { background-color: #ecf5ff; color: #409eff; }
.form-group {
margin-bottom: 24rpx;
}
.label {
font-size: 26rpx;
color: #909399;
margin-bottom: 12rpx;
display: block;
}
.input {
border: 1rpx solid #dcdfe6;
padding: 16rpx;
border-radius: 8rpx;
font-size: 26rpx;
}
.checkbox-item {
font-size: 26rpx;
color: #606266;
display: flex;
align-items: center;
gap: 10rpx;
}
.add-link {
font-size: 24rpx;
color: #409eff;
}
.carrier-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #f2f6fc;
}
.carrier-name {
font-size: 28rpx;
color: #303133;
display: block;
}
.carrier-meta {
font-size: 22rpx;
color: #909399;
}
.save-btn {
background-color: #409eff;
color: #fff;
margin-top: 40rpx;
border-radius: 12rpx;
}
</style>

View File

@@ -0,0 +1,278 @@
<template>
<view class="container">
<view class="header">
<text class="title">运单/轨迹查询 (运维排障)</text>
</view>
<!-- 搜索栏 -->
<view class="search-section">
<input v-model="searchQuery" placeholder="请输入运单号或订单号" class="search-input" />
<button class="search-btn" @click="doSearch">查询</button>
</view>
<view v-if="waybillInfo" class="result-section">
<!-- 运单摘要 -->
<view class="card summary-card">
<view class="card-title">基本信息</view>
<view class="grid">
<view class="info-item">
<text class="label">运单号:</text>
<text class="value highlight">{{ waybillInfo.tracking_no }}</text>
</view>
<view class="info-item">
<text class="label">承运商:</text>
<text class="value">{{ waybillInfo.carrier }}</text>
</view>
<view class="info-item">
<text class="label">订单号:</text>
<text class="value">{{ waybillInfo.order_no }}</text>
</view>
<view class="info-item">
<text class="label">当前状态:</text>
<text class="value">{{ waybillInfo.status }}</text>
</view>
</view>
</view>
<!-- 轨迹列表 -->
<view class="card events-card">
<view class="card-title">轨迹列表 (内网入库)</view>
<view v-for="(event, index) in events" :key="index" class="event-row">
<view class="event-time-col">
<text class="event-time">{{ formatTime(event.event_time) }}</text>
</view>
<view class="event-main-col">
<text class="event-text">{{ event.event_text }}</text>
<view class="event-meta">
<text class="meta-tag blue">CODE: {{ event.event_code }}</text>
<text class="meta-tag orange">MAP: {{ event.status_code }}</text>
</view>
<view class="raw-box" v-if="showRaw[index]">
<text class="raw-text">{{ JSON.stringify(event.raw_payload, null, 2) }}</text>
</view>
<text class="toggle-raw" @click="toggleRaw(index)">{{ showRaw[index] ? '折叠原始报文' : '查看原始报文' }}</text>
</view>
</view>
</view>
<!-- Mock 控制板 (仅测试环境) -->
<view v-if="isTestEnv" class="card mock-card">
<view class="card-title">Mock 控制台 (测试辅助)</view>
<view class="action-grid">
<button class="mock-btn" @click="runScenario('full')">生成全流程轨迹</button>
<button class="mock-btn" @click="runScenario('exception')">注入异常节点</button>
<button class="mock-btn" @click="runScenario('standard')">步进下一节点</button>
</view>
</view>
</view>
<view v-else-if="hasSearched" class="empty-section">
<text>未找到相关运单记录</text>
</view>
</view>
</template>
<script uts>
import { mockService, MockOrder, MockTrackingEvent } from './mock-service.uts'
export default {
data() {
return {
searchQuery: 'YD987654321',
hasSearched: false,
waybillInfo: null as MockOrder | null,
events: [] as MockTrackingEvent[],
showRaw: [] as boolean[],
isTestEnv: true
}
},
methods: {
async doSearch() {
if (!this.searchQuery) return
uni.showLoading({ title: '查询中...' })
setTimeout(() => {
this.hasSearched = true
const tracking = mockService.getMockTracking(this.searchQuery)
const orders = mockService.getMockOrders()
const order = orders.find((o: MockOrder): boolean => o.tracking_no === this.searchQuery || o.order_no === this.searchQuery)
if (order != null) {
this.waybillInfo = order
this.events = tracking
} else {
this.waybillInfo = null
this.events = []
}
this.showRaw = new Array(this.events.length).fill(false)
uni.hideLoading()
}, 600)
},
formatTime(time: string) : string {
return time
},
toggleRaw(index: number) {
const current = this.showRaw[index]
this.showRaw[index] = !current
},
runScenario(type: string) {
if (this.waybillInfo == null) return
uni.showModal({
title: '确认模拟',
content: `是否对 ${this.waybillInfo!.tracking_no} 运行【${type}】场景模拟?`,
success: (res) => {
if (res.confirm) {
mockService.runScenario(this.waybillInfo!.tracking_no, type)
uni.showToast({ title: '已触发' })
setTimeout(() => this.doSearch(), 1000)
}
}
})
}
}
}
</script>
<style scoped>
.container {
padding: 24rpx;
background-color: #f0f2f5;
min-height: 100vh;
}
.header {
margin-bottom: 30rpx;
}
.title {
font-size: 34rpx;
font-weight: bold;
color: #1a1a1a;
}
.search-section {
display: flex;
gap: 20rpx;
margin-bottom: 30rpx;
}
.search-input {
flex: 1;
background-color: #fff;
border: 1rpx solid #dcdfe6;
padding: 20rpx;
border-radius: 8rpx;
font-size: 28rpx;
}
.search-btn {
background-color: #409eff;
color: #fff;
padding: 0 40rpx;
border-radius: 8rpx;
font-size: 28rpx;
}
.card {
background-color: #fff;
border-radius: 12rpx;
padding: 24rpx;
margin-bottom: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05);
}
.card-title {
font-size: 28rpx;
font-weight: bold;
color: #606266;
margin-bottom: 20rpx;
border-bottom: 1rpx solid #ebeef5;
padding-bottom: 16rpx;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20rpx;
}
.info-item {
display: flex;
font-size: 26rpx;
}
.label {
color: #909399;
margin-right: 12rpx;
}
.value {
color: #303133;
}
.value.highlight {
color: #409eff;
font-weight: bold;
}
.event-row {
display: flex;
border-bottom: 1rpx solid #f2f6fc;
padding: 20rpx 0;
}
.event-time-col {
width: 200rpx;
}
.event-time {
font-size: 24rpx;
color: #909399;
}
.event-main-col {
flex: 1;
}
.event-text {
font-size: 28rpx;
color: #303133;
margin-bottom: 10rpx;
display: block;
}
.event-meta {
display: flex;
gap: 12rpx;
margin-bottom: 12rpx;
}
.meta-tag {
font-size: 20rpx;
padding: 2rpx 10rpx;
border-radius: 4rpx;
}
.meta-tag.blue {
background-color: #ecf5ff;
color: #409eff;
}
.meta-tag.orange {
background-color: #fdf6ec;
color: #e6a23c;
}
.toggle-raw {
font-size: 22rpx;
color: #409eff;
cursor: pointer;
}
.raw-box {
background-color: #f5f7fa;
padding: 16rpx;
border-radius: 4rpx;
margin: 10rpx 0;
}
.raw-text {
font-size: 20rpx;
color: #606266;
font-family: monospace;
}
.action-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16rpx;
}
.mock-btn {
font-size: 24rpx;
padding: 16rpx 0;
background-color: #f4f4f5;
color: #909399;
border: 1rpx solid #dcdfe6;
}
.empty-section {
text-align: center;
padding: 100rpx 0;
color: #909399;
}
</style>

View File

@@ -0,0 +1,238 @@
<template>
<view class="container">
<view class="header">
<text class="title">Webhook 接收日志</text>
<text class="subtitle">监控推送到达、验签与入库状态</text>
</view>
<view class="stats-bar">
<view class="stat-box">
<text class="stat-val green">99.8%</text>
<text class="stat-lab">成功率</text>
</view>
<view class="stat-box">
<text class="stat-val red">24</text>
<text class="stat-lab">验签失败</text>
</view>
<view class="stat-box">
<text class="stat-val blue">1.2s</text>
<text class="stat-lab">平均延迟</text>
</view>
</view>
<scroll-view class="log-list" scroll-y="true">
<view v-for="(log, index) in logs" :key="index" class="log-item">
<view class="log-header">
<view class="log-status" :class="log.success ? 'bg-success' : 'bg-danger'"></view>
<text class="log-time">{{ log.time }}</text>
<text class="log-carrier">{{ log.carrier }}</text>
</view>
<view class="log-body">
<view class="log-row">
<text class="log-label">单号:</text>
<text class="log-value">{{ log.tracking_no }}</text>
</view>
<view class="log-row">
<text class="log-label">事件:</text>
<text class="log-value">{{ log.event_code }}</text>
</view>
<view class="log-row">
<text class="log-label">状态:</text>
<text class="log-value" :class="log.success ? 'text-success' : 'text-danger'">{{ log.result_text }}</text>
</view>
</view>
<view class="log-actions">
<text class="action-link" @click="viewPayload(log)">查看 Payload</text>
<text class="action-link" @click="reProcess(log)" v-if="!log.success">尝试重新入库</text>
</view>
</view>
</scroll-view>
<!-- Payload 详情抽屉 -->
<view v-if="showPayload" class="drawer-mask" @click="showPayload = false">
<view class="drawer-content" @click.stop>
<view class="drawer-header">
<text>原始报文明细</text>
<text class="close" @click="showPayload = false">×</text>
</view>
<scroll-view class="drawer-body" scroll-y="true">
<view class="payload-box">
<text class="payload-text">{{ currentPayload }}</text>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { onShow } from '@dcloudio/uni-app'
import { mockService, MockWebhookLog } from './mock-service.uts'
const logs = ref([] as MockWebhookLog[])
const showPayload = ref(false)
const currentPayload = ref('')
onShow(() => {
logs.value = mockService.getMockWebhookLogs()
})
function viewPayload(log: MockWebhookLog) {
currentPayload.value = JSON.stringify(log.payload, null, 2)
showPayload.value = true
}
function reProcess(log: MockWebhookLog) {
uni.showLoading({ title: '重送中...' })
setTimeout(() => {
uni.hideLoading()
uni.showToast({ title: '重试成功: 已重新入库', icon: 'success' })
log.success = true
log.result_text = '成功入库 (手动补录)'
}, 1000)
}
</script>
<style scoped>
.container {
padding: 24rpx;
background-color: #f8f9fa;
min-height: 100vh;
}
.header {
margin-bottom: 30rpx;
}
.title {
font-size: 36rpx;
font-weight: bold;
color: #2c3e50;
}
.subtitle {
font-size: 24rpx;
color: #7f8c8d;
}
.stats-bar {
display: flex;
gap: 20rpx;
margin-bottom: 30rpx;
}
.stat-box {
flex: 1;
background-color: #fff;
border-radius: 12rpx;
padding: 20rpx;
text-align: center;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
}
.stat-val {
font-size: 32rpx;
font-weight: bold;
display: block;
}
.stat-lab {
font-size: 22rpx;
color: #95a5a6;
}
.green { color: #27ae60; }
.red { color: #e74c3c; }
.blue { color: #3498db; }
.log-item {
background-color: #fff;
border-radius: 12rpx;
padding: 24rpx;
margin-bottom: 16rpx;
border-left: 8rpx solid transparent;
}
.bg-success { background-color: #27ae60; }
.bg-danger { background-color: #e74c3c; }
.log-header {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.log-status {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
margin-right: 16rpx;
}
.log-time {
font-size: 24rpx;
color: #7f8c8d;
margin-right: 20rpx;
}
.log-carrier {
font-size: 24rpx;
font-weight: bold;
color: #34495e;
}
.log-row {
display: flex;
margin-bottom: 8rpx;
}
.log-label {
width: 100rpx;
font-size: 24rpx;
color: #95a5a6;
}
.log-value {
flex: 1;
font-size: 24rpx;
color: #34495e;
}
.text-success { color: #27ae60; }
.text-danger { color: #e74c3c; }
.log-actions {
display: flex;
gap: 30rpx;
margin-top: 20rpx;
padding-top: 16rpx;
border-top: 1rpx solid #f1f1f1;
}
.action-link {
font-size: 24rpx;
color: #3498db;
}
.drawer-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background-color: rgba(0,0,0,0.5);
z-index: 1000;
}
.drawer-content {
position: absolute;
bottom: 0; left: 0; right: 0;
height: 70%;
background-color: #fff;
border-radius: 24rpx 24rpx 0 0;
display: flex;
flex-direction: column;
}
.drawer-header {
padding: 30rpx;
border-bottom: 1rpx solid #eee;
display: flex;
justify-content: space-between;
font-weight: bold;
}
.drawer-body {
flex: 1;
padding: 20rpx;
}
.payload-box {
background-color: #2c3e50;
padding: 20rpx;
border-radius: 8rpx;
}
.payload-text {
color: #ecf0f1;
font-size: 22rpx;
font-family: monospace;
}
</style>