Add admin-frontend and user-frontend standalone projects, certification/invoice/discovery features, fix auth header and theme consistency
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #1890ff;
|
||||
--el-color-primary-light-3: #40a9ff;
|
||||
--el-color-primary-light-5: #69c0ff;
|
||||
--el-color-primary-light-7: #91d5ff;
|
||||
--el-color-primary-light-8: #bae7ff;
|
||||
--el-color-primary-light-9: #e6f7ff;
|
||||
--el-color-primary-dark-2: #096dd9;
|
||||
--el-color-success: #52c41a;
|
||||
--el-color-warning: #faad14;
|
||||
--el-color-danger: #ff4d4f;
|
||||
--el-color-info: #909399;
|
||||
}
|
||||
.el-button--primary { --el-button-bg-color: #1890ff; --el-button-border-color: #1890ff; --el-button-hover-bg-color: #40a9ff; --el-button-hover-border-color: #40a9ff; --el-button-active-bg-color: #096dd9; --el-button-active-border-color: #096dd9; }
|
||||
.el-tag--primary { --el-tag-bg-color: #e6f7ff; --el-tag-border-color: #91d5ff; --el-tag-text-color: #1890ff; }
|
||||
a { color: #1890ff; }
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const http = axios.create({ baseURL: '/api/v1', timeout: 30000 })
|
||||
|
||||
http.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
res => res.data,
|
||||
err => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('admin_token')
|
||||
localStorage.removeItem('admin_user')
|
||||
const currentPath = window.location.pathname.replace('/admin', '') || '/'
|
||||
window.location.href = '/admin/login?redirect=' + encodeURIComponent(currentPath)
|
||||
}
|
||||
return Promise.reject(err.response?.data || err)
|
||||
}
|
||||
)
|
||||
|
||||
export function login(data) { return http.post('/auth/login', data) }
|
||||
|
||||
export function getDashboard() { return http.get('/admin/dashboard') }
|
||||
|
||||
export function searchUsers(query) { return http.post('/admin/users/search', { query }) }
|
||||
export function listUsers(page = 1, size = 20) { return http.get('/admin/users', { params: { page, size } }) }
|
||||
export function getUserDetail(id) { return http.get(`/admin/users/${id}`) }
|
||||
export function updateUser(id, data) { return http.put(`/admin/users/${id}`, data) }
|
||||
|
||||
export function getUsageStats() { return http.get('/admin/stats/usage') }
|
||||
|
||||
export function listLogs(params) { return http.get('/admin/logs', { params }) }
|
||||
|
||||
export function listConfig() { return http.get('/admin/config') }
|
||||
export function updateConfig(key, value) { return http.put(`/admin/config/${key}`, { value }) }
|
||||
|
||||
export function listQuotas() { return http.get('/admin/quotas') }
|
||||
export function updateQuota(version, data) { return http.put(`/admin/quotas/${version}`, data) }
|
||||
export function resetQuota(version) { return http.post(`/admin/quotas/${version}/reset`) }
|
||||
|
||||
export function listCertifications(page = 1, size = 50, status = '') {
|
||||
return http.get('/admin/certifications', { params: { page, size, status: status || undefined } })
|
||||
}
|
||||
export function reviewCertification(id, action, reason = '') {
|
||||
return http.post(`/admin/certifications/${id}/review`, { action, reason })
|
||||
}
|
||||
|
||||
export function listInvoices(page = 1, size = 50, status = '') {
|
||||
return http.get('/admin/invoices', { params: { page, size, status: status || undefined } })
|
||||
}
|
||||
export function processInvoice(id, action) {
|
||||
return http.post(`/admin/invoices/${id}/process`, { action })
|
||||
}
|
||||
|
||||
export default http
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<el-container class="layout-container">
|
||||
<el-aside :width="collapsed ? '64px' : '220px'" class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<span v-show="!collapsed" class="logo-text">TradeMate</span>
|
||||
<span v-show="collapsed" class="logo-text logo-sm">TM</span>
|
||||
</div>
|
||||
<el-menu
|
||||
:default-active="route.path"
|
||||
:collapse="collapsed"
|
||||
router
|
||||
>
|
||||
<el-menu-item index="/dashboard">
|
||||
<el-icon><Odometer /></el-icon>
|
||||
<span>概览</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/users">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>用户</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/stats">
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
<span>统计</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/logs">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>日志</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/config">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>配置</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/quota">
|
||||
<el-icon><Coin /></el-icon>
|
||||
<span>翻译配额</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/certifications">
|
||||
<el-icon><Stamp /></el-icon>
|
||||
<span>认证审核</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/invoices">
|
||||
<el-icon><List /></el-icon>
|
||||
<span>发票管理</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<el-header class="header">
|
||||
<div class="header-left">
|
||||
<el-icon class="collapse-btn" @click="collapsed = !collapsed">
|
||||
<Fold v-if="!collapsed" />
|
||||
<Expand v-else />
|
||||
</el-icon>
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item>{{ route.meta?.title }}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-dropdown trigger="click">
|
||||
<span class="user-info">
|
||||
<el-avatar :size="32" icon="UserFilled" style="background:#1890ff" />
|
||||
<span class="user-name">{{ auth.user?.username || '管理员' }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-item @click="auth.logout(); router.push('/login')">
|
||||
<el-icon><SwitchButton /></el-icon>退出登录
|
||||
</el-dropdown-item>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-main class="main-content">
|
||||
<router-view />
|
||||
</el-main>
|
||||
|
||||
<el-footer class="footer">
|
||||
<span>TradeMate 外贸小助手 © {{ new Date().getFullYear() }}</span>
|
||||
</el-footer>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const collapsed = ref(false)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-container { height: 100vh; }
|
||||
.sidebar { background: #fff; border-right: 1px solid #e8e8e8; transition: width 0.3s; overflow: hidden; }
|
||||
.sidebar-header { height: 60px; display: flex; align-items: center; justify-content: center; border-bottom: 1px solid #f0f0f0; }
|
||||
.logo-text { color: #1890ff; font-size: 18px; font-weight: 700; white-space: nowrap; }
|
||||
.logo-sm { font-size: 16px; }
|
||||
.sidebar :deep(.el-menu) { border-right: none; }
|
||||
.sidebar :deep(.el-menu-item) { margin: 2px 8px; border-radius: 8px; }
|
||||
.sidebar :deep(.el-menu-item.is-active) { background: #e6f7ff; color: #1890ff !important; font-weight: 500; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; background: #fff; border-bottom: 1px solid #e8e8e8; padding: 0 20px; height: 60px; }
|
||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||
.collapse-btn { font-size: 20px; cursor: pointer; color: #666; }
|
||||
.header-right { display: flex; align-items: center; }
|
||||
.user-info { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
||||
.user-name { font-size: 14px; color: #333; }
|
||||
.main-content { background: #f5f5f5; padding: 20px; overflow-y: auto; }
|
||||
.footer { display: flex; align-items: center; justify-content: center; height: 48px; background: #fff; border-top: 1px solid #e8e8e8; color: #999; font-size: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
for (const [k, v] of Object.entries(ElementPlusIconsVue)) app.component(k, v)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/login', redirect: '/' },
|
||||
{ path: '/', name: 'Landing', component: () => import('@/views/Landing.vue') },
|
||||
{
|
||||
path: '/dashboard',
|
||||
component: AdminLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{ path: '', name: 'Dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '概览' } },
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
component: AdminLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{ path: '', name: 'Users', component: () => import('@/views/Users.vue'), meta: { title: '用户' } },
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/stats',
|
||||
component: AdminLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{ path: '', name: 'Stats', component: () => import('@/views/Stats.vue'), meta: { title: '统计' } },
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/logs',
|
||||
component: AdminLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{ path: '', name: 'Logs', component: () => import('@/views/Logs.vue'), meta: { title: '日志' } },
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/config',
|
||||
component: AdminLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{ path: '', name: 'Config', component: () => import('@/views/Config.vue'), meta: { title: '配置' } },
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/quota',
|
||||
component: AdminLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{ path: '', name: 'Quota', component: () => import('@/views/Quota.vue'), meta: { title: '翻译配额' } },
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/certifications',
|
||||
component: AdminLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{ path: '', name: 'Certifications', component: () => import('@/views/Certifications.vue'), meta: { title: '认证审核' } },
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/invoices',
|
||||
component: AdminLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{ path: '', name: 'Invoices', component: () => import('@/views/Invoices.vue'), meta: { title: '发票管理' } },
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({ history: createWebHistory('/admin/'), routes })
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (to.meta?.requiresAuth) {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (!token) next({ name: 'Login', query: { redirect: to.fullPath } })
|
||||
else next()
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { login as loginApi } from '@/api'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref(localStorage.getItem('admin_token') || '')
|
||||
const user = ref(JSON.parse(localStorage.getItem('admin_user') || 'null'))
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
|
||||
async function login(credentials) {
|
||||
const res = await loginApi(credentials)
|
||||
token.value = res.access_token
|
||||
user.value = res.user || {}
|
||||
localStorage.setItem('admin_token', res.access_token)
|
||||
localStorage.setItem('admin_user', JSON.stringify(res.user || {}))
|
||||
return res
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = ''
|
||||
user.value = null
|
||||
localStorage.removeItem('admin_token')
|
||||
localStorage.removeItem('admin_user')
|
||||
}
|
||||
|
||||
return { token, user, isLoggedIn, login, logout }
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="header-bar">
|
||||
<span>实名认证审核</span>
|
||||
<div class="filter-group">
|
||||
<el-radio-group v-model="statusFilter" @change="page=1;load()">
|
||||
<el-radio-button value="">全部</el-radio-button>
|
||||
<el-radio-button value="pending">待审核</el-radio-button>
|
||||
<el-radio-button value="approved">已通过</el-radio-button>
|
||||
<el-radio-button value="rejected">已驳回</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="list" v-loading="loading" stripe style="width:100%">
|
||||
<el-table-column label="类型" width="100">
|
||||
<template #default="{ row }">{{ row.cert_type === 'individual' ? '个人认证' : '企业认证' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="姓名" width="120">
|
||||
<template #default="{ row }">{{ row.personal_name || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="企业名称">
|
||||
<template #default="{ row }">{{ row.company_name || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="税号" width="140">
|
||||
<template #default="{ row }">{{ row.tax_id || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="用户" width="100">
|
||||
<template #default="{ row }">{{ row.user_id?.substring(0,8) }}...</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="{ pending: 'warning', approved: 'success', rejected: 'danger' }[row.status]" size="small">
|
||||
{{ { pending: '待审核', approved: '已通过', rejected: '已驳回' }[row.status] }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="申请时间" width="160" :formatter="fmtDate" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 'pending'">
|
||||
<el-button type="success" size="small" @click="review(row.id, 'approve')">通过</el-button>
|
||||
<el-button type="danger" size="small" @click="openReject(row.id)">驳回</el-button>
|
||||
</template>
|
||||
<span v-else-if="row.reject_reason" style="font-size:12px;color:#f56c6c;">原因:{{ row.reject_reason }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-wrap" v-if="total > pageSize">
|
||||
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev,pager,next" @current-change="load" background />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="rejectDialog" title="驳回原因" width="400px">
|
||||
<el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请输入驳回原因" />
|
||||
<template #footer>
|
||||
<el-button @click="rejectDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmReject">确认驳回</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listCertifications, reviewCertification } from '@/api'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const statusFilter = ref('pending')
|
||||
const rejectDialog = ref(false)
|
||||
const rejectId = ref('')
|
||||
const rejectReason = ref('')
|
||||
|
||||
function fmtDate(_, __, val) { return val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-' }
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await listCertifications(page.value, pageSize, statusFilter.value)
|
||||
list.value = r.items || []
|
||||
total.value = r.total || 0
|
||||
} catch (e) { console.error(e) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function review(id, action, reason = '') {
|
||||
try {
|
||||
await reviewCertification(id, action, reason)
|
||||
ElMessage.success(action === 'approve' ? '已通过' : '已驳回')
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e?.detail || '操作失败') }
|
||||
}
|
||||
|
||||
function openReject(id) { rejectId.value = id; rejectReason.value = ''; rejectDialog.value = true }
|
||||
|
||||
async function confirmReject() {
|
||||
if (!rejectReason.value) { ElMessage.warning('请输入驳回原因'); return }
|
||||
rejectDialog.value = false
|
||||
await review(rejectId.value, 'reject', rejectReason.value)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-bar { display: flex; justify-content: space-between; align-items: center; }
|
||||
.filter-group { }
|
||||
.pagination-wrap { margin-top: 20px; display: flex; justify-content: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card v-for="cfg in configs" :key="cfg.key" shadow="never" class="cfg-card">
|
||||
<template #header>
|
||||
<div class="cfg-header">
|
||||
<span>{{ configLabels[cfg.key] || cfg.key }}</span>
|
||||
<el-tag size="small" v-if="cfg.description">{{ cfg.description }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="typeof cfg.value === 'object' && !Array.isArray(cfg.value)">
|
||||
<div class="cfg-field" v-for="(v, k) in cfg.value" :key="k">
|
||||
<span class="cfg-label">{{ fieldLabel(cfg.key, k) }}</span>
|
||||
<el-input v-if="typeof v === 'string'" v-model="edits[cfg.key][k]" size="small" style="width:300px" />
|
||||
<el-input-number v-else-if="typeof v === 'number'" v-model="edits[cfg.key][k]" size="small" />
|
||||
<el-switch v-else-if="typeof v === 'boolean'" v-model="edits[cfg.key][k]" />
|
||||
<el-input v-else v-model="edits[cfg.key][k]" type="textarea" :rows="2" size="small" style="width:400px" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="typeof cfg.value === 'boolean'">
|
||||
<el-switch v-model="edits[cfg.key]" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-input v-model="edits[cfg.key]" type="textarea" :rows="3" style="max-width:600px" />
|
||||
</div>
|
||||
<div class="cfg-actions">
|
||||
<el-button type="success" size="small" @click="save(cfg.key)">保存</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-empty v-if="!configs.length" description="暂无配置" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listConfig, updateConfig } from '@/api'
|
||||
|
||||
const configs = ref([])
|
||||
const edits = reactive({})
|
||||
const configLabels = { system_maintenance: '系统维护', feature_flags: '功能开关', translation_providers: '翻译服务', ai_model_config: 'AI模型', cache_ttl: '缓存时间' }
|
||||
const fieldLabelsMap = { system_maintenance: { maintenance_mode: '维护模式', maintenance_message: '维护消息' }, feature_flags: { feature_wechat_login: '微信登录', feature_export: '数据导出' }, translation_providers: { primary: '首选服务', fallback: '备用服务' }, ai_model_config: { default_model: '默认模型', max_tokens: '最大Token' } }
|
||||
function fieldLabel(key, k) { return fieldLabelsMap[key]?.[k] || k }
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r = await listConfig()
|
||||
configs.value = r.items || r || []
|
||||
for (const cfg of configs.value) {
|
||||
edits[cfg.key] = JSON.parse(JSON.stringify(cfg.value))
|
||||
}
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function save(key) {
|
||||
try {
|
||||
await updateConfig(key, edits[key])
|
||||
ElMessage.success('已保存')
|
||||
} catch (e) { ElMessage.error(e?.detail || '保存失败') }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cfg-card { margin-bottom: 16px; }
|
||||
.cfg-header { display: flex; align-items: center; gap: 12px; font-weight: 600; }
|
||||
.cfg-field { display: flex; align-items: center; gap: 12px; padding: 8px 0; }
|
||||
.cfg-label { width: 160px; font-size: 13px; color: #666; flex-shrink: 0; }
|
||||
.cfg-actions { margin-top: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6" v-for="item in stats" :key="item.label">
|
||||
<el-card shadow="hover" class="stat-card" @click="handleStatClick(item)">
|
||||
<div class="stat-value">{{ item.value }}</div>
|
||||
<div class="stat-label">{{ item.label }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card class="section-card" shadow="never">
|
||||
<template #header><span>最近注册用户</span></template>
|
||||
<el-table :data="recentUsers" v-loading="loading" stripe style="width:100%">
|
||||
<el-table-column prop="username" label="用户名" />
|
||||
<el-table-column prop="email" label="邮箱" />
|
||||
<el-table-column prop="tier" label="套餐" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="tierType(row.tier)" size="small">{{ row.tier }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="role" label="角色" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.role === 'admin' ? 'danger' : 'info'" size="small">{{ row.role }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="注册时间" width="180" :formatter="fmtDate" />
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="showUser(row.id)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!loading && !recentUsers.length" description="暂无数据" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getDashboard } from '@/api'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const stats = ref([])
|
||||
const recentUsers = ref([])
|
||||
|
||||
function tierType(t) { return { free: 'warning', pro: 'primary', enterprise: 'success' }[t] || 'info' }
|
||||
function fmtDate(_, __, val) { return val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-' }
|
||||
function handleStatClick(item) { if (item.route) router.push(item.route) }
|
||||
function showUser(id) { router.push({ path: '/users', query: { detail: id } }) }
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getDashboard()
|
||||
const d = res.data || res
|
||||
stats.value = [
|
||||
{ value: d.users?.total || 0, label: '用户总数', route: '/users' },
|
||||
{ value: d.teams?.total || 0, label: '团队数' },
|
||||
{ value: d.customers?.total || 0, label: '客户总数' },
|
||||
{ value: d.usage?.today || 0, label: '今日请求', route: '/stats' },
|
||||
]
|
||||
recentUsers.value = d.recent_users || []
|
||||
} catch (e) { console.error(e) }
|
||||
finally { loading.value = false }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card { cursor: pointer; text-align: center; margin-bottom: 20px; }
|
||||
.stat-value { font-size: 32px; font-weight: 700; color: #409eff; }
|
||||
.stat-label { font-size: 14px; color: #999; margin-top: 4px; }
|
||||
.section-card { margin-top: 10px; }
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="header-bar">
|
||||
<span>发票管理</span>
|
||||
<div class="filter-group">
|
||||
<el-radio-group v-model="statusFilter" @change="page=1;load()">
|
||||
<el-radio-button value="">全部</el-radio-button>
|
||||
<el-radio-button value="pending">待开票</el-radio-button>
|
||||
<el-radio-button value="issued">已开票</el-radio-button>
|
||||
<el-radio-button value="rejected">已驳回</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="list" v-loading="loading" stripe style="width:100%">
|
||||
<el-table-column label="发票类型" width="100">
|
||||
<template #default="{ row }">{{ row.invoice_type === 'individual' ? '个人' : '企业' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="发票抬头" width="200" />
|
||||
<el-table-column prop="tax_id" label="税号" width="140" />
|
||||
<el-table-column label="金额" width="120">
|
||||
<template #default="{ row }">¥{{ (row.amount || 0).toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="用户" width="100">
|
||||
<template #default="{ row }">{{ row.user_id?.substring(0,8) }}...</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="{ pending: 'warning', issued: 'success', rejected: 'danger' }[row.status]" size="small">
|
||||
{{ { pending: '待开票', issued: '已开票', rejected: '已驳回' }[row.status] }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="申请时间" width="160" :formatter="fmtDate" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 'pending'">
|
||||
<el-button type="success" size="small" @click="process(row.id, 'issue')">已开票</el-button>
|
||||
<el-button type="danger" size="small" @click="openReject(row.id)">驳回</el-button>
|
||||
</template>
|
||||
<span v-else-if="row.reject_reason" style="font-size:12px;color:#f56c6c;">原因:{{ row.reject_reason }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-wrap" v-if="total > pageSize">
|
||||
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev,pager,next" @current-change="load" background />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="rejectDialog" title="驳回原因" width="400px">
|
||||
<el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请输入驳回原因" />
|
||||
<template #footer>
|
||||
<el-button @click="rejectDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmReject">确认驳回</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listInvoices, processInvoice } from '@/api'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const statusFilter = ref('pending')
|
||||
const rejectDialog = ref(false)
|
||||
const rejectId = ref('')
|
||||
const rejectReason = ref('')
|
||||
|
||||
function fmtDate(_, __, val) { return val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-' }
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await listInvoices(page.value, pageSize, statusFilter.value)
|
||||
list.value = r.items || []
|
||||
total.value = r.total || 0
|
||||
} catch (e) { console.error(e) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function process(id, action) {
|
||||
try {
|
||||
await processInvoice(id, action)
|
||||
ElMessage.success(action === 'issue' ? '已开票' : '已驳回')
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e?.detail || '操作失败') }
|
||||
}
|
||||
|
||||
function openReject(id) { rejectId.value = id; rejectReason.value = ''; rejectDialog.value = true }
|
||||
|
||||
async function confirmReject() {
|
||||
if (!rejectReason.value) { ElMessage.warning('请输入驳回原因'); return }
|
||||
rejectDialog.value = false
|
||||
await process(rejectId.value, 'reject')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-bar { display: flex; justify-content: space-between; align-items: center; }
|
||||
.pagination-wrap { margin-top: 20px; display: flex; justify-content: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="landing-page">
|
||||
<header class="landing-header">
|
||||
<div class="header-inner">
|
||||
<span class="logo">Trade<span>Mate</span></span>
|
||||
<span class="subtitle">管理后台</span>
|
||||
<div class="header-right">
|
||||
<el-button v-if="isLoggedIn" @click="goDashboard">进入后台</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-inner">
|
||||
<div class="hero-left">
|
||||
<h1>TradeMate 管理后台</h1>
|
||||
<p class="hero-desc">一站式管理你的外贸业务:用户、数据、配置、认证与发票</p>
|
||||
</div>
|
||||
<div class="hero-right">
|
||||
<div v-if="!isLoggedIn" class="login-card">
|
||||
<h3>管理员登录</h3>
|
||||
<el-form :model="form" size="large" @keyup.enter="submit">
|
||||
<el-form-item>
|
||||
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" show-password />
|
||||
</el-form-item>
|
||||
<p v-if="error" class="login-error">{{ error }}</p>
|
||||
<el-button type="primary" :loading="loading" style="width:100%" @click="submit">登录</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
<div v-else class="login-card logged-in">
|
||||
<el-icon :size="48" color="#67c23a"><CircleCheckFilled /></el-icon>
|
||||
<h3>已登录</h3>
|
||||
<el-button type="primary" @click="goDashboard">进入后台</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="features">
|
||||
<div class="feature-grid">
|
||||
<div v-for="f in features" :key="f.title" class="feature-card" @click="handleClick(f)">
|
||||
<div class="feature-icon" :style="{ background: f.color + '18' }">
|
||||
<el-icon :size="28" :color="f.color"><component :is="f.icon" /></el-icon>
|
||||
</div>
|
||||
<h3>{{ f.title }}</h3>
|
||||
<p>{{ f.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="landing-footer">
|
||||
<span>TradeMate 外贸小助手 © {{ new Date().getFullYear() }}</span>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { login as loginApi } from '@/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const isLoggedIn = computed(() => !!localStorage.getItem('admin_token'))
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const form = reactive({ username: '', password: '' })
|
||||
|
||||
const features = [
|
||||
{ title: '概览', desc: '用户总数、团队数、今日请求等核心数据一目了然', icon: 'Odometer', color: '#409eff', route: '/dashboard' },
|
||||
{ title: '用户管理', desc: '搜索、查看、编辑用户信息,管理套餐和权限', icon: 'User', color: '#67c23a', route: '/users' },
|
||||
{ title: '使用统计', desc: '功能调用统计、日活跃用户、近7日趋势分析', icon: 'DataAnalysis', color: '#e6a23c', route: '/stats' },
|
||||
{ title: '操作日志', desc: '查看系统操作记录,按动作、用户、日期筛选', icon: 'Document', color: '#909399', route: '/logs' },
|
||||
{ title: '系统配置', desc: '管理功能开关、翻译服务、AI模型等系统设置', icon: 'Setting', color: '#f56c6c', route: '/config' },
|
||||
{ title: '翻译配额', desc: '管理各版本翻译 API 的月配额和使用量', icon: 'Coin', color: '#409eff', route: '/quota' },
|
||||
{ title: '认证审核', desc: '审核用户的实名认证申请(个人/企业)', icon: 'Stamp', color: '#67c23a', route: '/certifications' },
|
||||
{ title: '发票管理', desc: '处理用户的开票申请,支持个人和企业发票', icon: 'List', color: '#e6a23c', route: '/invoices' },
|
||||
]
|
||||
|
||||
async function submit() {
|
||||
if (!form.username || !form.password) { error.value = '请输入用户名和密码'; return }
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await loginApi(form)
|
||||
localStorage.setItem('admin_token', res.access_token)
|
||||
localStorage.setItem('admin_user', JSON.stringify(res.user || {}))
|
||||
ElMessage.success('登录成功')
|
||||
} catch (e) {
|
||||
error.value = e?.detail || '登录失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(f) {
|
||||
if (!isLoggedIn.value) {
|
||||
error.value = '请先登录'
|
||||
} else {
|
||||
router.push(f.route)
|
||||
}
|
||||
}
|
||||
|
||||
function goDashboard() { router.push('/dashboard') }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.landing-page { min-height: 100vh; display: flex; flex-direction: column; background: #f5f5f5; }
|
||||
.landing-header { background: #fff; border-bottom: 1px solid #eee; padding: 0 40px; height: 60px; display: flex; align-items: center; position: sticky; top: 0; z-index: 100; }
|
||||
.header-inner { width: 100%; max-width: 1200px; margin: 0 auto; display: flex; align-items: center; gap: 16px; }
|
||||
.logo { font-size: 20px; font-weight: 700; color: #1890ff; }
|
||||
.logo span { color: #333; }
|
||||
.subtitle { font-size: 13px; color: #999; flex: 1; }
|
||||
.header-right { flex-shrink: 0; }
|
||||
|
||||
.hero { background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%); color: #fff; }
|
||||
.hero-inner { max-width: 1200px; margin: 0 auto; padding: 60px 20px; display: flex; gap: 48px; align-items: center; }
|
||||
.hero-left { flex: 1; }
|
||||
.hero-left h1 { font-size: 36px; margin-bottom: 16px; }
|
||||
.hero-desc { font-size: 16px; opacity: 0.85; line-height: 1.6; }
|
||||
.hero-right { flex-shrink: 0; width: 360px; }
|
||||
|
||||
.login-card { background: #fff; border-radius: 12px; padding: 32px; box-shadow: 0 8px 40px rgba(0,0,0,0.15); }
|
||||
.login-card h3 { text-align: center; margin-bottom: 24px; font-size: 18px; color: #333; }
|
||||
.login-card.logged-in { text-align: center; }
|
||||
.login-card.logged-in h3 { margin: 16px 0; }
|
||||
.login-error { color: #f56c6c; text-align: center; font-size: 13px; margin: -8px 0 12px; }
|
||||
|
||||
.features { max-width: 1200px; margin: -30px auto 40px; padding: 0 20px; }
|
||||
.feature-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
|
||||
.feature-card { background: #fff; border-radius: 12px; padding: 28px 24px; cursor: pointer; transition: all 0.25s; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
|
||||
.feature-card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0,0,0,0.1); }
|
||||
.feature-icon { width: 52px; height: 52px; border-radius: 12px; display: flex; align-items: center; justify-content: center; margin-bottom: 16px; }
|
||||
.feature-card h3 { font-size: 16px; margin-bottom: 8px; color: #333; }
|
||||
.feature-card p { font-size: 13px; color: #999; line-height: 1.5; }
|
||||
.landing-footer { text-align: center; padding: 24px; color: #999; font-size: 12px; margin-top: auto; border-top: 1px solid #e8e8e8; background: #fff; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-inner { flex-direction: column; padding: 40px 20px; }
|
||||
.hero-right { width: 100%; }
|
||||
.feature-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<h2 class="login-title">TradeMate 管理后台</h2>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" size="large" @keyup.enter="submit">
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" style="width:100%" @click="submit">登录</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<p v-if="error" class="login-error">{{ error }}</p>
|
||||
<p class="login-back"><router-link to="/">返回首页</router-link></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const formRef = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const form = reactive({ username: '', password: '' })
|
||||
const rules = { username: [{ required: true, message: '请输入用户名' }], password: [{ required: true, message: '请输入密码' }] }
|
||||
|
||||
async function submit() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await auth.login(form)
|
||||
const redirect = route.query.redirect || '/dashboard'
|
||||
router.push(redirect)
|
||||
} catch (e) {
|
||||
error.value = e?.detail || '登录失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page { height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
||||
.login-card { width: 400px; padding: 40px; background: #fff; border-radius: 12px; box-shadow: 0 8px 40px rgba(0,0,0,0.15); }
|
||||
.login-title { text-align: center; margin-bottom: 30px; font-size: 22px; color: #333; }
|
||||
.login-error { color: #f56c6c; text-align: center; font-size: 13px; margin-top: -10px; }
|
||||
.login-back { text-align: center; margin-top: 16px; font-size: 13px; }
|
||||
.login-back a { color: #409eff; text-decoration: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form :inline="true" :model="filter">
|
||||
<el-form-item label="动作">
|
||||
<el-input v-model="filter.action" placeholder="动作类型" clearable style="width:160px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用户ID">
|
||||
<el-input v-model="filter.user_id" placeholder="用户ID" clearable style="width:200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开始">
|
||||
<el-date-picker v-model="filter.date_from" type="date" placeholder="开始日期" value-format="YYYY-MM-DD" style="width:140px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="结束">
|
||||
<el-date-picker v-model="filter.date_to" type="date" placeholder="结束日期" value-format="YYYY-MM-DD" style="width:140px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="page = 1; load()">筛选</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<template #header><span>操作日志 (共 {{ total }} 条)</span></template>
|
||||
<el-table :data="logs" v-loading="loading" stripe style="width:100%">
|
||||
<el-table-column prop="action" label="动作" width="140" />
|
||||
<el-table-column label="用户" width="120">
|
||||
<template #default="{ row }">{{ row.user_id?.substring(0,8) }}...</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ip_address" label="IP" width="140" />
|
||||
<el-table-column prop="detail" label="详情">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.detail && Object.keys(row.detail).length">{{ JSON.stringify(row.detail) }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="时间" width="180" :formatter="fmtDate" />
|
||||
</el-table>
|
||||
<div class="pagination-wrap" v-if="total > pageSize">
|
||||
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev,pager,next" @current-change="load" background />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { listLogs } from '@/api'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const loading = ref(false)
|
||||
const logs = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const filter = reactive({ action: '', user_id: '', date_from: '', date_to: '' })
|
||||
|
||||
function fmtDate(_, __, val) { return val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-' }
|
||||
function reset() { filter.action = ''; filter.user_id = ''; filter.date_from = ''; filter.date_to = ''; page.value = 1; load() }
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { page: page.value, size: pageSize }
|
||||
if (filter.action) params.action = filter.action
|
||||
if (filter.user_id) params.user_id = filter.user_id
|
||||
if (filter.date_from) params.date_from = filter.date_from
|
||||
if (filter.date_to) params.date_to = filter.date_to
|
||||
const r = await listLogs(params)
|
||||
logs.value = r.items || []
|
||||
total.value = r.total || 0
|
||||
} catch (e) { console.error(e) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-card { margin-bottom: 20px; }
|
||||
.pagination-wrap { margin-top: 20px; display: flex; justify-content: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-for="q in quotas" :key="q.version" class="quota-card">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="quota-header">
|
||||
<span class="quota-version">{{ q.version === 'ecommerce' ? '电商版' : '通用版' }}</span>
|
||||
<el-tag size="small" v-if="q.description">{{ q.description }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="quota-stat">
|
||||
<span class="quota-label">{{ q.current_month || '当前月' }}</span>
|
||||
<el-progress :percentage="quotaPercent(q)" :stroke-width="20" />
|
||||
<span class="quota-value">{{ q.used_chars }} / {{ q.monthly_limit }}</span>
|
||||
</div>
|
||||
<div class="quota-actions">
|
||||
<el-form :inline="true" size="small">
|
||||
<el-form-item label="月限额">
|
||||
<el-input-number :model-value="q._edit_limit !== undefined ? q._edit_limit : q.monthly_limit" :min="1000" :step="10000" style="width:140px" @update:model-value="q._edit_limit=$event" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch :model-value="q._edit_enabled !== undefined ? q._edit_enabled : q.enabled" @update:model-value="q._edit_enabled=$event" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="save(q)">保存</el-button>
|
||||
<el-button @click="resetQuota(q.version)">重置用量</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
<el-empty v-if="!quotas.length" description="暂无配额数据" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listQuotas, updateQuota, resetQuota as resetApi } from '@/api'
|
||||
|
||||
const quotas = ref([])
|
||||
|
||||
function quotaPercent(q) {
|
||||
const limit = q.monthly_limit || 1
|
||||
return Math.min(100, Math.round((q.used_chars / limit) * 100))
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r = await listQuotas()
|
||||
quotas.value = (r.items || r || []).map(q => ({ ...q, _edit_limit: undefined, _edit_enabled: undefined }))
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function save(q) {
|
||||
try {
|
||||
const data = {}
|
||||
if (q._edit_limit !== undefined && q._edit_limit !== null) data.monthly_limit = q._edit_limit
|
||||
if (q._edit_enabled !== undefined && q._edit_enabled !== null) data.enabled = q._edit_enabled
|
||||
if (!Object.keys(data).length) { ElMessage.info('无改动'); return }
|
||||
await updateQuota(q.version, data)
|
||||
if (data.monthly_limit !== undefined) q.monthly_limit = data.monthly_limit
|
||||
if (data.enabled !== undefined) q.enabled = data.enabled
|
||||
q._edit_limit = undefined; q._edit_enabled = undefined
|
||||
ElMessage.success('已保存')
|
||||
} catch (e) { ElMessage.error(e?.detail || '保存失败') }
|
||||
}
|
||||
|
||||
async function resetQuota(version) {
|
||||
try { await resetApi(version); ElMessage.success('已重置'); load() }
|
||||
catch (e) { ElMessage.error(e?.detail || '重置失败') }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.quota-card { margin-bottom: 16px; }
|
||||
.quota-header { display: flex; align-items: center; gap: 12px; }
|
||||
.quota-version { font-size: 15px; font-weight: 600; }
|
||||
.quota-stat { display: flex; align-items: center; gap: 16px; margin-bottom: 16px; }
|
||||
.quota-label { width: 80px; font-size: 13px; color: #666; flex-shrink: 0; }
|
||||
.el-progress { flex: 1; }
|
||||
.quota-value { width: 180px; font-size: 13px; color: #333; text-align: right; flex-shrink: 0; }
|
||||
.quota-actions { }
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8" v-for="item in topStats" :key="item.label">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-value">{{ item.value }}</div>
|
||||
<div class="stat-label">{{ item.label }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never" class="section-card">
|
||||
<template #header><span>今日各功能调用</span></template>
|
||||
<div v-if="Object.keys(byAction).length" class="bar-chart">
|
||||
<div class="bar-row" v-for="(count, action) in byAction" :key="action">
|
||||
<span class="bar-label">{{ action }}</span>
|
||||
<el-progress :percentage="barPercent(count)" :stroke-width="18" />
|
||||
<span class="bar-count">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无数据" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never" class="section-card">
|
||||
<template #header><span>近7日趋势</span></template>
|
||||
<div v-if="trend.length" class="bar-chart">
|
||||
<div class="bar-row" v-for="d in trend" :key="d.date">
|
||||
<span class="bar-label">{{ dayjs(d.date).format('MM-DD') }}</span>
|
||||
<el-progress :percentage="trendPercent(d.count)" :stroke-width="18" color="#52c41a" />
|
||||
<span class="bar-count">{{ d.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无数据" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getUsageStats } from '@/api'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const topStats = ref([])
|
||||
const byAction = ref({})
|
||||
const trend = ref([])
|
||||
let maxAction = 1, maxTrend = 1
|
||||
|
||||
function barPercent(v) { return Math.round((v / maxAction) * 100) }
|
||||
function trendPercent(v) { return Math.round((v / maxTrend) * 100) }
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const r = await getUsageStats()
|
||||
const d = r.data || r
|
||||
topStats.value = [
|
||||
{ value: d.today_total || 0, label: '今日请求' },
|
||||
{ value: d.dau || 0, label: '日活跃用户' },
|
||||
{ value: d.total_users || 0, label: '总用户' },
|
||||
]
|
||||
byAction.value = d.by_action || {}
|
||||
maxAction = Math.max(...Object.values(byAction.value), 1)
|
||||
trend.value = d.daily_trend || []
|
||||
maxTrend = Math.max(...trend.value.map(d => d.count), 1)
|
||||
} catch (e) { console.error(e) }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card { text-align: center; margin-bottom: 20px; }
|
||||
.stat-value { font-size: 32px; font-weight: 700; color: #409eff; }
|
||||
.stat-label { font-size: 14px; color: #999; margin-top: 4px; }
|
||||
.section-card { margin-bottom: 20px; }
|
||||
.bar-chart { display: flex; flex-direction: column; gap: 12px; }
|
||||
.bar-row { display: flex; align-items: center; gap: 12px; }
|
||||
.bar-label { width: 80px; font-size: 13px; color: #666; text-align: right; flex-shrink: 0; }
|
||||
.el-progress { flex: 1; }
|
||||
.bar-count { width: 50px; font-size: 13px; color: #333; text-align: right; flex-shrink: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never" class="search-card">
|
||||
<el-form :inline="true" :model="searchForm" @keyup.enter="doSearch">
|
||||
<el-form-item label="搜索">
|
||||
<el-input v-model="searchForm.query" placeholder="用户名/手机号" clearable style="width:260px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="searching" @click="doSearch">搜索</el-button>
|
||||
<el-button @click="searchForm.query = ''; searchResults = []">清空</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-table :data="searchResults" v-if="searchResults.length" stripe style="width:100%">
|
||||
<el-table-column prop="username" label="用户名" />
|
||||
<el-table-column prop="phone" label="手机号" />
|
||||
<el-table-column prop="tier" label="套餐" width="100">
|
||||
<template #default="{ row }"><el-tag :type="tierType(row.tier)" size="small">{{ row.tier }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="role" label="角色" width="100">
|
||||
<template #default="{ row }"><el-tag :type="row.role==='admin'?'danger':'info'" size="small">{{ row.role }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="280">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" :type="row.tier==='free'?'default':'warning'" @click="changeTier(row,'free')">免费</el-button>
|
||||
<el-button size="small" type="primary" :disabled="row.tier==='pro'" @click="changeTier(row,'pro')">Pro</el-button>
|
||||
<el-button size="small" type="success" :disabled="row.tier==='enterprise'" @click="changeTier(row,'enterprise')">企业</el-button>
|
||||
<el-button size="small" :type="row.is_active?'danger':'success'" @click="toggleActive(row)">{{ row.is_active?'禁用':'启用' }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="section-card">
|
||||
<template #header><span>所有用户 (共 {{ total }} 人)</span></template>
|
||||
<el-table :data="users" v-loading="loading" stripe style="width:100%">
|
||||
<el-table-column prop="username" label="用户名" />
|
||||
<el-table-column prop="phone" label="手机号" width="140" />
|
||||
<el-table-column prop="email" label="邮箱" />
|
||||
<el-table-column prop="tier" label="套餐" width="100">
|
||||
<template #default="{ row }"><el-tag :type="tierType(row.tier)" size="small">{{ row.tier }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="role" label="角色" width="100">
|
||||
<template #default="{ row }"><el-tag :type="row.role==='admin'?'danger':'info'" size="small">{{ row.role }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="is_active" label="状态" width="80">
|
||||
<template #default="{ row }"><el-tag :type="row.is_active?'success':'danger'" size="small">{{ row.is_active?'正常':'禁用' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="340">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="changeTier(row,'free')" :disabled="row.tier==='free'">免费</el-button>
|
||||
<el-button size="small" type="primary" @click="changeTier(row,'pro')" :disabled="row.tier==='pro'">Pro</el-button>
|
||||
<el-button size="small" type="success" @click="changeTier(row,'enterprise')" :disabled="row.tier==='enterprise'">企业</el-button>
|
||||
<el-button size="small" :type="row.is_active?'danger':'success'" @click="toggleActive(row)">{{ row.is_active?'禁用':'启用' }}</el-button>
|
||||
<el-button size="small" type="warning" @click="toggleRole(row)">{{ row.role==='admin'?'撤销管理':'设为管理' }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-wrap" v-if="total > pageSize">
|
||||
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev,pager,next" @current-change="loadUsers" background />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listUsers, searchUsers, updateUser } from '@/api'
|
||||
|
||||
const loading = ref(false)
|
||||
const searching = ref(false)
|
||||
const users = ref([])
|
||||
const searchResults = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const searchForm = ref({ query: '' })
|
||||
|
||||
function tierType(t) { return { free: 'warning', pro: 'primary', enterprise: 'success' }[t] || 'info' }
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try { const r = await listUsers(page.value, pageSize); users.value = r.items || []; total.value = r.total || 0 }
|
||||
catch (e) { console.error(e) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
const q = searchForm.value.query?.trim()
|
||||
if (!q) { searchResults.value = []; return }
|
||||
searching.value = true
|
||||
try {
|
||||
const r = await searchUsers(q)
|
||||
searchResults.value = r.items || [r] || []
|
||||
} catch (e) { console.error(e) }
|
||||
finally { searching.value = false }
|
||||
}
|
||||
|
||||
async function changeTier(row, tier) {
|
||||
try { await updateUser(row.id, { tier }); row.tier = tier; ElMessage.success('已更新') }
|
||||
catch (e) { ElMessage.error(e?.detail || '操作失败') }
|
||||
}
|
||||
async function toggleActive(row) {
|
||||
try { await updateUser(row.id, { is_active: !row.is_active }); row.is_active = !row.is_active; ElMessage.success('已更新') }
|
||||
catch (e) { ElMessage.error(e?.detail || '操作失败') }
|
||||
}
|
||||
async function toggleRole(row) {
|
||||
const role = row.role === 'admin' ? 'user' : 'admin'
|
||||
try { await updateUser(row.id, { role }); row.role = role; ElMessage.success('已更新') }
|
||||
catch (e) { ElMessage.error(e?.detail || '操作失败') }
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-card { margin-bottom: 20px; }
|
||||
.section-card { }
|
||||
.pagination-wrap { margin-top: 20px; display: flex; justify-content: center; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user