Compare commits
13 Commits
master
...
1c461ab5c3
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c461ab5c3 | |||
| d914301ee3 | |||
| bd6b7b3b79 | |||
| 2dfd0c13a8 | |||
| 45f6a5020d | |||
| 2f3606e967 | |||
| 5d0bdef650 | |||
| f9b103426c | |||
| f440ce2ade | |||
| 0cfe1e6942 | |||
| ed745ee6a5 | |||
| 3e1d77988d | |||
| 57d9c03332 |
130
README.md
130
README.md
@@ -1,6 +1,6 @@
|
||||
# MT Pay Frontend
|
||||
|
||||
PingPong支付系统前端页面(Vue3)
|
||||
电商支付系统前端(Vue 3 + Element Plus)
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -10,6 +10,23 @@ PingPong支付系统前端页面(Vue3)
|
||||
- Axios
|
||||
- Vite
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 安装依赖
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 启动开发服务器
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 构建生产版本
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
@@ -17,99 +34,50 @@ MTKJPAY-FRONT/
|
||||
├── src/
|
||||
│ ├── api/ # API接口
|
||||
│ │ ├── request.js # Axios封装
|
||||
│ │ └── payment.js # 支付相关API
|
||||
│ │ ├── product.js # 商品API
|
||||
│ │ └── order.js # 订单API
|
||||
│ ├── router/ # 路由配置
|
||||
│ │ └── index.js
|
||||
│ ├── views/ # 页面组件
|
||||
│ │ ├── CreateOrder.vue # 创建订单页面
|
||||
│ │ ├── Checkout.vue # 收银台页面
|
||||
│ │ ├── PaymentResult.vue # 支付结果页面
|
||||
│ │ └── OrderQuery.vue # 订单查询页面
|
||||
│ │ ├── ProductDetail.vue # 商品详情页
|
||||
│ │ ├── CreateOrder.vue # 创建订单页(支持东南亚地址)
|
||||
│ │ ├── OrderConfirm.vue # 订单确认页
|
||||
│ │ └── OrderQuery.vue # 订单查询页
|
||||
│ ├── utils/ # 工具函数
|
||||
│ │ ├── helpers.js # 辅助函数
|
||||
│ │ └── countryConfig.js # 国家配置
|
||||
│ ├── App.vue # 根组件
|
||||
│ └── main.js # 入口文件
|
||||
├── index.html # HTML模板
|
||||
├── vite.config.js # Vite配置
|
||||
└── package.json # 项目配置
|
||||
└── index.html # HTML模板
|
||||
```
|
||||
|
||||
## 安装依赖
|
||||
## 核心功能
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
- 商品详情展示(支持多图、SKU选择、货币切换)
|
||||
- 订单创建(动态地址表单,根据国家显示不同字段)
|
||||
- 订单确认(显示货币转换信息)
|
||||
- PayPal支付集成
|
||||
|
||||
## 开发运行
|
||||
## 地址表单
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
支持东南亚国家地址格式:
|
||||
- 新加坡:组屋号、单元号
|
||||
- 马来西亚:州属
|
||||
- 菲律宾:Barangay
|
||||
- 泰国:泰文地址(双语)
|
||||
- 越南:省/市/郡/区/坊
|
||||
|
||||
访问:http://localhost:3000
|
||||
根据SKU货币自动识别国家,动态显示对应字段。
|
||||
|
||||
## 构建生产版本
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 功能说明
|
||||
|
||||
### 1. 创建订单页面 (/)
|
||||
- 填写订单信息(金额、币种、交易类型等)
|
||||
- 填写风控信息(客户信息、商品信息、地址信息等)
|
||||
- 提交后跳转到收银台
|
||||
|
||||
### 2. 收银台页面 (/checkout)
|
||||
- 集成PingPong支付SDK
|
||||
- 用户完成支付操作
|
||||
- 支付完成后跳转到结果页面
|
||||
|
||||
### 3. 支付结果页面 (/result)
|
||||
- 显示支付结果
|
||||
- 显示订单详情
|
||||
- 提供继续支付和查询订单操作
|
||||
|
||||
### 4. 订单查询页面 (/query)
|
||||
- 根据商户订单号查询订单状态
|
||||
- 显示订单详情
|
||||
- 支持继续支付(如果订单未完成)
|
||||
|
||||
## 配置说明
|
||||
|
||||
### API代理配置
|
||||
|
||||
在 `vite.config.js` 中配置了API代理:
|
||||
## API配置
|
||||
|
||||
在 `src/api/request.js` 中配置后端API地址:
|
||||
```javascript
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080', // 后端服务地址
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
const service = axios.create({
|
||||
baseURL: 'http://localhost:8082/api',
|
||||
timeout: 10000
|
||||
})
|
||||
```
|
||||
|
||||
### PingPong SDK模式
|
||||
|
||||
在 `Checkout.vue` 中配置SDK模式:
|
||||
|
||||
```javascript
|
||||
mode: 'sandbox' // 根据环境修改:sandbox/test/build
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **后端服务**:确保后端服务已启动(默认端口8080)
|
||||
2. **CORS配置**:如果跨域,需要后端配置CORS
|
||||
3. **SDK模式**:生产环境需要将 `mode` 改为 `build`
|
||||
4. **重定向URL**:创建订单时的重定向URL会自动设置为当前域名
|
||||
|
||||
## 开发建议
|
||||
|
||||
1. 根据实际需求调整表单字段
|
||||
2. 根据业务需求添加更多验证规则
|
||||
3. 优化用户体验和错误提示
|
||||
4. 添加加载状态和错误处理
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
|
||||
14
index.html
Normal file
14
index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MT Pay - PingPong支付</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
1641
package-lock.json
generated
Normal file
1641
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.2.5",
|
||||
"vue-i18n": "^9.8.0",
|
||||
"axios": "^1.6.0",
|
||||
"element-plus": "^2.4.4",
|
||||
"@element-plus/icons-vue": "^2.3.1"
|
||||
|
||||
83
src/App.vue
Normal file
83
src/App.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<!-- 客户页面:商品详情页,不显示管理导航 -->
|
||||
<div v-if="isCustomerPage" class="customer-layout">
|
||||
<router-view />
|
||||
</div>
|
||||
|
||||
<!-- 管理页面:显示完整导航 -->
|
||||
<el-container v-else>
|
||||
<el-header>
|
||||
<div class="header-content">
|
||||
<h1>MT Pay 管理系统</h1>
|
||||
<el-menu
|
||||
mode="horizontal"
|
||||
:default-active="activeIndex"
|
||||
router
|
||||
>
|
||||
<el-menu-item index="/query">订单查询</el-menu-item>
|
||||
<el-menu-item index="/manage/product">商品管理</el-menu-item>
|
||||
</el-menu>
|
||||
</div>
|
||||
</el-header>
|
||||
<el-main>
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// 判断是否为客户页面(商品详情页)
|
||||
const isCustomerPage = computed(() => {
|
||||
return route.meta?.isCustomerPage === true ||
|
||||
route.path.startsWith('/product/')
|
||||
})
|
||||
|
||||
const activeIndex = computed(() => route.path)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#app {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.el-header {
|
||||
background-color: #409eff;
|
||||
color: white;
|
||||
line-height: 60px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.el-main {
|
||||
padding: 20px;
|
||||
min-height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
/* 客户页面布局:全屏显示,无导航栏 */
|
||||
.customer-layout {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
</style>
|
||||
|
||||
44
src/api/order.js
Normal file
44
src/api/order.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import request from './request'
|
||||
|
||||
/**
|
||||
* 创建客户订单
|
||||
*/
|
||||
export function createCustomerOrder(data) {
|
||||
return request({
|
||||
url: '/order',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据订单号获取订单详情
|
||||
*/
|
||||
export function getOrderByOrderNo(orderNo) {
|
||||
return request({
|
||||
url: `/order/${orderNo}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取订单详情
|
||||
*/
|
||||
export function getOrderById(id) {
|
||||
return request({
|
||||
url: `/order/id/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算并更新订单的货币转换信息
|
||||
*/
|
||||
export function calculateCurrencyConversion(data) {
|
||||
return request({
|
||||
url: '/order/calculate-currency-conversion',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
30
src/api/payment.js
Normal file
30
src/api/payment.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import request from './request'
|
||||
|
||||
/**
|
||||
* 创建支付订单
|
||||
*/
|
||||
export function createPaymentOrder(data) {
|
||||
return request({
|
||||
url: '/payment/checkout',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询订单状态
|
||||
*/
|
||||
export function getOrderStatus(merchantTransactionId) {
|
||||
return request({
|
||||
url: `/payment/order/${merchantTransactionId}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取收银台页面URL
|
||||
*/
|
||||
export function getCheckoutPageUrl(token) {
|
||||
return `/api/payment/checkout/page?token=${token}`
|
||||
}
|
||||
|
||||
40
src/api/paypal.js
Normal file
40
src/api/paypal.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import request from './request'
|
||||
|
||||
/**
|
||||
* 创建PayPal订单
|
||||
*/
|
||||
export function createPayPalOrder(data) {
|
||||
return request({
|
||||
url: '/paypal/orders',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询PayPal订单详情
|
||||
*/
|
||||
export function getPayPalOrder(orderId) {
|
||||
return request({
|
||||
url: `/paypal/orders/${orderId}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 捕获PayPal订单(完成支付)
|
||||
* @param orderId PayPal订单ID
|
||||
* @param erpOrderNo ERP订单号(可选)
|
||||
*/
|
||||
export function capturePayPalOrder(orderId, erpOrderNo) {
|
||||
const params = {}
|
||||
if (erpOrderNo) {
|
||||
params.erpOrderNo = erpOrderNo
|
||||
}
|
||||
return request({
|
||||
url: `/paypal/orders/${orderId}/capture`,
|
||||
method: 'post',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
67
src/api/product.js
Normal file
67
src/api/product.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import request from './request'
|
||||
|
||||
/**
|
||||
* 创建商品
|
||||
*/
|
||||
export function createProduct(data) {
|
||||
return request({
|
||||
url: '/product',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取商品详情(通过商品ID或链接码)
|
||||
*/
|
||||
export function getProduct(id) {
|
||||
return request({
|
||||
url: `/product/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据链接码获取商品详情
|
||||
*/
|
||||
export function getProductByLinkCode(linkCode) {
|
||||
return request({
|
||||
url: `/product/link/${linkCode}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取商品列表
|
||||
*/
|
||||
export function getProductList() {
|
||||
return request({
|
||||
url: '/product/list',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取商品URL
|
||||
*/
|
||||
export function getProductUrl(id) {
|
||||
return request({
|
||||
url: `/product/${id}/url`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传商品图片
|
||||
*/
|
||||
export function uploadProductImage(file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return request({
|
||||
url: '/product/upload/image',
|
||||
method: 'post',
|
||||
data: formData
|
||||
// 注意:不设置 Content-Type,让浏览器自动设置(包含 boundary)
|
||||
})
|
||||
}
|
||||
64
src/api/request.js
Normal file
64
src/api/request.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import config from '../config'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: config.apiBaseUrl,
|
||||
timeout: config.requestTimeout,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
request.interceptors.request.use(
|
||||
config => {
|
||||
// 如果是文件上传,不设置 Content-Type,让浏览器自动设置(包含 boundary)
|
||||
if (config.data instanceof FormData) {
|
||||
delete config.headers['Content-Type']
|
||||
}
|
||||
return config
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
request.interceptors.response.use(
|
||||
response => {
|
||||
const data = response.data
|
||||
|
||||
// 如果响应格式是统一的Result格式
|
||||
if (data && typeof data === 'object' && 'code' in data) {
|
||||
if (data.code === '0000') {
|
||||
// 返回完整对象,让调用方自己处理
|
||||
return data
|
||||
} else {
|
||||
ElMessage.error(data.message || '请求失败')
|
||||
return Promise.reject(new Error(data.message || '请求失败'))
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
},
|
||||
error => {
|
||||
console.error('请求错误:', error)
|
||||
|
||||
let message = '请求失败'
|
||||
if (error.response) {
|
||||
const data = error.response.data
|
||||
message = data?.message || `请求失败: ${error.response.status}`
|
||||
} else if (error.request) {
|
||||
message = '网络错误,请检查网络连接'
|
||||
} else {
|
||||
message = error.message || '请求失败'
|
||||
}
|
||||
|
||||
ElMessage.error(message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
|
||||
38
src/components/Loading.vue
Normal file
38
src/components/Loading.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div v-if="loading" class="loading-overlay">
|
||||
<el-loading
|
||||
:text="text"
|
||||
:spinner="spinner"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: '加载中...'
|
||||
},
|
||||
spinner: {
|
||||
type: String,
|
||||
default: 'el-icon-loading'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
</style>
|
||||
|
||||
39
src/components/PageHeader.vue
Normal file
39
src/components/PageHeader.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<h2>{{ title }}</h2>
|
||||
<p v-if="description" class="description">{{ description }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 8px 0 0 0;
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
|
||||
24
src/config/index.js
Normal file
24
src/config/index.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 配置文件
|
||||
*/
|
||||
|
||||
export default {
|
||||
// API基础URL
|
||||
apiBaseUrl: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
|
||||
// PingPong SDK模式
|
||||
pingpongMode: import.meta.env.VITE_PINGPONG_MODE || 'sandbox',
|
||||
|
||||
// PingPong SDK URL
|
||||
pingpongSdkUrl: 'https://pay-cdn.pingpongx.com/production/static/sdk/1.2.0/ppPay.min.js',
|
||||
|
||||
// 请求超时时间(毫秒)
|
||||
requestTimeout: 30000,
|
||||
|
||||
// 分页配置
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
pageSizes: [10, 20, 50, 100]
|
||||
}
|
||||
}
|
||||
|
||||
137
src/i18n/index.js
Normal file
137
src/i18n/index.js
Normal file
@@ -0,0 +1,137 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { getLanguageByCurrency, getTranslationsByCurrency } from './locales'
|
||||
|
||||
// 默认中文文本(作为fallback)
|
||||
const defaultMessages = {
|
||||
// 商品详情页
|
||||
product: {
|
||||
selectCurrency: '选择货币',
|
||||
selectSku: '选择商品规格(SKU)',
|
||||
currentPrice: '现价',
|
||||
quantity: '数量',
|
||||
stock: '库存',
|
||||
buyNow: '立即购买',
|
||||
addToCart: '加入购物车',
|
||||
productDetails: '商品详情',
|
||||
specifications: '规格参数',
|
||||
outOfStock: '缺货',
|
||||
confirmPurchase: '确认购买',
|
||||
cancel: '取消',
|
||||
unitPrice: '单价',
|
||||
total: '总计',
|
||||
confirmPurchaseInfo: '确认购买信息',
|
||||
sku: 'SKU',
|
||||
productNotExist: '商品不存在或链接已过期',
|
||||
linkExpired: '该商品链接可能已失效,请联系商家获取新的商品链接'
|
||||
},
|
||||
// 订单创建页
|
||||
order: {
|
||||
fillOrderInfo: '填写订单信息',
|
||||
productInfo: '商品信息',
|
||||
customerInfo: '客户信息',
|
||||
shippingAddress: '收货地址',
|
||||
customerName: '客户姓名',
|
||||
customerPhone: '客户电话',
|
||||
customerEmail: '客户邮箱',
|
||||
shippingName: '收货人姓名',
|
||||
shippingPhone: '收货人电话',
|
||||
shippingCountry: '收货国家',
|
||||
addressLine1: '详细地址1',
|
||||
addressLine2: '详细地址2',
|
||||
state: '州/省',
|
||||
city: '城市',
|
||||
postcode: '邮编',
|
||||
remark: '备注',
|
||||
submit: '提交订单',
|
||||
back: '返回',
|
||||
pleaseEnter: '请输入',
|
||||
optional: '可选',
|
||||
required: '必填',
|
||||
addressFormat: '地址格式',
|
||||
phoneCode: '国际区号',
|
||||
mustMatchId: '需与证件一致,支持当地语言+英文'
|
||||
},
|
||||
// 订单确认页
|
||||
confirm: {
|
||||
orderInfo: '订单信息',
|
||||
orderNo: '订单号',
|
||||
orderStatus: '订单状态',
|
||||
paymentStatus: '支付状态',
|
||||
payNow: '立即支付',
|
||||
viewOrder: '查看订单'
|
||||
},
|
||||
// 通用
|
||||
common: {
|
||||
loading: '加载中...',
|
||||
submit: '提交',
|
||||
cancel: '取消',
|
||||
confirm: '确认',
|
||||
save: '保存',
|
||||
delete: '删除',
|
||||
edit: '编辑',
|
||||
search: '搜索',
|
||||
reset: '重置',
|
||||
operation: '操作',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
error: '错误',
|
||||
warning: '警告',
|
||||
info: '提示'
|
||||
}
|
||||
}
|
||||
|
||||
// 创建 i18n 实例
|
||||
const i18n = createI18n({
|
||||
legacy: false, // 使用 Composition API 模式
|
||||
locale: 'zh', // 默认语言
|
||||
fallbackLocale: 'zh', // 回退语言
|
||||
messages: {
|
||||
zh: defaultMessages
|
||||
}
|
||||
})
|
||||
|
||||
// 翻译缓存(避免重复请求)
|
||||
const translationCache = new Map()
|
||||
|
||||
/**
|
||||
* 根据货币代码加载翻译文本(使用静态翻译,不依赖后端API)
|
||||
* @param {string} currency 货币代码
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function loadTranslationByCurrency(currency) {
|
||||
if (!currency) {
|
||||
return
|
||||
}
|
||||
|
||||
// 根据货币代码获取语言代码
|
||||
const language = getLanguageByCurrency(currency)
|
||||
|
||||
// 检查缓存
|
||||
if (translationCache.has(language)) {
|
||||
const cachedMessages = translationCache.get(language)
|
||||
i18n.global.setLocaleMessage(language, cachedMessages)
|
||||
i18n.global.locale.value = language
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 从静态翻译文件获取翻译
|
||||
const translatedMessages = getTranslationsByCurrency(currency)
|
||||
|
||||
// 缓存翻译结果
|
||||
translationCache.set(language, translatedMessages)
|
||||
|
||||
// 设置翻译消息
|
||||
i18n.global.setLocaleMessage(language, translatedMessages)
|
||||
i18n.global.locale.value = language
|
||||
|
||||
console.log(`翻译加载成功,货币: ${currency}, 语言: ${language}`)
|
||||
} catch (error) {
|
||||
console.error('加载翻译失败:', error)
|
||||
// 翻译失败时使用默认中文
|
||||
i18n.global.locale.value = 'zh'
|
||||
}
|
||||
}
|
||||
|
||||
export default i18n
|
||||
|
||||
1080
src/i18n/locales.js
Normal file
1080
src/i18n/locales.js
Normal file
File diff suppressed because it is too large
Load Diff
34
src/main.js
Normal file
34
src/main.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
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)
|
||||
|
||||
// 注册所有图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
app.use(i18n)
|
||||
|
||||
// 添加错误处理
|
||||
app.config.errorHandler = (err, instance, info) => {
|
||||
console.error('Vue错误:', err)
|
||||
console.error('错误信息:', info)
|
||||
console.error('组件实例:', instance)
|
||||
}
|
||||
|
||||
// 挂载应用
|
||||
try {
|
||||
app.mount('#app')
|
||||
console.log('Vue应用已成功挂载')
|
||||
} catch (error) {
|
||||
console.error('应用挂载失败:', error)
|
||||
}
|
||||
|
||||
90
src/router/index.js
Normal file
90
src/router/index.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import CreateOrder from '../views/CreateOrder.vue'
|
||||
import Checkout from '../views/Checkout.vue'
|
||||
import PaymentResult from '../views/PaymentResult.vue'
|
||||
import OrderQuery from '../views/OrderQuery.vue'
|
||||
import ProductDetail from '../views/ProductDetail.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
redirect: '/manage/product'
|
||||
},
|
||||
{
|
||||
path: '/product/:id',
|
||||
name: 'ProductDetail',
|
||||
component: ProductDetail,
|
||||
// 支持商品ID(数字)或链接码(32位字符串)
|
||||
props: true,
|
||||
meta: { isCustomerPage: true } // 标记为客户页面
|
||||
},
|
||||
{
|
||||
path: '/product/link/:linkCode',
|
||||
name: 'ProductDetailByLinkCode',
|
||||
component: ProductDetail,
|
||||
props: true,
|
||||
meta: { isCustomerPage: true } // 标记为客户页面
|
||||
},
|
||||
{
|
||||
path: '/create-order',
|
||||
name: 'CreateOrder',
|
||||
component: CreateOrder
|
||||
},
|
||||
{
|
||||
path: '/order/confirm',
|
||||
name: 'OrderConfirm',
|
||||
component: () => import('../views/OrderConfirm.vue')
|
||||
},
|
||||
{
|
||||
path: '/paypal/success',
|
||||
name: 'PayPalSuccess',
|
||||
component: () => import('../views/PayPalSuccess.vue'),
|
||||
meta: { isCustomerPage: true }
|
||||
},
|
||||
{
|
||||
path: '/paypal/cancel',
|
||||
name: 'PayPalCancel',
|
||||
component: () => import('../views/PayPalCancel.vue'),
|
||||
meta: { isCustomerPage: true }
|
||||
},
|
||||
{
|
||||
path: '/checkout',
|
||||
name: 'Checkout',
|
||||
component: Checkout
|
||||
},
|
||||
{
|
||||
path: '/result',
|
||||
name: 'PaymentResult',
|
||||
component: PaymentResult
|
||||
},
|
||||
{
|
||||
path: '/query',
|
||||
name: 'OrderQuery',
|
||||
component: OrderQuery
|
||||
},
|
||||
{
|
||||
path: '/manage/product',
|
||||
name: 'ProductManage',
|
||||
component: () => import('../views/ProductManage.vue')
|
||||
},
|
||||
{
|
||||
path: '/manage/product/create',
|
||||
name: 'ProductCreate',
|
||||
component: () => import('../views/ProductCreate.vue')
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
// 添加路由守卫,用于调试
|
||||
router.beforeEach((to, from, next) => {
|
||||
console.log('路由导航:', from.path, '->', to.path)
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
42
src/store/index.js
Normal file
42
src/store/index.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
/**
|
||||
* 简单的状态管理
|
||||
* 如果需要更复杂的状态管理,可以使用 Pinia
|
||||
*/
|
||||
|
||||
const state = reactive({
|
||||
// 用户信息
|
||||
user: null,
|
||||
// 当前订单
|
||||
currentOrder: null,
|
||||
// 加载状态
|
||||
loading: false
|
||||
})
|
||||
|
||||
export default {
|
||||
state,
|
||||
|
||||
// 设置用户信息
|
||||
setUser(user) {
|
||||
state.user = user
|
||||
},
|
||||
|
||||
// 设置当前订单
|
||||
setCurrentOrder(order) {
|
||||
state.currentOrder = order
|
||||
},
|
||||
|
||||
// 设置加载状态
|
||||
setLoading(loading) {
|
||||
state.loading = loading
|
||||
},
|
||||
|
||||
// 清除状态
|
||||
clear() {
|
||||
state.user = null
|
||||
state.currentOrder = null
|
||||
state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
75
src/utils/constants.js
Normal file
75
src/utils/constants.js
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 常量定义
|
||||
*/
|
||||
|
||||
// 订单状态
|
||||
export const ORDER_STATUS = {
|
||||
PENDING: 'PENDING',
|
||||
SUCCESS: 'SUCCESS',
|
||||
FAILED: 'FAILED',
|
||||
REVIEW: 'REVIEW',
|
||||
CANCELLED: 'CANCELLED'
|
||||
}
|
||||
|
||||
// 订单状态文本
|
||||
export const ORDER_STATUS_TEXT = {
|
||||
[ORDER_STATUS.PENDING]: '待支付',
|
||||
[ORDER_STATUS.SUCCESS]: '支付成功',
|
||||
[ORDER_STATUS.FAILED]: '支付失败',
|
||||
[ORDER_STATUS.REVIEW]: '审核中',
|
||||
[ORDER_STATUS.CANCELLED]: '已取消'
|
||||
}
|
||||
|
||||
// 支付类型
|
||||
export const PAYMENT_TYPE = {
|
||||
SALE: 'SALE',
|
||||
AUTH: 'AUTH'
|
||||
}
|
||||
|
||||
// 支付类型文本
|
||||
export const PAYMENT_TYPE_TEXT = {
|
||||
[PAYMENT_TYPE.SALE]: '直接付款',
|
||||
[PAYMENT_TYPE.AUTH]: '预授权'
|
||||
}
|
||||
|
||||
// 币种
|
||||
export const CURRENCY = {
|
||||
USD: 'USD',
|
||||
EUR: 'EUR',
|
||||
GBP: 'GBP',
|
||||
CNY: 'CNY',
|
||||
JPY: 'JPY'
|
||||
}
|
||||
|
||||
// 币种文本
|
||||
export const CURRENCY_TEXT = {
|
||||
[CURRENCY.USD]: '美元',
|
||||
[CURRENCY.EUR]: '欧元',
|
||||
[CURRENCY.GBP]: '英镑',
|
||||
[CURRENCY.CNY]: '人民币',
|
||||
[CURRENCY.JPY]: '日元'
|
||||
}
|
||||
|
||||
// 响应码
|
||||
export const RESPONSE_CODE = {
|
||||
SUCCESS: '0000',
|
||||
FAIL: '9999',
|
||||
PARAM_ERROR: '4000',
|
||||
VALIDATION_ERROR: '4001',
|
||||
ORDER_NOT_FOUND: '1001',
|
||||
ORDER_EXISTS: '1002'
|
||||
}
|
||||
|
||||
// API基础路径
|
||||
export const API_BASE_URL = '/api'
|
||||
|
||||
// PingPong SDK URL
|
||||
export const PINGPONG_SDK_URL = 'https://pay-cdn.pingpongx.com/production/static/sdk/1.2.0/ppPay.min.js'
|
||||
|
||||
// PingPong SDK模式
|
||||
export const PINGPONG_MODE = {
|
||||
SANDBOX: 'sandbox',
|
||||
TEST: 'test',
|
||||
BUILD: 'build'
|
||||
}
|
||||
|
||||
166
src/utils/countryConfig.js
Normal file
166
src/utils/countryConfig.js
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 国家地址配置
|
||||
* 定义各国地址字段规则和邮编格式
|
||||
*/
|
||||
|
||||
// 货币代码到国家代码的映射
|
||||
export const currencyToCountry = {
|
||||
'USD': 'US',
|
||||
'SGD': 'SG',
|
||||
'MYR': 'MY',
|
||||
'PHP': 'PH',
|
||||
'THB': 'TH',
|
||||
'VND': 'VN',
|
||||
'CNY': 'CN',
|
||||
'GBP': 'GB',
|
||||
'EUR': 'DE'
|
||||
}
|
||||
|
||||
// 国家配置
|
||||
export const countryConfigs = {
|
||||
SG: {
|
||||
code: 'SG',
|
||||
name: '新加坡',
|
||||
nameEn: 'Singapore',
|
||||
phoneCode: '+65',
|
||||
postcodeLength: 6,
|
||||
postcodePattern: /^\d{6}$/,
|
||||
requiredFields: ['shippingName', 'shippingPhone', 'shippingCountry', 'shippingCity',
|
||||
'shippingAddressLine1', 'shippingBlockNumber', 'shippingUnitNumber', 'shippingPostcode'],
|
||||
specialFields: ['shippingBlockNumber', 'shippingUnitNumber'],
|
||||
fieldLabels: {
|
||||
shippingBlockNumber: '组屋号 (Block Number)',
|
||||
shippingUnitNumber: '单元号 (Unit Number)',
|
||||
shippingAddressLine1: '详细地址1 (Address Line 1)',
|
||||
shippingAddressLine2: '详细地址2 (Address Line 2)',
|
||||
shippingCity: '城市 (City)',
|
||||
shippingPostcode: '邮编 (Postcode)'
|
||||
},
|
||||
addressFormat: 'Blk 123 Jurong West St 41 #12-345, Singapore 640123'
|
||||
},
|
||||
MY: {
|
||||
code: 'MY',
|
||||
name: '马来西亚',
|
||||
nameEn: 'Malaysia',
|
||||
phoneCode: '+60',
|
||||
postcodeLength: 5,
|
||||
postcodePattern: /^\d{5}$/,
|
||||
requiredFields: ['shippingName', 'shippingPhone', 'shippingCountry', 'shippingCity',
|
||||
'shippingStateMalaysia', 'shippingAddressLine1', 'shippingPostcode'],
|
||||
specialFields: ['shippingStateMalaysia'],
|
||||
fieldLabels: {
|
||||
shippingStateMalaysia: '州属 (State)',
|
||||
shippingAddressLine1: '详细地址1 (Address Line 1)',
|
||||
shippingAddressLine2: '详细地址2 (Address Line 2)',
|
||||
shippingCity: '城市 (City)',
|
||||
shippingPostcode: '邮编 (Postcode)'
|
||||
},
|
||||
addressFormat: '123 Jalan Abdullah, 05-01 Menara A, Kuala Lumpur, Selangor 50300'
|
||||
},
|
||||
PH: {
|
||||
code: 'PH',
|
||||
name: '菲律宾',
|
||||
nameEn: 'Philippines',
|
||||
phoneCode: '+63',
|
||||
postcodeLength: 4,
|
||||
postcodePattern: /^\d{4}$/,
|
||||
requiredFields: ['shippingName', 'shippingPhone', 'shippingCountry', 'shippingCity',
|
||||
'shippingState', 'shippingBarangay', 'shippingAddressLine1', 'shippingPostcode'],
|
||||
specialFields: ['shippingBarangay'],
|
||||
fieldLabels: {
|
||||
shippingBarangay: 'Barangay(社区编号)',
|
||||
shippingState: '省 (Province)',
|
||||
shippingCity: '市 (City)',
|
||||
shippingAddressLine1: '详细地址1 (Address Line 1)',
|
||||
shippingAddressLine2: '详细地址2 (Address Line 2)',
|
||||
shippingPostcode: '邮编 (Postcode)'
|
||||
},
|
||||
addressFormat: '123 Main St, Barangay 12, Manila, Metro Manila 1000'
|
||||
},
|
||||
TH: {
|
||||
code: 'TH',
|
||||
name: '泰国',
|
||||
nameEn: 'Thailand',
|
||||
phoneCode: '+66',
|
||||
postcodeLength: 5,
|
||||
postcodePattern: /^\d{5}$/,
|
||||
requiredFields: ['shippingName', 'shippingPhone', 'shippingCountry', 'shippingCity',
|
||||
'shippingState', 'shippingAddressLine1', 'shippingPostcode', 'shippingAddressThai'],
|
||||
specialFields: ['shippingAddressThai', 'shippingAdministrativeArea'],
|
||||
fieldLabels: {
|
||||
shippingAddressThai: '泰文地址 (Thai Address)',
|
||||
shippingAddressLine1: '英文地址 (English Address)',
|
||||
shippingAddressLine2: '详细地址2 (Address Line 2)',
|
||||
shippingState: '府 (Changwat)',
|
||||
shippingCity: '县 (Amphoe)',
|
||||
shippingAdministrativeArea: '区 (Tambon)',
|
||||
shippingPostcode: '邮编 (Postcode)'
|
||||
},
|
||||
addressFormat: '123 Soi Sukhumvit 101, Khlong Toei, Bangkok 10110'
|
||||
},
|
||||
VN: {
|
||||
code: 'VN',
|
||||
name: '越南',
|
||||
nameEn: 'Vietnam',
|
||||
phoneCode: '+84',
|
||||
postcodeLength: 5,
|
||||
postcodePattern: /^\d{5}$/,
|
||||
requiredFields: ['shippingName', 'shippingPhone', 'shippingCountry', 'shippingProvince',
|
||||
'shippingDistrict', 'shippingWard', 'shippingAddressLine1', 'shippingPostcode'],
|
||||
specialFields: ['shippingProvince', 'shippingDistrict', 'shippingWard'],
|
||||
fieldLabels: {
|
||||
shippingProvince: '省 (Tỉnh)',
|
||||
shippingDistrict: '市/郡 (Thành phố/Huyện)',
|
||||
shippingWard: '区/坊 (Quận/Phường)',
|
||||
shippingAddressLine1: '详细地址1 (Địa chỉ chi tiết 1)',
|
||||
shippingAddressLine2: '详细地址2 (Địa chỉ chi tiết 2)',
|
||||
shippingPostcode: '邮编 (Postcode)'
|
||||
},
|
||||
addressFormat: '123 Đường Nguyễn Huệ, Phường Bến Nghé, Quận 1, Thành phố Hồ Chí Minh 70000'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据国家代码获取配置
|
||||
*/
|
||||
export function getCountryConfig(countryCode) {
|
||||
return countryConfigs[countryCode] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据货币代码推断国家
|
||||
*/
|
||||
export function getCountryByCurrency(currency) {
|
||||
return currencyToCountry[currency] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮编格式
|
||||
*/
|
||||
export function validatePostcode(countryCode, postcode) {
|
||||
if (!postcode || !postcode.trim()) {
|
||||
return false
|
||||
}
|
||||
const config = getCountryConfig(countryCode)
|
||||
if (!config) {
|
||||
return true // 未知国家不验证
|
||||
}
|
||||
return config.postcodePattern.test(postcode.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取必填字段
|
||||
*/
|
||||
export function getRequiredFields(countryCode) {
|
||||
const config = getCountryConfig(countryCode)
|
||||
return config ? config.requiredFields : []
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取特殊字段
|
||||
*/
|
||||
export function getSpecialFields(countryCode) {
|
||||
const config = getCountryConfig(countryCode)
|
||||
return config ? config.specialFields : []
|
||||
}
|
||||
|
||||
123
src/utils/helpers.js
Normal file
123
src/utils/helpers.js
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 格式化金额
|
||||
*/
|
||||
export function formatAmount(amount, currency = 'USD') {
|
||||
if (!amount) return '0.00'
|
||||
return parseFloat(amount).toFixed(2)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期时间
|
||||
*/
|
||||
export function formatDateTime(dateTime, format = 'YYYY-MM-DD HH:mm:ss') {
|
||||
if (!dateTime) return ''
|
||||
|
||||
const date = new Date(dateTime)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
|
||||
return format
|
||||
.replace('YYYY', year)
|
||||
.replace('MM', month)
|
||||
.replace('DD', day)
|
||||
.replace('HH', hours)
|
||||
.replace('mm', minutes)
|
||||
.replace('ss', seconds)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成订单号
|
||||
*/
|
||||
export function generateOrderId() {
|
||||
const timestamp = Date.now()
|
||||
const random = Math.floor(Math.random() * 10000)
|
||||
return `MTN${timestamp}${random.toString().padStart(4, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取订单状态标签类型
|
||||
*/
|
||||
export function getStatusTagType(status) {
|
||||
if (!status) return 'info'
|
||||
const statusUpper = status.toUpperCase()
|
||||
if (statusUpper === 'SUCCESS' || statusUpper === 'SUCCESSFUL') {
|
||||
return 'success'
|
||||
} else if (statusUpper === 'FAILED' || statusUpper === 'FAILURE') {
|
||||
return 'danger'
|
||||
} else if (statusUpper === 'REVIEW') {
|
||||
return 'warning'
|
||||
}
|
||||
return 'info'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取订单状态文本
|
||||
*/
|
||||
export function getStatusText(status) {
|
||||
if (!status) return '未知'
|
||||
const statusMap = {
|
||||
'PENDING': '待支付',
|
||||
'SUCCESS': '支付成功',
|
||||
'SUCCESSFUL': '支付成功',
|
||||
'FAILED': '支付失败',
|
||||
'FAILURE': '支付失败',
|
||||
'REVIEW': '审核中',
|
||||
'CANCELLED': '已取消',
|
||||
'CANCEL': '已取消'
|
||||
}
|
||||
return statusMap[status.toUpperCase()] || status
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮箱
|
||||
*/
|
||||
export function validateEmail(email) {
|
||||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return re.test(email)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证手机号(简单验证)
|
||||
*/
|
||||
export function validatePhone(phone) {
|
||||
const re = /^1[3-9]\d{9}$/
|
||||
return re.test(phone)
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖函数
|
||||
*/
|
||||
export function debounce(func, wait) {
|
||||
let timeout
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout)
|
||||
func(...args)
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(later, wait)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 节流函数
|
||||
*/
|
||||
export function throttle(func, limit) {
|
||||
let inThrottle
|
||||
return function(...args) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args)
|
||||
inThrottle = true
|
||||
setTimeout(() => inThrottle = false, limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
32
src/utils/request.js
Normal file
32
src/utils/request.js
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 请求工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 处理API响应
|
||||
*/
|
||||
export function handleResponse(response) {
|
||||
if (response.code === '0000') {
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.message || '请求失败')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理错误
|
||||
*/
|
||||
export function handleError(error) {
|
||||
if (error.response) {
|
||||
// 服务器返回了错误响应
|
||||
const { data } = error.response
|
||||
return data?.message || `请求失败: ${error.response.status}`
|
||||
} else if (error.request) {
|
||||
// 请求已发出但没有收到响应
|
||||
return '网络错误,请检查网络连接'
|
||||
} else {
|
||||
// 其他错误
|
||||
return error.message || '请求失败'
|
||||
}
|
||||
}
|
||||
|
||||
139
src/views/Checkout.vue
Normal file
139
src/views/Checkout.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div class="checkout">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>PingPong支付收银台</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div id="ufo-container" class="checkout-container"></div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import config from '../config'
|
||||
|
||||
const route = useRoute()
|
||||
const token = route.query.token
|
||||
|
||||
onMounted(() => {
|
||||
if (!token) {
|
||||
ElMessage.error('缺少支付token,请重新创建订单')
|
||||
return
|
||||
}
|
||||
|
||||
// 动态加载PingPong SDK
|
||||
loadPingPongSDK()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理资源
|
||||
})
|
||||
|
||||
const loadPingPongSDK = () => {
|
||||
// 检查SDK是否已加载
|
||||
if (window.ppPay) {
|
||||
initPingPongPay()
|
||||
return
|
||||
}
|
||||
|
||||
// 加载SDK
|
||||
const script = document.createElement('script')
|
||||
script.src = config.pingpongSdkUrl
|
||||
script.onload = () => {
|
||||
initPingPongPay()
|
||||
}
|
||||
script.onerror = () => {
|
||||
ElMessage.error('加载支付SDK失败,请刷新页面重试')
|
||||
}
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
|
||||
const initPingPongPay = () => {
|
||||
try {
|
||||
const client = new window.ppPay({
|
||||
lang: 'zh',
|
||||
root: '#ufo-container',
|
||||
manul: false,
|
||||
located: true,
|
||||
showPrice: true,
|
||||
bill: true,
|
||||
mode: config.pingpongMode, // 根据环境配置:sandbox/test/build
|
||||
menu: false,
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
fontSize: '14px',
|
||||
backgroundColor: '#fff',
|
||||
showHeader: true,
|
||||
showHeaderLabel: true,
|
||||
headerLabelFont: '支付',
|
||||
headerColor: '#333333',
|
||||
headerSize: '16px',
|
||||
headerBackgroundColor: '#fff',
|
||||
headerPadding: '20px',
|
||||
btnSize: '100%',
|
||||
btnColor: '#fff',
|
||||
btnFontSize: '14px',
|
||||
btnPaddingX: '20px',
|
||||
btnPaddingY: '10px',
|
||||
btnBackgroundColor: '#1fa0e8',
|
||||
btnBorderRadius: '4px',
|
||||
btnMarginTop: '20px'
|
||||
}
|
||||
})
|
||||
|
||||
const sdkConfig = {
|
||||
token: token
|
||||
}
|
||||
|
||||
client.createPayment(sdkConfig)
|
||||
|
||||
// 调整容器大小
|
||||
adjustContainerSize()
|
||||
window.addEventListener('resize', adjustContainerSize)
|
||||
} catch (error) {
|
||||
console.error('初始化支付失败:', error)
|
||||
ElMessage.error('初始化支付失败,请刷新页面重试')
|
||||
}
|
||||
}
|
||||
|
||||
const adjustContainerSize = () => {
|
||||
const container = document.getElementById('ufo-container')
|
||||
if (!container) return
|
||||
|
||||
const winWidth = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth
|
||||
|
||||
if (winWidth >= 500) {
|
||||
const clientW = Math.floor(winWidth / 3)
|
||||
container.style.width = (clientW >= 500 ? clientW : 500) + 'px'
|
||||
container.style.margin = '0 auto'
|
||||
} else {
|
||||
container.style.width = winWidth + 'px'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.checkout {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.checkout-container {
|
||||
min-height: 600px;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
|
||||
973
src/views/CreateOrder.vue
Normal file
973
src/views/CreateOrder.vue
Normal file
@@ -0,0 +1,973 @@
|
||||
<template>
|
||||
<div class="create-order">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>{{ $t('order.fillOrderInfo') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 商品信息展示 -->
|
||||
<el-card v-if="productInfo" class="product-info-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="product-card-header">
|
||||
<span>{{ $t('order.productInfo') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="product-info-content">
|
||||
<div class="product-info-main">
|
||||
<el-image
|
||||
:src="productInfo.image"
|
||||
fit="cover"
|
||||
class="product-info-image"
|
||||
/>
|
||||
<div class="product-info-details">
|
||||
<div class="product-info-name">{{ productInfo.name }}</div>
|
||||
<div class="product-info-sku" v-if="productInfo.sku">
|
||||
<span class="sku-label">SKU:</span>
|
||||
<span class="sku-value">{{ productInfo.sku }}</span>
|
||||
</div>
|
||||
<div class="product-info-price">
|
||||
<span class="price-label">{{ $t('product.unitPrice') }}:</span>
|
||||
<span class="price-value">{{ productInfo.currency }} {{ formatPrice(productInfo.price) }}</span>
|
||||
<span class="quantity-label">{{ $t('product.quantity') }}:</span>
|
||||
<span class="quantity-value">x{{ productInfo.quantity }}</span>
|
||||
<span class="total-label">{{ $t('order.subtotal') }}:</span>
|
||||
<span class="total-value">{{ productInfo.currency }} {{ formatPrice(productInfo.price * productInfo.quantity) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
:label-width="isMobile ? 'auto' : '120px'"
|
||||
:label-position="isMobile ? 'top' : 'left'"
|
||||
>
|
||||
<el-divider>{{ $t('order.customerInfo') }}</el-divider>
|
||||
|
||||
<el-form-item :label="t('order.customerName')" prop="customerName">
|
||||
<el-input
|
||||
v-model="form.customerName"
|
||||
:placeholder="t('order.pleaseEnter') + t('order.customerName')"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('order.customerPhone')" prop="customerPhone">
|
||||
<el-input
|
||||
v-model="form.customerPhone"
|
||||
:placeholder="t('order.pleaseEnter') + t('order.customerPhone')"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('order.customerEmail')" prop="customerEmail">
|
||||
<el-input
|
||||
v-model="form.customerEmail"
|
||||
:placeholder="t('order.pleaseEnter') + t('order.customerEmail') + '(' + t('order.optional') + ')'"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<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">
|
||||
<el-input
|
||||
v-model="form.shippingName"
|
||||
:placeholder="t('order.pleaseEnter') + t('order.shippingName') + '(' + t('order.mustMatchId') + ')'"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('order.shippingPhone')" prop="shippingPhone">
|
||||
<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') + ')'"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 收货国家(只读显示,根据货币自动确定) -->
|
||||
<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="t('order.addressLine1')" prop="shippingAddressLine1">
|
||||
<el-input
|
||||
v-model="form.shippingAddressLine1"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
:placeholder="t('order.placeholderAddressLine1')"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 详细地址2(楼层、单元号,可选) -->
|
||||
<el-form-item v-if="showField('shippingAddressLine2')" :label="t('order.addressLine2')" prop="shippingAddressLine2">
|
||||
<el-input
|
||||
v-model="form.shippingAddressLine2"
|
||||
:placeholder="t('order.placeholderAddressLine2')"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 新加坡:组屋号和单元号 -->
|
||||
<template v-if="form.shippingCountry === 'SG'">
|
||||
<el-form-item :label="t('order.blockNumber')" prop="shippingBlockNumber">
|
||||
<el-input
|
||||
v-model="form.shippingBlockNumber"
|
||||
:placeholder="t('order.placeholderBlockNumber')"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('order.unitNumber')" prop="shippingUnitNumber">
|
||||
<el-input
|
||||
v-model="form.shippingUnitNumber"
|
||||
:placeholder="t('order.placeholderUnitNumber')"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 菲律宾:Barangay -->
|
||||
<el-form-item v-if="form.shippingCountry === 'PH'" :label="t('order.barangay')" prop="shippingBarangay">
|
||||
<el-input
|
||||
v-model="form.shippingBarangay"
|
||||
:placeholder="t('order.placeholderBarangay')"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 泰国:泰文地址 -->
|
||||
<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="t('order.placeholderAddressThai')"
|
||||
clearable
|
||||
/>
|
||||
</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">
|
||||
<el-input
|
||||
v-model="form.shippingAdministrativeArea"
|
||||
:placeholder="t('order.placeholderAdministrativeArea')"
|
||||
clearable
|
||||
/>
|
||||
</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-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')"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 兼容旧字段:街道地址(如果新字段为空,使用旧字段) -->
|
||||
<el-form-item v-if="!form.shippingAddressLine1" :label="t('order.street')" prop="shippingStreet">
|
||||
<el-input
|
||||
v-model="form.shippingStreet"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
:placeholder="t('order.placeholderStreet')"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('order.remark')" prop="remark">
|
||||
<el-input
|
||||
v-model="form.remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
: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">{{ $t('order.back') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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()
|
||||
const formRef = ref()
|
||||
const loading = ref(false)
|
||||
const productInfo = ref(null)
|
||||
const postcodeMatching = ref(false) // 邮编匹配中
|
||||
|
||||
const form = reactive({
|
||||
customerName: '',
|
||||
customerPhone: '',
|
||||
customerEmail: '',
|
||||
shippingName: '',
|
||||
shippingPhone: '',
|
||||
shippingCountry: '',
|
||||
shippingState: '',
|
||||
shippingCity: '',
|
||||
shippingStreet: '',
|
||||
shippingPostcode: '',
|
||||
// 东南亚地址扩展字段
|
||||
shippingAddressLine1: '',
|
||||
shippingAddressLine2: '',
|
||||
shippingAdministrativeArea: '',
|
||||
shippingBlockNumber: '',
|
||||
shippingUnitNumber: '',
|
||||
shippingBarangay: '',
|
||||
shippingAddressThai: '',
|
||||
shippingProvince: '',
|
||||
shippingDistrict: '',
|
||||
shippingWard: '',
|
||||
shippingStateMalaysia: '',
|
||||
shippingFloorUnit: '',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
// 当前国家配置
|
||||
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) {
|
||||
// 默认显示基础字段
|
||||
return ['shippingName', 'shippingPhone', 'shippingCountry', 'shippingCity',
|
||||
'shippingState', 'shippingStreet', 'shippingPostcode'].includes(fieldName)
|
||||
}
|
||||
|
||||
const specialFields = currentCountryConfig.value.specialFields || []
|
||||
const requiredFields = currentCountryConfig.value.requiredFields || []
|
||||
|
||||
// 如果是特殊字段,只在对应国家显示
|
||||
if (specialFields.includes(fieldName)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果是必填字段,显示
|
||||
if (requiredFields.includes(fieldName)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 基础字段始终显示
|
||||
const baseFields = ['shippingName', 'shippingPhone', 'shippingCountry',
|
||||
'shippingCity', 'shippingState', 'shippingStreet',
|
||||
'shippingPostcode', 'shippingAddressLine1', 'shippingAddressLine2']
|
||||
if (baseFields.includes(fieldName)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 越南特殊字段
|
||||
if (form.shippingCountry === 'VN') {
|
||||
return ['shippingProvince', 'shippingDistrict', 'shippingWard'].includes(fieldName)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 获取字段标签
|
||||
const getFieldLabel = (fieldName) => {
|
||||
if (currentCountryConfig.value && currentCountryConfig.value.fieldLabels) {
|
||||
return currentCountryConfig.value.fieldLabels[fieldName] || fieldName
|
||||
}
|
||||
return fieldName
|
||||
}
|
||||
|
||||
// 获取城市字段标签(根据国家动态显示)
|
||||
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) {
|
||||
// 清空国家特定字段
|
||||
form.shippingStateMalaysia = ''
|
||||
form.shippingBarangay = ''
|
||||
form.shippingBlockNumber = ''
|
||||
form.shippingUnitNumber = ''
|
||||
form.shippingAddressThai = ''
|
||||
form.shippingProvince = ''
|
||||
form.shippingDistrict = ''
|
||||
form.shippingWard = ''
|
||||
form.shippingAdministrativeArea = ''
|
||||
|
||||
// 更新电话区号提示
|
||||
if (currentCountryConfig.value) {
|
||||
form.shippingPhone = currentCountryConfig.value.phoneCode + ' '
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 监听邮编变化,自动匹配城市
|
||||
watch(() => form.shippingPostcode, async (newPostcode) => {
|
||||
if (!newPostcode || !form.shippingCountry) return
|
||||
|
||||
// 验证邮编格式
|
||||
if (!validatePostcode(form.shippingCountry, newPostcode)) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 调用后端API匹配城市/区域
|
||||
// 这里先预留接口,后续实现
|
||||
postcodeMatching.value = true
|
||||
try {
|
||||
// const result = await matchPostcode(form.shippingCountry, newPostcode)
|
||||
// if (result && result.city) {
|
||||
// form.shippingCity = result.city
|
||||
// if (result.state) {
|
||||
// form.shippingState = result.state
|
||||
// }
|
||||
// }
|
||||
} catch (error) {
|
||||
console.error('邮编匹配失败:', error)
|
||||
} finally {
|
||||
postcodeMatching.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// 动态验证规则
|
||||
const getRules = () => {
|
||||
const baseRules = {
|
||||
customerName: [
|
||||
{ required: true, message: t('order.validationRequired', [t('order.customerName')]), trigger: 'blur' }
|
||||
],
|
||||
customerPhone: [
|
||||
{ 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: 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 = [
|
||||
{ required: true, message: t('order.validationRequired', [t('order.addressLine1')]), trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
if (requiredFields.includes('shippingPostcode')) {
|
||||
baseRules.shippingPostcode = [
|
||||
{ required: true, message: t('order.validationRequired', [t('order.postcode')]), trigger: 'blur' },
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (value && !validatePostcode(form.shippingCountry, value)) {
|
||||
callback(new Error(t('order.validationPostcodeFormat', [currentCountryConfig.value.postcodeLength])))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
if (requiredFields.includes('shippingBlockNumber')) {
|
||||
baseRules.shippingBlockNumber = [
|
||||
{ required: true, message: t('order.validationRequired', [t('order.blockNumber')]), trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
if (requiredFields.includes('shippingUnitNumber')) {
|
||||
baseRules.shippingUnitNumber = [
|
||||
{ required: true, message: t('order.validationRequired', [t('order.unitNumber')]), trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
if (requiredFields.includes('shippingBarangay')) {
|
||||
baseRules.shippingBarangay = [
|
||||
{ required: true, message: t('order.validationRequired', [t('order.barangay')]), trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
if (requiredFields.includes('shippingStateMalaysia')) {
|
||||
baseRules.shippingStateMalaysia = [
|
||||
{ required: true, message: t('order.validationRequired', [t('order.stateMalaysia')]), trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
if (requiredFields.includes('shippingProvince')) {
|
||||
baseRules.shippingProvince = [
|
||||
{ required: true, message: t('order.validationRequired', [t('order.province')]), trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
if (requiredFields.includes('shippingDistrict')) {
|
||||
baseRules.shippingDistrict = [
|
||||
{ required: true, message: t('order.validationRequired', [t('order.district')]), trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
if (requiredFields.includes('shippingWard')) {
|
||||
baseRules.shippingWard = [
|
||||
{ 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' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
return baseRules
|
||||
}
|
||||
|
||||
const rules = computed(() => getRules())
|
||||
|
||||
// 返回上一页
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 提交订单
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) {
|
||||
ElMessage.error(t('order.validationFillComplete'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!productInfo.value) {
|
||||
ElMessage.error(t('order.validationProductMissing'))
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
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,
|
||||
quantity: productInfo.value.quantity,
|
||||
customerName: form.customerName,
|
||||
customerPhone: form.customerPhone,
|
||||
customerEmail: form.customerEmail || null,
|
||||
shippingName: form.shippingName,
|
||||
shippingPhone: form.shippingPhone,
|
||||
shippingCountry: form.shippingCountry,
|
||||
shippingState: form.shippingState || null,
|
||||
shippingCity: shippingCity, // 使用映射后的值,越南使用 shippingDistrict
|
||||
shippingStreet: form.shippingStreet || form.shippingAddressLine1, // 兼容旧字段
|
||||
shippingPostcode: form.shippingPostcode || null,
|
||||
// 东南亚地址扩展字段
|
||||
shippingAddressLine1: form.shippingAddressLine1 || null,
|
||||
shippingAddressLine2: form.shippingAddressLine2 || null,
|
||||
shippingAdministrativeArea: form.shippingAdministrativeArea || null,
|
||||
shippingBlockNumber: form.shippingBlockNumber || null,
|
||||
shippingUnitNumber: form.shippingUnitNumber || null,
|
||||
shippingBarangay: form.shippingBarangay || null,
|
||||
shippingAddressThai: form.shippingAddressThai || null,
|
||||
shippingProvince: form.shippingProvince || null,
|
||||
shippingDistrict: form.shippingDistrict || null,
|
||||
shippingWard: form.shippingWard || null,
|
||||
shippingStateMalaysia: form.shippingStateMalaysia || null,
|
||||
shippingFloorUnit: form.shippingFloorUnit || null,
|
||||
remark: form.remark || null
|
||||
}
|
||||
|
||||
const response = await createCustomerOrder(orderData)
|
||||
|
||||
if (response.code === '0000' && response.data) {
|
||||
ElMessage.success(t('order.validationOrderCreateSuccess'))
|
||||
// 跳转到订单确认页面
|
||||
router.push({
|
||||
path: '/order/confirm',
|
||||
query: { orderNo: response.data.orderNo }
|
||||
})
|
||||
} else {
|
||||
ElMessage.error(response.message || t('order.validationOrderCreateFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建订单失败:', error)
|
||||
ElMessage.error(error.response?.data?.message || t('order.validationOrderCreateRetry'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 格式化价格
|
||||
const formatPrice = (price) => {
|
||||
return formatAmount(price)
|
||||
}
|
||||
|
||||
// 从路由参数获取商品信息
|
||||
onMounted(async () => {
|
||||
if (route.query.data) {
|
||||
try {
|
||||
const data = JSON.parse(decodeURIComponent(route.query.data))
|
||||
if (data.product) {
|
||||
productInfo.value = data.product
|
||||
// 根据SKU的货币代码自动设置国家并加载翻译
|
||||
if (data.product.currency) {
|
||||
// 加载对应货币的翻译
|
||||
await loadTranslationByCurrency(data.product.currency)
|
||||
|
||||
const countryCode = getCountryByCurrency(data.product.currency)
|
||||
if (countryCode) {
|
||||
form.shippingCountry = countryCode
|
||||
const config = getCountryConfig(countryCode)
|
||||
if (config) {
|
||||
form.shippingPhone = config.phoneCode + ' '
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析商品信息失败:', error)
|
||||
ElMessage.error(t('order.validationProductParseFailed'))
|
||||
router.push('/')
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(t('order.validationProductInfoMissing'))
|
||||
router.push('/')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.create-order {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 商品信息卡片 */
|
||||
.product-info-card {
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.product-card-header {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.product-info-content {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.product-info-main {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.product-info-image {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e4e7ed;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.product-info-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.product-info-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.product-info-sku {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #409eff;
|
||||
}
|
||||
|
||||
.sku-label {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sku-value {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.product-info-price {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 12px;
|
||||
background: #f9fafb;
|
||||
border-radius: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.price-label,
|
||||
.quantity-label,
|
||||
.total-label {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.total-label {
|
||||
margin-left: auto;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.total-value {
|
||||
font-size: 18px;
|
||||
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>
|
||||
59
src/views/Home.vue
Normal file
59
src/views/Home.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h2>欢迎使用 MT Pay 支付系统</h2>
|
||||
</div>
|
||||
</template>
|
||||
<div class="welcome-content">
|
||||
<p>正在跳转到商品管理页面...</p>
|
||||
<el-button type="primary" @click="goToProductManage">
|
||||
立即前往商品管理
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goToProductManage = () => {
|
||||
router.push('/manage/product')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 自动跳转到商品管理页面
|
||||
setTimeout(() => {
|
||||
router.push('/manage/product')
|
||||
}, 1000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.welcome-content p {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
735
src/views/OrderConfirm.vue
Normal file
735
src/views/OrderConfirm.vue
Normal file
@@ -0,0 +1,735 @@
|
||||
<template>
|
||||
<div class="order-confirm">
|
||||
<el-card v-if="orderLoading">
|
||||
<el-skeleton :rows="10" animated />
|
||||
</el-card>
|
||||
|
||||
<template v-else-if="order">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>{{ $t('confirm.orderInfo') }}</span>
|
||||
<el-tag :type="getStatusType(order.status)" size="large">
|
||||
{{ getStatusText(order.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 订单信息 -->
|
||||
<div class="order-info-section">
|
||||
<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>
|
||||
</div>
|
||||
<!-- 货币转换信息 - 更醒目的提示 -->
|
||||
<div v-if="order.paymentCurrency && order.paymentCurrency !== order.currency" class="currency-conversion-info">
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:effect="'dark'"
|
||||
class="conversion-alert-box"
|
||||
>
|
||||
<template #title>
|
||||
<div class="conversion-alert-content">
|
||||
<div class="conversion-title">
|
||||
<el-icon class="warning-icon"><Warning /></el-icon>
|
||||
<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">{{ $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">({{ $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>{{ $t('confirm.rateLockedAt') }}:{{ formatDateTime(order.rateLockedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
</div>
|
||||
</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>{{ $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>{{ $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>{{ $t('confirm.addressLine1') }}:</strong>{{ order.shippingAddressLine1 }}
|
||||
</div>
|
||||
<div v-if="order.shippingAddressLine2" class="address-line">
|
||||
<strong>{{ $t('confirm.addressLine2') }}:</strong>{{ order.shippingAddressLine2 }}
|
||||
</div>
|
||||
<!-- 从JSON字段中读取特殊字段 -->
|
||||
<template v-if="order.shippingSpecialFields">
|
||||
<div v-if="order.shippingSpecialFields.blockNumber" class="address-line">
|
||||
<strong>{{ $t('order.blockNumber') }}:</strong>{{ order.shippingSpecialFields.blockNumber }}
|
||||
</div>
|
||||
<div v-if="order.shippingSpecialFields.unitNumber" class="address-line">
|
||||
<strong>{{ $t('order.unitNumber') }}:</strong>{{ order.shippingSpecialFields.unitNumber }}
|
||||
</div>
|
||||
<div v-if="order.shippingSpecialFields.barangay" class="address-line">
|
||||
<strong>{{ $t('order.barangay') }}:</strong>{{ order.shippingSpecialFields.barangay }}
|
||||
</div>
|
||||
<div v-if="order.shippingSpecialFields.addressThai" class="address-line">
|
||||
<strong>{{ $t('order.addressThai') }}:</strong>{{ order.shippingSpecialFields.addressThai }}
|
||||
</div>
|
||||
<div v-if="order.shippingSpecialFields.province" class="address-line">
|
||||
<strong>{{ $t('order.province') }}:</strong>{{ order.shippingSpecialFields.province }}
|
||||
</div>
|
||||
<div v-if="order.shippingSpecialFields.district" class="address-line">
|
||||
<strong>{{ $t('order.district') }}:</strong>{{ order.shippingSpecialFields.district }}
|
||||
</div>
|
||||
<div v-if="order.shippingSpecialFields.ward" class="address-line">
|
||||
<strong>{{ $t('order.ward') }}:</strong>{{ order.shippingSpecialFields.ward }}
|
||||
</div>
|
||||
<div v-if="order.shippingSpecialFields.stateMalaysia" class="address-line">
|
||||
<strong>{{ $t('order.stateMalaysia') }}:</strong>{{ order.shippingSpecialFields.stateMalaysia }}
|
||||
</div>
|
||||
<div v-if="order.shippingSpecialFields.administrativeArea" class="address-line">
|
||||
<strong>{{ $t('order.administrativeArea') }}:</strong>{{ order.shippingSpecialFields.administrativeArea }}
|
||||
</div>
|
||||
<div v-if="order.shippingSpecialFields.floorUnit" class="address-line">
|
||||
<strong>{{ $t('order.floorUnit') }}:</strong>{{ order.shippingSpecialFields.floorUnit }}
|
||||
</div>
|
||||
</template>
|
||||
<div class="address-line">
|
||||
{{ formatShippingAddress(order) }}
|
||||
</div>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 订单备注 -->
|
||||
<div class="order-info-section" v-if="order.remark">
|
||||
<h3>{{ $t('confirm.orderRemark') }}</h3>
|
||||
<p class="order-remark">{{ order.remark }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-buttons">
|
||||
<el-button
|
||||
v-if="order.paymentStatus === 'UNPAID'"
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="handlePay"
|
||||
:loading="payLoading"
|
||||
:style="isMobile ? 'width: 100%' : 'width: 200px'"
|
||||
>
|
||||
<el-icon><Money /></el-icon>
|
||||
{{ $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>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<el-card v-else>
|
||||
<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, 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()
|
||||
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 = {
|
||||
'PENDING': 'warning',
|
||||
'PAID': 'success',
|
||||
'SHIPPED': 'info',
|
||||
'COMPLETED': 'success',
|
||||
'CANCELLED': 'info'
|
||||
}
|
||||
return statusMap[status] || 'info'
|
||||
}
|
||||
|
||||
// 获取订单状态文本
|
||||
const getStatusText = (status) => {
|
||||
const statusMap = {
|
||||
'PENDING': t('confirm.statusPending'),
|
||||
'PAID': t('confirm.statusPaid'),
|
||||
'SHIPPED': t('confirm.statusShipped'),
|
||||
'COMPLETED': t('confirm.statusCompleted'),
|
||||
'CANCELLED': t('confirm.statusCancelled')
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
// 格式化价格
|
||||
const formatPrice = (price) => {
|
||||
return formatAmount(price)
|
||||
}
|
||||
|
||||
// 获取货币名称
|
||||
const getCurrencyName = (currencyCode) => {
|
||||
const currencyNames = {
|
||||
'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
|
||||
}
|
||||
|
||||
// 格式化汇率
|
||||
const formatRate = (rate) => {
|
||||
if (!rate) return ''
|
||||
return rate.toFixed(6)
|
||||
}
|
||||
|
||||
// 格式化日期时间
|
||||
const formatDateTime = (dateTime) => {
|
||||
if (!dateTime) return '-'
|
||||
const date = new Date(dateTime)
|
||||
return date.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 格式化收货地址(从小到大的地址格式:详细街道→城市→州/省→国家)
|
||||
const formatShippingAddress = (order) => {
|
||||
const parts = []
|
||||
// 详细地址1或街道地址
|
||||
if (order.shippingAddressLine1) {
|
||||
parts.push(order.shippingAddressLine1)
|
||||
} else if (order.shippingStreet) {
|
||||
parts.push(order.shippingStreet)
|
||||
}
|
||||
|
||||
if (order.shippingCity) parts.push(order.shippingCity)
|
||||
|
||||
// 根据国家显示不同的州/省字段(从JSON字段中读取)
|
||||
if (order.shippingSpecialFields && order.shippingSpecialFields.stateMalaysia) {
|
||||
parts.push(order.shippingSpecialFields.stateMalaysia)
|
||||
} else if (order.shippingState) {
|
||||
parts.push(order.shippingState)
|
||||
}
|
||||
|
||||
if (order.shippingCountry) parts.push(order.shippingCountry)
|
||||
if (order.shippingPostcode) parts.push(`${t('confirm.postcodeLabel')}:${order.shippingPostcode}`)
|
||||
|
||||
return parts.length > 0 ? parts.join(',') : t('confirm.addressIncomplete')
|
||||
}
|
||||
|
||||
// 返回上一页
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 加载订单详情
|
||||
const loadOrder = async () => {
|
||||
// 先加载订单,获取货币信息后再加载翻译
|
||||
const orderNo = route.query.orderNo
|
||||
if (!orderNo) {
|
||||
ElMessage.error(t('order.validationRequired', [t('confirm.orderNo')]))
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
|
||||
orderLoading.value = true
|
||||
try {
|
||||
const response = await getOrderByOrderNo(orderNo)
|
||||
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支持的货币列表)
|
||||
const supportedCurrencies = ['USD', 'EUR', 'GBP', 'AUD', 'CAD', 'JPY', 'CNY', 'HKD', 'SGD', 'NZD',
|
||||
'CHF', 'SEK', 'NOK', 'DKK', 'PLN', 'MXN', 'BRL', 'INR', 'KRW', 'THB']
|
||||
|
||||
const needsConversion = !supportedCurrencies.includes(order.value.currency)
|
||||
|
||||
// 如果订单还没有货币转换信息,或者需要更新,则计算
|
||||
if (needsConversion && (!order.value.paymentCurrency || order.value.paymentCurrency === order.value.currency)) {
|
||||
try {
|
||||
const conversionResponse = await calculateCurrencyConversion({
|
||||
orderNo: order.value.orderNo,
|
||||
originalCurrency: order.value.currency,
|
||||
originalAmount: order.value.totalAmount
|
||||
})
|
||||
|
||||
if (conversionResponse.code === '0000' && conversionResponse.data) {
|
||||
const conversion = conversionResponse.data
|
||||
// 更新订单显示信息(保留原有字段,只更新货币转换相关字段)
|
||||
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)
|
||||
// 不显示错误,因为不影响订单显示
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(response.message || t('order.validationOrderCreateFailed'))
|
||||
order.value = null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取订单信息失败:', error)
|
||||
ElMessage.error(t('order.validationOrderCreateFailed'))
|
||||
order.value = null
|
||||
} finally {
|
||||
orderLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理PayPal支付(步骤1-4)
|
||||
const handlePay = async () => {
|
||||
if (!order.value) {
|
||||
ElMessage.error(t('confirm.orderNotFound'))
|
||||
return
|
||||
}
|
||||
|
||||
if (order.value.paymentStatus !== 'UNPAID') {
|
||||
ElMessage.warning(t('confirm.statusPaid') + ' ' + t('confirm.statusCancelled'))
|
||||
return
|
||||
}
|
||||
|
||||
payLoading.value = true
|
||||
|
||||
try {
|
||||
// 步骤2:构建PayPal订单创建请求
|
||||
const paypalOrderData = {
|
||||
intent: 'CAPTURE', // 立即捕获
|
||||
referenceId: order.value.orderNo, // ERP订单号
|
||||
amount: order.value.totalAmount,
|
||||
currencyCode: order.value.currency,
|
||||
itemName: order.value.productName,
|
||||
itemDescription: order.value.skuName,
|
||||
itemSku: order.value.skuName,
|
||||
itemQuantity: order.value.quantity,
|
||||
itemUnitAmount: order.value.unitPrice,
|
||||
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.shippingAddressLine1 || order.value.shippingStreet,
|
||||
shippingAddressLine2: order.value.shippingAddressLine2 || null,
|
||||
shippingCity: order.value.shippingCity,
|
||||
shippingState: order.value.shippingState || (order.value.shippingSpecialFields?.stateMalaysia) || null,
|
||||
shippingPostalCode: order.value.shippingPostcode || null,
|
||||
shippingCountryCode: order.value.shippingCountry,
|
||||
emailAddress: order.value.customerEmail || null
|
||||
}
|
||||
|
||||
// 调用后端创建PayPal订单
|
||||
const response = await createPayPalOrder(paypalOrderData)
|
||||
|
||||
if (response.code === '0000' && response.data) {
|
||||
const responseData = response.data
|
||||
const paypalOrder = responseData.paypalOrder || responseData // 兼容新旧格式
|
||||
const currencyConversion = responseData.currencyConversion
|
||||
|
||||
// 如果有货币转换信息,更新订单显示
|
||||
if (currencyConversion) {
|
||||
order.value = {
|
||||
...order.value,
|
||||
originalCurrency: currencyConversion.originalCurrency,
|
||||
originalAmount: currencyConversion.originalAmount,
|
||||
paymentCurrency: currencyConversion.paymentCurrency,
|
||||
paymentAmount: currencyConversion.paymentAmount,
|
||||
exchangeRate: currencyConversion.exchangeRate,
|
||||
rateLockedAt: currencyConversion.rateLockedAt
|
||||
}
|
||||
|
||||
// 显示货币转换提示
|
||||
if (currencyConversion.conversionRequired) {
|
||||
ElMessage.info({
|
||||
message: `${t('confirm.willPayIn', [getCurrencyName(currencyConversion.paymentCurrency)])},${t('confirm.actualCost')}:${currencyConversion.paymentCurrency} ${formatPrice(currencyConversion.paymentAmount)}`,
|
||||
duration: 5000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 步骤3-4:获取approval_url并跳转到PayPal登录页
|
||||
const approvalLink = paypalOrder.links?.find(link => link.rel === 'payer-action')
|
||||
|
||||
if (approvalLink && approvalLink.href) {
|
||||
ElMessage.success(t('order.validationOrderCreateSuccess'))
|
||||
// 步骤4:跳转到PayPal登录页
|
||||
window.location.href = approvalLink.href
|
||||
} else {
|
||||
ElMessage.error(t('order.validationOrderCreateFailed'))
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(response.message || t('order.validationOrderCreateFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建PayPal订单失败:', error)
|
||||
ElMessage.error(error.response?.data?.message || t('order.validationOrderCreateRetry'))
|
||||
} finally {
|
||||
payLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadOrder()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.order-confirm {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.order-info-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.order-info-section h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #409eff;
|
||||
}
|
||||
|
||||
.order-amount {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.order-remark {
|
||||
padding: 15px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
color: #606266;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
margin-top: 30px;
|
||||
text-align: center;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.order-amount-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.currency-conversion-info {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.conversion-alert-box {
|
||||
border: 2px solid #e6a23c;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #fff7e6 0%, #fff3d9 100%);
|
||||
box-shadow: 0 4px 12px rgba(230, 162, 60, 0.2);
|
||||
}
|
||||
|
||||
.conversion-alert-content {
|
||||
line-height: 1.8;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.conversion-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
font-size: 24px;
|
||||
color: #e6a23c;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.title-text {
|
||||
color: #e6a23c;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.conversion-main-info {
|
||||
background: #fff;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
margin: 10px 0;
|
||||
border-left: 4px solid #e6a23c;
|
||||
}
|
||||
|
||||
.payment-amount-highlight {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.payment-amount-highlight .label {
|
||||
font-size: 16px;
|
||||
color: #606266;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.payment-amount-large {
|
||||
font-weight: 800;
|
||||
color: #e6a23c;
|
||||
font-size: 28px;
|
||||
text-shadow: 0 2px 4px rgba(230, 162, 60, 0.3);
|
||||
}
|
||||
|
||||
.exchange-rate-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed #dcdfe6;
|
||||
}
|
||||
|
||||
.equivalent-amount {
|
||||
color: #606266;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rate-value {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rate-locked-info {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.shipping-address-detail {
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.address-line {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.address-line strong {
|
||||
color: #606266;
|
||||
margin-right: 8px;
|
||||
color: #909399;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
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>
|
||||
|
||||
152
src/views/OrderQuery.vue
Normal file
152
src/views/OrderQuery.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="order-query">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>订单查询</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form :inline="true" :model="queryForm" class="query-form">
|
||||
<el-form-item label="商户订单号">
|
||||
<el-input
|
||||
v-model="queryForm.merchantTransactionId"
|
||||
placeholder="请输入商户订单号"
|
||||
clearable
|
||||
style="width: 300px"
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleQuery" :loading="loading">
|
||||
查询
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div v-if="orderData" class="order-detail">
|
||||
<el-descriptions title="订单详情" :column="2" border>
|
||||
<el-descriptions-item label="商户订单号">
|
||||
{{ orderData.merchantTransactionId }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="PingPong交易流水号">
|
||||
{{ orderData.transactionId || '暂无' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="订单状态">
|
||||
<el-tag :type="getStatusTagType(orderData.status)">
|
||||
{{ getStatusText(orderData.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="交易金额">
|
||||
{{ orderData.amount }} {{ orderData.currency }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="交易类型">
|
||||
{{ orderData.paymentType === 'SALE' ? '直接付款' : '预授权' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ orderData.createTime }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="action-buttons" style="margin-top: 20px">
|
||||
<el-button type="primary" @click="goToPay" v-if="orderData.status === 'PENDING'">
|
||||
继续支付
|
||||
</el-button>
|
||||
<el-button @click="goToCreate">创建新订单</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-else-if="!loading" description="请输入订单号进行查询" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getOrderStatus } from '../api/payment'
|
||||
import { getStatusText, getStatusTagType } from '../utils/helpers'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const orderData = ref(null)
|
||||
|
||||
const queryForm = reactive({
|
||||
merchantTransactionId: ''
|
||||
})
|
||||
|
||||
const handleQuery = async () => {
|
||||
if (!queryForm.merchantTransactionId) {
|
||||
ElMessage.warning('请输入商户订单号')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
orderData.value = null
|
||||
|
||||
try {
|
||||
const response = await getOrderStatus(queryForm.merchantTransactionId)
|
||||
if (response.code === '0000' && response.data) {
|
||||
orderData.value = response.data
|
||||
ElMessage.success('查询成功')
|
||||
} else {
|
||||
ElMessage.error(response.message || '订单不存在')
|
||||
orderData.value = null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('查询订单失败:', error)
|
||||
ElMessage.error('查询失败,请稍后重试')
|
||||
orderData.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const goToPay = () => {
|
||||
if (orderData.value && orderData.value.token) {
|
||||
router.push({
|
||||
path: '/checkout',
|
||||
query: { token: orderData.value.token }
|
||||
})
|
||||
} else {
|
||||
ElMessage.warning('该订单无法继续支付,请创建新订单')
|
||||
}
|
||||
}
|
||||
|
||||
const goToCreate = () => {
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.order-query {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.query-form {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.order-detail {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action-buttons .el-button {
|
||||
margin: 0 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
106
src/views/PayPalCancel.vue
Normal file
106
src/views/PayPalCancel.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="paypal-cancel">
|
||||
<el-card>
|
||||
<el-result
|
||||
icon="warning"
|
||||
title="支付已取消"
|
||||
sub-title="您已取消PayPal支付,订单已保存,您可以稍后继续支付"
|
||||
>
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="continuePay">继续支付</el-button>
|
||||
<el-button @click="goHome">返回首页</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
|
||||
<div class="order-summary" v-if="order">
|
||||
<h3>订单信息</h3>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="订单号">{{ order.orderNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="订单金额">
|
||||
<span class="order-amount">{{ order.currency }} {{ formatPrice(order.totalAmount) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="商品名称" :span="2">{{ order.productName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="订单状态" :span="2">
|
||||
<el-tag type="warning">待支付</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getOrderByOrderNo } from '../api/order'
|
||||
import { formatAmount } from '../utils/helpers'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const order = ref(null)
|
||||
|
||||
// 格式化价格
|
||||
const formatPrice = (price) => {
|
||||
return formatAmount(price)
|
||||
}
|
||||
|
||||
// 继续支付
|
||||
const continuePay = () => {
|
||||
if (order.value) {
|
||||
router.push({
|
||||
path: '/order/confirm',
|
||||
query: { orderNo: order.value.orderNo }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 返回首页
|
||||
const goHome = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
// 加载订单信息
|
||||
onMounted(async () => {
|
||||
const orderNo = route.query.orderNo
|
||||
if (orderNo) {
|
||||
try {
|
||||
const response = await getOrderByOrderNo(orderNo)
|
||||
if (response.code === '0000' && response.data) {
|
||||
order.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取订单信息失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.paypal-cancel {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.order-summary {
|
||||
margin-top: 30px;
|
||||
padding: 20px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.order-summary h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.order-amount {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #f56c6c;
|
||||
}
|
||||
</style>
|
||||
|
||||
236
src/views/PayPalSuccess.vue
Normal file
236
src/views/PayPalSuccess.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<div class="paypal-success">
|
||||
<el-card v-if="processing">
|
||||
<div class="processing-container">
|
||||
<el-icon class="is-loading" style="font-size: 48px; color: #409eff">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
<p>正在处理支付结果...</p>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-else-if="order">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>支付结果</span>
|
||||
<el-tag :type="paymentStatus === 'PAID' ? 'success' : 'warning'" size="large">
|
||||
{{ paymentStatus === 'PAID' ? '支付成功' : '支付处理中' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="result-content">
|
||||
<el-result
|
||||
:icon="paymentStatus === 'PAID' ? 'success' : 'warning'"
|
||||
:title="paymentStatus === 'PAID' ? '支付成功' : '支付处理中'"
|
||||
:sub-title="paymentStatus === 'PAID' ? '您的订单已支付成功,我们将尽快为您发货' : '正在确认支付结果,请稍候...'"
|
||||
>
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="viewOrder">查看订单</el-button>
|
||||
<el-button @click="goHome">返回首页</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
|
||||
<div class="order-summary" v-if="order">
|
||||
<h3>订单信息</h3>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="订单号">{{ order.orderNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="订单金额">
|
||||
<span class="order-amount">{{ order.currency }} {{ formatPrice(order.totalAmount) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="商品名称" :span="2">{{ order.productName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="支付状态" :span="2">
|
||||
<el-tag :type="order.paymentStatus === 'PAID' ? 'success' : 'warning'">
|
||||
{{ order.paymentStatus === 'PAID' ? '已支付' : '未支付' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-else>
|
||||
<el-result
|
||||
icon="error"
|
||||
title="支付失败"
|
||||
sub-title="无法获取订单信息,请稍后重试或联系客服"
|
||||
>
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="goHome">返回首页</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import { getOrderByOrderNo } from '../api/order'
|
||||
import { getPayPalOrder, capturePayPalOrder } from '../api/paypal'
|
||||
import { formatAmount } from '../utils/helpers'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const processing = ref(true)
|
||||
const order = ref(null)
|
||||
const paymentStatus = ref('UNPAID')
|
||||
const paypalOrderId = ref(null)
|
||||
|
||||
// 格式化价格
|
||||
const formatPrice = (price) => {
|
||||
return formatAmount(price)
|
||||
}
|
||||
|
||||
// 查看订单
|
||||
const viewOrder = () => {
|
||||
if (order.value) {
|
||||
router.push({
|
||||
path: '/order/confirm',
|
||||
query: { orderNo: order.value.orderNo }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 返回首页
|
||||
const goHome = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
// 处理PayPal支付回调(步骤7-11)
|
||||
const handlePayPalCallback = async () => {
|
||||
const orderNo = route.query.orderNo
|
||||
const token = route.query.token // PayPal返回的token(orderId)
|
||||
|
||||
if (!orderNo) {
|
||||
ElMessage.error('订单号不能为空')
|
||||
processing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 加载ERP订单信息
|
||||
const orderResponse = await getOrderByOrderNo(orderNo)
|
||||
if (orderResponse.code === '0000' && orderResponse.data) {
|
||||
order.value = orderResponse.data
|
||||
} else {
|
||||
ElMessage.error('获取订单信息失败')
|
||||
processing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 如果有token,说明是从PayPal跳转回来的
|
||||
if (token) {
|
||||
paypalOrderId.value = token
|
||||
|
||||
// 步骤8:查询PayPal订单状态
|
||||
const paypalOrderResponse = await getPayPalOrder(token)
|
||||
|
||||
if (paypalOrderResponse.code === '0000' && paypalOrderResponse.data) {
|
||||
const paypalOrder = paypalOrderResponse.data
|
||||
|
||||
// 步骤9-10:检查订单状态
|
||||
if (paypalOrder.status === 'APPROVED') {
|
||||
// 步骤11:捕获订单(完成支付)
|
||||
// 传递ERP订单号,后端会自动更新订单状态
|
||||
const captureResponse = await capturePayPalOrder(token, orderNo)
|
||||
|
||||
if (captureResponse.code === '0000' && captureResponse.data) {
|
||||
const capturedOrder = captureResponse.data
|
||||
|
||||
// 检查捕获状态
|
||||
if (capturedOrder.status === 'COMPLETED') {
|
||||
paymentStatus.value = 'PAID'
|
||||
ElMessage.success('支付成功!订单状态已更新')
|
||||
|
||||
// 重新加载订单信息以获取最新状态
|
||||
const updatedOrderResponse = await getOrderByOrderNo(orderNo)
|
||||
if (updatedOrderResponse.code === '0000' && updatedOrderResponse.data) {
|
||||
order.value = updatedOrderResponse.data
|
||||
}
|
||||
} else {
|
||||
ElMessage.warning('支付处理中,请稍候...')
|
||||
}
|
||||
} else {
|
||||
ElMessage.error('捕获订单失败,请稍后重试')
|
||||
}
|
||||
} else if (paypalOrder.status === 'COMPLETED') {
|
||||
// 订单已完成
|
||||
paymentStatus.value = 'PAID'
|
||||
ElMessage.success('支付成功!')
|
||||
} else {
|
||||
ElMessage.warning('支付状态:' + paypalOrder.status)
|
||||
}
|
||||
} else {
|
||||
ElMessage.error('查询PayPal订单失败')
|
||||
}
|
||||
} else {
|
||||
// 没有token,可能是直接访问页面
|
||||
ElMessage.warning('缺少支付信息')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('处理PayPal回调失败:', error)
|
||||
ElMessage.error('处理支付结果失败,请稍后重试')
|
||||
} finally {
|
||||
processing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handlePayPalCallback()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.paypal-success {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.processing-container {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.processing-container p {
|
||||
margin-top: 20px;
|
||||
font-size: 16px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.result-content {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.order-summary {
|
||||
margin-top: 30px;
|
||||
padding: 20px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.order-summary h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.order-amount {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #f56c6c;
|
||||
}
|
||||
</style>
|
||||
|
||||
171
src/views/PaymentResult.vue
Normal file
171
src/views/PaymentResult.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div class="payment-result">
|
||||
<el-card>
|
||||
<div class="result-container">
|
||||
<el-result
|
||||
:icon="resultIcon"
|
||||
:title="resultTitle"
|
||||
:sub-title="resultSubTitle"
|
||||
>
|
||||
<template #extra>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="订单号">
|
||||
{{ orderInfo.merchantTransactionId || '未知' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="交易流水号">
|
||||
{{ orderInfo.transactionId || '暂无' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="订单状态">
|
||||
<el-tag :type="statusTagTypeComputed">{{ orderInfo.statusText }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="交易金额" v-if="orderInfo.amount">
|
||||
{{ orderInfo.amount }} {{ orderInfo.currency }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间" v-if="orderInfo.createTime">
|
||||
{{ orderInfo.createTime }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="action-buttons">
|
||||
<el-button type="primary" @click="goToCreate">继续支付</el-button>
|
||||
<el-button @click="goToQuery">查询订单</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getOrderStatus } from '../api/payment'
|
||||
import { getStatusText, getStatusTagType } from '../utils/helpers'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const orderInfo = ref({
|
||||
merchantTransactionId: '',
|
||||
transactionId: '',
|
||||
status: '',
|
||||
statusText: '',
|
||||
amount: '',
|
||||
currency: '',
|
||||
createTime: ''
|
||||
})
|
||||
|
||||
const resultIcon = computed(() => {
|
||||
const status = orderInfo.value.status?.toUpperCase()
|
||||
if (status === 'SUCCESS' || status === 'SUCCESSFUL') {
|
||||
return 'success'
|
||||
} else if (status === 'FAILED' || status === 'FAILURE') {
|
||||
return 'error'
|
||||
} else if (status === 'REVIEW') {
|
||||
return 'warning'
|
||||
}
|
||||
return 'info'
|
||||
})
|
||||
|
||||
const resultTitle = computed(() => {
|
||||
const status = orderInfo.value.status?.toUpperCase()
|
||||
if (status === 'SUCCESS' || status === 'SUCCESSFUL') {
|
||||
return '支付成功'
|
||||
} else if (status === 'FAILED' || status === 'FAILURE') {
|
||||
return '支付失败'
|
||||
} else if (status === 'REVIEW') {
|
||||
return '订单审核中'
|
||||
}
|
||||
return '支付处理中'
|
||||
})
|
||||
|
||||
const resultSubTitle = computed(() => {
|
||||
const status = orderInfo.value.status?.toUpperCase()
|
||||
if (status === 'SUCCESS' || status === 'SUCCESSFUL') {
|
||||
return '您的订单已成功支付'
|
||||
} else if (status === 'FAILED' || status === 'FAILURE') {
|
||||
return '支付失败,请重试或联系客服'
|
||||
} else if (status === 'REVIEW') {
|
||||
return '您的订单正在审核中,请耐心等待'
|
||||
}
|
||||
return '订单正在处理中,请稍候'
|
||||
})
|
||||
|
||||
const statusTagTypeComputed = computed(() => {
|
||||
return getStatusTagType(orderInfo.value.status)
|
||||
})
|
||||
|
||||
const loadOrderInfo = async () => {
|
||||
const merchantTransactionId = route.query.merchantTransactionId
|
||||
const status = route.query.status
|
||||
|
||||
if (!merchantTransactionId) {
|
||||
// 如果没有订单号,只显示状态
|
||||
orderInfo.value.status = status || 'UNKNOWN'
|
||||
orderInfo.value.statusText = getStatusText(status)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getOrderStatus(merchantTransactionId)
|
||||
if (response.code === '0000' && response.data) {
|
||||
orderInfo.value = {
|
||||
merchantTransactionId: response.data.merchantTransactionId,
|
||||
transactionId: response.data.transactionId || '',
|
||||
status: response.data.status,
|
||||
statusText: getStatusTextLocal(response.data.status),
|
||||
amount: response.data.amount,
|
||||
currency: response.data.currency,
|
||||
createTime: response.data.createTime
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(response.message || '查询订单失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('查询订单失败:', error)
|
||||
// 如果查询失败,使用URL参数中的状态
|
||||
orderInfo.value.merchantTransactionId = merchantTransactionId
|
||||
orderInfo.value.status = status || 'UNKNOWN'
|
||||
orderInfo.value.statusText = getStatusTextLocal(status)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusTextLocal = (status) => {
|
||||
return getStatusText(status)
|
||||
}
|
||||
|
||||
const goToCreate = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const goToQuery = () => {
|
||||
router.push('/query')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadOrderInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.payment-result {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.result-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action-buttons .el-button {
|
||||
margin: 0 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
1424
src/views/ProductCreate.vue
Normal file
1424
src/views/ProductCreate.vue
Normal file
File diff suppressed because it is too large
Load Diff
1314
src/views/ProductDetail.vue
Normal file
1314
src/views/ProductDetail.vue
Normal file
File diff suppressed because it is too large
Load Diff
360
src/views/ProductManage.vue
Normal file
360
src/views/ProductManage.vue
Normal file
@@ -0,0 +1,360 @@
|
||||
<template>
|
||||
<div class="product-manage">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>商品管理</span>
|
||||
<el-button type="primary" @click="goToCreate">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增商品
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 商品列表 -->
|
||||
<el-table :data="productList" v-loading="loading" style="width: 100%">
|
||||
<!-- 商品封面图 -->
|
||||
<el-table-column label="商品封面" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="getFirstMainImage(row)"
|
||||
:src="getFirstMainImage(row)"
|
||||
style="width: 80px; height: 80px; border-radius: 4px"
|
||||
fit="cover"
|
||||
:preview-src-list="row.mainImages || [row.mainImage]"
|
||||
:initial-index="0"
|
||||
/>
|
||||
<span v-else class="no-image">无图片</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 商品名称(名称下方附带商品Id) -->
|
||||
<el-table-column label="商品名称" min-width="250">
|
||||
<template #default="{ row }">
|
||||
<div class="product-name-cell">
|
||||
<div class="product-name">{{ row.name }}</div>
|
||||
<div class="product-id">商品ID: {{ row.id }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 商品链接 -->
|
||||
<el-table-column label="商品链接" min-width="300">
|
||||
<template #default="{ row }">
|
||||
<div class="product-url-cell">
|
||||
<el-input
|
||||
v-model="row.productUrl"
|
||||
readonly
|
||||
size="small"
|
||||
style="width: 100%"
|
||||
>
|
||||
<template #append>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="copyProductUrl(row.id, row.productUrl)"
|
||||
>
|
||||
复制
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 商品价格 -->
|
||||
<el-table-column label="商品价格" width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="price-cell">
|
||||
<span class="price-value">{{ formatPrice(row.price) }}</span>
|
||||
<span class="price-currency">CNY</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 商品状态 -->
|
||||
<el-table-column label="商品状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 'ACTIVE' ? '上架' : '下架' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 发售地区 -->
|
||||
<el-table-column label="发售地区" width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="sales-regions-cell">
|
||||
<el-tag
|
||||
v-for="region in getSalesRegions(row)"
|
||||
:key="region.code"
|
||||
size="small"
|
||||
style="margin-right: 5px; margin-bottom: 5px"
|
||||
>
|
||||
{{ region.name }}
|
||||
</el-tag>
|
||||
<span v-if="!getSalesRegions(row) || getSalesRegions(row).length === 0" class="no-region">
|
||||
未设置
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 操作 -->
|
||||
<el-table-column label="操作" width="200" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="editProduct(row.id)">
|
||||
修改
|
||||
</el-button>
|
||||
<el-button type="success" link size="small" @click="copyProductUrl(row.id, row.productUrl)">
|
||||
复制
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="deleteProduct(row.id)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { getProductList, getProductUrl } from '../api/product'
|
||||
import { formatAmount } from '../utils/helpers'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const productList = ref([])
|
||||
|
||||
// 跳转到新增商品页面
|
||||
const goToCreate = () => {
|
||||
router.push('/manage/product/create')
|
||||
}
|
||||
|
||||
// 格式化价格
|
||||
const formatPrice = (price) => {
|
||||
return formatAmount(price)
|
||||
}
|
||||
|
||||
// 货币到销售地区的映射
|
||||
const currencyToRegionMap = {
|
||||
'MYR': { code: 'MY', name: '马来西亚' },
|
||||
'PHP': { code: 'PH', name: '菲律宾' },
|
||||
'THB': { code: 'TH', name: '泰国' },
|
||||
'VND': { code: 'VN', name: '越南' },
|
||||
'SGD': { code: 'SG', name: '新加坡' },
|
||||
'CNY': { code: 'CN', name: '中国' },
|
||||
'USD': { code: 'US', name: '美国' },
|
||||
'EUR': { code: 'EU', name: '欧洲' },
|
||||
'GBP': { code: 'GB', name: '英国' }
|
||||
}
|
||||
|
||||
// 获取第一张主图
|
||||
const getFirstMainImage = (row) => {
|
||||
if (row.mainImages && row.mainImages.length > 0) {
|
||||
return row.mainImages[0]
|
||||
}
|
||||
return row.mainImage || null
|
||||
}
|
||||
|
||||
// 获取销售地区(从SKU的currency中提取)
|
||||
const getSalesRegions = (row) => {
|
||||
if (!row.skus || row.skus.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// 从SKU中提取所有不同的货币
|
||||
const currencies = [...new Set(row.skus.map(sku => sku.currency).filter(Boolean))]
|
||||
|
||||
// 映射到销售地区
|
||||
const regions = currencies
|
||||
.map(currency => currencyToRegionMap[currency])
|
||||
.filter(Boolean)
|
||||
|
||||
return regions
|
||||
}
|
||||
|
||||
// 加载商品列表
|
||||
const loadProductList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await getProductList()
|
||||
if (response.code === '0000' && response.data) {
|
||||
// 为每个商品获取链接URL
|
||||
const productsWithUrl = await Promise.all(
|
||||
response.data.map(async (product) => {
|
||||
try {
|
||||
const urlResponse = await getProductUrl(product.id)
|
||||
if (urlResponse.code === '0000' && urlResponse.data && urlResponse.data.url) {
|
||||
product.productUrl = urlResponse.data.url
|
||||
} else {
|
||||
product.productUrl = ''
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`获取商品${product.id}链接失败:`, error)
|
||||
product.productUrl = ''
|
||||
}
|
||||
return product
|
||||
})
|
||||
)
|
||||
productList.value = productsWithUrl
|
||||
} else {
|
||||
ElMessage.error(response.message || '获取商品列表失败')
|
||||
productList.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载商品列表失败:', error)
|
||||
ElMessage.error('加载商品列表失败')
|
||||
productList.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑商品
|
||||
const editProduct = (id) => {
|
||||
// TODO: 实现编辑功能,跳转到编辑页面
|
||||
ElMessage.info('编辑功能待实现')
|
||||
// router.push(`/manage/product/edit/${id}`)
|
||||
}
|
||||
|
||||
// 复制商品链接
|
||||
const copyProductUrl = async (id, url) => {
|
||||
try {
|
||||
let urlToCopy = url
|
||||
// 如果没有URL,先获取
|
||||
if (!urlToCopy) {
|
||||
const response = await getProductUrl(id)
|
||||
if (response.code === '0000' && response.data && response.data.url) {
|
||||
urlToCopy = response.data.url
|
||||
// 更新列表中的URL
|
||||
const product = productList.value.find(p => p.id === id)
|
||||
if (product) {
|
||||
product.productUrl = urlToCopy
|
||||
}
|
||||
} else {
|
||||
ElMessage.error('获取商品链接失败')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 复制到剪贴板
|
||||
await navigator.clipboard.writeText(urlToCopy)
|
||||
ElMessage.success('商品链接已复制到剪贴板')
|
||||
} catch (error) {
|
||||
console.error('复制商品链接失败:', error)
|
||||
ElMessage.error('复制商品链接失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除商品
|
||||
const deleteProduct = async (id) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该商品吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
// TODO: 实现删除商品API
|
||||
ElMessage.info('删除功能待实现')
|
||||
// await request.delete(`/api/product/${id}`)
|
||||
// ElMessage.success('商品删除成功')
|
||||
// loadProductList()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除商品失败:', error)
|
||||
ElMessage.error('删除商品失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.log('ProductManage 组件已挂载')
|
||||
loadProductList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-manage {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 商品名称单元格 */
|
||||
.product-name-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.product-id {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
/* 商品链接单元格 */
|
||||
.product-url-cell {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 价格单元格 */
|
||||
.price-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.price-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.price-currency {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
/* 销售地区单元格 */
|
||||
.sales-regions-cell {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.no-region {
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 无图片 */
|
||||
.no-image {
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
12
src/views/TestPage.vue
Normal file
12
src/views/TestPage.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div style="padding: 20px;">
|
||||
<h1>测试页面</h1>
|
||||
<p>如果你能看到这个页面,说明Vue应用正常运行。</p>
|
||||
<el-button type="primary">测试按钮</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
console.log('TestPage组件已加载')
|
||||
</script>
|
||||
|
||||
34
vite.config.js
Normal file
34
vite.config.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8082', // 使用 127.0.0.1 而不是 localhost,避免 IPv6 问题
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
ws: true,
|
||||
timeout: 30000,
|
||||
configure: (proxy, options) => {
|
||||
proxy.on('error', (err, req, res) => {
|
||||
console.error('代理错误:', err.message)
|
||||
console.error('请确保后端服务已启动在 http://127.0.0.1:8082')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user