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

@@ -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;