feat: AI routing DB-driven, payment gateway full integration, WeChat mini-program CI/CD

- AI routing rules now stored in system_configs DB table instead of hardcoded config
- Multi-model support via name|model composite key for same-provider routing
- UnifiedPayService with HMAC-SHA256 gateway integration (alipay/wechat)
- Admin payment panel: list, stats, search, filter, refund
- WeChat mini-program CI/CD via miniprogram-ci (v1.0.9)
- Translation quota extended to LLM provider tier
- SearchService with DB-driven provider config (bing/google_cse/searxng)
- Footer cleanup across admin/workspace/uni-app
- Private key excluded from git tracking
This commit is contained in:
TradeMate Dev
2026-06-09 17:19:45 +08:00
parent f17a6ccbac
commit d2736d1ef6
28 changed files with 12368 additions and 267 deletions
+4
View File
@@ -58,4 +58,8 @@ export function processInvoice(id, action) {
export function aiChat(message, history = []) { return http.post('/ai/chat', { message, history }) }
export function aiQuickQuestions() { return http.get('/ai/quick-questions') }
export function listPayments(params) { return http.get('/admin/payments', { params }) }
export function getPaymentStats() { return http.get('/admin/payments/stats') }
export function adminRefund(order_no, reason = '') { return http.post('/admin/payments/refund', { order_no, reason }) }
export default http
+12 -39
View File
@@ -26,6 +26,10 @@
<el-icon><Document /></el-icon>
<span>日志</span>
</el-menu-item>
<el-menu-item index="/payments">
<el-icon><Wallet /></el-icon>
<span>支付管理</span>
</el-menu-item>
<el-menu-item index="/config">
<el-icon><Setting /></el-icon>
<span>配置</span>
@@ -93,37 +97,13 @@
<el-footer class="footer">
<div class="footer-content">
<div class="footer-section">
<div class="footer-brand">TradeMate</div>
<p class="footer-tagline">AI 外贸小助手 · 让外贸更简单</p>
<div class="qrcode-row">
<div class="qrcode-item">
<img src="/images/yzr/yuzhiran.jpg" alt="微信公众号" class="qrcode-img" />
<span>微信公众号</span>
</div>
<div class="qrcode-item">
<img src="/images/yzr/yuzhiran-tech.jpg" alt="微信服务号" class="qrcode-img" />
<span>微信服务号</span>
</div>
<div class="qrcode-item">
<img src="/images/yzr/yuzhiran-yhl.jpg" alt="小程序" class="qrcode-img" />
<span>小程序</span>
</div>
<div class="qrcode-item">
<img src="/images/yzr/kefu.png" alt="客服" class="qrcode-img" />
<span>微信客服</span>
</div>
</div>
</div>
<div class="footer-bottom">
<p>&copy; {{ new Date().getFullYear() }} TradeMate 外贸小助手. 保留所有权利.</p>
<div class="footer-links">
<a href="http://beian.miit.gov.cn/" target="_blank">{{ beianInfo.icp }}</a>
<a v-if="beianInfo.showGongan" :href="beianInfo.gonganLink" target="_blank" rel="noreferrer" class="gongan-link">
<img src="/images/beian/gongan-beian.png" alt="公安备案" class="gongan-icon" />
{{ beianInfo.gongan }}
</a>
</div>
<p>&copy; {{ new Date().getFullYear() }} TradeMate</p>
<div class="footer-links">
<a href="http://beian.miit.gov.cn/" target="_blank">{{ beianInfo.icp }}</a>
<a v-if="beianInfo.showGongan" :href="beianInfo.gonganLink" target="_blank" rel="noreferrer" class="gongan-link">
<img src="/images/beian/gongan-beian.png" alt="公安备案" class="gongan-icon" />
{{ beianInfo.gongan }}
</a>
</div>
</div>
</el-footer>
@@ -172,14 +152,7 @@ const beianInfo = computed(() => {
.user-name { font-size: 14px; color: #333; }
.main-content { background: #f5f5f5; padding: 20px; overflow-y: auto; }
.footer { padding: 0; background: #fff; border-top: 1px solid #e8e8e8; color: #666; font-size: 12px; }
.footer-content { padding: 20px 24px 16px; }
.footer-section { margin-bottom: 16px; }
.footer-brand { font-size: 15px; font-weight: 700; color: #1890ff; margin-bottom: 4px; }
.footer-tagline { color: #999; font-size: 12px; margin-bottom: 12px; }
.qrcode-row { display: flex; gap: 16px; flex-wrap: wrap; }
.qrcode-item { display: flex; flex-direction: column; align-items: center; gap: 4px; color: #999; font-size: 11px; }
.qrcode-img { width: 48px; height: 48px; border-radius: 6px; }
.footer-bottom { border-top: 1px solid #eee; padding-top: 12px; display: flex; justify-content: center; align-items: center; gap: 16px; flex-wrap: wrap; }
.footer-content { padding: 8px 24px; display: flex; justify-content: center; align-items: center; gap: 16px; flex-wrap: wrap; }
.footer-links { display: flex; gap: 16px; align-items: center; }
.footer-links a { color: #999; text-decoration: none; }
.footer-links a:hover { color: #1890ff; }
+8
View File
@@ -36,6 +36,14 @@ const routes = [
{ path: '', name: 'Logs', component: () => import('@/views/Logs.vue'), meta: { title: '日志' } },
]
},
{
path: '/payments',
component: AdminLayout,
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Payments', component: () => import('@/views/Payments.vue'), meta: { title: '支付管理' } },
]
},
{
path: '/config',
component: AdminLayout,
+4 -4
View File
@@ -12,14 +12,14 @@
<div class="cfg-group-title">{{ fieldLabelsMap.ai_routing?.[taskKey] || taskKey }}</div>
<div class="cfg-field">
<span class="cfg-label">主选</span>
<el-select v-model="edits[cfg.key][taskKey].primary" size="small" style="width:300px" filterable>
<el-option v-for="p in providers" :key="p.provider_type" :value="p.provider_type" :label="p.name + ' — ' + p.model_name" />
<el-select v-model="edits[cfg.key][taskKey].primary" size="small" style="width:380px" filterable>
<el-option v-for="p in providers" :key="p.name + '|' + p.model_name" :value="p.name + '|' + p.model_name" :label="p.name + ' — ' + p.model_name" />
</el-select>
</div>
<div class="cfg-field">
<span class="cfg-label">备用</span>
<el-select v-model="edits[cfg.key][taskKey].fallback" size="small" style="width:400px" multiple filterable collapse-tags>
<el-option v-for="p in providers" :key="p.provider_type" :value="p.provider_type" :label="p.name + ' — ' + p.model_name" />
<el-select v-model="edits[cfg.key][taskKey].fallback" size="small" style="width:500px" multiple filterable collapse-tags>
<el-option v-for="p in providers" :key="p.name + '|' + p.model_name" :value="p.name + '|' + p.model_name" :label="p.name + ' — ' + p.model_name" />
</el-select>
</div>
</div>
+180
View File
@@ -0,0 +1,180 @@
<template>
<div>
<el-row :gutter="16" style="margin-bottom:20px">
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-item">
<div class="stat-label">总收入</div>
<div class="stat-value">¥{{ (stats.total_revenue || 0).toFixed(2) }}</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-item">
<div class="stat-label">已支付</div>
<div class="stat-value" style="color:#52c41a">{{ stats.paid_count || 0 }}</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-item">
<div class="stat-label">待支付</div>
<div class="stat-value" style="color:#faad14">{{ stats.pending_count || 0 }}</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-item">
<div class="stat-label">已退款</div>
<div class="stat-value" style="color:#ff4d4f">{{ stats.refunded_count || 0 }}</div>
</div>
</el-card>
</el-col>
</el-row>
<el-card>
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<span>交易记录</span>
<div style="display:flex;gap:8px">
<el-select v-model="filters.status" placeholder="状态" clearable style="width:120px" @change="loadData">
<el-option label="全部" value="" />
<el-option label="待支付" value="pending" />
<el-option label="已支付" value="paid" />
<el-option label="已退款" value="refunded" />
<el-option label="失败" value="failed" />
</el-select>
<el-select v-model="filters.pay_type" placeholder="支付方式" clearable style="width:120px" @change="loadData">
<el-option label="全部" value="" />
<el-option label="支付宝" value="alipay" />
<el-option label="微信" value="wechat" />
</el-select>
<el-input v-model="filters.user_id" placeholder="用户ID" clearable style="width:180px" @clear="loadData" @keyup.enter="loadData" />
<el-button type="primary" @click="loadData">搜索</el-button>
</div>
</div>
</template>
<el-table :data="list" v-loading="loading" stripe style="width:100%">
<el-table-column prop="order_no" label="订单号" width="200" />
<el-table-column prop="user_id" label="用户" min-width="180" />
<el-table-column prop="plan" label="套餐" width="100" />
<el-table-column label="金额" width="100">
<template #default="{ row }">¥{{ (row.amount || 0).toFixed(2) }}</template>
</el-table-column>
<el-table-column label="支付方式" width="80">
<template #default="{ row }">{{ row.pay_type === 'wechat' ? '微信' : '支付宝' }}</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag v-if="row.status === 'paid'" type="success" size="small">已支付</el-tag>
<el-tag v-else-if="row.status === 'pending'" type="warning" size="small">待支付</el-tag>
<el-tag v-else-if="row.status === 'refunded'" type="danger" size="small">已退款</el-tag>
<el-tag v-else type="info" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="时间" width="170">
<template #default="{ row }">{{ row.paid_at || row.created_at }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 'paid'" type="danger" size="small" @click="handleRefund(row)">退款</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top:16px;display:flex;justify-content:flex-end">
<el-pagination
v-model:current-page="page"
v-model:page-size="size"
:total="total"
layout="total, prev, pager, next"
@current-change="loadData"
/>
</div>
</el-card>
<el-dialog v-model="refundDialog.visible" title="确认退款" width="400px">
<p>订单号{{ refundDialog.order_no }}</p>
<p>退款金额¥{{ (refundDialog.amount || 0).toFixed(2) }}</p>
<el-input v-model="refundDialog.reason" type="textarea" placeholder="退款原因(可选)" :rows="3" />
<template #footer>
<el-button @click="refundDialog.visible = false">取消</el-button>
<el-button type="danger" :loading="refundDialog.loading" @click="confirmRefund">确认退款</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { listPayments, getPaymentStats, adminRefund } from '@/api'
import { ElMessage, ElMessageBox } from 'element-plus'
const list = ref([])
const total = ref(0)
const page = ref(1)
const size = ref(20)
const loading = ref(false)
const stats = ref({})
const filters = reactive({ status: '', pay_type: '', user_id: '' })
const refundDialog = reactive({ visible: false, loading: false, order_no: '', amount: 0, reason: '' })
onMounted(() => {
loadStats()
loadData()
})
async function loadStats() {
try {
const res = await getPaymentStats()
stats.value = res.data || res
} catch { /* ignore */ }
}
async function loadData() {
loading.value = true
try {
const params = { page: page.value, size: size.value }
if (filters.status) params.status = filters.status
if (filters.pay_type) params.pay_type = filters.pay_type
if (filters.user_id) params.user_id = filters.user_id
const res = await listPayments(params)
const d = res.data || res
list.value = d.items || []
total.value = d.total || 0
} catch { /* ignore */ }
finally { loading.value = false }
}
function handleRefund(row) {
refundDialog.order_no = row.order_no
refundDialog.amount = row.amount
refundDialog.reason = ''
refundDialog.visible = true
}
async function confirmRefund() {
refundDialog.loading = true
try {
await adminRefund(refundDialog.order_no, refundDialog.reason)
ElMessage.success('退款成功')
refundDialog.visible = false
loadData()
loadStats()
} catch (e) {
ElMessage.error(e?.detail || '退款失败')
} finally {
refundDialog.loading = false
}
}
</script>
<style scoped>
.stat-item { text-align: center; padding: 8px 0; }
.stat-label { font-size: 13px; color: #999; margin-bottom: 4px; }
.stat-value { font-size: 24px; font-weight: 700; color: #1890ff; }
</style>