feat(order): 实现国际化支持并优化创建订单界面

- 将创建订单界面的所有静态文本替换为国际化标签
- 实现在移动端和桌面端的响应式表单布局
- 添加多语言国家名称显示功能
- 集成Vue I18n国际化框架到应用主入口
- 优化订单确认页面的国际化文本显示
- 移除导航菜单中的创建订单项
- 添加针对不同国家的地址字段验证规则
- 实现越南地址层级映射逻辑
- 添加货币代码自动设置国家功能
This commit is contained in:
2025-12-24 17:38:33 +08:00
parent bd6b7b3b79
commit d914301ee3
7 changed files with 929 additions and 270 deletions

View File

@@ -14,7 +14,6 @@
:default-active="activeIndex"
router
>
<el-menu-item index="/create-order">创建订单</el-menu-item>
<el-menu-item index="/query">订单查询</el-menu-item>
<el-menu-item index="/manage/product">商品管理</el-menu-item>
</el-menu>

View File

@@ -4,6 +4,7 @@ import router from './router'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import i18n from './i18n'
const app = createApp(App)
@@ -14,6 +15,7 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.use(router)
app.use(ElementPlus)
app.use(i18n)
// 添加错误处理
app.config.errorHandler = (err, instance, info) => {

View File

@@ -3,7 +3,7 @@
<el-card>
<template #header>
<div class="card-header">
<span>填写订单信息</span>
<span>{{ $t('order.fillOrderInfo') }}</span>
</div>
</template>
@@ -11,7 +11,7 @@
<el-card v-if="productInfo" class="product-info-card" shadow="never">
<template #header>
<div class="product-card-header">
<span>商品信息</span>
<span>{{ $t('order.productInfo') }}</span>
</div>
</template>
<div class="product-info-content">
@@ -28,11 +28,11 @@
<span class="sku-value">{{ productInfo.sku }}</span>
</div>
<div class="product-info-price">
<span class="price-label">单价</span>
<span class="price-label">{{ $t('product.unitPrice') }}</span>
<span class="price-value">{{ productInfo.currency }} {{ formatPrice(productInfo.price) }}</span>
<span class="quantity-label">数量</span>
<span class="quantity-label">{{ $t('product.quantity') }}</span>
<span class="quantity-value">x{{ productInfo.quantity }}</span>
<span class="total-label">小计</span>
<span class="total-label">{{ $t('order.subtotal') }}</span>
<span class="total-value">{{ productInfo.currency }} {{ formatPrice(productInfo.price * productInfo.quantity) }}</span>
</div>
</div>
@@ -44,239 +44,238 @@
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
label-position="left"
:label-width="isMobile ? 'auto' : '120px'"
:label-position="isMobile ? 'top' : 'left'"
>
<el-divider>客户信息</el-divider>
<el-divider>{{ $t('order.customerInfo') }}</el-divider>
<el-form-item label="客户姓名" prop="customerName">
<el-form-item :label="t('order.customerName')" prop="customerName">
<el-input
v-model="form.customerName"
placeholder="请输入客户姓名"
:placeholder="t('order.pleaseEnter') + t('order.customerName')"
clearable
/>
</el-form-item>
<el-form-item label="客户电话" prop="customerPhone">
<el-form-item :label="t('order.customerPhone')" prop="customerPhone">
<el-input
v-model="form.customerPhone"
placeholder="请输入客户电话"
:placeholder="t('order.pleaseEnter') + t('order.customerPhone')"
clearable
/>
</el-form-item>
<el-form-item label="客户邮箱" prop="customerEmail">
<el-form-item :label="t('order.customerEmail')" prop="customerEmail">
<el-input
v-model="form.customerEmail"
placeholder="请输入客户邮箱(可选)"
:placeholder="t('order.pleaseEnter') + t('order.customerEmail') + '' + t('order.optional') + ''"
clearable
/>
</el-form-item>
<el-divider>收货地址</el-divider>
<el-alert v-if="currentCountryConfig" :title="`地址格式${currentCountryConfig.addressFormat}`" type="info" :closable="false" style="margin-bottom: 20px" />
<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="收货人姓名" prop="shippingName">
<el-form-item :label="t('order.shippingName')" prop="shippingName">
<el-input
v-model="form.shippingName"
placeholder="请输入收货人姓名(需与证件一致,支持当地语言+英文)"
:placeholder="t('order.pleaseEnter') + t('order.shippingName') + '' + t('order.mustMatchId') + ''"
clearable
/>
</el-form-item>
<el-form-item label="收货人电话" prop="shippingPhone">
<el-form-item :label="t('order.shippingPhone')" prop="shippingPhone">
<el-input
v-model="form.shippingPhone"
:placeholder="currentCountryConfig ? `请输入收货人电话(国际区号:${currentCountryConfig.phoneCode}` : '请输入收货人电话(带国际区号'"
:placeholder="currentCountryConfig ? `${t('order.pleaseEnter')}${t('order.shippingPhone')}${t('order.phoneCode')}${currentCountryConfig.phoneCode}` : t('order.pleaseEnter') + t('order.shippingPhone') + '' + t('order.phoneCode') + ''"
clearable
/>
</el-form-item>
<el-form-item label="收货国家" prop="shippingCountry">
<el-select v-model="form.shippingCountry" placeholder="请选择国家" style="width: 100%">
<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="中国 (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 :label="t('order.shippingCountry')" prop="shippingCountry">
<el-input
v-model="currentCountryDisplayName"
disabled
style="width: 100%"
/>
</el-form-item>
<!-- 详细地址1门牌号街道楼栋- 所有国家都显示 -->
<el-form-item v-if="showField('shippingAddressLine1')" :label="getFieldLabel('shippingAddressLine1')" prop="shippingAddressLine1">
<el-form-item v-if="showField('shippingAddressLine1')" :label="t('order.addressLine1')" prop="shippingAddressLine1">
<el-input
v-model="form.shippingAddressLine1"
type="textarea"
:rows="2"
placeholder="请输入门牌号、街道、楼栋"
:placeholder="t('order.placeholderAddressLine1')"
clearable
/>
</el-form-item>
<!-- 详细地址2楼层单元号可选 -->
<el-form-item v-if="showField('shippingAddressLine2')" :label="getFieldLabel('shippingAddressLine2')" prop="shippingAddressLine2">
<el-form-item v-if="showField('shippingAddressLine2')" :label="t('order.addressLine2')" prop="shippingAddressLine2">
<el-input
v-model="form.shippingAddressLine2"
placeholder="请输入楼层、单元号(可选)"
:placeholder="t('order.placeholderAddressLine2')"
clearable
/>
</el-form-item>
<!-- 新加坡组屋号和单元号 -->
<template v-if="form.shippingCountry === 'SG'">
<el-form-item :label="getFieldLabel('shippingBlockNumber')" prop="shippingBlockNumber">
<el-form-item :label="t('order.blockNumber')" prop="shippingBlockNumber">
<el-input
v-model="form.shippingBlockNumber"
placeholder="例如Blk 123"
:placeholder="t('order.placeholderBlockNumber')"
clearable
/>
</el-form-item>
<el-form-item :label="getFieldLabel('shippingUnitNumber')" prop="shippingUnitNumber">
<el-form-item :label="t('order.unitNumber')" prop="shippingUnitNumber">
<el-input
v-model="form.shippingUnitNumber"
placeholder="例如:#01-234"
:placeholder="t('order.placeholderUnitNumber')"
clearable
/>
</el-form-item>
</template>
<!-- 菲律宾Barangay -->
<el-form-item v-if="form.shippingCountry === 'PH'" :label="getFieldLabel('shippingBarangay')" prop="shippingBarangay">
<el-form-item v-if="form.shippingCountry === 'PH'" :label="t('order.barangay')" prop="shippingBarangay">
<el-input
v-model="form.shippingBarangay"
placeholder="请输入Barangay(社区编号)"
:placeholder="t('order.placeholderBarangay')"
clearable
/>
</el-form-item>
<!-- 泰国泰文地址 -->
<el-form-item v-if="form.shippingCountry === 'TH'" :label="getFieldLabel('shippingAddressThai')" prop="shippingAddressThai">
<el-form-item v-if="form.shippingCountry === 'TH'" :label="t('order.addressThai')" prop="shippingAddressThai">
<el-input
v-model="form.shippingAddressThai"
type="textarea"
:rows="2"
placeholder="请输入泰文地址(支持双语)"
:placeholder="t('order.placeholderAddressThai')"
clearable
/>
</el-form-item>
<!-- 越南// -->
<template v-if="form.shippingCountry === 'VN'">
<el-form-item :label="getFieldLabel('shippingProvince')" prop="shippingProvince">
<el-form-item :label="t('order.province')" prop="shippingProvince">
<el-input
v-model="form.shippingProvince"
placeholder="请输入省 (Tỉnh)"
:placeholder="t('order.placeholderProvinceVN')"
clearable
/>
</el-form-item>
<el-form-item :label="getFieldLabel('shippingDistrict')" prop="shippingDistrict">
<el-form-item :label="t('order.district')" prop="shippingDistrict">
<el-input
v-model="form.shippingDistrict"
placeholder="请输入市/郡 (Thành phố/Huyện)"
:placeholder="t('order.placeholderDistrictVN')"
clearable
/>
</el-form-item>
<el-form-item :label="getFieldLabel('shippingWard')" prop="shippingWard">
<el-form-item :label="t('order.ward')" prop="shippingWard">
<el-input
v-model="form.shippingWard"
placeholder="请输入区/坊 (Quận/Phường)"
:placeholder="t('order.placeholderWardVN')"
clearable
/>
</el-form-item>
</template>
<!-- 马来西亚州属 -->
<el-form-item v-if="form.shippingCountry === 'MY'" :label="getFieldLabel('shippingStateMalaysia')" prop="shippingStateMalaysia">
<el-form-item v-if="form.shippingCountry === 'MY'" :label="t('order.stateMalaysia')" prop="shippingStateMalaysia">
<el-input
v-model="form.shippingStateMalaysia"
placeholder="例如Selangor雪兰莪"
:placeholder="t('order.placeholderStateMalaysia')"
clearable
/>
</el-form-item>
<!-- 泰国行政区域/Tambon -->
<el-form-item v-if="form.shippingCountry === 'TH' && showField('shippingAdministrativeArea')" :label="getFieldLabel('shippingAdministrativeArea')" prop="shippingAdministrativeArea">
<el-form-item v-if="form.shippingCountry === 'TH' && showField('shippingAdministrativeArea')" :label="t('order.administrativeArea')" prop="shippingAdministrativeArea">
<el-input
v-model="form.shippingAdministrativeArea"
placeholder="请输入区 (Tambon)"
:placeholder="t('order.placeholderAdministrativeArea')"
clearable
/>
</el-form-item>
<!-- 城市和州/通用字段 -->
<el-form-item label="城市/城镇" prop="shippingCity">
<el-input
v-model="form.shippingCity"
:placeholder="form.shippingCountry === 'TH' ? '请输入县 (Amphoe)' : '请输入城市/城镇'"
style="width: 48%"
clearable
/>
<el-input
v-if="form.shippingCountry !== 'VN' && form.shippingCountry !== 'MY'"
v-model="form.shippingState"
:placeholder="form.shippingCountry === 'TH' ? '府 (Changwat)' : form.shippingCountry === 'PH' ? '省 (Province)' : '州/省(可选)'"
style="width: 48%; margin-left: 4%"
clearable
/>
<!-- 城市和州/通用字段- 越南不使用此字段使用独立的省//// -->
<el-form-item v-if="form.shippingCountry !== 'VN'" :label="getCityLabel()" prop="shippingCity">
<div :class="isMobile ? 'mobile-input-group' : 'desktop-input-group'">
<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="邮政编码" 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 :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="楼层/单元/代收点" prop="shippingFloorUnit">
<el-form-item :label="t('order.floorUnit')" prop="shippingFloorUnit">
<el-input
v-model="form.shippingFloorUnit"
placeholder="楼层、单元号或代收点信息(可选)"
:placeholder="t('order.placeholderFloorUnit')"
clearable
/>
</el-form-item>
<!-- 兼容旧字段街道地址如果新字段为空使用旧字段 -->
<el-form-item v-if="!form.shippingAddressLine1" label="街道地址" prop="shippingStreet">
<el-form-item v-if="!form.shippingAddressLine1" :label="t('order.street')" prop="shippingStreet">
<el-input
v-model="form.shippingStreet"
type="textarea"
:rows="2"
placeholder="请输入详细街道地址"
:placeholder="t('order.placeholderStreet')"
clearable
/>
</el-form-item>
<el-form-item label="订单备注" prop="remark">
<el-form-item :label="t('order.remark')" prop="remark">
<el-input
v-model="form.remark"
type="textarea"
:rows="3"
placeholder="请输入订单备注(可选)"
:placeholder="t('order.placeholderRemark')"
clearable
/>
</el-form-item>
<el-form-item>
<el-button type="primary" size="large" @click="submitForm" :loading="loading" style="width: 200px">
提交订单
{{ $t('order.submit') }}
</el-button>
<el-button @click="goBack" style="margin-left: 10px">返回</el-button>
<el-button @click="goBack" style="margin-left: 10px">{{ $t('order.back') }}</el-button>
</el-form-item>
</el-form>
</el-card>
@@ -286,10 +285,15 @@
<script setup>
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 { createCustomerOrder } from '../api/order'
import { formatAmount } from '../utils/helpers'
import { getCountryConfig, getCountryByCurrency, validatePostcode, getRequiredFields } from '../utils/countryConfig'
import { loadTranslationByCurrency } from '../i18n'
import i18n from '../i18n'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
@@ -330,6 +334,35 @@ const currentCountryConfig = computed(() => {
return getCountryConfig(form.shippingCountry)
})
// 国家显示名称(根据国家代码显示对应语言的名称)
const currentCountryDisplayName = computed(() => {
if (!form.shippingCountry || !currentCountryConfig.value) {
return ''
}
// 根据当前语言显示国家名称
const countryNames = {
'SG': { zh: '新加坡', en: 'Singapore', may: 'Singapura', fil: 'Singapore', th: 'สิงคโปร์', vie: 'Singapore', id: 'Singapura' },
'MY': { zh: '马来西亚', en: 'Malaysia', may: 'Malaysia', fil: 'Malaysia', th: 'มาเลเซีย', vie: 'Malaysia', id: 'Malaysia' },
'PH': { zh: '菲律宾', en: 'Philippines', may: 'Filipina', fil: 'Pilipinas', th: 'ฟิลิปปินส์', vie: 'Philippines', id: 'Filipina' },
'TH': { zh: '泰国', en: 'Thailand', may: 'Thailand', fil: 'Thailand', th: 'ประเทศไทย', vie: 'Thailand', id: 'Thailand' },
'VN': { zh: '越南', en: 'Vietnam', may: 'Vietnam', fil: 'Vietnam', th: 'เวียดนาม', vie: 'Việt Nam', id: 'Vietnam' },
'CN': { zh: '中国', en: 'China', may: 'China', fil: 'China', th: 'จีน', vie: 'Trung Quốc', id: 'China' },
'US': { zh: '美国', en: 'United States', may: 'Amerika Syarikat', fil: 'Estados Unidos', th: 'สหรัฐอเมริกา', vie: 'Hoa Kỳ', id: 'Amerika Serikat' },
'GB': { zh: '英国', en: 'United Kingdom', may: 'United Kingdom', fil: 'United Kingdom', th: 'สหราชอาณาจักร', vie: 'Vương quốc Anh', id: 'Inggris' },
'DE': { zh: '德国', en: 'Germany', may: 'Jerman', fil: 'Alemanya', th: 'เยอรมนี', vie: 'Đức', id: 'Jerman' },
'FR': { zh: '法国', en: 'France', may: 'Perancis', fil: 'Pransya', th: 'ฝรั่งเศส', vie: 'Pháp', id: 'Prancis' }
}
const countryNameMap = countryNames[form.shippingCountry]
if (!countryNameMap) {
return currentCountryConfig.value.nameEn || form.shippingCountry
}
// 获取当前语言
const currentLang = i18n.global.locale.value || 'zh'
const langMap = { 'zh': 'zh', 'en': 'en', 'may': 'may', 'fil': 'fil', 'th': 'th', 'vie': 'vie', 'id': 'id' }
const lang = langMap[currentLang] || 'en'
return countryNameMap[lang] || countryNameMap.en || form.shippingCountry
})
// 是否显示特定字段
const showField = (fieldName) => {
if (!currentCountryConfig.value) {
@@ -375,6 +408,38 @@ 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)'
}
return t('order.cityTown')
}
// 获取城市字段占位符
const getCityPlaceholder = () => {
if (form.shippingCountry === 'TH') {
return t('order.placeholderCityTH')
}
return t('order.placeholderCity')
}
// 获取州/省字段占位符
const getStatePlaceholder = () => {
if (form.shippingCountry === 'TH') {
return t('order.placeholderStateTH')
} else if (form.shippingCountry === 'PH') {
return t('order.placeholderStatePH')
}
return t('order.placeholderStateOptional')
}
// 监听国家变化,清空相关字段
watch(() => form.shippingCountry, (newCountry, oldCountry) => {
if (newCountry !== oldCountry) {
@@ -427,30 +492,30 @@ watch(() => form.shippingPostcode, async (newPostcode) => {
const getRules = () => {
const baseRules = {
customerName: [
{ required: true, message: '请输入客户姓名', trigger: 'blur' }
{ required: true, message: t('order.validationRequired', [t('order.customerName')]), trigger: 'blur' }
],
customerPhone: [
{ required: true, message: '请输入客户电话', trigger: 'blur' },
{ pattern: /^[0-9+\-\s()]+$/, message: '请输入有效的电话号码', trigger: 'blur' }
{ required: true, message: t('order.validationRequired', [t('order.customerPhone')]), trigger: 'blur' },
{ pattern: /^[0-9+\-\s()]+$/, message: t('order.validationInvalidPhone'), trigger: 'blur' }
],
customerEmail: [
{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }
{ type: 'email', message: t('order.validationInvalidEmail'), trigger: 'blur' }
],
shippingName: [
{ required: true, message: '请输入收货人姓名', trigger: 'blur' }
{ required: true, message: t('order.validationRequired', [t('order.shippingName')]), trigger: 'blur' }
],
shippingPhone: [
{ required: true, message: '请输入收货人电话', trigger: 'blur' },
{ pattern: /^[0-9+\-\s()]+$/, message: '请输入有效的电话号码', trigger: 'blur' }
{ 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: '请选择收货国家', trigger: 'change' }
{ required: true, message: t('order.validationSelectCountry'), trigger: 'change' }
],
shippingCity: [
{ required: true, message: '请输入收货城市', trigger: 'blur' }
{ required: true, message: t('order.validationRequired', [t('order.cityTown')]), trigger: 'blur' }
],
shippingStreet: [
{ required: true, message: '请输入街道地址', trigger: 'blur' }
{ required: true, message: t('order.validationRequired', [t('order.street')]), trigger: 'blur' }
]
}
@@ -460,17 +525,17 @@ const getRules = () => {
if (requiredFields.includes('shippingAddressLine1')) {
baseRules.shippingAddressLine1 = [
{ required: true, message: '请输入详细地址1', trigger: 'blur' }
{ required: true, message: t('order.validationRequired', [t('order.addressLine1')]), trigger: 'blur' }
]
}
if (requiredFields.includes('shippingPostcode')) {
baseRules.shippingPostcode = [
{ required: true, message: '请输入邮编', trigger: 'blur' },
{ required: true, message: t('order.validationRequired', [t('order.postcode')]), trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value && !validatePostcode(form.shippingCountry, value)) {
callback(new Error(`邮编格式不正确,应为${currentCountryConfig.value.postcodeLength}位数字`))
callback(new Error(t('order.validationPostcodeFormat', [currentCountryConfig.value.postcodeLength])))
} else {
callback()
}
@@ -482,43 +547,50 @@ const getRules = () => {
if (requiredFields.includes('shippingBlockNumber')) {
baseRules.shippingBlockNumber = [
{ required: true, message: '请输入组屋号', trigger: 'blur' }
{ required: true, message: t('order.validationRequired', [t('order.blockNumber')]), trigger: 'blur' }
]
}
if (requiredFields.includes('shippingUnitNumber')) {
baseRules.shippingUnitNumber = [
{ required: true, message: '请输入单元号', trigger: 'blur' }
{ required: true, message: t('order.validationRequired', [t('order.unitNumber')]), trigger: 'blur' }
]
}
if (requiredFields.includes('shippingBarangay')) {
baseRules.shippingBarangay = [
{ required: true, message: '请输入Barangay社区编号', trigger: 'blur' }
{ required: true, message: t('order.validationRequired', [t('order.barangay')]), trigger: 'blur' }
]
}
if (requiredFields.includes('shippingStateMalaysia')) {
baseRules.shippingStateMalaysia = [
{ required: true, message: '请输入州属', trigger: 'blur' }
{ required: true, message: t('order.validationRequired', [t('order.stateMalaysia')]), trigger: 'blur' }
]
}
if (requiredFields.includes('shippingProvince')) {
baseRules.shippingProvince = [
{ required: true, message: '请输入省', trigger: 'blur' }
{ required: true, message: t('order.validationRequired', [t('order.province')]), trigger: 'blur' }
]
}
if (requiredFields.includes('shippingDistrict')) {
baseRules.shippingDistrict = [
{ required: true, message: '请输入市/郡', trigger: 'blur' }
{ required: true, message: t('order.validationRequired', [t('order.district')]), trigger: 'blur' }
]
}
if (requiredFields.includes('shippingWard')) {
baseRules.shippingWard = [
{ required: true, message: '请输入区/坊', trigger: 'blur' }
{ required: true, message: t('order.validationRequired', [t('order.ward')]), trigger: 'blur' }
]
}
// 泰国:泰文地址必填
if (requiredFields.includes('shippingAddressThai')) {
baseRules.shippingAddressThai = [
{ required: true, message: t('order.validationRequired', [t('order.addressThai')]), trigger: 'blur' }
]
}
}
@@ -539,12 +611,12 @@ const submitForm = async () => {
await formRef.value.validate(async (valid) => {
if (!valid) {
ElMessage.error('请填写完整信息')
ElMessage.error(t('order.validationFillComplete'))
return
}
if (!productInfo.value) {
ElMessage.error('商品信息缺失,请重新选择商品')
ElMessage.error(t('order.validationProductMissing'))
return
}
@@ -552,6 +624,14 @@ const submitForm = async () => {
try {
// 构建订单请求数据
// 对于越南,将 shippingDistrict市/郡)映射到 shippingCity因为后端要求 shippingCity 不能为空
// 越南的地址层级Tỉnh→ 市/郡Thành phố/Huyện→ 区/坊Quận/Phường
// shippingDistrict市/郡)对应其他国家的"城市"概念
let shippingCity = form.shippingCity
if (form.shippingCountry === 'VN' && (!shippingCity || shippingCity.trim() === '') && form.shippingDistrict) {
shippingCity = form.shippingDistrict
}
const orderData = {
productId: productInfo.value.id,
skuId: productInfo.value.skuId,
@@ -563,7 +643,7 @@ const submitForm = async () => {
shippingPhone: form.shippingPhone,
shippingCountry: form.shippingCountry,
shippingState: form.shippingState || null,
shippingCity: form.shippingCity,
shippingCity: shippingCity, // 使用映射后的值,越南使用 shippingDistrict
shippingStreet: form.shippingStreet || form.shippingAddressLine1, // 兼容旧字段
shippingPostcode: form.shippingPostcode || null,
// 东南亚地址扩展字段
@@ -585,18 +665,18 @@ const submitForm = async () => {
const response = await createCustomerOrder(orderData)
if (response.code === '0000' && response.data) {
ElMessage.success('订单创建成功')
ElMessage.success(t('order.validationOrderCreateSuccess'))
// 跳转到订单确认页面
router.push({
path: '/order/confirm',
query: { orderNo: response.data.orderNo }
})
} else {
ElMessage.error(response.message || '创建订单失败')
ElMessage.error(response.message || t('order.validationOrderCreateFailed'))
}
} catch (error) {
console.error('创建订单失败:', error)
ElMessage.error(error.response?.data?.message || '创建订单失败,请稍后重试')
ElMessage.error(error.response?.data?.message || t('order.validationOrderCreateRetry'))
} finally {
loading.value = false
}
@@ -609,14 +689,17 @@ const formatPrice = (price) => {
}
// 从路由参数获取商品信息
onMounted(() => {
onMounted(async () => {
if (route.query.data) {
try {
const data = JSON.parse(decodeURIComponent(route.query.data))
if (data.product) {
productInfo.value = data.product
// 根据SKU的货币代码自动设置国家
// 根据SKU的货币代码自动设置国家并加载翻译
if (data.product.currency) {
// 加载对应货币的翻译
await loadTranslationByCurrency(data.product.currency)
const countryCode = getCountryByCurrency(data.product.currency)
if (countryCode) {
form.shippingCountry = countryCode
@@ -629,11 +712,11 @@ onMounted(() => {
}
} catch (error) {
console.error('解析商品信息失败:', error)
ElMessage.error('商品信息解析失败')
ElMessage.error(t('order.validationProductParseFailed'))
router.push('/')
}
} else {
ElMessage.error('缺少商品信息')
ElMessage.error(t('order.validationProductInfoMissing'))
router.push('/')
}
})
@@ -746,4 +829,145 @@ onMounted(() => {
color: #f56c6c;
font-weight: 700;
}
/* 移动端优化 */
@media (max-width: 768px) {
.create-order {
padding: 10px;
max-width: 100%;
}
.card-header {
font-size: 16px;
padding: 10px 0;
}
.product-info-card {
margin-bottom: 15px;
}
.product-card-header {
font-size: 14px;
}
.product-info-main {
flex-direction: column;
gap: 12px;
}
.product-info-image {
width: 100%;
height: 200px;
align-self: center;
}
.product-info-name {
font-size: 14px;
}
.product-info-price {
flex-direction: column;
align-items: flex-start;
gap: 8px;
padding: 10px;
}
.total-label {
margin-left: 0;
margin-top: 8px;
font-size: 16px;
}
.total-value {
font-size: 20px;
}
/* 表单优化 */
.el-form-item {
margin-bottom: 18px;
}
.el-form-item__label {
padding-bottom: 5px;
font-size: 14px;
font-weight: 600;
}
.el-input,
.el-select,
.el-textarea {
width: 100%;
}
/* 地址字段优化 */
.el-form-item :deep(.el-input-group) {
display: flex;
flex-direction: column;
}
.el-form-item :deep(.el-input-group__append) {
margin-left: 0;
margin-top: 8px;
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;
}
.el-divider__text {
font-size: 14px;
padding: 0 15px;
}
/* 提示信息优化 */
.el-alert {
margin-bottom: 15px;
font-size: 12px;
}
/* 输入组优化 */
.mobile-input-group {
display: flex;
flex-direction: column;
width: 100%;
}
.desktop-input-group {
display: flex;
width: 100%;
}
.mobile-postcode-group {
display: flex;
flex-direction: column;
width: 100%;
}
.desktop-postcode-group {
display: flex;
align-items: center;
width: 100%;
}
}
</style>

View File

@@ -8,7 +8,7 @@
<el-card>
<template #header>
<div class="card-header">
<span>订单确认</span>
<span>{{ $t('confirm.orderInfo') }}</span>
<el-tag :type="getStatusType(order.status)" size="large">
{{ getStatusText(order.status) }}
</el-tag>
@@ -17,10 +17,10 @@
<!-- 订单信息 -->
<div class="order-info-section">
<h3>订单信息</h3>
<el-descriptions :column="2" border>
<el-descriptions-item label="订单号">{{ order.orderNo }}</el-descriptions-item>
<el-descriptions-item label="订单金额">
<h3>{{ $t('confirm.orderInfo') }}</h3>
<el-descriptions :column="isMobile ? 1 : 2" border>
<el-descriptions-item :label="$t('confirm.orderNo')">{{ order.orderNo }}</el-descriptions-item>
<el-descriptions-item :label="$t('confirm.orderAmount')">
<div class="order-amount-section">
<div class="original-amount">
<span class="order-amount">{{ order.currency }} {{ formatPrice(order.totalAmount) }}</span>
@@ -38,21 +38,21 @@
<div class="conversion-alert-content">
<div class="conversion-title">
<el-icon class="warning-icon"><Warning /></el-icon>
<strong class="title-text">您将以{{ getCurrencyName(order.paymentCurrency) }}支付</strong>
<strong class="title-text">{{ $t('confirm.willPayIn', [getCurrencyName(order.paymentCurrency)]) }}</strong>
</div>
<div class="conversion-main-info">
<div class="payment-amount-highlight">
<span class="label">实际费用</span>
<span class="label">{{ $t('confirm.actualCost') }}</span>
<span class="payment-amount-large">{{ order.paymentCurrency }} {{ formatPrice(order.paymentAmount) }}</span>
</div>
<div v-if="order.exchangeRate" class="exchange-rate-info">
<span class="equivalent-amount"> {{ order.currency }} {{ formatPrice(order.totalAmount) }}</span>
<span class="rate-value">汇率{{ formatRate(order.exchangeRate) }}</span>
<span class="equivalent-amount">{{ $t('confirm.approximately') }} {{ order.currency }} {{ formatPrice(order.totalAmount) }}</span>
<span class="rate-value">{{ $t('confirm.exchangeRate') }}{{ formatRate(order.exchangeRate) }}</span>
</div>
</div>
<div v-if="order.rateLockedAt" class="rate-locked-info">
<el-icon><Clock /></el-icon>
<span>汇率锁定时间{{ formatDateTime(order.rateLockedAt) }}</span>
<span>{{ $t('confirm.rateLockedAt') }}{{ formatDateTime(order.rateLockedAt) }}</span>
</div>
</div>
</template>
@@ -60,71 +60,71 @@
</div>
</div>
</el-descriptions-item>
<el-descriptions-item label="商品名称" :span="2">{{ order.productName }}</el-descriptions-item>
<el-descriptions-item label="SKU名称" :span="2">{{ order.skuName }}</el-descriptions-item>
<el-descriptions-item label="购买数量">{{ order.quantity }}</el-descriptions-item>
<el-descriptions-item label="单价">{{ order.currency }} {{ formatPrice(order.unitPrice) }}</el-descriptions-item>
<el-descriptions-item label="创建时间" :span="2">{{ formatDateTime(order.createTime) }}</el-descriptions-item>
<el-descriptions-item :label="$t('confirm.productName')" :span="2">{{ order.productName }}</el-descriptions-item>
<el-descriptions-item :label="$t('confirm.skuName')" :span="2">{{ order.skuName }}</el-descriptions-item>
<el-descriptions-item :label="$t('confirm.quantity')">{{ order.quantity }}</el-descriptions-item>
<el-descriptions-item :label="$t('confirm.unitPrice')">{{ order.currency }} {{ formatPrice(order.unitPrice) }}</el-descriptions-item>
<el-descriptions-item :label="$t('confirm.createTime')" :span="2">{{ formatDateTime(order.createTime) }}</el-descriptions-item>
</el-descriptions>
</div>
<!-- 客户信息 -->
<div class="order-info-section">
<h3>客户信息</h3>
<el-descriptions :column="2" border>
<el-descriptions-item label="客户姓名">{{ order.customerName }}</el-descriptions-item>
<el-descriptions-item label="客户电话">{{ order.customerPhone }}</el-descriptions-item>
<el-descriptions-item label="客户邮箱" :span="2">
{{ order.customerEmail || '未填写' }}
<h3>{{ $t('order.customerInfo') }}</h3>
<el-descriptions :column="isMobile ? 1 : 2" border>
<el-descriptions-item :label="$t('order.customerName')">{{ order.customerName }}</el-descriptions-item>
<el-descriptions-item :label="$t('order.customerPhone')">{{ order.customerPhone }}</el-descriptions-item>
<el-descriptions-item :label="$t('order.customerEmail')" :span="2">
{{ order.customerEmail || $t('confirm.notFilled') }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 收货地址 -->
<div class="order-info-section">
<h3>收货地址</h3>
<el-descriptions :column="2" border>
<el-descriptions-item label="收货人">{{ order.shippingName }}</el-descriptions-item>
<el-descriptions-item label="收货电话">{{ order.shippingPhone }}</el-descriptions-item>
<el-descriptions-item label="收货地址" :span="2">
<h3>{{ $t('order.shippingAddress') }}</h3>
<el-descriptions :column="isMobile ? 1 : 2" border>
<el-descriptions-item :label="$t('confirm.recipient')">{{ order.shippingName }}</el-descriptions-item>
<el-descriptions-item :label="$t('confirm.recipientPhone')">{{ order.shippingPhone }}</el-descriptions-item>
<el-descriptions-item :label="$t('confirm.shippingAddress')" :span="2">
<div class="shipping-address-detail">
<div v-if="order.shippingAddressLine1" class="address-line">
<strong>详细地址1</strong>{{ order.shippingAddressLine1 }}
<strong>{{ $t('confirm.addressLine1') }}</strong>{{ order.shippingAddressLine1 }}
</div>
<div v-if="order.shippingAddressLine2" class="address-line">
<strong>详细地址2</strong>{{ order.shippingAddressLine2 }}
<strong>{{ $t('confirm.addressLine2') }}</strong>{{ order.shippingAddressLine2 }}
</div>
<!-- 从JSON字段中读取特殊字段 -->
<template v-if="order.shippingSpecialFields">
<div v-if="order.shippingSpecialFields.blockNumber" class="address-line">
<strong>组屋号</strong>{{ order.shippingSpecialFields.blockNumber }}
<strong>{{ $t('order.blockNumber') }}</strong>{{ order.shippingSpecialFields.blockNumber }}
</div>
<div v-if="order.shippingSpecialFields.unitNumber" class="address-line">
<strong>单元号</strong>{{ order.shippingSpecialFields.unitNumber }}
<strong>{{ $t('order.unitNumber') }}</strong>{{ order.shippingSpecialFields.unitNumber }}
</div>
<div v-if="order.shippingSpecialFields.barangay" class="address-line">
<strong>Barangay</strong>{{ order.shippingSpecialFields.barangay }}
<strong>{{ $t('order.barangay') }}</strong>{{ order.shippingSpecialFields.barangay }}
</div>
<div v-if="order.shippingSpecialFields.addressThai" class="address-line">
<strong>泰文地址</strong>{{ order.shippingSpecialFields.addressThai }}
<strong>{{ $t('order.addressThai') }}</strong>{{ order.shippingSpecialFields.addressThai }}
</div>
<div v-if="order.shippingSpecialFields.province" class="address-line">
<strong></strong>{{ order.shippingSpecialFields.province }}
<strong>{{ $t('order.province') }}</strong>{{ order.shippingSpecialFields.province }}
</div>
<div v-if="order.shippingSpecialFields.district" class="address-line">
<strong>/</strong>{{ order.shippingSpecialFields.district }}
<strong>{{ $t('order.district') }}</strong>{{ order.shippingSpecialFields.district }}
</div>
<div v-if="order.shippingSpecialFields.ward" class="address-line">
<strong>/</strong>{{ order.shippingSpecialFields.ward }}
<strong>{{ $t('order.ward') }}</strong>{{ order.shippingSpecialFields.ward }}
</div>
<div v-if="order.shippingSpecialFields.stateMalaysia" class="address-line">
<strong>州属</strong>{{ order.shippingSpecialFields.stateMalaysia }}
<strong>{{ $t('order.stateMalaysia') }}</strong>{{ order.shippingSpecialFields.stateMalaysia }}
</div>
<div v-if="order.shippingSpecialFields.administrativeArea" class="address-line">
<strong>行政区域</strong>{{ order.shippingSpecialFields.administrativeArea }}
<strong>{{ $t('order.administrativeArea') }}</strong>{{ order.shippingSpecialFields.administrativeArea }}
</div>
<div v-if="order.shippingSpecialFields.floorUnit" class="address-line">
<strong>楼层/单元/代收点</strong>{{ order.shippingSpecialFields.floorUnit }}
<strong>{{ $t('order.floorUnit') }}</strong>{{ order.shippingSpecialFields.floorUnit }}
</div>
</template>
<div class="address-line">
@@ -137,7 +137,7 @@
<!-- 订单备注 -->
<div class="order-info-section" v-if="order.remark">
<h3>订单备注</h3>
<h3>{{ $t('confirm.orderRemark') }}</h3>
<p class="order-remark">{{ order.remark }}</p>
</div>
@@ -149,32 +149,36 @@
size="large"
@click="handlePay"
:loading="payLoading"
style="width: 200px"
:style="isMobile ? 'width: 100%' : 'width: 200px'"
>
<el-icon><Money /></el-icon>
立即支付
{{ $t('confirm.payNow') }}
</el-button>
<el-button @click="goBack" style="margin-left: 10px">返回</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>
</el-card>
</template>
<el-card v-else>
<el-empty description="订单不存在">
<el-button type="primary" @click="goBack">返回</el-button>
<el-empty :description="$t('confirm.orderNotFound')">
<el-button type="primary" @click="goBack">{{ $t('confirm.back') }}</el-button>
</el-empty>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { Money, Clock, Warning } from '@element-plus/icons-vue'
import { getOrderByOrderNo, calculateCurrencyConversion } from '../api/order'
import { createPayPalOrder, getPayPalOrder, capturePayPalOrder } from '../api/paypal'
import { formatAmount } from '../utils/helpers'
import { loadTranslationByCurrency } from '../i18n'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
@@ -182,6 +186,14 @@ const orderLoading = ref(true)
const payLoading = ref(false)
const order = ref(null)
// 移动端检测
const isMobile = computed(() => {
if (typeof window !== 'undefined') {
return window.innerWidth <= 768
}
return false
})
// 获取订单状态类型
const getStatusType = (status) => {
const statusMap = {
@@ -197,11 +209,11 @@ const getStatusType = (status) => {
// 获取订单状态文本
const getStatusText = (status) => {
const statusMap = {
'PENDING': '待支付',
'PAID': '已支付',
'SHIPPED': '已发货',
'COMPLETED': '已完成',
'CANCELLED': '已取消'
'PENDING': t('confirm.statusPending'),
'PAID': t('confirm.statusPaid'),
'SHIPPED': t('confirm.statusShipped'),
'COMPLETED': t('confirm.statusCompleted'),
'CANCELLED': t('confirm.statusCancelled')
}
return statusMap[status] || status
}
@@ -214,17 +226,18 @@ const formatPrice = (price) => {
// 获取货币名称
const getCurrencyName = (currencyCode) => {
const currencyNames = {
'USD': '美元',
'EUR': '欧元',
'GBP': '英镑',
'CNY': '人民币',
'MYR': '马来西亚林吉特',
'VND': '越南盾',
'JPY': '日元',
'KRW': '韩元',
'THB': '泰铢',
'SGD': '新加坡元',
'HKD': '港币'
'USD': t('confirm.currencyUSD'),
'EUR': t('confirm.currencyEUR'),
'GBP': t('confirm.currencyGBP'),
'CNY': t('confirm.currencyCNY'),
'MYR': t('confirm.currencyMYR'),
'VND': t('confirm.currencyVND'),
'JPY': t('confirm.currencyJPY'),
'KRW': t('confirm.currencyKRW'),
'THB': t('confirm.currencyTHB'),
'SGD': t('confirm.currencySGD'),
'HKD': t('confirm.currencyHKD'),
'PHP': t('confirm.currencyPHP')
}
return currencyNames[currencyCode] || currencyCode
}
@@ -262,9 +275,9 @@ const formatShippingAddress = (order) => {
}
if (order.shippingCountry) parts.push(order.shippingCountry)
if (order.shippingPostcode) parts.push(`邮编${order.shippingPostcode}`)
if (order.shippingPostcode) parts.push(`${t('confirm.postcodeLabel')}${order.shippingPostcode}`)
return parts.length > 0 ? parts.join('') : '地址信息不完整'
return parts.length > 0 ? parts.join('') : t('confirm.addressIncomplete')
}
// 返回上一页
@@ -274,9 +287,10 @@ const goBack = () => {
// 加载订单详情
const loadOrder = async () => {
// 先加载订单,获取货币信息后再加载翻译
const orderNo = route.query.orderNo
if (!orderNo) {
ElMessage.error('订单号不能为空')
ElMessage.error(t('order.validationRequired', [t('confirm.orderNo')]))
router.push('/')
return
}
@@ -287,6 +301,19 @@ const loadOrder = async () => {
if (response.code === '0000' && response.data) {
order.value = response.data
// 调试:打印订单信息
console.log('订单加载成功:', {
orderNo: order.value.orderNo,
paymentStatus: order.value.paymentStatus,
status: order.value.status,
currency: order.value.currency
})
// 根据订单货币加载翻译
if (order.value.currency) {
await loadTranslationByCurrency(order.value.currency)
}
// 如果订单未支付且需要货币转换,提前计算货币转换信息
if (order.value.paymentStatus === 'UNPAID' && order.value.currency) {
// 检查是否需要货币转换PayPal支持的货币列表
@@ -306,17 +333,14 @@ const loadOrder = async () => {
if (conversionResponse.code === '0000' && conversionResponse.data) {
const conversion = conversionResponse.data
// 更新订单显示信息
order.value = {
...order.value,
originalCurrency: conversion.originalCurrency,
originalAmount: conversion.originalAmount,
paymentCurrency: conversion.paymentCurrency,
paymentAmount: conversion.paymentAmount,
exchangeRate: conversion.exchangeRate,
rateLockedAt: conversion.rateLockedAt
}
console.log('货币转换信息已计算并更新')
// 更新订单显示信息(保留原有字段,只更新货币转换相关字段)
order.value.originalCurrency = conversion.originalCurrency
order.value.originalAmount = conversion.originalAmount
order.value.paymentCurrency = conversion.paymentCurrency
order.value.paymentAmount = conversion.paymentAmount
order.value.exchangeRate = conversion.exchangeRate
order.value.rateLockedAt = conversion.rateLockedAt
console.log('货币转换信息已计算并更新,支付状态:', order.value.paymentStatus)
}
} catch (error) {
console.warn('计算货币转换信息失败:', error)
@@ -325,12 +349,12 @@ const loadOrder = async () => {
}
}
} else {
ElMessage.error(response.message || '获取订单信息失败')
ElMessage.error(response.message || t('order.validationOrderCreateFailed'))
order.value = null
}
} catch (error) {
console.error('获取订单信息失败:', error)
ElMessage.error('获取订单信息失败')
ElMessage.error(t('order.validationOrderCreateFailed'))
order.value = null
} finally {
orderLoading.value = false
@@ -340,12 +364,12 @@ const loadOrder = async () => {
// 处理PayPal支付步骤1-4
const handlePay = async () => {
if (!order.value) {
ElMessage.error('订单信息不存在')
ElMessage.error(t('confirm.orderNotFound'))
return
}
if (order.value.paymentStatus !== 'UNPAID') {
ElMessage.warning('订单已支付或已取消')
ElMessage.warning(t('confirm.statusPaid') + ' ' + t('confirm.statusCancelled'))
return
}
@@ -366,9 +390,10 @@ const handlePay = async () => {
returnUrl: `${window.location.origin}/paypal/success?orderNo=${order.value.orderNo}`,
cancelUrl: `${window.location.origin}/paypal/cancel?orderNo=${order.value.orderNo}`,
shippingName: order.value.shippingName,
shippingAddressLine1: order.value.shippingStreet,
shippingAddressLine1: order.value.shippingAddressLine1 || order.value.shippingStreet,
shippingAddressLine2: order.value.shippingAddressLine2 || null,
shippingCity: order.value.shippingCity,
shippingState: order.value.shippingState || null,
shippingState: order.value.shippingState || (order.value.shippingSpecialFields?.stateMalaysia) || null,
shippingPostalCode: order.value.shippingPostcode || null,
shippingCountryCode: order.value.shippingCountry,
emailAddress: order.value.customerEmail || null
@@ -397,7 +422,7 @@ const handlePay = async () => {
// 显示货币转换提示
if (currencyConversion.conversionRequired) {
ElMessage.info({
message: `您将以${getCurrencyName(currencyConversion.paymentCurrency)}支付,实际费用为${currencyConversion.paymentCurrency} ${formatPrice(currencyConversion.paymentAmount)}`,
message: `${t('confirm.willPayIn', [getCurrencyName(currencyConversion.paymentCurrency)])}${t('confirm.actualCost')}${currencyConversion.paymentCurrency} ${formatPrice(currencyConversion.paymentAmount)}`,
duration: 5000
})
}
@@ -407,18 +432,18 @@ const handlePay = async () => {
const approvalLink = paypalOrder.links?.find(link => link.rel === 'payer-action')
if (approvalLink && approvalLink.href) {
ElMessage.success('正在跳转到PayPal支付页面...')
ElMessage.success(t('order.validationOrderCreateSuccess'))
// 步骤4跳转到PayPal登录页
window.location.href = approvalLink.href
} else {
ElMessage.error('获取PayPal支付链接失败')
ElMessage.error(t('order.validationOrderCreateFailed'))
}
} else {
ElMessage.error(response.message || '创建PayPal订单失败')
ElMessage.error(response.message || t('order.validationOrderCreateFailed'))
}
} catch (error) {
console.error('创建PayPal订单失败:', error)
ElMessage.error(error.response?.data?.message || '创建PayPal订单失败请稍后重试')
ElMessage.error(error.response?.data?.message || t('order.validationOrderCreateRetry'))
} finally {
payLoading.value = false
}
@@ -600,5 +625,111 @@ onMounted(() => {
padding-top: 8px;
border-top: 1px solid #ebeef5;
}
/* 移动端优化 */
@media (max-width: 768px) {
.order-confirm {
padding: 10px;
max-width: 100%;
}
.card-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
font-size: 16px;
}
.order-info-section {
margin-bottom: 20px;
}
.order-info-section h3 {
font-size: 14px;
margin-bottom: 12px;
padding-bottom: 8px;
}
.order-amount {
font-size: 18px;
}
.conversion-title {
font-size: 14px;
margin-bottom: 10px;
}
.title-text {
font-size: 14px;
}
.warning-icon {
font-size: 18px;
}
.conversion-main-info {
padding: 12px;
}
.payment-amount-highlight {
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.payment-amount-large {
font-size: 20px;
}
.exchange-rate-info {
flex-direction: column;
gap: 6px;
margin-top: 8px;
}
.action-buttons {
margin-top: 20px;
padding-top: 15px;
}
.action-buttons .el-button {
width: 100%;
margin: 0;
height: 44px;
font-size: 16px;
}
.action-buttons .el-button + .el-button {
margin-top: 10px;
margin-left: 0;
}
.el-descriptions {
font-size: 13px;
}
.el-descriptions-item__label {
font-size: 13px;
width: 100px;
}
.el-descriptions-item__content {
font-size: 13px;
}
.shipping-address-detail {
font-size: 13px;
line-height: 1.6;
}
.address-line {
margin-bottom: 6px;
}
.address-line strong {
font-size: 12px;
margin-right: 6px;
}
}
</style>

View File

@@ -7,8 +7,8 @@
<!-- 无商品数据提示 -->
<div v-else-if="!product.id" class="empty-container">
<el-empty description="商品不存在或链接已过期">
<p class="empty-tip">该商品链接可能已失效请联系商家获取新的商品链接</p>
<el-empty :description="t('product.productNotExist')">
<p class="empty-tip">{{ t('product.linkExpired') }}</p>
</el-empty>
</div>
@@ -29,7 +29,7 @@
<template #error>
<div class="image-slot">
<el-icon><Picture /></el-icon>
<div>图片加载失败</div>
<div>{{ t('product.imageLoadFailed') }}</div>
</div>
</template>
</el-image>
@@ -53,23 +53,29 @@
<div class="product-info">
<h1 class="product-title">{{ product.name }}</h1>
<!-- 货币选择如果有多个货币 -->
<div class="currency-selector" v-if="Object.keys(skusByCurrency).length > 1">
<span class="selector-label">选择货币</span>
<el-radio-group v-model="currentCurrency" size="small">
<el-radio-button
v-for="(skus, currency) in skusByCurrency"
:key="currency"
:label="currency"
>
{{ getCurrencyName(currency) }}
</el-radio-button>
</el-radio-group>
<!-- 货币和语言选择如果有多个货币- 突出显示 -->
<div class="currency-selector-card" v-if="Object.keys(skusByCurrency).length > 1">
<div class="currency-selector-header">
<el-icon class="currency-icon"><Location /></el-icon>
<span class="selector-label">{{ t('product.selectCurrency') }}</span>
</div>
<div class="currency-selector-content">
<el-radio-group v-model="currentCurrency" size="default" class="currency-radio-group">
<el-radio-button
v-for="(skus, currency) in skusByCurrency"
:key="currency"
:label="currency"
class="currency-radio-button"
>
{{ getCurrencyName(currency) }}
</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- SKU选择区域 -->
<div class="sku-selection-section" v-if="currentCurrencySkus.length > 0">
<div class="sku-section-title">选择商品规格SKU</div>
<div class="sku-section-title">{{ t('product.selectSku') }}</div>
<div class="sku-list">
<div
v-for="(sku, index) in currentCurrencySkus"
@@ -85,7 +91,7 @@
<div class="sku-price-stock">
<span class="sku-price">{{ sku.currency }} {{ formatPrice(sku.price) }}</span>
<span class="sku-stock" :class="{ 'stock-zero': !sku.stock || sku.stock === 0 }">
库存{{ sku.stock || 0 }}
{{ t('product.stock') }}{{ sku.stock || 0 }}
</span>
</div>
</div>
@@ -93,32 +99,32 @@
<el-icon><Check /></el-icon>
</div>
<div class="sku-disabled-mask" v-if="!sku.stock || sku.stock === 0">
缺货
{{ t('product.outOfStock') }}
</div>
</div>
</div>
</div>
<div v-else class="no-sku-tip">
<el-alert type="warning" :closable="false">
暂无可用SKU
{{ t('product.noSkuAvailable') }}
</el-alert>
</div>
<!-- 价格区域显示选中SKU的价格 -->
<div class="price-section" v-if="selectedSku">
<div class="price-label">现价</div>
<div class="price-label">{{ t('product.currentPrice') }}</div>
<div class="price-main">
<span class="currency">{{ selectedSku.currency }}</span>
<span class="price">{{ formatPrice(selectedSku.price) }}</span>
</div>
</div>
<div class="price-section" v-else>
<div class="price-label">请选择SKU查看价格</div>
<div class="price-label">{{ t('product.selectSkuForPrice') }}</div>
</div>
<!-- 数量选择 -->
<div class="quantity-section">
<div class="quantity-label">数量</div>
<div class="quantity-label">{{ t('product.quantity') }}</div>
<el-input-number
v-model="quantity"
:min="1"
@@ -126,22 +132,22 @@
:disabled="!selectedSku || !selectedSku.stock || selectedSku.stock === 0"
class="quantity-input"
/>
<span class="stock-info">库存{{ selectedSku ? (selectedSku.stock || 0) : '请选择SKU' }}</span>
<span class="stock-info">{{ t('product.stock') }}{{ selectedSku ? (selectedSku.stock || 0) : t('product.selectSku') }}</span>
</div>
<!-- 服务保障 -->
<div class="service-section">
<div class="service-item">
<el-icon><Goods /></el-icon>
<span>7天无理由退货</span>
<span>{{ t('product.sevenDayReturn') }}</span>
</div>
<div class="service-item">
<el-icon><Lock /></el-icon>
<span>正品保证</span>
<span>{{ t('product.authenticGuarantee') }}</span>
</div>
<div class="service-item">
<el-icon><Clock /></el-icon>
<span>极速发货</span>
<span>{{ t('product.expeditedShipping') }}</span>
</div>
</div>
@@ -156,7 +162,7 @@
:disabled="!selectedSku || !selectedSku.stock || selectedSku.stock === 0"
>
<el-icon><ShoppingCart /></el-icon>
立即购买
{{ t('product.buyNow') }}
</el-button>
</div>
</div>
@@ -165,28 +171,28 @@
<!-- 商品详情区域 -->
<div class="product-tabs">
<el-tabs v-model="activeTab" class="detail-tabs">
<el-tab-pane label="商品详情" name="detail">
<el-tab-pane :label="t('product.productDetails')" name="detail">
<div class="tab-content">
<div v-if="product.description" v-html="product.description" class="product-description"></div>
<div v-else class="empty-description">
<el-empty description="暂无商品详情" />
<el-empty :description="t('product.noProductDetails')" />
</div>
</div>
</el-tab-pane>
<el-tab-pane label="规格参数" name="params">
<el-tab-pane :label="t('product.specifications')" name="params">
<div class="tab-content">
<el-descriptions :column="2" border v-if="selectedSku">
<el-descriptions-item label="SKU编码">{{ selectedSku.sku }}</el-descriptions-item>
<el-descriptions-item label="价格">{{ selectedSku.currency }} {{ formatPrice(selectedSku.price) }}</el-descriptions-item>
<el-descriptions-item label="库存">{{ selectedSku.stock || 0 }}</el-descriptions-item>
<el-descriptions-item label="重量" v-if="selectedSku.weight">
<el-descriptions-item :label="t('product.skuCode')">{{ selectedSku.sku }}</el-descriptions-item>
<el-descriptions-item :label="t('product.price')">{{ selectedSku.currency }} {{ formatPrice(selectedSku.price) }}</el-descriptions-item>
<el-descriptions-item :label="t('product.stock')">{{ selectedSku.stock || 0 }}</el-descriptions-item>
<el-descriptions-item :label="t('product.weight')" v-if="selectedSku.weight">
{{ selectedSku.weight }}g
</el-descriptions-item>
<el-descriptions-item label="尺寸" v-if="selectedSku.size" :span="2">
<el-descriptions-item :label="t('product.size')" v-if="selectedSku.size" :span="2">
{{ formatSize(selectedSku.size) }}
</el-descriptions-item>
</el-descriptions>
<el-empty v-else description="请先选择SKU查看规格参数" />
<el-empty v-else :description="t('product.selectSkuForSpecs')" />
</div>
</el-tab-pane>
</el-tabs>
@@ -195,9 +201,10 @@
<!-- 购买确认对话框 -->
<el-dialog
v-model="showConfirmDialog"
title="确认购买信息"
width="600px"
:title="t('product.confirmPurchaseInfo')"
:width="isMobile ? '95%' : '600px'"
:close-on-click-modal="false"
class="confirm-dialog"
>
<div class="confirm-dialog-content">
<!-- 商品信息 -->
@@ -210,7 +217,7 @@
<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">SKU</span>
<span class="confirm-sku-label">{{ t('product.sku') }}</span>
<span class="confirm-sku-value">{{ selectedSku.sku }}</span>
</div>
</div>
@@ -220,17 +227,17 @@
<el-divider />
<div class="confirm-price-section">
<div class="confirm-price-item">
<span class="confirm-price-label">单价</span>
<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">数量</span>
<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">总计</span>
<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>
@@ -240,9 +247,9 @@
<template #footer>
<div class="dialog-footer">
<el-button @click="showConfirmDialog = false">取消</el-button>
<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>
@@ -254,10 +261,14 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
// 图标已全局注册在 main.js 中,直接使用组件名称即可
import { getProduct, getProductByLinkCode } from '../api/product'
import { formatAmount } from '../utils/helpers'
import { loadTranslationByCurrency } from '../i18n'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
@@ -272,6 +283,14 @@ const activeTab = ref('detail')
const selectedSku = ref(null)
const showConfirmDialog = ref(false)
// 移动端检测
const isMobile = computed(() => {
if (typeof window !== 'undefined') {
return window.innerWidth <= 768
}
return false
})
// 商品数据
const product = ref({
id: null,
@@ -284,17 +303,17 @@ const product = ref({
skus: []
})
// 货币名称映射
const currencyNames = {
'USD': '美元',
'CNY': '人民币',
'MYR': '马来西亚林吉特',
'PHP': '菲律宾比索',
'THB': '泰铢',
'VND': '越南盾',
'SGD': '新加坡元',
'EUR': '欧元',
'GBP': '英镑'
// 国家名称映射(使用对应国家的本地语言,固定不变)
const countryNamesByCurrency = {
'USD': 'United States', // 美国 - 英语
'CNY': '中国', // 中国 - 中文
'MYR': 'Malaysia', // 马来西亚 - 马来语(英语也是官方语言)
'PHP': 'Pilipinas', // 菲律宾 - 菲律宾语
'THB': 'ประเทศไทย', // 泰国 - 泰语
'VND': 'Việt Nam', // 越南 - 越南语
'SGD': 'Singapore', // 新加坡 - 英语
'EUR': 'Europe', // 欧洲 - 英语
'GBP': 'United Kingdom' // 英国 - 英语
}
// 按货币分组SKU
@@ -344,9 +363,9 @@ const currentImage = computed(() => {
return product.value.mainImage || ''
})
// 获取货币名称
// 获取国家名称(使用对应国家的本地语言,固定不变)
const getCurrencyName = (currency) => {
return currencyNames[currency] || currency
return countryNamesByCurrency[currency] || currency
}
// 选择SKU
@@ -491,14 +510,18 @@ const loadProductDetail = async (id) => {
}
}
// 监听货币变化重置选中的SKU
watch(currentCurrency, () => {
// 监听货币变化重置选中的SKU并加载翻译
watch(currentCurrency, async (newCurrency) => {
// 加载对应货币的翻译
if (newCurrency) {
await loadTranslationByCurrency(newCurrency)
}
selectedSku.value = null
const firstAvailableSku = currentCurrencySkus.value.find(sku => sku.stock && sku.stock > 0)
if (firstAvailableSku) {
selectedSku.value = firstAvailableSku
}
})
}, { immediate: false })
onMounted(() => {
const id = route.params.id
@@ -623,7 +646,122 @@ onMounted(() => {
letter-spacing: 0.5px;
}
/* 货币选择器 */
/* 货币选择器卡片 - 紧凑但醒目,适合移动端 */
.currency-selector-card {
margin-top: 15px;
margin-bottom: 20px;
padding: 12px 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.3);
border: 2px solid rgba(255, 255, 255, 0.3);
position: relative;
overflow: hidden;
}
.currency-selector-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, #ffd700, #ffed4e, #ffd700);
animation: shimmer 2s infinite;
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.currency-selector-header {
display: flex;
align-items: center;
margin-bottom: 10px;
color: white;
}
.currency-icon {
font-size: 18px;
margin-right: 6px;
color: #ffd700;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
}
.currency-selector-header .selector-label {
font-size: 14px;
font-weight: 600;
color: white;
margin: 0;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.currency-selector-content {
background: white;
padding: 10px;
border-radius: 8px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.currency-radio-group {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.currency-radio-button {
flex: 1;
min-width: 0;
}
.currency-radio-button :deep(.el-radio-button__inner) {
font-size: 13px;
font-weight: 500;
padding: 8px 12px;
border-radius: 6px;
transition: all 0.3s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 移动端:紧凑布局,全部展示在一行 */
@media (max-width: 768px) {
.currency-selector-content {
padding: 8px;
}
.currency-radio-group {
display: flex;
flex-wrap: nowrap;
gap: 4px;
width: 100%;
}
.currency-radio-button {
flex: 1;
min-width: 0;
}
.currency-radio-button :deep(.el-radio-button__inner) {
font-size: 11px;
padding: 6px 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.currency-radio-button :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: #667eea;
color: white;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
}
/* 旧样式保留(兼容性) */
.currency-selector {
margin-bottom: 25px;
padding: 15px;
@@ -905,6 +1043,64 @@ onMounted(() => {
line-height: 1.5;
}
/* 移动端:弹窗优化 */
@media (max-width: 768px) {
.confirm-dialog :deep(.el-dialog) {
margin: 5vh auto !important;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.confirm-dialog :deep(.el-dialog__body) {
flex: 1;
overflow-y: auto;
padding: 15px;
}
.confirm-dialog-content {
padding: 0;
}
.confirm-product-info {
flex-direction: column;
gap: 12px;
margin-bottom: 15px;
}
.confirm-product-image {
width: 100%;
height: 200px;
align-self: center;
}
.confirm-product-name {
font-size: 14px;
}
.confirm-price-section {
gap: 10px;
}
.confirm-price-item {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.dialog-footer {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 0;
}
.dialog-footer .el-button {
width: 100%;
margin: 0;
}
}
.confirm-sku-info {
display: flex;
align-items: center;
@@ -1060,6 +1256,51 @@ onMounted(() => {
gap: 15px;
}
.currency-selector-card {
padding: 10px 12px;
margin-top: 10px;
margin-bottom: 15px;
}
.currency-selector-header {
margin-bottom: 8px;
}
.currency-icon {
font-size: 16px;
margin-right: 5px;
}
.currency-selector-header .selector-label {
font-size: 13px;
}
.currency-selector-content {
padding: 8px;
}
.currency-radio-group {
display: flex;
flex-wrap: nowrap;
gap: 4px;
width: 100%;
}
.currency-radio-button {
flex: 1;
min-width: 0;
}
.currency-radio-button :deep(.el-radio-button__inner) {
font-size: 11px;
padding: 6px 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
/* 旧样式兼容 */
.currency-selector {
flex-direction: column;
gap: 10px;