Add admin-frontend and user-frontend standalone projects, certification/invoice/discovery features, fix auth header and theme consistency

This commit is contained in:
TradeMate Dev
2026-05-22 18:35:30 +08:00
parent 18c6cf5406
commit 52dba37f22
79 changed files with 10333 additions and 248 deletions
+22
View File
@@ -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>
+58
View File
@@ -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
+115
View File
@@ -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 外贸小助手 &copy; {{ 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>
+15
View File
@@ -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')
+85
View File
@@ -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
+28
View File
@@ -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 }
})
+117
View File
@@ -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>
+70
View File
@@ -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>
+77
View File
@@ -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>
+112
View File
@@ -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>
+146
View File
@@ -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 外贸小助手 &copy; {{ 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>
+60
View File
@@ -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>
+83
View File
@@ -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>
+86
View File
@@ -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>
+83
View File
@@ -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>
+120
View File
@@ -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>