feat: add Vue 3 admin frontend and integrate with backend like mediabot

This commit is contained in:
mediabot-pt
2026-06-29 11:14:37 +08:00
parent 25cd9873ae
commit 6f56cde229
17 changed files with 729 additions and 11 deletions

18
web/src/App.vue Normal file
View File

@@ -0,0 +1,18 @@
<template>
<router-view />
</template>
<script setup>
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f5f7fa;
}
</style>

49
web/src/api/index.js Normal file
View File

@@ -0,0 +1,49 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api/v1',
timeout: 30000
})
api.interceptors.request.use(config => {
const token = localStorage.getItem('par_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
response => response.data,
error => {
if (error.response?.status === 401) {
localStorage.removeItem('par_token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default {
auth: {
login: (data) => api.post('/auth/login', data),
register: (data) => api.post('/auth/register', data)
},
sites: {
list: () => api.get('/sites'),
get: (id) => api.get(`/sites/${id}`),
create: (data) => api.post('/sites', data),
update: (id, data) => api.put(`/sites/${id}`, data)
},
configs: {
list: () => api.get('/configs'),
pending: () => api.get('/configs/pending'),
approve: (id, comment) => api.post(`/admin/reviews/${id}/approve`, null, { params: { comment } }),
reject: (id, comment) => api.post(`/admin/reviews/${id}/reject`, null, { params: { comment } })
},
users: {
list: () => api.get('/admin/accounts'),
setTrustLevel: (id, level) => api.post(`/admin/accounts/${id}/trust-level`, null, { params: { level } })
},
health: () => api.get('/health', { baseURL: '' })
}

16
web/src/main.js Normal file
View File

@@ -0,0 +1,16 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus)
app.use(router)
app.mount('#app')

57
web/src/router/index.js Normal file
View File

@@ -0,0 +1,57 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue')
},
{
path: '/',
name: 'Layout',
component: () => import('../views/Layout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('../views/Dashboard.vue'),
meta: { title: '概览' }
},
{
path: 'sites',
name: 'Sites',
component: () => import('../views/Sites.vue'),
meta: { title: '站点管理' }
},
{
path: 'configs',
name: 'Configs',
component: () => import('../views/Configs.vue'),
meta: { title: '配置审核' }
},
{
path: 'users',
name: 'Users',
component: () => import('../views/Users.vue'),
meta: { title: '用户管理' }
}
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('par_token')
if (to.path !== '/login' && !token) {
next('/login')
} else {
next()
}
})
export default router

72
web/src/views/Configs.vue Normal file
View File

@@ -0,0 +1,72 @@
<template>
<div>
<h2>配置审核</h2>
<el-card style="margin-top: 20px">
<template #header>
<span>待审核配置</span>
</template>
<el-table :data="pendingConfigs" v-loading="loading" stripe>
<el-table-column prop="siteId" label="站点ID" width="120" />
<el-table-column prop="version" label="版本" width="100" />
<el-table-column prop="schemaVersion" label="Schema" width="100" />
<el-table-column prop="submitterId" label="提交者" width="100" />
<el-table-column prop="createdAt" label="提交时间" width="180" />
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button size="small" type="success" @click="handleApprove(scope.row)">通过</el-button>
<el-button size="small" type="danger" @click="handleReject(scope.row)">拒绝</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '../api'
const pendingConfigs = ref([])
const loading = ref(false)
const fetchPending = async () => {
loading.value = true
try {
const res = await api.configs.pending()
pendingConfigs.value = res.data || []
} catch (e) {
ElMessage.error('获取待审核列表失败')
} finally {
loading.value = false
}
}
const handleApprove = async (row) => {
try {
await ElMessageBox.confirm(`确认通过站点 ${row.siteId} 的配置版本 ${row.version}?`, '提示')
await api.configs.approve(row.id)
ElMessage.success('已通过')
fetchPending()
} catch (e) {
if (e !== 'cancel') ElMessage.error(e.response?.data?.message || '操作失败')
}
}
const handleReject = async (row) => {
try {
const { value: comment } = await ElMessageBox.prompt('请输入拒绝原因', '拒绝配置', {
confirmButtonText: '确认',
cancelButtonText: '取消',
inputValidator: (v) => v ? true : '请输入拒绝原因'
})
await api.configs.reject(row.id, comment)
ElMessage.success('已拒绝')
fetchPending()
} catch (e) {
if (e !== 'cancel') ElMessage.error(e.response?.data?.message || '操作失败')
}
}
onMounted(fetchPending)
</script>

View File

@@ -0,0 +1,88 @@
<template>
<div>
<h2>系统概览</h2>
<el-row :gutter="20" style="margin-top: 20px">
<el-col :span="6">
<el-card>
<div class="stat-title">站点总数</div>
<div class="stat-value">{{ stats.sites }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<div class="stat-title">配置版本数</div>
<div class="stat-value">{{ stats.configs }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<div class="stat-title">待审核配置</div>
<div class="stat-value">{{ stats.pending }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<div class="stat-title">注册用户</div>
<div class="stat-value">{{ stats.users }}</div>
</el-card>
</el-col>
</el-row>
<el-card style="margin-top: 20px">
<template #header>
<span>API 状态</span>
</template>
<el-tag :type="healthStatus ? 'success' : 'danger'">
{{ healthStatus ? '服务正常' : '服务异常' }}
</el-tag>
</el-card>
</div>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import api from '../api'
const stats = reactive({
sites: 0,
configs: 0,
pending: 0,
users: 0
})
const healthStatus = ref(false)
onMounted(async () => {
try {
const [sitesRes, configsRes, usersRes] = await Promise.all([
api.sites.list().catch(() => ({ data: [] })),
api.configs.list().catch(() => ({ data: [] })),
api.users.list().catch(() => ({ data: [] }))
])
stats.sites = sitesRes.data?.length || 0
stats.configs = configsRes.data?.length || 0
stats.users = usersRes.data?.length || 0
} catch (e) {
console.error(e)
}
try {
await api.health()
healthStatus.value = true
} catch {
healthStatus.value = false
}
})
</script>
<style scoped>
.stat-title {
color: #909399;
font-size: 14px;
}
.stat-value {
font-size: 32px;
font-weight: bold;
color: #303133;
margin-top: 10px;
}
</style>

110
web/src/views/Layout.vue Normal file
View File

@@ -0,0 +1,110 @@
<template>
<el-container class="layout-container">
<el-aside width="200px" class="aside">
<div class="logo">PAR Admin</div>
<el-menu
:default-active="$route.path"
router
class="menu"
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409EFF"
>
<el-menu-item index="/dashboard">
<el-icon><HomeFilled /></el-icon>
<span>概览</span>
</el-menu-item>
<el-menu-item index="/sites">
<el-icon><OfficeBuilding /></el-icon>
<span>站点管理</span>
</el-menu-item>
<el-menu-item index="/configs">
<el-icon><DocumentChecked /></el-icon>
<span>配置审核</span>
</el-menu-item>
<el-menu-item index="/users">
<el-icon><UserFilled /></el-icon>
<span>用户管理</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<div class="header-right">
<el-dropdown @command="handleCommand">
<span class="user-info">
<el-icon><User /></el-icon>
{{ userEmail }}
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="main">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
const router = useRouter()
const userEmail = ref(localStorage.getItem('par_token') || 'admin')
const handleCommand = (cmd) => {
if (cmd === 'logout') {
localStorage.removeItem('par_token')
ElMessage.success('已退出')
router.push('/login')
}
}
</script>
<style scoped>
.layout-container {
height: 100vh;
}
.aside {
background-color: #304156;
}
.logo {
height: 60px;
line-height: 60px;
text-align: center;
color: #fff;
font-size: 18px;
font-weight: bold;
border-bottom: 1px solid #1f2d3d;
}
.menu {
border-right: none;
}
.header {
background-color: #fff;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
display: flex;
align-items: center;
justify-content: flex-end;
}
.header-right {
cursor: pointer;
}
.user-info {
display: flex;
align-items: center;
gap: 5px;
color: #606266;
}
.main {
background-color: #f5f7fa;
padding: 20px;
}
</style>

77
web/src/views/Login.vue Normal file
View File

@@ -0,0 +1,77 @@
<template>
<div class="login-container">
<el-card class="login-box" shadow="always">
<h2 class="login-title">PAR Admin</h2>
<el-form :model="form" :rules="rules" ref="formRef" @keyup.enter="handleLogin">
<el-form-item prop="email">
<el-input v-model="form.email" 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" @click="handleLogin" :loading="loading" style="width: 100%">登录</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import api from '../api'
const router = useRouter()
const formRef = ref()
const loading = ref(false)
const form = reactive({
email: '',
password: ''
})
const rules = {
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
const handleLogin = async () => {
await formRef.value.validate()
loading.value = true
try {
const res = await api.auth.login({ email: form.email, password: form.password })
if (res.code === 200) {
localStorage.setItem('par_token', res.data.email)
ElMessage.success('登录成功')
router.push('/')
} else {
ElMessage.error(res.message || '登录失败')
}
} catch (e) {
ElMessage.error(e.response?.data?.message || '登录失败')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-box {
width: 400px;
padding: 20px;
}
.login-title {
text-align: center;
margin-bottom: 30px;
color: #333;
}
</style>

53
web/src/views/Sites.vue Normal file
View File

@@ -0,0 +1,53 @@
<template>
<div>
<h2>站点管理</h2>
<el-card style="margin-top: 20px">
<el-table :data="sites" v-loading="loading" stripe>
<el-table-column prop="siteId" label="站点ID" width="120" />
<el-table-column prop="name" label="名称" width="150" />
<el-table-column prop="baseUrl" label="基础URL" />
<el-table-column prop="siteType" label="类型" width="100" />
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag :type="scope.row.status === 'active' ? 'success' : 'info'">
{{ scope.row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="schemaVersion" label="Schema版本" width="120" />
<el-table-column label="操作" width="150">
<template #default="scope">
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import api from '../api'
const sites = ref([])
const loading = ref(false)
const fetchSites = async () => {
loading.value = true
try {
const res = await api.sites.list()
sites.value = res.data || []
} catch (e) {
ElMessage.error('获取站点列表失败')
} finally {
loading.value = false
}
}
const handleEdit = (row) => {
ElMessage.info(`编辑站点: ${row.name}`)
}
onMounted(fetchSites)
</script>

76
web/src/views/Users.vue Normal file
View File

@@ -0,0 +1,76 @@
<template>
<div>
<h2>用户管理</h2>
<el-card style="margin-top: 20px">
<el-table :data="users" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="displayName" label="昵称" width="150" />
<el-table-column prop="trustLevel" label="信任等级" width="100">
<template #default="scope">
<el-tag :type="trustLevelType(scope.row.trustLevel)">
{{ scope.row.trustLevel }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="isActive" label="状态" width="80">
<template #default="scope">
<el-tag :type="scope.row.isActive ? 'success' : 'danger'">
{{ scope.row.isActive ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="注册时间" width="180" />
<el-table-column label="操作" width="150">
<template #default="scope">
<el-button size="small" @click="handleSetLevel(scope.row)">设置等级</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '../api'
const users = ref([])
const loading = ref(false)
const trustLevelType = (level) => {
const map = { MEMBER: '', TRUSTED: 'warning', ADMIN: 'danger' }
return map[level] || ''
}
const fetchUsers = async () => {
loading.value = true
try {
const res = await api.users.list()
users.value = res.data || []
} catch (e) {
ElMessage.error('获取用户列表失败')
} finally {
loading.value = false
}
}
const handleSetLevel = async (row) => {
try {
const { value: level } = await ElMessageBox.prompt('请输入信任等级 (MEMBER/TRUSTED/ADMIN)', '设置信任等级', {
inputValue: row.trustLevel,
confirmButtonText: '确认',
cancelButtonText: '取消',
inputValidator: (v) => ['MEMBER', 'TRUSTED', 'ADMIN'].includes(v) ? true : '无效等级'
})
await api.users.setTrustLevel(row.id, level)
ElMessage.success('设置成功')
fetchUsers()
} catch (e) {
if (e !== 'cancel') ElMessage.error(e.response?.data?.message || '操作失败')
}
}
onMounted(fetchUsers)
</script>