数据库分析和对应不同角色页面
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# 配送端逻辑说明(概要与建议)
|
||||
# 配送端逻辑说明(概要与建议)(舍弃)
|
||||
|
||||
**概览**
|
||||
- 本文档总结 `pages/mall/delivery` 目录下配送端主要页面的工作流与实现细节,定位到关键数据库表与状态机,列出已识别的并发/同步风险,并给出可执行的改进建议。
|
||||
@@ -3,12 +3,14 @@
|
||||
本目录聚焦“模拟三通一达后台(Mock 承运方 Server)”的联调与测试需求,用于在未签约真实承运方前验证平台侧的 Webhook 接收、验签、幂等、入库与前端时间线展示。
|
||||
|
||||
## 文档分层(先看什么、后看什么)
|
||||
- 需求(做什么):`配送模块需求文档.md`
|
||||
- 页面(后台长什么样):`后台页面设计说明.md`
|
||||
- 接口(怎么对接):`接口规范.md`
|
||||
- 映射(状态怎么统一):`状态映射表.md`
|
||||
- 前端契约(怎么展示):`前端字段清单.md`
|
||||
- 补齐清单(还缺什么):`缺口与待补充清单.md`
|
||||
- 需求(做什么):[配送模块需求文档.md](配送模块需求文档.md)
|
||||
- 页面(后台长什么样):[后台页面设计说明.md](后台页面设计说明.md)
|
||||
- 接口(怎么对接):[接口规范.md](接口规范.md)
|
||||
- 映射(状态怎么统一):[状态映射表.md](状态映射表.md)
|
||||
- 前端契约(怎么展示):[前端字段清单.md](前端字段清单.md)
|
||||
- 补齐清单(还缺什么):[缺口与待补充清单.md](缺口与待补充清单.md)
|
||||
- 数据库(旧表 vs 新表):[数据库对比与修改建议.md](数据库对比与修改建议.md)
|
||||
- 数据库(生产表字段说明):[生产表说明_platform_express.md](生产表说明_platform_express.md)
|
||||
|
||||
## 先读这个:这套文档在解决什么
|
||||
你现在要做的是一个“像三通一达一样会产生物流节点并回调的平台外部系统”。真实承运方接口通常需要签约与联调周期,因此先通过 Mock 承运方 Server 把平台侧能力跑通:
|
||||
@@ -27,14 +29,14 @@
|
||||
- 故障注入必须:重复推送、乱序、延迟、签名错误、缺字段等是外部对接最常见问题,必须在联调阶段覆盖。
|
||||
|
||||
## 阅读顺序(推荐)
|
||||
1) 先看 `配送模块需求文档.md`:理解 Mock Server 要做什么、有哪些验收与故障注入。
|
||||
2) 再看 `后台页面设计说明.md`:明确平台后台/商家后台需要哪些页面与最小展示/权限要求。
|
||||
3) 再看 `接口规范.md`:
|
||||
1) 先看 [配送模块需求文档.md](配送模块需求文档.md):理解 Mock Server 要做什么、有哪些验收与故障注入。
|
||||
2) 再看 [后台页面设计说明.md](后台页面设计说明.md):明确平台后台/商家后台需要哪些页面与最小展示/权限要求。
|
||||
3) 再看 [接口规范.md](接口规范.md):
|
||||
- Mock Server -> 平台:Webhook 推送格式与验签
|
||||
- 平台/测试工具 -> Mock Server:控制面 API(创建运单/追加事件/跑场景/触发推送)
|
||||
4) 看 `状态映射表.md`:明确 `event_code` 如何映射到平台统一 `status_code`。
|
||||
5) 看 `前端字段清单.md`:前端时间线组件到底需要哪些字段(以及 `source` 如何标注)。
|
||||
6) 最后看 `缺口与待补充清单.md`:把联调验收所需材料补齐,避免漏项。
|
||||
4) 看 [状态映射表.md](状态映射表.md):明确 `event_code` 如何映射到平台统一 `status_code`。
|
||||
5) 看 [前端字段清单.md](前端字段清单.md):前端时间线组件到底需要哪些字段(以及 `source` 如何标注)。
|
||||
6) 最后看 [缺口与待补充清单.md](缺口与待补充清单.md):把联调验收所需材料补齐,避免漏项。
|
||||
|
||||
## 快速上手(联调路径)
|
||||
1) 平台准备一个 Webhook 接收地址:`/webhook/express/status`,并实现 `X-Signature` 验签与幂等去重(按 `event_id`)。
|
||||
@@ -48,12 +50,19 @@
|
||||
- 为什么要状态映射?因为不同承运方文案与事件码不稳定,统一状态才能做稳定 UI 与统计。
|
||||
|
||||
文件列表:
|
||||
- 配送模块需求文档.md:Mock 承运方 Server 的目标、范围、流程与验收。
|
||||
- 接口规范.md:Mock Server 控制面 API + Mock -> 平台 Webhook 推送规范。
|
||||
- 状态映射表.md:Mock 事件码到平台统一状态映射建议。
|
||||
- 前端字段清单.md:时间线组件所需字段契约与展示规则。
|
||||
- [配送模块需求文档.md](配送模块需求文档.md):Mock 承运方 Server 的目标、范围、流程与验收。
|
||||
- [后台页面设计说明.md](后台页面设计说明.md):后台页面结构、权限与最小交互。
|
||||
- [接口规范.md](接口规范.md):Mock Server 控制面 API + Mock -> 平台 Webhook 推送规范。
|
||||
- [状态映射表.md](状态映射表.md):Mock 事件码到平台统一状态映射建议。
|
||||
- [前端字段清单.md](前端字段清单.md):时间线组件所需字段契约与展示规则。
|
||||
- [缺口与待补充清单.md](缺口与待补充清单.md):联调/验收需要补齐的资料与决策项。
|
||||
- [数据库对比与修改建议.md](数据库对比与修改建议.md):旧自营骑手表 vs 第三方快递表的改库建议。
|
||||
- [生产表说明_platform_express.md](生产表说明_platform_express.md):生产环境 `platform_express_*` 三表用途、字段、约束/索引与写入查询建议。
|
||||
|
||||
数据库:
|
||||
- `mall_sql/schemas/express_tracking_mock_platform.sql`:本体系建议的 Postgres 建表脚本(平台统一入库 + Mock 持久化)。
|
||||
- 生产环境(推荐执行):[express_tracking_platform_upgrade.sql](express_tracking_platform_upgrade.sql)
|
||||
- 仅包含平台侧 `platform_express_*` 三张表,供生产代码使用。
|
||||
- 联调/设计参考(不要用于生产直接落库):[express_tracking_mock_platform.sql](express_tracking_mock_platform.sql)
|
||||
- 包含平台侧表 + `mock_*` 测试表(故障注入/回放用),仅用于联调与文档说明。
|
||||
|
||||
建议下一步:在平台侧实现一个可切换的“Mock 数据源”开关(仅测试环境),并在 QA 用例中覆盖重复/乱序/验签失败等注入场景。
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
-- 物流轨迹(第三方/Mock)数据库设计(PostgreSQL / Supabase 兼容)
|
||||
-- 重要说明(生产环境请勿直接执行本文件):
|
||||
-- - 本文件包含平台侧表 + mock_* 测试表(用于联调、故障注入与回放)。
|
||||
-- - 生产环境请执行迁移脚本:mall_sql/migrations/express_tracking_platform_upgrade.sql
|
||||
-- 该脚本仅创建平台侧 platform_express_* 三张表,并与主库 UUID/updated_at 规范对齐。
|
||||
-- 目标:
|
||||
-- 1) 平台侧:统一接收不同第三方事件 -> 幂等去重/乱序入库 -> 给前端提供稳定时间线
|
||||
-- 2) Mock 承运方侧:可持久化运单/事件/场景/推送日志,支持故障注入与回放
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
-- =====================================================================================
|
||||
-- 第三方快递轨迹(平台侧)表结构升级 (PostgreSQL + Supabase)
|
||||
-- 用途:
|
||||
-- - 引入第三方承运方运单与轨迹事件的统一入库模型
|
||||
-- - 支撑商家端/用户端/平台后台的同源轨迹展示
|
||||
-- 说明:
|
||||
-- - 仅创建 platform 侧三张表:platform_express_waybills / platform_express_tracking_events / platform_express_event_raw
|
||||
-- - 不包含 mock_* 测试表
|
||||
-- - 不涉及自营骑手表(ml_delivery_*)
|
||||
-- =====================================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 依赖扩展(项目主库已使用 uuid-ossp;这里重复声明是安全的)
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "btree_gin";
|
||||
|
||||
-- updated_at 维护函数:若主库已存在则不重复创建
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regprocedure('public.update_updated_at_column()') IS NULL THEN
|
||||
CREATE OR REPLACE FUNCTION public.update_updated_at_column()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =====================================================================
|
||||
-- A. 平台侧(platform):统一轨迹模型入库与查询
|
||||
-- =====================================================================
|
||||
|
||||
-- 运单主表:按 (carrier, tracking_no) 唯一
|
||||
CREATE TABLE IF NOT EXISTS public.platform_express_waybills (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- 关联订单(推荐关联主键;order_no 作为展示/兜底)
|
||||
order_id UUID NULL REFERENCES public.ml_orders(id) ON DELETE SET NULL,
|
||||
order_no VARCHAR(64) NULL,
|
||||
|
||||
carrier VARCHAR(32) NOT NULL, -- YUNDA/YTO/ZTO/STO/KDN...
|
||||
tracking_no VARCHAR(64) NOT NULL,
|
||||
source VARCHAR(16) NOT NULL DEFAULT 'mock', -- mock/carrier/aggregator
|
||||
|
||||
current_status_code VARCHAR(32) NOT NULL DEFAULT 'PENDING',
|
||||
current_status_text TEXT NULL,
|
||||
|
||||
eta TIMESTAMP WITH TIME ZONE NULL,
|
||||
last_synced_at TIMESTAMP WITH TIME ZONE NULL,
|
||||
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT uk_platform_express_waybill UNIQUE (carrier, tracking_no)
|
||||
);
|
||||
|
||||
-- updated_at 触发器
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_trigger
|
||||
WHERE tgname = 'trigger_platform_express_waybills_updated_at'
|
||||
) THEN
|
||||
CREATE TRIGGER trigger_platform_express_waybills_updated_at
|
||||
BEFORE UPDATE ON public.platform_express_waybills
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 索引(按订单查运单是主查询路径)
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_express_waybills_order_id
|
||||
ON public.platform_express_waybills(order_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_express_waybills_order_no
|
||||
ON public.platform_express_waybills(order_no);
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_express_waybills_tracking_no
|
||||
ON public.platform_express_waybills(tracking_no);
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_express_waybills_status
|
||||
ON public.platform_express_waybills(current_status_code);
|
||||
|
||||
|
||||
-- 轨迹事件表:前端时间线/告警/统计的主数据来源
|
||||
CREATE TABLE IF NOT EXISTS public.platform_express_tracking_events (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
waybill_id UUID NOT NULL REFERENCES public.platform_express_waybills(id) ON DELETE CASCADE,
|
||||
carrier VARCHAR(32) NOT NULL,
|
||||
tracking_no VARCHAR(64) NOT NULL,
|
||||
|
||||
-- 第三方事件唯一标识(可能缺失)
|
||||
event_id VARCHAR(128) NULL,
|
||||
|
||||
event_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
event_code VARCHAR(64) NOT NULL,
|
||||
event_text TEXT NOT NULL,
|
||||
|
||||
-- 平台统一状态
|
||||
status_code VARCHAR(32) NOT NULL,
|
||||
|
||||
node_name VARCHAR(128) NULL,
|
||||
location TEXT NULL,
|
||||
description TEXT NULL,
|
||||
|
||||
evidence_urls JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
|
||||
-- 原始回文(用于审计/排障)
|
||||
raw_payload JSONB NULL,
|
||||
|
||||
-- 接收侧信息
|
||||
received_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
source VARCHAR(16) NOT NULL DEFAULT 'webhook', -- webhook/poll/manual
|
||||
|
||||
-- 幂等去重键:优先 event_id;缺失时用 tracking_no+event_code+event_time(+可选字段) 构造
|
||||
dedupe_key VARCHAR(256) NOT NULL,
|
||||
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT uk_platform_express_event_dedupe UNIQUE (waybill_id, dedupe_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_express_events_waybill_time
|
||||
ON public.platform_express_tracking_events(waybill_id, event_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_express_events_tracking_time
|
||||
ON public.platform_express_tracking_events(tracking_no, event_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_express_events_status
|
||||
ON public.platform_express_tracking_events(status_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_express_events_received_at
|
||||
ON public.platform_express_tracking_events(received_at);
|
||||
CREATE INDEX IF NOT EXISTS gin_platform_express_events_raw_payload
|
||||
ON public.platform_express_tracking_events USING gin (raw_payload);
|
||||
|
||||
|
||||
-- 原始接收表:记录每一次 webhook/轮询原文、验签结果与解析错误
|
||||
CREATE TABLE IF NOT EXISTS public.platform_express_event_raw (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
received_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
source VARCHAR(16) NOT NULL DEFAULT 'webhook',
|
||||
|
||||
-- 请求侧标识
|
||||
client_id VARCHAR(64) NULL,
|
||||
carrier VARCHAR(32) NULL,
|
||||
tracking_no VARCHAR(64) NULL,
|
||||
|
||||
-- 安全审计
|
||||
signature_valid BOOLEAN NULL,
|
||||
signature TEXT NULL,
|
||||
ts_header TEXT NULL,
|
||||
|
||||
request_id VARCHAR(64) NULL,
|
||||
remote_ip INET NULL,
|
||||
headers JSONB NULL,
|
||||
|
||||
body JSONB NULL,
|
||||
parse_error TEXT NULL,
|
||||
|
||||
-- 去重辅助(可选):用于识别完全重复的原始请求
|
||||
dedupe_key VARCHAR(256) NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_express_raw_received_at
|
||||
ON public.platform_express_event_raw(received_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_express_raw_tracking_no
|
||||
ON public.platform_express_event_raw(tracking_no);
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_express_raw_signature_valid
|
||||
ON public.platform_express_event_raw(signature_valid);
|
||||
CREATE INDEX IF NOT EXISTS gin_platform_express_raw_body
|
||||
ON public.platform_express_event_raw USING gin (body);
|
||||
|
||||
COMMIT;
|
||||
@@ -45,6 +45,17 @@
|
||||
- 手机号必须脱敏(如 138****8000),完整手机号仅在客服/运维界面显示并记录访问审计。
|
||||
- 证据 URL 应带鉴权或短期有效,前端需支持预览与下载。
|
||||
|
||||
4.1 同源数据,不同端展示颗粒度
|
||||
- 同一运单的轨迹事件(`event_code/event_text/status_code/node_name/location`)必须同源,保证事实与状态口径一致;不同端仅做“展示层过滤/脱敏”,不做口径改写。
|
||||
- 商家端(B 端):只需展示大致进度(到达某地/中转/派送/签收/异常)与最新节点摘要;不展示配送员/快递员手机号等个人敏感信息。
|
||||
- 消费者端(C 端):可展示更详细的节点文案;若提供“联系配送”能力,建议用按钮/虚拟号/中转号承载,而不是把第三方 `event_text` 中的手机号原文直接展示或通过消息推送透传。
|
||||
|
||||
4.2 `event_text` 使用建议(避免直接透传敏感信息)
|
||||
- `event_text` 属于第三方原文,可能包含手机号、网点内部信息等;建议前端展示使用“清洗后的文案”。
|
||||
- 最小实现:
|
||||
- 商家端优先展示 `status_code + node_name/location` 组合出的摘要;必要时展示清洗后的 `event_text`。
|
||||
- 消费者端可展示清洗后的 `event_text`;仅在 `OUT_FOR_DELIVERY` 等末端节点按需提供脱敏联系方式。
|
||||
|
||||
5. 安卓端适配要点
|
||||
- 缓存:将最近一次 `status_history` 缓存在本地(仅该用户的订单范围),离线可展示并提示“数据可能不是最新”。
|
||||
- 图片:POD/证据图片支持大图预览与失败重试;弱网下优先加载文本节点。
|
||||
|
||||
196
pages/mall/delivery/doc/需求文档/数据库对比与修改建议.md
Normal file
196
pages/mall/delivery/doc/需求文档/数据库对比与修改建议.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# 配送端(自营骑手)表 vs 第三方快递轨迹表:对比与数据库修改建议
|
||||
|
||||
本文目标:
|
||||
- 整理“原先配送端(自营骑手/同城配送)”使用的核心数据表。
|
||||
- 对比“现在第三方快递配送(韵达/圆通等)”的轨迹/运单数据表。
|
||||
- 给出建议的数据库改造方案:如何在不破坏订单主表的前提下,引入第三方快递轨迹能力,并(可选)逐步弃用原先配送端表的依赖。
|
||||
|
||||
明确决策(当前结论):
|
||||
- 仅支持第三方快递轨迹(承运方运单 + 轨迹时间线)。
|
||||
- 不做自营骑手端/同城配送任务流;`ml_delivery_drivers`/`ml_delivery_tasks` 视为历史遗留表,不再作为新能力的依赖与写入目标。
|
||||
|
||||
> 说明:本对比只讨论“第三方快递轨迹/运单”与“自营骑手任务”两套模型如何在数据库层共存/迁移;二者是不同业务域,不建议强行复用同一张表。
|
||||
|
||||
---
|
||||
|
||||
## 1. 原先配送端(自营骑手)核心表(现状)
|
||||
|
||||
数据来源:主库 DDL(`mall_sql/schemas/complete_mall_database.sql`)与旧配送端文档(`pages/mall/delivery/doc/old/*`)。
|
||||
|
||||
### 1.1 `ml_delivery_drivers`(配送员信息表)
|
||||
用途:记录配送员(骑手)档案与实时工作状态;用于配送端登录后的“是否可接单/是否在线”。
|
||||
|
||||
关键字段(节选):
|
||||
- `id`:配送员 ID
|
||||
- `user_id`:关联 `ak_users.id`
|
||||
- `real_name`、`id_card`、`driver_license`
|
||||
- `vehicle_type`、`vehicle_number`
|
||||
- `service_areas`(JSONB)
|
||||
- `work_status`:1 在线 / 2 忙碌 / 3 离线
|
||||
- `current_lat` / `current_lng`
|
||||
- `rating_avg` / `rating_count` / `order_count`
|
||||
- `status`:1 正常 / 2 暂停 / 3 离职
|
||||
|
||||
### 1.2 `ml_delivery_tasks`(配送任务表,配送端“状态真源”)
|
||||
用途:描述“一个订单被分配给某个骑手并经历接单→取货→配送→送达”的任务流。
|
||||
|
||||
关键字段(节选):
|
||||
- 关联:
|
||||
- `order_id`(UUID,**唯一**,FK 到 `ml_orders.id`)
|
||||
- `driver_id`(FK 到 `ml_delivery_drivers.id`,可为空表示未接单)
|
||||
- 地址与费用:
|
||||
- `pickup_address`(JSONB)取货地址
|
||||
- `delivery_address`(JSONB)配送地址
|
||||
- `distance`、`estimated_time`、`delivery_fee`
|
||||
- 任务状态:`status`(int)
|
||||
- 1 待接单
|
||||
- 2 已接单
|
||||
- 3 取货中
|
||||
- 4 配送中
|
||||
- 5 已送达
|
||||
- 6 配送失败
|
||||
- 时间戳:`assigned_at` / `picked_at` / `delivered_at`
|
||||
- 其他:`delivery_code`(取货码)、`remark`、`failure_reason`
|
||||
|
||||
> 注意:旧页面/旧文档里常出现 `accepted_at`、`pickup_time`、`delivered_time` 等命名,和主库 DDL 的 `assigned_at/picked_at/delivered_at` 存在差异;如果你要继续使用自营配送链路,建议在代码层做统一字段映射或做一次字段对齐迁移。
|
||||
|
||||
### 1.3 与订单表的关系(旧模型的关键耦合点)
|
||||
- `ml_delivery_tasks.order_id` 强依赖 `ml_orders.id`,并且 **1 个订单只能有 1 条配送任务**(`order_id UNIQUE`)。
|
||||
- 旧实现里常尝试把 `ml_delivery_tasks.status` 同步到 `ml_orders.order_status`,容易造成口径不一致(旧文档也提到了这种冲突风险)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 现在第三方快递配送(轨迹/运单)表(目标模型)
|
||||
|
||||
数据来源:`pages/mall/delivery/doc/需求文档/express_tracking_mock_platform.sql`。
|
||||
|
||||
> 这套表的定位不是“骑手任务流”,而是“第三方承运的运单 + 轨迹事件时间线”。
|
||||
|
||||
### 2.1 `platform_express_waybills`(平台侧:运单主表)
|
||||
用途:一条运单(一个 `tracking_no`)的聚合信息;用于订单详情页展示“承运方/运单号/当前状态/最后同步时间”。
|
||||
|
||||
关键字段(节选):
|
||||
- 关联:`order_id`(可选)、`order_no`(可选)
|
||||
- 运单:`carrier`、`tracking_no`、`source`
|
||||
- 聚合状态:`current_status_code`、`current_status_text`
|
||||
- 时间:`eta`、`last_synced_at`
|
||||
- 约束:`UNIQUE (carrier, tracking_no)`
|
||||
|
||||
特点:
|
||||
- 同一个订单允许对应多条运单(拆包裹/分批发货)——因为 `order_id` 不唯一。
|
||||
|
||||
### 2.2 `platform_express_tracking_events`(平台侧:轨迹事件表)
|
||||
用途:时间线的主数据来源;用于前端展示、告警统计与排障。
|
||||
|
||||
关键字段(节选):
|
||||
- 关联:`waybill_id`(FK 到 `platform_express_waybills.id`)
|
||||
- 事件:`event_id`(第三方可能缺失)、`event_time`、`event_code`、`event_text`
|
||||
- 平台统一状态:`status_code`
|
||||
- 节点:`node_name`、`location`、`description`
|
||||
- 证据:`evidence_urls`(jsonb)
|
||||
- 原始回文:`raw_payload`(jsonb,可用于审计/排障)
|
||||
- 幂等:`dedupe_key` + `UNIQUE (waybill_id, dedupe_key)`
|
||||
|
||||
### 2.3 `platform_express_event_raw`(平台侧:原始接收表)
|
||||
用途:记录 webhook/轮询的原始请求、验签与解析错误,便于排障与审计。
|
||||
|
||||
### 2.4 `mock_*`(Mock 承运方侧表)
|
||||
这部分只用于联调/回归/故障注入,不建议进入生产主库的核心业务 schema(可以放在独立 schema 或测试库)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 两套模型的核心差异(为什么不建议“复用一张表”)
|
||||
|
||||
| 维度 | 自营骑手(旧配送端) | 第三方快递(新模型) |
|
||||
|---|---|---|
|
||||
| 业务对象 | 任务(骑手接单、取货、送达) | 运单(承运方扫描轨迹) |
|
||||
| 与订单关系 | 1 订单 = 1 任务(`order_id UNIQUE`) | 1 订单 = N 运单(拆包裹常见) |
|
||||
| 状态来源 | 平台自己驱动状态机(按钮/操作) | 第三方事件驱动(webhook/轮询) |
|
||||
| 数据颗粒度 | 少量节点(接单/取货/送达) | 高频节点(到站/发车/分拣/派送/签收…) |
|
||||
| 关键字段 | `driver_id`、地址 JSON、配送费、距离 | `carrier`、`tracking_no`、事件时间线、幂等去重 |
|
||||
| 风险点 | 并发抢单、任务状态与订单状态不同步 | 乱序/重复事件、验签、防重放、脱敏 |
|
||||
|
||||
结论:
|
||||
- 旧表(`ml_delivery_*`)适合“自营骑手/同城配送”。
|
||||
- 新表(`platform_express_*`)适合“第三方快递轨迹”。
|
||||
- 两者可以共存,但不要强行把第三方轨迹塞进 `ml_delivery_tasks`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 推荐的数据库修改方案(兼容、可渐进)
|
||||
|
||||
### 4.1 方案 1(推荐):新增第三方快递表,不改旧配送表
|
||||
适用:你们当前要做第三方快递展示,且已明确“不做自营骑手”。
|
||||
|
||||
做法:
|
||||
1) 在主库新增(或通过 migration 引入)三张平台侧表:
|
||||
- `platform_express_waybills`
|
||||
- `platform_express_tracking_events`
|
||||
- `platform_express_event_raw`
|
||||
2) 商家发货时写入/绑定:创建 `platform_express_waybills` 行(填 `order_id`、`order_no`、`carrier`、`tracking_no`)。
|
||||
3) 第三方回调/轮询入库:写 `platform_express_tracking_events`,并更新 `platform_express_waybills.current_status_*` 与 `last_synced_at`。
|
||||
|
||||
建议的小幅增强(强烈建议做):
|
||||
- 给 `platform_express_waybills(order_id)` 增加索引(便于按订单查运单)。
|
||||
- 让 `platform_express_waybills.order_id` 建外键到 `ml_orders(id)`(如果你们能保证订单一定存在)。
|
||||
|
||||
### 4.2 方案 2:逐步“弃用旧配送端表”的依赖(当你们全面第三方快递时)
|
||||
适用:你们不再提供自营骑手配送,或者想把骑手端做成独立项目。
|
||||
|
||||
做法(建议按阶段,不要硬删表):
|
||||
- 阶段 A:停止业务写入 `ml_delivery_tasks`(页面/接口不再创建任务)。
|
||||
- 阶段 B:订单详情页的物流展示只依赖 `platform_express_*`。
|
||||
- 阶段 C:将 `ml_delivery_tasks` 标为 legacy(只读保留一段时间),最终再评估是否归档/删除。
|
||||
|
||||
为什么不建议立刻删:
|
||||
- 历史数据、结算对账、纠纷复盘可能仍需要旧数据。
|
||||
|
||||
在“不做自营骑手”的前提下,最小要求:
|
||||
- 新增第三方快递表后,业务代码只写入/读取 `platform_express_*`,不要再创建/更新 `ml_delivery_tasks`。
|
||||
- 旧表保留只读(或仅用于历史查询/清理脚本),后续再评估归档策略。
|
||||
|
||||
### 4.3 如果两种配送方式要并存(同城骑手 + 快递)
|
||||
建议用“履约类型”做分流,而不是混表:
|
||||
- 同城/自营:继续用 `ml_delivery_tasks`(任务流)
|
||||
- 快递:用 `platform_express_*`(运单轨迹)
|
||||
- 订单详情页按订单的履约类型选择展示模块(或同时展示多个包裹)
|
||||
|
||||
---
|
||||
|
||||
## 5. 迁移/改库执行清单(建议写成 migration)
|
||||
|
||||
### 5.1 建表落库
|
||||
- 把 `express_tracking_mock_platform.sql` 的 A 部分(platform)整理为一份 migration(放到 `mall_sql/migrations/`),避免:
|
||||
- 重复创建 `set_updated_at()`(项目若已有同名函数)
|
||||
- 把 `mock_*` 测试表混进生产 schema
|
||||
|
||||
### 5.2 对接 `ml_orders`
|
||||
- 建议:
|
||||
- `platform_express_waybills.order_id REFERENCES public.ml_orders(id)`
|
||||
- `CREATE INDEX ... ON platform_express_waybills(order_id)`
|
||||
- 不建议:
|
||||
- 把运单号/承运方直接塞进 `ml_orders`(会导致一单多包裹很难处理)
|
||||
|
||||
### 5.3 数据回填(可选)
|
||||
如果你们历史上已经保存过“承运方/运单号”在别处(例如订单扩展表/发货日志),可做一次性回填:
|
||||
- 按订单生成 waybill 行
|
||||
- 再按运单触发一次轨迹拉取
|
||||
|
||||
### 5.4 权限与隐私
|
||||
- 买家/商家/客服看到的轨迹必须“同源”,但展示要按角色脱敏;敏感字段(如手机号、原始回文)按文档约束控制。
|
||||
|
||||
---
|
||||
|
||||
## 6. 你接下来要怎么改数据库(最短路线)
|
||||
|
||||
如果你现在的目标是:商家可选承运方 + 录入运单号 + 用户能看轨迹时间线。
|
||||
|
||||
最短路线:
|
||||
1) 在主库落 `platform_express_waybills` / `platform_express_tracking_events` / `platform_express_event_raw`
|
||||
2) 发货绑定时写 `platform_express_waybills(order_id, order_no, carrier, tracking_no)`
|
||||
3) 第三方事件入库写 `platform_express_tracking_events`
|
||||
4) 订单详情页按 `order_id` 查 waybills,再查 events 渲染时间线
|
||||
|
||||
---
|
||||
|
||||
(如你确认要我进一步把“建议的小幅增强”直接写成 SQL migration 文件,我可以在 `mall_sql/migrations/` 里新增一份只包含 platform 表的 migration,并确保不引入 `mock_*` 表。)
|
||||
148
pages/mall/delivery/doc/需求文档/生产表说明_platform_express.md
Normal file
148
pages/mall/delivery/doc/需求文档/生产表说明_platform_express.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# 生产表说明:platform_express_*(第三方快递轨迹平台侧)
|
||||
|
||||
本文档对应生产迁移脚本:`pages/mall/delivery/doc/需求文档/express_tracking_platform_upgrade.sql`。
|
||||
|
||||
## 1. 设计目标与边界
|
||||
|
||||
- 目标:为第三方承运(韵达/圆通/中通等)提供统一的“运单 + 轨迹事件 + 原始回调留痕”入库模型,支撑用户端/商家端/平台后台同源展示。
|
||||
- 边界:
|
||||
- 仅覆盖第三方快递轨迹,不包含自营骑手/配送任务(`ml_delivery_*`)。
|
||||
- 生产仅使用 `platform_express_*` 三表;不包含任何 `mock_*` 测试表。
|
||||
|
||||
## 2. 表关系概览
|
||||
|
||||
- `platform_express_waybills`(运单主表)
|
||||
- 1 条运单对应 N 条轨迹事件(`platform_express_tracking_events`)。
|
||||
- `platform_express_tracking_events`(轨迹事件事实表)
|
||||
- 时间线展示、状态判断、告警统计的主来源。
|
||||
- 对同一运单使用 `(waybill_id, dedupe_key)` 做幂等去重。
|
||||
- `platform_express_event_raw`(原始接收留痕表)
|
||||
- 记录每一次 webhook/轮询/人工补录的原始请求/响应内容与验签结果、解析错误,用于审计与排障。
|
||||
|
||||
## 3. 字段说明
|
||||
|
||||
### 3.1 `public.platform_express_waybills`(运单主表)
|
||||
|
||||
用途:
|
||||
- 以 `(carrier, tracking_no)` 为唯一键管理“运单实体”,可选关联订单;承载“当前状态/预计到达/最近同步时间”等运单级摘要信息。
|
||||
|
||||
约束/触发器/索引:
|
||||
- 唯一约束:`uk_platform_express_waybill (carrier, tracking_no)`
|
||||
- 外键:`order_id -> public.ml_orders(id)`,`ON DELETE SET NULL`
|
||||
- `updated_at` 触发器:`trigger_platform_express_waybills_updated_at`(UPDATE 时自动刷新)
|
||||
- 常用索引:
|
||||
- `idx_platform_express_waybills_order_id (order_id)`
|
||||
- `idx_platform_express_waybills_order_no (order_no)`
|
||||
- `idx_platform_express_waybills_tracking_no (tracking_no)`
|
||||
- `idx_platform_express_waybills_status (current_status_code)`
|
||||
|
||||
字段表:
|
||||
|
||||
| 字段 | 类型 | 可空 | 默认/约束 | 含义 |
|
||||
|---|---|---:|---|---|
|
||||
| id | UUID | 否 | PK,`uuid_generate_v4()` | 运单主键 |
|
||||
| order_id | UUID | 是 | FK -> `ml_orders(id)`,删除置空 | 关联订单主键(推荐) |
|
||||
| order_no | VARCHAR(64) | 是 | | 订单号(展示/兜底,用于无法拿到 `order_id` 的场景) |
|
||||
| carrier | VARCHAR(32) | 否 | 参与唯一约束 | 承运方编码(如 `YUNDA`/`YTO`/`ZTO` 等;也可接入聚合方编码) |
|
||||
| tracking_no | VARCHAR(64) | 否 | 参与唯一约束 | 运单号/快递单号 |
|
||||
| source | VARCHAR(16) | 否 | 默认 `'mock'` | 运单数据来源:`mock`/`carrier`/`aggregator` 等 |
|
||||
| current_status_code | VARCHAR(32) | 否 | 默认 `'PENDING'` | 运单当前平台状态(用于列表页/摘要),通常由最新事件映射得到 |
|
||||
| current_status_text | TEXT | 是 | | 运单当前状态文本(可为最新事件文本的“清洗版”或平台自定义) |
|
||||
| eta | TIMESTAMPTZ | 是 | | 预计送达时间(可选;来自承运方/聚合方或平台预测) |
|
||||
| last_synced_at | TIMESTAMPTZ | 是 | | 最近一次与承运方/聚合方同步时间(用于健康度与补偿轮询判断) |
|
||||
| created_at | TIMESTAMPTZ | 否 | 默认 `NOW()` | 创建时间 |
|
||||
| updated_at | TIMESTAMPTZ | 否 | 默认 `NOW()`;触发器维护 | 更新时间(UPDATE 自动刷新) |
|
||||
|
||||
|
||||
### 3.2 `public.platform_express_tracking_events`(轨迹事件表)
|
||||
|
||||
用途:
|
||||
- 存储每一个轨迹节点(揽收/到站/运输中/派送/签收/异常等),作为时间线展示与状态机判断的事实来源。
|
||||
- 保留第三方原文(`event_code`/`event_text`/`raw_payload`),同时写入平台统一状态(`status_code`)。
|
||||
- 通过 `dedupe_key` 实现幂等:重复回调/轮询不会重复插入。
|
||||
|
||||
约束/索引:
|
||||
- 外键:`waybill_id -> platform_express_waybills(id)`,`ON DELETE CASCADE`
|
||||
- 唯一约束:`uk_platform_express_event_dedupe (waybill_id, dedupe_key)`
|
||||
- 常用索引:
|
||||
- `idx_platform_express_events_waybill_time (waybill_id, event_time)`(按运单查时间线)
|
||||
- `idx_platform_express_events_tracking_time (tracking_no, event_time)`(按运单号查)
|
||||
- `idx_platform_express_events_status (status_code)`(按状态统计/筛选)
|
||||
- `idx_platform_express_events_received_at (received_at)`(按接收时间排障)
|
||||
- `gin_platform_express_events_raw_payload`(`raw_payload` JSONB GIN,便于排障检索)
|
||||
|
||||
字段表:
|
||||
|
||||
| 字段 | 类型 | 可空 | 默认/约束 | 含义 |
|
||||
|---|---|---:|---|---|
|
||||
| id | UUID | 否 | PK,`uuid_generate_v4()` | 事件主键 |
|
||||
| waybill_id | UUID | 否 | FK,级联删除 | 所属运单 |
|
||||
| carrier | VARCHAR(32) | 否 | | 承运方编码(冗余字段,便于查询/对账) |
|
||||
| tracking_no | VARCHAR(64) | 否 | | 运单号(冗余字段,便于查询/对账) |
|
||||
| event_id | VARCHAR(128) | 是 | | 第三方事件唯一 ID(有些承运方会提供;优先用于幂等) |
|
||||
| event_time | TIMESTAMPTZ | 否 | | 事件发生时间(时间线排序的主依据) |
|
||||
| event_code | VARCHAR(64) | 否 | | 第三方事件码/节点码(保留原值) |
|
||||
| event_text | TEXT | 否 | | 第三方事件文本(保留原值;展示前可做脱敏/清洗) |
|
||||
| status_code | VARCHAR(32) | 否 | | 平台统一状态码(由 `event_code/event_text` 规则映射得出) |
|
||||
| node_name | VARCHAR(128) | 是 | | 节点/网点名称(如“xx转运中心”) |
|
||||
| location | TEXT | 是 | | 地理位置文本(如省市区/网点地址;不建议存精确经纬度,除非明确需求) |
|
||||
| description | TEXT | 是 | | 平台补充说明(如“疑似退回件,已通知商家”) |
|
||||
| evidence_urls | JSONB | 否 | 默认 `[]` | 证据/附件 URL 列表(签收图、面单、异常凭证等) |
|
||||
| raw_payload | JSONB | 是 | | 第三方原始事件载荷(用于审计/排障;注意权限控制) |
|
||||
| received_at | TIMESTAMPTZ | 否 | 默认 `NOW()` | 平台接收时间(用于排查延迟/乱序) |
|
||||
| source | VARCHAR(16) | 否 | 默认 `'webhook'` | 数据进入平台的方式:`webhook`/`poll`/`manual` |
|
||||
| dedupe_key | VARCHAR(256) | 否 | 参与唯一约束 | 幂等去重键(建议:有 `event_id` 用 `event_id`;否则构造 `tracking_no|event_code|event_time` 并按需要拼接 `node/location`) |
|
||||
| created_at | TIMESTAMPTZ | 否 | 默认 `NOW()` | 入库时间 |
|
||||
|
||||
幂等与乱序建议:
|
||||
- 幂等:插入事件时以 `(waybill_id, dedupe_key)` 唯一约束兜底;遇到冲突可 `ON CONFLICT DO NOTHING`。
|
||||
- 乱序:允许旧事件晚到入库;前端展示按 `event_time` 排序。
|
||||
- 运单摘要(`platform_express_waybills.current_status_*`)建议由“最新事件(按 event_time)映射结果”更新,避免用 `received_at` 作为最新依据。
|
||||
|
||||
|
||||
### 3.3 `public.platform_express_event_raw`(原始接收留痕表)
|
||||
|
||||
用途:
|
||||
- 记录每一次 webhook 回调或轮询结果的“原始内容 + 安全审计 + 解析结果”。
|
||||
- 用于:验签问题定位、第三方字段变更回溯、解析失败重放、争议审计。
|
||||
|
||||
索引:
|
||||
- `idx_platform_express_raw_received_at (received_at)`
|
||||
- `idx_platform_express_raw_tracking_no (tracking_no)`
|
||||
- `idx_platform_express_raw_signature_valid (signature_valid)`
|
||||
- `gin_platform_express_raw_body`(`body` JSONB GIN,便于按字段排障检索)
|
||||
|
||||
字段表:
|
||||
|
||||
| 字段 | 类型 | 可空 | 默认/约束 | 含义 |
|
||||
|---|---|---:|---|---|
|
||||
| id | UUID | 否 | PK,`uuid_generate_v4()` | 原始接收记录主键 |
|
||||
| received_at | TIMESTAMPTZ | 否 | 默认 `NOW()` | 平台收到请求/响应的时间 |
|
||||
| source | VARCHAR(16) | 否 | 默认 `'webhook'` | 来源:`webhook`/`poll`/`manual` |
|
||||
| client_id | VARCHAR(64) | 是 | | 调用方/渠道标识(如回调应用 ID、聚合方商户号等) |
|
||||
| carrier | VARCHAR(32) | 是 | | 承运方编码(可从路由/请求体解析;可能为空) |
|
||||
| tracking_no | VARCHAR(64) | 是 | | 运单号(可能为空:如批量回调或解析失败) |
|
||||
| signature_valid | BOOLEAN | 是 | | 验签是否通过(解析失败或未验签可为空) |
|
||||
| signature | TEXT | 是 | | 签名原文(如 header 签名) |
|
||||
| ts_header | TEXT | 是 | | 时间戳 header(用于防重放;字段名取决于对接规范) |
|
||||
| request_id | VARCHAR(64) | 是 | | 请求追踪 ID(平台生成或对方传入) |
|
||||
| remote_ip | INET | 是 | | 来源 IP(用于安全审计/黑白名单) |
|
||||
| headers | JSONB | 是 | | 请求头(建议按需过滤敏感头再落库) |
|
||||
| body | JSONB | 是 | | 原始请求体/响应体(用于回溯与重放) |
|
||||
| parse_error | TEXT | 是 | | 解析错误信息(成功解析则为空) |
|
||||
| dedupe_key | VARCHAR(256) | 是 | | 原始请求去重辅助键(可选:如 `hash(headers+body)`) |
|
||||
|
||||
权限与合规建议(实现侧):
|
||||
- `raw_payload`、`platform_express_event_raw.body/headers` 可能包含敏感信息(手机号、地址、签名等),建议仅后台/运维具备读取权限,并记录审计日志。
|
||||
|
||||
## 4. 常见查询与写入路径(建议)
|
||||
|
||||
- 发货/绑定运单:
|
||||
- Upsert `platform_express_waybills`(按 `(carrier, tracking_no)`);有订单关联时写入 `order_id/order_no`。
|
||||
- 接收回调/轮询:
|
||||
- 先插入 `platform_express_event_raw` 做留痕(无论解析成功与否)。
|
||||
- 解析出事件后写入 `platform_express_tracking_events`;用 `dedupe_key` 做幂等。
|
||||
- 视需要更新 `platform_express_waybills.current_status_*`、`last_synced_at`。
|
||||
- 订单详情页展示:
|
||||
- 通过 `order_id` 查 `platform_express_waybills`。
|
||||
- 再按 `waybill_id` 查 `platform_express_tracking_events`,按 `event_time` 升序组装时间线。
|
||||
@@ -20,12 +20,18 @@
|
||||
|
||||
多端归属与页面边界(统一口径):
|
||||
- 本需求产出的“物流能力”不是一个独立的骑手/配送员端 App,而是作为同一套物流模块能力,分别嵌入到三个端(按权限展示不同颗粒度):
|
||||
1) 消费者端(C 端):订单详情的物流时间线/物流详情(只读为主,展示 `event_text` + `status_code`,不展示 `raw_payload`)。
|
||||
2) 商家端(B 端):发货与运单绑定(选择承运方、录入/回填 `tracking_no`)+ 查看物流(不包含对接配置与敏感调试信息)。
|
||||
1) 消费者端(C 端):订单详情的物流时间线/物流详情(只读为主,展示 `status_code` + 轨迹文案;可在“派送中”等末端环节提供“联系配送”能力,但涉及手机号等敏感信息需脱敏/虚拟号策略,且仅对收件人可见;不展示 `raw_payload`)。
|
||||
2) 商家端(B 端):发货与运单绑定(选择承运方、录入/回填 `tracking_no`)+ 查看物流概览(以“到达某地/中转/派送/签收/异常”等大致进度为主;不展示配送员手机号等个人敏感信息;不包含对接配置与敏感调试信息)。
|
||||
3) 管理端(平台后台:客服/履约/对接运维):订单/运单查询、Webhook 接收日志、接入配置、(可选)监控告警与审计能力。
|
||||
- 三端展示字段口径以统一事件模型与前端字段契约为准(见 `接口规范.md`、`前端字段清单.md`),避免出现“不同端展示不一致/状态口径不一致”。
|
||||
- 若未来要做“自营配送/同城骑手端”(接单、导航、到店取货、送达签收等),需单独立项与另写需求文档,不在本需求范围内。
|
||||
|
||||
敏感信息展示差异(必须遵循):
|
||||
- 轨迹事件以同一份“事实数据源”入库与治理(`event_code/event_text/status_code` 等同源),但不同端的展示需按角色过滤与脱敏。
|
||||
- 配送员/快递员手机号:
|
||||
- 商家端:不展示。
|
||||
- 消费者端:仅在末端环节(如 `OUT_FOR_DELIVERY`)按需提供,并必须脱敏(如 138****8000)或采用虚拟号/中转号方案;避免把第三方 `event_text` 原文中的手机号明文直接透传到推送消息或页面。
|
||||
|
||||
## 2. 目标
|
||||
- 适用阶段:主要用于真实第三方承运方未签约/未联调/不可控时的替代数据源;即便后续已接入真实承运方,也保留用于回归测试、故障注入与排障复现(生产环境默认关闭)。
|
||||
- 提供一个可配置的 Mock 承运方服务,模拟多承运方(圆通/韵达/中通/申通)轨迹事件与签收凭证。
|
||||
@@ -233,7 +239,7 @@
|
||||
端侧体验要求(安卓/Web):
|
||||
- 刷新策略:页面进入时拉取一次平台侧轨迹;用户可手动“刷新物流”;显示 `last_synced_at`。
|
||||
- 离线降级:无网络时展示上次缓存的时间线并提示“网络不可用”。
|
||||
- 权限与隐私:手机号脱敏;`raw_payload` 仅客服/运维可见并记录审计;不展示配送员精确地址/定位。
|
||||
- 权限与隐私:手机号脱敏;配送员/快递员手机号仅对收件人可见且需脱敏/虚拟号策略(商家端不展示);`raw_payload` 仅客服/运维可见并记录审计;不展示配送员精确地址/定位。
|
||||
|
||||
## 9. 验收标准
|
||||
- 能创建运单并运行“标准签收”场景,向平台推送至少 6 个关键节点(揽收/到达中转/在途/到达目的地/派送中/签收)。
|
||||
|
||||
291
pages/mall/delivery/test/api-simulator.uvue
Normal file
291
pages/mall/delivery/test/api-simulator.uvue
Normal 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>
|
||||
494
pages/mall/delivery/test/consumer-logistics-detail.uvue
Normal file
494
pages/mall/delivery/test/consumer-logistics-detail.uvue
Normal 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>
|
||||
225
pages/mall/delivery/test/consumer-order-list.uvue
Normal file
225
pages/mall/delivery/test/consumer-order-list.uvue
Normal 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>
|
||||
122
pages/mall/delivery/test/index.uvue
Normal file
122
pages/mall/delivery/test/index.uvue
Normal 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>
|
||||
269
pages/mall/delivery/test/merchant-order-detail.uvue
Normal file
269
pages/mall/delivery/test/merchant-order-detail.uvue
Normal 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>
|
||||
327
pages/mall/delivery/test/merchant-order-list.uvue
Normal file
327
pages/mall/delivery/test/merchant-order-list.uvue
Normal 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>
|
||||
496
pages/mall/delivery/test/mock-service.uts
Normal file
496
pages/mall/delivery/test/mock-service.uts
Normal 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()
|
||||
187
pages/mall/delivery/test/platform-config-center.uvue
Normal file
187
pages/mall/delivery/test/platform-config-center.uvue
Normal 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>
|
||||
278
pages/mall/delivery/test/platform-tracking-query.uvue
Normal file
278
pages/mall/delivery/test/platform-tracking-query.uvue
Normal 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>
|
||||
238
pages/mall/delivery/test/platform-webhook-logs.uvue
Normal file
238
pages/mall/delivery/test/platform-webhook-logs.uvue
Normal 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>
|
||||
Reference in New Issue
Block a user