chore: release updated anxinyan version

This commit is contained in:
wushumin
2026-05-22 21:13:52 +08:00
parent 7e86e2a5ec
commit 78098851f9
29 changed files with 1949 additions and 184 deletions

View File

@@ -499,6 +499,7 @@ export interface AdminReportListItem {
zhongjian_report_no: string;
report_entry_admin_name: string;
report_entered_at: string;
trace_info_visible: boolean;
product_name: string;
category_name: string;
brand_name: string;
@@ -525,6 +526,7 @@ export interface AdminReportDetail {
report_entry_admin_id: number;
report_entry_admin_name: string;
report_entered_at: string;
trace_info_visible: boolean;
};
product_info: Record<string, any>;
result_info: Record<string, any>;
@@ -1168,6 +1170,29 @@ export interface AdminWarehousePayload {
remark: string;
}
export interface AdminExpressCompanyItem {
id: number;
company_name: string;
company_code: string;
status: string;
status_text: string;
is_default: boolean;
sort_order: number;
remark: string;
created_at: string;
updated_at: string;
}
export interface AdminExpressCompanyPayload {
id?: number;
company_name: string;
company_code: string;
status: string;
is_default: boolean;
sort_order: number;
remark: string;
}
export interface AdminMaterialTagCode {
id: number;
batch_id: number;
@@ -1752,6 +1777,16 @@ export const adminApi = {
data: AdminPublishReportResponse & { material_tag?: AdminMaterialTagCode | null };
}>;
},
updateReportTraceVisibility(id: number, visible: boolean) {
return request.post("/api/admin/report/trace-visibility", {
id,
trace_info_visible: visible,
}) as Promise<{
code: number;
message: string;
data: { id: number; trace_info_visible: boolean };
}>;
},
saveInspectionReport(data: AdminManualInspectionPayload) {
return request.post("/api/admin/report/inspection/save", data) as Promise<{
code: number;
@@ -2242,6 +2277,23 @@ export const adminApi = {
data: { id: number };
}>;
},
getExpressCompanies(params?: { enabled_only?: 0 | 1 }) {
return request.get("/api/admin/express-companies", { params }) as Promise<{
code: number;
message: string;
data: {
list: AdminExpressCompanyItem[];
default_company: string;
};
}>;
},
saveExpressCompany(data: AdminExpressCompanyPayload) {
return request.post("/api/admin/express-company/save", data) as Promise<{
code: number;
message: string;
data: { id: number };
}>;
},
getMaterialBatches(params?: Record<string, string>) {
return request.get("/api/admin/material/batches", { params }) as Promise<{
code: number;

View File

@@ -28,6 +28,7 @@ const menus = [
{ index: "users", label: "用户管理", icon: User, permission: "users.manage" },
{ index: "customers", label: "客户管理", icon: Connection, permission: "customers.manage" },
{ index: "warehouses", label: "仓库中心", icon: OfficeBuilding, permission: "warehouses.manage" },
{ index: "express-companies", label: "快递公司", icon: Van, permission: "warehouses.manage" },
{ index: "materials", label: "物料管理", icon: Box, permission: "materials.manage" },
{ index: "access", label: "权限中心", icon: Lock, permission: "access.manage" },
{ index: "content", label: "内容中心", icon: DocumentChecked, permission: "system.manage" },

View File

@@ -745,7 +745,7 @@ function normalizedKeyPoints() {
point_code: item.point_code,
point_name: item.point_name,
point_value: item.point_value.trim(),
point_remark: item.point_remark.trim(),
point_remark: "",
}));
}
@@ -1577,7 +1577,7 @@ onMounted(async () => {
<div class="task-form-block">
<div class="task-form-block__title">鉴定模板填写</div>
<div class="task-panel__desc">
选择品类后自动加载对应模板按关键项逐项填写检查结论和备注
选择品类后自动加载对应模板按关键项逐项填写检查结
</div>
<div v-loading="appraisalTemplateLoading">
<div v-if="resultKeyPoints.length" class="task-key-point-list">
@@ -1619,10 +1619,6 @@ onMounted(async () => {
/>
<el-input v-else v-model="item.point_value" :disabled="isTaskReadonly" placeholder="请输入检查结果" />
</div>
<div class="task-form-field task-form-field--full">
<div class="task-form-field__label">备注</div>
<el-input v-model="item.point_remark" :disabled="isTaskReadonly" type="textarea" :rows="2" placeholder="可补充细节、依据或风险点" />
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,193 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import {
adminApi,
type AdminExpressCompanyItem,
type AdminExpressCompanyPayload,
} from "../../api/admin";
import OrderStatusTag from "../../components/OrderStatusTag.vue";
const loading = ref(false);
const submitting = ref(false);
const dialogVisible = ref(false);
const companies = ref<AdminExpressCompanyItem[]>([]);
const defaultCompany = ref("");
const enabledCount = computed(() => companies.value.filter((item) => item.status === "enabled").length);
const form = reactive<AdminExpressCompanyPayload>({
company_name: "",
company_code: "",
status: "enabled",
is_default: false,
sort_order: 0,
remark: "",
});
async function fetchCompanies() {
loading.value = true;
try {
const response = await adminApi.getExpressCompanies();
companies.value = response.data.list;
defaultCompany.value = response.data.default_company;
} catch (error) {
console.error(error);
ElMessage.error("快递公司加载失败");
} finally {
loading.value = false;
}
}
function openDialog(row?: AdminExpressCompanyItem) {
if (row) {
form.id = row.id;
form.company_name = row.company_name;
form.company_code = row.company_code;
form.status = row.status;
form.is_default = row.is_default;
form.sort_order = row.sort_order;
form.remark = row.remark;
} else {
form.id = undefined;
form.company_name = "";
form.company_code = "";
form.status = "enabled";
form.is_default = companies.value.length === 0;
form.sort_order = companies.value.length + 1;
form.remark = "";
}
dialogVisible.value = true;
}
async function submit() {
if (!form.company_name.trim()) {
ElMessage.warning("请填写快递公司名称");
return;
}
if (form.is_default && form.status !== "enabled") {
ElMessage.warning("默认快递公司必须保持启用");
return;
}
submitting.value = true;
try {
await adminApi.saveExpressCompany({
...form,
company_name: form.company_name.trim(),
company_code: form.company_code.trim(),
remark: form.remark.trim(),
});
ElMessage.success(form.id ? "快递公司已更新" : "快递公司已创建");
dialogVisible.value = false;
await fetchCompanies();
} catch (error: any) {
console.error(error);
ElMessage.error(error?.message || "快递公司保存失败");
} finally {
submitting.value = false;
}
}
function statusTagText(row: AdminExpressCompanyItem) {
return row.is_default ? `${row.status_text} / 默认` : row.status_text;
}
onMounted(fetchCompanies);
</script>
<template>
<div v-loading="loading">
<div class="metric-grid" style="margin-bottom: 18px">
<div class="metric-card">
<div class="metric-card__label">快递公司总数</div>
<div class="metric-card__value">{{ companies.length }}</div>
<div class="metric-card__desc">当前已维护的可选快递公司</div>
</div>
<div class="metric-card">
<div class="metric-card__label">启用中</div>
<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 metric-card__value--text">{{ defaultCompany || "-" }}</div>
<div class="metric-card__desc">新登记寄回运单时默认选中</div>
</div>
</div>
<el-card class="panel-card" shadow="never">
<div class="filters-row" style="justify-content: space-between;">
<div style="color: var(--admin-text-subtle);">
维护仓管寄回时可选的快递公司停用后不会出现在寄回下拉列表中
</div>
<el-button type="primary" @click="openDialog()">新增快递公司</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 label="状态" min-width="140">
<template #default="{ row }">
<OrderStatusTag :status="statusTagText(row)" />
</template>
</el-table-column>
<el-table-column prop="sort_order" label="排序" min-width="90" />
<el-table-column prop="remark" label="备注" min-width="240" />
<el-table-column prop="updated_at" label="更新时间" min-width="170" />
<el-table-column label="操作" fixed="right" width="100">
<template #default="{ row }">
<el-button link type="primary" @click="openDialog(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑快递公司' : '新增快递公司'" width="560px">
<el-form label-position="top">
<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>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="状态">
<el-select v-model="form.status" style="width: 100%">
<el-option label="启用" value="enabled" />
<el-option label="停用" value="disabled" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序值">
<el-input v-model.number="form.sort_order" type="number" placeholder="越小越靠前" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="默认设置">
<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-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.metric-card__value--text {
overflow: hidden;
font-size: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { adminApi, type AdminManualOrderCreatePayload, type AdminManualOrderMeta, type AdminOrderDetail, type AdminOrderListItem, type AdminOrderWarehouseOption } from "../../api/admin";
import { adminApi, type AdminExpressCompanyItem, 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";
const loading = ref(false);
const detailLoading = ref(false);
@@ -18,11 +19,15 @@ const returnDialogVisible = ref(false);
const returnSubmitting = ref(false);
const returnExpressCompany = ref("");
const returnTrackingNo = ref("");
const expressCompanyLoading = ref(false);
const expressCompanyOptions = ref<AdminExpressCompanyItem[]>([]);
const defaultExpressCompany = ref("");
const manualDialogVisible = ref(false);
const manualSubmitting = ref(false);
const manualMetaLoading = ref(false);
const manualMeta = ref<AdminManualOrderMeta>({ categories: [], brands: [] });
const manualForm = ref<AdminManualOrderCreatePayload>(createManualOrderForm());
const manualAddressRecognitionText = ref("");
const keyword = ref("");
const serviceProvider = ref("");
@@ -113,6 +118,27 @@ const logisticsActionText = computed(() => {
const canSubmitReturnLogistics = computed(() => Boolean(detail.value?.order_info.can_submit_return_logistics));
const returnLogisticsBlockReason = computed(() => detail.value?.order_info.return_logistics_block_reason || "");
const canMarkReturnReceived = computed(() => Boolean(detail.value?.order_info.can_mark_return_received));
const expressCompanySelectOptions = computed(() => {
if (!returnExpressCompany.value || expressCompanyOptions.value.some((item) => item.company_name === returnExpressCompany.value)) {
return expressCompanyOptions.value;
}
return [
{
id: 0,
company_name: returnExpressCompany.value,
company_code: returnExpressCompany.value,
status: "enabled",
status_text: "启用中",
is_default: false,
sort_order: 0,
remark: "",
created_at: "",
updated_at: "",
},
...expressCompanyOptions.value,
];
});
function createManualOrderForm(): AdminManualOrderCreatePayload {
return {
service_provider: "anxinyan",
@@ -185,10 +211,40 @@ async function ensureManualMeta() {
async function openManualDialog() {
manualForm.value = createManualOrderForm();
manualAddressRecognitionText.value = "";
manualDialogVisible.value = true;
await ensureManualMeta();
}
async function ensureExpressCompanyOptions() {
if (expressCompanyOptions.value.length) return;
expressCompanyLoading.value = true;
try {
const response = await adminApi.getExpressCompanies({ enabled_only: 1 });
expressCompanyOptions.value = response.data.list;
defaultExpressCompany.value = response.data.default_company;
} catch (error) {
console.error(error);
ElMessage.error("快递公司列表加载失败");
} finally {
expressCompanyLoading.value = false;
}
}
function applyRecognizedManualAddress() {
const result = recognizeReturnAddress(manualAddressRecognitionText.value);
if (!result.ok || !result.address) {
ElMessage.warning(result.message || "寄回地址识别失败");
return;
}
manualForm.value.return_address = {
...manualForm.value.return_address,
...result.address,
};
ElMessage.success("寄回地址已识别并填入");
}
function validateManualForm() {
const form = manualForm.value;
if (!form.product_info.category_id) {
@@ -312,13 +368,14 @@ async function submitWarehouseReassign() {
}
}
function openReturnDialog() {
async function openReturnDialog() {
if (!detail.value) return;
if (!canSubmitReturnLogistics.value) {
ElMessage.warning(returnLogisticsBlockReason.value || "当前订单暂不支持登记回寄运单");
return;
}
returnExpressCompany.value = detail.value.return_logistics?.express_company || "";
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 || "";
returnDialogVisible.value = true;
}
@@ -735,7 +792,20 @@ onMounted(fetchOrders);
<el-dialog v-model="returnDialogVisible" title="登记回寄运单" width="520px">
<el-form label-position="top">
<el-form-item label="回寄快递公司">
<el-input v-model="returnExpressCompany" placeholder="例如:顺丰速运" />
<el-select
v-model="returnExpressCompany"
:loading="expressCompanyLoading"
filterable
placeholder="请选择回寄快递公司"
style="width: 100%"
>
<el-option
v-for="item in expressCompanySelectOptions"
:key="`${item.id}-${item.company_name}`"
:label="item.company_name"
:value="item.company_name"
/>
</el-select>
</el-form-item>
<el-form-item label="回寄运单号">
<el-input v-model="returnTrackingNo" placeholder="请输入回寄运单号" />
@@ -810,6 +880,18 @@ onMounted(fetchOrders);
<div class="manual-section">
<div class="manual-section__title">寄回信息</div>
<el-form label-position="top">
<el-form-item label="自动识别寄回地址">
<div class="manual-address-recognition">
<el-input
v-model="manualAddressRecognitionText"
type="textarea"
:rows="5"
resize="none"
placeholder="粘贴收货人、收货电话、收货地址,自动识别后填入下方字段"
/>
<el-button @click="applyRecognizedManualAddress">识别并填入</el-button>
</div>
</el-form-item>
<div class="manual-grid">
<el-form-item label="收件人">
<el-input v-model="manualForm.return_address.consignee" placeholder="用于匹配或创建用户" />
@@ -985,6 +1067,15 @@ onMounted(fetchOrders);
gap: 0 18px;
}
.manual-address-recognition {
display: grid;
gap: 10px;
}
.manual-address-recognition .el-button {
justify-self: flex-start;
}
.manual-upload-head {
display: flex;
align-items: center;

View File

@@ -56,6 +56,7 @@ const drawerVisible = ref(false);
const inspectionDrawerVisible = ref(false);
const inspectionSubmitting = ref(false);
const publishingId = ref<number | null>(null);
const traceVisibilitySavingId = ref<number | null>(null);
const detailQrDataUrl = ref("");
const keyword = ref("");
@@ -279,6 +280,7 @@ type PublishReportTarget = Pick<AdminReportListItem, "id" | "report_status" | "r
report_type: string;
material_tag_bound: boolean;
};
type ReportTraceVisibilityTarget = Pick<AdminReportListItem, "id" | "trace_info_visible">;
async function promptReportMaterialTagInput() {
try {
@@ -344,6 +346,46 @@ async function publishReport(row: PublishReportTarget) {
}
}
function switchValueToBoolean(value: unknown) {
if (typeof value === "boolean") return value;
if (typeof value === "number") return value === 1;
return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
}
async function updateReportTraceVisibility(row: ReportTraceVisibilityTarget, value: unknown) {
const visible = switchValueToBoolean(value);
traceVisibilitySavingId.value = row.id;
try {
const response = await adminApi.updateReportTraceVisibility(row.id, visible);
if (response.code !== 0) {
ElMessage.error(response.message || "追溯信息开关保存失败");
return;
}
const appliedVisible = Boolean(response.data.trace_info_visible);
row.trace_info_visible = appliedVisible;
const listItem = reports.value.find((item) => item.id === row.id);
if (listItem) {
listItem.trace_info_visible = appliedVisible;
}
if (detail.value?.report_header.id === row.id) {
detail.value.report_header.trace_info_visible = appliedVisible;
}
ElMessage.success(response.message || (appliedVisible ? "追溯信息已设为显示" : "追溯信息已隐藏"));
} catch (error) {
console.error(error);
ElMessage.error("追溯信息开关保存失败");
} finally {
traceVisibilitySavingId.value = null;
}
}
function handleTraceVisibilityChange(row: ReportTraceVisibilityTarget, value: unknown) {
void updateReportTraceVisibility(row, value);
}
function validateInspectionForm() {
const { report_header, product_info, result_info } = inspectionForm.value;
if (!report_header.report_title.trim()) {
@@ -466,6 +508,18 @@ watch(
<span v-else class="detail-label">不适用</span>
</template>
</el-table-column>
<el-table-column label="追溯信息" min-width="130">
<template #default="{ row }">
<el-switch
:model-value="row.trace_info_visible"
:loading="traceVisibilitySavingId === row.id"
inline-prompt
active-text="显示"
inactive-text="隐藏"
@change="handleTraceVisibilityChange(row, $event)"
/>
</template>
</el-table-column>
<el-table-column prop="institution_name" label="出具机构" min-width="160" />
<el-table-column prop="publish_time" label="发布时间" min-width="170" />
<el-table-column label="操作" fixed="right" width="220">
@@ -538,6 +592,20 @@ watch(
<div class="detail-label">出具机构</div>
<div class="detail-value">{{ detail.report_header.institution_name }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">追溯信息</div>
<div class="detail-value report-visibility-control">
<el-switch
:model-value="detail.report_header.trace_info_visible"
:loading="traceVisibilitySavingId === detail.report_header.id"
inline-prompt
active-text="显示"
inactive-text="隐藏"
@change="handleTraceVisibilityChange(detail.report_header, $event)"
/>
<span>{{ detail.report_header.trace_info_visible ? "用户端显示追溯信息 tab" : "用户端隐藏追溯信息 tab" }}</span>
</div>
</div>
</div>
<div class="detail-card">
@@ -599,7 +667,7 @@ watch(
<template v-if="detail.result_info.key_points?.length">
<div v-for="(item, index) in detail.result_info.key_points" :key="`${item.point_code || item.point_name}-${index}`" class="detail-card__desc">
<div class="detail-label">{{ item.point_name || "鉴定项" }}</div>
<div class="detail-value">{{ [item.point_value, item.point_remark].filter(Boolean).join("") || "-" }}</div>
<div class="detail-value">{{ item.point_value || "-" }}</div>
</div>
</template>
<div v-if="detail.result_info.external_remark" class="detail-card__desc">
@@ -987,4 +1055,17 @@ watch(
.report-evidence-card__body {
min-width: 0;
}
.report-visibility-control {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.report-visibility-control span {
color: var(--admin-text-subtle);
font-size: 13px;
font-weight: 500;
}
</style>

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed, defineComponent, h, nextTick, ref, type PropType } from "vue";
import { computed, defineComponent, h, nextTick, onMounted, ref, type PropType } from "vue";
import { ElMessage, type InputInstance } from "element-plus";
import {
adminApi,
type AdminExpressCompanyItem,
type AdminFileAsset,
type AdminReportDetail,
type AdminWarehouseWorkbenchContext,
@@ -20,6 +21,9 @@ const returnTagNo = ref("");
const returnMaterialQr = ref("");
const returnExpressCompany = ref("");
const returnTrackingNo = ref("");
const expressCompanyLoading = ref(false);
const expressCompanyOptions = ref<AdminExpressCompanyItem[]>([]);
const defaultExpressCompany = ref("");
const inboundAttachments = ref<AdminFileAsset[]>([]);
const returnPackingAttachments = ref<AdminFileAsset[]>([]);
@@ -47,6 +51,27 @@ const returnReportActionText = computed(() => {
if (currentReturnIsZhongjian.value || returnMaterialMatched.value) return "核对报告";
return "匹配吊牌并核对报告";
});
const expressCompanySelectOptions = computed(() => {
if (!returnExpressCompany.value || expressCompanyOptions.value.some((item) => item.company_name === returnExpressCompany.value)) {
return expressCompanyOptions.value;
}
return [
{
id: 0,
company_name: returnExpressCompany.value,
company_code: returnExpressCompany.value,
status: "enabled",
status_text: "启用中",
is_default: false,
sort_order: 0,
remark: "",
created_at: "",
updated_at: "",
},
...expressCompanyOptions.value,
];
});
const OrderContextCard = defineComponent({
name: "OrderContextCard",
@@ -225,6 +250,21 @@ function removeInboundAttachment(fileUrl: string) {
inboundAttachments.value = inboundAttachments.value.filter((item) => item.file_url !== fileUrl);
}
async function ensureExpressCompanyOptions() {
if (expressCompanyOptions.value.length) return;
expressCompanyLoading.value = true;
try {
const response = await adminApi.getExpressCompanies({ enabled_only: 1 });
expressCompanyOptions.value = response.data.list;
defaultExpressCompany.value = response.data.default_company;
} catch (error) {
console.error(error);
ElMessage.error("快递公司列表加载失败");
} finally {
expressCompanyLoading.value = false;
}
}
async function lookupZhongjian() {
if (!zhongjianTagNo.value.trim()) {
ElMessage.warning("请扫描内部流转码");
@@ -274,8 +314,9 @@ async function lookupReturn() {
}
loading.value = true;
try {
await ensureExpressCompanyOptions();
returnMaterialQr.value = "";
returnExpressCompany.value = "";
returnExpressCompany.value = defaultExpressCompany.value || expressCompanyOptions.value[0]?.company_name || "";
returnTrackingNo.value = "";
returnPackingAttachments.value = [];
returnReviewReport.value = null;
@@ -449,6 +490,10 @@ function openFile(url: string) {
if (!url) return;
window.open(url, "_blank", "noopener,noreferrer");
}
onMounted(() => {
void ensureExpressCompanyOptions();
});
</script>
<template>
@@ -570,7 +615,21 @@ function openFile(url: string) {
</el-button>
</div>
<div v-if="returnContext && returnConfirmed" class="return-form">
<el-input v-model="returnExpressCompany" size="large" placeholder="回寄快递公司,例如:顺丰速运" />
<el-select
v-model="returnExpressCompany"
:loading="expressCompanyLoading"
filterable
size="large"
placeholder="请选择回寄快递公司"
style="width: 100%"
>
<el-option
v-for="item in expressCompanySelectOptions"
:key="`${item.id}-${item.company_name}`"
:label="item.company_name"
:value="item.company_name"
/>
</el-select>
<el-input ref="returnTrackingInputRef" v-model="returnTrackingNo" size="large" placeholder="扫描或输入回寄运单号" @keyup.enter="shipReturn" />
<div class="packing-upload">
<div class="packing-upload-head">

View File

@@ -113,6 +113,16 @@ const adminChildren = [
permission: "warehouses.manage",
},
},
{
path: "express-companies",
name: "express-companies",
component: () => import("../pages/express-companies/index.vue"),
meta: {
title: "快递公司",
desc: "维护仓管寄回时可选择的快递公司和默认项。",
permission: "warehouses.manage",
},
},
{
path: "materials",
name: "materials",

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,213 @@
import regionSource from "../static/regions/pca.json";
type RegionNode = {
code: string;
name: string;
children?: RegionNode[];
};
export type RecognizedReturnAddress = {
consignee: string;
mobile: string;
province: string;
city: string;
district: string;
detail_address: string;
};
export type RecognizeReturnAddressResult = {
ok: boolean;
address?: RecognizedReturnAddress;
message?: string;
};
const regionTree = regionSource as RegionNode[];
const nameLabels = ["收货人", "收件人", "姓名", "联系人", "取件人"];
const mobileLabels = ["收货电话", "联系电话", "手机号", "手机号码", "手机", "电话"];
const addressLabels = ["收货地址", "收件地址", "寄回地址", "地址"];
const allLabels = [...nameLabels, ...mobileLabels, ...addressLabels];
function normalizeLines(raw: string) {
return raw
.replace(/\r/g, "\n")
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
function labelMatch(line: string, labels: string[]) {
for (const label of labels) {
const pattern = new RegExp(`^\\s*${label}\\s*[:]?\\s*(.*)$`);
const match = line.match(pattern);
if (match) {
return { label, value: String(match[1] || "").trim() };
}
}
return null;
}
function isKnownLabel(line: string) {
const normalized = line.replace(/\s+/g, "");
return allLabels.some((label) => normalized === label || normalized === `${label}:` || normalized === `${label}`);
}
function extractLabeledValue(lines: string[], labels: string[], block = false) {
for (let index = 0; index < lines.length; index += 1) {
const match = labelMatch(lines[index], labels);
if (!match) continue;
if (match.value) return match.value;
const values: string[] = [];
for (let nextIndex = index + 1; nextIndex < lines.length; nextIndex += 1) {
const nextLine = lines[nextIndex];
if (labelMatch(nextLine, allLabels) && isKnownLabel(nextLine)) {
break;
}
if (labelMatch(nextLine, allLabels)?.value) {
break;
}
values.push(nextLine);
if (!block) break;
}
return values.join(block ? "" : " ").trim();
}
return "";
}
function normalizeMobile(value: string) {
const directMatch = value.match(/1[3-9]\d{9}/);
if (directMatch) return directMatch[0];
const digits = value.replace(/\D+/g, "");
return digits.match(/1[3-9]\d{9}/)?.[0] || "";
}
function stripKnownAddressPrefixes(value: string) {
return value
.replace(/\s+/g, "")
.replace(/^(中国大陆|中华人民共和国|中国|大陆)+/, "")
.replace(/^(收货地址|收件地址|寄回地址|地址)[:]?/, "");
}
function aliases(name: string) {
const suffixes = ["特别行政区", "壮族自治区", "回族自治区", "维吾尔自治区", "自治区", "自治州", "自治县", "地区", "省", "市", "区", "县", "旗", "盟"];
const values = [name];
for (const suffix of suffixes) {
if (name.endsWith(suffix) && name.length > suffix.length) {
values.push(name.slice(0, -suffix.length));
}
}
return Array.from(new Set(values)).sort((a, b) => b.length - a.length);
}
function consumePrefix(text: string, names: string[]) {
for (const name of names) {
if (name && text.startsWith(name)) {
return { consumed: name.length, rest: text.slice(name.length) };
}
}
return null;
}
function isDirectCity(province: RegionNode, city: RegionNode) {
return province.name === city.name || aliases(province.name).some((name) => aliases(city.name).includes(name));
}
function matchDistrict(city: RegionNode, text: string) {
for (const district of city.children || []) {
const match = consumePrefix(text, aliases(district.name));
if (match) {
return { district, detail: match.rest };
}
}
return null;
}
function matchRegion(addressText: string) {
const address = stripKnownAddressPrefixes(addressText);
if (!address) return null;
for (const province of regionTree) {
const provinceMatch = consumePrefix(address, aliases(province.name));
if (!provinceMatch) continue;
for (const city of province.children || []) {
const cityMatch = consumePrefix(provinceMatch.rest, aliases(city.name));
const districtSource = cityMatch ? cityMatch.rest : (isDirectCity(province, city) ? provinceMatch.rest : "");
if (!districtSource) continue;
const districtMatch = matchDistrict(city, districtSource);
if (districtMatch) {
return {
province: province.name,
city: city.name,
district: districtMatch.district.name,
detail_address: districtMatch.detail,
};
}
}
}
for (const province of regionTree) {
for (const city of province.children || []) {
const cityMatch = consumePrefix(address, aliases(city.name));
if (!cityMatch) continue;
const districtMatch = matchDistrict(city, cityMatch.rest);
if (districtMatch) {
return {
province: province.name,
city: city.name,
district: districtMatch.district.name,
detail_address: districtMatch.detail,
};
}
}
}
return null;
}
function fallbackAddressLine(lines: string[], consignee: string, mobile: string) {
return lines
.map((line) => labelMatch(line, allLabels)?.value || line)
.filter((line) => line && line !== consignee && !line.includes(mobile) && !normalizeMobile(line))
.sort((a, b) => b.length - a.length)[0] || "";
}
export function recognizeReturnAddress(raw: string): RecognizeReturnAddressResult {
const lines = normalizeLines(raw);
if (!lines.length) {
return { ok: false, message: "请先粘贴寄回地址信息" };
}
const consignee = extractLabeledValue(lines, nameLabels).trim();
const mobile = normalizeMobile(extractLabeledValue(lines, mobileLabels) || raw);
const addressText = extractLabeledValue(lines, addressLabels, true) || fallbackAddressLine(lines, consignee, mobile);
const region = matchRegion(addressText);
if (!consignee) {
return { ok: false, message: "未识别到收件人,请检查文本中是否包含收货人或收件人" };
}
if (!mobile) {
return { ok: false, message: "未识别到有效手机号,请检查文本中的收货电话" };
}
if (!region) {
return { ok: false, message: "未识别到省市区,请检查地址是否包含城市和区县" };
}
if (!region.detail_address.trim()) {
return { ok: false, message: "未识别到详细地址,请检查区县后的街道门牌信息" };
}
return {
ok: true,
address: {
consignee,
mobile,
province: region.province,
city: region.city,
district: region.district,
detail_address: region.detail_address.trim(),
},
};
}