Build and Publish Server / build (push) Successful in 4m30s
- 新增商业化模块:Stripe/Binance Pay 支付、邮箱登录、订单管理 - 新增数据模型:ReportProduct, ReportEntitlement, ReportSnapshot, PaymentEvent, EmailLoginCode - 新增 API:/api/commerce/checkout, /api/commerce/webhooks/*, /api/commerce/orders/* - 新增管理后台:订单列表、统计图表、统一 AdminHeader 组件 - 新增中间件测试:RateLimit, Auth, CORS 共 7 个测试用例 - 新增 AGENTS.md 项目代理指令文件 - 新增 REMINDER.md 商业化功能开发规范 - 修复 JWT 解析:添加 WithValidMethods 限制 HS256 - 修复 GetPayStatus:增加用户权限校验 - 更新 .gitignore 和 README.md
95 lines
7.6 KiB
JavaScript
95 lines
7.6 KiB
JavaScript
import { onMounted, ref } from 'vue';
|
|
import { adminHeader } from './AdminHeader.js';
|
|
|
|
export default {
|
|
setup() {
|
|
const orders = ref([]);
|
|
const stats = ref({ totalOrders: 0, paidOrders: 0, pendingOrders: 0, refundedOrders: 0, revenueUsdCents: 0 });
|
|
const total = ref(0);
|
|
const loading = ref(true);
|
|
const error = ref('');
|
|
const filters = ref({ status: '', provider: '', search: '' });
|
|
|
|
const load = async () => {
|
|
loading.value = true;
|
|
error.value = '';
|
|
const params = new URLSearchParams({ page: '1', pageSize: '100' });
|
|
Object.entries(filters.value).forEach(([key, value]) => value && params.set(key, value));
|
|
try {
|
|
const [orderResponse, statsResponse] = await Promise.all([
|
|
fetch('/admin/api/orders?' + params.toString(), { credentials: 'same-origin' }),
|
|
fetch('/admin/api/orders/stats', { credentials: 'same-origin' }),
|
|
]);
|
|
if (!orderResponse.ok || !statsResponse.ok) throw new Error('加载订单失败');
|
|
const orderPayload = await orderResponse.json();
|
|
const statsPayload = await statsResponse.json();
|
|
orders.value = orderPayload.data.list || [];
|
|
total.value = orderPayload.data.total || 0;
|
|
stats.value = statsPayload.data;
|
|
} catch (reason) {
|
|
error.value = reason instanceof Error ? reason.message : '加载订单失败';
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const money = order => order.currency === 'USD'
|
|
? '$' + (order.amount / 100).toFixed(2)
|
|
: order.providerAmount + ' ' + order.currency;
|
|
const dateTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-';
|
|
const statusText = status => ({ pending: '待支付', paid: '已支付', cancelled: '已关闭', refunded: '已退款' }[status] || status);
|
|
const statusClass = status => ({
|
|
paid: 'bg-green-100 text-green-800', pending: 'bg-amber-100 text-amber-800',
|
|
cancelled: 'bg-gray-100 text-gray-700', refunded: 'bg-blue-100 text-blue-800',
|
|
}[status] || 'bg-gray-100 text-gray-700');
|
|
const providerText = provider => ({ stripe: 'Stripe', binance_pay: 'Binance Pay' }[provider] || provider || '-');
|
|
|
|
onMounted(load);
|
|
return { orders, stats, total, loading, error, filters, load, money, dateTime, statusText, statusClass, providerText };
|
|
},
|
|
template: `
|
|
<div class="min-h-screen bg-gray-100">
|
|
${adminHeader('orders')}
|
|
<main class="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
|
|
<div class="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
|
|
<div class="bg-white p-4 rounded-lg shadow-sm"><p class="text-xs text-gray-500">报告订单</p><b class="text-2xl">{{ stats.totalOrders }}</b></div>
|
|
<div class="bg-white p-4 rounded-lg shadow-sm"><p class="text-xs text-gray-500">已支付</p><b class="text-2xl text-green-700">{{ stats.paidOrders }}</b></div>
|
|
<div class="bg-white p-4 rounded-lg shadow-sm"><p class="text-xs text-gray-500">待支付</p><b class="text-2xl text-amber-700">{{ stats.pendingOrders }}</b></div>
|
|
<div class="bg-white p-4 rounded-lg shadow-sm"><p class="text-xs text-gray-500">已退款</p><b class="text-2xl text-blue-700">{{ stats.refundedOrders }}</b></div>
|
|
<div class="bg-white p-4 rounded-lg shadow-sm"><p class="text-xs text-gray-500">Stripe 收入</p><b class="text-2xl">\${{ (stats.revenueUsdCents / 100).toFixed(2) }}</b></div>
|
|
</div>
|
|
<div class="bg-white shadow rounded-lg">
|
|
<div class="p-5 border-b flex flex-col lg:flex-row gap-3 lg:items-center lg:justify-between">
|
|
<div><h2 class="text-lg font-medium">PC 八字报告订单</h2><p class="text-xs text-gray-500 mt-1">仅显示 PC 商业版八字报告订单,共 {{ total }} 笔</p></div>
|
|
<div class="flex flex-wrap gap-2">
|
|
<input v-model="filters.search" @keyup.enter="load" class="border rounded px-3 py-2 text-sm" placeholder="订单号 / 邮箱 / 报告号" />
|
|
<select v-model="filters.provider" @change="load" class="border rounded px-3 py-2 text-sm"><option value="">全部渠道</option><option value="stripe">Stripe</option><option value="binance_pay">Binance Pay</option></select>
|
|
<select v-model="filters.status" @change="load" class="border rounded px-3 py-2 text-sm"><option value="">全部状态</option><option value="pending">待支付</option><option value="paid">已支付</option><option value="cancelled">已关闭</option><option value="refunded">已退款</option></select>
|
|
<button @click="load" class="bg-red-700 text-white rounded px-4 py-2 text-sm">查询</button>
|
|
</div>
|
|
</div>
|
|
<div v-if="error" class="m-5 p-3 bg-red-50 text-red-700 text-sm">{{ error }}</div>
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-gray-200">
|
|
<thead class="bg-gray-50"><tr><th class="px-4 py-3 text-left text-xs text-gray-500">订单 / 报告</th><th class="px-4 py-3 text-left text-xs text-gray-500">客户</th><th class="px-4 py-3 text-left text-xs text-gray-500">渠道</th><th class="px-4 py-3 text-left text-xs text-gray-500">金额</th><th class="px-4 py-3 text-left text-xs text-gray-500">状态</th><th class="px-4 py-3 text-left text-xs text-gray-500">渠道交易号</th><th class="px-4 py-3 text-left text-xs text-gray-500">时间</th></tr></thead>
|
|
<tbody class="divide-y divide-gray-100">
|
|
<tr v-if="loading"><td colspan="7" class="p-10 text-center text-sm text-gray-500">正在加载订单...</td></tr>
|
|
<tr v-else-if="orders.length === 0"><td colspan="7" class="p-10 text-center text-sm text-gray-500">暂无符合条件的订单</td></tr>
|
|
<tr v-for="order in orders" :key="order.id" class="hover:bg-gray-50">
|
|
<td class="px-4 py-4 text-xs"><b class="block text-gray-900">{{ order.orderNo }}</b><span class="block text-gray-500">{{ order.reportId || '-' }}</span><code v-if="order.accessCode" class="block mt-1 text-red-700">{{ order.accessCode }}</code></td>
|
|
<td class="px-4 py-4 text-sm">{{ order.customerEmail || '-' }}</td>
|
|
<td class="px-4 py-4 text-sm">{{ providerText(order.provider) }}</td>
|
|
<td class="px-4 py-4 text-sm font-medium">{{ money(order) }}</td>
|
|
<td class="px-4 py-4"><span :class="statusClass(order.status)" class="px-2 py-1 rounded-full text-xs font-medium">{{ statusText(order.status) }}</span></td>
|
|
<td class="px-4 py-4 text-xs text-gray-500 max-w-xs truncate" :title="order.providerTransactionId">{{ order.providerTransactionId || order.providerOrderId || '-' }}</td>
|
|
<td class="px-4 py-4 text-xs text-gray-500"><span class="block">创建 {{ dateTime(order.createdAt) }}</span><span class="block">支付 {{ dateTime(order.payTime) }}</span></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
`,
|
|
};
|