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; zhongjian_report_no: string;
report_entry_admin_name: string; report_entry_admin_name: string;
report_entered_at: string; report_entered_at: string;
trace_info_visible: boolean;
product_name: string; product_name: string;
category_name: string; category_name: string;
brand_name: string; brand_name: string;
@@ -525,6 +526,7 @@ export interface AdminReportDetail {
report_entry_admin_id: number; report_entry_admin_id: number;
report_entry_admin_name: string; report_entry_admin_name: string;
report_entered_at: string; report_entered_at: string;
trace_info_visible: boolean;
}; };
product_info: Record<string, any>; product_info: Record<string, any>;
result_info: Record<string, any>; result_info: Record<string, any>;
@@ -1168,6 +1170,29 @@ export interface AdminWarehousePayload {
remark: string; 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 { export interface AdminMaterialTagCode {
id: number; id: number;
batch_id: number; batch_id: number;
@@ -1752,6 +1777,16 @@ export const adminApi = {
data: AdminPublishReportResponse & { material_tag?: AdminMaterialTagCode | null }; 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) { saveInspectionReport(data: AdminManualInspectionPayload) {
return request.post("/api/admin/report/inspection/save", data) as Promise<{ return request.post("/api/admin/report/inspection/save", data) as Promise<{
code: number; code: number;
@@ -2242,6 +2277,23 @@ export const adminApi = {
data: { id: number }; 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>) { getMaterialBatches(params?: Record<string, string>) {
return request.get("/api/admin/material/batches", { params }) as Promise<{ return request.get("/api/admin/material/batches", { params }) as Promise<{
code: number; code: number;

View File

@@ -28,6 +28,7 @@ const menus = [
{ index: "users", label: "用户管理", icon: User, permission: "users.manage" }, { index: "users", label: "用户管理", icon: User, permission: "users.manage" },
{ index: "customers", label: "客户管理", icon: Connection, permission: "customers.manage" }, { index: "customers", label: "客户管理", icon: Connection, permission: "customers.manage" },
{ index: "warehouses", label: "仓库中心", icon: OfficeBuilding, permission: "warehouses.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: "materials", label: "物料管理", icon: Box, permission: "materials.manage" },
{ index: "access", label: "权限中心", icon: Lock, permission: "access.manage" }, { index: "access", label: "权限中心", icon: Lock, permission: "access.manage" },
{ index: "content", label: "内容中心", icon: DocumentChecked, permission: "system.manage" }, { index: "content", label: "内容中心", icon: DocumentChecked, permission: "system.manage" },

View File

@@ -745,7 +745,7 @@ function normalizedKeyPoints() {
point_code: item.point_code, point_code: item.point_code,
point_name: item.point_name, point_name: item.point_name,
point_value: item.point_value.trim(), 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">
<div class="task-form-block__title">鉴定模板填写</div> <div class="task-form-block__title">鉴定模板填写</div>
<div class="task-panel__desc"> <div class="task-panel__desc">
选择品类后自动加载对应模板按关键项逐项填写检查结论和备注 选择品类后自动加载对应模板按关键项逐项填写检查结
</div> </div>
<div v-loading="appraisalTemplateLoading"> <div v-loading="appraisalTemplateLoading">
<div v-if="resultKeyPoints.length" class="task-key-point-list"> <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="请输入检查结果" /> <el-input v-else v-model="item.point_value" :disabled="isTaskReadonly" placeholder="请输入检查结果" />
</div> </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> </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"> <script setup lang="ts">
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus"; 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 OrderStatusTag from "../../components/OrderStatusTag.vue";
import { recognizeReturnAddress } from "../../utils/address-recognition";
const loading = ref(false); const loading = ref(false);
const detailLoading = ref(false); const detailLoading = ref(false);
@@ -18,11 +19,15 @@ const returnDialogVisible = ref(false);
const returnSubmitting = ref(false); const returnSubmitting = ref(false);
const returnExpressCompany = ref(""); const returnExpressCompany = ref("");
const returnTrackingNo = ref(""); const returnTrackingNo = ref("");
const expressCompanyLoading = ref(false);
const expressCompanyOptions = ref<AdminExpressCompanyItem[]>([]);
const defaultExpressCompany = ref("");
const manualDialogVisible = ref(false); const manualDialogVisible = ref(false);
const manualSubmitting = ref(false); const manualSubmitting = ref(false);
const manualMetaLoading = ref(false); const manualMetaLoading = ref(false);
const manualMeta = ref<AdminManualOrderMeta>({ categories: [], brands: [] }); const manualMeta = ref<AdminManualOrderMeta>({ categories: [], brands: [] });
const manualForm = ref<AdminManualOrderCreatePayload>(createManualOrderForm()); const manualForm = ref<AdminManualOrderCreatePayload>(createManualOrderForm());
const manualAddressRecognitionText = ref("");
const keyword = ref(""); const keyword = ref("");
const serviceProvider = ref(""); const serviceProvider = ref("");
@@ -113,6 +118,27 @@ const logisticsActionText = computed(() => {
const canSubmitReturnLogistics = computed(() => Boolean(detail.value?.order_info.can_submit_return_logistics)); const canSubmitReturnLogistics = computed(() => Boolean(detail.value?.order_info.can_submit_return_logistics));
const returnLogisticsBlockReason = computed(() => detail.value?.order_info.return_logistics_block_reason || ""); const returnLogisticsBlockReason = computed(() => detail.value?.order_info.return_logistics_block_reason || "");
const canMarkReturnReceived = computed(() => Boolean(detail.value?.order_info.can_mark_return_received)); 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 { function createManualOrderForm(): AdminManualOrderCreatePayload {
return { return {
service_provider: "anxinyan", service_provider: "anxinyan",
@@ -185,10 +211,40 @@ async function ensureManualMeta() {
async function openManualDialog() { async function openManualDialog() {
manualForm.value = createManualOrderForm(); manualForm.value = createManualOrderForm();
manualAddressRecognitionText.value = "";
manualDialogVisible.value = true; manualDialogVisible.value = true;
await ensureManualMeta(); 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() { function validateManualForm() {
const form = manualForm.value; const form = manualForm.value;
if (!form.product_info.category_id) { if (!form.product_info.category_id) {
@@ -312,13 +368,14 @@ async function submitWarehouseReassign() {
} }
} }
function openReturnDialog() { async function openReturnDialog() {
if (!detail.value) return; if (!detail.value) return;
if (!canSubmitReturnLogistics.value) { if (!canSubmitReturnLogistics.value) {
ElMessage.warning(returnLogisticsBlockReason.value || "当前订单暂不支持登记回寄运单"); ElMessage.warning(returnLogisticsBlockReason.value || "当前订单暂不支持登记回寄运单");
return; 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 || ""; returnTrackingNo.value = detail.value.return_logistics?.tracking_no || "";
returnDialogVisible.value = true; returnDialogVisible.value = true;
} }
@@ -735,7 +792,20 @@ onMounted(fetchOrders);
<el-dialog v-model="returnDialogVisible" title="登记回寄运单" width="520px"> <el-dialog v-model="returnDialogVisible" title="登记回寄运单" width="520px">
<el-form label-position="top"> <el-form label-position="top">
<el-form-item label="回寄快递公司"> <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>
<el-form-item label="回寄运单号"> <el-form-item label="回寄运单号">
<el-input v-model="returnTrackingNo" placeholder="请输入回寄运单号" /> <el-input v-model="returnTrackingNo" placeholder="请输入回寄运单号" />
@@ -810,6 +880,18 @@ onMounted(fetchOrders);
<div class="manual-section"> <div class="manual-section">
<div class="manual-section__title">寄回信息</div> <div class="manual-section__title">寄回信息</div>
<el-form label-position="top"> <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"> <div class="manual-grid">
<el-form-item label="收件人"> <el-form-item label="收件人">
<el-input v-model="manualForm.return_address.consignee" placeholder="用于匹配或创建用户" /> <el-input v-model="manualForm.return_address.consignee" placeholder="用于匹配或创建用户" />
@@ -985,6 +1067,15 @@ onMounted(fetchOrders);
gap: 0 18px; gap: 0 18px;
} }
.manual-address-recognition {
display: grid;
gap: 10px;
}
.manual-address-recognition .el-button {
justify-self: flex-start;
}
.manual-upload-head { .manual-upload-head {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -56,6 +56,7 @@ const drawerVisible = ref(false);
const inspectionDrawerVisible = ref(false); const inspectionDrawerVisible = ref(false);
const inspectionSubmitting = ref(false); const inspectionSubmitting = ref(false);
const publishingId = ref<number | null>(null); const publishingId = ref<number | null>(null);
const traceVisibilitySavingId = ref<number | null>(null);
const detailQrDataUrl = ref(""); const detailQrDataUrl = ref("");
const keyword = ref(""); const keyword = ref("");
@@ -279,6 +280,7 @@ type PublishReportTarget = Pick<AdminReportListItem, "id" | "report_status" | "r
report_type: string; report_type: string;
material_tag_bound: boolean; material_tag_bound: boolean;
}; };
type ReportTraceVisibilityTarget = Pick<AdminReportListItem, "id" | "trace_info_visible">;
async function promptReportMaterialTagInput() { async function promptReportMaterialTagInput() {
try { 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() { function validateInspectionForm() {
const { report_header, product_info, result_info } = inspectionForm.value; const { report_header, product_info, result_info } = inspectionForm.value;
if (!report_header.report_title.trim()) { if (!report_header.report_title.trim()) {
@@ -466,6 +508,18 @@ watch(
<span v-else class="detail-label">不适用</span> <span v-else class="detail-label">不适用</span>
</template> </template>
</el-table-column> </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="institution_name" label="出具机构" min-width="160" />
<el-table-column prop="publish_time" label="发布时间" min-width="170" /> <el-table-column prop="publish_time" label="发布时间" min-width="170" />
<el-table-column label="操作" fixed="right" width="220"> <el-table-column label="操作" fixed="right" width="220">
@@ -538,6 +592,20 @@ watch(
<div class="detail-label">出具机构</div> <div class="detail-label">出具机构</div>
<div class="detail-value">{{ detail.report_header.institution_name }}</div> <div class="detail-value">{{ detail.report_header.institution_name }}</div>
</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>
<div class="detail-card"> <div class="detail-card">
@@ -599,7 +667,7 @@ watch(
<template v-if="detail.result_info.key_points?.length"> <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 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-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> </div>
</template> </template>
<div v-if="detail.result_info.external_remark" class="detail-card__desc"> <div v-if="detail.result_info.external_remark" class="detail-card__desc">
@@ -987,4 +1055,17 @@ watch(
.report-evidence-card__body { .report-evidence-card__body {
min-width: 0; 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> </style>

View File

@@ -1,8 +1,9 @@
<script setup lang="ts"> <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 { ElMessage, type InputInstance } from "element-plus";
import { import {
adminApi, adminApi,
type AdminExpressCompanyItem,
type AdminFileAsset, type AdminFileAsset,
type AdminReportDetail, type AdminReportDetail,
type AdminWarehouseWorkbenchContext, type AdminWarehouseWorkbenchContext,
@@ -20,6 +21,9 @@ const returnTagNo = ref("");
const returnMaterialQr = ref(""); const returnMaterialQr = ref("");
const returnExpressCompany = ref(""); const returnExpressCompany = ref("");
const returnTrackingNo = ref(""); const returnTrackingNo = ref("");
const expressCompanyLoading = ref(false);
const expressCompanyOptions = ref<AdminExpressCompanyItem[]>([]);
const defaultExpressCompany = ref("");
const inboundAttachments = ref<AdminFileAsset[]>([]); const inboundAttachments = ref<AdminFileAsset[]>([]);
const returnPackingAttachments = ref<AdminFileAsset[]>([]); const returnPackingAttachments = ref<AdminFileAsset[]>([]);
@@ -47,6 +51,27 @@ const returnReportActionText = computed(() => {
if (currentReturnIsZhongjian.value || returnMaterialMatched.value) return "核对报告"; if (currentReturnIsZhongjian.value || returnMaterialMatched.value) return "核对报告";
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({ const OrderContextCard = defineComponent({
name: "OrderContextCard", name: "OrderContextCard",
@@ -225,6 +250,21 @@ function removeInboundAttachment(fileUrl: string) {
inboundAttachments.value = inboundAttachments.value.filter((item) => item.file_url !== fileUrl); 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() { async function lookupZhongjian() {
if (!zhongjianTagNo.value.trim()) { if (!zhongjianTagNo.value.trim()) {
ElMessage.warning("请扫描内部流转码"); ElMessage.warning("请扫描内部流转码");
@@ -274,8 +314,9 @@ async function lookupReturn() {
} }
loading.value = true; loading.value = true;
try { try {
await ensureExpressCompanyOptions();
returnMaterialQr.value = ""; returnMaterialQr.value = "";
returnExpressCompany.value = ""; returnExpressCompany.value = defaultExpressCompany.value || expressCompanyOptions.value[0]?.company_name || "";
returnTrackingNo.value = ""; returnTrackingNo.value = "";
returnPackingAttachments.value = []; returnPackingAttachments.value = [];
returnReviewReport.value = null; returnReviewReport.value = null;
@@ -449,6 +490,10 @@ function openFile(url: string) {
if (!url) return; if (!url) return;
window.open(url, "_blank", "noopener,noreferrer"); window.open(url, "_blank", "noopener,noreferrer");
} }
onMounted(() => {
void ensureExpressCompanyOptions();
});
</script> </script>
<template> <template>
@@ -570,7 +615,21 @@ function openFile(url: string) {
</el-button> </el-button>
</div> </div>
<div v-if="returnContext && returnConfirmed" class="return-form"> <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" /> <el-input ref="returnTrackingInputRef" v-model="returnTrackingNo" size="large" placeholder="扫描或输入回寄运单号" @keyup.enter="shipReturn" />
<div class="packing-upload"> <div class="packing-upload">
<div class="packing-upload-head"> <div class="packing-upload-head">

View File

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

View File

@@ -0,0 +1,50 @@
<?php
namespace app\controller\admin;
use app\support\ExpressCompanyService;
use support\Request;
class ExpressCompaniesController
{
public function index(Request $request)
{
$enabledOnly = (int)$request->input('enabled_only', 0) === 1;
return api_success([
'list' => $this->service()->list($enabledOnly),
'default_company' => $this->service()->defaultName(),
]);
}
public function save(Request $request)
{
$id = (int)$request->input('id', 0);
try {
$companyId = $this->service()->save([
'company_name' => $request->input('company_name', ''),
'company_code' => $request->input('company_code', ''),
'status' => $request->input('status', 'enabled'),
'is_default' => $request->input('is_default', false),
'sort_order' => $request->input('sort_order', 0),
'remark' => $request->input('remark', ''),
], $id);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 422);
} catch (\Throwable $e) {
return api_error('快递公司保存失败', 500, [
'detail' => $e->getMessage(),
]);
}
return api_success([
'id' => $companyId,
], $id > 0 ? '快递公司已更新' : '快递公司已创建');
}
private function service(): ExpressCompanyService
{
return new ExpressCompanyService();
}
}

View File

@@ -41,6 +41,7 @@ class ReportsController
'r.zhongjian_report_no', 'r.zhongjian_report_no',
'r.report_entry_admin_name', 'r.report_entry_admin_name',
'r.report_entered_at', 'r.report_entered_at',
'r.trace_info_visible',
'o.order_no', 'o.order_no',
'p.product_name', 'p.product_name',
'p.category_name', 'p.category_name',
@@ -83,6 +84,7 @@ class ReportsController
'zhongjian_report_no' => (string)($item['zhongjian_report_no'] ?? ''), 'zhongjian_report_no' => (string)($item['zhongjian_report_no'] ?? ''),
'report_entry_admin_name' => (string)($item['report_entry_admin_name'] ?? ''), 'report_entry_admin_name' => (string)($item['report_entry_admin_name'] ?? ''),
'report_entered_at' => (string)($item['report_entered_at'] ?? ''), 'report_entered_at' => (string)($item['report_entered_at'] ?? ''),
'trace_info_visible' => (int)($item['trace_info_visible'] ?? 0) === 1,
'product_name' => $item['product_name'] ?: (string)($productSnapshot['product_name'] ?? ''), 'product_name' => $item['product_name'] ?: (string)($productSnapshot['product_name'] ?? ''),
'category_name' => $item['category_name'] ?: (string)($productSnapshot['category_name'] ?? ''), 'category_name' => $item['category_name'] ?: (string)($productSnapshot['category_name'] ?? ''),
'brand_name' => $item['brand_name'] ?: (string)($productSnapshot['brand_name'] ?? ''), 'brand_name' => $item['brand_name'] ?: (string)($productSnapshot['brand_name'] ?? ''),
@@ -169,6 +171,7 @@ class ReportsController
'report_entry_admin_id' => (int)($report['report_entry_admin_id'] ?? 0), 'report_entry_admin_id' => (int)($report['report_entry_admin_id'] ?? 0),
'report_entry_admin_name' => (string)($report['report_entry_admin_name'] ?? ''), 'report_entry_admin_name' => (string)($report['report_entry_admin_name'] ?? ''),
'report_entered_at' => (string)($report['report_entered_at'] ?? ''), 'report_entered_at' => (string)($report['report_entered_at'] ?? ''),
'trace_info_visible' => (int)($report['trace_info_visible'] ?? 0) === 1,
], ],
'product_info' => $productSnapshot, 'product_info' => $productSnapshot,
'result_info' => $resultSnapshot, 'result_info' => $resultSnapshot,
@@ -188,6 +191,32 @@ class ReportsController
]); ]);
} }
public function updateTraceVisibility(Request $request)
{
$id = (int)$request->input('id', 0);
if (!$id) {
return api_error('报告 ID 不能为空', 422);
}
$report = Db::name('reports')->where('id', $id)->find();
if (!$report) {
return api_error('报告不存在', 404);
}
$visible = $this->boolInput($request->input('trace_info_visible', false));
$now = date('Y-m-d H:i:s');
Db::name('reports')->where('id', $id)->update([
'trace_info_visible' => $visible ? 1 : 0,
'updated_at' => $now,
]);
return api_success([
'id' => $id,
'trace_info_visible' => $visible,
], $visible ? '追溯信息已设为显示' : '追溯信息已隐藏');
}
public function saveInspection(Request $request) public function saveInspection(Request $request)
{ {
$id = (int)$request->input('id', 0); $id = (int)$request->input('id', 0);
@@ -540,6 +569,18 @@ class ReportsController
return []; return [];
} }
private function boolInput(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_numeric($value)) {
return (int)$value === 1;
}
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
}
private function loadReportContentMap(array $reportIds): array private function loadReportContentMap(array $reportIds): array
{ {
if (!$reportIds) { if (!$reportIds) {

View File

@@ -108,12 +108,16 @@ class ReportsController
$reportMedia = [ $reportMedia = [
'images' => $this->filterAssetsByType($evidenceAttachments, 'image'), 'images' => $this->filterAssetsByType($evidenceAttachments, 'image'),
]; ];
$traceInfo = $this->buildTraceInfo( $traceInfoVisible = (int)($reportData['trace_info_visible'] ?? 0) === 1;
(int)($reportData['order_id'] ?? 0), $traceInfo = $traceInfoVisible
$payload['appraisal_snapshot'], ? $this->buildTraceInfo(
$evidenceAttachments, (int)($reportData['order_id'] ?? 0),
$request $payload['appraisal_snapshot'],
); $evidenceAttachments,
$request
)
: ['visible' => false, 'nodes' => []];
$traceInfo['visible'] = $traceInfoVisible;
$pdfUrl = $this->ensurePdfFile($request, $reportData, $content ?: [], $verify ?: [], $productDisplay, $reportMedia); $pdfUrl = $this->ensurePdfFile($request, $reportData, $content ?: [], $verify ?: [], $productDisplay, $reportMedia);
return api_success([ return api_success([
@@ -129,6 +133,7 @@ class ReportsController
'zhongjian_report_no' => (string)($reportData['zhongjian_report_no'] ?? ''), 'zhongjian_report_no' => (string)($reportData['zhongjian_report_no'] ?? ''),
'report_entry_admin_name' => (string)($reportData['report_entry_admin_name'] ?? ''), 'report_entry_admin_name' => (string)($reportData['report_entry_admin_name'] ?? ''),
'report_entered_at' => (string)($reportData['report_entered_at'] ?? ''), 'report_entered_at' => (string)($reportData['report_entered_at'] ?? ''),
'trace_info_visible' => $traceInfoVisible,
], ],
'result_info' => $payload['result_snapshot'], 'result_info' => $payload['result_snapshot'],
'product_info' => $payload['product_snapshot'], 'product_info' => $payload['product_snapshot'],
@@ -353,7 +358,7 @@ class ReportsController
$items, $items,
$label, $label,
$this->textValue($point['point_value'] ?? '') ?: '-', $this->textValue($point['point_value'] ?? '') ?: '-',
$this->textValue($point['point_remark'] ?? ''), '',
true true
); );
} }

View File

@@ -71,6 +71,8 @@ class AdminAuthMiddleware implements MiddlewareInterface
str_starts_with($path, '/api/admin/customers'), str_starts_with($path, '/api/admin/customers'),
str_starts_with($path, '/api/admin/customer/') => ['customers.manage'], str_starts_with($path, '/api/admin/customer/') => ['customers.manage'],
str_starts_with($path, '/api/admin/warehouse-workbench/') => ['warehouse_workbench.manage'], str_starts_with($path, '/api/admin/warehouse-workbench/') => ['warehouse_workbench.manage'],
str_starts_with($path, '/api/admin/express-companies') && strtoupper($method) === 'GET' => ['warehouse_workbench.manage', 'orders.manage', 'warehouses.manage'],
str_starts_with($path, '/api/admin/express-company/') => ['warehouses.manage'],
str_starts_with($path, '/api/admin/warehouses'), str_starts_with($path, '/api/admin/warehouses'),
str_starts_with($path, '/api/admin/warehouse/') => ['warehouses.manage'], str_starts_with($path, '/api/admin/warehouse/') => ['warehouses.manage'],
str_starts_with($path, '/api/admin/material/') => ['materials.manage'], str_starts_with($path, '/api/admin/material/') => ['materials.manage'],

View File

@@ -0,0 +1,208 @@
<?php
namespace app\support;
use support\think\Db;
class ExpressCompanyService
{
public function __construct()
{
$this->ensureTable();
$this->bootstrapDefaults();
}
public function list(bool $enabledOnly = false): array
{
$query = Db::name('express_companies')
->order('is_default', 'desc')
->order('sort_order', 'asc')
->order('id', 'asc');
if ($enabledOnly) {
$query->where('status', 'enabled');
}
return array_map(fn (array $item) => $this->format($item), $query->select()->toArray());
}
public function save(array $payload, int $id = 0): int
{
$now = date('Y-m-d H:i:s');
$companyName = trim((string)($payload['company_name'] ?? ''));
$companyCode = trim((string)($payload['company_code'] ?? ''));
$status = trim((string)($payload['status'] ?? 'enabled'));
$isDefault = !empty($payload['is_default']);
if ($companyName === '') {
throw new \RuntimeException('快递公司名称不能为空');
}
if (!in_array($status, ['enabled', 'disabled'], true)) {
throw new \RuntimeException('快递公司状态无效');
}
if ($isDefault && $status !== 'enabled') {
throw new \RuntimeException('默认快递公司必须保持启用');
}
if ($companyCode === '') {
$companyCode = $this->generateCompanyCode($companyName);
}
$existsByName = Db::name('express_companies')
->where('company_name', $companyName)
->when($id > 0, fn ($query) => $query->where('id', '<>', $id))
->find();
if ($existsByName) {
throw new \RuntimeException('快递公司名称已存在');
}
$existsByCode = Db::name('express_companies')
->where('company_code', $companyCode)
->when($id > 0, fn ($query) => $query->where('id', '<>', $id))
->find();
if ($existsByCode) {
throw new \RuntimeException('快递公司编码已存在');
}
$data = [
'company_name' => $companyName,
'company_code' => $companyCode,
'status' => $status,
'is_default' => $isDefault ? 1 : 0,
'sort_order' => (int)($payload['sort_order'] ?? 0),
'remark' => trim((string)($payload['remark'] ?? '')),
'updated_at' => $now,
];
Db::startTrans();
try {
if ($isDefault) {
Db::name('express_companies')->update([
'is_default' => 0,
'updated_at' => $now,
]);
}
if ($id > 0) {
Db::name('express_companies')->where('id', $id)->update($data);
$companyId = $id;
} else {
$data['created_at'] = $now;
$companyId = (int)Db::name('express_companies')->insertGetId($data);
}
$this->ensureEnabledDefault($now);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
return $companyId;
}
public function defaultName(): string
{
$row = Db::name('express_companies')
->where('status', 'enabled')
->where('is_default', 1)
->find();
if (!$row) {
$row = Db::name('express_companies')
->where('status', 'enabled')
->order('sort_order', 'asc')
->order('id', 'asc')
->find();
}
return trim((string)($row['company_name'] ?? ''));
}
private function format(array $item): array
{
$status = (string)($item['status'] ?? 'enabled');
return [
'id' => (int)$item['id'],
'company_name' => (string)$item['company_name'],
'company_code' => (string)$item['company_code'],
'status' => $status,
'status_text' => $status === 'enabled' ? '启用中' : '已停用',
'is_default' => (bool)$item['is_default'],
'sort_order' => (int)$item['sort_order'],
'remark' => (string)($item['remark'] ?? ''),
'created_at' => (string)($item['created_at'] ?? ''),
'updated_at' => (string)($item['updated_at'] ?? ''),
];
}
private function bootstrapDefaults(): void
{
$now = date('Y-m-d H:i:s');
$exists = Db::name('express_companies')->where('company_name', '顺丰速运')->find();
if (!$exists) {
Db::name('express_companies')->insert([
'company_name' => '顺丰速运',
'company_code' => 'sf_express',
'status' => 'enabled',
'is_default' => 1,
'sort_order' => 1,
'remark' => '系统默认快递公司',
'created_at' => $now,
'updated_at' => $now,
]);
}
$this->ensureEnabledDefault($now);
}
private function ensureEnabledDefault(string $now): void
{
$default = Db::name('express_companies')
->where('status', 'enabled')
->where('is_default', 1)
->find();
if ($default) {
return;
}
$firstEnabled = Db::name('express_companies')
->where('status', 'enabled')
->order('sort_order', 'asc')
->order('id', 'asc')
->find();
if ($firstEnabled) {
Db::name('express_companies')->where('id', $firstEnabled['id'])->update([
'is_default' => 1,
'updated_at' => $now,
]);
}
}
private function generateCompanyCode(string $companyName): string
{
return 'express_' . substr(hash('sha256', $companyName), 0, 12);
}
private function ensureTable(): void
{
Db::execute(<<<'SQL'
CREATE TABLE IF NOT EXISTS express_companies (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
company_name VARCHAR(64) NOT NULL DEFAULT '',
company_code VARCHAR(64) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'enabled',
is_default TINYINT(1) NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_express_companies_name (company_name),
UNIQUE KEY uk_express_companies_code (company_code),
KEY idx_express_companies_status (status),
KEY idx_express_companies_default (is_default)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='快递公司字典'
SQL);
}
}

View File

@@ -46,6 +46,7 @@ use app\controller\admin\SystemConfigsController as AdminSystemConfigsController
use app\controller\admin\AuthController as AdminAuthController; use app\controller\admin\AuthController as AdminAuthController;
use app\controller\admin\CustomersController as AdminCustomersController; use app\controller\admin\CustomersController as AdminCustomersController;
use app\controller\admin\WarehouseWorkbenchController as AdminWarehouseWorkbenchController; use app\controller\admin\WarehouseWorkbenchController as AdminWarehouseWorkbenchController;
use app\controller\admin\ExpressCompaniesController as AdminExpressCompaniesController;
use app\controller\admin\FileUploadController as AdminFileUploadController; use app\controller\admin\FileUploadController as AdminFileUploadController;
use app\controller\open\OrdersController as OpenOrdersController; use app\controller\open\OrdersController as OpenOrdersController;
@@ -219,6 +220,7 @@ Route::post('/api/admin/catalog/upload-templates/save', [AdminCatalogController:
Route::post('/api/admin/catalog/appraisal-templates/save', [AdminCatalogController::class, 'saveAppraisalTemplates']); Route::post('/api/admin/catalog/appraisal-templates/save', [AdminCatalogController::class, 'saveAppraisalTemplates']);
Route::get('/api/admin/reports', [AdminReportsController::class, 'index']); Route::get('/api/admin/reports', [AdminReportsController::class, 'index']);
Route::get('/api/admin/report/detail', [AdminReportsController::class, 'detail']); Route::get('/api/admin/report/detail', [AdminReportsController::class, 'detail']);
Route::post('/api/admin/report/trace-visibility', [AdminReportsController::class, 'updateTraceVisibility']);
Route::post('/api/admin/report/inspection/save', [AdminReportsController::class, 'saveInspection']); Route::post('/api/admin/report/inspection/save', [AdminReportsController::class, 'saveInspection']);
Route::post('/api/admin/report/publish', [AdminReportsController::class, 'publish']); Route::post('/api/admin/report/publish', [AdminReportsController::class, 'publish']);
Route::get('/api/admin/appraisal-tasks', [AdminAppraisalTasksController::class, 'index']); Route::get('/api/admin/appraisal-tasks', [AdminAppraisalTasksController::class, 'index']);
@@ -262,6 +264,8 @@ Route::post('/api/admin/customer/event/resend', [AdminCustomersController::class
Route::get('/api/admin/warehouses/overview', [AdminWarehousesController::class, 'overview']); Route::get('/api/admin/warehouses/overview', [AdminWarehousesController::class, 'overview']);
Route::get('/api/admin/warehouses', [AdminWarehousesController::class, 'index']); Route::get('/api/admin/warehouses', [AdminWarehousesController::class, 'index']);
Route::post('/api/admin/warehouse/save', [AdminWarehousesController::class, 'save']); Route::post('/api/admin/warehouse/save', [AdminWarehousesController::class, 'save']);
Route::get('/api/admin/express-companies', [AdminExpressCompaniesController::class, 'index']);
Route::post('/api/admin/express-company/save', [AdminExpressCompaniesController::class, 'save']);
Route::get('/api/admin/warehouse-workbench/inbound/lookup', [AdminWarehouseWorkbenchController::class, 'inboundLookup']); Route::get('/api/admin/warehouse-workbench/inbound/lookup', [AdminWarehouseWorkbenchController::class, 'inboundLookup']);
Route::post('/api/admin/warehouse-workbench/inbound/receive', [AdminWarehouseWorkbenchController::class, 'inboundReceive']); Route::post('/api/admin/warehouse-workbench/inbound/receive', [AdminWarehouseWorkbenchController::class, 'inboundReceive']);
Route::post('/api/admin/warehouse-workbench/inbound/evidence/upload', [AdminWarehouseWorkbenchController::class, 'uploadInboundEvidenceFile']); Route::post('/api/admin/warehouse-workbench/inbound/evidence/upload', [AdminWarehouseWorkbenchController::class, 'uploadInboundEvidenceFile']);

View File

@@ -12,6 +12,7 @@ DROP TABLE IF EXISTS enterprise_customer_order_refs;
DROP TABLE IF EXISTS enterprise_api_nonces; DROP TABLE IF EXISTS enterprise_api_nonces;
DROP TABLE IF EXISTS enterprise_customer_apps; DROP TABLE IF EXISTS enterprise_customer_apps;
DROP TABLE IF EXISTS enterprise_customers; DROP TABLE IF EXISTS enterprise_customers;
DROP TABLE IF EXISTS express_companies;
DROP TABLE IF EXISTS shipping_warehouses; DROP TABLE IF EXISTS shipping_warehouses;
DROP TABLE IF EXISTS user_api_tokens; DROP TABLE IF EXISTS user_api_tokens;
DROP TABLE IF EXISTS sms_code_logs; DROP TABLE IF EXISTS sms_code_logs;
@@ -729,6 +730,23 @@ CREATE TABLE order_logistics_nodes (
KEY idx_order_logistics_nodes_logistics_id (logistics_id) KEY idx_order_logistics_nodes_logistics_id (logistics_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='物流节点'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='物流节点';
CREATE TABLE express_companies (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
company_name VARCHAR(64) NOT NULL DEFAULT '',
company_code VARCHAR(64) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'enabled',
is_default TINYINT(1) NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_express_companies_name (company_name),
UNIQUE KEY uk_express_companies_code (company_code),
KEY idx_express_companies_status (status),
KEY idx_express_companies_default (is_default)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='快递公司字典';
CREATE TABLE order_abnormals ( CREATE TABLE order_abnormals (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL, order_id BIGINT UNSIGNED NOT NULL,
@@ -949,6 +967,7 @@ CREATE TABLE reports (
report_entry_admin_id BIGINT UNSIGNED NULL DEFAULT NULL, report_entry_admin_id BIGINT UNSIGNED NULL DEFAULT NULL,
report_entry_admin_name VARCHAR(64) NOT NULL DEFAULT '', report_entry_admin_name VARCHAR(64) NOT NULL DEFAULT '',
report_entered_at DATETIME NULL DEFAULT NULL, report_entered_at DATETIME NULL DEFAULT NULL,
trace_info_visible TINYINT(1) NOT NULL DEFAULT 0,
invalid_reason VARCHAR(255) NOT NULL DEFAULT '', invalid_reason VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

View File

@@ -26,6 +26,7 @@ $pdo = new PDO(
); );
$tables = [ $tables = [
'express_companies',
'shipping_warehouses', 'shipping_warehouses',
'order_shipping_targets', 'order_shipping_targets',
'material_tag_scan_logs', 'material_batch_download_logs', 'material_tag_codes', 'material_batches', 'material_tag_scan_logs', 'material_batch_download_logs', 'material_tag_codes', 'material_batches',
@@ -62,6 +63,9 @@ INSERT INTO shipping_warehouses (id, warehouse_name, warehouse_code, warehouse_t
(1, '安心验鉴定中心', 'AXY-WH-DEFAULT', 'detection_center', 'anxinyan', '安心验鉴定中心', '400-800-1314', '广东省', '深圳市', '南山区', '科技园鉴定路 88 号 安心验收件中心', '周一至周日 09:30-18:30', '寄送前请确认订单信息完整,包裹内附上订单号可提升签收后的处理效率。', NULL, NULL, NULL, 'enabled', 1, 1, '默认仓库', '{$now}', '{$now}'), (1, '安心验鉴定中心', 'AXY-WH-DEFAULT', 'detection_center', 'anxinyan', '安心验鉴定中心', '400-800-1314', '广东省', '深圳市', '南山区', '科技园鉴定路 88 号 安心验收件中心', '周一至周日 09:30-18:30', '寄送前请确认订单信息完整,包裹内附上订单号可提升签收后的处理效率。', NULL, NULL, NULL, 'enabled', 1, 1, '默认仓库', '{$now}', '{$now}'),
(2, '中检合作鉴定中心', 'ZJ-WH-DEFAULT', 'detection_center', 'zhongjian', '中检合作鉴定中心', '400-800-1314', '广东省', '深圳市', '南山区', '科技园鉴定路 88 号 安心验中检收件中心', '周一至周日 09:30-18:30', '中检鉴定订单请优先附上鉴定单号,寄出后尽快填写运单号。', NULL, NULL, NULL, 'enabled', 1, 1, '默认仓库', '{$now}', '{$now}'); (2, '中检合作鉴定中心', 'ZJ-WH-DEFAULT', 'detection_center', 'zhongjian', '中检合作鉴定中心', '400-800-1314', '广东省', '深圳市', '南山区', '科技园鉴定路 88 号 安心验中检收件中心', '周一至周日 09:30-18:30', '中检鉴定订单请优先附上鉴定单号,寄出后尽快填写运单号。', NULL, NULL, NULL, 'enabled', 1, 1, '默认仓库', '{$now}', '{$now}');
INSERT INTO express_companies (id, company_name, company_code, status, is_default, sort_order, remark, created_at, updated_at) VALUES
(1, '顺丰速运', 'sf_express', 'enabled', 1, 1, '系统默认快递公司', '{$now}', '{$now}');
INSERT INTO catalog_categories (id, name, code, sort_order, is_enabled, need_shipping, supported_service_types, created_at, updated_at) VALUES INSERT INTO catalog_categories (id, name, code, sort_order, is_enabled, need_shipping, supported_service_types, created_at, updated_at) VALUES
(1, '奢侈品箱包', 'luxury_bag', 1, 1, 1, JSON_ARRAY('anxinyan', 'zhongjian'), '{$now}', '{$now}'), (1, '奢侈品箱包', 'luxury_bag', 1, 1, 1, JSON_ARRAY('anxinyan', 'zhongjian'), '{$now}', '{$now}'),
(2, '潮流鞋类', 'sneaker', 2, 1, 1, JSON_ARRAY('anxinyan', 'zhongjian'), '{$now}', '{$now}'), (2, '潮流鞋类', 'sneaker', 2, 1, 1, JSON_ARRAY('anxinyan', 'zhongjian'), '{$now}', '{$now}'),

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
$dotenv->safeLoad();
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
$_ENV['DB_HOST'] ?? '127.0.0.1',
$_ENV['DB_PORT'] ?? '3306',
$_ENV['DB_DATABASE'] ?? '',
$_ENV['DB_CHARSET'] ?? 'utf8mb4'
);
$pdo = new PDO(
$dsn,
$_ENV['DB_USERNAME'] ?? '',
$_ENV['DB_PASSWORD'] ?? '',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS express_companies (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
company_name VARCHAR(64) NOT NULL DEFAULT '',
company_code VARCHAR(64) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'enabled',
is_default TINYINT(1) NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_express_companies_name (company_name),
UNIQUE KEY uk_express_companies_code (company_code),
KEY idx_express_companies_status (status),
KEY idx_express_companies_default (is_default)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='快递公司字典'
SQL);
$now = date('Y-m-d H:i:s');
$stmt = $pdo->prepare('SELECT id FROM express_companies WHERE company_name = ? LIMIT 1');
$stmt->execute(['顺丰速运']);
$exists = $stmt->fetch();
if (!$exists) {
$insert = $pdo->prepare('INSERT INTO express_companies (company_name, company_code, status, is_default, sort_order, remark, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
$insert->execute(['顺丰速运', 'sf_express', 'enabled', 1, 1, '系统默认快递公司', $now, $now]);
echo "SEED_DEFAULT_EXPRESS_COMPANY\n";
}
$defaultCount = (int)$pdo->query("SELECT COUNT(*) FROM express_companies WHERE status = 'enabled' AND is_default = 1")->fetchColumn();
if ($defaultCount === 0) {
$row = $pdo->query("SELECT id FROM express_companies WHERE status = 'enabled' ORDER BY sort_order ASC, id ASC LIMIT 1")->fetch();
if ($row) {
$update = $pdo->prepare('UPDATE express_companies SET is_default = 1, updated_at = ? WHERE id = ?');
$update->execute([$now, (int)$row['id']]);
echo "SET_DEFAULT_EXPRESS_COMPANY\n";
}
}
echo "SCHEMA_UPGRADE_OK\n";

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
$dotenv->safeLoad();
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
$_ENV['DB_HOST'] ?? '127.0.0.1',
$_ENV['DB_PORT'] ?? '3306',
$_ENV['DB_DATABASE'] ?? '',
$_ENV['DB_CHARSET'] ?? 'utf8mb4'
);
$pdo = new PDO(
$dsn,
$_ENV['DB_USERNAME'] ?? '',
$_ENV['DB_PASSWORD'] ?? '',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
function hasColumn(PDO $pdo, string $table, string $column): bool
{
$stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?');
$stmt->execute([$table, $column]);
return (int)$stmt->fetchColumn() > 0;
}
if (!hasColumn($pdo, 'reports', 'trace_info_visible')) {
$pdo->exec('ALTER TABLE reports ADD COLUMN trace_info_visible TINYINT(1) NOT NULL DEFAULT 0 AFTER report_entered_at');
echo "ADD_COLUMN reports.trace_info_visible\n";
}
echo "SCHEMA_UPGRADE_REPORT_TRACE_VISIBILITY_OK\n";

View File

@@ -357,6 +357,7 @@ export interface ReportDetailData {
}>; }>;
}; };
trace_info: { trace_info: {
visible: boolean;
nodes: Array<{ nodes: Array<{
code: "inbound" | "appraisal" | "return"; code: "inbound" | "appraisal" | "return";
title: string; title: string;
@@ -377,6 +378,7 @@ export interface ReportDetailData {
zhongjian_report_no: string; zhongjian_report_no: string;
report_entry_admin_name: string; report_entry_admin_name: string;
report_entered_at: string; report_entered_at: string;
trace_info_visible: boolean;
}; };
result_info: Record<string, any>; result_info: Record<string, any>;
product_info: Record<string, any>; product_info: Record<string, any>;

View File

@@ -390,6 +390,7 @@ export const reportDetailFallback: ReportDetailData = {
], ],
}, },
trace_info: { trace_info: {
visible: false,
nodes: [ nodes: [
{ {
code: "inbound", code: "inbound",
@@ -426,6 +427,7 @@ export const reportDetailFallback: ReportDetailData = {
zhongjian_report_no: "ZJ-20260418-0001", zhongjian_report_no: "ZJ-20260418-0001",
report_entry_admin_name: "王师傅", report_entry_admin_name: "王师傅",
report_entered_at: "2026-04-18 18:20:00", report_entered_at: "2026-04-18 18:20:00",
trace_info_visible: false,
}, },
result_info: { result_info: {
result_status: "authentic", result_status: "authentic",

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue"; import { computed, ref, watch } from "vue";
import { onLoad } from "@dcloudio/uni-app"; import { onLoad } from "@dcloudio/uni-app";
import { appApi, type EvidenceAttachmentAsset, type ReportDetailData } from "../../api/app"; import { appApi, type EvidenceAttachmentAsset, type ReportDetailData } from "../../api/app";
import { reportDetailFallback } from "../../mocks/app"; import { reportDetailFallback } from "../../mocks/app";
@@ -17,6 +17,9 @@ const antiModalVisible = ref(false);
const antiCode = ref(""); const antiCode = ref("");
const antiVerifying = ref(false); const antiVerifying = ref(false);
const antiResult = ref<null | { passed: boolean; message: string }>(null); const antiResult = ref<null | { passed: boolean; message: string }>(null);
const imagePreviewVisible = ref(false);
const imagePreviewUrls = ref<string[]>([]);
const imagePreviewIndex = ref(0);
const reportImages = computed(() => { const reportImages = computed(() => {
const images = detail.value.report_media?.images || []; const images = detail.value.report_media?.images || [];
@@ -41,7 +44,31 @@ const productItems = computed(() => {
{ label: "品牌", value: detail.value.product_info.brand_name || "-" }, { label: "品牌", value: detail.value.product_info.brand_name || "-" },
]; ];
}); });
const traceNodes = computed(() => detail.value.trace_info?.nodes || []); const publishTime = computed(() => detail.value.report_header.publish_time || "-");
const resultItem = computed(() => {
const item = productItems.value.find((entry) => entry.label.includes("结论"));
return {
label: item?.label || "检测结论",
value: item?.value || detail.value.result_info.result_text || "-",
remark: item?.remark || detail.value.result_info.result_desc || "",
};
});
const productSpecItems = computed(() => {
const items = productItems.value
.filter((item) => !item.label.includes("结论"))
.map((item) => ({ label: item.label, value: item.value, remark: item.remark || "" }));
if (items.length) return items;
return [
{ label: "品类", value: detail.value.product_info.category_name || "-", remark: "" },
{ label: "品牌", value: detail.value.product_info.brand_name || "-", remark: "" },
{ label: "颜色", value: detail.value.product_info.color || "-", remark: "" },
{ label: "规格/尺寸", value: detail.value.product_info.size_spec || "-", remark: "" },
{ label: "序列号/编码", value: detail.value.product_info.serial_no || "-", remark: "" },
].filter((item) => item.value && item.value !== "-");
});
const traceInfoVisible = computed(() => Boolean(detail.value.trace_info?.visible || detail.value.report_header.trace_info_visible));
const traceNodes = computed(() => (traceInfoVisible.value ? detail.value.trace_info?.nodes || [] : []));
const zhongjianReportFiles = computed(() => detail.value.zhongjian_report_files || []); const zhongjianReportFiles = computed(() => detail.value.zhongjian_report_files || []);
const zhongjianImageFiles = computed(() => zhongjianReportFiles.value.filter((item) => item.file_type === "image")); const zhongjianImageFiles = computed(() => zhongjianReportFiles.value.filter((item) => item.file_type === "image"));
const zhongjianOtherFiles = computed(() => zhongjianReportFiles.value.filter((item) => item.file_type !== "image")); const zhongjianOtherFiles = computed(() => zhongjianReportFiles.value.filter((item) => item.file_type !== "image"));
@@ -59,9 +86,23 @@ function assetDisplayName(item: EvidenceAttachmentAsset, index: number) {
} }
function previewImages(files: EvidenceAttachmentAsset[], current: string) { function previewImages(files: EvidenceAttachmentAsset[], current: string) {
const urls = files.filter((item) => item.file_type === "image").map((item) => item.file_url); const urls = files
.filter((item) => item.file_type === "image" && item.file_url)
.map((item) => item.file_url);
if (!urls.length) return; if (!urls.length) return;
uni.previewImage({ urls, current });
const currentIndex = urls.indexOf(current);
imagePreviewUrls.value = urls;
imagePreviewIndex.value = currentIndex >= 0 ? currentIndex : 0;
imagePreviewVisible.value = true;
}
function closeImagePreview() {
imagePreviewVisible.value = false;
}
function handleImagePreviewChange(event: { detail?: { current?: number } }) {
imagePreviewIndex.value = Number(event.detail?.current || 0);
} }
function openAsset(item: EvidenceAttachmentAsset, files: EvidenceAttachmentAsset[]) { function openAsset(item: EvidenceAttachmentAsset, files: EvidenceAttachmentAsset[]) {
@@ -209,6 +250,12 @@ function downloadPdf() {
}); });
} }
watch(traceInfoVisible, (visible) => {
if (!visible && activeTab.value === "trace") {
activeTab.value = "product";
}
});
onLoad(async (options) => { onLoad(async (options) => {
const id = Number(options?.id || 0); const id = Number(options?.id || 0);
const currentReportNo = String(options?.report_no || ""); const currentReportNo = String(options?.report_no || "");
@@ -237,7 +284,7 @@ onLoad(async (options) => {
<view class="app-page report-page"> <view class="app-page report-page">
<view v-if="!pageReady && loading" class="section notice-card"> <view v-if="!pageReady && loading" class="section notice-card">
<view class="notice-card__title">正在加载报告详情</view> <view class="notice-card__title">正在加载报告详情</view>
<view class="notice-card__desc">请稍候我们正在同步报告正文追溯信息 PDF 文件</view> <view class="notice-card__desc">请稍候我们正在同步报告正文与 PDF 文件</view>
</view> </view>
<view v-else-if="!pageReady && loadError" class="section notice-card"> <view v-else-if="!pageReady && loadError" class="section notice-card">
@@ -247,48 +294,67 @@ onLoad(async (options) => {
<template v-else> <template v-else>
<view class="report-shell"> <view class="report-shell">
<view class="report-carousel"> <view class="report-cover">
<swiper v-if="reportImages.length" class="report-carousel__swiper" indicator-dots circular> <swiper v-if="reportImages.length" class="report-cover__swiper" indicator-dots circular>
<swiper-item v-for="item in reportImages" :key="item.file_url || item.file_id"> <swiper-item v-for="item in reportImages" :key="item.file_url || item.file_id">
<image <image
class="report-carousel__image" class="report-cover__image"
:src="item.thumbnail_url || item.file_url" :src="item.thumbnail_url || item.file_url"
mode="aspectFill" mode="aspectFill"
@click="previewImages(reportImages, item.file_url)" @click="previewImages(reportImages, item.file_url)"
/> />
</swiper-item> </swiper-item>
</swiper> </swiper>
<view v-else class="report-carousel__empty">暂无鉴定图片</view> <view v-else class="report-cover__empty">暂无鉴定图片</view>
</view> </view>
<view class="report-summary"> <view class="report-meta">
<view class="report-summary__row"> <view class="report-meta__row">
<text class="report-summary__label">产品名称</text> <text class="report-meta__label">产品名称</text>
<text class="report-summary__value">{{ productName }}</text> <text class="report-meta__value">{{ productName }}</text>
</view> </view>
<view class="report-summary__row"> <view class="report-meta__row">
<text class="report-summary__label">检测机构</text> <text class="report-meta__label">检测机构</text>
<text class="report-summary__value">{{ institutionName }}</text> <text class="report-meta__value">{{ institutionName }}</text>
</view> </view>
<view class="report-summary__tools"> <view class="report-meta__row">
<text class="report-summary__chip">报告编号 {{ detail.report_header.report_no }}</text> <text class="report-meta__label">报告编号</text>
<text class="report-summary__chip">出具日期 {{ detail.report_header.publish_time || "-" }}</text> <text class="report-meta__value">{{ reportNo || "-" }}</text>
<text class="report-summary__download" @click="downloadPdf">{{ downloading ? "下载中..." : "下载 PDF" }}</text> </view>
<view class="report-meta__row report-meta__row--date">
<text class="report-meta__label">出具日期</text>
<text class="report-meta__download" @click="downloadPdf">{{ downloading ? "下载中" : "下载PDF" }}</text>
<text class="report-meta__value">{{ publishTime }}</text>
</view> </view>
</view> </view>
<view class="report-tabs"> <view class="report-tabs">
<view :class="['report-tab', activeTab === 'product' ? 'report-tab--active' : '']" @click="activeTab = 'product'">产品信息</view> <view :class="['report-tab', activeTab === 'product' ? 'report-tab--active' : '']" @click="activeTab = 'product'">产品信息</view>
<view :class="['report-tab', activeTab === 'trace' ? 'report-tab--active' : '']" @click="activeTab = 'trace'">追溯信息</view> <view v-if="traceInfoVisible" :class="['report-tab', activeTab === 'trace' ? 'report-tab--active' : '']" @click="activeTab = 'trace'">追溯信息</view>
</view> </view>
<view v-if="activeTab === 'product'" class="report-panel"> <view v-if="activeTab === 'product'" class="report-panel">
<view v-for="(item, index) in productItems" :key="`${item.label}-${index}`" class="product-row"> <view class="report-watermark" aria-hidden="true"></view>
<view class="product-row__label">{{ item.label }}</view>
<view class="product-row__value" :class="item.label === '检测结论' ? 'product-row__value--result' : ''"> <view class="report-result">
{{ item.value || "-" }} <view class="report-result__content">
<view class="report-result__label">{{ resultItem.label }}</view>
<view class="report-result__value">{{ resultItem.value || "-" }}</view>
<view v-if="resultItem.remark" class="report-result__desc">{{ resultItem.remark }}</view>
</view>
<view class="report-seal">
<text class="report-seal__brand">ANXINYAN</text>
<text class="report-seal__main">可信</text>
</view>
</view>
<view class="product-spec">
<view v-for="(item, index) in productSpecItems" :key="`${item.label}-${index}`" class="product-spec__row">
<view class="product-spec__label">{{ item.label }}</view>
<view class="product-spec__line"></view>
<view class="product-spec__value">{{ item.value || "-" }}</view>
<view v-if="item.remark" class="product-spec__remark">{{ item.remark }}</view>
</view> </view>
<view v-if="item.remark" class="product-row__remark">{{ item.remark }}</view>
</view> </view>
<view v-if="zhongjianReportFiles.length" class="inline-section"> <view v-if="zhongjianReportFiles.length" class="inline-section">
@@ -317,7 +383,8 @@ onLoad(async (options) => {
</view> </view>
</view> </view>
<view v-else class="report-panel"> <view v-else-if="traceInfoVisible" class="report-panel report-panel--trace">
<view v-if="traceNodes.length === 0" class="trace-empty">暂无追溯信息</view>
<view v-for="node in traceNodes" :key="node.code" class="trace-node"> <view v-for="node in traceNodes" :key="node.code" class="trace-node">
<view class="trace-node__head"> <view class="trace-node__head">
<view> <view>
@@ -353,6 +420,24 @@ onLoad(async (options) => {
<view class="btn btn--primary" @click="openAntiModal">防伪查询</view> <view class="btn btn--primary" @click="openAntiModal">防伪查询</view>
</view> </view>
<view v-if="imagePreviewVisible" class="image-preview-mask" @click="closeImagePreview">
<view class="image-preview__close" @click.stop="closeImagePreview">×</view>
<swiper
class="image-preview__swiper"
:current="imagePreviewIndex"
:circular="imagePreviewUrls.length > 1"
@change="handleImagePreviewChange"
@click.stop
>
<swiper-item v-for="url in imagePreviewUrls" :key="url" class="image-preview__item">
<image class="image-preview__image" :src="url" mode="aspectFit" />
</swiper-item>
</swiper>
<view v-if="imagePreviewUrls.length > 1" class="image-preview__counter">
{{ imagePreviewIndex + 1 }} / {{ imagePreviewUrls.length }}
</view>
</view>
<view v-if="antiModalVisible" class="anti-modal-mask" @click="closeAntiModal"> <view v-if="antiModalVisible" class="anti-modal-mask" @click="closeAntiModal">
<view class="anti-modal" @click.stop> <view class="anti-modal" @click.stop>
<view class="anti-modal__title">防伪查询</view> <view class="anti-modal__title">防伪查询</view>
@@ -388,115 +473,121 @@ onLoad(async (options) => {
<style scoped> <style scoped>
.report-page { .report-page {
padding-bottom: 148rpx; width: 100vw;
background: #eef6ff; max-width: 100vw;
box-sizing: border-box;
overflow-x: hidden;
padding: 28rpx 32rpx 170rpx;
background: #f1f3f6;
color: #3c3f45;
} }
.report-shell { .report-shell {
position: relative;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(33, 94, 160, 0.12); border-radius: 28rpx;
border-radius: 8rpx; background: #ffffff;
background: rgba(255, 255, 255, 0.96); box-shadow: 0 18rpx 48rpx rgba(31, 36, 48, 0.08);
box-shadow: 0 18rpx 42rpx rgba(30, 76, 130, 0.12);
} }
.report-carousel { .report-cover {
margin: 0 24rpx; height: 356rpx;
height: 392rpx; margin: 28rpx 28rpx 0;
overflow: hidden; overflow: hidden;
background: #eaf0f6; border-radius: 10rpx;
background: #e8eaee;
} }
.report-carousel__swiper, .report-cover__swiper,
.report-carousel__image, .report-cover__image,
.report-carousel__empty { .report-cover__empty {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.report-carousel__image { .report-cover__image {
display: block; display: block;
} }
.report-carousel__empty { .report-cover__empty {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--color-text-muted); color: #8c919b;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
.report-summary { .report-meta {
padding: 34rpx 40rpx 22rpx; padding: 34rpx 28rpx 12rpx;
} }
.report-summary__row { .report-meta__row {
display: flex; display: flex;
align-items: baseline; align-items: flex-start;
gap: 20rpx; gap: 18rpx;
min-width: 0; min-width: 0;
min-height: 48rpx;
} }
.report-summary__row + .report-summary__row { .report-meta__row + .report-meta__row {
margin-top: 24rpx; margin-top: 20rpx;
} }
.report-summary__label { .report-meta__label {
flex: 0 0 128rpx; flex: 0 0 138rpx;
color: var(--color-text-muted); display: block;
font-size: 30rpx; color: #7d828a;
font-size: 28rpx;
line-height: 1.4; line-height: 1.4;
} }
.report-summary__value { .report-meta__value {
flex: 1; flex: 1;
display: block;
min-width: 0; min-width: 0;
color: var(--color-heading); color: #44474d;
font-size: 34rpx; font-size: 28rpx;
font-weight: 900; font-weight: 700;
line-height: 1.35; line-height: 1.35;
word-break: break-word; text-align: right;
white-space: normal;
word-break: break-all;
overflow-wrap: anywhere;
} }
.report-summary__tools { .report-meta__row--date .report-meta__value {
display: flex; flex: 0 1 auto;
flex-wrap: wrap; margin-left: auto;
gap: 12rpx; word-break: normal;
margin-top: 28rpx;
} }
.report-summary__chip, .report-meta__download {
.report-summary__download { flex: 0 0 auto;
padding: 10rpx 14rpx; display: block;
min-height: 38rpx;
padding: 0 16rpx;
border: 1px solid rgba(221, 179, 47, 0.74);
border-radius: 6rpx; border-radius: 6rpx;
font-size: 22rpx; background: #fff9e6;
line-height: 1.3;
}
.report-summary__chip {
background: #f3f7fb;
color: var(--color-text-muted);
}
.report-summary__download {
border: 1px solid var(--color-accent);
color: var(--color-accent); color: var(--color-accent);
font-weight: 800; font-size: 22rpx;
font-weight: 700;
line-height: 36rpx;
} }
.report-tabs { .report-tabs {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 112rpx; gap: 104rpx;
padding: 10rpx 40rpx 28rpx; padding: 26rpx 40rpx 34rpx;
} }
.report-tab { .report-tab {
position: relative; position: relative;
color: #8b929d; color: #8e9298;
font-size: 34rpx; font-size: 32rpx;
font-weight: 800; font-weight: 700;
line-height: 1.6; line-height: 1.6;
} }
@@ -507,9 +598,9 @@ onLoad(async (options) => {
.report-tab--active::after { .report-tab--active::after {
position: absolute; position: absolute;
left: 50%; left: 50%;
bottom: -8rpx; bottom: -10rpx;
width: 42rpx; width: 44rpx;
height: 8rpx; height: 6rpx;
border-radius: 999rpx; border-radius: 999rpx;
background: var(--color-accent); background: var(--color-accent);
content: ""; content: "";
@@ -517,63 +608,185 @@ onLoad(async (options) => {
} }
.report-panel { .report-panel {
position: relative;
min-height: 440rpx; min-height: 440rpx;
padding: 18rpx 40rpx 46rpx; padding: 18rpx 28rpx 48rpx;
overflow: hidden;
} }
.product-row { .report-watermark {
padding: 22rpx 0; position: absolute;
border-bottom: 1px solid rgba(104, 121, 141, 0.14); top: -6rpx;
left: 50%;
width: 520rpx;
height: 430rpx;
border-radius: 50%;
opacity: 0.46;
transform: translateX(-50%);
background:
repeating-radial-gradient(ellipse at center, rgba(230, 195, 79, 0.2) 0, rgba(230, 195, 79, 0.2) 2rpx, transparent 3rpx, transparent 17rpx),
repeating-conic-gradient(from 0deg, rgba(230, 195, 79, 0.12) 0deg 8deg, transparent 8deg 16deg);
pointer-events: none;
} }
.product-row__label { .report-result {
color: var(--color-text-muted); position: relative;
font-size: 30rpx; z-index: 1;
line-height: 1.4; display: flex;
align-items: flex-start;
gap: 24rpx;
padding: 10rpx 0 30rpx;
border: 0;
border-bottom: 1px solid #e5e5e5;
border-radius: 0;
background: transparent;
box-shadow: none;
} }
.product-row__value { .report-result__content {
margin-top: 10rpx; flex: 1;
color: var(--color-heading); min-width: 0;
font-size: 32rpx; }
.report-result__label {
color: #3d3f44;
font-size: 28rpx;
font-weight: 800;
line-height: 1.45;
}
.report-result__value {
margin-top: 14rpx;
color: #e04135;
font-size: 38rpx;
font-weight: 900; font-weight: 900;
line-height: 1.4; line-height: 1.22;
word-break: break-word; letter-spacing: 0;
} }
.product-row__value--result { .report-result__desc {
color: #d83b4c; margin-top: 10rpx;
} color: #6f747c;
.product-row__remark {
margin-top: 8rpx;
color: var(--color-text-muted);
font-size: 24rpx; font-size: 24rpx;
line-height: 1.5; line-height: 1.55;
}
.report-seal {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 104rpx;
height: 104rpx;
margin-top: 2rpx;
border: 4rpx solid rgba(56, 164, 73, 0.8);
border-radius: 999rpx;
color: #39a54b;
transform: rotate(-10deg);
}
.report-seal__brand {
font-size: 16rpx;
font-weight: 800;
line-height: 1;
}
.report-seal__main {
margin-top: 8rpx;
font-size: 28rpx;
font-weight: 900;
line-height: 1;
}
.product-spec {
position: relative;
z-index: 1;
padding-top: 20rpx;
}
.product-spec__row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10rpx;
min-height: 54rpx;
}
.product-spec__label {
flex: 0 0 auto;
display: block;
color: #858991;
font-size: 24rpx;
line-height: 1.4;
}
.product-spec__line {
flex: 1;
min-width: 32rpx;
height: 1px;
border-bottom: 1px dotted #b9bdc4;
}
.product-spec__value {
flex: 0 1 auto;
display: block;
max-width: 58%;
color: #5b5f67;
font-size: 24rpx;
font-weight: 700;
line-height: 1.4;
text-align: right;
word-break: break-all;
overflow-wrap: anywhere;
}
.product-spec__remark {
flex-basis: 100%;
display: block;
margin: -2rpx 0 12rpx 0;
color: #8b9098;
font-size: 22rpx;
line-height: 1.45;
} }
.inline-section { .inline-section {
margin-top: 30rpx; position: relative;
z-index: 1;
margin-top: 32rpx;
padding-top: 24rpx;
border-top: 1px solid #ececec;
} }
.inline-section__title { .inline-section__title {
color: var(--color-heading); color: #3f4248;
font-size: 30rpx; font-size: 28rpx;
font-weight: 900; font-weight: 800;
}
.report-panel--trace {
padding-top: 2rpx;
}
.trace-empty {
padding: 58rpx 0;
color: #8b9098;
font-size: 26rpx;
text-align: center;
} }
.trace-node { .trace-node {
position: relative; position: relative;
padding: 24rpx 0 26rpx 34rpx; padding: 26rpx 0 28rpx 38rpx;
} }
.trace-node::before { .trace-node::before {
position: absolute; position: absolute;
left: 8rpx; left: 9rpx;
top: 34rpx; top: 38rpx;
bottom: -20rpx; bottom: -22rpx;
width: 2rpx; width: 2rpx;
background: #d9e5f2; background: #e4e0d3;
content: ""; content: "";
} }
@@ -584,7 +797,7 @@ onLoad(async (options) => {
.trace-node::after { .trace-node::after {
position: absolute; position: absolute;
left: 0; left: 0;
top: 34rpx; top: 36rpx;
width: 18rpx; width: 18rpx;
height: 18rpx; height: 18rpx;
border-radius: 999rpx; border-radius: 999rpx;
@@ -595,28 +808,28 @@ onLoad(async (options) => {
.trace-node__head { .trace-node__head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 18rpx; gap: 20rpx;
} }
.trace-node__title { .trace-node__title {
color: var(--color-heading); color: #3f4248;
font-size: 30rpx; font-size: 28rpx;
font-weight: 900; font-weight: 800;
} }
.trace-node__time, .trace-node__time,
.trace-node__empty { .trace-node__empty {
margin-top: 8rpx; margin-top: 8rpx;
color: var(--color-text-muted); color: #8b9098;
font-size: 24rpx; font-size: 23rpx;
} }
.trace-node__status { .trace-node__status {
align-self: flex-start; align-self: flex-start;
padding: 8rpx 12rpx; padding: 8rpx 12rpx;
border-radius: 6rpx; border-radius: 6rpx;
background: #f3f5f8; background: #f5f2e9;
color: var(--color-text-muted); color: #9f8433;
font-size: 22rpx; font-size: 22rpx;
font-weight: 800; font-weight: 800;
} }
@@ -629,7 +842,7 @@ onLoad(async (options) => {
.asset-grid { .asset-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14rpx; gap: 12rpx;
margin-top: 18rpx; margin-top: 18rpx;
} }
@@ -637,9 +850,9 @@ onLoad(async (options) => {
position: relative; position: relative;
aspect-ratio: 1; aspect-ratio: 1;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(104, 121, 141, 0.16); border: 1px solid rgba(127, 119, 94, 0.16);
border-radius: 8rpx; border-radius: 8rpx;
background: #f4f7fa; background: #f5f3ee;
} }
.asset-tile__image { .asset-tile__image {
@@ -656,7 +869,7 @@ onLoad(async (options) => {
height: 100%; height: 100%;
color: var(--color-accent); color: var(--color-accent);
font-size: 24rpx; font-size: 24rpx;
font-weight: 900; font-weight: 800;
} }
.asset-tile__play { .asset-tile__play {
@@ -686,10 +899,10 @@ onLoad(async (options) => {
gap: 16rpx; gap: 16rpx;
padding: 18rpx; padding: 18rpx;
border-radius: 8rpx; border-radius: 8rpx;
background: #f6f8fb; background: #faf8f1;
color: var(--color-heading); color: #3f4248;
font-size: 26rpx; font-size: 26rpx;
font-weight: 800; font-weight: 700;
} }
.asset-list__type { .asset-list__type {
@@ -700,6 +913,92 @@ onLoad(async (options) => {
.report-actions { .report-actions {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 28rpx;
width: 100vw;
box-sizing: border-box;
padding: 22rpx 32rpx calc(22rpx + env(safe-area-inset-bottom));
background: rgba(241, 243, 246, 0.96);
border-top: 0;
box-shadow: 0 -10rpx 34rpx rgba(31, 36, 48, 0.05);
}
.report-actions .btn {
min-width: 0;
min-height: 76rpx;
border-radius: 999rpx;
font-size: 28rpx;
font-weight: 700;
box-shadow: none;
}
.report-actions .btn--secondary {
border: 0;
background: #ffffff;
color: #4f535a;
}
.report-actions .btn--primary {
background: #dfb733;
color: #ffffff;
}
.image-preview-mask {
position: fixed;
z-index: 200;
inset: 0;
background: rgba(0, 0, 0, 0.88);
touch-action: none;
}
.image-preview__swiper {
width: 100%;
height: 100%;
}
.image-preview__item {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.image-preview__image {
display: block;
width: 100%;
height: 100%;
}
.image-preview__close {
position: fixed;
z-index: 201;
top: calc(110rpx + env(safe-area-inset-top));
right: 34rpx;
display: flex;
align-items: center;
justify-content: center;
width: 72rpx;
height: 72rpx;
color: #ffffff;
font-size: 72rpx;
font-weight: 300;
line-height: 1;
}
.image-preview__counter {
position: fixed;
z-index: 201;
left: 50%;
bottom: calc(42rpx + env(safe-area-inset-bottom));
min-width: 96rpx;
padding: 10rpx 22rpx;
border-radius: 999rpx;
background: rgba(0, 0, 0, 0.42);
color: #ffffff;
font-size: 24rpx;
line-height: 1.3;
text-align: center;
transform: translateX(-50%);
} }
.anti-modal-mask { .anti-modal-mask {
@@ -710,19 +1009,19 @@ onLoad(async (options) => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 48rpx; padding: 48rpx;
background: rgba(13, 30, 48, 0.46); background: rgba(31, 36, 48, 0.42);
} }
.anti-modal { .anti-modal {
width: 100%; width: 100%;
max-width: 620rpx; max-width: 620rpx;
padding: 34rpx; padding: 34rpx;
border-radius: 8rpx; border-radius: 20rpx;
background: #fff; background: #fff;
} }
.anti-modal__title { .anti-modal__title {
color: var(--color-heading); color: #2f3238;
font-size: 36rpx; font-size: 36rpx;
font-weight: 900; font-weight: 900;
line-height: 1.35; line-height: 1.35;
@@ -730,7 +1029,7 @@ onLoad(async (options) => {
.anti-modal__desc { .anti-modal__desc {
margin-top: 12rpx; margin-top: 12rpx;
color: var(--color-text-muted); color: #7e838b;
font-size: 26rpx; font-size: 26rpx;
line-height: 1.5; line-height: 1.5;
} }
@@ -742,7 +1041,7 @@ onLoad(async (options) => {
.anti-result { .anti-result {
margin-top: 20rpx; margin-top: 20rpx;
padding: 18rpx; padding: 18rpx;
border-radius: 8rpx; border-radius: 10rpx;
background: #f7f8fa; background: #f7f8fa;
} }
@@ -755,14 +1054,14 @@ onLoad(async (options) => {
} }
.anti-result__title { .anti-result__title {
color: var(--color-heading); color: #2f3238;
font-size: 28rpx; font-size: 28rpx;
font-weight: 900; font-weight: 900;
} }
.anti-result__desc { .anti-result__desc {
margin-top: 8rpx; margin-top: 8rpx;
color: var(--color-text-muted); color: #7e838b;
font-size: 24rpx; font-size: 24rpx;
line-height: 1.5; line-height: 1.5;
} }
@@ -776,9 +1075,9 @@ onLoad(async (options) => {
.anti-modal__button { .anti-modal__button {
height: 82rpx; height: 82rpx;
border-radius: 8rpx; border-radius: 999rpx;
font-size: 28rpx; font-size: 28rpx;
font-weight: 900; font-weight: 800;
line-height: 82rpx; line-height: 82rpx;
} }

View File

@@ -33,6 +33,19 @@ export interface AdminDirectUploadPolicy {
expires_at?: string; expires_at?: 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;
}
function filenameFromPath(filePath: string) { function filenameFromPath(filePath: string) {
return filePath.split(/[\\/]/).pop() || `upload-${Date.now()}`; return filePath.split(/[\\/]/).pop() || `upload-${Date.now()}`;
} }
@@ -475,6 +488,9 @@ export const adminApi = {
getManualOrderMeta() { getManualOrderMeta() {
return request<AdminManualOrderMeta>("/api/admin/manual-order/meta"); return request<AdminManualOrderMeta>("/api/admin/manual-order/meta");
}, },
getExpressCompanies(params: { enabled_only?: 0 | 1 } = { enabled_only: 1 }) {
return request<{ list: AdminExpressCompanyItem[]; default_company: string }>("/api/admin/express-companies", { params });
},
createManualOrder(data: AdminManualOrderCreatePayload) { createManualOrder(data: AdminManualOrderCreatePayload) {
return request<AdminManualOrderCreateResponse>("/api/admin/manual-order/create", { return request<AdminManualOrderCreateResponse>("/api/admin/manual-order/create", {
method: "POST", method: "POST",

View File

@@ -3,13 +3,15 @@ import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app"; import { onLoad } from "@dcloudio/uni-app";
import { adminApi, type AdminManualOrderCreatePayload, type AdminManualOrderMeta } from "../../api/admin"; import { adminApi, type AdminManualOrderCreatePayload, type AdminManualOrderMeta } from "../../api/admin";
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback"; import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
import { buildRegionPickerState, updateRegionPickerIndexes } from "../../utils/regions"; import { recognizeReturnAddress } from "../../utils/address-recognition";
import { buildRegionPickerState, findRegionIndexes, updateRegionPickerIndexes } from "../../utils/regions";
const loading = ref(false); const loading = ref(false);
const submitting = ref(false); const submitting = ref(false);
const meta = ref<AdminManualOrderMeta>({ categories: [], brands: [] }); const meta = ref<AdminManualOrderMeta>({ categories: [], brands: [] });
const form = ref<AdminManualOrderCreatePayload>(createForm()); const form = ref<AdminManualOrderCreatePayload>(createForm());
const regionPickerIndexes = ref<[number, number, number]>([0, 0, 0]); const regionPickerIndexes = ref<[number, number, number]>([0, 0, 0]);
const addressRecognitionText = ref("");
const providerOptions = [ const providerOptions = [
{ label: "实物鉴定", value: "anxinyan" }, { label: "实物鉴定", value: "anxinyan" },
@@ -110,6 +112,21 @@ function pickerText(options: Array<{ label?: string; name?: string }>, index: nu
return item?.label || item?.name || fallback; return item?.label || item?.name || fallback;
} }
function applyRecognizedReturnAddress() {
const result = recognizeReturnAddress(addressRecognitionText.value);
if (!result.ok || !result.address) {
showInfoToast(result.message || "寄回地址识别失败");
return;
}
form.value.return_address = {
...form.value.return_address,
...result.address,
};
regionPickerIndexes.value = findRegionIndexes(result.address);
showInfoToast("寄回地址已识别并填入");
}
function validateForm() { function validateForm() {
const product = form.value.product_info; const product = form.value.product_info;
const address = form.value.return_address; const address = form.value.return_address;
@@ -173,6 +190,14 @@ onLoad(() => {
<view class="card stack"> <view class="card stack">
<view class="card-title">寄回信息</view> <view class="card-title">寄回信息</view>
<view class="address-recognition">
<textarea
v-model="addressRecognitionText"
class="textarea address-recognition__textarea"
placeholder="粘贴收货人、收货电话、收货地址,自动识别后填入下方字段"
/>
<button class="btn btn--ghost address-recognition__button" @click="applyRecognizedReturnAddress">识别并填入</button>
</view>
<input v-model="form.return_address.consignee" class="field" placeholder="收件人" /> <input v-model="form.return_address.consignee" class="field" placeholder="收件人" />
<input v-model="form.return_address.mobile" class="field" type="number" placeholder="手机号,用于匹配用户" /> <input v-model="form.return_address.mobile" class="field" type="number" placeholder="手机号,用于匹配用户" />
<picker <picker
@@ -216,6 +241,19 @@ onLoad(() => {
line-height: 1.4; line-height: 1.4;
} }
.address-recognition {
display: grid;
gap: 12rpx;
}
.address-recognition__textarea {
min-height: 188rpx;
}
.address-recognition__button {
justify-self: stretch;
}
.region-field { .region-field {
justify-content: space-between; justify-content: space-between;
gap: 16rpx; gap: 16rpx;

View File

@@ -48,7 +48,7 @@ const resultMetaItems = computed(() => {
for (const point of normalizedKeyPoints(result.key_points)) { for (const point of normalizedKeyPoints(result.key_points)) {
items.push({ items.push({
label: point.point_name, label: point.point_name,
value: [point.point_value, point.point_remark].filter(Boolean).join("") || "-", value: point.point_value || "-",
}); });
} }
@@ -97,7 +97,7 @@ function normalizedKeyPoints(value: unknown) {
point_remark: textValue(point.point_remark), point_remark: textValue(point.point_remark),
}; };
}) })
.filter((item) => item.point_value || item.point_remark); .filter((item) => item.point_value);
} }
function isImageAsset(item: AdminFileAsset) { function isImageAsset(item: AdminFileAsset) {

View File

@@ -1,12 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app"; import { onLoad } from "@dcloudio/uni-app";
import { adminApi, type AdminFileAsset, type AdminWarehouseWorkbenchContext } from "../../api/admin"; import { adminApi, type AdminExpressCompanyItem, type AdminFileAsset, type AdminWarehouseWorkbenchContext } from "../../api/admin";
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback"; import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
const internalTagNo = ref(""); const internalTagNo = ref("");
const expressCompany = ref(""); const expressCompany = ref("");
const trackingNo = ref(""); const trackingNo = ref("");
const expressCompanyOptions = ref<AdminExpressCompanyItem[]>([]);
const expressCompanyLoading = ref(false);
const defaultExpressCompany = ref("");
const context = ref<AdminWarehouseWorkbenchContext | null>(null); const context = ref<AdminWarehouseWorkbenchContext | null>(null);
const loading = ref(false); const loading = ref(false);
const submitting = ref(false); const submitting = ref(false);
@@ -16,6 +19,14 @@ const activeVideo = ref<AdminFileAsset | null>(null);
const RETURN_SHIPPED_STORAGE_KEY = "warehouse_return_shipped_context"; const RETURN_SHIPPED_STORAGE_KEY = "warehouse_return_shipped_context";
const returnConfirmed = computed(() => Boolean(context.value?.transfer_flow?.return_confirmed_at)); const returnConfirmed = computed(() => Boolean(context.value?.transfer_flow?.return_confirmed_at));
const expressCompanyNames = computed(() => {
const names = expressCompanyOptions.value.map((item) => item.company_name);
if (expressCompany.value && !names.includes(expressCompany.value)) {
return [expressCompany.value, ...names];
}
return names;
});
const expressCompanyIndex = computed(() => Math.max(0, expressCompanyNames.value.findIndex((item) => item === expressCompany.value)));
const canSubmit = computed(() => const canSubmit = computed(() =>
returnConfirmed.value && returnConfirmed.value &&
Boolean(expressCompany.value.trim()) && Boolean(expressCompany.value.trim()) &&
@@ -53,6 +64,27 @@ async function fetchContext() {
} }
} }
async function fetchExpressCompanies() {
expressCompanyLoading.value = true;
try {
const response = await adminApi.getExpressCompanies({ enabled_only: 1 });
expressCompanyOptions.value = response.list;
defaultExpressCompany.value = response.default_company;
if (!expressCompany.value) {
expressCompany.value = response.default_company || response.list[0]?.company_name || "";
}
} catch (error) {
showErrorToast(error, "快递公司列表加载失败");
} finally {
expressCompanyLoading.value = false;
}
}
function onExpressCompanyChange(event: any) {
const index = Number(event.detail?.value || 0);
expressCompany.value = expressCompanyNames.value[index] || "";
}
function scanTrackingNo() { function scanTrackingNo() {
uni.scanCode({ uni.scanCode({
scanType: ["barCode", "qrCode"], scanType: ["barCode", "qrCode"],
@@ -189,6 +221,7 @@ async function submitReturnShipping() {
onLoad((options) => { onLoad((options) => {
internalTagNo.value = readQueryString(options?.internal_tag_no); internalTagNo.value = readQueryString(options?.internal_tag_no);
void fetchExpressCompanies();
void fetchContext(); void fetchContext();
}); });
</script> </script>
@@ -241,7 +274,13 @@ onLoad((options) => {
<view class="card"> <view class="card">
<view class="card-title">快递单号</view> <view class="card-title">快递单号</view>
<view class="card-desc">报告确认后登记回寄物流信息</view> <view class="card-desc">报告确认后登记回寄物流信息</view>
<input v-model="expressCompany" class="field form-field" placeholder="回寄快递公司,例如:顺丰速运" /> <picker :range="expressCompanyNames" :value="expressCompanyIndex" @change="onExpressCompanyChange">
<view class="field picker-field form-field">
<text v-if="expressCompany" class="picker-field__value">{{ expressCompany }}</text>
<text v-else class="picker-field__placeholder">{{ expressCompanyLoading ? "正在加载快递公司" : "请选择回寄快递公司" }}</text>
<text class="picker-field__arrow"></text>
</view>
</picker>
<view class="scan-control"> <view class="scan-control">
<input v-model="trackingNo" class="field scan-input" placeholder="扫描或输入回寄运单号" /> <input v-model="trackingNo" class="field scan-input" placeholder="扫描或输入回寄运单号" />
<button class="btn scan-button" @click="scanTrackingNo">扫码</button> <button class="btn scan-button" @click="scanTrackingNo">扫码</button>
@@ -305,6 +344,38 @@ onLoad((options) => {
margin-top: 18rpx; margin-top: 18rpx;
} }
.picker-field {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.picker-field__value,
.picker-field__placeholder {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picker-field__value {
color: var(--work-text);
}
.picker-field__placeholder {
color: var(--work-text-muted);
}
.picker-field__arrow {
width: 14rpx;
height: 14rpx;
flex: 0 0 14rpx;
border-right: 3rpx solid var(--work-text-soft);
border-bottom: 3rpx solid var(--work-text-soft);
transform: rotate(45deg);
}
.scan-control { .scan-control {
display: flex; display: flex;
gap: 14rpx; gap: 14rpx;

View File

@@ -269,7 +269,7 @@ function templateKeyPointsPayload() {
point_code: item.point_code, point_code: item.point_code,
point_name: item.point_name, point_name: item.point_name,
point_value: item.point_value || "", point_value: item.point_value || "",
point_remark: item.point_remark || "", point_remark: "",
})) || []; })) || [];
} }
@@ -787,13 +787,6 @@ onShow(() => {
:placeholder="`${item.point_name} 值`" :placeholder="`${item.point_name} 值`"
@input="updateTemplatePointFromInput(index, 'point_value', $event)" @input="updateTemplatePointFromInput(index, 'point_value', $event)"
/> />
<textarea
:value="item.point_remark"
class="textarea"
:disabled="isTaskReadonly"
:placeholder="`${item.point_name} 说明`"
@input="updateTemplatePointFromInput(index, 'point_remark', $event)"
/>
</view> </view>
</view> </view>
@@ -888,13 +881,6 @@ onShow(() => {
:placeholder="`${item.point_name} 值`" :placeholder="`${item.point_name} 值`"
@input="updateTemplatePointFromInput(index, 'point_value', $event)" @input="updateTemplatePointFromInput(index, 'point_value', $event)"
/> />
<textarea
:value="item.point_remark"
class="textarea"
:disabled="isTaskReadonly"
:placeholder="`${item.point_name} 说明`"
@input="updateTemplatePointFromInput(index, 'point_remark', $event)"
/>
</view> </view>
</view> </view>

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(),
},
};
}