chore: release updated anxinyan version
This commit is contained in:
@@ -33,6 +33,19 @@ export interface AdminDirectUploadPolicy {
|
||||
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) {
|
||||
return filePath.split(/[\\/]/).pop() || `upload-${Date.now()}`;
|
||||
}
|
||||
@@ -475,6 +488,9 @@ export const adminApi = {
|
||||
getManualOrderMeta() {
|
||||
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) {
|
||||
return request<AdminManualOrderCreateResponse>("/api/admin/manual-order/create", {
|
||||
method: "POST",
|
||||
|
||||
@@ -3,13 +3,15 @@ import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { adminApi, type AdminManualOrderCreatePayload, type AdminManualOrderMeta } from "../../api/admin";
|
||||
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 submitting = ref(false);
|
||||
const meta = ref<AdminManualOrderMeta>({ categories: [], brands: [] });
|
||||
const form = ref<AdminManualOrderCreatePayload>(createForm());
|
||||
const regionPickerIndexes = ref<[number, number, number]>([0, 0, 0]);
|
||||
const addressRecognitionText = ref("");
|
||||
|
||||
const providerOptions = [
|
||||
{ label: "实物鉴定", value: "anxinyan" },
|
||||
@@ -110,6 +112,21 @@ function pickerText(options: Array<{ label?: string; name?: string }>, index: nu
|
||||
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() {
|
||||
const product = form.value.product_info;
|
||||
const address = form.value.return_address;
|
||||
@@ -173,6 +190,14 @@ onLoad(() => {
|
||||
|
||||
<view class="card stack">
|
||||
<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.mobile" class="field" type="number" placeholder="手机号,用于匹配用户" />
|
||||
<picker
|
||||
@@ -216,6 +241,19 @@ onLoad(() => {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.address-recognition {
|
||||
display: grid;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.address-recognition__textarea {
|
||||
min-height: 188rpx;
|
||||
}
|
||||
|
||||
.address-recognition__button {
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.region-field {
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
|
||||
@@ -48,7 +48,7 @@ const resultMetaItems = computed(() => {
|
||||
for (const point of normalizedKeyPoints(result.key_points)) {
|
||||
items.push({
|
||||
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),
|
||||
};
|
||||
})
|
||||
.filter((item) => item.point_value || item.point_remark);
|
||||
.filter((item) => item.point_value);
|
||||
}
|
||||
|
||||
function isImageAsset(item: AdminFileAsset) {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
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";
|
||||
|
||||
const internalTagNo = ref("");
|
||||
const expressCompany = ref("");
|
||||
const trackingNo = ref("");
|
||||
const expressCompanyOptions = ref<AdminExpressCompanyItem[]>([]);
|
||||
const expressCompanyLoading = ref(false);
|
||||
const defaultExpressCompany = ref("");
|
||||
const context = ref<AdminWarehouseWorkbenchContext | null>(null);
|
||||
const loading = 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 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(() =>
|
||||
returnConfirmed.value &&
|
||||
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() {
|
||||
uni.scanCode({
|
||||
scanType: ["barCode", "qrCode"],
|
||||
@@ -189,6 +221,7 @@ async function submitReturnShipping() {
|
||||
|
||||
onLoad((options) => {
|
||||
internalTagNo.value = readQueryString(options?.internal_tag_no);
|
||||
void fetchExpressCompanies();
|
||||
void fetchContext();
|
||||
});
|
||||
</script>
|
||||
@@ -241,7 +274,13 @@ onLoad((options) => {
|
||||
<view class="card">
|
||||
<view class="card-title">快递单号</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">
|
||||
<input v-model="trackingNo" class="field scan-input" placeholder="扫描或输入回寄运单号" />
|
||||
<button class="btn scan-button" @click="scanTrackingNo">扫码</button>
|
||||
@@ -305,6 +344,38 @@ onLoad((options) => {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 14rpx;
|
||||
|
||||
@@ -269,7 +269,7 @@ function templateKeyPointsPayload() {
|
||||
point_code: item.point_code,
|
||||
point_name: item.point_name,
|
||||
point_value: item.point_value || "",
|
||||
point_remark: item.point_remark || "",
|
||||
point_remark: "",
|
||||
})) || [];
|
||||
}
|
||||
|
||||
@@ -787,13 +787,6 @@ onShow(() => {
|
||||
:placeholder="`${item.point_name} 值`"
|
||||
@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>
|
||||
|
||||
@@ -888,13 +881,6 @@ onShow(() => {
|
||||
:placeholder="`${item.point_name} 值`"
|
||||
@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>
|
||||
|
||||
|
||||
213
work-app/src/utils/address-recognition.ts
Normal file
213
work-app/src/utils/address-recognition.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import regionSource from "../static/regions/pca.json";
|
||||
|
||||
type RegionNode = {
|
||||
code: string;
|
||||
name: string;
|
||||
children?: RegionNode[];
|
||||
};
|
||||
|
||||
export type RecognizedReturnAddress = {
|
||||
consignee: string;
|
||||
mobile: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
detail_address: string;
|
||||
};
|
||||
|
||||
export type RecognizeReturnAddressResult = {
|
||||
ok: boolean;
|
||||
address?: RecognizedReturnAddress;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const regionTree = regionSource as RegionNode[];
|
||||
const nameLabels = ["收货人", "收件人", "姓名", "联系人", "取件人"];
|
||||
const mobileLabels = ["收货电话", "联系电话", "手机号", "手机号码", "手机", "电话"];
|
||||
const addressLabels = ["收货地址", "收件地址", "寄回地址", "地址"];
|
||||
const allLabels = [...nameLabels, ...mobileLabels, ...addressLabels];
|
||||
|
||||
function normalizeLines(raw: string) {
|
||||
return raw
|
||||
.replace(/\r/g, "\n")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function labelMatch(line: string, labels: string[]) {
|
||||
for (const label of labels) {
|
||||
const pattern = new RegExp(`^\\s*${label}\\s*[::]?\\s*(.*)$`);
|
||||
const match = line.match(pattern);
|
||||
if (match) {
|
||||
return { label, value: String(match[1] || "").trim() };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isKnownLabel(line: string) {
|
||||
const normalized = line.replace(/\s+/g, "");
|
||||
return allLabels.some((label) => normalized === label || normalized === `${label}:` || normalized === `${label}:`);
|
||||
}
|
||||
|
||||
function extractLabeledValue(lines: string[], labels: string[], block = false) {
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const match = labelMatch(lines[index], labels);
|
||||
if (!match) continue;
|
||||
if (match.value) return match.value;
|
||||
|
||||
const values: string[] = [];
|
||||
for (let nextIndex = index + 1; nextIndex < lines.length; nextIndex += 1) {
|
||||
const nextLine = lines[nextIndex];
|
||||
if (labelMatch(nextLine, allLabels) && isKnownLabel(nextLine)) {
|
||||
break;
|
||||
}
|
||||
if (labelMatch(nextLine, allLabels)?.value) {
|
||||
break;
|
||||
}
|
||||
values.push(nextLine);
|
||||
if (!block) break;
|
||||
}
|
||||
return values.join(block ? "" : " ").trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeMobile(value: string) {
|
||||
const directMatch = value.match(/1[3-9]\d{9}/);
|
||||
if (directMatch) return directMatch[0];
|
||||
|
||||
const digits = value.replace(/\D+/g, "");
|
||||
return digits.match(/1[3-9]\d{9}/)?.[0] || "";
|
||||
}
|
||||
|
||||
function stripKnownAddressPrefixes(value: string) {
|
||||
return value
|
||||
.replace(/\s+/g, "")
|
||||
.replace(/^(中国大陆|中华人民共和国|中国|大陆)+/, "")
|
||||
.replace(/^(收货地址|收件地址|寄回地址|地址)[::]?/, "");
|
||||
}
|
||||
|
||||
function aliases(name: string) {
|
||||
const suffixes = ["特别行政区", "壮族自治区", "回族自治区", "维吾尔自治区", "自治区", "自治州", "自治县", "地区", "省", "市", "区", "县", "旗", "盟"];
|
||||
const values = [name];
|
||||
for (const suffix of suffixes) {
|
||||
if (name.endsWith(suffix) && name.length > suffix.length) {
|
||||
values.push(name.slice(0, -suffix.length));
|
||||
}
|
||||
}
|
||||
return Array.from(new Set(values)).sort((a, b) => b.length - a.length);
|
||||
}
|
||||
|
||||
function consumePrefix(text: string, names: string[]) {
|
||||
for (const name of names) {
|
||||
if (name && text.startsWith(name)) {
|
||||
return { consumed: name.length, rest: text.slice(name.length) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isDirectCity(province: RegionNode, city: RegionNode) {
|
||||
return province.name === city.name || aliases(province.name).some((name) => aliases(city.name).includes(name));
|
||||
}
|
||||
|
||||
function matchDistrict(city: RegionNode, text: string) {
|
||||
for (const district of city.children || []) {
|
||||
const match = consumePrefix(text, aliases(district.name));
|
||||
if (match) {
|
||||
return { district, detail: match.rest };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchRegion(addressText: string) {
|
||||
const address = stripKnownAddressPrefixes(addressText);
|
||||
if (!address) return null;
|
||||
|
||||
for (const province of regionTree) {
|
||||
const provinceMatch = consumePrefix(address, aliases(province.name));
|
||||
if (!provinceMatch) continue;
|
||||
|
||||
for (const city of province.children || []) {
|
||||
const cityMatch = consumePrefix(provinceMatch.rest, aliases(city.name));
|
||||
const districtSource = cityMatch ? cityMatch.rest : (isDirectCity(province, city) ? provinceMatch.rest : "");
|
||||
if (!districtSource) continue;
|
||||
|
||||
const districtMatch = matchDistrict(city, districtSource);
|
||||
if (districtMatch) {
|
||||
return {
|
||||
province: province.name,
|
||||
city: city.name,
|
||||
district: districtMatch.district.name,
|
||||
detail_address: districtMatch.detail,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const province of regionTree) {
|
||||
for (const city of province.children || []) {
|
||||
const cityMatch = consumePrefix(address, aliases(city.name));
|
||||
if (!cityMatch) continue;
|
||||
|
||||
const districtMatch = matchDistrict(city, cityMatch.rest);
|
||||
if (districtMatch) {
|
||||
return {
|
||||
province: province.name,
|
||||
city: city.name,
|
||||
district: districtMatch.district.name,
|
||||
detail_address: districtMatch.detail,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function fallbackAddressLine(lines: string[], consignee: string, mobile: string) {
|
||||
return lines
|
||||
.map((line) => labelMatch(line, allLabels)?.value || line)
|
||||
.filter((line) => line && line !== consignee && !line.includes(mobile) && !normalizeMobile(line))
|
||||
.sort((a, b) => b.length - a.length)[0] || "";
|
||||
}
|
||||
|
||||
export function recognizeReturnAddress(raw: string): RecognizeReturnAddressResult {
|
||||
const lines = normalizeLines(raw);
|
||||
if (!lines.length) {
|
||||
return { ok: false, message: "请先粘贴寄回地址信息" };
|
||||
}
|
||||
|
||||
const consignee = extractLabeledValue(lines, nameLabels).trim();
|
||||
const mobile = normalizeMobile(extractLabeledValue(lines, mobileLabels) || raw);
|
||||
const addressText = extractLabeledValue(lines, addressLabels, true) || fallbackAddressLine(lines, consignee, mobile);
|
||||
const region = matchRegion(addressText);
|
||||
|
||||
if (!consignee) {
|
||||
return { ok: false, message: "未识别到收件人,请检查文本中是否包含收货人或收件人" };
|
||||
}
|
||||
if (!mobile) {
|
||||
return { ok: false, message: "未识别到有效手机号,请检查文本中的收货电话" };
|
||||
}
|
||||
if (!region) {
|
||||
return { ok: false, message: "未识别到省市区,请检查地址是否包含城市和区县" };
|
||||
}
|
||||
if (!region.detail_address.trim()) {
|
||||
return { ok: false, message: "未识别到详细地址,请检查区县后的街道门牌信息" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
address: {
|
||||
consignee,
|
||||
mobile,
|
||||
province: region.province,
|
||||
city: region.city,
|
||||
district: region.district,
|
||||
detail_address: region.detail_address.trim(),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user