feat(order): 支持东南亚多国地址格式的订单创建功能

- 实现动态地址表单,根据选择国家显示对应字段
- 添加新加坡组屋号、单元号,马来西亚州属,菲律宾Barangay等特殊字段
- 集成泰国双语地址、越南省市坊三级地址格式支持
- 优化订单确认页地址展示,按从小到大格式排列
- 添加邮编格式验证和自动匹配城市功能
- 实现SKU货币自动识别国家并预设字段
- 重构README文档结构和项目说明信息
This commit is contained in:
2025-12-24 11:20:34 +08:00
parent 2dfd0c13a8
commit bd6b7b3b79
4 changed files with 507 additions and 263 deletions

View File

@@ -74,11 +74,12 @@
</el-form-item>
<el-divider>收货地址</el-divider>
<el-alert v-if="currentCountryConfig" :title="`地址格式:${currentCountryConfig.addressFormat}`" type="info" :closable="false" style="margin-bottom: 20px" />
<el-form-item label="收货人姓名" prop="shippingName">
<el-input
v-model="form.shippingName"
placeholder="请输入收货人姓名"
placeholder="请输入收货人姓名(需与证件一致,支持当地语言+英文)"
clearable
/>
</el-form-item>
@@ -86,42 +87,172 @@
<el-form-item label="收货人电话" prop="shippingPhone">
<el-input
v-model="form.shippingPhone"
placeholder="请输入收货人电话"
:placeholder="currentCountryConfig ? `请输入收货人电话(国际区号:${currentCountryConfig.phoneCode}` : '请输入收货人电话(带国际区号)'"
clearable
/>
</el-form-item>
<el-form-item label="收货国家" prop="shippingCountry">
<el-select v-model="form.shippingCountry" placeholder="请选择国家" style="width: 100%">
<el-option label="中国 (CN)" value="CN" />
<el-option label="美国 (US)" value="US" />
<el-option label="新加坡 (SG)" value="SG" />
<el-option label="马来西亚 (MY)" value="MY" />
<el-option label="菲律宾 (PH)" value="PH" />
<el-option label="泰国 (TH)" value="TH" />
<el-option label="越南 (VN)" value="VN" />
<el-option label="新加坡 (SG)" value="SG" />
<el-option label="中国 (CN)" value="CN" />
<el-option label="美国 (US)" value="US" />
<el-option label="英国 (GB)" value="GB" />
<el-option label="德国 (DE)" value="DE" />
<el-option label="法国 (FR)" value="FR" />
</el-select>
</el-form-item>
<el-form-item label="收货城市" prop="shippingCity">
<!-- 详细地址1门牌号街道楼栋- 所有国家都显示 -->
<el-form-item v-if="showField('shippingAddressLine1')" :label="getFieldLabel('shippingAddressLine1')" prop="shippingAddressLine1">
<el-input
v-model="form.shippingAddressLine1"
type="textarea"
:rows="2"
placeholder="请输入门牌号、街道、楼栋"
clearable
/>
</el-form-item>
<!-- 详细地址2楼层单元号可选 -->
<el-form-item v-if="showField('shippingAddressLine2')" :label="getFieldLabel('shippingAddressLine2')" prop="shippingAddressLine2">
<el-input
v-model="form.shippingAddressLine2"
placeholder="请输入楼层、单元号(可选)"
clearable
/>
</el-form-item>
<!-- 新加坡组屋号和单元号 -->
<template v-if="form.shippingCountry === 'SG'">
<el-form-item :label="getFieldLabel('shippingBlockNumber')" prop="shippingBlockNumber">
<el-input
v-model="form.shippingBlockNumber"
placeholder="例如Blk 123"
clearable
/>
</el-form-item>
<el-form-item :label="getFieldLabel('shippingUnitNumber')" prop="shippingUnitNumber">
<el-input
v-model="form.shippingUnitNumber"
placeholder="例如:#01-234"
clearable
/>
</el-form-item>
</template>
<!-- 菲律宾Barangay -->
<el-form-item v-if="form.shippingCountry === 'PH'" :label="getFieldLabel('shippingBarangay')" prop="shippingBarangay">
<el-input
v-model="form.shippingBarangay"
placeholder="请输入Barangay社区编号"
clearable
/>
</el-form-item>
<!-- 泰国泰文地址 -->
<el-form-item v-if="form.shippingCountry === 'TH'" :label="getFieldLabel('shippingAddressThai')" prop="shippingAddressThai">
<el-input
v-model="form.shippingAddressThai"
type="textarea"
:rows="2"
placeholder="请输入泰文地址(支持双语)"
clearable
/>
</el-form-item>
<!-- 越南// -->
<template v-if="form.shippingCountry === 'VN'">
<el-form-item :label="getFieldLabel('shippingProvince')" prop="shippingProvince">
<el-input
v-model="form.shippingProvince"
placeholder="请输入省 (Tỉnh)"
clearable
/>
</el-form-item>
<el-form-item :label="getFieldLabel('shippingDistrict')" prop="shippingDistrict">
<el-input
v-model="form.shippingDistrict"
placeholder="请输入市/郡 (Thành phố/Huyện)"
clearable
/>
</el-form-item>
<el-form-item :label="getFieldLabel('shippingWard')" prop="shippingWard">
<el-input
v-model="form.shippingWard"
placeholder="请输入区/坊 (Quận/Phường)"
clearable
/>
</el-form-item>
</template>
<!-- 马来西亚州属 -->
<el-form-item v-if="form.shippingCountry === 'MY'" :label="getFieldLabel('shippingStateMalaysia')" prop="shippingStateMalaysia">
<el-input
v-model="form.shippingStateMalaysia"
placeholder="例如Selangor雪兰莪"
clearable
/>
</el-form-item>
<!-- 泰国行政区域/Tambon -->
<el-form-item v-if="form.shippingCountry === 'TH' && showField('shippingAdministrativeArea')" :label="getFieldLabel('shippingAdministrativeArea')" prop="shippingAdministrativeArea">
<el-input
v-model="form.shippingAdministrativeArea"
placeholder="请输入区 (Tambon)"
clearable
/>
</el-form-item>
<!-- 城市和州/通用字段 -->
<el-form-item label="城市/城镇" prop="shippingCity">
<el-input
v-model="form.shippingCity"
placeholder="请输入收货城市"
:placeholder="form.shippingCountry === 'TH' ? '请输入县 (Amphoe)' : '请输入城市/城镇'"
style="width: 48%"
clearable
/>
<el-input
v-if="form.shippingCountry !== 'VN' && form.shippingCountry !== 'MY'"
v-model="form.shippingState"
placeholder="州/省(可选)"
:placeholder="form.shippingCountry === 'TH' ? '府 (Changwat)' : form.shippingCountry === 'PH' ? '省 (Province)' : '州/省(可选)'"
style="width: 48%; margin-left: 4%"
clearable
/>
</el-form-item>
<el-form-item label="街道地址" prop="shippingStreet">
<!-- 邮编 -->
<el-form-item label="邮政编码" prop="shippingPostcode">
<el-input
v-model="form.shippingPostcode"
:placeholder="currentCountryConfig ? `请输入邮编(${currentCountryConfig.postcodeLength}位数字)` : '请输入邮编'"
clearable
style="width: 48%"
>
<template #append v-if="postcodeMatching">
<el-icon class="is-loading"><el-icon-loading /></el-icon>
</template>
</el-input>
<span v-if="currentCountryConfig" style="margin-left: 10px; color: #909399; font-size: 12px">
{{ currentCountryConfig.name }}邮编为{{ currentCountryConfig.postcodeLength }}位数字
</span>
</el-form-item>
<!-- 楼层/单元/代收点补充信息 -->
<el-form-item label="楼层/单元/代收点" prop="shippingFloorUnit">
<el-input
v-model="form.shippingFloorUnit"
placeholder="楼层、单元号或代收点信息(可选)"
clearable
/>
</el-form-item>
<!-- 兼容旧字段街道地址如果新字段为空使用旧字段 -->
<el-form-item v-if="!form.shippingAddressLine1" label="街道地址" prop="shippingStreet">
<el-input
v-model="form.shippingStreet"
type="textarea"
@@ -131,14 +262,6 @@
/>
</el-form-item>
<el-form-item label="邮编" prop="shippingPostcode">
<el-input
v-model="form.shippingPostcode"
placeholder="请输入邮编(可选)"
clearable
/>
</el-form-item>
<el-form-item label="订单备注" prop="remark">
<el-input
v-model="form.remark"
@@ -161,17 +284,19 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { createCustomerOrder } from '../api/order'
import { formatAmount } from '../utils/helpers'
import { getCountryConfig, getCountryByCurrency, validatePostcode, getRequiredFields } from '../utils/countryConfig'
const router = useRouter()
const route = useRoute()
const formRef = ref()
const loading = ref(false)
const productInfo = ref(null)
const postcodeMatching = ref(false) // 邮编匹配中
const form = reactive({
customerName: '',
@@ -184,38 +309,225 @@ const form = reactive({
shippingCity: '',
shippingStreet: '',
shippingPostcode: '',
// 东南亚地址扩展字段
shippingAddressLine1: '',
shippingAddressLine2: '',
shippingAdministrativeArea: '',
shippingBlockNumber: '',
shippingUnitNumber: '',
shippingBarangay: '',
shippingAddressThai: '',
shippingProvince: '',
shippingDistrict: '',
shippingWard: '',
shippingStateMalaysia: '',
shippingFloorUnit: '',
remark: ''
})
const rules = {
customerName: [
{ required: true, message: '请输入客户姓名', trigger: 'blur' }
],
customerPhone: [
{ required: true, message: '请输入客户电话', trigger: 'blur' },
{ pattern: /^[0-9+\-\s()]+$/, message: '请输入有效的电话号码', trigger: 'blur' }
],
customerEmail: [
{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }
],
shippingName: [
{ required: true, message: '请输入收货人姓名', trigger: 'blur' }
],
shippingPhone: [
{ required: true, message: '请输入收货人电话', trigger: 'blur' },
{ pattern: /^[0-9+\-\s()]+$/, message: '请输入有效的电话号码', trigger: 'blur' }
],
shippingCountry: [
{ required: true, message: '请选择收货国家', trigger: 'change' }
],
shippingCity: [
{ required: true, message: '请输入收货城市', trigger: 'blur' }
],
shippingStreet: [
{ required: true, message: '请输入街道地址', trigger: 'blur' }
]
// 当前国家配置
const currentCountryConfig = computed(() => {
return getCountryConfig(form.shippingCountry)
})
// 是否显示特定字段
const showField = (fieldName) => {
if (!currentCountryConfig.value) {
// 默认显示基础字段
return ['shippingName', 'shippingPhone', 'shippingCountry', 'shippingCity',
'shippingState', 'shippingStreet', 'shippingPostcode'].includes(fieldName)
}
const specialFields = currentCountryConfig.value.specialFields || []
const requiredFields = currentCountryConfig.value.requiredFields || []
// 如果是特殊字段,只在对应国家显示
if (specialFields.includes(fieldName)) {
return true
}
// 如果是必填字段,显示
if (requiredFields.includes(fieldName)) {
return true
}
// 基础字段始终显示
const baseFields = ['shippingName', 'shippingPhone', 'shippingCountry',
'shippingCity', 'shippingState', 'shippingStreet',
'shippingPostcode', 'shippingAddressLine1', 'shippingAddressLine2']
if (baseFields.includes(fieldName)) {
return true
}
// 越南特殊字段
if (form.shippingCountry === 'VN') {
return ['shippingProvince', 'shippingDistrict', 'shippingWard'].includes(fieldName)
}
return false
}
// 获取字段标签
const getFieldLabel = (fieldName) => {
if (currentCountryConfig.value && currentCountryConfig.value.fieldLabels) {
return currentCountryConfig.value.fieldLabels[fieldName] || fieldName
}
return fieldName
}
// 监听国家变化,清空相关字段
watch(() => form.shippingCountry, (newCountry, oldCountry) => {
if (newCountry !== oldCountry) {
// 清空国家特定字段
form.shippingStateMalaysia = ''
form.shippingBarangay = ''
form.shippingBlockNumber = ''
form.shippingUnitNumber = ''
form.shippingAddressThai = ''
form.shippingProvince = ''
form.shippingDistrict = ''
form.shippingWard = ''
form.shippingAdministrativeArea = ''
// 更新电话区号提示
if (currentCountryConfig.value) {
form.shippingPhone = currentCountryConfig.value.phoneCode + ' '
}
}
})
// 监听邮编变化,自动匹配城市
watch(() => form.shippingPostcode, async (newPostcode) => {
if (!newPostcode || !form.shippingCountry) return
// 验证邮编格式
if (!validatePostcode(form.shippingCountry, newPostcode)) {
return
}
// TODO: 调用后端API匹配城市/区域
// 这里先预留接口,后续实现
postcodeMatching.value = true
try {
// const result = await matchPostcode(form.shippingCountry, newPostcode)
// if (result && result.city) {
// form.shippingCity = result.city
// if (result.state) {
// form.shippingState = result.state
// }
// }
} catch (error) {
console.error('邮编匹配失败:', error)
} finally {
postcodeMatching.value = false
}
})
// 动态验证规则
const getRules = () => {
const baseRules = {
customerName: [
{ required: true, message: '请输入客户姓名', trigger: 'blur' }
],
customerPhone: [
{ required: true, message: '请输入客户电话', trigger: 'blur' },
{ pattern: /^[0-9+\-\s()]+$/, message: '请输入有效的电话号码', trigger: 'blur' }
],
customerEmail: [
{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }
],
shippingName: [
{ required: true, message: '请输入收货人姓名', trigger: 'blur' }
],
shippingPhone: [
{ required: true, message: '请输入收货人电话', trigger: 'blur' },
{ pattern: /^[0-9+\-\s()]+$/, message: '请输入有效的电话号码', trigger: 'blur' }
],
shippingCountry: [
{ required: true, message: '请选择收货国家', trigger: 'change' }
],
shippingCity: [
{ required: true, message: '请输入收货城市', trigger: 'blur' }
],
shippingStreet: [
{ required: true, message: '请输入街道地址', trigger: 'blur' }
]
}
// 根据国家配置添加必填字段验证
if (currentCountryConfig.value) {
const requiredFields = getRequiredFields(form.shippingCountry)
if (requiredFields.includes('shippingAddressLine1')) {
baseRules.shippingAddressLine1 = [
{ required: true, message: '请输入详细地址1', trigger: 'blur' }
]
}
if (requiredFields.includes('shippingPostcode')) {
baseRules.shippingPostcode = [
{ required: true, message: '请输入邮编', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value && !validatePostcode(form.shippingCountry, value)) {
callback(new Error(`邮编格式不正确,应为${currentCountryConfig.value.postcodeLength}位数字`))
} else {
callback()
}
},
trigger: 'blur'
}
]
}
if (requiredFields.includes('shippingBlockNumber')) {
baseRules.shippingBlockNumber = [
{ required: true, message: '请输入组屋号', trigger: 'blur' }
]
}
if (requiredFields.includes('shippingUnitNumber')) {
baseRules.shippingUnitNumber = [
{ required: true, message: '请输入单元号', trigger: 'blur' }
]
}
if (requiredFields.includes('shippingBarangay')) {
baseRules.shippingBarangay = [
{ required: true, message: '请输入Barangay社区编号', trigger: 'blur' }
]
}
if (requiredFields.includes('shippingStateMalaysia')) {
baseRules.shippingStateMalaysia = [
{ required: true, message: '请输入州属', trigger: 'blur' }
]
}
if (requiredFields.includes('shippingProvince')) {
baseRules.shippingProvince = [
{ required: true, message: '请输入省', trigger: 'blur' }
]
}
if (requiredFields.includes('shippingDistrict')) {
baseRules.shippingDistrict = [
{ required: true, message: '请输入市/郡', trigger: 'blur' }
]
}
if (requiredFields.includes('shippingWard')) {
baseRules.shippingWard = [
{ required: true, message: '请输入区/坊', trigger: 'blur' }
]
}
}
return baseRules
}
const rules = computed(() => getRules())
// 返回上一页
const goBack = () => {
router.back()
@@ -252,8 +564,21 @@ const submitForm = async () => {
shippingCountry: form.shippingCountry,
shippingState: form.shippingState || null,
shippingCity: form.shippingCity,
shippingStreet: form.shippingStreet,
shippingStreet: form.shippingStreet || form.shippingAddressLine1, // 兼容旧字段
shippingPostcode: form.shippingPostcode || null,
// 东南亚地址扩展字段
shippingAddressLine1: form.shippingAddressLine1 || null,
shippingAddressLine2: form.shippingAddressLine2 || null,
shippingAdministrativeArea: form.shippingAdministrativeArea || null,
shippingBlockNumber: form.shippingBlockNumber || null,
shippingUnitNumber: form.shippingUnitNumber || null,
shippingBarangay: form.shippingBarangay || null,
shippingAddressThai: form.shippingAddressThai || null,
shippingProvince: form.shippingProvince || null,
shippingDistrict: form.shippingDistrict || null,
shippingWard: form.shippingWard || null,
shippingStateMalaysia: form.shippingStateMalaysia || null,
shippingFloorUnit: form.shippingFloorUnit || null,
remark: form.remark || null
}
@@ -290,6 +615,17 @@ onMounted(() => {
const data = JSON.parse(decodeURIComponent(route.query.data))
if (data.product) {
productInfo.value = data.product
// 根据SKU的货币代码自动设置国家
if (data.product.currency) {
const countryCode = getCountryByCurrency(data.product.currency)
if (countryCode) {
form.shippingCountry = countryCode
const config = getCountryConfig(countryCode)
if (config) {
form.shippingPhone = config.phoneCode + ' '
}
}
}
}
} catch (error) {
console.error('解析商品信息失败:', error)

View File

@@ -87,7 +87,50 @@
<el-descriptions-item label="收货人">{{ order.shippingName }}</el-descriptions-item>
<el-descriptions-item label="收货电话">{{ order.shippingPhone }}</el-descriptions-item>
<el-descriptions-item label="收货地址" :span="2">
{{ formatShippingAddress(order) }}
<div class="shipping-address-detail">
<div v-if="order.shippingAddressLine1" class="address-line">
<strong>详细地址1</strong>{{ order.shippingAddressLine1 }}
</div>
<div v-if="order.shippingAddressLine2" class="address-line">
<strong>详细地址2</strong>{{ order.shippingAddressLine2 }}
</div>
<!-- 从JSON字段中读取特殊字段 -->
<template v-if="order.shippingSpecialFields">
<div v-if="order.shippingSpecialFields.blockNumber" class="address-line">
<strong>组屋号</strong>{{ order.shippingSpecialFields.blockNumber }}
</div>
<div v-if="order.shippingSpecialFields.unitNumber" class="address-line">
<strong>单元号</strong>{{ order.shippingSpecialFields.unitNumber }}
</div>
<div v-if="order.shippingSpecialFields.barangay" class="address-line">
<strong>Barangay</strong>{{ order.shippingSpecialFields.barangay }}
</div>
<div v-if="order.shippingSpecialFields.addressThai" class="address-line">
<strong>泰文地址</strong>{{ order.shippingSpecialFields.addressThai }}
</div>
<div v-if="order.shippingSpecialFields.province" class="address-line">
<strong></strong>{{ order.shippingSpecialFields.province }}
</div>
<div v-if="order.shippingSpecialFields.district" class="address-line">
<strong>/</strong>{{ order.shippingSpecialFields.district }}
</div>
<div v-if="order.shippingSpecialFields.ward" class="address-line">
<strong>/</strong>{{ order.shippingSpecialFields.ward }}
</div>
<div v-if="order.shippingSpecialFields.stateMalaysia" class="address-line">
<strong>州属</strong>{{ order.shippingSpecialFields.stateMalaysia }}
</div>
<div v-if="order.shippingSpecialFields.administrativeArea" class="address-line">
<strong>行政区域</strong>{{ order.shippingSpecialFields.administrativeArea }}
</div>
<div v-if="order.shippingSpecialFields.floorUnit" class="address-line">
<strong>楼层/单元/代收点</strong>{{ order.shippingSpecialFields.floorUnit }}
</div>
</template>
<div class="address-line">
{{ formatShippingAddress(order) }}
</div>
</div>
</el-descriptions-item>
</el-descriptions>
</div>
@@ -199,15 +242,29 @@ const formatDateTime = (dateTime) => {
return date.toLocaleString('zh-CN')
}
// 格式化收货地址
// 格式化收货地址(从小到大的地址格式:详细街道→城市→州/省→国家)
const formatShippingAddress = (order) => {
const parts = []
if (order.shippingCountry) parts.push(order.shippingCountry)
if (order.shippingState) parts.push(order.shippingState)
// 详细地址1或街道地址
if (order.shippingAddressLine1) {
parts.push(order.shippingAddressLine1)
} else if (order.shippingStreet) {
parts.push(order.shippingStreet)
}
if (order.shippingCity) parts.push(order.shippingCity)
if (order.shippingStreet) parts.push(order.shippingStreet)
// 根据国家显示不同的州/省字段从JSON字段中读取
if (order.shippingSpecialFields && order.shippingSpecialFields.stateMalaysia) {
parts.push(order.shippingSpecialFields.stateMalaysia)
} else if (order.shippingState) {
parts.push(order.shippingState)
}
if (order.shippingCountry) parts.push(order.shippingCountry)
if (order.shippingPostcode) parts.push(`邮编:${order.shippingPostcode}`)
return parts.join(' ')
return parts.length > 0 ? parts.join('') : '地址信息不完整'
}
// 返回上一页
@@ -523,6 +580,19 @@ onMounted(() => {
.rate-locked-info {
margin-top: 10px;
font-size: 13px;
}
.shipping-address-detail {
line-height: 1.8;
}
.address-line {
margin-bottom: 4px;
}
.address-line strong {
color: #606266;
margin-right: 8px;
color: #909399;
display: flex;
align-items: center;