feat: add kuaidi100 logistics sync

This commit is contained in:
wushumin
2026-05-26 17:08:33 +08:00
parent 09d9fcbe69
commit a5f00d7e31
31 changed files with 2596 additions and 67 deletions

View File

@@ -46,6 +46,37 @@ export interface AdminExpressCompanyItem {
updated_at: string;
}
export interface AdminExpressCompanyCatalogItem {
id: number;
company_name: string;
company_code: string;
company_type: string;
display_text: string;
source: string;
synced_at: string;
}
export interface AdminExpressCompanyRecognitionCandidate {
company_name: string;
company_code: string;
official_name?: string;
display_text: string;
length_pre?: number;
source: string;
}
export interface AdminExpressCompanyRecognitionResult {
input: string;
tracking_no: string;
company_code: string;
company_name: string;
status: string;
status_text: string;
error_message?: string;
resolved: null | AdminExpressCompanyRecognitionCandidate;
candidates: AdminExpressCompanyRecognitionCandidate[];
}
function filenameFromPath(filePath: string) {
return filePath.split(/[\\/]/).pop() || `upload-${Date.now()}`;
}
@@ -236,6 +267,17 @@ export interface AdminWarehouseWorkbenchContext {
express_company: string;
tracking_no: string;
tracking_status: string;
tracking_status_text: string;
provider_status_text: string;
sync_status_text: string;
sync_error: string;
latest_desc: string;
latest_time: string;
nodes: Array<{
node_time: string;
node_desc: string;
node_location: string;
}>;
};
return_address: null | {
consignee: string;
@@ -246,6 +288,17 @@ export interface AdminWarehouseWorkbenchContext {
express_company: string;
tracking_no: string;
tracking_status: string;
tracking_status_text: string;
provider_status_text: string;
sync_status_text: string;
sync_error: string;
latest_desc: string;
latest_time: string;
nodes: Array<{
node_time: string;
node_desc: string;
node_location: string;
}>;
};
transfer_flow: null | {
id?: number;
@@ -491,6 +544,23 @@ export const adminApi = {
getExpressCompanies(params: { enabled_only?: 0 | 1 } = { enabled_only: 1 }) {
return request<{ list: AdminExpressCompanyItem[]; default_company: string }>("/api/admin/express-companies", { params });
},
getExpressCompanyCatalog(params: { keyword?: string; limit?: number } = {}) {
return request<{ list: AdminExpressCompanyCatalogItem[]; total: number; synced_at: string }>("/api/admin/express-company/catalog", {
params,
});
},
syncExpressCompanyCatalog() {
return request<{ total: number; inserted: number; updated: number; backfilled: number; synced_at: string }>(
"/api/admin/express-company/catalog/sync",
{ method: "POST" },
);
},
recognizeExpressCompany(data: { tracking_no: string; company_name?: string; company_code?: string }) {
return request<AdminExpressCompanyRecognitionResult>("/api/admin/express-company/recognize", {
method: "POST",
data,
});
},
createManualOrder(data: AdminManualOrderCreatePayload) {
return request<AdminManualOrderCreateResponse>("/api/admin/manual-order/create", {
method: "POST",

View File

@@ -188,6 +188,10 @@ onShow(() => {
<view class="meta-label">寄送到中心</view>
<view class="meta-value">{{ detail.logistics_info ? `${detail.logistics_info.express_company || "-"} / ${detail.logistics_info.tracking_no || "-"}` : "-" }}</view>
</view>
<view v-if="detail.logistics_info" class="meta-item">
<view class="meta-label">寄送状态</view>
<view class="meta-value">{{ detail.logistics_info.provider_status_text || detail.logistics_info.sync_status_text || detail.logistics_info.tracking_status_text || "-" }}</view>
</view>
<view class="meta-item">
<view class="meta-label">寄回地址</view>
<view class="meta-value">{{ displayAddress(detail.return_address) }}</view>
@@ -196,6 +200,24 @@ onShow(() => {
<view class="meta-label">回寄运单</view>
<view class="meta-value">{{ detail.return_logistics ? `${detail.return_logistics.express_company || "-"} / ${detail.return_logistics.tracking_no || "-"}` : "-" }}</view>
</view>
<view v-if="detail.return_logistics" class="meta-item">
<view class="meta-label">回寄状态</view>
<view class="meta-value">{{ detail.return_logistics.provider_status_text || detail.return_logistics.sync_status_text || detail.return_logistics.tracking_status_text || "-" }}</view>
</view>
</view>
<view v-if="detail.logistics_info?.nodes?.length" class="logistics-timeline">
<view class="card-desc">寄送轨迹</view>
<view v-for="item in detail.logistics_info.nodes" :key="`send-${item.node_time}-${item.node_desc}`" class="logistics-timeline__item">
<view class="logistics-timeline__title">{{ item.node_desc }}</view>
<view class="logistics-timeline__meta">{{ item.node_time }}{{ item.node_location ? ` / ${item.node_location}` : "" }}</view>
</view>
</view>
<view v-if="detail.return_logistics?.nodes?.length" class="logistics-timeline">
<view class="card-desc">回寄轨迹</view>
<view v-for="item in detail.return_logistics.nodes" :key="`return-${item.node_time}-${item.node_desc}`" class="logistics-timeline__item">
<view class="logistics-timeline__title">{{ item.node_desc }}</view>
<view class="logistics-timeline__meta">{{ item.node_time }}{{ item.node_location ? ` / ${item.node_location}` : "" }}</view>
</view>
</view>
</view>
@@ -445,6 +467,31 @@ onShow(() => {
font-weight: 800;
}
.logistics-timeline {
margin-top: 22rpx;
padding-top: 20rpx;
border-top: 1px solid var(--work-border);
}
.logistics-timeline__item {
padding: 16rpx 0 16rpx 22rpx;
border-left: 4rpx solid var(--work-primary);
}
.logistics-timeline__title {
color: var(--work-text);
font-size: 24rpx;
font-weight: 800;
line-height: 1.45;
}
.logistics-timeline__meta {
margin-top: 6rpx;
color: var(--work-text-soft);
font-size: 22rpx;
line-height: 1.5;
}
.attachment-play {
position: absolute;
left: 50%;

View File

@@ -1,7 +1,13 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { adminApi, type AdminExpressCompanyItem, type AdminFileAsset, type AdminWarehouseWorkbenchContext } from "../../api/admin";
import {
adminApi,
type AdminExpressCompanyItem,
type AdminExpressCompanyRecognitionCandidate,
type AdminFileAsset,
type AdminWarehouseWorkbenchContext,
} from "../../api/admin";
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
const internalTagNo = ref("");
@@ -10,6 +16,8 @@ const trackingNo = ref("");
const expressCompanyOptions = ref<AdminExpressCompanyItem[]>([]);
const expressCompanyLoading = ref(false);
const defaultExpressCompany = ref("");
const recognitionLoading = ref(false);
const recognitionCandidates = ref<AdminExpressCompanyRecognitionCandidate[]>([]);
const context = ref<AdminWarehouseWorkbenchContext | null>(null);
const loading = ref(false);
const submitting = ref(false);
@@ -34,6 +42,7 @@ const canSubmit = computed(() =>
!uploading.value &&
!submitting.value,
);
let recognitionTimer: ReturnType<typeof setTimeout> | undefined;
function readQueryString(value: unknown) {
const raw = Array.isArray(value) ? value[0] : value;
@@ -85,6 +94,47 @@ function onExpressCompanyChange(event: any) {
expressCompany.value = expressCompanyNames.value[index] || "";
}
async function recognizeExpressCompany() {
const trackingValue = trackingNo.value.trim();
if (!trackingValue) {
recognitionCandidates.value = [];
return;
}
recognitionLoading.value = true;
try {
const result = await adminApi.recognizeExpressCompany({
tracking_no: trackingValue,
company_name: expressCompany.value.trim(),
});
recognitionCandidates.value = result.candidates || [];
if (result.resolved) {
expressCompany.value = result.resolved.company_name;
} else if (result.candidates.length === 1) {
expressCompany.value = result.candidates[0].company_name;
}
} catch (error) {
console.error(error);
recognitionCandidates.value = [];
} finally {
recognitionLoading.value = false;
}
}
function scheduleRecognition() {
if (recognitionTimer) {
clearTimeout(recognitionTimer);
}
recognitionTimer = setTimeout(() => {
void recognizeExpressCompany();
}, 500);
}
function chooseRecognitionCandidate(candidate: AdminExpressCompanyRecognitionCandidate) {
expressCompany.value = candidate.company_name;
recognitionCandidates.value = [candidate];
}
function scanTrackingNo() {
uni.scanCode({
scanType: ["barCode", "qrCode"],
@@ -224,6 +274,16 @@ onLoad((options) => {
void fetchExpressCompanies();
void fetchContext();
});
watch(trackingNo, () => {
scheduleRecognition();
});
onBeforeUnmount(() => {
if (recognitionTimer) {
clearTimeout(recognitionTimer);
}
});
</script>
<template>
@@ -281,6 +341,17 @@ onLoad((options) => {
<text class="picker-field__arrow"></text>
</view>
</picker>
<view v-if="recognitionLoading" class="recognition-tip">正在识别快递公司...</view>
<view v-if="recognitionCandidates.length" class="chip-list recognition-chip-list">
<text
v-for="candidate in recognitionCandidates"
:key="`${candidate.company_code}-${candidate.company_name}`"
class="choice-chip"
@click="chooseRecognitionCandidate(candidate)"
>
{{ candidate.company_name }}
</text>
</view>
<view class="scan-control">
<input v-model="trackingNo" class="field scan-input" placeholder="扫描或输入回寄运单号" />
<button class="btn scan-button" @click="scanTrackingNo">扫码</button>
@@ -382,6 +453,40 @@ onLoad((options) => {
margin-top: 14rpx;
}
.recognition-tip {
margin-top: 14rpx;
color: var(--work-text-soft);
font-size: 22rpx;
line-height: 1.45;
}
.recognition-chip-list {
margin-top: 14rpx;
}
.chip-list {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.choice-chip {
display: inline-flex;
align-items: center;
min-height: 44rpx;
padding: 0 18rpx;
border-radius: var(--work-radius-pill);
background: var(--work-card-muted);
color: var(--work-text);
font-size: 22rpx;
line-height: 44rpx;
border: 1px solid transparent;
}
.choice-chip:active {
opacity: 0.88;
}
.scan-input {
flex: 1;
min-width: 0;