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

@@ -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",

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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>

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