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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + 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 @@ + + + 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 + } +})