init: 微信支付 Native 页面 (Express + JSON 存储)
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
data/orders.json
|
||||||
|
config.js
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const config = require('./config');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Body parsing
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.text({ type: ['text/xml', 'application/xml'] }));
|
||||||
|
|
||||||
|
// Static files
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// View engine
|
||||||
|
app.set('view engine', 'ejs');
|
||||||
|
app.set('views', path.join(__dirname, 'views'));
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.use('/', require('./routes/index'));
|
||||||
|
app.use('/pay', require('./routes/pay'));
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
app.listen(config.server.port, config.server.host, () => {
|
||||||
|
console.log(`Server running at http://localhost:${config.server.port}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
module.exports = {
|
||||||
|
// WeChat Pay configuration - replace with your own credentials
|
||||||
|
wechat: {
|
||||||
|
appid: 'your_appid',
|
||||||
|
mchid: 'your_mchid',
|
||||||
|
key: 'your_api_key',
|
||||||
|
notifyUrl: 'https://your-domain.com/pay/notify',
|
||||||
|
apiBase: 'https://api.mch.weixin.qq.com',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Server configuration
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
host: '0.0.0.0',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Product list
|
||||||
|
products: [
|
||||||
|
{ id: 1, name: '商品A', price: 1, desc: '测试商品A - 1分钱' },
|
||||||
|
{ id: 2, name: '商品B', price: 100, desc: '测试商品B - 1元' },
|
||||||
|
{ id: 3, name: '商品C', price: 1000, desc: '测试商品C - 10元' },
|
||||||
|
],
|
||||||
|
};
|
||||||
Generated
+1824
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "wechat-pay-native",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "WeChat Pay Native payment page with Express",
|
||||||
|
"main": "app.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node app.js",
|
||||||
|
"dev": "nodemon app.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.0",
|
||||||
|
"ejs": "^3.1.10",
|
||||||
|
"express": "^4.19.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
|
"xml2js": "^0.6.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Product List */
|
||||||
|
.product-list { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
.product-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card h2 { font-size: 20px; color: #333; margin-bottom: 8px; }
|
||||||
|
|
||||||
|
.price { font-size: 28px; color: #e74c3c; font-weight: bold; margin-bottom: 8px; }
|
||||||
|
|
||||||
|
.desc { color: #888; font-size: 14px; margin-bottom: 16px; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
background: #07c160;
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 24px;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 16px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover { background: #06ad56; }
|
||||||
|
|
||||||
|
.btn-back { background: #666; margin-top: 16px; display: block; }
|
||||||
|
.btn-back:hover { background: #555; }
|
||||||
|
|
||||||
|
/* Pay */
|
||||||
|
.pay-card, .error-card, .success-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px 24px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-info {
|
||||||
|
margin: 20px 0;
|
||||||
|
text-align: left;
|
||||||
|
background: #f9f9f9;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-info p { margin: 6px 0; color: #555; font-size: 14px; }
|
||||||
|
.order-info p span { float: right; color: #333; font-weight: 500; }
|
||||||
|
|
||||||
|
.qrcode-wrapper { margin: 24px 0; }
|
||||||
|
|
||||||
|
.qrcode-wrapper img { width: 260px; height: 260px; }
|
||||||
|
|
||||||
|
.hint { color: #888; font-size: 14px; margin-top: 12px; }
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.pending { background: #fff3cd; color: #856404; }
|
||||||
|
.status.success { background: #d4edda; color: #155724; }
|
||||||
|
.status.failed { background: #f8d7da; color: #721c24; }
|
||||||
|
|
||||||
|
/* Error */
|
||||||
|
.error-card h1 { color: #e74c3c; }
|
||||||
|
.error-card p { color: #666; margin: 16px 0; }
|
||||||
|
|
||||||
|
/* Success */
|
||||||
|
.success-card h1 { color: #07c160; }
|
||||||
|
|
||||||
|
/* Navigation */
|
||||||
|
.nav-btn { display: block; margin-top: 20px; background: rgba(255,255,255,0.2); text-align: center; }
|
||||||
|
.nav-btn:hover { background: rgba(255,255,255,0.3); }
|
||||||
|
|
||||||
|
/* Orders */
|
||||||
|
.orders-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container:has(.orders-card) { max-width: 700px; }
|
||||||
|
|
||||||
|
.orders-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
|
||||||
|
.orders-table th {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 10px 8px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
border-bottom: 2px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders-table td {
|
||||||
|
padding: 10px 8px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders-table tbody tr:hover { background: #fafafa; }
|
||||||
|
|
||||||
|
.order-id { font-family: monospace; color: #888; font-size: 12px; }
|
||||||
|
|
||||||
|
.empty { text-align: center; color: #999; padding: 40px 0; font-size: 16px; }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-creating { background: #e8e8e8; color: #666; }
|
||||||
|
.badge-pending { background: #fff3cd; color: #856404; }
|
||||||
|
.badge-success { background: #d4edda; color: #155724; }
|
||||||
|
.badge-failed { background: #f8d7da; color: #721c24; }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
res.render('index', { products: config.products });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const axios = require('axios');
|
||||||
|
const xml2js = require('xml2js');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const QRCode = require('qrcode');
|
||||||
|
const config = require('../config');
|
||||||
|
const storage = require('../utils/storage');
|
||||||
|
|
||||||
|
function buildSign(params, key) {
|
||||||
|
const str = Object.keys(params)
|
||||||
|
.filter(k => params[k] !== '' && k !== 'sign')
|
||||||
|
.sort()
|
||||||
|
.map(k => `${k}=${params[k]}`)
|
||||||
|
.join('&') + `&key=${key}`;
|
||||||
|
return crypto.createHash('md5').update(str, 'utf-8').digest('hex').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildXml(obj) {
|
||||||
|
let xml = '<xml>';
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
xml += `<${k}>${v}</${k}>`;
|
||||||
|
}
|
||||||
|
xml += '</xml>';
|
||||||
|
return xml;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Create order page - show QR code
|
||||||
|
router.get('/create/:productId', async (req, res) => {
|
||||||
|
const product = config.products.find(p => p.id === parseInt(req.params.productId));
|
||||||
|
if (!product) return res.status(404).send('商品不存在');
|
||||||
|
|
||||||
|
const orderId = uuidv4().replace(/-/g, '').substring(0, 28);
|
||||||
|
const order = {
|
||||||
|
orderId,
|
||||||
|
productId: product.id,
|
||||||
|
productName: product.name,
|
||||||
|
totalFee: product.price,
|
||||||
|
status: 'CREATING',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
storage.saveOrder(order);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nonceStr = uuidv4().replace(/-/g, '').substring(0, 16);
|
||||||
|
const unifiedParams = {
|
||||||
|
appid: config.wechat.appid,
|
||||||
|
mch_id: config.wechat.mchid,
|
||||||
|
nonce_str: nonceStr,
|
||||||
|
body: product.name,
|
||||||
|
out_trade_no: orderId,
|
||||||
|
total_fee: product.price,
|
||||||
|
spbill_create_ip: req.ip || '127.0.0.1',
|
||||||
|
notify_url: config.wechat.notifyUrl,
|
||||||
|
trade_type: 'NATIVE',
|
||||||
|
};
|
||||||
|
unifiedParams.sign = buildSign(unifiedParams, config.wechat.key);
|
||||||
|
|
||||||
|
const xml = buildXml(unifiedParams);
|
||||||
|
const resp = await axios.post(
|
||||||
|
`${config.wechat.apiBase}/pay/unifiedorder`,
|
||||||
|
xml,
|
||||||
|
{ headers: { 'Content-Type': 'text/xml' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsed = await xml2js.parseStringPromise(resp.data, { explicitArray: false });
|
||||||
|
const result = parsed.xml;
|
||||||
|
|
||||||
|
if (result.return_code !== 'SUCCESS' || result.result_code !== 'SUCCESS') {
|
||||||
|
order.status = 'FAILED';
|
||||||
|
storage.saveOrder(order);
|
||||||
|
return res.render('pay', { error: `下单失败: ${result.err_code_des || result.return_msg}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
order.codeUrl = result.code_url;
|
||||||
|
order.status = 'PENDING';
|
||||||
|
storage.saveOrder(order);
|
||||||
|
|
||||||
|
const qrDataUrl = await QRCode.toDataURL(result.code_url, { width: 300, margin: 2 });
|
||||||
|
res.render('pay', { order, qrDataUrl, error: null });
|
||||||
|
} catch (err) {
|
||||||
|
order.status = 'FAILED';
|
||||||
|
storage.saveOrder(order);
|
||||||
|
res.render('pay', { error: `系统错误: ${err.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Poll payment status
|
||||||
|
router.get('/status/:orderId', (req, res) => {
|
||||||
|
const order = storage.findOrder(req.params.orderId);
|
||||||
|
if (!order) return res.json({ status: 'NOT_FOUND' });
|
||||||
|
res.json({ status: order.status, orderId: order.orderId });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: WeChat payment notification callback
|
||||||
|
router.post('/notify', async (req, res) => {
|
||||||
|
if (!req.body) {
|
||||||
|
return res.send(buildXml({ return_code: 'FAIL', return_msg: '空请求体' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = await xml2js.parseStringPromise(req.body, { explicitArray: false });
|
||||||
|
const data = parsed.xml;
|
||||||
|
|
||||||
|
const expectedSign = buildSign(data, config.wechat.key);
|
||||||
|
if (data.sign !== expectedSign) {
|
||||||
|
return res.send(buildXml({ return_code: 'FAIL', return_msg: '签名失败' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.return_code === 'SUCCESS' && data.result_code === 'SUCCESS') {
|
||||||
|
storage.updateOrderStatus(data.out_trade_no, 'SUCCESS', data.transaction_id);
|
||||||
|
return res.send(buildXml({ return_code: 'SUCCESS', return_msg: 'OK' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.send(buildXml({ return_code: 'FAIL', return_msg: data.err_code_des || '支付失败' }));
|
||||||
|
} catch (err) {
|
||||||
|
res.send(buildXml({ return_code: 'FAIL', return_msg: '解析失败' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 4: Order list
|
||||||
|
router.get('/orders', (req, res) => {
|
||||||
|
const orders = storage.readOrders().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||||
|
res.render('orders', { orders });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Success page
|
||||||
|
router.get('/success/:orderId', (req, res) => {
|
||||||
|
const order = storage.findOrder(req.params.orderId);
|
||||||
|
if (!order) return res.status(404).send('订单不存在');
|
||||||
|
res.render('success', { order });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DATA_FILE = path.join(__dirname, '..', 'data', 'orders.json');
|
||||||
|
|
||||||
|
function readOrders() {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(DATA_FILE, 'utf-8');
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeOrders(orders) {
|
||||||
|
fs.writeFileSync(DATA_FILE, JSON.stringify(orders, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function findOrder(orderId) {
|
||||||
|
const orders = readOrders();
|
||||||
|
return orders.find(o => o.orderId === orderId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveOrder(order) {
|
||||||
|
const orders = readOrders();
|
||||||
|
const idx = orders.findIndex(o => o.orderId === order.orderId);
|
||||||
|
if (idx !== -1) {
|
||||||
|
orders[idx] = order;
|
||||||
|
} else {
|
||||||
|
orders.push(order);
|
||||||
|
}
|
||||||
|
writeOrders(orders);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOrderStatus(orderId, status, transactionId) {
|
||||||
|
const orders = readOrders();
|
||||||
|
const order = orders.find(o => o.orderId === orderId);
|
||||||
|
if (order) {
|
||||||
|
order.status = status;
|
||||||
|
if (transactionId) order.transactionId = transactionId;
|
||||||
|
order.updatedAt = new Date().toISOString();
|
||||||
|
writeOrders(orders);
|
||||||
|
}
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { readOrders, findOrder, saveOrder, updateOrderStatus };
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>微信支付 - 商品列表</title>
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>📦 商品列表</h1>
|
||||||
|
<div class="product-list">
|
||||||
|
<% products.forEach(product => { %>
|
||||||
|
<div class="product-card">
|
||||||
|
<h2><%= product.name %></h2>
|
||||||
|
<p class="price">¥ <%= (product.price / 100).toFixed(2) %></p>
|
||||||
|
<p class="desc"><%= product.desc %></p>
|
||||||
|
<a href="/pay/create/<%= product.id %>" class="btn">立即购买</a>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<a href="/pay/orders" class="btn nav-btn">📋 订单记录</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>订单记录</title>
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>📋 订单记录</h1>
|
||||||
|
<div class="orders-card">
|
||||||
|
<% if (orders.length === 0) { %>
|
||||||
|
<p class="empty">暂无订单</p>
|
||||||
|
<% } else { %>
|
||||||
|
<table class="orders-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>订单号</th>
|
||||||
|
<th>商品</th>
|
||||||
|
<th>金额</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% orders.forEach(order => { %>
|
||||||
|
<tr>
|
||||||
|
<td class="order-id" title="<%= order.orderId %>"><%= order.orderId.substring(0, 12) %>...</td>
|
||||||
|
<td><%= order.productName %></td>
|
||||||
|
<td>¥ <%= (order.totalFee / 100).toFixed(2) %></td>
|
||||||
|
<td>
|
||||||
|
<% const statusMap = { CREATING: '创建中', PENDING: '待支付', SUCCESS: '已支付', FAILED: '失败' }; %>
|
||||||
|
<span class="badge badge-<%= order.status.toLowerCase() %>"><%= statusMap[order.status] || order.status %></span>
|
||||||
|
</td>
|
||||||
|
<td><%= new Date(order.createdAt).toLocaleString() %></td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<a href="/" class="btn nav-btn">← 返回首页</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>微信支付 - 扫码支付</title>
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<% if (error) { %>
|
||||||
|
<div class="error-card">
|
||||||
|
<h1>❌ 下单失败</h1>
|
||||||
|
<p><%= error %></p>
|
||||||
|
<a href="/" class="btn">返回重试</a>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="pay-card">
|
||||||
|
<h1>⚡ 微信扫码支付</h1>
|
||||||
|
<div class="order-info">
|
||||||
|
<p>订单号: <span><%= order.orderId %></span></p>
|
||||||
|
<p>商品: <span><%= order.productName %></span></p>
|
||||||
|
<p>金额: <span>¥ <%= (order.totalFee / 100).toFixed(2) %></span></p>
|
||||||
|
</div>
|
||||||
|
<div class="qrcode-wrapper">
|
||||||
|
<img src="<%= qrDataUrl %>" alt="支付二维码">
|
||||||
|
<p class="hint">请使用微信扫描二维码支付</p>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status pending">等待支付...</div>
|
||||||
|
<a href="/" class="btn btn-back">返回首页</a>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
<% if (!error) { %>
|
||||||
|
const orderId = '<%= order.orderId %>';
|
||||||
|
const checkStatus = () => {
|
||||||
|
fetch('/pay/status/' + orderId)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
if (data.status === 'SUCCESS') {
|
||||||
|
el.className = 'status success';
|
||||||
|
el.textContent = '✅ 支付成功!';
|
||||||
|
clearInterval(timer);
|
||||||
|
setTimeout(() => { window.location.href = '/pay/success/' + orderId; }, 1500);
|
||||||
|
} else if (data.status === 'FAILED') {
|
||||||
|
el.className = 'status failed';
|
||||||
|
el.textContent = '❌ 支付失败';
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const timer = setInterval(checkStatus, 2000);
|
||||||
|
<% } %>
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>支付成功</title>
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="success-card">
|
||||||
|
<h1>✅ 支付成功</h1>
|
||||||
|
<div class="order-info">
|
||||||
|
<p>订单号: <span><%= order.orderId %></span></p>
|
||||||
|
<p>商品: <span><%= order.productName %></span></p>
|
||||||
|
<p>金额: <span>¥ <%= (order.totalFee / 100).toFixed(2) %></span></p>
|
||||||
|
</div>
|
||||||
|
<a href="/" class="btn">继续购物</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user