feat: add Vue 3 admin frontend and integrate with backend like mediabot
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
# 构建产物
|
||||
target/
|
||||
*/target/
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -36,3 +36,8 @@ Thumbs.db
|
||||
static/
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# Frontend
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
web/.vite/
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
# PAR Server - 纯 Java Docker 镜像
|
||||
# Phase 1: 最小可用版本
|
||||
# PAR Server - 前后端一体化 Docker 镜像
|
||||
# 参考 mediabot 的多阶段构建方式
|
||||
|
||||
# 构建阶段
|
||||
FROM eclipse-temurin:21-jdk-alpine AS builder
|
||||
# 第一阶段:构建前端
|
||||
FROM 192.168.31.253/library/node:22-alpine AS web-builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
COPY web/ .
|
||||
RUN npm run build
|
||||
|
||||
# 第二阶段:构建后端
|
||||
FROM eclipse-temurin:21-jdk-alpine AS server-builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
@@ -13,21 +22,22 @@ COPY par-core/pom.xml par-core/
|
||||
COPY par-api/pom.xml par-api/
|
||||
|
||||
# 下载依赖(缓存层)
|
||||
RUN --mount=type=cache,target=/root/.m2 \
|
||||
./mvnw dependency:go-offline -B 2>/dev/null || \
|
||||
(apk add --no-cache maven && mvn dependency:go-offline -B)
|
||||
RUN apk add --no-cache maven && \
|
||||
mvn dependency:go-offline -B
|
||||
|
||||
# 复制源码并构建
|
||||
COPY par-common/src par-common/src
|
||||
COPY par-core/src par-core/src
|
||||
COPY par-api/src par-api/src
|
||||
|
||||
RUN apk add --no-cache maven && \
|
||||
mvn clean package -DskipTests -B && \
|
||||
# 将前端构建产物复制到后端静态资源目录
|
||||
COPY --from=web-builder /app/dist par-api/src/main/resources/static
|
||||
|
||||
RUN mvn clean package -DskipTests -B && \
|
||||
mkdir -p target && \
|
||||
cp par-api/target/*.jar target/app.jar
|
||||
|
||||
# 运行阶段
|
||||
# 第三阶段:运行
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
|
||||
LABEL maintainer="PAR Team"
|
||||
@@ -42,7 +52,7 @@ WORKDIR /app
|
||||
RUN mkdir -p /app/static/configs && chown -R par:par /app
|
||||
|
||||
# 复制构建产物
|
||||
COPY --from=builder /build/target/app.jar app.jar
|
||||
COPY --from=server-builder /build/target/app.jar app.jar
|
||||
|
||||
USER par
|
||||
|
||||
|
||||
39
par-api/src/main/java/com/par/api/config/WebMvcConfig.java
Normal file
39
par-api/src/main/java/com/par/api/config/WebMvcConfig.java
Normal file
@@ -0,0 +1,39 @@
|
||||
package com.par.api.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.servlet.resource.PathResourceResolver;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* SPA 路由回退:所有非 API 非静态资源请求返回 index.html
|
||||
*/
|
||||
@Configuration
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
registry.addResourceHandler("/**")
|
||||
.addResourceLocations("classpath:/static/")
|
||||
.resourceChain(true)
|
||||
.addResolver(new PathResourceResolver() {
|
||||
@Override
|
||||
protected Resource getResource(String resourcePath, Resource location) throws IOException {
|
||||
Resource resource = location.createRelative(resourcePath);
|
||||
if (resource.exists() && resource.isReadable()) {
|
||||
return resource;
|
||||
}
|
||||
// API 路径不处理
|
||||
if (resourcePath.startsWith("api/")) {
|
||||
return null;
|
||||
}
|
||||
// 回退到 index.html(支持前端路由)
|
||||
return new ClassPathResource("static/index.html");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
13
web/index.html
Normal file
13
web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PAR Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
22
web/package.json
Normal file
22
web/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "par-admin",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.2.0",
|
||||
"axios": "^1.7.0",
|
||||
"element-plus": "^2.7.0",
|
||||
"@element-plus/icons-vue": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
18
web/src/App.vue
Normal file
18
web/src/App.vue
Normal 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
49
web/src/api/index.js
Normal 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
16
web/src/main.js
Normal 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
57
web/src/router/index.js
Normal 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
72
web/src/views/Configs.vue
Normal 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>
|
||||
88
web/src/views/Dashboard.vue
Normal file
88
web/src/views/Dashboard.vue
Normal 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
110
web/src/views/Layout.vue
Normal 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
77
web/src/views/Login.vue
Normal 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
53
web/src/views/Sites.vue
Normal 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
76
web/src/views/Users.vue
Normal 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>
|
||||
11
web/vite.config.js
Normal file
11
web/vite.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: '/',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user