This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
4862
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
309
utils/shippingAddress.js
Normal file
309
utils/shippingAddress.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user