feat: WeChat Pay integration, translation quota management, login UX fixes

- WeChat Pay APIv3 integration (JSAPI + Native) with cert-based auth
- TranslationQuota model + admin management UI (配额 tab)
- Alibaba MT provider now checks quota before translation
- Fix: admin tabs scrollable on mobile, remove header-card
- Fix: profile/login navigation - logout stays on profile, login returns to profile
- Fix: login form now visible by default (no extra click to show)
- Fix: home page translate link uses navigateTo (was switchTab to non-tabBar page)
- Add .coverage and apiclient_key.pem to gitignore
This commit is contained in:
TradeMate Dev
2026-05-20 18:30:12 +08:00
parent a60aac4638
commit c397740748
22 changed files with 828 additions and 35 deletions
+108 -12
View File
@@ -1,17 +1,13 @@
<template>
<view class="admin-container">
<view class="header-card">
<text class="title">管理后台</text>
<text class="subtitle">系统管理与监控</text>
</view>
<view class="tabs">
<scroll-view class="tabs" scroll-x>
<view class="tab" :class="{ active: tab === 'overview' }" @click="tab = 'overview'">概览</view>
<view class="tab" :class="{ active: tab === 'users' }" @click="tab = 'users'">用户</view>
<view class="tab" :class="{ active: tab === 'stats' }" @click="tab = 'stats'">统计</view>
<view class="tab" :class="{ active: tab === 'logs' }" @click="tab = 'logs'">日志</view>
<view class="tab" :class="{ active: tab === 'config' }" @click="tab = 'config'">配置</view>
</view>
<view class="tab" :class="{ active: tab === 'quota' }" @click="tab = 'quota'">翻译配额</view>
</scroll-view>
<!-- 概览 -->
<view v-if="tab === 'overview'">
@@ -231,6 +227,45 @@
<text v-if="!configList.length" class="empty-text">暂无配置</text>
</view>
<!-- 翻译配额 -->
<view v-if="tab === 'quota'">
<view class="section">
<view class="section-header">
<text class="section-title">翻译 API 配额管理</text>
<text class="section-count">月配额每月自动重置</text>
</view>
<view class="quota-list" v-if="quotas.length">
<view class="quota-card" v-for="q in quotas" :key="q.version">
<view class="quota-header">
<text class="quota-version">{{ q.version === 'ecommerce' ? '电商版' : '通用版' }}</text>
<text class="quota-desc">{{ q.description }}</text>
</view>
<view class="quota-stat">
<text class="quota-label">已用 ({{ q.current_month }})</text>
<view class="quota-bar-track">
<view class="quota-bar-fill" :style="{ width: quotaPercent(q) }"></view>
</view>
<text class="quota-value">{{ q.used_chars }} / {{ q.monthly_limit }}</text>
</view>
<view class="quota-actions">
<view class="quota-field">
<text class="quota-field-label">月限额</text>
<input class="quota-input" type="number" :value="q.monthly_limit"
@blur="e => onQuotaEdit(q.version, 'monthly_limit', e)" />
</view>
<view class="quota-field">
<text class="quota-field-label">启用</text>
<switch :checked="q.enabled" @change="e => onQuotaEdit(q.version, 'enabled', e)" />
</view>
<text class="quota-reset-btn" @click="resetQuota(q.version)">重置用量</text>
<text class="quota-save-btn" @click="saveQuota(q.version)">保存</text>
</view>
</view>
</view>
<text v-else class="empty-text">暂无配额数据</text>
</view>
</view>
<!-- 用户详情弹窗 -->
<view class="modal-mask" v-if="userDetail" @click="userDetail = null">
<view class="modal-content" @click.stop>
@@ -287,6 +322,8 @@ const logFilter = ref({ action: '', user_id: '', date_from: '', date_to: '' })
const configList = ref([])
const configEdits = ref({})
const quotas = ref([])
const quotaEdits = ref({})
const configLabels = {
ai_provider_translate: '翻译 AI 模型',
@@ -490,20 +527,63 @@ const saveConfig = async (key) => {
}
}
const loadQuotas = async () => {
try {
quotas.value = await adminApi.getTranslationQuotas()
} catch (err) {
uni.showToast({ title: err.message || '加载失败', icon: 'none' })
}
}
const quotaPercent = (q) => {
if (!q.monthly_limit) return '0%'
return Math.min((q.used_chars / q.monthly_limit) * 100, 100) + '%'
}
const onQuotaEdit = (version, field, e) => {
if (!quotaEdits.value[version]) quotaEdits.value[version] = {}
const val = e.detail ? e.detail.value : e
quotaEdits.value[version][field] = field === 'monthly_limit' ? Number(val) : !!val
}
const saveQuota = async (version) => {
const edit = quotaEdits.value[version]
if (!edit) {
uni.showToast({ title: '无改动', icon: 'none' })
return
}
try {
await adminApi.updateTranslationQuota(version, edit)
delete quotaEdits.value[version]
uni.showToast({ title: '已保存', icon: 'success' })
loadQuotas()
} catch (err) {
uni.showToast({ title: err.message || '保存失败', icon: 'none' })
}
}
const resetQuota = async (version) => {
try {
await adminApi.resetTranslationQuota(version)
uni.showToast({ title: '已重置', icon: 'success' })
loadQuotas()
} catch (err) {
uni.showToast({ title: err.message || '重置失败', icon: 'none' })
}
}
watch(tab, (val) => {
if (val === 'stats') loadUsageStats()
else if (val === 'logs') { logPage.value = 1; loadLogs() }
else if (val === 'config') loadConfig()
else if (val === 'quota') loadQuotas()
})
</script>
<style lang="scss" scoped>
.admin-container { min-height: 100vh; background: #f5f5f5; padding: 20rpx; }
.header-card { background: linear-gradient(135deg, #667eea, #764ba2); border-radius: 16rpx; padding: 40rpx; margin-bottom: 30rpx; }
.header-card .title { font-size: 40rpx; font-weight: 700; color: #fff; display: block; }
.header-card .subtitle { font-size: 26rpx; color: rgba(255,255,255,0.8); margin-top: 8rpx; }
.tabs { display: flex; background: #fff; border-radius: 16rpx; overflow: hidden; margin-bottom: 30rpx; }
.tab { flex: 1; text-align: center; padding: 20rpx 0; font-size: 26rpx; color: #666; font-weight: 500; }
.tabs { width: 100%; background: #fff; border-radius: 16rpx; margin-bottom: 30rpx; white-space: nowrap; }
.tab { display: inline-block; text-align: center; padding: 20rpx 28rpx; font-size: 26rpx; color: #666; font-weight: 500; }
.tab.active { color: #667eea; border-bottom: 4rpx solid #667eea; }
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20rpx; margin-bottom: 30rpx; }
.stat-card { background: #fff; border-radius: 16rpx; padding: 30rpx; text-align: center; }
@@ -583,4 +663,20 @@ watch(tab, (val) => {
.divider { height: 1rpx; background: #f0f0f0; margin: 16rpx 0; }
.text-green { color: #52c41a; }
.text-red { color: #f5222d; }
.quota-list { display: flex; flex-direction: column; gap: 20rpx; }
.quota-card { padding: 24rpx; background: #f9f9f9; border-radius: 12rpx; }
.quota-header { margin-bottom: 16rpx; }
.quota-version { font-size: 28rpx; font-weight: 600; }
.quota-desc { font-size: 22rpx; color: #999; margin-left: 12rpx; }
.quota-stat { display: flex; align-items: center; gap: 12rpx; margin-bottom: 16rpx; }
.quota-label { font-size: 22rpx; color: #666; width: 120rpx; flex-shrink: 0; }
.quota-bar-track { flex: 1; height: 24rpx; background: #f0f0f0; border-radius: 12rpx; overflow: hidden; }
.quota-bar-fill { height: 100%; background: linear-gradient(90deg, #1890ff, #52c41a); border-radius: 12rpx; transition: width 0.3s; }
.quota-value { font-size: 22rpx; color: #333; width: 200rpx; text-align: right; flex-shrink: 0; }
.quota-actions { display: flex; align-items: center; gap: 16rpx; flex-wrap: wrap; }
.quota-field { display: flex; align-items: center; gap: 8rpx; }
.quota-field-label { font-size: 22rpx; color: #666; }
.quota-input { width: 140rpx; height: 56rpx; background: #fff; border: 1rpx solid #e0e0e0; border-radius: 8rpx; padding: 0 12rpx; font-size: 24rpx; text-align: center; }
.quota-reset-btn { font-size: 22rpx; padding: 6rpx 16rpx; background: #fff7e6; color: #fa8c16; border-radius: 6rpx; }
.quota-save-btn { font-size: 22rpx; padding: 6rpx 16rpx; background: #52c41a; color: #fff; border-radius: 6rpx; }
</style>
+2 -2
View File
@@ -427,7 +427,7 @@ const loadData = async () => {
}
}
const tabbarPages = [PAGES.INDEX, PAGES.TRANSLATE, PAGES.CUSTOMERS, PAGES.MARKETING, PAGES.QUOTATION]
const tabbarPages = [PAGES.INDEX, PAGES.CUSTOMERS, PAGES.MARKETING, PAGES.QUOTATION]
const goToPage = (url) => {
if (tabbarPages.includes(url)) {
@@ -443,7 +443,7 @@ const goToLogin = () => {
content: '请先登录',
success: (res) => {
if (res.confirm) {
uni.reLaunch({ url: PAGES.LOGIN })
uni.navigateTo({ url: PAGES.LOGIN })
}
},
})
+11 -2
View File
@@ -125,7 +125,7 @@ const isRegister = ref(false)
const loading = ref(false)
const silentLoading = ref(true)
const error = ref('')
const showForm = ref(false)
const showForm = ref(true)
const isWechatAvailable = ref(false)
const doWechatLogin = async (code) => {
@@ -225,7 +225,7 @@ const handleSubmit = async () => {
uni.setStorageSync(STORAGE_KEYS.USER_INFO, res.user)
uni.setStorageSync(STORAGE_KEYS.HAS_LOGIN, true)
uni.setStorageSync(STORAGE_KEYS.IS_GUEST, false)
uni.reLaunch({ url: PAGES.INDEX })
afterLogin()
}
} catch (err) {
console.error('登录失败', err)
@@ -238,6 +238,15 @@ const handleSubmit = async () => {
}
}
const afterLogin = () => {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
} else {
uni.switchTab({ url: PAGES.PROFILE })
}
}
const handleWechatLogin = () => {
uni.login({
provider: 'weixin',
+12 -3
View File
@@ -103,6 +103,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { authApi } from '@/utils/api.js'
import AiAssistant from '@/components/ai-assistant.vue'
import { STORAGE_KEYS, PAGES, TIER_LABELS } from '@/config.js'
@@ -121,11 +122,18 @@ const initials = computed(() => {
const tierLabel = computed(() => TIER_LABELS[user.value.tier] || '免费版')
const loadUser = async () => {
const token = uni.getStorageSync(STORAGE_KEYS.TOKEN)
if (!token) {
user.value = { tier: 'guest' }
return
}
try {
const res = await authApi.getUserInfo()
user.value = res
editForm.value = { username: res.username || '', email: res.email || '' }
} catch {}
} catch {
user.value = { tier: 'guest' }
}
}
const saveProfile = async () => {
@@ -162,7 +170,7 @@ const changePwd = async () => {
}
}
const goLogin = () => uni.reLaunch({ url: PAGES.LOGIN })
const goLogin = () => uni.navigateTo({ url: PAGES.LOGIN })
const goUpgrade = () => uni.navigateTo({ url: PAGES.UPGRADE })
const goFeedback = () => uni.navigateTo({ url: PAGES.FEEDBACK })
const goAgreement = (type) => uni.navigateTo({ url: `/pages/agreement/${type}` })
@@ -175,13 +183,14 @@ const logout = () => {
if (res.confirm) {
uni.removeStorageSync(STORAGE_KEYS.TOKEN)
uni.removeStorageSync(STORAGE_KEYS.REFRESH_TOKEN)
uni.reLaunch({ url: PAGES.LOGIN })
user.value = { tier: 'guest' }
}
},
})
}
onMounted(loadUser)
onShow(loadUser)
</script>
<style scoped>
+32 -2
View File
@@ -33,6 +33,16 @@
>
{{ loading ? '处理中...' : (selected === currentPlan ? '当前方案' : '立即升级') }}
</button>
<!-- H5 Native 支付显示二维码 -->
<view class="qr-modal" v-if="showQr">
<view class="qr-box">
<text class="qr-title">请使用微信扫码支付</text>
<image class="qr-img" :src="qrCodeUrl" mode="widthFix" />
<text class="qr-hint">打开微信扫一扫完成支付</text>
<text class="qr-close" @click="showQr = false">关闭</text>
</view>
</view>
</view>
</template>
@@ -45,6 +55,8 @@ const plans = ref([])
const currentPlan = ref('free')
const selected = ref('')
const loading = ref(false)
const showQr = ref(false)
const qrCodeUrl = ref('')
onShow(async () => {
try {
@@ -66,13 +78,21 @@ const handleUpgrade = async () => {
if (!selected.value || selected.value === currentPlan.value) return
loading.value = true
try {
const res = await paymentApi.createOrder(selected.value)
// #ifdef MP-WEIXIN
const payType = 'jsapi'
// #endif
// #ifdef H5
const payType = 'native'
// #endif
const res = await paymentApi.createOrder(selected.value, payType)
if (res.amount === 0) {
uni.showToast({ title: '已切换为免费版', icon: 'success' })
currentPlan.value = selected.value
return
}
if (res.pay_params) {
if (res.pay_type === 'jsapi' && res.pay_params) {
uni.requestPayment({
provider: 'wxpay',
...res.pay_params,
@@ -84,6 +104,9 @@ const handleUpgrade = async () => {
uni.showToast({ title: err.errMsg || '支付失败', icon: 'none' })
},
})
} else if (res.pay_type === 'native' && res.code_url) {
qrCodeUrl.value = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(res.code_url)}`
showQr.value = true
}
} catch (err) {
uni.showToast({ title: err.message || '操作失败', icon: 'none' })
@@ -111,4 +134,11 @@ const handleUpgrade = async () => {
.plan-badge { position: absolute; top: 16rpx; right: 16rpx; font-size: 22rpx; color: #52c41a; background: #f6ffed; padding: 4rpx 12rpx; border-radius: 6rpx; }
.upgrade-btn { width: 100%; height: 96rpx; background: linear-gradient(135deg, #1890ff, #096dd9); color: #fff; border: none; border-radius: 16rpx; font-size: 32rpx; font-weight: 500; }
.upgrade-btn[disabled] { background: #a0cfff; }
.qr-modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); z-index: 999; display: flex; align-items: center; justify-content: center; }
.qr-box { background: #fff; border-radius: 20rpx; padding: 50rpx; text-align: center; width: 500rpx; }
.qr-title { font-size: 30rpx; font-weight: 600; color: #333; margin-bottom: 30rpx; display: block; }
.qr-img { width: 300rpx; height: 300rpx; display: block; margin: 0 auto 30rpx; }
.qr-hint { font-size: 24rpx; color: #999; display: block; margin-bottom: 20rpx; }
.qr-close { font-size: 26rpx; color: #1890ff; display: block; }
</style>
+7 -1
View File
@@ -178,6 +178,11 @@ export const adminApi = {
},
getConfig: () => request('/admin/config'),
updateConfig: (key, value) => request(`/admin/config/${key}`, 'PUT', { value }),
getTranslationQuotas: () => request('/admin/translation-quotas'),
updateTranslationQuota: (version, data) =>
request(`/admin/translation-quotas/${encodeURIComponent(version)}`, 'PUT', data),
resetTranslationQuota: (version) =>
request(`/admin/translation-quotas/${encodeURIComponent(version)}/reset`, 'POST'),
}
export const aiChatApi = {
@@ -230,7 +235,8 @@ export const notificationApi = {
export const paymentApi = {
plans: () => request('/payment/plans'),
subscription: () => request('/payment/subscription'),
createOrder: (plan) => request('/payment/create-order', 'POST', { plan }),
createOrder: (plan, payType = 'jsapi') =>
request('/payment/create-order', 'POST', { plan, pay_type: payType }),
}
export const feedbackApi = {