From 6f56cde229e3de0d63e37c28d0bea7ef25c5b00f Mon Sep 17 00:00:00 2001
From: mediabot-pt <295750538+mediabot-pt@users.noreply.github.com>
Date: Mon, 29 Jun 2026 11:14:37 +0800
Subject: [PATCH] feat: add Vue 3 admin frontend and integrate with backend
like mediabot
---
.dockerignore | 2 +
.gitignore | 5 +
par-api/Dockerfile | 32 +++--
.../java/com/par/api/config/WebMvcConfig.java | 39 +++++++
web/index.html | 13 +++
web/package.json | 22 ++++
web/src/App.vue | 18 +++
web/src/api/index.js | 49 ++++++++
web/src/main.js | 16 +++
web/src/router/index.js | 57 +++++++++
web/src/views/Configs.vue | 72 ++++++++++++
web/src/views/Dashboard.vue | 88 ++++++++++++++
web/src/views/Layout.vue | 110 ++++++++++++++++++
web/src/views/Login.vue | 77 ++++++++++++
web/src/views/Sites.vue | 53 +++++++++
web/src/views/Users.vue | 76 ++++++++++++
web/vite.config.js | 11 ++
17 files changed, 729 insertions(+), 11 deletions(-)
create mode 100644 par-api/src/main/java/com/par/api/config/WebMvcConfig.java
create mode 100644 web/index.html
create mode 100644 web/package.json
create mode 100644 web/src/App.vue
create mode 100644 web/src/api/index.js
create mode 100644 web/src/main.js
create mode 100644 web/src/router/index.js
create mode 100644 web/src/views/Configs.vue
create mode 100644 web/src/views/Dashboard.vue
create mode 100644 web/src/views/Layout.vue
create mode 100644 web/src/views/Login.vue
create mode 100644 web/src/views/Sites.vue
create mode 100644 web/src/views/Users.vue
create mode 100644 web/vite.config.js
diff --git a/.dockerignore b/.dockerignore
index 857a535..76c51d7 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,6 +1,8 @@
# 构建产物
target/
*/target/
+web/node_modules/
+web/dist/
# IDE
.idea/
diff --git a/.gitignore b/.gitignore
index 8a41bea..16a6160 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,3 +36,8 @@ Thumbs.db
static/
*.db
*.db-journal
+
+# Frontend
+web/node_modules/
+web/dist/
+web/.vite/
diff --git a/par-api/Dockerfile b/par-api/Dockerfile
index 659616d..5f99aac 100644
--- a/par-api/Dockerfile
+++ b/par-api/Dockerfile
@@ -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
diff --git a/par-api/src/main/java/com/par/api/config/WebMvcConfig.java b/par-api/src/main/java/com/par/api/config/WebMvcConfig.java
new file mode 100644
index 0000000..beb6d8f
--- /dev/null
+++ b/par-api/src/main/java/com/par/api/config/WebMvcConfig.java
@@ -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");
+ }
+ });
+ }
+}
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..98f5b0a
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ PAR Admin
+
+
+
+
+
+
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..b43ffde
--- /dev/null
+++ b/web/package.json
@@ -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"
+ }
+}
diff --git a/web/src/App.vue b/web/src/App.vue
new file mode 100644
index 0000000..668aab8
--- /dev/null
+++ b/web/src/App.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/web/src/api/index.js b/web/src/api/index.js
new file mode 100644
index 0000000..67619f5
--- /dev/null
+++ b/web/src/api/index.js
@@ -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: '' })
+}
diff --git a/web/src/main.js b/web/src/main.js
new file mode 100644
index 0000000..65d68cf
--- /dev/null
+++ b/web/src/main.js
@@ -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')
diff --git a/web/src/router/index.js b/web/src/router/index.js
new file mode 100644
index 0000000..1c0fe64
--- /dev/null
+++ b/web/src/router/index.js
@@ -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
diff --git a/web/src/views/Configs.vue b/web/src/views/Configs.vue
new file mode 100644
index 0000000..ad8cb4a
--- /dev/null
+++ b/web/src/views/Configs.vue
@@ -0,0 +1,72 @@
+
+
+
配置审核
+
+
+ 待审核配置
+
+
+
+
+
+
+
+
+
+ 通过
+ 拒绝
+
+
+
+
+
+
+
+
diff --git a/web/src/views/Dashboard.vue b/web/src/views/Dashboard.vue
new file mode 100644
index 0000000..2feee1c
--- /dev/null
+++ b/web/src/views/Dashboard.vue
@@ -0,0 +1,88 @@
+
+
+
系统概览
+
+
+
+ 站点总数
+ {{ stats.sites }}
+
+
+
+
+ 配置版本数
+ {{ stats.configs }}
+
+
+
+
+ 待审核配置
+ {{ stats.pending }}
+
+
+
+
+ 注册用户
+ {{ stats.users }}
+
+
+
+
+
+
+ API 状态
+
+
+ {{ healthStatus ? '服务正常' : '服务异常' }}
+
+
+
+
+
+
+
+
diff --git a/web/src/views/Layout.vue b/web/src/views/Layout.vue
new file mode 100644
index 0000000..c4d2297
--- /dev/null
+++ b/web/src/views/Layout.vue
@@ -0,0 +1,110 @@
+
+
+
+ PAR Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/Login.vue b/web/src/views/Login.vue
new file mode 100644
index 0000000..5169e97
--- /dev/null
+++ b/web/src/views/Login.vue
@@ -0,0 +1,77 @@
+
+
+
+ PAR Admin
+
+
+
+
+
+
+
+
+ 登录
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/Sites.vue b/web/src/views/Sites.vue
new file mode 100644
index 0000000..1660009
--- /dev/null
+++ b/web/src/views/Sites.vue
@@ -0,0 +1,53 @@
+
+
+
站点管理
+
+
+
+
+
+
+
+
+
+ {{ scope.row.status }}
+
+
+
+
+
+
+ 编辑
+
+
+
+
+
+
+
+
diff --git a/web/src/views/Users.vue b/web/src/views/Users.vue
new file mode 100644
index 0000000..8358d5c
--- /dev/null
+++ b/web/src/views/Users.vue
@@ -0,0 +1,76 @@
+
+
+
用户管理
+
+
+
+
+
+
+
+
+ {{ scope.row.trustLevel }}
+
+
+
+
+
+
+ {{ scope.row.isActive ? '启用' : '禁用' }}
+
+
+
+
+
+
+ 设置等级
+
+
+
+
+
+
+
+
diff --git a/web/vite.config.js b/web/vite.config.js
new file mode 100644
index 0000000..7cf6b38
--- /dev/null
+++ b/web/vite.config.js
@@ -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
+ }
+})