fix: 优化换货
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 1m48s

This commit is contained in:
sexygoat
2026-05-14 12:14:05 +08:00
parent 7253996f40
commit b611808316
11 changed files with 5653 additions and 86 deletions

View File

@@ -1,13 +1,11 @@
<template> <template>
<view class="card user-info-card interactive"> <view class="card user-info-card interactive">
<view class="flex-row-g20"> <view class="flex-row-g20">
<!-- <view class="user-avatar">
<image :src="avatarUrl" mode="aspectFill" alt="用户头像" />
</view> -->
<view class="user-details flex-col-g8"> <view class="user-details flex-col-g8">
<view class="flex-row-g20"> <view class="flex-row-g20">
<view class="title">{{ currentCardNo || '-' }}</view> <view class="title">{{ currentCardNo || '-' }}</view>
</view> </view>
<view class="caption">ICCID: {{ deviceInfo.iccid || '-' }}</view>
<view class="caption">套餐名称{{ deviceInfo.packageName || '-' }}</view> <view class="caption">套餐名称{{ deviceInfo.packageName || '-' }}</view>
<view class="caption">套餐到期时间{{ deviceInfo.expireDate || '-' }}</view> <view class="caption">套餐到期时间{{ deviceInfo.expireDate || '-' }}</view>
</view> </view>
@@ -22,11 +20,6 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue';
import { useUserStore } from '@/store/index.js';
const userStore = useUserStore();
defineProps({ defineProps({
currentCardNo: { type: String, default: '' }, currentCardNo: { type: String, default: '' },
deviceInfo: { type: Object, default: () => ({}) }, deviceInfo: { type: Object, default: () => ({}) },
@@ -34,32 +27,12 @@
networkStatus: { type: [String, Number], default: '离线' }, networkStatus: { type: [String, Number], default: '离线' },
isDevice: { type: Boolean, default: true } isDevice: { type: Boolean, default: true }
}); });
const defaultAvatar = 'https://img1.baidu.com/it/u=2462918877,1866131262&fm=253&fmt=auto&app=138&f=JPEG?w=506&h=500';
const avatarUrl = computed(() => {
return userStore.state.userInfo.avatar || defaultAvatar;
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.user-info-card { .user-info-card {
color: var(--text-primary); color: var(--text-primary);
.user-avatar {
width: 80rpx;
height: 80rpx;
border-radius: var(--radius-medium);
overflow: hidden;
border: 2rpx solid var(--border-light);
image {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.user-details { .user-details {
flex: 1; flex: 1;
} }

View File

@@ -0,0 +1,49 @@
## Context
The current device exchange popup stores recipient name, phone, and address in three flat fields, with `recipient_address` being a single string. The shipping submit API currently accepts only `recipient_name`, `recipient_phone`, and `recipient_address`, so the frontend must remain compatible unless the backend contract changes later.
The homepage already loads `/api/c/v1/asset/info` through `assetApi.getInfo(identifier)`. The UI shows package name and expiry date in the top summary card, but the request now also returns `data.iccid`, which should be exposed without adding a second asset query.
## Goals / Non-Goals
- Goals:
- Add a structured province/city/district selector using `element-china-area-data`
- Keep a separate detailed-address field for device exchange shipping info
- Allow users to paste one shipping-info string and prefill recipient name, phone, and address fields
- Preserve compatibility with the existing exchange shipping submit endpoint
- Show `ICCID` above the package name in the homepage top summary card
- Non-Goals:
- Redesign the backend exchange shipping API
- Add OCR or image-based recognition for shipping labels
- Normalize all historical address data outside the device exchange popup
## Decisions
- Decision: Use `element-china-area-data` as the source for province/city/district options.
Why: The request explicitly requires that dataset, and a static local area dataset avoids introducing extra network dependencies.
- Decision: Keep `/api/c/v1/exchange/{id}/shipping-info` unchanged and compose `recipient_address` on the client.
Why: The existing API already accepts one address string, so the frontend can implement the new UX without blocking on backend changes.
- Decision: Recognize pasted shipping text with best-effort client-side parsing for mainland China phone numbers and common copied-address formats.
Why: Users often copy a single line or multi-line string from chat or e-commerce apps, and partial automation reduces manual entry while still allowing correction.
- Decision: When reopening existing saved shipping info, prefill split fields with best-effort parsing and preserve the original address content if a full region split cannot be derived.
Why: Existing records are already stored as a combined string, so the UI needs a no-data-loss compatibility path during the transition.
- Decision: Use `data.iccid` from the existing asset info response as the homepage summary source of truth.
Why: The user explicitly named that field and endpoint, and it avoids mixing multiple ICCID sources in the header card.
## Risks / Trade-offs
- Pasted shipping strings vary in format, so recognition must remain best-effort and allow manual correction before submission.
- Some previously saved combined addresses may not map cleanly back to province/city/district selections, so fallback behavior must preserve the original text.
- Adding a new area dataset dependency increases bundle size slightly, but it keeps address selection deterministic and local.
## Migration Plan
1. Add the `element-china-area-data` dependency and wire it into the device exchange popup.
2. Introduce split address state, pasted-text parsing, and composed submit payload handling.
3. Update homepage asset state to read `data.iccid` and render it above package name.
4. Verify that existing exchange records remain editable without losing previously saved address content.

View File

@@ -0,0 +1,28 @@
## Why
The device exchange popup currently collects the shipping address as a single free-text field. This makes Chinese address entry error-prone, does not support selecting province/city/district from a standard area dataset, and forces users to manually rewrite recipient details even when they already copied a full shipping string from another app.
The homepage top summary card already loads asset information from `/api/c/v1/asset/info`, but it does not surface the `ICCID` value even though the response now provides `data.iccid`. Users need that value visible above the package name on the homepage.
## What Changes
- Replace the single shipping-address input in `pages/device-exchange/device-exchange.vue` with province/city/district selection backed by `element-china-area-data` plus a separate detailed-address input
- Add a pasteable shipping-info input so the page can recognize and prefill recipient name, phone number, and address from one copied string
- Keep the existing exchange shipping submission API contract and assemble the selected region text together with the detailed address into `recipient_address`
- Add an `ICCID` line above the package name in the homepage top summary card
- Use `data.iccid` from `/api/c/v1/asset/info?identifier=...` as the homepage `ICCID` source of truth
## Capabilities
### New Capabilities
- `device-exchange-shipping-entry`: Define structured shipping entry, pasted shipping-text recognition, and payload composition for the device exchange popup
- `index-asset-summary`: Define homepage summary display rules for `ICCID`
### Modified Capabilities
## Impact
- Affected code: `pages/device-exchange/device-exchange.vue`, `pages/index/index.vue`, `components/UserInfoCard.vue`, related address parsing helpers if introduced
- Affected dependencies: `element-china-area-data`
- Affected API usage: `/api/c/v1/exchange/{id}/shipping-info`, `/api/c/v1/asset/info`
- No backend API contract change is required if the frontend continues submitting a composed `recipient_address` string

View File

@@ -0,0 +1,50 @@
## ADDED Requirements
### Requirement: Device exchange shipping entry SHALL collect a structured recipient address
The system SHALL collect recipient name, recipient phone number, a province/city/district selection sourced from `element-china-area-data`, and a separate detailed address when the user fills shipping information for a pending device exchange.
#### Scenario: User opens the shipping popup for a pending exchange
- **WHEN** the device exchange record is in the state that allows filling shipping information
- **AND** the user opens the shipping popup
- **THEN** the popup SHALL show editable fields for recipient name, recipient phone number, province/city/district, and detailed address
#### Scenario: User attempts to submit without a complete address
- **WHEN** the user submits the shipping popup without selecting province/city/district or without entering a detailed address
- **THEN** the system SHALL block submission
- **AND** the system SHALL tell the user that the shipping address is incomplete
### Requirement: Device exchange shipping entry SHALL support pasted shipping-text recognition
The system SHALL provide a way for the user to paste one shipping-info string and SHALL attempt to recognize recipient name, mainland China phone number, and address content from that string into editable form fields.
#### Scenario: Pasted shipping text contains recognizable recipient information
- **WHEN** the user pastes a shipping-info string containing a recipient name, an 11-digit mainland China phone number, and address content
- **THEN** the system SHALL prefill the recognized recipient name and phone number
- **AND** the system SHALL prefill the recognized address content into the structured address form as far as it can infer
#### Scenario: Pasted shipping text is only partially recognizable
- **WHEN** the user pastes a shipping-info string but the system cannot confidently extract every required field
- **THEN** the system SHALL preserve the fields it can recognize
- **AND** the user SHALL still be able to manually complete or correct the remaining fields before submission
### Requirement: Device exchange shipping submission SHALL preserve the existing API contract
The system SHALL continue submitting `recipient_name`, `recipient_phone`, and `recipient_address` to `/api/c/v1/exchange/{id}/shipping-info`, and SHALL compose `recipient_address` from the selected province/city/district text together with the detailed address.
#### Scenario: User submits valid shipping information
- **WHEN** the user submits the popup with a valid recipient name, a valid mainland China phone number, a selected province/city/district, and a detailed address
- **THEN** the system SHALL call `/api/c/v1/exchange/{id}/shipping-info`
- **AND** the payload SHALL keep `recipient_name` and `recipient_phone`
- **AND** the payload SHALL send a composed `recipient_address` string containing the selected region text and the detailed address
### Requirement: Existing exchange shipping information SHALL remain editable without data loss
The system SHALL preserve previously saved shipping information when reopening the popup and SHALL support best-effort conversion from a previously saved combined address string into the new structured address fields.
#### Scenario: Existing saved address can be split into region and detail
- **WHEN** the popup opens for an exchange record that already has a saved `recipient_address`
- **AND** the saved address can be matched to province/city/district data
- **THEN** the system SHALL prefill the region selection and the detailed address
#### Scenario: Existing saved address cannot be fully split
- **WHEN** the popup opens for an exchange record that already has a saved `recipient_address`
- **AND** the saved address cannot be confidently split into province/city/district plus detailed address
- **THEN** the system SHALL preserve the original saved address content for manual correction
- **AND** the user SHALL still be able to complete a valid structured address before resubmitting

View File

@@ -0,0 +1,23 @@
## ADDED Requirements
### Requirement: Homepage top summary SHALL display ICCID above the package name
The system SHALL display an `ICCID` line above the package name in the homepage top summary card after asset information is loaded.
#### Scenario: Asset info returns an ICCID value
- **WHEN** the homepage loads asset information successfully
- **AND** the response contains `data.iccid`
- **THEN** the top summary card SHALL display `ICCID: <value>` above the package name
#### Scenario: Asset info does not return an ICCID value
- **WHEN** the homepage loads asset information successfully
- **AND** `data.iccid` is empty or missing
- **THEN** the top summary card SHALL still display the `ICCID` field
- **AND** the field SHALL show a stable fallback value
### Requirement: Homepage ICCID SHALL use the asset info response as the source of truth
The system SHALL source the homepage `ICCID` value from `data.iccid` returned by `/api/c/v1/asset/info?identifier=...`.
#### Scenario: Homepage refreshes asset information
- **WHEN** the homepage reads or refreshes asset information from `/api/c/v1/asset/info`
- **THEN** the `ICCID` shown in the top summary card SHALL be updated from `data.iccid`
- **AND** the header SHALL NOT depend on deriving the display value from a different ICCID field for this summary position

View File

@@ -0,0 +1,20 @@
## 1. Device Exchange Shipping Entry
- [x] 1.1 Add `element-china-area-data` and integrate province/city/district selection into `pages/device-exchange/device-exchange.vue`
- [x] 1.2 Replace the single shipping-address input with structured region selection plus a separate detailed-address input
- [x] 1.3 Add a pasted shipping-info input and parse recipient name, recipient phone, and address content into editable form fields
- [x] 1.4 Compose the selected region text and detailed address into `recipient_address` when submitting `/api/c/v1/exchange/{id}/shipping-info`
- [x] 1.5 Preserve edit compatibility for previously saved combined addresses
## 2. Homepage ICCID Summary
- [x] 2.1 Read `data.iccid` from `assetApi.getInfo(identifier)` into homepage state
- [x] 2.2 Render `ICCID` above the package name in `components/UserInfoCard.vue`
- [x] 2.3 Show a stable fallback when `data.iccid` is absent
## 3. Regression Verification
- [x] 3.1 Verify the device exchange popup blocks submission when region selection or detailed address is incomplete
- [x] 3.2 Verify a pasted shipping string can prefill recipient name, phone number, and address with manual correction still available
- [x] 3.3 Verify the exchange shipping submit payload remains compatible with the existing backend contract
- [x] 3.4 Verify the homepage top summary shows `ICCID` from `/api/c/v1/asset/info` above the package name

View File

@@ -19,6 +19,7 @@
"@dcloudio/uni-h5": "3.0.0-4080720251210001", "@dcloudio/uni-h5": "3.0.0-4080720251210001",
"clipboard": "^2.0.11", "clipboard": "^2.0.11",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"element-china-area-data": "^6.1.0",
"jweixin-module": "^1.6.0", "jweixin-module": "^1.6.0",
"uview-plus": "^3.6.29", "uview-plus": "^3.6.29",
"vue": "^3.4.0" "vue": "^3.4.0"

View File

@@ -1,83 +1,158 @@
<template> <template>
<view class="container"> <view class="container">
<view v-if="!exchangeData && !loading" class="empty-state"> <view v-if="!exchangeData && !loading" class="empty-state">
<view class="empty-icon">🔄</view> <view class="empty-icon">📧</view>
<view class="empty-title">暂无换货记录</view> <view class="empty-title">暂无换货记录</view>
<view class="empty-desc">当前账号下暂无设备换货记录</view> <view class="empty-desc">当前账号下暂无设备换货记录</view>
</view> </view>
<view v-else-if="exchangeData" class="card exchange-card"> <view v-else-if="exchangeData" class="card exchange-card">
<view class="exchange-header flex-row-sb"> <view class="exchange-header flex-row-sb">
<view class="exchange-no">{{ exchangeData?.exchange_no }}</view> <view class="exchange-no">{{ exchangeData.exchange_no }}</view>
<view class="tag-apple" :class="getStatusClass(exchangeData?.status)">{{ exchangeData?.status_text }}</view> <view class="tag-apple" :class="getStatusClass(exchangeData.status)">
{{ exchangeData.status_text }}
</view>
</view> </view>
<view class="exchange-info"> <view class="exchange-info">
<view class="info-row flex-row-sb"> <view class="info-row flex-row-sb">
<view class="info-label">换货原因</view> <view class="info-label">换货原因</view>
<view class="info-value">{{ exchangeData?.exchange_reason }}</view> <view class="info-value">{{ exchangeData.exchange_reason }}</view>
</view> </view>
<view class="info-row flex-row-sb"> <view class="info-row flex-row-sb">
<view class="info-label">申请时间</view> <view class="info-label">申请时间</view>
<view class="info-value">{{ formatTime(exchangeData?.created_at) }}</view> <view class="info-value">{{ formatTime(exchangeData.created_at) }}</view>
</view> </view>
<view class="info-row flex-row-sb" v-if="exchangeData?.recipient_name"> <view class="info-row flex-row-sb" v-if="exchangeData.recipient_name">
<view class="info-label">收件人</view> <view class="info-label">收件人</view>
<view class="info-value">{{ exchangeData?.recipient_name }}</view> <view class="info-value">{{ exchangeData.recipient_name }}</view>
</view> </view>
<view class="info-row flex-row-sb" v-if="exchangeData?.recipient_phone"> <view class="info-row flex-row-sb" v-if="exchangeData.recipient_phone">
<view class="info-label">联系电话</view> <view class="info-label">联系电话</view>
<view class="info-value">{{ exchangeData?.recipient_phone }}</view> <view class="info-value">{{ exchangeData.recipient_phone }}</view>
</view> </view>
<view class="info-row flex-row-sb" v-if="exchangeData?.recipient_address"> <view class="info-row flex-row-sb" v-if="exchangeData.recipient_address">
<view class="info-label">收货地址</view> <view class="info-label">收货地址</view>
<view class="info-value address-value">{{ exchangeData?.recipient_address }}</view> <view class="info-value address-value">{{ exchangeData.recipient_address }}</view>
</view> </view>
</view> </view>
<view class="exchange-actions" v-if="exchangeData?.status === 1"> <view class="exchange-actions" v-if="exchangeData.status === 1">
<button class="btn-apple btn-primary" @tap="openPopup">填写信息</button> <button class="btn-apple btn-primary" @tap="openPopup">填写信息</button>
</view> </view>
</view> </view>
<up-popup :show="showPopup" mode="center" @close="showPopup=false"> <up-popup :show="showPopup" mode="center" @close="handlePopupClose">
<view class="popup-content"> <view class="popup-content">
<view class="popup-title">填写换货信息</view> <view class="popup-title">填写换货信息</view>
<view class="form-item">
<view class="form-label">粘贴收货信息</view>
<up-textarea
v-model="form.raw_shipping_text"
placeholder="可粘贴包含收件人、电话、地址的一整段文字"
autoHeight
:maxlength="-1"
border="surround"
/>
<view class="form-helper">识别结果可继续手动修改</view>
<button class="btn-apple btn-secondary parse-btn" @tap="fillFromPastedText">
识别并填充
</button>
</view>
<view class="form-item"> <view class="form-item">
<view class="form-label">收件人姓名</view> <view class="form-label">收件人姓名</view>
<up-input placeholder="请输入收件人姓名" border="surround" v-model="form.recipient_name" /> <up-input
v-model="form.recipient_name"
placeholder="请输入收件人姓名"
border="surround"
/>
</view> </view>
<view class="form-item"> <view class="form-item">
<view class="form-label">收件人电话</view> <view class="form-label">收件人电话</view>
<up-input placeholder="请输入收件人电话" border="surround" v-model="form.recipient_phone" /> <up-input
v-model="form.recipient_phone"
placeholder="请输入收件人电话"
border="surround"
/>
</view> </view>
<view class="form-item"> <view class="form-item">
<view class="form-label">收货地址</view> <view class="form-label">所在地区</view>
<up-input placeholder="请输入详细收货地址" border="surround" v-model="form.recipient_address" /> <view class="region-trigger" @tap="openAreaPicker">
<text class="region-trigger-text" :class="{ placeholder: !regionDisplayText }">
{{ regionDisplayText || '请选择省 / 市 / 区' }}
</text>
</view>
</view> </view>
<view class="form-item">
<view class="form-label">详细地址</view>
<up-textarea
v-model="form.recipient_address_detail"
placeholder="请输入详细收货地址"
autoHeight
:maxlength="-1"
border="surround"
/>
</view>
<view class="popup-btn-group"> <view class="popup-btn-group">
<button class="btn-apple btn-secondary" @tap="showPopup=false">取消</button> <button class="btn-apple btn-secondary" @tap="handlePopupClose">取消</button>
<button class="btn-apple btn-primary" @tap="submitExchange">提交</button> <button class="btn-apple btn-primary" @tap="submitExchange">提交</button>
</view> </view>
</view> </view>
</up-popup> </up-popup>
<up-picker
:show="showAreaPicker"
:columns="areaPickerColumns"
keyName="label"
title="选择所在地区"
:defaultIndex="areaPickerDefaultIndex"
closeOnClickOverlay
@confirm="confirmAreaPicker"
@change="handleAreaPickerChange"
@cancel="showAreaPicker = false"
@close="showAreaPicker = false"
/>
</view> </view>
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue'; import { computed, onMounted, reactive, ref } from 'vue';
import { exchangeApi } from '@/api/index.js'; import { exchangeApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js'; import { useUserStore } from '@/store/index.js';
import {
buildRegionColumns,
composeRecipientAddress,
getRegionDisplayText,
parseSavedRecipientAddress,
parseShippingText
} from '@/utils/shippingAddress.js';
const PHONE_REG = /^1[3-9]\d{9}$/;
const userStore = useUserStore(); const userStore = useUserStore();
let exchangeData = ref(null); const exchangeData = ref(null);
let loading = ref(false); const loading = ref(false);
let showPopup = ref(false); const showPopup = ref(false);
let form = reactive({ const showAreaPicker = ref(false);
const areaPickerColumns = ref([]);
const areaPickerDefaultIndex = ref([0, 0, 0]);
const form = reactive({
raw_shipping_text: '',
recipient_name: '', recipient_name: '',
recipient_phone: '', recipient_phone: '',
recipient_address: '' recipient_region_codes: [],
recipient_address_detail: ''
});
const regionDisplayText = computed(() => {
return getRegionDisplayText(form.recipient_region_codes);
}); });
const getStatusClass = (status) => { const getStatusClass = (status) => {
@@ -93,67 +168,166 @@
const formatTime = (time) => { const formatTime = (time) => {
if (!time) return '-'; if (!time) return '-';
return time.split('T').join(' ').slice(0, 19); return String(time).split('T').join(' ').slice(0, 19);
};
const syncAreaPickerState = (regionCodes = []) => {
const { columns, defaultIndex } = buildRegionColumns(regionCodes);
areaPickerColumns.value = columns;
areaPickerDefaultIndex.value = defaultIndex.map((index) => (index < 0 ? 0 : index));
}; };
const loadExchange = async () => { const loadExchange = async () => {
loading.value = true; loading.value = true;
try { try {
const data = await exchangeApi.getPending(userStore.state.identifier); const data = await exchangeApi.getPending(userStore.state.identifier);
// 接口返回的是单个对象,不是数组
exchangeData.value = data || null; exchangeData.value = data || null;
} catch (e) { } catch (error) {
console.error('加载换货记录失败', e); console.error('加载换货记录失败', error);
exchangeData.value = null; exchangeData.value = null;
} finally {
loading.value = false;
} }
loading.value = false; };
const handlePopupClose = () => {
showPopup.value = false;
showAreaPicker.value = false;
};
const openAreaPicker = () => {
syncAreaPickerState(form.recipient_region_codes);
showAreaPicker.value = true;
}; };
const openPopup = () => { const openPopup = () => {
form.recipient_name = exchangeData?.recipient_name || ''; const currentExchange = exchangeData.value || {};
form.recipient_phone = exchangeData?.recipient_phone || ''; const { regionCodes, detailAddress } = parseSavedRecipientAddress(
form.recipient_address = exchangeData?.recipient_address || ''; currentExchange.recipient_address || ''
);
form.raw_shipping_text = '';
form.recipient_name = currentExchange.recipient_name || '';
form.recipient_phone = currentExchange.recipient_phone || '';
form.recipient_region_codes = regionCodes;
form.recipient_address_detail = detailAddress;
syncAreaPickerState(regionCodes);
showPopup.value = true; showPopup.value = true;
}; };
const handleAreaPickerChange = (event) => {
const selectedValues = event.value || [];
const selectedProvinceCode = selectedValues[0]?.value || '';
const selectedCityCode =
event.columnIndex === 0 ? '' : selectedValues[1]?.value || '';
const selectedDistrictCode =
event.columnIndex === 2 ? selectedValues[2]?.value || '' : '';
const { columns, defaultIndex } = buildRegionColumns([
selectedProvinceCode,
selectedCityCode,
selectedDistrictCode
]);
areaPickerColumns.value = columns;
areaPickerDefaultIndex.value = defaultIndex.map((index) => (index < 0 ? 0 : index));
};
const confirmAreaPicker = ({ value }) => {
const selectedCodes = (value || []).map((item) => item?.value).filter(Boolean);
if (selectedCodes.length === 3) {
form.recipient_region_codes = selectedCodes;
syncAreaPickerState(selectedCodes);
}
showAreaPicker.value = false;
};
const fillFromPastedText = () => {
if (!form.raw_shipping_text.trim()) {
uni.showToast({ title: '请先粘贴收货信息', icon: 'none' });
return;
}
const parsed = parseShippingText(form.raw_shipping_text);
let updatedCount = 0;
if (parsed.recipient_name) {
form.recipient_name = parsed.recipient_name;
updatedCount += 1;
}
if (parsed.recipient_phone) {
form.recipient_phone = parsed.recipient_phone;
updatedCount += 1;
}
if (parsed.regionCodes.length === 3) {
form.recipient_region_codes = parsed.regionCodes;
syncAreaPickerState(parsed.regionCodes);
updatedCount += 1;
}
if (parsed.detailAddress) {
form.recipient_address_detail = parsed.detailAddress;
updatedCount += 1;
}
if (!updatedCount) {
uni.showToast({ title: '未识别到有效收货信息', icon: 'none' });
return;
}
uni.showToast({ title: '已识别并填充', icon: 'success' });
};
const submitExchange = async () => { const submitExchange = async () => {
if (!form.recipient_name) { if (!form.recipient_name.trim()) {
uni.showToast({ title: '请输入收件人姓名', icon: 'none' }); uni.showToast({ title: '请输入收件人姓名', icon: 'none' });
return; return;
} }
if (!form.recipient_phone) {
if (!form.recipient_phone.trim()) {
uni.showToast({ title: '请输入收件人电话', icon: 'none' }); uni.showToast({ title: '请输入收件人电话', icon: 'none' });
return; return;
} }
// 手机号格式校验 if (!PHONE_REG.test(form.recipient_phone.trim())) {
const phoneReg = /^1[3-9]\d{9}$/;
if (!phoneReg.test(form.recipient_phone)) {
uni.showToast({ title: '请输入正确的手机号', icon: 'none' }); uni.showToast({ title: '请输入正确的手机号', icon: 'none' });
return; return;
} }
if (!form.recipient_address) { if (form.recipient_region_codes.length !== 3) {
uni.showToast({ title: '请输入收货地址', icon: 'none' }); uni.showToast({ title: '请选择省市区', icon: 'none' });
return; return;
} }
if (!form.recipient_address_detail.trim()) {
uni.showToast({ title: '请输入详细收货地址', icon: 'none' });
return;
}
const recipientAddress = composeRecipientAddress(
form.recipient_region_codes,
form.recipient_address_detail
);
try { try {
await exchangeApi.submitShippingInfo( await exchangeApi.submitShippingInfo(
exchangeData.value.id, exchangeData.value.id,
form.recipient_name, form.recipient_name.trim(),
form.recipient_phone, form.recipient_phone.trim(),
form.recipient_address recipientAddress
); );
uni.showToast({ title: '提交成功', icon: 'success' }); uni.showToast({ title: '提交成功', icon: 'success' });
showPopup.value = false; handlePopupClose();
loadExchange(); await loadExchange();
} catch (e) { } catch (error) {
console.error('提交换货信息失败', e); console.error('提交换货信息失败', error);
} }
}; };
onMounted(() => { onMounted(() => {
syncAreaPickerState();
loadExchange(); loadExchange();
}); });
</script> </script>
@@ -167,24 +341,57 @@
justify-content: center; justify-content: center;
padding: 120rpx 40rpx; padding: 120rpx 40rpx;
min-height: 60vh; min-height: 60vh;
.empty-icon { font-size: 120rpx; margin-bottom: 30rpx; opacity: 0.6; }
.empty-title { font-size: 32rpx; font-weight: 600; color: var(--text-primary); margin-bottom: 16rpx; } .empty-icon {
.empty-desc { font-size: 26rpx; color: var(--text-tertiary); text-align: center; margin-bottom: 40rpx; } font-size: 120rpx;
.empty-btn { width: 100%; max-width: 400rpx; } margin-bottom: 30rpx;
opacity: 0.6;
}
.empty-title {
font-size: 32rpx;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 16rpx;
}
.empty-desc {
font-size: 26rpx;
color: var(--text-tertiary);
text-align: center;
margin-bottom: 40rpx;
}
} }
.exchange-card { .exchange-card {
.exchange-header { .exchange-header {
margin-bottom: var(--space-md); margin-bottom: var(--space-md);
.exchange-no { font-size: 28rpx; font-weight: 600; color: var(--text-primary); }
.exchange-no {
font-size: 28rpx;
font-weight: 600;
color: var(--text-primary);
}
} }
.exchange-info { .exchange-info {
.info-row { .info-row {
padding: var(--space-xs) 0; padding: var(--space-xs) 0;
.info-label { font-size: 24rpx; color: var(--text-tertiary); }
.info-value { font-size: 24rpx; color: var(--text-primary); } .info-label {
.address-value { max-width: 400rpx; text-align: right; } font-size: 24rpx;
color: var(--text-tertiary);
}
.info-value {
font-size: 24rpx;
color: var(--text-primary);
}
.address-value {
max-width: 400rpx;
text-align: right;
}
} }
} }
@@ -192,14 +399,21 @@
margin-top: var(--space-lg); margin-top: var(--space-lg);
padding-top: var(--space-md); padding-top: var(--space-md);
border-top: 1rpx solid var(--gray-200); border-top: 1rpx solid var(--gray-200);
.btn-apple { width: 100%; }
.btn-apple {
width: 100%;
}
} }
} }
} }
.popup-content { .popup-content {
width: 600rpx; width: 660rpx;
max-height: 80vh;
padding: 30rpx; padding: 30rpx;
box-sizing: border-box;
overflow-y: auto;
.popup-title { .popup-title {
font-size: 32rpx; font-size: 32rpx;
font-weight: 600; font-weight: 600;
@@ -207,19 +421,55 @@
text-align: center; text-align: center;
margin-bottom: var(--space-lg); margin-bottom: var(--space-lg);
} }
.form-item { .form-item {
margin-bottom: var(--space-md); margin-bottom: var(--space-md);
.form-label { .form-label {
font-size: 26rpx; font-size: 26rpx;
color: var(--text-secondary); color: var(--text-secondary);
margin-bottom: var(--space-xs); margin-bottom: var(--space-xs);
} }
.form-helper {
margin-top: var(--space-xs);
font-size: 22rpx;
color: var(--text-tertiary);
}
.parse-btn {
width: 100%;
margin-top: var(--space-sm);
}
} }
.region-trigger {
display: flex;
align-items: center;
min-height: 84rpx;
padding: 0 24rpx;
border: 2rpx solid var(--border-light);
border-radius: 12rpx;
background: #fff;
.region-trigger-text {
font-size: 28rpx;
color: var(--text-primary);
&.placeholder {
color: var(--text-tertiary);
}
}
}
.popup-btn-group { .popup-btn-group {
display: flex; display: flex;
gap: var(--space-md); gap: var(--space-md);
margin-top: var(--space-lg); margin-top: var(--space-lg);
.btn-apple { flex: 1; }
.btn-apple {
flex: 1;
}
} }
} }
</style> </style>

View File

@@ -151,6 +151,7 @@
packageName: '-', packageName: '-',
expireDate: '-', expireDate: '-',
walletBalance: 0, walletBalance: 0,
iccid: '-',
currentIccid: '-', currentIccid: '-',
category: '-', category: '-',
phone: '-', phone: '-',
@@ -233,6 +234,7 @@
deviceInfo.bound_phone = data.bound_phone || ''; deviceInfo.bound_phone = data.bound_phone || '';
deviceInfo.packageName = data.current_package || '-'; deviceInfo.packageName = data.current_package || '-';
deviceInfo.expireDate = formatDate(data.current_package_expires_at); deviceInfo.expireDate = formatDate(data.current_package_expires_at);
deviceInfo.iccid = data.iccid || '-';
deviceInfo.walletBalance = data.wallet_balance ?? 0; deviceInfo.walletBalance = data.wallet_balance ?? 0;
isRealName.value = data.real_name_status === 1; isRealName.value = data.real_name_status === 1;
realNameStatus.value = data.real_name_status === 1 ? '已实名' : '未实名'; realNameStatus.value = data.real_name_status === 1 ? '已实名' : '未实名';

4862
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

309
utils/shippingAddress.js Normal file
View File

@@ -0,0 +1,309 @@
import { regionData } from 'element-china-area-data';
const GENERIC_CITY_LABELS = new Set([
'市辖区',
'县',
'自治区直辖县级行政区划',
'省直辖县级行政区划'
]);
const NAME_LABEL_REG = /(收货人|收件人|联系人|姓名)/g;
const PHONE_LABEL_REG = /(手机号码|手机号|联系电话|联系方式|电话|手机)/g;
const ADDRESS_LABEL_REG = /(收货地址|详细地址|地址|所在地区|地区)/g;
const ADDRESS_SIGNAL_REG = /(省|市|区|县|旗|乡|镇|街道|大道|路|街|巷|弄|村|社区|小区|号|栋|单元|室|楼)/;
const PHONE_REG = /(?<!\d)(?:\+?86[-\s]?)?(1[3-9](?:[\s-]?\d){9})(?!\d)/;
const provinceOptions = regionData.map(({ label, value }) => ({ label, value }));
const districtRecords = regionData.flatMap((province) =>
(province.children || []).flatMap((city) =>
(city.children || []).map((district) => ({
province,
city,
district,
provinceSearchLabel: normalizeForSearch(province.label),
citySearchLabel: normalizeForSearch(getDisplayCityLabel(city.label)),
districtSearchLabel: normalizeForSearch(district.label),
fullSearchLabel: normalizeForSearch(
`${province.label}${getDisplayCityLabel(city.label)}${district.label}`
)
}))
)
);
function normalizeText(value = '') {
return String(value)
.replace(/\r/g, '\n')
.replace(/\u00a0/g, ' ')
.replace(/\t/g, ' ')
.trim();
}
function normalizeForSearch(value = '') {
return normalizeText(value).replace(/[\s,.。;::、\-_/]/g, '');
}
function getDisplayCityLabel(label = '') {
return GENERIC_CITY_LABELS.has(label) ? '' : label;
}
function getProvinceByCode(provinceCode) {
return regionData.find((item) => item.value === provinceCode) || null;
}
function getCityByCode(provinceCode, cityCode) {
const province = getProvinceByCode(provinceCode);
return province?.children?.find((item) => item.value === cityCode) || null;
}
function getDistrictByCode(provinceCode, cityCode, districtCode) {
const city = getCityByCode(provinceCode, cityCode);
return city?.children?.find((item) => item.value === districtCode) || null;
}
function getCityOptions(provinceCode) {
const province = getProvinceByCode(provinceCode) || regionData[0];
return (province?.children || []).map(({ label, value }) => ({ label, value }));
}
function getDistrictOptions(provinceCode, cityCode) {
const province = getProvinceByCode(provinceCode) || regionData[0];
const city =
province?.children?.find((item) => item.value === cityCode) ||
province?.children?.[0] ||
null;
return (city?.children || []).map(({ label, value }) => ({ label, value }));
}
function getLabelParts(regionCodes = []) {
const [provinceCode, cityCode, districtCode] = regionCodes;
const province = getProvinceByCode(provinceCode);
const city = getCityByCode(provinceCode, cityCode);
const district = getDistrictByCode(provinceCode, cityCode, districtCode);
if (!province || !city || !district) {
return [];
}
const displayCityLabel = getDisplayCityLabel(city.label);
return [province.label, displayCityLabel, district.label].filter(Boolean);
}
function cleanupAddressText(value = '') {
return normalizeText(value)
.replace(NAME_LABEL_REG, ' ')
.replace(PHONE_LABEL_REG, ' ')
.replace(ADDRESS_LABEL_REG, ' ')
.replace(/[:]/g, ' ')
.replace(/[,;]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function extractPhone(rawText = '') {
const text = normalizeText(rawText);
const match = text.match(PHONE_REG);
if (!match) {
return {
phone: '',
textWithoutPhone: text
};
}
const digits = match[1].replace(/\D/g, '');
const phone = digits.length > 11 ? digits.slice(-11) : digits;
return {
phone,
textWithoutPhone: text.replace(match[0], ' ').replace(/\s+/g, ' ').trim()
};
}
function pickBestMatch(records, matcher) {
return records
.filter(matcher)
.sort((left, right) => {
return (
right.fullSearchLabel.length - left.fullSearchLabel.length ||
right.districtSearchLabel.length - left.districtSearchLabel.length
);
})[0] || null;
}
function findRegionMatch(rawText = '') {
const searchText = normalizeForSearch(rawText);
if (!searchText) {
return null;
}
let match = pickBestMatch(districtRecords, (record) => {
return record.fullSearchLabel && searchText.includes(record.fullSearchLabel);
});
if (!match) {
match = pickBestMatch(districtRecords, (record) => {
return (
record.provinceSearchLabel &&
record.districtSearchLabel &&
searchText.includes(record.provinceSearchLabel) &&
searchText.includes(record.districtSearchLabel)
);
});
}
return match;
}
function getRegionStartIndex(text, match) {
const cityLabel = getDisplayCityLabel(match.city.label);
const candidates = [match.province.label, cityLabel, match.district.label]
.filter(Boolean)
.map((label) => text.indexOf(label))
.filter((index) => index >= 0);
return candidates.length ? Math.min(...candidates) : -1;
}
function stripMatchedRegion(addressText, match) {
const cityLabel = getDisplayCityLabel(match.city.label);
let detailAddress = normalizeText(addressText);
[match.province.label, cityLabel, match.district.label]
.filter(Boolean)
.forEach((label) => {
detailAddress = detailAddress.replace(label, '');
});
return detailAddress.replace(/^[,;:\s]+/, '').trim();
}
function extractNameFromText(rawText = '', textWithoutPhone = '', regionStartIndex = -1) {
const sourceText = regionStartIndex > 0
? textWithoutPhone.slice(0, regionStartIndex)
: textWithoutPhone || rawText;
const directNameMatch = normalizeText(rawText).match(
/(?:收货人|收件人|联系人|姓名)\s*[:]?\s*([A-Za-z\u4e00-\u9fa5·]{2,20})/
);
if (directNameMatch?.[1]) {
return directNameMatch[1].trim();
}
const cleaned = cleanupAddressText(sourceText)
.replace(PHONE_REG, ' ')
.replace(/\d+/g, ' ')
.trim();
const tokens = cleaned.split(/\s+/).filter(Boolean);
const nameToken = tokens.find((token) => {
return !ADDRESS_SIGNAL_REG.test(token) && /^[A-Za-z\u4e00-\u9fa5·]{2,20}$/.test(token);
});
return nameToken || '';
}
function extractAddressSource(textWithoutPhone = '', name = '', match = null) {
let addressSource = normalizeText(textWithoutPhone);
if (name) {
addressSource = addressSource.replace(name, ' ').replace(/\s+/g, ' ').trim();
}
if (match) {
const regionStartIndex = getRegionStartIndex(addressSource, match);
if (regionStartIndex >= 0) {
return addressSource.slice(regionStartIndex).trim();
}
}
return cleanupAddressText(addressSource);
}
function formatRegionCodes(match) {
return [match.province.value, match.city.value, match.district.value];
}
export function buildRegionColumns(regionCodes = []) {
const provinceCode =
getProvinceByCode(regionCodes[0])?.value || provinceOptions[0]?.value || '';
const cityOptions = getCityOptions(provinceCode);
const cityCode =
cityOptions.find((item) => item.value === regionCodes[1])?.value ||
cityOptions[0]?.value ||
'';
const districtOptions = getDistrictOptions(provinceCode, cityCode);
const districtCode =
districtOptions.find((item) => item.value === regionCodes[2])?.value ||
districtOptions[0]?.value ||
'';
return {
columns: [provinceOptions, cityOptions, districtOptions],
defaultIndex: [
provinceOptions.findIndex((item) => item.value === provinceCode),
cityOptions.findIndex((item) => item.value === cityCode),
districtOptions.findIndex((item) => item.value === districtCode)
]
};
}
export function getRegionDisplayText(regionCodes = []) {
return getLabelParts(regionCodes).join(' / ');
}
export function composeRecipientAddress(regionCodes = [], detailAddress = '') {
const regionText = getLabelParts(regionCodes).join('');
return `${regionText}${normalizeText(detailAddress)}`.trim();
}
export function parseSavedRecipientAddress(rawAddress = '') {
const addressText = normalizeText(rawAddress);
if (!addressText) {
return {
regionCodes: [],
detailAddress: ''
};
}
const match = findRegionMatch(addressText);
if (!match) {
return {
regionCodes: [],
detailAddress: addressText
};
}
const detailAddress = stripMatchedRegion(addressText, match);
return {
regionCodes: formatRegionCodes(match),
detailAddress: detailAddress || addressText
};
}
export function parseShippingText(rawText = '') {
const text = normalizeText(rawText);
if (!text) {
return {
recipient_name: '',
recipient_phone: '',
regionCodes: [],
detailAddress: ''
};
}
const { phone, textWithoutPhone } = extractPhone(text);
const regionMatch = findRegionMatch(textWithoutPhone);
const regionStartIndex = regionMatch ? getRegionStartIndex(textWithoutPhone, regionMatch) : -1;
const recipientName = extractNameFromText(text, textWithoutPhone, regionStartIndex);
const addressSource = extractAddressSource(textWithoutPhone, recipientName, regionMatch);
const detailAddress = regionMatch
? stripMatchedRegion(addressSource, regionMatch)
: cleanupAddressText(addressSource);
return {
recipient_name: recipientName,
recipient_phone: phone,
regionCodes: regionMatch ? formatRegionCodes(regionMatch) : [],
detailAddress
};
}