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:
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user