chore: release updated anxinyan version
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
|
||||
193
admin-web/src/pages/express-companies/index.vue
Normal file
193
admin-web/src/pages/express-companies/index.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
1
admin-web/src/static/regions/pca.json
Normal file
1
admin-web/src/static/regions/pca.json
Normal file
File diff suppressed because one or more lines are too long
213
admin-web/src/utils/address-recognition.ts
Normal file
213
admin-web/src/utils/address-recognition.ts
Normal 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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user