1 Commits

Author SHA1 Message Date
23e535562d feat(order): 优化创建订单页面的地址字段和必填项
- 调整新加坡、马来西亚、菲律宾、泰国、越南等国家的必填字段配置
- 将邮编和城市设为PayPal要求的必填项,越南使用省/市/郡/区/坊代替城市
- 重新排列表单字段顺序,优化移动端显示效果
- 添加更多地址信息按钮,将详细地址2放入折叠区域
- 实现地址组件自动更新功能,将省/州、市/郡、区/坊等信息拼接到详细地址1
- 更新国际化文本,添加更多和收起按钮的翻译
- 为创建订单和订单确认页面添加路由元信息,标记为不显示导航栏的客户页面
2025-12-26 18:13:11 +08:00
6 changed files with 732 additions and 390 deletions

View File

@@ -30,6 +30,8 @@ const zh = {
pleaseEnter: '请输入',
optional: '可选',
required: '必填',
more: '更多',
collapse: '收起',
addressFormat: '地址格式',
phoneCode: '国际区号',
mustMatchId: '需与证件一致,支持当地语言+英文',
@@ -234,6 +236,8 @@ const en = {
pleaseEnter: 'Please enter',
optional: 'Optional',
required: 'Required',
more: 'More',
collapse: 'Collapse',
addressFormat: 'Address Format',
phoneCode: 'Phone Code',
mustMatchId: 'Must match ID, supports local language + English',
@@ -436,6 +440,8 @@ const may = {
pleaseEnter: 'Sila masukkan',
optional: 'Pilihan',
required: 'Diperlukan',
more: 'Lebih Banyak',
collapse: 'Tutup',
addressFormat: 'Format Alamat',
phoneCode: 'Kod Telefon',
mustMatchId: 'Mesti sepadan dengan ID, menyokong bahasa tempatan + Inggeris',
@@ -633,9 +639,14 @@ const fil = {
pleaseEnter: 'Mangyaring ipasok',
optional: 'Opsiyonal',
required: 'Kinakailangan',
more: 'Higit Pa',
collapse: 'Itago',
addressFormat: 'Format ng Address',
phoneCode: 'Phone Code',
mustMatchId: 'Dapat tumugma sa ID, sumusuporta sa lokal na wika + Ingles'
mustMatchId: 'Dapat tumugma sa ID, sumusuporta sa lokal na wika + Ingles',
// 占位符文本
placeholderAddressLine1: 'Mangyaring ipasok ang numero ng bahay, kalye, gusali',
placeholderAddressLine2: 'Mangyaring ipasok ang sahig, numero ng unit (opsyonal)'
},
product: {
selectCurrency: 'Pumili ng Currency at Wika',
@@ -779,6 +790,8 @@ const th = {
pleaseEnter: 'กรุณากรอก',
optional: 'ไม่บังคับ',
required: 'จำเป็น',
more: 'เพิ่มเติม',
collapse: 'ย่อ',
addressFormat: 'รูปแบบที่อยู่',
phoneCode: 'รหัสโทรศัพท์',
mustMatchId: 'ต้องตรงกับบัตรประชาชน รองรับภาษาท้องถิ่น + อังกฤษ',
@@ -979,6 +992,8 @@ const vie = {
pleaseEnter: 'Vui lòng nhập',
optional: 'Tùy chọn',
required: 'Bắt buộc',
more: 'Thêm',
collapse: 'Thu gọn',
addressFormat: 'Định Dạng Địa Chỉ',
phoneCode: 'Mã Điện Thoại',
mustMatchId: 'Phải khớp với ID, hỗ trợ ngôn ngữ địa phương + tiếng Anh',
@@ -1176,9 +1191,14 @@ const id = {
pleaseEnter: 'Silakan masukkan',
optional: 'Opsional',
required: 'Diperlukan',
more: 'Lebih Banyak',
collapse: 'Tutup',
addressFormat: 'Format Alamat',
phoneCode: 'Kode Telepon',
mustMatchId: 'Harus sesuai dengan ID, mendukung bahasa lokal + Inggris'
mustMatchId: 'Harus sesuai dengan ID, mendukung bahasa lokal + Inggris',
// 占位符文本
placeholderAddressLine1: 'Silakan masukkan nomor rumah, jalan, gedung',
placeholderAddressLine2: 'Silakan masukkan lantai, nomor unit (opsional)'
},
product: {
selectCurrency: 'Pilih Mata Uang dan Bahasa',

View File

@@ -44,12 +44,14 @@ const routes = [
{
path: '/create-order',
name: 'CreateOrder',
component: CreateOrder
component: CreateOrder,
meta: { isCustomerPage: true } // 标记为客户页面,不显示导航栏
},
{
path: '/order/confirm',
name: 'OrderConfirm',
component: () => import('../views/OrderConfirm.vue')
component: () => import('../views/OrderConfirm.vue'),
meta: { isCustomerPage: true } // 标记为客户页面,不显示导航栏
},
{
path: '/paypal/success',

View File

@@ -25,8 +25,9 @@ export const countryConfigs = {
phoneCode: '+65',
postcodeLength: 6,
postcodePattern: /^\d{6}$/,
requiredFields: ['shippingName', 'shippingPhone', 'shippingCountry', 'shippingCity',
'shippingAddressLine1', 'shippingBlockNumber', 'shippingUnitNumber', 'shippingPostcode'],
// 新加坡城市和邮编是PayPal要求必填的
requiredFields: ['shippingName', 'shippingPhone', 'shippingCountry',
'shippingCity', 'shippingAddressLine1', 'shippingBlockNumber', 'shippingUnitNumber', 'shippingPostcode'],
specialFields: ['shippingBlockNumber', 'shippingUnitNumber'],
fieldLabels: {
shippingBlockNumber: '组屋号 (Block Number)',
@@ -45,8 +46,9 @@ export const countryConfigs = {
phoneCode: '+60',
postcodeLength: 5,
postcodePattern: /^\d{5}$/,
requiredFields: ['shippingName', 'shippingPhone', 'shippingCountry', 'shippingCity',
'shippingStateMalaysia', 'shippingAddressLine1', 'shippingPostcode'],
// 马来西亚城市和邮编是PayPal要求必填的州属放入更多按钮中改为非必填
requiredFields: ['shippingName', 'shippingPhone', 'shippingCountry',
'shippingCity', 'shippingAddressLine1', 'shippingPostcode'],
specialFields: ['shippingStateMalaysia'],
fieldLabels: {
shippingStateMalaysia: '州属 (State)',
@@ -64,8 +66,9 @@ export const countryConfigs = {
phoneCode: '+63',
postcodeLength: 4,
postcodePattern: /^\d{4}$/,
requiredFields: ['shippingName', 'shippingPhone', 'shippingCountry', 'shippingCity',
'shippingState', 'shippingBarangay', 'shippingAddressLine1', 'shippingPostcode'],
// 菲律宾城市和邮编是PayPal要求必填的省放入更多按钮中改为非必填
requiredFields: ['shippingName', 'shippingPhone', 'shippingCountry',
'shippingCity', 'shippingBarangay', 'shippingAddressLine1', 'shippingPostcode'],
specialFields: ['shippingBarangay'],
fieldLabels: {
shippingBarangay: 'Barangay社区编号',
@@ -84,8 +87,9 @@ export const countryConfigs = {
phoneCode: '+66',
postcodeLength: 5,
postcodePattern: /^\d{5}$/,
requiredFields: ['shippingName', 'shippingPhone', 'shippingCountry', 'shippingCity',
'shippingState', 'shippingAddressLine1', 'shippingPostcode', 'shippingAddressThai'],
// 泰国城市和邮编是PayPal要求必填的府放入更多按钮中改为非必填
requiredFields: ['shippingName', 'shippingPhone', 'shippingCountry',
'shippingCity', 'shippingAddressLine1', 'shippingAddressThai', 'shippingPostcode'],
specialFields: ['shippingAddressThai', 'shippingAdministrativeArea'],
fieldLabels: {
shippingAddressThai: '泰文地址 (Thai Address)',
@@ -105,8 +109,9 @@ export const countryConfigs = {
phoneCode: '+84',
postcodeLength: 5,
postcodePattern: /^\d{5}$/,
requiredFields: ['shippingName', 'shippingPhone', 'shippingCountry', 'shippingProvince',
'shippingDistrict', 'shippingWard', 'shippingAddressLine1', 'shippingPostcode'],
// 越南:使用省/市/郡/区/坊代替城市邮编是PayPal要求必填的
requiredFields: ['shippingName', 'shippingPhone', 'shippingCountry',
'shippingProvince', 'shippingDistrict', 'shippingWard', 'shippingAddressLine1', 'shippingPostcode'],
specialFields: ['shippingProvince', 'shippingDistrict', 'shippingWard'],
fieldLabels: {
shippingProvince: '省 (Tỉnh)',

View File

@@ -21,8 +21,9 @@
fit="cover"
class="product-info-image"
/>
<div class="product-info-details">
<div class="product-info-name">{{ productInfo.name }}</div>
</div>
<div class="product-info-details">
<div class="product-info-sku" v-if="productInfo.sku">
<span class="sku-label">SKU</span>
<span class="sku-value">{{ productInfo.sku }}</span>
@@ -37,7 +38,6 @@
</div>
</div>
</div>
</div>
</el-card>
<el-form
@@ -76,50 +76,74 @@
<el-divider>{{ $t('order.shippingAddress') }}</el-divider>
<el-alert v-if="currentCountryConfig" :title="`${$t('order.addressFormat')}${currentCountryConfig.addressFormat}`" type="info" :closable="false" style="margin-bottom: 20px" />
<el-form-item :label="t('order.shippingName')" prop="shippingName">
<!-- 邮编PayPal要求必填- 常驻展示必填 -->
<el-form-item :label="t('order.postcode')" prop="shippingPostcode" required>
<div :class="isMobile ? 'mobile-postcode-group' : 'desktop-postcode-group'">
<el-input
v-model="form.shippingName"
:placeholder="t('order.pleaseEnter') + t('order.shippingName') + '' + t('order.mustMatchId') + ''"
v-model="form.shippingPostcode"
:placeholder="currentCountryConfig ? t('order.placeholderPostcode') + `${currentCountryConfig.postcodeLength}${t('order.postcodeHint').replace('{0}', '')}` : t('order.placeholderPostcode')"
clearable
/>
:style="isMobile ? 'width: 100%' : '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="isMobile ? 'display: block; margin-top: 8px; color: #909399; font-size: 12px' : 'margin-left: 10px; color: #909399; font-size: 12px'">
{{ currentCountryConfig.name }}{{ t('order.postcodeHint').replace('{0}', currentCountryConfig.postcodeLength) }}
</span>
</div>
</el-form-item>
<el-form-item :label="t('order.shippingPhone')" prop="shippingPhone">
<!-- 城市PayPal要求必填- 常驻展示必填越南不使用此字段使用独立的省//// -->
<el-form-item v-if="form.shippingCountry !== 'VN'" :label="getCityLabel()" prop="shippingCity" required>
<div :class="isMobile ? 'mobile-input-group' : 'desktop-input-group'">
<el-input
v-model="form.shippingPhone"
:placeholder="currentCountryConfig ? `${t('order.pleaseEnter')}${t('order.shippingPhone')}${t('order.phoneCode')}${currentCountryConfig.phoneCode}` : t('order.pleaseEnter') + t('order.shippingPhone') + '' + t('order.phoneCode') + ''"
v-model="form.shippingCity"
:placeholder="getCityPlaceholder()"
:style="isMobile ? 'width: 100%' : form.shippingCountry === 'MY' ? 'width: 100%' : 'width: 48%'"
clearable
@input="updateAddressLine1"
/>
<!-- /省字段泰国显示府菲律宾显示省其他显示州/可选 -->
<el-input
v-if="form.shippingCountry !== 'VN' && form.shippingCountry !== 'MY'"
v-model="form.shippingState"
:placeholder="getStatePlaceholder()"
:style="isMobile ? 'width: 100%; margin-top: 10px' : 'width: 48%; margin-left: 4%'"
clearable
@input="updateAddressLine1"
/>
</div>
</el-form-item>
<!-- 收货国家只读显示根据货币自动确定 -->
<el-form-item :label="t('order.shippingCountry')" prop="shippingCountry">
<!-- 越南//PayPal要求必填- 常驻展示必填 -->
<template v-if="form.shippingCountry === 'VN'">
<el-form-item :label="t('order.province')" prop="shippingProvince" required>
<el-input
v-model="currentCountryDisplayName"
disabled
style="width: 100%"
/>
</el-form-item>
<!-- 详细地址1门牌号街道楼栋- 所有国家都显示 -->
<el-form-item v-if="showField('shippingAddressLine1')" :label="t('order.addressLine1')" prop="shippingAddressLine1">
<el-input
v-model="form.shippingAddressLine1"
type="textarea"
:rows="2"
:placeholder="t('order.placeholderAddressLine1')"
v-model="form.shippingProvince"
:placeholder="t('order.placeholderProvinceVN')"
clearable
@input="updateAddressLine1"
/>
</el-form-item>
<!-- 详细地址2楼层单元号可选 -->
<el-form-item v-if="showField('shippingAddressLine2')" :label="t('order.addressLine2')" prop="shippingAddressLine2">
<el-form-item :label="t('order.district')" prop="shippingDistrict" required>
<el-input
v-model="form.shippingAddressLine2"
:placeholder="t('order.placeholderAddressLine2')"
v-model="form.shippingDistrict"
:placeholder="t('order.placeholderDistrictVN')"
clearable
@input="updateAddressLine1"
/>
</el-form-item>
<el-form-item :label="t('order.ward')" prop="shippingWard" required>
<el-input
v-model="form.shippingWard"
:placeholder="t('order.placeholderWardVN')"
clearable
@input="updateAddressLine1"
/>
</el-form-item>
</template>
<!-- 新加坡组屋号和单元号 -->
<template v-if="form.shippingCountry === 'SG'">
@@ -159,39 +183,6 @@
/>
</el-form-item>
<!-- 越南// -->
<template v-if="form.shippingCountry === 'VN'">
<el-form-item :label="t('order.province')" prop="shippingProvince">
<el-input
v-model="form.shippingProvince"
:placeholder="t('order.placeholderProvinceVN')"
clearable
/>
</el-form-item>
<el-form-item :label="t('order.district')" prop="shippingDistrict">
<el-input
v-model="form.shippingDistrict"
:placeholder="t('order.placeholderDistrictVN')"
clearable
/>
</el-form-item>
<el-form-item :label="t('order.ward')" prop="shippingWard">
<el-input
v-model="form.shippingWard"
:placeholder="t('order.placeholderWardVN')"
clearable
/>
</el-form-item>
</template>
<!-- 马来西亚州属 -->
<el-form-item v-if="form.shippingCountry === 'MY'" :label="t('order.stateMalaysia')" prop="shippingStateMalaysia">
<el-input
v-model="form.shippingStateMalaysia"
:placeholder="t('order.placeholderStateMalaysia')"
clearable
/>
</el-form-item>
<!-- 泰国行政区域/Tambon -->
<el-form-item v-if="form.shippingCountry === 'TH' && showField('shippingAdministrativeArea')" :label="t('order.administrativeArea')" prop="shippingAdministrativeArea">
@@ -202,74 +193,55 @@
/>
</el-form-item>
<!-- 城市和州/通用字段- 越南不使用此字段使用独立的省//// -->
<el-form-item v-if="form.shippingCountry !== 'VN'" :label="getCityLabel()" prop="shippingCity">
<div :class="isMobile ? 'mobile-input-group' : 'desktop-input-group'">
<!-- 马来西亚州属 - 常驻展示 -->
<el-form-item v-if="form.shippingCountry === 'MY'" :label="t('order.stateMalaysia')" prop="shippingStateMalaysia">
<el-input
v-model="form.shippingCity"
:placeholder="getCityPlaceholder()"
:style="isMobile ? 'width: 100%' : form.shippingCountry === 'MY' ? 'width: 100%' : 'width: 48%'"
clearable
/>
<!-- /省字段泰国显示府菲律宾显示省其他显示州/可选 -->
<el-input
v-if="form.shippingCountry !== 'VN' && form.shippingCountry !== 'MY'"
v-model="form.shippingState"
:placeholder="getStatePlaceholder()"
:style="isMobile ? 'width: 100%; margin-top: 10px' : 'width: 48%; margin-left: 4%'"
clearable
/>
</div>
</el-form-item>
<!-- 邮编 -->
<el-form-item :label="t('order.postcode')" prop="shippingPostcode">
<div :class="isMobile ? 'mobile-postcode-group' : 'desktop-postcode-group'">
<el-input
v-model="form.shippingPostcode"
:placeholder="currentCountryConfig ? t('order.placeholderPostcode') + `${currentCountryConfig.postcodeLength}${t('order.postcodeHint').replace('{0}', '')}` : t('order.placeholderPostcode')"
clearable
:style="isMobile ? 'width: 100%' : '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="isMobile ? 'display: block; margin-top: 8px; color: #909399; font-size: 12px' : 'margin-left: 10px; color: #909399; font-size: 12px'">
{{ currentCountryConfig.name }}{{ t('order.postcodeHint').replace('{0}', currentCountryConfig.postcodeLength) }}
</span>
</div>
</el-form-item>
<!-- 楼层/单元/代收点补充信息 -->
<el-form-item :label="t('order.floorUnit')" prop="shippingFloorUnit">
<el-input
v-model="form.shippingFloorUnit"
:placeholder="t('order.placeholderFloorUnit')"
v-model="form.shippingStateMalaysia"
:placeholder="t('order.placeholderStateMalaysia')"
clearable
@input="updateAddressLine1"
/>
</el-form-item>
<!-- 兼容旧字段街道地址如果新字段为空使用旧字段 -->
<el-form-item v-if="!form.shippingAddressLine1" :label="t('order.street')" prop="shippingStreet">
<!-- 详细地址1门牌号街道楼栋- 所有国家都显示必填放在最下面 -->
<el-form-item v-if="showField('shippingAddressLine1')" :label="t('order.addressLine1')" prop="shippingAddressLine1" required>
<el-input
v-model="form.shippingStreet"
v-model="form.shippingAddressLine1"
type="textarea"
:rows="2"
:placeholder="t('order.placeholderStreet')"
:placeholder="t('order.placeholderAddressLine1')"
clearable
/>
</el-form-item>
<el-form-item :label="t('order.remark')" prop="remark">
<!-- 更多地址信息按钮 -->
<el-form-item>
<el-button
type="text"
@click="showMoreAddressFields = !showMoreAddressFields"
style="padding: 0; color: #409eff;"
>
<el-icon style="margin-right: 4px;">
<ArrowDown v-if="!showMoreAddressFields" />
<ArrowUp v-else />
</el-icon>
{{ showMoreAddressFields ? t('order.collapse') : t('order.more') }}
</el-button>
</el-form-item>
<!-- 更多地址信息 - 只包含详细地址2 -->
<template v-if="showMoreAddressFields">
<!-- 详细地址2楼层单元号可选- 放入更多按钮中 -->
<el-form-item v-if="showField('shippingAddressLine2')" :label="t('order.addressLine2')" prop="shippingAddressLine2">
<el-input
v-model="form.remark"
type="textarea"
:rows="3"
:placeholder="t('order.placeholderRemark')"
v-model="form.shippingAddressLine2"
:placeholder="t('order.placeholderAddressLine2')"
clearable
/>
</el-form-item>
</template>
<el-form-item>
<el-button type="primary" size="large" @click="submitForm" :loading="loading" style="width: 200px">
@@ -287,6 +259,7 @@ import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue'
import { createCustomerOrder } from '../api/order'
import { formatAmount } from '../utils/helpers'
import { getCountryConfig, getCountryByCurrency, validatePostcode, getRequiredFields } from '../utils/countryConfig'
@@ -301,13 +274,13 @@ const formRef = ref()
const loading = ref(false)
const productInfo = ref(null)
const postcodeMatching = ref(false) // 邮编匹配中
const showMoreAddressFields = ref(false) // 控制更多地址字段的显示
const isAutoUpdating = ref(false) // 防止自动更新时的递归调用
const form = reactive({
customerName: '',
customerPhone: '',
customerEmail: '',
shippingName: '',
shippingPhone: '',
shippingCountry: '',
shippingState: '',
shippingCity: '',
@@ -324,9 +297,7 @@ const form = reactive({
shippingProvince: '',
shippingDistrict: '',
shippingWard: '',
shippingStateMalaysia: '',
shippingFloorUnit: '',
remark: ''
shippingStateMalaysia: ''
})
// 当前国家配置
@@ -363,12 +334,13 @@ const currentCountryDisplayName = computed(() => {
return countryNameMap[lang] || countryNameMap.en || form.shippingCountry
})
// 是否显示特定字段
// 是否显示特定字段
const showField = (fieldName) => {
if (!currentCountryConfig.value) {
// 默认显示基础字段
return ['shippingName', 'shippingPhone', 'shippingCountry', 'shippingCity',
'shippingState', 'shippingStreet', 'shippingPostcode'].includes(fieldName)
// 默认显示基础字段(包括详细地址)
return ['shippingCountry', 'shippingCity',
'shippingState', 'shippingPostcode',
'shippingAddressLine1', 'shippingAddressLine2'].includes(fieldName)
}
const specialFields = currentCountryConfig.value.specialFields || []
@@ -385,8 +357,8 @@ const showField = (fieldName) => {
}
// 基础字段始终显示
const baseFields = ['shippingName', 'shippingPhone', 'shippingCountry',
'shippingCity', 'shippingState', 'shippingStreet',
const baseFields = ['shippingCountry',
'shippingCity', 'shippingState',
'shippingPostcode', 'shippingAddressLine1', 'shippingAddressLine2']
if (baseFields.includes(fieldName)) {
return true
@@ -408,17 +380,9 @@ const getFieldLabel = (fieldName) => {
return fieldName
}
// 获取城市字段标签(根据国家动态显示)
// 获取城市字段标签(根据国家动态显示,只显示对应语言,不包含中文
const getCityLabel = () => {
if (form.shippingCountry === 'TH') {
return t('order.cityTown') + ' (县/Amphoe)'
} else if (form.shippingCountry === 'PH') {
return t('order.cityTown') + ' (市/City)'
} else if (form.shippingCountry === 'SG') {
return t('order.cityTown') + ' (城市/City)'
} else if (form.shippingCountry === 'MY') {
return t('order.cityTown') + ' (城市/City)'
}
// 直接返回国际化翻译i18n会根据当前语言环境自动返回对应语言的标签
return t('order.cityTown')
}
@@ -440,6 +404,66 @@ const getStatePlaceholder = () => {
return t('order.placeholderStateOptional')
}
// 自动更新详细地址1将省/州、市/郡、区/坊等信息拼接到详细地址1
// 使用一个标志来跟踪是否应该自动更新(避免用户手动编辑时被覆盖)
const updateAddressLine1 = () => {
if (isAutoUpdating.value) return // 防止递归更新
isAutoUpdating.value = true
try {
// 构建地址组件数组(省/州、市/郡、区/坊等)
const addressParts = []
// 根据国家添加不同的地址组件
if (form.shippingCountry === 'VN') {
// 越南:省、市/郡、区/坊
if (form.shippingProvince) addressParts.push(form.shippingProvince)
if (form.shippingDistrict) addressParts.push(form.shippingDistrict)
if (form.shippingWard) addressParts.push(form.shippingWard)
} else {
// 其他国家:城市、州/省
if (form.shippingCity) addressParts.push(form.shippingCity)
if (form.shippingState) addressParts.push(form.shippingState)
if (form.shippingCountry === 'MY' && form.shippingStateMalaysia) {
addressParts.push(form.shippingStateMalaysia)
}
}
// 如果有地址组件拼接到详细地址1
if (addressParts.length > 0) {
const addressSuffix = addressParts.join(', ')
const currentAddress = form.shippingAddressLine1 || ''
// 检查当前地址是否已包含这些组件(避免重复拼接)
const hasAllParts = addressParts.every(part => currentAddress.includes(part))
if (!hasAllParts) {
// 移除旧的地址组件(如果存在),保留用户手动输入的门牌号、街道、楼栋等信息
let cleanAddress = currentAddress
addressParts.forEach(part => {
// 移除该组件及其前后的逗号和空格
const escapedPart = part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// 移除 ", 组件" 或 "组件, " 或单独的 "组件"
cleanAddress = cleanAddress.replace(new RegExp(`,\\s*${escapedPart}(?=\\s|,|$)`, 'g'), '')
cleanAddress = cleanAddress.replace(new RegExp(`${escapedPart}\\s*,`, 'g'), '')
cleanAddress = cleanAddress.replace(new RegExp(`^${escapedPart}\\s*$`, 'g'), '')
})
cleanAddress = cleanAddress.trim().replace(/^,\s*|,\s*$/g, '').replace(/,\s*,/g, ',')
// 追加新的地址组件
if (cleanAddress) {
form.shippingAddressLine1 = `${cleanAddress}, ${addressSuffix}`
} else {
form.shippingAddressLine1 = addressSuffix
}
}
}
} finally {
isAutoUpdating.value = false
}
}
// 监听国家变化,清空相关字段
watch(() => form.shippingCountry, (newCountry, oldCountry) => {
if (newCountry !== oldCountry) {
@@ -453,14 +477,19 @@ watch(() => form.shippingCountry, (newCountry, oldCountry) => {
form.shippingDistrict = ''
form.shippingWard = ''
form.shippingAdministrativeArea = ''
// 更新电话区号提示
if (currentCountryConfig.value) {
form.shippingPhone = currentCountryConfig.value.phoneCode + ' '
}
// 清空详细地址1因为地址组件已清空
form.shippingAddressLine1 = ''
}
})
// 监听地址组件变化自动更新详细地址1
watch([() => form.shippingCity, () => form.shippingState, () => form.shippingStateMalaysia,
() => form.shippingProvince, () => form.shippingDistrict, () => form.shippingWard],
() => {
updateAddressLine1()
}
)
// 监听邮编变化,自动匹配城市
watch(() => form.shippingPostcode, async (newPostcode) => {
if (!newPostcode || !form.shippingCountry) return
@@ -501,40 +530,19 @@ const getRules = () => {
customerEmail: [
{ type: 'email', message: t('order.validationInvalidEmail'), trigger: 'blur' }
],
shippingName: [
{ required: true, message: t('order.validationRequired', [t('order.shippingName')]), trigger: 'blur' }
],
shippingPhone: [
{ required: true, message: t('order.validationRequired', [t('order.shippingPhone')]), trigger: 'blur' },
{ pattern: /^[0-9+\-\s()]+$/, message: t('order.validationInvalidPhone'), trigger: 'blur' }
],
shippingCountry: [
{ required: true, message: t('order.validationSelectCountry'), trigger: 'change' }
],
shippingCity: [
{ required: true, message: t('order.validationRequired', [t('order.cityTown')]), trigger: 'blur' }
],
shippingStreet: [
{ required: true, message: t('order.validationRequired', [t('order.street')]), trigger: 'blur' }
]
}
// 根据国家配置添加必填字段验证
if (currentCountryConfig.value) {
const requiredFields = getRequiredFields(form.shippingCountry)
if (requiredFields.includes('shippingAddressLine1')) {
baseRules.shippingAddressLine1 = [
// 详细地址1始终必填
shippingAddressLine1: [
{ required: true, message: t('order.validationRequired', [t('order.addressLine1')]), trigger: 'blur' }
]
}
if (requiredFields.includes('shippingPostcode')) {
baseRules.shippingPostcode = [
],
// 邮编始终必填PayPal要求
shippingPostcode: [
{ required: true, message: t('order.validationRequired', [t('order.postcode')]), trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value && !validatePostcode(form.shippingCountry, value)) {
if (value && currentCountryConfig.value && !validatePostcode(form.shippingCountry, value)) {
callback(new Error(t('order.validationPostcodeFormat', [currentCountryConfig.value.postcodeLength])))
} else {
callback()
@@ -542,9 +550,17 @@ const getRules = () => {
},
trigger: 'blur'
}
],
// 城市始终必填PayPal要求越南除外
shippingCity: [
{ required: true, message: t('order.validationRequired', [t('order.city')]), trigger: 'blur' }
]
}
// 根据国家配置添加必填字段验证
if (currentCountryConfig.value) {
const requiredFields = getRequiredFields(form.shippingCountry)
if (requiredFields.includes('shippingBlockNumber')) {
baseRules.shippingBlockNumber = [
{ required: true, message: t('order.validationRequired', [t('order.blockNumber')]), trigger: 'blur' }
@@ -639,12 +655,12 @@ const submitForm = async () => {
customerName: form.customerName,
customerPhone: form.customerPhone,
customerEmail: form.customerEmail || null,
shippingName: form.shippingName,
shippingPhone: form.shippingPhone,
shippingName: form.customerName, // 使用客户姓名作为收货人姓名
shippingPhone: form.customerPhone, // 使用客户电话作为收货人电话
shippingCountry: form.shippingCountry,
shippingState: form.shippingState || null,
shippingCity: shippingCity, // 使用映射后的值,越南使用 shippingDistrict
shippingStreet: form.shippingStreet || form.shippingAddressLine1, // 兼容旧字段
shippingStreet: form.shippingAddressLine1, // 使用详细地址1
shippingPostcode: form.shippingPostcode || null,
// 东南亚地址扩展字段
shippingAddressLine1: form.shippingAddressLine1 || null,
@@ -658,8 +674,6 @@ const submitForm = async () => {
shippingDistrict: form.shippingDistrict || null,
shippingWard: form.shippingWard || null,
shippingStateMalaysia: form.shippingStateMalaysia || null,
shippingFloorUnit: form.shippingFloorUnit || null,
remark: form.remark || null
}
const response = await createCustomerOrder(orderData)
@@ -703,10 +717,6 @@ onMounted(async () => {
const countryCode = getCountryByCurrency(data.product.currency)
if (countryCode) {
form.shippingCountry = countryCode
const config = getCountryConfig(countryCode)
if (config) {
form.shippingPhone = config.phoneCode + ' '
}
}
}
}
@@ -755,22 +765,18 @@ onMounted(async () => {
.product-info-main {
display: flex;
gap: 20px;
align-items: center;
gap: 15px;
margin-bottom: 15px;
}
.product-info-image {
width: 120px;
height: 120px;
border-radius: 8px;
width: 80px;
height: 80px;
border-radius: 6px;
border: 1px solid #e4e7ed;
flex-shrink: 0;
}
.product-info-details {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
object-fit: cover;
}
.product-info-name {
@@ -778,6 +784,13 @@ onMounted(async () => {
font-weight: 600;
color: #303133;
line-height: 1.5;
flex: 1;
}
.product-info-details {
display: flex;
flex-direction: column;
gap: 10px;
}
.product-info-sku {
@@ -830,39 +843,74 @@ onMounted(async () => {
font-weight: 700;
}
/* 移动端优化 */
/* 移动端优先设计 - 响应式优化 */
@media (max-width: 768px) {
.create-order {
padding: 10px;
padding: 0;
max-width: 100%;
}
.el-card {
border-radius: 0;
box-shadow: none;
border: none;
margin: 0;
}
.card-header {
font-size: 16px;
padding: 10px 0;
padding: 12px 16px;
font-weight: 600;
}
.el-card__body {
padding: 16px;
}
/* 商品信息卡片优化 */
.product-info-card {
margin-bottom: 15px;
margin-bottom: 16px;
border: 1px solid #e4e7ed;
border-radius: 8px;
}
.product-card-header {
font-size: 14px;
font-weight: 600;
}
.product-info-content {
padding: 12px 0;
}
.product-info-main {
flex-direction: column;
flex-direction: row;
gap: 12px;
align-items: flex-start;
margin-bottom: 12px;
}
.product-info-image {
width: 100%;
height: 200px;
align-self: center;
width: 60px;
height: 60px;
flex-shrink: 0;
border-radius: 6px;
}
.product-info-name {
font-size: 14px;
line-height: 1.4;
word-break: break-word;
flex: 1;
}
.product-info-details {
gap: 8px;
}
.product-info-sku {
padding: 8px 10px;
font-size: 12px;
}
.product-info-price {
@@ -870,33 +918,58 @@ onMounted(async () => {
align-items: flex-start;
gap: 8px;
padding: 10px;
font-size: 13px;
}
.price-label,
.quantity-label {
font-size: 12px;
}
.price-value,
.quantity-value {
font-size: 14px;
}
.total-label {
margin-left: 0;
margin-top: 8px;
font-size: 16px;
font-size: 14px;
font-weight: 600;
}
.total-value {
font-size: 20px;
font-size: 18px;
}
/* 表单优化 */
.el-form {
padding: 0;
}
.el-form-item {
margin-bottom: 18px;
margin-bottom: 16px;
}
.el-form-item__label {
padding-bottom: 5px;
padding-bottom: 6px;
font-size: 14px;
font-weight: 600;
line-height: 1.4;
margin-bottom: 4px;
}
.el-input,
.el-select,
.el-textarea {
width: 100%;
font-size: 14px;
}
.el-input__inner,
.el-textarea__inner {
font-size: 14px;
padding: 10px 12px;
}
/* 地址字段优化 */
@@ -911,39 +984,22 @@ onMounted(async () => {
width: 100%;
}
/* 按钮优化 */
.el-form-item:last-child {
margin-bottom: 0;
padding-top: 15px;
border-top: 1px solid #e4e7ed;
}
.el-form-item:last-child .el-button {
width: 100%;
margin: 0;
height: 44px;
font-size: 16px;
}
.el-form-item:last-child .el-button + .el-button {
margin-top: 10px;
margin-left: 0;
}
/* 分隔线优化 */
.el-divider {
margin: 20px 0;
margin: 16px 0;
}
.el-divider__text {
font-size: 14px;
padding: 0 15px;
padding: 0 12px;
font-weight: 600;
}
/* 提示信息优化 */
.el-alert {
margin-bottom: 15px;
margin-bottom: 12px;
font-size: 12px;
padding: 10px 12px;
}
/* 输入组优化 */
@@ -951,23 +1007,73 @@ onMounted(async () => {
display: flex;
flex-direction: column;
width: 100%;
gap: 10px;
}
.desktop-input-group {
display: flex;
width: 100%;
gap: 4%;
}
.mobile-postcode-group {
display: flex;
flex-direction: column;
width: 100%;
gap: 8px;
}
.desktop-postcode-group {
display: flex;
align-items: center;
width: 100%;
gap: 10px;
}
/* 按钮优化 */
.el-form-item:last-child {
margin-bottom: 0;
padding-top: 16px;
border-top: 1px solid #e4e7ed;
position: sticky;
bottom: 0;
background: white;
z-index: 10;
}
.el-form-item:last-child .el-button {
width: 100%;
margin: 0;
height: 48px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
}
.el-form-item:last-child .el-button + .el-button {
margin-top: 10px;
margin-left: 0;
}
/* 更多按钮优化 */
.el-button--text {
font-size: 14px;
padding: 8px 0;
}
/* 国家选择器优化 */
.el-select {
width: 100%;
}
/* 文本域优化 */
.el-textarea {
width: 100%;
}
.el-textarea__inner {
min-height: 80px;
line-height: 1.5;
}
}
</style>

View File

@@ -152,7 +152,7 @@
:style="isMobile ? 'width: 100%' : 'width: 200px'"
>
<el-icon><Money /></el-icon>
{{ $t('confirm.payNow') }}
{{ countdown > 0 ? `${$t('confirm.payNow')} (${countdown}s)` : $t('confirm.payNow') }}
</el-button>
<el-button @click="goBack" :style="isMobile ? 'width: 100%; margin-top: 10px; margin-left: 0' : 'margin-left: 10px'">{{ $t('confirm.back') }}</el-button>
</div>
@@ -168,7 +168,7 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
@@ -185,6 +185,9 @@ const route = useRoute()
const orderLoading = ref(true)
const payLoading = ref(false)
const order = ref(null)
const autoPayTimer = ref(null) // 自动支付定时器
const countdownTimer = ref(null) // 倒计时定时器
const countdown = ref(0) // 倒计时(秒)
// 移动端检测
const isMobile = computed(() => {
@@ -363,6 +366,31 @@ const loadOrder = async () => {
order.value = null
} finally {
orderLoading.value = false
// 如果订单状态是未支付3秒后自动触发支付
if (order.value && order.value.paymentStatus === 'UNPAID') {
countdown.value = 3
// 倒计时显示
countdownTimer.value = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
if (countdownTimer.value) {
clearInterval(countdownTimer.value)
countdownTimer.value = null
}
}
}, 1000)
// 3秒后自动触发支付
autoPayTimer.value = setTimeout(() => {
if (countdownTimer.value) {
clearInterval(countdownTimer.value)
countdownTimer.value = null
}
countdown.value = 0
handlePay()
}, 3000)
}
}
}
@@ -378,6 +406,17 @@ const handlePay = async () => {
return
}
// 清除自动支付定时器和倒计时(如果存在)
if (autoPayTimer.value) {
clearTimeout(autoPayTimer.value)
autoPayTimer.value = null
}
if (countdownTimer.value) {
clearInterval(countdownTimer.value)
countdownTimer.value = null
}
countdown.value = 0
payLoading.value = true
try {
@@ -454,6 +493,18 @@ const handlePay = async () => {
onMounted(() => {
loadOrder()
})
// 组件卸载时清理定时器
onUnmounted(() => {
if (autoPayTimer.value) {
clearTimeout(autoPayTimer.value)
autoPayTimer.value = null
}
if (countdownTimer.value) {
clearInterval(countdownTimer.value)
countdownTimer.value = null
}
})
</script>
<style scoped>
@@ -628,18 +679,31 @@ onMounted(() => {
border-top: 1px solid #ebeef5;
}
/* 移动端优化 */
/* 移动端优先设计 - 响应式优化 */
@media (max-width: 768px) {
.order-confirm {
padding: 10px;
padding: 0;
max-width: 100%;
}
.el-card {
border-radius: 0;
box-shadow: none;
border: none;
margin: 0;
}
.el-card__body {
padding: 16px;
}
.card-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
gap: 12px;
font-size: 16px;
font-weight: 600;
padding: 12px 16px;
}
.order-info-section {
@@ -647,22 +711,47 @@ onMounted(() => {
}
.order-info-section h3 {
font-size: 14px;
font-size: 15px;
margin-bottom: 12px;
padding-bottom: 8px;
font-weight: 600;
border-bottom: 2px solid #409eff;
}
.order-amount {
font-size: 18px;
font-size: 20px;
font-weight: 700;
}
.order-amount-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.currency-conversion-info {
margin-top: 12px;
}
.conversion-alert-box {
border-radius: 8px;
}
.conversion-alert-content {
padding: 0;
}
.conversion-title {
font-size: 14px;
margin-bottom: 10px;
flex-direction: row;
align-items: center;
gap: 6px;
}
.title-text {
font-size: 14px;
font-weight: 600;
}
.warning-icon {
@@ -670,35 +759,53 @@ onMounted(() => {
}
.conversion-main-info {
padding: 12px;
padding: 12px 0;
}
.payment-amount-highlight {
flex-direction: column;
align-items: flex-start;
gap: 6px;
margin-bottom: 8px;
}
.payment-amount-large {
font-size: 20px;
font-size: 22px;
font-weight: 700;
}
.exchange-rate-info {
flex-direction: column;
gap: 6px;
margin-top: 8px;
font-size: 12px;
}
.rate-locked-info {
margin-top: 8px;
font-size: 12px;
display: flex;
align-items: center;
gap: 4px;
}
.action-buttons {
margin-top: 20px;
padding-top: 15px;
padding-top: 16px;
border-top: 1px solid #e4e7ed;
position: sticky;
bottom: 0;
background: white;
z-index: 10;
}
.action-buttons .el-button {
width: 100%;
margin: 0;
height: 44px;
height: 48px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
}
.action-buttons .el-button + .el-button {
@@ -710,26 +817,49 @@ onMounted(() => {
font-size: 13px;
}
.el-descriptions--border {
border: 1px solid #e4e7ed;
border-radius: 8px;
overflow: hidden;
}
.el-descriptions-item {
padding: 12px;
}
.el-descriptions-item__label {
font-size: 13px;
font-weight: 600;
width: 100px;
padding-right: 12px;
color: #606266;
}
.el-descriptions-item__content {
font-size: 13px;
color: #303133;
word-break: break-word;
}
.shipping-address-detail {
font-size: 13px;
line-height: 1.6;
padding: 8px 0;
}
.address-line {
margin-bottom: 6px;
margin-bottom: 8px;
padding: 6px 0;
border-bottom: 1px solid #f0f0f0;
}
.address-line:last-child {
border-bottom: none;
}
.address-line strong {
font-size: 12px;
font-weight: 600;
color: #606266;
margin-right: 6px;
}
}

View File

@@ -198,62 +198,6 @@
</el-tabs>
</div>
<!-- 购买确认对话框 -->
<el-dialog
v-model="showConfirmDialog"
:title="t('product.confirmPurchaseInfo')"
:width="isMobile ? '95%' : '600px'"
:close-on-click-modal="false"
class="confirm-dialog"
>
<div class="confirm-dialog-content">
<!-- 商品信息 -->
<div class="confirm-product-info">
<el-image
:src="currentImage"
fit="cover"
class="confirm-product-image"
/>
<div class="confirm-product-details">
<div class="confirm-product-name">{{ product.name }}</div>
<div class="confirm-sku-info" v-if="selectedSku">
<span class="confirm-sku-label">{{ t('product.sku') }}</span>
<span class="confirm-sku-value">{{ selectedSku.sku }}</span>
</div>
</div>
</div>
<!-- 价格和数量 -->
<el-divider />
<div class="confirm-price-section">
<div class="confirm-price-item">
<span class="confirm-price-label">{{ t('product.unitPrice') }}</span>
<span class="confirm-price-value">
{{ selectedSku ? selectedSku.currency : '' }} {{ formatPrice(selectedSku ? selectedSku.price : 0) }}
</span>
</div>
<div class="confirm-price-item">
<span class="confirm-price-label">{{ t('product.quantity') }}</span>
<span class="confirm-price-value">{{ quantity }}</span>
</div>
<div class="confirm-price-item confirm-total">
<span class="confirm-price-label">{{ t('product.total') }}</span>
<span class="confirm-price-value confirm-total-price">
{{ selectedSku ? selectedSku.currency : '' }} {{ formatPrice(selectedSku ? (selectedSku.price * quantity) : 0) }}
</span>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="showConfirmDialog = false">{{ t('product.cancel') }}</el-button>
<el-button type="primary" @click="confirmBuy" :loading="loading">
{{ t('product.confirmPurchase') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
</div>
</template>
@@ -281,7 +225,6 @@ const hoveredImage = ref(null)
const quantity = ref(1)
const activeTab = ref('detail')
const selectedSku = ref(null)
const showConfirmDialog = ref(false)
// 移动端检测
const isMobile = computed(() => {
@@ -413,7 +356,7 @@ const hoverImage = (img) => {
hoveredImage.value = img
}
// 立即购买
// 立即购买(直接跳转,不再显示确认弹窗)
const handleBuyNow = () => {
if (!selectedSku.value) {
ElMessage.warning('请先选择商品SKU')
@@ -425,18 +368,6 @@ const handleBuyNow = () => {
return
}
showConfirmDialog.value = true
}
// 确认购买
const confirmBuy = () => {
if (!selectedSku.value) {
ElMessage.warning('请先选择商品SKU')
return
}
showConfirmDialog.value = false
// 构建订单数据
const orderData = {
product: {
@@ -1209,97 +1140,245 @@ onMounted(() => {
line-height: 1.6;
}
/* 响应式设计 */
/* 移动端优先设计 - 响应式设计 */
@media (max-width: 768px) {
.product-detail {
padding: 10px;
padding: 0;
max-width: 100%;
}
.product-main {
flex-direction: column;
padding: 15px;
gap: 20px;
padding: 12px;
gap: 16px;
margin: 0;
border-radius: 0;
box-shadow: none;
}
.product-images {
flex: 1;
width: 100%;
margin-bottom: 0;
}
.main-image {
height: 300px;
height: auto;
min-height: 250px;
max-height: 400px;
border-radius: 8px;
}
.thumbnail-list {
margin-top: 12px;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
-webkit-overflow-scrolling: touch;
}
.thumbnail-item {
width: 60px;
height: 60px;
flex-shrink: 0;
}
.product-info {
padding-left: 0;
}
.product-title {
font-size: 20px;
}
.sku-list {
max-height: 300px;
}
.action-buttons {
flex-direction: column;
}
.buy-now-btn,
.pay-now-btn {
width: 100%;
}
.service-section {
flex-direction: column;
gap: 15px;
.product-title {
font-size: 18px;
margin-bottom: 16px;
line-height: 1.4;
word-break: break-word;
}
.currency-selector-card {
padding: 10px 12px;
margin-top: 10px;
margin-bottom: 15px;
padding: 12px;
margin-top: 0;
margin-bottom: 16px;
border-radius: 8px;
}
.currency-selector-header {
margin-bottom: 8px;
margin-bottom: 10px;
}
.currency-icon {
font-size: 16px;
margin-right: 5px;
font-size: 18px;
margin-right: 6px;
}
.currency-selector-header .selector-label {
font-size: 13px;
font-size: 14px;
}
.currency-selector-content {
padding: 8px;
padding: 0;
}
.currency-radio-group {
display: flex;
flex-wrap: nowrap;
gap: 4px;
flex-wrap: wrap;
gap: 6px;
width: 100%;
}
.currency-radio-button {
flex: 1;
min-width: 0;
min-width: calc(50% - 3px);
}
.currency-radio-button :deep(.el-radio-button__inner) {
font-size: 11px;
padding: 6px 4px;
font-size: 12px;
padding: 8px 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.sku-selection-section {
margin-bottom: 16px;
}
.sku-section-title {
font-size: 14px;
margin-bottom: 10px;
}
.sku-list {
max-height: none;
gap: 8px;
}
.sku-item {
padding: 12px;
border-radius: 8px;
}
.sku-name {
font-size: 14px;
margin-bottom: 6px;
}
.sku-price-stock {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.sku-price {
font-size: 16px;
}
.sku-stock {
font-size: 12px;
}
.price-section {
margin-bottom: 16px;
padding: 12px;
border-radius: 8px;
}
.price-label {
font-size: 13px;
margin-bottom: 6px;
}
.price-main {
flex-direction: row;
align-items: baseline;
}
.currency {
font-size: 16px;
margin-right: 4px;
}
.price {
font-size: 24px;
}
.quantity-section {
margin-bottom: 16px;
padding: 12px;
background: #f5f7fa;
border-radius: 8px;
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.quantity-label {
font-size: 14px;
font-weight: 600;
}
.quantity-input {
width: 100%;
}
.quantity-input :deep(.el-input-number) {
width: 100%;
}
.stock-info {
font-size: 12px;
color: #909399;
}
.service-section {
flex-direction: column;
gap: 10px;
margin-bottom: 16px;
padding: 12px;
background: #f9fafb;
border-radius: 8px;
}
.service-item {
font-size: 13px;
gap: 8px;
}
.action-buttons {
flex-direction: column;
gap: 10px;
margin-top: 20px;
position: sticky;
bottom: 0;
background: white;
padding: 12px 0;
z-index: 10;
}
.buy-now-btn,
.pay-now-btn {
width: 100%;
height: 48px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
}
.product-details-section {
padding: 12px;
}
.product-details-section h3 {
font-size: 16px;
margin-bottom: 12px;
}
.product-details-content {
font-size: 14px;
line-height: 1.6;
}
/* 旧样式兼容 */
.currency-selector {
flex-direction: column;