Compare commits

..

13 Commits

Author SHA1 Message Date
1c461ab5c3 feat(i18n): 添加国际化配置和国家地址配置
- 新增 src/utils/countryConfig.js 文件,包含各国地址字段规则和邮编格式配置
- 新增 src/i18n/index.js 文件,实现基于货币代码的国际化翻译功能
- 新增 src/i18n/locales.js 文件,提供中英马泰菲多语言静态翻译文本
- 实现货币代码到国家代码映射及地址验证功能
- 添加订单创建页、商品详情页、订单确认页等多页面国际化支持
- 支持新加坡、马来西亚、菲律宾、泰国、越南等国家地址格式配置
2025-12-24 17:39:06 +08:00
d914301ee3 feat(order): 实现国际化支持并优化创建订单界面
- 将创建订单界面的所有静态文本替换为国际化标签
- 实现在移动端和桌面端的响应式表单布局
- 添加多语言国家名称显示功能
- 集成Vue I18n国际化框架到应用主入口
- 优化订单确认页面的国际化文本显示
- 移除导航菜单中的创建订单项
- 添加针对不同国家的地址字段验证规则
- 实现越南地址层级映射逻辑
- 添加货币代码自动设置国家功能
2025-12-24 17:38:33 +08:00
bd6b7b3b79 feat(order): 支持东南亚多国地址格式的订单创建功能
- 实现动态地址表单,根据选择国家显示对应字段
- 添加新加坡组屋号、单元号,马来西亚州属,菲律宾Barangay等特殊字段
- 集成泰国双语地址、越南省市坊三级地址格式支持
- 优化订单确认页地址展示,按从小到大格式排列
- 添加邮编格式验证和自动匹配城市功能
- 实现SKU货币自动识别国家并预设字段
- 重构README文档结构和项目说明信息
2025-12-24 11:20:34 +08:00
2dfd0c13a8 feat(payment): 添加PayPal支付功能
- 实现PayPal订单创建、查询和捕获的API接口
- 创建PayPal支付取消页面,提供订单信息展示和继续支付功能
- 开发PayPal支付成功页面,处理支付回调和订单状态更新
- 集成PayPal订单状态检查和捕获流程
- 添加支付结果的用户界面和错误处理机制
- 实现订单信息的实时更新和状态同步
2025-12-24 09:22:00 +08:00
45f6a5020d feat(payment): 集成 PayPal 支付并添加货币转换功能
- 集成 PayPal 支付流程,包括订单创建、支付确认和取消页面
- 实现货币转换功能,支持不支持的货币自动转换为 PayPal 支持的货币
- 在订单确认页面显示货币转换信息,包括实际支付金额和汇率
- 添加 PayPal 支付成功和取消的路由处理
- 优化商品详情页图片预览,支持鼠标悬停显示主图
- 添加货币转换相关的 API 接口和工具函数
2025-12-23 17:49:51 +08:00
2f3606e967 feat(product): 实现商品管理与详情展示功能
- 新增商品相关API接口封装,包括创建、查询、上传图片等功能
- 实现商品详情页面,支持多货币SKU选择与图片预览
- 实现商品管理页面,展示商品列表与链接复制功能
- 添加商品状态标签与销售地区展示
- 实现购买确认弹窗与订单跳转逻辑
- 添加响应式布局适配移动端展示
- 集成Element Plus组件库实现UI交互效果
2025-12-22 18:14:11 +08:00
5d0bdef650 feat(payment): 新增支付订单创建和状态查询功能
- 添加创建支付订单接口
- 添加查询订单状态接口
- 添加获取收银台页面URL方法
- 创建支付结果展示页面 PaymentResult.vue
- 实现订单信息展示和状态判断逻辑
- 添加支付成功、失败、审核中等状态显示
- 集成 Element Plus 的结果页组件和描述列表
- 实现继续支付和查询订单操作按钮
2025-12-22 17:10:54 +08:00
f9b103426c feat(order): 实现客户订单创建与确认功能
- 新增客户订单创建页面,简化表单字段并优化用户体验
- 实现订单确认页面,展示订单详情、客户信息和收货地址
- 添加订单状态显示和支付跳转功能
- 创建订单相关API接口封装
- 优化路由配置,支持商品链接码访问
- 添加页面标题组件和首页跳转逻辑
2025-12-22 15:21:51 +08:00
f440ce2ade feat(router): 更新首页路由重定向至产品管理页
- 将首页路径 '/' 的组件加载改为重定向到 '/manage/product'
- 移除了原先直接加载 ProductDetail 组件的配置
- 确保用户访问根路径时能正确跳转到产品管理界面
2025-12-22 13:21:21 +08:00
0cfe1e6942 feat(payment): 新增创建支付订单功能
- 添加创建订单页面,支持完整的支付信息填写
- 实现商品信息展示与自动填充功能
- 集成风控信息、收货地址和账单地址表单
- 支持自动生成商户订单号
- 实现表单验证和提交逻辑
- 添加订单创建成功后的跳转逻辑
- 集成Element Plus组件库优化界面交互
- 添加路由配置支持商品ID或链接码访问
- 实现价格格式化和数据显示优化
- 添加基础的错误处理和用户提示机制
2025-12-22 13:11:13 +08:00
ed745ee6a5 feat(product): 新增商品创建页面
- 添加商品创建表单,支持商品基本信息录入
- 实现多图片上传功能,支持主图和SKU图片上传
- 添加SKU配置模块,支持多个SKU的添加和管理
- 实现销售地区选择功能,支持多地区价格设置
- 添加物流信息配置,包括重量和尺寸设置
- 实现批量编辑功能,支持批量设置价格和库存
- 添加表单验证和提交逻辑,确保数据完整性
- 集成API接口,实现商品创建功能
- 添加页面路由配置,支持从商品管理页面跳转
2025-12-22 11:37:35 +08:00
3e1d77988d feat(router): 添加商品详情和商品管理路由
- 在 App.vue 中添加商品详情和商品管理菜单项
- 配置商品详情页路由,支持动态参数 id
- 添加创建订单页面的新路由路径 /create-order
- 引入并配置商品管理页面路由 /manage/product
- 更新首页路由指向商品详情组件
- 动态导入商品管理组件以优化性能
2025-12-22 10:19:37 +08:00
57d9c03332 feat(payment): 集成PingPong支付SDK并实现支付功能
- 添加PingPong支付SDK动态加载逻辑
- 实现支付组件与SDK的初始化配置
- 配置支付容器自适应不同屏幕尺寸
- 添加支付token校验和错误提示
- 集成Element Plus消息组件显示支付状态
- 配置SDK基础样式和按钮样式参数
- 添加支付页面路由和基本布局结构
- 实现支付结果页面跳转逻辑
- 添加订单状态管理和响应码常量定义
- 集成工具函数支持金额格式化和日期处理
- 配置开发环境变量支持沙箱模式切换
- 添加防抖节流等常用工具函数实现
- 实现订单号生成和状态文本映射逻辑
- 添加表单验证函数支持邮箱和手机校验
2025-12-19 10:06:24 +08:00
35 changed files with 9628 additions and 81 deletions

130
README.md
View File

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

File diff suppressed because it is too large Load Diff

View File

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

View 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>

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

34
src/main.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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返回的tokenorderId
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
View 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

File diff suppressed because it is too large Load Diff

1314
src/views/ProductDetail.vue Normal file

File diff suppressed because it is too large Load Diff

360
src/views/ProductManage.vue Normal file
View 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
View 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
View 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')
})
}
}
}
}
})