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

@@ -253,6 +253,9 @@ export interface AdminOrderDetail {
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<{
@@ -266,6 +269,9 @@ export interface AdminOrderDetail {
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<{
@@ -1183,6 +1189,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[];
}
export interface AdminExpressCompanyPayload {
id?: number;
company_name: string;
@@ -2288,6 +2325,37 @@ export const adminApi = {
};
}>;
},
getExpressCompanyCatalog(params?: { keyword?: string; limit?: number }) {
return request.get("/api/admin/express-company/catalog", { params }) as Promise<{
code: number;
message: string;
data: {
list: AdminExpressCompanyCatalogItem[];
total: number;
synced_at: string;
};
}>;
},
syncExpressCompanyCatalog() {
return request.post("/api/admin/express-company/catalog/sync") as Promise<{
code: number;
message: string;
data: {
total: number;
inserted: number;
updated: number;
backfilled: number;
synced_at: string;
};
}>;
},
recognizeExpressCompany(data: { tracking_no: string; company_name?: string; company_code?: string }) {
return request.post("/api/admin/express-company/recognize", data) as Promise<{
code: number;
message: string;
data: AdminExpressCompanyRecognitionResult;
}>;
},
saveExpressCompany(data: AdminExpressCompanyPayload) {
return request.post("/api/admin/express-company/save", data) as Promise<{
code: number;

View File

@@ -1,20 +1,29 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { Refresh } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import {
adminApi,
type AdminExpressCompanyCatalogItem,
type AdminExpressCompanyItem,
type AdminExpressCompanyPayload,
} from "../../api/admin";
import OrderStatusTag from "../../components/OrderStatusTag.vue";
type CatalogSuggestion = AdminExpressCompanyCatalogItem & { value: string };
const loading = ref(false);
const submitting = ref(false);
const dialogVisible = ref(false);
const companies = ref<AdminExpressCompanyItem[]>([]);
const defaultCompany = ref("");
const catalogTotal = ref(0);
const catalogSyncedAt = ref("");
const catalogSyncing = ref(false);
const catalogLoading = ref(false);
const enabledCount = computed(() => companies.value.filter((item) => item.status === "enabled").length);
const displaySyncedAt = computed(() => (catalogSyncedAt.value && catalogSyncedAt.value !== "0" ? catalogSyncedAt.value : "-"));
const form = reactive<AdminExpressCompanyPayload>({
company_name: "",
@@ -39,6 +48,32 @@ async function fetchCompanies() {
}
}
async function fetchCatalogSummary() {
try {
const response = await adminApi.getExpressCompanyCatalog({ limit: 1 });
catalogTotal.value = response.data.total;
catalogSyncedAt.value = String(response.data.synced_at || "");
} catch (error) {
console.error(error);
}
}
async function syncCatalog() {
catalogSyncing.value = true;
try {
const response = await adminApi.syncExpressCompanyCatalog();
catalogTotal.value = response.data.total;
catalogSyncedAt.value = String(response.data.synced_at || "");
ElMessage.success(`公司码表已同步,共 ${response.data.total}`);
await fetchCompanies();
} catch (error: any) {
console.error(error);
ElMessage.error(error?.message || "快递100公司码表同步失败");
} finally {
catalogSyncing.value = false;
}
}
function openDialog(row?: AdminExpressCompanyItem) {
if (row) {
form.id = row.id;
@@ -60,6 +95,27 @@ function openDialog(row?: AdminExpressCompanyItem) {
dialogVisible.value = true;
}
async function resolveCatalogCode() {
const keyword = form.company_code.trim() || form.company_name.trim();
if (!keyword) {
return;
}
try {
const response = await adminApi.getExpressCompanyCatalog({ keyword, limit: 10 });
const exact = response.data.list.find((item) => item.company_code === keyword || item.company_name === keyword);
const candidate = exact || response.data.list[0];
if (candidate) {
form.company_code = candidate.company_code;
if (!form.company_name.trim()) {
form.company_name = candidate.company_name;
}
}
} catch (error) {
console.error(error);
}
}
async function submit() {
if (!form.company_name.trim()) {
ElMessage.warning("请填写快递公司名称");
@@ -70,6 +126,10 @@ async function submit() {
return;
}
if (!/^[a-z0-9]+$/i.test(form.company_code.trim()) || form.company_code.trim().startsWith("express_")) {
await resolveCatalogCode();
}
submitting.value = true;
try {
await adminApi.saveExpressCompany({
@@ -89,11 +149,40 @@ async function submit() {
}
}
async function queryCatalog(queryString: string, cb: (items: CatalogSuggestion[]) => void) {
catalogLoading.value = true;
try {
const response = await adminApi.getExpressCompanyCatalog({
keyword: queryString.trim(),
limit: 20,
});
const suggestions = response.data.list.map((item) => ({
...item,
value: item.display_text,
}));
cb(suggestions);
} catch (error) {
console.error(error);
cb([]);
} finally {
catalogLoading.value = false;
}
}
function handleCatalogSelect(item: CatalogSuggestion) {
form.company_code = item.company_code;
if (!form.company_name.trim()) {
form.company_name = item.company_name;
}
}
function statusTagText(row: AdminExpressCompanyItem) {
return row.is_default ? `${row.status_text} / 默认` : row.status_text;
}
onMounted(fetchCompanies);
onMounted(async () => {
await Promise.all([fetchCompanies(), fetchCatalogSummary()]);
});
</script>
<template>
@@ -109,6 +198,16 @@ onMounted(fetchCompanies);
<div class="metric-card__value">{{ enabledCount }}</div>
<div class="metric-card__desc">仓管寄回下拉列表中可选择</div>
</div>
<div class="metric-card">
<div class="metric-card__label">官方码表</div>
<div class="metric-card__value">{{ catalogTotal }}</div>
<div class="metric-card__desc">快递100 官方公司编码库</div>
</div>
<div class="metric-card">
<div class="metric-card__label">最近同步</div>
<div class="metric-card__value metric-card__value--text">{{ displaySyncedAt }}</div>
<div class="metric-card__desc">官方码表最后一次同步时间</div>
</div>
<div class="metric-card">
<div class="metric-card__label">默认快递公司</div>
<div class="metric-card__value metric-card__value--text">{{ defaultCompany || "-" }}</div>
@@ -119,16 +218,18 @@ onMounted(fetchCompanies);
<el-card class="panel-card" shadow="never">
<div class="filters-row" style="justify-content: space-between;">
<div style="color: var(--admin-text-subtle);">
维护仓管寄回时可选的快递公司停用后不会出现在寄回下拉列表中
维护仓管寄回时可选的快递公司公司码优先从快递100官方码表检索停用后不会出现在寄回下拉列表中
</div>
<el-button type="primary" @click="openDialog()">新增快递公司</el-button>
<el-button :icon="Refresh" :loading="catalogSyncing" type="primary" @click="syncCatalog">
同步快递100公司码表
</el-button>
</div>
</el-card>
<el-card class="panel-card orders-table" shadow="never">
<el-table :data="companies" stripe>
<el-table-column prop="company_name" label="快递公司" min-width="180" />
<el-table-column prop="company_code" label="编码" min-width="160" />
<el-table-column prop="company_code" label="快递100编码" min-width="160" />
<el-table-column label="状态" min-width="140">
<template #default="{ row }">
<OrderStatusTag :status="statusTagText(row)" />
@@ -150,8 +251,28 @@ onMounted(fetchCompanies);
<el-form-item label="快递公司名称">
<el-input v-model="form.company_name" maxlength="64" placeholder="例如:顺丰速运" />
</el-form-item>
<el-form-item label="编码">
<el-input v-model="form.company_code" maxlength="64" placeholder="可留空,系统自动生成" />
<el-form-item label="快递100编码">
<el-autocomplete
v-model="form.company_code"
:fetch-suggestions="queryCatalog"
:loading="catalogLoading"
clearable
placeholder="输入名称或编码搜索官方码表"
style="width: 100%"
@select="handleCatalogSelect"
>
<template #default="{ item }">
<div style="display: grid; gap: 4px;">
<div style="font-weight: 700;">{{ item.company_name }}</div>
<div style="color: var(--admin-text-subtle); font-size: 12px;">
{{ item.company_code }}{{ item.company_type ? ` / ${item.company_type}` : "" }}
</div>
</div>
</template>
</el-autocomplete>
<div style="margin-top: 6px; color: var(--admin-text-subtle); font-size: 12px;">
支持按名称或编码检索快递100官方码表选中后会自动回填公司码
</div>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
@@ -172,7 +293,7 @@ onMounted(fetchCompanies);
<el-checkbox v-model="form.is_default">设为默认快递公司</el-checkbox>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="3" maxlength="255" placeholder="内部备注,可不填" />
<el-input v-model="form.remark" :rows="3" maxlength="255" placeholder="内部备注,可不填" type="textarea" />
</el-form-item>
</el-form>
<template #footer>

View File

@@ -1,7 +1,16 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { adminApi, type AdminExpressCompanyItem, type AdminManualOrderCreatePayload, type AdminManualOrderMeta, type AdminOrderDetail, type AdminOrderListItem, type AdminOrderWarehouseOption } from "../../api/admin";
import {
adminApi,
type AdminExpressCompanyItem,
type AdminExpressCompanyRecognitionCandidate,
type AdminManualOrderCreatePayload,
type AdminManualOrderMeta,
type AdminOrderDetail,
type AdminOrderListItem,
type AdminOrderWarehouseOption,
} from "../../api/admin";
import OrderStatusTag from "../../components/OrderStatusTag.vue";
import { recognizeReturnAddress } from "../../utils/address-recognition";
@@ -19,6 +28,8 @@ const returnDialogVisible = ref(false);
const returnSubmitting = ref(false);
const returnExpressCompany = ref("");
const returnTrackingNo = ref("");
const returnRecognitionLoading = ref(false);
const returnRecognitionCandidates = ref<AdminExpressCompanyRecognitionCandidate[]>([]);
const expressCompanyLoading = ref(false);
const expressCompanyOptions = ref<AdminExpressCompanyItem[]>([]);
const defaultExpressCompany = ref("");
@@ -139,6 +150,8 @@ const expressCompanySelectOptions = computed(() => {
...expressCompanyOptions.value,
];
});
let returnRecognitionTimer: ReturnType<typeof setTimeout> | undefined;
function createManualOrderForm(): AdminManualOrderCreatePayload {
return {
service_provider: "anxinyan",
@@ -377,7 +390,53 @@ async function openReturnDialog() {
await ensureExpressCompanyOptions();
returnExpressCompany.value = detail.value.return_logistics?.express_company || defaultExpressCompany.value || expressCompanyOptions.value[0]?.company_name || "";
returnTrackingNo.value = detail.value.return_logistics?.tracking_no || "";
returnRecognitionCandidates.value = [];
returnDialogVisible.value = true;
if (returnTrackingNo.value) {
scheduleReturnRecognition();
}
}
async function recognizeReturnExpressCompany() {
const trackingNo = returnTrackingNo.value.trim();
if (!returnDialogVisible.value || !trackingNo) {
returnRecognitionCandidates.value = [];
return;
}
returnRecognitionLoading.value = true;
try {
const response = await adminApi.recognizeExpressCompany({
tracking_no: trackingNo,
company_name: returnExpressCompany.value.trim(),
});
const result = response.data;
returnRecognitionCandidates.value = result.candidates || [];
if (result.resolved) {
returnExpressCompany.value = result.resolved.company_name;
} else if (result.candidates.length === 1) {
returnExpressCompany.value = result.candidates[0].company_name;
}
} catch (error) {
console.error(error);
returnRecognitionCandidates.value = [];
} finally {
returnRecognitionLoading.value = false;
}
}
function scheduleReturnRecognition() {
if (returnRecognitionTimer) {
clearTimeout(returnRecognitionTimer);
}
returnRecognitionTimer = setTimeout(() => {
void recognizeReturnExpressCompany();
}, 500);
}
function chooseReturnRecognitionCandidate(candidate: AdminExpressCompanyRecognitionCandidate) {
returnExpressCompany.value = candidate.company_name;
returnRecognitionCandidates.value = [candidate];
}
async function submitReturnLogistics() {
@@ -409,6 +468,18 @@ async function submitReturnLogistics() {
}
}
watch(returnTrackingNo, () => {
if (returnDialogVisible.value) {
scheduleReturnRecognition();
}
});
onBeforeUnmount(() => {
if (returnRecognitionTimer) {
clearTimeout(returnRecognitionTimer);
}
});
async function markReturnReceived() {
if (!detail.value) return;
returnReceiveSubmitting.value = true;
@@ -655,10 +726,18 @@ onMounted(fetchOrders);
<div class="order-detail-item__label">物流状态</div>
<div class="order-detail-item__value">{{ logisticsActionText }}</div>
</div>
<div class="order-detail-item">
<div class="order-detail-item__label">快递100状态</div>
<div class="order-detail-item__value">{{ detail.logistics_info.provider_status_text || detail.logistics_info.sync_status_text || "-" }}</div>
</div>
<div class="order-detail-item order-detail-item--full">
<div class="order-detail-item__label">最新节点</div>
<div class="order-detail-item__value">{{ detail.logistics_info.latest_desc || "-" }}</div>
</div>
<div class="order-detail-item order-detail-item--full" v-if="detail.logistics_info.sync_error">
<div class="order-detail-item__label">同步异常</div>
<div class="order-detail-item__value">{{ detail.logistics_info.sync_error }}</div>
</div>
<div class="order-detail-item order-detail-item--full" v-if="detail.logistics_info.latest_time">
<div class="order-detail-item__label">最新更新时间</div>
<div class="order-detail-item__value">{{ detail.logistics_info.latest_time }}</div>
@@ -680,10 +759,18 @@ onMounted(fetchOrders);
<div class="order-detail-item__label">物流状态</div>
<div class="order-detail-item__value">{{ detail.return_logistics.tracking_status_text }}</div>
</div>
<div class="order-detail-item">
<div class="order-detail-item__label">快递100状态</div>
<div class="order-detail-item__value">{{ detail.return_logistics.provider_status_text || detail.return_logistics.sync_status_text || "-" }}</div>
</div>
<div class="order-detail-item order-detail-item--full">
<div class="order-detail-item__label">最新节点</div>
<div class="order-detail-item__value">{{ detail.return_logistics.latest_desc || "-" }}</div>
</div>
<div class="order-detail-item order-detail-item--full" v-if="detail.return_logistics.sync_error">
<div class="order-detail-item__label">同步异常</div>
<div class="order-detail-item__value">{{ detail.return_logistics.sync_error }}</div>
</div>
<div class="order-detail-item order-detail-item--full" v-if="detail.return_logistics.latest_time">
<div class="order-detail-item__label">最新更新时间</div>
<div class="order-detail-item__value">{{ detail.return_logistics.latest_time }}</div>
@@ -738,6 +825,18 @@ onMounted(fetchOrders);
</div>
</div>
<div class="detail-card" v-if="detail.return_logistics" style="grid-column: 1 / -1">
<div class="detail-card__title">回寄物流轨迹</div>
<div v-if="detail.return_logistics.nodes.length" class="timeline-list" style="margin-top: 14px">
<div v-for="item in detail.return_logistics.nodes" :key="`${item.node_time}-${item.node_desc}`" class="timeline-node">
<div class="timeline-node__title">{{ item.node_desc }}</div>
<div class="timeline-node__time">{{ item.node_time }}</div>
<div class="timeline-node__desc">{{ item.node_location || "-" }}</div>
</div>
</div>
<el-empty v-else description="暂无回寄物流轨迹" :image-size="64" />
</div>
<div class="detail-card" v-if="detail.supplement_task" style="grid-column: 1 / -1">
<div class="detail-card__title">补图任务</div>
<div class="detail-card__desc">
@@ -810,6 +909,20 @@ onMounted(fetchOrders);
<el-form-item label="回寄运单号">
<el-input v-model="returnTrackingNo" placeholder="请输入回寄运单号" />
</el-form-item>
<div v-if="returnRecognitionLoading" style="margin: -8px 0 12px; color: var(--admin-text-subtle); font-size: 12px;">
正在识别快递公司...
</div>
<div v-if="returnRecognitionCandidates.length" style="display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px;">
<el-tag
v-for="candidate in returnRecognitionCandidates"
:key="`${candidate.company_code}-${candidate.company_name}`"
effect="plain"
style="cursor: pointer;"
@click="chooseReturnRecognitionCandidate(candidate)"
>
{{ candidate.company_name }}
</el-tag>
</div>
<el-alert
v-if="detail?.return_address"
type="info"

View File

@@ -10,7 +10,7 @@ const uploadingKey = ref("");
const groups = ref<AdminSystemConfigGroupItem[]>([]);
const groupSnapshots = ref<Record<string, Record<string, string>>>({});
const groupOrder = ["file_storage", "mini_program", "h5", "payment", "sms"];
const groupOrder = ["file_storage", "mini_program", "h5", "payment", "sms", "kuaidi100"];
function cloneSnapshot(groupsList: AdminSystemConfigGroupItem[]) {
return Object.fromEntries(