Эх сурвалжийг харах

1. 实现了服务进程管理平台,支持服务的创建、编辑、删除和启停控制;2. 实现了多终端类型支持,包括 CMD、PowerShell、Git Bash 和 SSH 远程执行;3. 实现了启动命令解析引擎,支持可选片段语法和自定义变量替换;4. 实现了进程 PID 持久化,应用重启后可自动恢复服务的运行状态;5. 实现了实时日志查看功能,支持 UTF-8、GB18030 等多种字符编码;6. 实现了端口冲突管理功能,自动检测端口占用并支持终止冲突进程;7. 实现了 Web 界面跳转功能,点击运行中的服务卡片可直接打开浏览器;8. 实现了一键启停所有服务的能力;9. 新增了统一构建部署方案,前后端同端口(3945)单一 JAR 部署;10. 新增了 Windows 和 Linux/macOS 构建运行脚本。

weisijie 2 сар өмнө
commit
64cd6b2305
42 өөрчлөгдсөн 5590 нэмэгдсэн , 0 устгасан
  1. 52 0
      .gitignore
  2. 21 0
      LICENSE
  3. 145 0
      README.md
  4. 64 0
      backend/pom.xml
  5. 12 0
      backend/src/main/java/com/svcman/ServiceManagementApplication.java
  6. 35 0
      backend/src/main/java/com/svcman/config/WebConfig.java
  7. 146 0
      backend/src/main/java/com/svcman/controller/ServiceController.java
  8. 121 0
      backend/src/main/java/com/svcman/model/ServiceConfig.java
  9. 105 0
      backend/src/main/java/com/svcman/repository/ServiceConfigRepository.java
  10. 182 0
      backend/src/main/java/com/svcman/service/CommandParser.java
  11. 113 0
      backend/src/main/java/com/svcman/service/PidFileManager.java
  12. 370 0
      backend/src/main/java/com/svcman/service/PortService.java
  13. 514 0
      backend/src/main/java/com/svcman/service/ProcessManager.java
  14. 65 0
      backend/src/main/java/com/svcman/service/ServiceConfigService.java
  15. 11 0
      backend/src/main/resources/application.yml
  16. 31 0
      backend/src/main/resources/logback-spring.xml
  17. 24 0
      frontend/.gitignore
  18. 5 0
      frontend/README.md
  19. 13 0
      frontend/index.html
  20. 1677 0
      frontend/package-lock.json
  21. 26 0
      frontend/package.json
  22. 0 0
      frontend/public/favicon.svg
  23. 24 0
      frontend/public/icons.svg
  24. 302 0
      frontend/src/App.vue
  25. 61 0
      frontend/src/api/service.ts
  26. 55 0
      frontend/src/api/types.ts
  27. BIN
      frontend/src/assets/hero.png
  28. 0 0
      frontend/src/assets/vite.svg
  29. 1 0
      frontend/src/assets/vue.svg
  30. 95 0
      frontend/src/components/HelloWorld.vue
  31. 94 0
      frontend/src/components/LogDialog.vue
  32. 260 0
      frontend/src/components/PortKillDialog.vue
  33. 332 0
      frontend/src/components/ServiceCard.vue
  34. 305 0
      frontend/src/components/ServiceFormDialog.vue
  35. 17 0
      frontend/src/main.ts
  36. 16 0
      frontend/src/style.css
  37. 14 0
      frontend/tsconfig.app.json
  38. 7 0
      frontend/tsconfig.json
  39. 24 0
      frontend/tsconfig.node.json
  40. 15 0
      frontend/vite.config.ts
  41. 126 0
      run.bat
  42. 110 0
      run.sh

+ 52 - 0
.gitignore

@@ -0,0 +1,52 @@
+# ========================
+# OS generated files
+# ========================
+.DS_Store
+Thumbs.db
+Desktop.ini
+
+# ========================
+# IDE / Editor
+# ========================
+.idea/
+*.iml
+*.iws
+*.ipr
+.vscode/
+*.swp
+*.swo
+*~
+
+# ========================
+# Backend (Java / Maven)
+# ========================
+backend/target/
+*.jar
+*.war
+*.class
+
+# ========================
+# Frontend (Node / Vue)
+# ========================
+frontend/node_modules/
+frontend/dist/
+
+# ========================
+# Runtime data & logs
+# ========================
+data/
+logs/
+
+# ========================
+# Build output in static
+# (generated by build script, not source)
+# ========================
+backend/src/main/resources/static/
+
+# ========================
+# Misc
+# ========================
+prompt.md
+.spec-workflow/
+.playwright-mcp/
+*.log

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 145 - 0
README.md

@@ -0,0 +1,145 @@
+# Service Management
+
+一个轻量级的服务进程管理平台,用于统一管理本地或远程服务的启动、停止、日志查看和端口冲突处理。
+
+## 技术栈
+
+| 层级 | 技术 |
+|------|------|
+| 后端 | Java 17 + Spring Boot 3.2 |
+| 前端 | Vue 3 + TypeScript + Vite |
+| UI | PrimeVue (Aura Theme) + PrimeIcons |
+| 部署 | 统一打包为单一 JAR,前后端同端口 |
+
+## 功能特性
+
+- **服务管理** — 创建、编辑、删除服务配置
+- **多终端支持** — CMD、PowerShell、Git Bash、SSH 远程执行
+- **启动命令引擎** — 支持多行命令、`[可选片段]` 语法、`{{变量}}` 替换
+- **进程管理** — 后台启动、PID 追踪、优雅停止(10s 超时后强制终止)
+- **PID 持久化** — 进程 PID 写入磁盘,应用重启后自动恢复运行状态
+- **实时日志** — 在线查看服务输出,支持多种字符编码(UTF-8、GB18030 等)
+- **Web 界面跳转** — 配置 Web URL 后,点击运行中的服务卡片可直接在浏览器中打开
+- **端口冲突管理** — 自动检测端口占用,查看占用进程详情,确认后一键终止
+- **一键启停** — 支持一键启动/停止所有服务
+- **统一部署** — 前端编译后嵌入 Spring Boot 静态资源,单一 JAR 部署
+
+## 快速开始
+
+### 环境要求
+
+- **Java** 17+
+- **Maven** 3.6+
+- **Node.js** 18+(仅构建前端时需要)
+
+### 构建 & 运行
+
+**Windows:**
+```bash
+run.bat build
+run.bat run
+```
+
+**Linux / macOS / Git Bash:**
+```bash
+chmod +x run.sh
+./run.sh build
+./run.sh run
+```
+
+构建完成后,浏览器访问 **http://localhost:3945**
+
+### 单独运行(已构建)
+
+```bash
+cd backend
+java -jar target/service-management-1.0.0.jar
+```
+
+## 项目结构
+
+```
+service-management/
+├── backend/                    # Spring Boot 后端
+│   ├── pom.xml
+│   └── src/main/
+│       ├── java/com/svcman/
+│       │   ├── controller/     # REST API 控制器
+│       │   ├── model/          # 数据模型
+│       │   ├── repository/     # JSON 文件持久化存储
+│       │   ├── service/        # 核心业务逻辑
+│       │   └── config/         # Web 配置(CORS、SPA 路由)
+│       └── resources/
+│           ├── application.yml
+│           ├── logback-spring.xml
+│           └── static/         # 前端构建产物(自动生成)
+├── frontend/                   # Vue 3 前端
+│   ├── package.json
+│   ├── vite.config.ts
+│   └── src/
+│       ├── api/                # Axios API 层
+│       ├── components/         # Vue 组件
+│       └── main.ts
+├── data/                       # 运行时数据(自动生成,不入版本库)
+│   ├── services.json           # 服务配置持久化
+│   └── pids/                   # PID 文件持久化
+├── logs/                       # 运行日志(自动生成,不入版本库)
+├── run.bat                     # Windows 构建运行脚本
+└── run.sh                      # Linux/macOS 构建运行脚本
+```
+
+## API 接口
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| `GET` | `/api/services` | 获取所有服务 |
+| `GET` | `/api/services/{id}` | 获取单个服务 |
+| `POST` | `/api/services` | 创建服务 |
+| `PUT` | `/api/services/{id}` | 更新服务 |
+| `DELETE` | `/api/services/{id}` | 删除服务 |
+| `POST` | `/api/services/{id}/start` | 启动服务 |
+| `POST` | `/api/services/{id}/stop` | 停止服务 |
+| `POST` | `/api/services/start-all` | 一键启动 |
+| `POST` | `/api/services/stop-all` | 一键停止 |
+| `GET` | `/api/services/{id}/log` | 获取服务日志 |
+| `GET` | `/api/services/{id}/web-url` | 获取解析后的 Web URL |
+| `GET` | `/api/services/{id}/port-check` | 检查端口占用 |
+| `POST` | `/api/services/{id}/kill-port/{pid}` | 终止占用端口的进程 |
+
+## 启动命令语法
+
+启动命令支持多行,每行一条命令,按顺序执行:
+
+```
+mvn package
+java -jar [--server.port={{SERVER_PORT}}] target/app-1.0.0.jar
+```
+
+| 语法 | 说明 | 示例 |
+|------|------|------|
+| `{{VAR}}` | 自定义变量占位符 | `{{SERVER_PORT}}` |
+| `[content]` | 可选片段,仅当内部所有变量都有值时才保留 | `[--port={{PORT}}]` |
+| `{{VAR:default}}` | 带默认值的变量(仅用于 Web URL) | `{{PORT:8080}}` |
+
+## 配置说明
+
+服务配置存储在 `data/services.json`,主要字段:
+
+```json
+{
+  "name": "用户服务",
+  "description": "用户管理微服务",
+  "startupCommand": "java -jar [--server.port={{SERVER_PORT}}] app.jar",
+  "workDir": "D:/projects/user-service",
+  "terminalType": "CMD",
+  "variables": [
+    { "name": "SERVER_PORT", "value": "8080" }
+  ],
+  "webUrl": "http://localhost:{{SERVER_PORT:8080}}/index.html",
+  "logEncoding": "UTF-8"
+}
+```
+
+## License
+
+MIT

+ 64 - 0
backend/pom.xml

@@ -0,0 +1,64 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.springframework.boot</groupId>
+        <artifactId>spring-boot-starter-parent</artifactId>
+        <version>3.2.5</version>
+        <relativePath/>
+    </parent>
+
+    <groupId>com.svcman</groupId>
+    <artifactId>service-management</artifactId>
+    <version>1.0.0</version>
+    <name>Service Management</name>
+    <description>Multi-service launcher and manager</description>
+
+    <properties>
+        <java.version>17</java.version>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <optional>true</optional>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <configuration>
+                    <excludes>
+                        <exclude>
+                            <groupId>org.projectlombok</groupId>
+                            <artifactId>lombok</artifactId>
+                        </exclude>
+                    </excludes>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 12 - 0
backend/src/main/java/com/svcman/ServiceManagementApplication.java

@@ -0,0 +1,12 @@
+package com.svcman;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class ServiceManagementApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(ServiceManagementApplication.class, args);
+    }
+}

+ 35 - 0
backend/src/main/java/com/svcman/config/WebConfig.java

@@ -0,0 +1,35 @@
+package com.svcman.config;
+
+import org.springframework.boot.web.server.ErrorPage;
+import org.springframework.boot.web.server.ErrorPageRegistrar;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * Web 配置:CORS 支持 + SPA 路由转发
+ */
+@Configuration
+public class WebConfig implements WebMvcConfigurer {
+
+    @Override
+    public void addCorsMappings(CorsRegistry registry) {
+        registry.addMapping("/api/**")
+                .allowedOrigins("*")
+                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
+                .allowedHeaders("*");
+    }
+
+    /**
+     * SPA 前端路由支持:
+     * 所有非 /api 且非静态资源的 404 请求,回退到 index.html 由前端路由处理
+     */
+    @Bean
+    public ErrorPageRegistrar spaErrorPageRegistrar() {
+        return registry -> registry.addErrorPages(
+                new ErrorPage(HttpStatus.NOT_FOUND, "/index.html")
+        );
+    }
+}

+ 146 - 0
backend/src/main/java/com/svcman/controller/ServiceController.java

@@ -0,0 +1,146 @@
+package com.svcman.controller;
+
+import com.svcman.model.ServiceConfig;
+import com.svcman.service.CommandParser;
+import com.svcman.service.PortService;
+import com.svcman.service.ProcessManager;
+import com.svcman.service.ServiceConfigService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 服务管理 REST API
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/services")
+@RequiredArgsConstructor
+public class ServiceController {
+
+    private final ServiceConfigService configService;
+    private final ProcessManager processManager;
+    private final CommandParser commandParser;
+    private final PortService portService;
+
+    // ===== CRUD =====
+
+    @GetMapping
+    public List<ServiceConfig> listServices() {
+        return configService.listAll();
+    }
+
+    @GetMapping("/{id}")
+    public ResponseEntity<ServiceConfig> getService(@PathVariable String id) {
+        return ResponseEntity.ok(configService.getById(id));
+    }
+
+    @PostMapping
+    public ResponseEntity<ServiceConfig> createService(@RequestBody ServiceConfig config) {
+        log.info("创建服务: {}", config.getName());
+        return ResponseEntity.ok(configService.create(config));
+    }
+
+    @PutMapping("/{id}")
+    public ResponseEntity<ServiceConfig> updateService(
+            @PathVariable String id, @RequestBody ServiceConfig config) {
+        log.info("更新服务: id={}", id);
+        return ResponseEntity.ok(configService.update(id, config));
+    }
+
+    @DeleteMapping("/{id}")
+    public ResponseEntity<Void> deleteService(@PathVariable String id) {
+        log.info("删除服务: id={}", id);
+        // 如果服务正在运行,先停止
+        if (processManager.isRunning(id)) {
+            processManager.stopService(id);
+        }
+        configService.delete(id);
+        return ResponseEntity.noContent().build();
+    }
+
+    // ===== 进程控制 =====
+
+    @PostMapping("/{id}/start")
+    public ResponseEntity<Map<String, String>> startService(@PathVariable String id) {
+        log.info("启动服务: id={}", id);
+        processManager.startService(id);
+        return ResponseEntity.ok(Map.of("message", "服务已启动"));
+    }
+
+    @PostMapping("/{id}/stop")
+    public ResponseEntity<Map<String, String>> stopService(@PathVariable String id) {
+        log.info("停止服务: id={}", id);
+        processManager.stopService(id);
+        return ResponseEntity.ok(Map.of("message", "服务已停止"));
+    }
+
+    @PostMapping("/start-all")
+    public ResponseEntity<Map<String, String>> startAll() {
+        log.info("一键启动所有服务");
+        return ResponseEntity.ok(processManager.startAll());
+    }
+
+    @PostMapping("/stop-all")
+    public ResponseEntity<Map<String, String>> stopAll() {
+        log.info("一键停止所有服务");
+        return ResponseEntity.ok(processManager.stopAll());
+    }
+
+    // ===== Web URL =====
+
+    @GetMapping("/{id}/web-url")
+    public ResponseEntity<Map<String, String>> getWebUrl(@PathVariable String id) {
+        ServiceConfig config = configService.getById(id);
+        String resolvedUrl = commandParser.resolveWebUrl(config);
+        if (resolvedUrl != null) {
+            return ResponseEntity.ok(Map.of("url", resolvedUrl));
+        }
+        return ResponseEntity.ok(Map.of());
+    }
+
+    // ===== 端口管理 =====
+
+    @GetMapping("/{id}/port-check")
+    public ResponseEntity<List<PortService.PortCheckResult>> checkPorts(@PathVariable String id) {
+        log.info("检查端口占用: serviceId={}", id);
+        return ResponseEntity.ok(portService.checkPorts(id));
+    }
+
+    @PostMapping("/{id}/kill-port/{pid}")
+    public ResponseEntity<PortService.KillResult> killPortProcess(
+            @PathVariable String id, @PathVariable long pid) {
+        log.info("杀死端口占用进程: serviceId={}, pid={}", id, pid);
+        return ResponseEntity.ok(portService.killProcess(pid));
+    }
+
+    // ===== 日志 =====
+
+    @GetMapping("/{id}/log")
+    public ResponseEntity<Map<String, String>> getServiceLog(@PathVariable String id) {
+        String log = processManager.getServiceLog(id);
+        return ResponseEntity.ok(Map.of("log", log));
+    }
+
+    // ===== 异常处理 =====
+
+    @ExceptionHandler(IllegalArgumentException.class)
+    public ResponseEntity<Map<String, String>> handleBadRequest(IllegalArgumentException e) {
+        return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
+    }
+
+    @ExceptionHandler(IllegalStateException.class)
+    public ResponseEntity<Map<String, String>> handleConflict(IllegalStateException e) {
+        return ResponseEntity.status(409).body(Map.of("error", e.getMessage()));
+    }
+
+    @ExceptionHandler(Exception.class)
+    public ResponseEntity<Map<String, String>> handleInternal(Exception e) {
+        log.error("内部错误", e);
+        return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
+    }
+}

+ 121 - 0
backend/src/main/java/com/svcman/model/ServiceConfig.java

@@ -0,0 +1,121 @@
+package com.svcman.model;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 服务配置实体
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ServiceConfig {
+
+    /** 唯一标识 */
+    private String id;
+
+    /** 服务名称 */
+    private String name;
+
+    /** 服务描述 */
+    private String description;
+
+    /**
+     * 启动命令,可包含多行。
+     * 语法规则:
+     *   - [content] 表示可选片段
+     *   - {{VAR}} 表示自定义变量占位符
+     *   - 当可选片段内所有变量都有值时,该片段才会被加入最终命令
+     */
+    private String startupCommand;
+
+    /** 工作目录 */
+    private String workDir;
+
+    /**
+     * 终端类型: cmd, powershell, git_bash, ssh
+     */
+    private TerminalType terminalType;
+
+    /** SSH 配置(仅 terminalType=ssh 时使用) */
+    private SshConfig sshConfig;
+
+    /** 自定义变量列表 */
+    @Builder.Default
+    private List<CustomVariable> variables = new ArrayList<>();
+
+    /**
+     * 服务 Web 界面地址(可选)。
+     * 支持变量: {{VAR}} 或 {{VAR:default}}
+     * 例如: http://localhost:{{SERVER_PORT:8080}}/index.html
+     */
+    private String webUrl;
+
+    /** 日志编码,默认 UTF-8 */
+    @Builder.Default
+    private String logEncoding = "UTF-8";
+
+    /** 创建时间 */
+    private Instant createdAt;
+
+    /** 更新时间 */
+    private Instant updatedAt;
+
+    // ========== 运行时状态(不持久化) ==========
+
+    /** 运行状态 */
+    public enum RunStatus {
+        STOPPED, RUNNING, STARTING, STOPPING, ERROR
+    }
+
+    /** 当前运行状态 */
+    @Builder.Default
+    private RunStatus status = RunStatus.STOPPED;
+
+    /** 进程 PID */
+    private Long pid;
+
+    /** 状态消息(如错误信息) */
+    private String statusMessage;
+
+    /** 最后启动时间 */
+    private Instant lastStartedAt;
+
+    /** 最后停止时间 */
+    private Instant lastStoppedAt;
+
+    public enum TerminalType {
+        CMD, POWERSHELL, GIT_BASH, SSH
+    }
+
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class SshConfig {
+        private String host;
+        private Integer port;
+        private String username;
+        private String password;
+    }
+
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class CustomVariable {
+        /** 变量名,如 SERVER_PORT */
+        private String name;
+        /** 变量值,如 8080 */
+        private String value;
+    }
+}

+ 105 - 0
backend/src/main/java/com/svcman/repository/ServiceConfigRepository.java

@@ -0,0 +1,105 @@
+package com.svcman.repository;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import com.svcman.model.ServiceConfig;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Repository;
+
+import jakarta.annotation.PostConstruct;
+import java.io.File;
+import java.io.IOException;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 基于 JSON 文件的服务配置持久化存储
+ */
+@Slf4j
+@Repository
+public class ServiceConfigRepository {
+
+    private final File dataFile;
+    private final ObjectMapper objectMapper;
+    private final Map<String, ServiceConfig> store = new ConcurrentHashMap<>();
+
+    public ServiceConfigRepository(@Value("${app.data-dir}") String dataDir) {
+        this.dataFile = new File(dataDir, "services.json");
+        this.objectMapper = new ObjectMapper();
+        this.objectMapper.registerModule(new JavaTimeModule());
+        this.objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+        this.objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
+    }
+
+    @PostConstruct
+    public void init() {
+        if (dataFile.exists()) {
+            try {
+                ServiceConfig[] configs = objectMapper.readValue(dataFile, ServiceConfig[].class);
+                for (ServiceConfig config : configs) {
+                    // 运行时状态由 ProcessManager.recoverState() 统一恢复
+                    // 此处仅加载持久化数据,不做状态重置
+                    store.put(config.getId(), config);
+                }
+                log.info("已加载 {} 个服务配置", store.size());
+            } catch (IOException e) {
+                log.error("加载服务配置失败", e);
+            }
+        } else {
+            log.info("数据文件不存在,将从空配置开始");
+        }
+    }
+
+    public List<ServiceConfig> findAll() {
+        return new ArrayList<>(store.values());
+    }
+
+    public Optional<ServiceConfig> findById(String id) {
+        return Optional.ofNullable(store.get(id));
+    }
+
+    public ServiceConfig save(ServiceConfig config) {
+        if (config.getId() == null || config.getId().isBlank()) {
+            config.setId(UUID.randomUUID().toString());
+            config.setCreatedAt(java.time.Instant.now());
+        }
+        config.setUpdatedAt(java.time.Instant.now());
+        store.put(config.getId(), config);
+        persist();
+        log.info("服务配置已保存: id={}, name={}", config.getId(), config.getName());
+        return config;
+    }
+
+    public boolean deleteById(String id) {
+        ServiceConfig removed = store.remove(id);
+        if (removed != null) {
+            persist();
+            log.info("服务配置已删除: id={}, name={}", id, removed.getName());
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * 仅更新内存中的运行时状态,不持久化到文件
+     */
+    public void updateRuntimeState(String id, ServiceConfig.RunStatus status, Long pid, String message) {
+        ServiceConfig config = store.get(id);
+        if (config != null) {
+            config.setStatus(status);
+            config.setPid(pid);
+            config.setStatusMessage(message);
+        }
+    }
+
+    private void persist() {
+        try {
+            dataFile.getParentFile().mkdirs();
+            objectMapper.writeValue(dataFile, store.values());
+        } catch (IOException e) {
+            log.error("持久化服务配置失败", e);
+        }
+    }
+}

+ 182 - 0
backend/src/main/java/com/svcman/service/CommandParser.java

@@ -0,0 +1,182 @@
+package com.svcman.service;
+
+import com.svcman.model.ServiceConfig;
+import com.svcman.model.ServiceConfig.CustomVariable;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 命令解析引擎。
+ * <p>
+ * 解析规则:
+ * 1. {{VAR}} → 替换为对应变量的值
+ * 2. [content] → 可选片段,仅当内部所有变量都存在值时才保留
+ * 3. 嵌套可选片段支持
+ * 4. 多行命令按行拆分
+ */
+@Slf4j
+@Component
+public class CommandParser {
+
+    private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\{\\{(\\w+)}}");
+    private static final Pattern OPTIONAL_BLOCK_PATTERN = Pattern.compile("\\[([^\\[\\]]*(?:\\[.*?\\][^\\[\\]]*)*)\\]");
+    /** 匹配 {{VAR}} 或 {{VAR:default}} */
+    private static final Pattern URL_VAR_PATTERN = Pattern.compile("\\{\\{(\\w+)(?::([^}]*))?}}");
+
+    /**
+     * 将配置中的启动命令解析为最终的命令行列表(每行一个命令)
+     */
+    public List<String> parse(ServiceConfig config) {
+        String raw = config.getStartupCommand();
+        if (raw == null || raw.isBlank()) {
+            return Collections.emptyList();
+        }
+
+        // 构建变量映射
+        Map<String, String> variables = new HashMap<>();
+        if (config.getVariables() != null) {
+            for (CustomVariable var : config.getVariables()) {
+                if (var.getName() != null && var.getValue() != null) {
+                    variables.put(var.getName(), var.getValue());
+                }
+            }
+        }
+
+        // 按行处理
+        String[] lines = raw.split("\\r?\\n");
+        List<String> result = new ArrayList<>();
+
+        for (String line : lines) {
+            String processed = processLine(line.trim(), variables);
+            if (processed != null && !processed.isBlank()) {
+                result.add(processed);
+            }
+        }
+
+        log.debug("命令解析完成: 原始命令={}, 变量={}, 解析结果={}", raw, variables, result);
+        return result;
+    }
+
+    /**
+     * 处理单行命令:先处理可选块,再替换变量
+     */
+    private String processLine(String line, Map<String, String> variables) {
+        // 先处理可选块
+        String resolved = resolveOptionalBlocks(line, variables);
+        if (resolved == null) {
+            return null;
+        }
+
+        // 替换剩余变量
+        return replaceVariables(resolved, variables);
+    }
+
+    /**
+     * 递归处理可选块 [...]
+     * <p>
+     * 策略:从内到外逐层解析。当遇到 [...] 时,检查内部所有变量是否都有值:
+     * - 全部有值 → 移除 [] 保留内容
+     * - 有缺失 → 整个块移除
+     */
+    private String resolveOptionalBlocks(String text, Map<String, String> variables) {
+        String result = text;
+        int maxIterations = 10; // 防止无限循环
+        int iteration = 0;
+
+        while (iteration < maxIterations) {
+            // 查找最内层的可选块(不包含嵌套 [])
+            Matcher matcher = Pattern.compile("\\[([^\\[\\]]*)]").matcher(result);
+            if (!matcher.find()) {
+                break;
+            }
+
+            String blockContent = matcher.group(1);
+            boolean allVarsPresent = checkAllVariablesPresent(blockContent, variables);
+
+            if (allVarsPresent) {
+                // 移除外层的 [],保留内容
+                result = result.substring(0, matcher.start()) + blockContent + result.substring(matcher.end());
+            } else {
+                // 整块移除
+                result = result.substring(0, matcher.start()) + result.substring(matcher.end());
+            }
+
+            iteration++;
+        }
+
+        return result.trim();
+    }
+
+    /**
+     * 检查文本中所有 {{VAR}} 占位符对应的变量是否都存在且有值
+     */
+    private boolean checkAllVariablesPresent(String text, Map<String, String> variables) {
+        Matcher matcher = VARIABLE_PATTERN.matcher(text);
+        while (matcher.find()) {
+            String varName = matcher.group(1);
+            if (!variables.containsKey(varName) || variables.get(varName).isBlank()) {
+                log.debug("变量 {} 未定义或为空,可选块将被移除", varName);
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * 替换文本中的 {{VAR}} 为实际值
+     */
+    private String replaceVariables(String text, Map<String, String> variables) {
+        Matcher matcher = VARIABLE_PATTERN.matcher(text);
+        StringBuilder sb = new StringBuilder();
+        while (matcher.find()) {
+            String varName = matcher.group(1);
+            String value = variables.getOrDefault(varName, "");
+            matcher.appendReplacement(sb, Matcher.quoteReplacement(value));
+        }
+        matcher.appendTail(sb);
+        return sb.toString().trim();
+    }
+
+    /**
+     * 解析 Web URL,支持 {{VAR:default}} 语法。
+     * <p>
+     * 规则:
+     *   - {{VAR}} → 替换为变量值,变量不存在则置空
+     *   - {{VAR:default}} → 变量不存在时使用 default 作为值
+     *
+     * @return 解析后的 URL,如果结果为空或空白则返回 null
+     */
+    public String resolveWebUrl(ServiceConfig config) {
+        String url = config.getWebUrl();
+        if (url == null || url.isBlank()) {
+            return null;
+        }
+
+        Map<String, String> variables = new HashMap<>();
+        if (config.getVariables() != null) {
+            for (CustomVariable var : config.getVariables()) {
+                if (var.getName() != null && var.getValue() != null) {
+                    variables.put(var.getName(), var.getValue());
+                }
+            }
+        }
+
+        Matcher matcher = URL_VAR_PATTERN.matcher(url);
+        StringBuilder sb = new StringBuilder();
+        while (matcher.find()) {
+            String varName = matcher.group(1);
+            String defaultValue = matcher.group(2);
+            String value = variables.getOrDefault(varName, defaultValue != null ? defaultValue : "");
+            matcher.appendReplacement(sb, Matcher.quoteReplacement(value));
+        }
+        matcher.appendTail(sb);
+
+        String resolved = sb.toString().trim();
+        log.debug("URL 解析完成: 原始={}, 结果={}", url, resolved);
+        return resolved.isBlank() ? null : resolved;
+    }
+}

+ 113 - 0
backend/src/main/java/com/svcman/service/PidFileManager.java

@@ -0,0 +1,113 @@
+package com.svcman.service;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.nio.file.*;
+import java.util.*;
+import java.util.stream.Stream;
+
+/**
+ * PID 文件管理器:将服务进程 PID 持久化到磁盘。
+ * <p>
+ * 文件位置: {data-dir}/pids/{serviceId}.pid
+ * 文件内容: PID 数字(纯文本)
+ */
+@Slf4j
+@Component
+public class PidFileManager {
+
+    private final Path pidDir;
+
+    public PidFileManager(@Value("${app.data-dir}") String dataDir) {
+        this.pidDir = Paths.get(dataDir, "pids");
+    }
+
+    /**
+     * 保存服务 PID 到文件
+     */
+    public void savePid(String serviceId, long pid) {
+        try {
+            Files.createDirectories(pidDir);
+            Path pidFile = pidDir.resolve(serviceId + ".pid");
+            Files.writeString(pidFile, String.valueOf(pid),
+                    StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
+            log.debug("PID 文件已保存: {} -> {}", serviceId, pid);
+        } catch (IOException e) {
+            log.error("保存 PID 文件失败: serviceId={}, pid={}", serviceId, pid, e);
+        }
+    }
+
+    /**
+     * 删除服务的 PID 文件
+     */
+    public void deletePid(String serviceId) {
+        Path pidFile = pidDir.resolve(serviceId + ".pid");
+        try {
+            Files.deleteIfExists(pidFile);
+            log.debug("PID 文件已删除: {}", serviceId);
+        } catch (IOException e) {
+            log.error("删除 PID 文件失败: serviceId={}", serviceId, e);
+        }
+    }
+
+    /**
+     * 读取所有已持久化的 PID。
+     *
+     * @return serviceId -> PID 的映射(仅包含文件存在且内容合法的条目)
+     */
+    public Map<String, Long> loadAllPids() {
+        Map<String, Long> result = new LinkedHashMap<>();
+        if (!Files.exists(pidDir)) {
+            return result;
+        }
+
+        try (Stream<Path> files = Files.list(pidDir)) {
+            files.filter(p -> p.toString().endsWith(".pid"))
+                 .forEach(p -> {
+                     String fileName = p.getFileName().toString();
+                     String serviceId = fileName.substring(0, fileName.length() - ".pid".length());
+                     try {
+                         String content = Files.readString(p).trim();
+                         long pid = Long.parseLong(content);
+                         result.put(serviceId, pid);
+                     } catch (Exception e) {
+                         log.warn("读取 PID 文件失败,将忽略: {}", p, e);
+                     }
+                 });
+        } catch (IOException e) {
+            log.error("列出 PID 目录失败", e);
+        }
+
+        return result;
+    }
+
+    /**
+     * 检查指定 PID 对应的进程是否仍在运行。
+     * 使用 Java 9+ 的 ProcessHandle API。
+     */
+    public boolean isProcessAlive(long pid) {
+        try {
+            Optional<ProcessHandle> handle = ProcessHandle.of(pid);
+            return handle.isPresent() && handle.get().isAlive();
+        } catch (Exception e) {
+            log.debug("检查进程存活状态失败: pid={}", pid);
+            return false;
+        }
+    }
+
+    /**
+     * 获取进程信息(用于日志/调试)
+     */
+    public Optional<ProcessHandle.Info> getProcessInfo(long pid) {
+        try {
+            return ProcessHandle.of(pid).map(ProcessHandle::info);
+        } catch (Exception e) {
+            return Optional.empty();
+        }
+    }
+}

+ 370 - 0
backend/src/main/java/com/svcman/service/PortService.java

@@ -0,0 +1,370 @@
+package com.svcman.service;
+
+import com.svcman.model.ServiceConfig;
+import com.svcman.model.ServiceConfig.CustomVariable;
+import com.svcman.repository.ServiceConfigRepository;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+/**
+ * 端口管理服务:检测端口占用、杀死占用进程
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class PortService {
+
+    private final ServiceConfigRepository repository;
+
+    /** 端口类变量名模式(不区分大小写) */
+    private static final Set<String> PORT_VARIABLE_NAMES = Set.of(
+            "SERVER_PORT", "PORT", "SERVERPORT", "SERVICE_PORT", "HTTP_PORT", "HTTPS_PORT",
+            "APP_PORT", "API_PORT", "WEB_PORT", "TOMCAT_PORT", "JETTY_PORT"
+    );
+
+    /** 数字端口号正则 */
+    private static final Pattern PORT_NUMBER_PATTERN = Pattern.compile("^\\d{1,5}$");
+
+    /** netstat 输出中提取 PID 的正则(Windows: TCP ... LISTENING ... PID) */
+    private static final Pattern NETSTAT_PID_PATTERN = Pattern.compile("(?i)\\bLISTENING\\s+(\\d+)\\s*$");
+
+    /** lsof 输出中提取 PID(Linux/Mac) */
+    private static final Pattern LSOF_PID_PATTERN = Pattern.compile("^\\S+\\s+(\\d+)");
+
+    private static final boolean IS_WINDOWS = System.getProperty("os.name", "").toLowerCase().contains("win");
+
+    // ========== DTO ==========
+
+    @Data
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class PortCheckResult {
+        /** 变量名 */
+        private String variableName;
+        /** 端口号 */
+        private int port;
+        /** 是否被占用 */
+        private boolean occupied;
+        /** 占用进程列表 */
+        private List<OccupiedProcess> processes;
+    }
+
+    @Data
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class OccupiedProcess {
+        /** 进程 PID */
+        private long pid;
+        /** 进程名称(可获取时) */
+        private String processName;
+        /** 进程命令行(可获取时) */
+        private String commandLine;
+    }
+
+    @Data
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class KillResult {
+        private boolean success;
+        private String message;
+    }
+
+    // ========== 公共方法 ==========
+
+    /**
+     * 检查指定服务的端口类变量对应的端口是否被占用
+     */
+    public List<PortCheckResult> checkPorts(String serviceId) {
+        ServiceConfig config = repository.findById(serviceId)
+                .orElseThrow(() -> new IllegalArgumentException("服务不存在: " + serviceId));
+
+        List<Integer> ports = extractPorts(config);
+        if (ports.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        // 去重
+        ports = ports.stream().distinct().collect(Collectors.toList());
+
+        // 建立端口 → 变量名的映射
+        Map<Integer, List<String>> portToVars = new LinkedHashMap<>();
+        if (config.getVariables() != null) {
+            for (CustomVariable v : config.getVariables()) {
+                if (isPortVariable(v.getName()) && isValidPort(v.getValue())) {
+                    int port = Integer.parseInt(v.getValue());
+                    portToVars.computeIfAbsent(port, k -> new ArrayList<>()).add(v.getName());
+                }
+            }
+        }
+
+        List<PortCheckResult> results = new ArrayList<>();
+        for (int port : ports) {
+            List<OccupiedProcess> processes = findProcessesOnPort(port);
+            List<String> varNames = portToVars.getOrDefault(port, List.of());
+
+            PortCheckResult result = new PortCheckResult();
+            result.setPort(port);
+            result.setVariableName(String.join(", ", varNames));
+            result.setOccupied(!processes.isEmpty());
+            result.setProcesses(processes);
+            results.add(result);
+        }
+
+        return results;
+    }
+
+    /**
+     * 杀死指定 PID 的进程
+     */
+    public KillResult killProcess(long pid) {
+        if (pid <= 0) {
+            return new KillResult(false, "无效的 PID: " + pid);
+        }
+
+        // 安全检查:不允许杀死 PID 0 或 4(Windows 系统进程)
+        if (pid == 0 || pid == 4) {
+            return new KillResult(false, "不允许杀死系统进程 (PID=" + pid + ")");
+        }
+
+        log.info("尝试杀死进程: PID={}", pid);
+
+        try {
+            ProcessBuilder pb;
+            if (IS_WINDOWS) {
+                pb = new ProcessBuilder("taskkill", "/F", "/PID", String.valueOf(pid));
+            } else {
+                pb = new ProcessBuilder("kill", "-9", String.valueOf(pid));
+            }
+
+            Process process = pb.start();
+            int exitCode = process.waitFor();
+
+            String output = readProcessOutput(process);
+
+            if (exitCode == 0) {
+                log.info("进程 PID={} 已被杀死", pid);
+                return new KillResult(true, "进程 PID=" + pid + " 已被终止");
+            } else {
+                log.warn("杀死进程 PID={} 失败: exitCode={}, output={}", pid, exitCode, output);
+                return new KillResult(false, "终止进程失败 (PID=" + pid + "): " + output);
+            }
+        } catch (Exception e) {
+            log.error("杀死进程异常: PID={}", pid, e);
+            return new KillResult(false, "操作失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 检查服务是否包含端口类变量
+     */
+    public boolean hasPortVariables(ServiceConfig config) {
+        if (config.getVariables() == null || config.getVariables().isEmpty()) {
+            return false;
+        }
+        return config.getVariables().stream()
+                .anyMatch(v -> isPortVariable(v.getName()) && isValidPort(v.getValue()));
+    }
+
+    // ========== 私有方法 ==========
+
+    /**
+     * 从服务配置中提取所有端口号
+     */
+    private List<Integer> extractPorts(ServiceConfig config) {
+        List<Integer> ports = new ArrayList<>();
+        if (config.getVariables() == null) {
+            return ports;
+        }
+        for (CustomVariable v : config.getVariables()) {
+            if (isPortVariable(v.getName()) && isValidPort(v.getValue())) {
+                ports.add(Integer.parseInt(v.getValue()));
+            }
+        }
+        return ports;
+    }
+
+    /**
+     * 判断变量名是否为端口类变量
+     */
+    private boolean isPortVariable(String varName) {
+        if (varName == null) return false;
+        // 精确匹配预定义列表(不区分大小写)
+        String upper = varName.toUpperCase().replace("_", "");
+        return PORT_VARIABLE_NAMES.stream()
+                .map(n -> n.replace("_", ""))
+                .anyMatch(n -> n.equals(upper));
+    }
+
+    /**
+     * 判断值是否为有效端口号(1-65535)
+     */
+    private boolean isValidPort(String value) {
+        if (value == null || value.isBlank()) return false;
+        Matcher m = PORT_NUMBER_PATTERN.matcher(value.trim());
+        if (!m.matches()) return false;
+        int port = Integer.parseInt(value.trim());
+        return port >= 1 && port <= 65535;
+    }
+
+    /**
+     * 查找占用指定端口的进程
+     */
+    private List<OccupiedProcess> findProcessesOnPort(int port) {
+        try {
+            if (IS_WINDOWS) {
+                return findProcessesWindows(port);
+            } else {
+                return findProcessesLinux(port);
+            }
+        } catch (Exception e) {
+            log.error("查找端口 {} 的占用进程失败", port, e);
+            return Collections.emptyList();
+        }
+    }
+
+    /**
+     * Windows: 使用 netstat -ano 查找端口占用
+     */
+    private List<OccupiedProcess> findProcessesWindows(int port) throws Exception {
+        ProcessBuilder pb = new ProcessBuilder("netstat", "-ano", "-p", "TCP");
+        Process process = pb.start();
+        String output = readProcessOutput(process);
+
+        List<OccupiedProcess> result = new ArrayList<>();
+        Set<Long> seenPids = new HashSet<>();
+
+        // 查找包含 :端口号 的 LISTENING 行
+        Pattern linePattern = Pattern.compile(
+                "TCP\\s+\\S+:" + port + "\\s+\\S+\\s+LISTENING\\s+(\\d+)",
+                Pattern.CASE_INSENSITIVE
+        );
+
+        for (String line : output.split("\\r?\\n")) {
+            Matcher m = linePattern.matcher(line.trim());
+            if (m.find()) {
+                long pid = Long.parseLong(m.group(1));
+                if (seenPids.add(pid)) {
+                    OccupiedProcess op = new OccupiedProcess();
+                    op.setPid(pid);
+                    // 尝试获取进程名
+                    op.setProcessName(getProcessNameWindows(pid));
+                    op.setCommandLine(getProcessCommandLineWindows(pid));
+                    result.add(op);
+                }
+            }
+        }
+
+        return result;
+    }
+
+    /**
+     * Linux/Mac: 使用 lsof 查找端口占用
+     */
+    private List<OccupiedProcess> findProcessesLinux(int port) throws Exception {
+        ProcessBuilder pb = new ProcessBuilder("lsof", "-i", ":" + port, "-t", "-P", "-n");
+        Process process = pb.start();
+        String output = readProcessOutput(process);
+
+        List<OccupiedProcess> result = new ArrayList<>();
+        Set<Long> seenPids = new HashSet<>();
+
+        for (String line : output.split("\\r?\\n")) {
+            line = line.trim();
+            if (line.isEmpty()) continue;
+            Matcher m = LSOF_PID_PATTERN.matcher(line);
+            if (m.find()) {
+                long pid = Long.parseLong(m.group(1));
+                if (seenPids.add(pid)) {
+                    OccupiedProcess op = new OccupiedProcess();
+                    op.setPid(pid);
+                    op.setProcessName(line.split("\\s+")[0]);
+                    op.setCommandLine("");
+                    result.add(op);
+                }
+            }
+        }
+
+        return result;
+    }
+
+    /**
+     * Windows: 通过 tasklist 获取进程名
+     */
+    private String getProcessNameWindows(long pid) {
+        try {
+            ProcessBuilder pb = new ProcessBuilder("tasklist", "/FI", "PID eq " + pid, "/FO", "CSV", "/NH");
+            Process process = pb.start();
+            String output = readProcessOutput(process);
+            // 格式: "java.exe","12345","Console","1","123,456 K"
+            for (String line : output.split("\\r?\\n")) {
+                line = line.trim();
+                if (line.contains(String.valueOf(pid))) {
+                    String[] parts = line.split("\",\"");
+                    if (parts.length > 0) {
+                        return parts[0].replace("\"", "").trim();
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.debug("获取进程名失败: PID={}", pid);
+        }
+        return "unknown";
+    }
+
+    /**
+     * Windows: 通过 wmic 获取进程命令行
+     */
+    private String getProcessCommandLineWindows(long pid) {
+        try {
+            ProcessBuilder pb = new ProcessBuilder("wmic", "process", "where", "ProcessId=" + pid,
+                    "get", "CommandLine", "/format:list");
+            Process process = pb.start();
+            String output = readProcessOutput(process);
+            for (String line : output.split("\\r?\\n")) {
+                line = line.trim();
+                if (line.startsWith("CommandLine=")) {
+                    return line.substring("CommandLine=".length()).trim();
+                }
+            }
+        } catch (Exception e) {
+            log.debug("获取进程命令行失败: PID={}", pid);
+        }
+        return "";
+    }
+
+    /**
+     * 读取进程输出
+     */
+    private String readProcessOutput(Process process) {
+        StringBuilder sb = new StringBuilder();
+        try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
+            String line;
+            while ((line = reader.readLine()) != null) {
+                sb.append(line).append("\n");
+            }
+        } catch (Exception e) {
+            log.debug("读取进程输出失败", e);
+        }
+        // 也读取错误流
+        try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
+            String line;
+            while ((line = reader.readLine()) != null) {
+                sb.append(line).append("\n");
+            }
+        } catch (Exception e) {
+            log.debug("读取进程错误输出失败", e);
+        }
+        return sb.toString();
+    }
+}

+ 514 - 0
backend/src/main/java/com/svcman/service/ProcessManager.java

@@ -0,0 +1,514 @@
+package com.svcman.service;
+
+import com.svcman.model.ServiceConfig;
+import com.svcman.model.ServiceConfig.TerminalType;
+import com.svcman.repository.ServiceConfigRepository;
+import jakarta.annotation.PostConstruct;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.io.*;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 进程管理器:负责服务的启动、停止、进程追踪。
+ * <p>
+ * PID 持久化策略:
+ *   - 启动服务时,将 PID 写入 {data-dir}/pids/{serviceId}.pid
+ *   - 停止服务时,删除 PID 文件
+ *   - 应用重启时,读取所有 PID 文件,检查进程是否仍在运行,恢复运行状态
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ProcessManager {
+
+    private final ServiceConfigRepository repository;
+    private final CommandParser commandParser;
+    private final PidFileManager pidFileManager;
+
+    /** 运行中的进程:serviceId -> Process(仅限当前 JVM 启动的进程) */
+    private final Map<String, Process> runningProcesses = new ConcurrentHashMap<>();
+
+    /** 恢复的运行中 PID(非当前 JVM 启动):serviceId -> PID */
+    private final Map<String, Long> recoveredPids = new ConcurrentHashMap<>();
+
+    /** 服务日志输出:serviceId -> 日志文件路径 */
+    private final Map<String, String> logFilePaths = new ConcurrentHashMap<>();
+
+    /** 服务日志编码:serviceId -> charset name */
+    private final Map<String, String> logEncodings = new ConcurrentHashMap<>();
+
+    /**
+     * 应用启动时恢复服务状态。
+     * 读取所有持久化的 PID 文件,检查进程是否仍在运行。
+     */
+    @PostConstruct
+    public void recoverState() {
+        log.info("开始恢复服务运行状态...");
+        Map<String, Long> persistedPids = pidFileManager.loadAllPids();
+
+        if (persistedPids.isEmpty()) {
+            // 没有持久化的 PID,将所有服务重置为 STOPPED
+            resetAllToStopped();
+            return;
+        }
+
+        int recovered = 0;
+        int cleaned = 0;
+
+        for (Map.Entry<String, Long> entry : persistedPids.entrySet()) {
+            String serviceId = entry.getKey();
+            long pid = entry.getValue();
+
+            Optional<ServiceConfig> configOpt = repository.findById(serviceId);
+            if (configOpt.isEmpty()) {
+                // 服务已被删除,清理 PID 文件
+                pidFileManager.deletePid(serviceId);
+                log.info("清理已删除服务的 PID 文件: serviceId={}, pid={}", serviceId, pid);
+                cleaned++;
+                continue;
+            }
+
+            ServiceConfig config = configOpt.get();
+
+            if (pidFileManager.isProcessAlive(pid)) {
+                // 进程仍在运行 → 恢复状态
+                recoveredPids.put(serviceId, pid);
+                repository.updateRuntimeState(serviceId, ServiceConfig.RunStatus.RUNNING, pid, null);
+                // 恢复日志编码和日志路径
+                restoreLogState(serviceId, config);
+                log.info("恢复运行状态: {} (PID={})", config.getName(), pid);
+                recovered++;
+            } else {
+                // 进程已退出 → 清理 PID 文件,重置状态
+                pidFileManager.deletePid(serviceId);
+                repository.updateRuntimeState(serviceId, ServiceConfig.RunStatus.STOPPED, null, "应用重启时检测到进程已退出");
+                log.info("进程已退出,清理状态: {} (PID={})", config.getName(), pid);
+                cleaned++;
+            }
+        }
+
+        // 对没有 PID 文件的服务,重置为 STOPPED
+        for (ServiceConfig config : repository.findAll()) {
+            if (!persistedPids.containsKey(config.getId())) {
+                config.setStatus(ServiceConfig.RunStatus.STOPPED);
+                config.setPid(null);
+                config.setStatusMessage(null);
+            }
+        }
+
+        log.info("服务状态恢复完成: 恢复运行={}, 清理={}, 总服务数={}",
+                recovered, cleaned, repository.findAll().size());
+    }
+
+    /**
+     * 将所有服务重置为 STOPPED 状态(无 PID 文件时使用)
+     */
+    private void resetAllToStopped() {
+        for (ServiceConfig config : repository.findAll()) {
+            config.setStatus(ServiceConfig.RunStatus.STOPPED);
+            config.setPid(null);
+            config.setStatusMessage(null);
+        }
+        log.info("无持久化 PID,所有服务状态已重置为 STOPPED");
+    }
+
+    /**
+     * 恢复服务的日志相关状态(编码、日志文件路径)
+     */
+    private void restoreLogState(String serviceId, ServiceConfig config) {
+        // 恢复日志编码
+        String encoding = config.getLogEncoding();
+        if (encoding != null && Charset.isSupported(encoding)) {
+            logEncodings.put(serviceId, encoding);
+        } else {
+            logEncodings.put(serviceId, "UTF-8");
+        }
+
+        // 恢复日志文件路径(指向已有的日志文件)
+        Path logDir = Paths.get(System.getProperty("user.dir"), "../logs/services");
+        Path logFile = logDir.resolve(serviceId + ".log");
+        if (Files.exists(logFile)) {
+            logFilePaths.put(serviceId, logFile.toAbsolutePath().toString());
+        }
+    }
+
+    /**
+     * 获取服务的日志编码,默认 UTF-8
+     */
+    private Charset getLogCharset(String serviceId) {
+        String encoding = logEncodings.get(serviceId);
+        if (encoding != null && Charset.isSupported(encoding)) {
+            return Charset.forName(encoding);
+        }
+        return StandardCharsets.UTF_8;
+    }
+
+    /**
+     * 启动服务
+     */
+    public synchronized void startService(String serviceId) {
+        ServiceConfig config = repository.findById(serviceId)
+                .orElseThrow(() -> new IllegalArgumentException("服务不存在: " + serviceId));
+
+        if (isRunning(serviceId)) {
+            throw new IllegalStateException("服务已在运行: " + config.getName());
+        }
+
+        List<String> commands = commandParser.parse(config);
+        if (commands.isEmpty()) {
+            throw new IllegalStateException("解析后命令为空,请检查启动命令配置");
+        }
+
+        log.info("启动服务 {}: 命令={}", config.getName(), commands);
+
+        try {
+            repository.updateRuntimeState(serviceId, ServiceConfig.RunStatus.STARTING, null, null);
+
+            ProcessBuilder pb = buildProcessBuilder(config, commands);
+            Process process = pb.start();
+
+            // 记录进程
+            runningProcesses.put(serviceId, process);
+            // 清除恢复的 PID 记录(如有)
+            recoveredPids.remove(serviceId);
+
+            // 获取 PID
+            long pid = getPid(process);
+            log.info("服务 {} 已启动, PID={}", config.getName(), pid);
+
+            // 持久化 PID 到磁盘
+            pidFileManager.savePid(serviceId, pid);
+
+            repository.updateRuntimeState(serviceId, ServiceConfig.RunStatus.RUNNING, pid, null);
+            config.setLastStartedAt(java.time.Instant.now());
+
+            // 异步捕获输出到日志文件
+            String encoding = config.getLogEncoding();
+            if (encoding != null && Charset.isSupported(encoding)) {
+                logEncodings.put(serviceId, encoding);
+            } else {
+                logEncodings.put(serviceId, "UTF-8");
+            }
+            captureOutput(serviceId, process);
+
+            // 异步监听进程退出
+            process.onExit().thenAccept(p -> {
+                int exitCode = p.exitValue();
+                log.info("服务 {} 进程已退出, exitCode={}", config.getName(), exitCode);
+                runningProcesses.remove(serviceId);
+                pidFileManager.deletePid(serviceId);
+                repository.updateRuntimeState(serviceId, ServiceConfig.RunStatus.STOPPED, null,
+                        exitCode == 0 ? "正常退出" : "异常退出(code=" + exitCode + ")");
+                config.setLastStoppedAt(java.time.Instant.now());
+            });
+
+        } catch (Exception e) {
+            log.error("启动服务 {} 失败", config.getName(), e);
+            repository.updateRuntimeState(serviceId, ServiceConfig.RunStatus.ERROR, null, e.getMessage());
+            throw new RuntimeException("启动服务失败: " + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 停止服务(优雅终止)
+     */
+    public synchronized void stopService(String serviceId) {
+        ServiceConfig config = repository.findById(serviceId)
+                .orElseThrow(() -> new IllegalArgumentException("服务不存在: " + serviceId));
+
+        // 情况 1:当前 JVM 启动的进程
+        Process process = runningProcesses.get(serviceId);
+        if (process != null && process.isAlive()) {
+            stopManagedProcess(serviceId, config, process);
+            return;
+        }
+
+        // 清理已退出的管理进程
+        if (process != null) {
+            runningProcesses.remove(serviceId);
+        }
+
+        // 情况 2:恢复的 PID(非当前 JVM 启动,但仍在运行)
+        Long recoveredPid = recoveredPids.get(serviceId);
+        if (recoveredPid != null) {
+            stopRecoveredProcess(serviceId, config, recoveredPid);
+            return;
+        }
+
+        // 情况 3:没有运行中的进程
+        pidFileManager.deletePid(serviceId);
+        repository.updateRuntimeState(serviceId, ServiceConfig.RunStatus.STOPPED, null, null);
+        log.info("服务 {} 未在运行", config.getName());
+    }
+
+    /**
+     * 停止当前 JVM 管理的进程
+     */
+    private void stopManagedProcess(String serviceId, ServiceConfig config, Process process) {
+        log.info("停止服务 {}: PID={}", config.getName(), getPid(process));
+        repository.updateRuntimeState(serviceId, ServiceConfig.RunStatus.STOPPING, config.getPid(), null);
+
+        try {
+            process.destroy();
+            boolean exited = process.waitFor(10, java.util.concurrent.TimeUnit.SECONDS);
+            if (!exited) {
+                log.warn("服务 {} 未在10秒内退出,强制终止", config.getName());
+                process.destroyForcibly();
+                process.waitFor(5, java.util.concurrent.TimeUnit.SECONDS);
+            }
+
+            runningProcesses.remove(serviceId);
+            pidFileManager.deletePid(serviceId);
+            repository.updateRuntimeState(serviceId, ServiceConfig.RunStatus.STOPPED, null, null);
+            config.setLastStoppedAt(java.time.Instant.now());
+            log.info("服务 {} 已停止", config.getName());
+
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            process.destroyForcibly();
+            runningProcesses.remove(serviceId);
+            pidFileManager.deletePid(serviceId);
+            repository.updateRuntimeState(serviceId, ServiceConfig.RunStatus.STOPPED, null, "被中断强制终止");
+        }
+    }
+
+    /**
+     * 停止恢复的进程(非当前 JVM 启动)
+     * 使用 ProcessHandle.destroy() 尝试终止
+     */
+    private void stopRecoveredProcess(String serviceId, ServiceConfig config, long pid) {
+        log.info("停止恢复的进程: {} PID={}", config.getName(), pid);
+        repository.updateRuntimeState(serviceId, ServiceConfig.RunStatus.STOPPING, pid, null);
+
+        Optional<ProcessHandle> handleOpt = ProcessHandle.of(pid);
+        if (handleOpt.isPresent()) {
+            ProcessHandle handle = handleOpt.get();
+            if (handle.isAlive()) {
+                boolean destroyed = handle.destroy();
+                if (destroyed) {
+                    // 等待最多 10 秒
+                    try {
+                        boolean exited = handle.onExit().get(10, java.util.concurrent.TimeUnit.SECONDS) != null;
+                        if (!exited) {
+                            handle.destroyForcibly();
+                        }
+                    } catch (Exception e) {
+                        log.warn("等待进程退出超时或异常: PID={}", pid);
+                        handle.destroyForcibly();
+                    }
+                } else {
+                    log.warn("destroy() 返回 false,尝试 destroyForcibly: PID={}", pid);
+                    handle.destroyForcibly();
+                }
+            }
+        }
+
+        recoveredPids.remove(serviceId);
+        pidFileManager.deletePid(serviceId);
+        repository.updateRuntimeState(serviceId, ServiceConfig.RunStatus.STOPPED, null, null);
+        config.setLastStoppedAt(java.time.Instant.now());
+        log.info("恢复的进程已停止: {} PID={}", config.getName(), pid);
+    }
+
+    /**
+     * 一键启动所有服务
+     */
+    public Map<String, String> startAll() {
+        Map<String, String> results = new LinkedHashMap<>();
+        for (ServiceConfig config : repository.findAll()) {
+            try {
+                startService(config.getId());
+                results.put(config.getName(), "已启动");
+            } catch (Exception e) {
+                results.put(config.getName(), "启动失败: " + e.getMessage());
+            }
+        }
+        return results;
+    }
+
+    /**
+     * 一键停止所有服务
+     */
+    public Map<String, String> stopAll() {
+        Map<String, String> results = new LinkedHashMap<>();
+        for (ServiceConfig config : repository.findAll()) {
+            try {
+                stopService(config.getId());
+                results.put(config.getName(), "已停止");
+            } catch (Exception e) {
+                results.put(config.getName(), "停止失败: " + e.getMessage());
+            }
+        }
+        return results;
+    }
+
+    /**
+     * 获取服务实时日志
+     */
+    public String getServiceLog(String serviceId) {
+        String logPath = logFilePaths.get(serviceId);
+        if (logPath == null) {
+            return "暂无日志";
+        }
+        try {
+            Path path = Paths.get(logPath);
+            if (!Files.exists(path)) {
+                return "日志文件不存在";
+            }
+            Charset charset = getLogCharset(serviceId);
+            List<String> lines = Files.readAllLines(path, charset);
+            int start = Math.max(0, lines.size() - 200);
+            return String.join("\n", lines.subList(start, lines.size()));
+        } catch (IOException e) {
+            return "读取日志失败: " + e.getMessage();
+        }
+    }
+
+    /**
+     * 判断服务是否在运行。
+     * 同时检查当前 JVM 管理的进程和恢复的 PID。
+     */
+    public boolean isRunning(String serviceId) {
+        // 检查当前 JVM 管理的进程
+        Process process = runningProcesses.get(serviceId);
+        if (process != null && process.isAlive()) {
+            return true;
+        }
+
+        // 检查恢复的 PID
+        Long recoveredPid = recoveredPids.get(serviceId);
+        if (recoveredPid != null && pidFileManager.isProcessAlive(recoveredPid)) {
+            return true;
+        }
+
+        // 清理无效记录
+        if (process != null) {
+            runningProcesses.remove(serviceId);
+        }
+        if (recoveredPid != null) {
+            recoveredPids.remove(serviceId);
+        }
+
+        return false;
+    }
+
+    // ========== 私有方法 ==========
+
+    private ProcessBuilder buildProcessBuilder(ServiceConfig config, List<String> commands) {
+        TerminalType terminal = config.getTerminalType();
+        if (terminal == null) {
+            terminal = TerminalType.CMD;
+        }
+
+        String workDir = config.getWorkDir();
+        ProcessBuilder pb;
+
+        switch (terminal) {
+            case POWERSHELL -> {
+                String script = String.join("; ", commands);
+                pb = new ProcessBuilder("powershell.exe", "-NoProfile", "-Command", script);
+            }
+            case GIT_BASH -> {
+                String script = String.join(" && ", commands);
+                pb = new ProcessBuilder("bash", "-c", script);
+            }
+            case SSH -> {
+                pb = buildSshProcessBuilder(config, commands);
+                return pb;
+            }
+            default -> {
+                String script = String.join(" && ", commands);
+                pb = new ProcessBuilder("cmd.exe", "/c", script);
+            }
+        }
+
+        if (workDir != null && !workDir.isBlank()) {
+            pb.directory(new File(workDir));
+        }
+
+        pb.environment().putAll(System.getenv());
+        return pb;
+    }
+
+    private ProcessBuilder buildSshProcessBuilder(ServiceConfig config, List<String> commands) {
+        ServiceConfig.SshConfig ssh = config.getSshConfig();
+        if (ssh == null) {
+            throw new IllegalArgumentException("SSH 配置缺失");
+        }
+
+        String remoteCommand = String.join(" && ", commands);
+
+        List<String> sshCmd = new ArrayList<>();
+        sshCmd.add("ssh");
+        if (ssh.getPort() != null) {
+            sshCmd.add("-p");
+            sshCmd.add(String.valueOf(ssh.getPort()));
+        }
+        sshCmd.add("-o");
+        sshCmd.add("StrictHostKeyChecking=no");
+        sshCmd.add(ssh.getUsername() + "@" + ssh.getHost());
+        sshCmd.add(remoteCommand);
+
+        ProcessBuilder pb = new ProcessBuilder(sshCmd);
+        return pb;
+    }
+
+    private long getPid(Process process) {
+        try {
+            return process.pid();
+        } catch (Exception e) {
+            return -1;
+        }
+    }
+
+    private void captureOutput(String serviceId, Process process) {
+        try {
+            Path logDir = Paths.get(System.getProperty("user.dir"), "../logs/services");
+            Files.createDirectories(logDir);
+            Path logFile = logDir.resolve(serviceId + ".log");
+            logFilePaths.put(serviceId, logFile.toAbsolutePath().toString());
+
+            if (Files.exists(logFile)) {
+                Files.delete(logFile);
+            }
+
+            Charset charset = getLogCharset(serviceId);
+            Thread stdoutThread = new Thread(() -> readStream(process.getInputStream(), logFile, charset), "svc-stdout-" + serviceId);
+            Thread stderrThread = new Thread(() -> readStream(process.getErrorStream(), logFile, charset), "svc-stderr-" + serviceId);
+            stdoutThread.setDaemon(true);
+            stderrThread.setDaemon(true);
+            stdoutThread.start();
+            stderrThread.start();
+
+        } catch (IOException e) {
+            log.error("创建服务日志文件失败: {}", serviceId, e);
+        }
+    }
+
+    private void readStream(InputStream inputStream, Path logFile, Charset charset) {
+        try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, charset));
+             BufferedWriter writer = Files.newBufferedWriter(logFile, charset,
+                     java.nio.file.StandardOpenOption.CREATE,
+                     java.nio.file.StandardOpenOption.APPEND)) {
+            String line;
+            while ((line = reader.readLine()) != null) {
+                synchronized (logFile) {
+                    writer.write(line);
+                    writer.newLine();
+                    writer.flush();
+                }
+            }
+        } catch (IOException e) {
+            log.debug("日志流关闭");
+        }
+    }
+}

+ 65 - 0
backend/src/main/java/com/svcman/service/ServiceConfigService.java

@@ -0,0 +1,65 @@
+package com.svcman.service;
+
+import com.svcman.model.ServiceConfig;
+import com.svcman.repository.ServiceConfigRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * 服务配置 CRUD 业务层
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ServiceConfigService {
+
+    private final ServiceConfigRepository repository;
+
+    public List<ServiceConfig> listAll() {
+        return repository.findAll();
+    }
+
+    public ServiceConfig getById(String id) {
+        return repository.findById(id)
+                .orElseThrow(() -> new IllegalArgumentException("服务不存在: " + id));
+    }
+
+    public ServiceConfig create(ServiceConfig config) {
+        validate(config);
+        return repository.save(config);
+    }
+
+    public ServiceConfig update(String id, ServiceConfig updated) {
+        ServiceConfig existing = getById(id);
+        validate(updated);
+
+        updated.setId(id);
+        updated.setCreatedAt(existing.getCreatedAt());
+        updated.setStatus(existing.getStatus());
+        updated.setPid(existing.getPid());
+        updated.setLastStartedAt(existing.getLastStartedAt());
+        updated.setLastStoppedAt(existing.getLastStoppedAt());
+
+        return repository.save(updated);
+    }
+
+    public void delete(String id) {
+        repository.deleteById(id);
+    }
+
+    private void validate(ServiceConfig config) {
+        if (config.getName() == null || config.getName().isBlank()) {
+            throw new IllegalArgumentException("服务名称不能为空");
+        }
+        if (config.getStartupCommand() == null || config.getStartupCommand().isBlank()) {
+            throw new IllegalArgumentException("启动命令不能为空");
+        }
+        if (config.getTerminalType() == ServiceConfig.TerminalType.SSH && config.getSshConfig() == null) {
+            throw new IllegalArgumentException("SSH 模式下必须提供 SSH 配置");
+        }
+    }
+}

+ 11 - 0
backend/src/main/resources/application.yml

@@ -0,0 +1,11 @@
+server:
+  port: 3945
+
+app:
+  data-dir: ${user.dir}/../data
+
+spring:
+  jackson:
+    default-property-inclusion: non_null
+    serialization:
+      indent_output: false

+ 31 - 0
backend/src/main/resources/logback-spring.xml

@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration>
+    <property name="LOG_DIR" value="${user.dir}/../logs"/>
+    <property name="PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"/>
+
+    <!-- 控制台输出 -->
+    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder>
+            <pattern>${PATTERN}</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+    </appender>
+
+    <!-- 30天滚动文件日志 -->
+    <appender name="ROLLING_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${LOG_DIR}/service-management.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/service-management.%d{yyyy-MM-dd}.log</fileNamePattern>
+            <maxHistory>30</maxHistory>
+        </rollingPolicy>
+        <encoder>
+            <pattern>${PATTERN}</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+    </appender>
+
+    <root level="INFO">
+        <appender-ref ref="CONSOLE"/>
+        <appender-ref ref="ROLLING_FILE"/>
+    </root>
+</configuration>

+ 24 - 0
frontend/.gitignore

@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 5 - 0
frontend/README.md

@@ -0,0 +1,5 @@
+# Vue 3 + TypeScript + Vite
+
+This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
+
+Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

+ 13 - 0
frontend/index.html

@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>frontend</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 1677 - 0
frontend/package-lock.json

@@ -0,0 +1,1677 @@
+{
+  "name": "frontend",
+  "version": "0.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "frontend",
+      "version": "0.0.0",
+      "dependencies": {
+        "@primevue/themes": "^4.5.4",
+        "axios": "^1.16.1",
+        "primeicons": "^7.0.0",
+        "primevue": "^4.5.5",
+        "vue": "^3.5.34"
+      },
+      "devDependencies": {
+        "@types/node": "^24.12.3",
+        "@vitejs/plugin-vue": "^6.0.6",
+        "@vue/tsconfig": "^0.9.1",
+        "typescript": "~6.0.2",
+        "vite": "^8.0.12",
+        "vue-tsc": "^3.2.8"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+      "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+      "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz",
+      "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.29.7"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz",
+      "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.29.7",
+        "@babel/helper-validator-identifier": "^7.29.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@emnapi/core": {
+      "version": "1.10.0",
+      "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz",
+      "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/wasi-threads": "1.2.1",
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@emnapi/runtime": {
+      "version": "1.10.0",
+      "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.10.0.tgz",
+      "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@emnapi/wasi-threads": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+      "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "license": "MIT"
+    },
+    "node_modules/@napi-rs/wasm-runtime": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
+      "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@tybys/wasm-util": "^0.10.1"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      },
+      "peerDependencies": {
+        "@emnapi/core": "^1.7.1",
+        "@emnapi/runtime": "^1.7.1"
+      }
+    },
+    "node_modules/@oxc-project/types": {
+      "version": "0.133.0",
+      "resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.133.0.tgz",
+      "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/Boshen"
+      }
+    },
+    "node_modules/@primeuix/styled": {
+      "version": "0.7.4",
+      "resolved": "https://registry.npmmirror.com/@primeuix/styled/-/styled-0.7.4.tgz",
+      "integrity": "sha512-QSO/NpOQg8e9BONWRBx9y8VGMCMYz0J/uKfNJEya/RGEu7ARx0oYW0ugI1N3/KB1AAvyGxzKBzGImbwg0KUiOQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@primeuix/utils": "^0.6.1"
+      },
+      "engines": {
+        "node": ">=12.11.0"
+      }
+    },
+    "node_modules/@primeuix/styles": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmmirror.com/@primeuix/styles/-/styles-2.0.3.tgz",
+      "integrity": "sha512-2ykAB6BaHzR/6TwF8ShpJTsZrid6cVIEBVlookSdvOdmlWuevGu5vWOScgIwqWwlZcvkFYAGR/SUV3OHCTBMdw==",
+      "license": "MIT",
+      "dependencies": {
+        "@primeuix/styled": "^0.7.4"
+      }
+    },
+    "node_modules/@primeuix/themes": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmmirror.com/@primeuix/themes/-/themes-2.0.3.tgz",
+      "integrity": "sha512-3fS1883mtCWhgUgNf/feiaaDSOND4EBIOu9tZnzJlJ8QtYyL6eFLcA6V3ymCWqLVXQ1+lTVEZv1gl47FIdXReg==",
+      "license": "MIT",
+      "dependencies": {
+        "@primeuix/styled": "^0.7.4"
+      }
+    },
+    "node_modules/@primeuix/utils": {
+      "version": "0.6.4",
+      "resolved": "https://registry.npmmirror.com/@primeuix/utils/-/utils-0.6.4.tgz",
+      "integrity": "sha512-pZ5f+vj7wSzRhC7KoEQRU5fvYAe+RP9+m39CTscZ3UywCD1Y2o6Fe1rRgklMPSkzUcty2jzkA0zMYkiJBD1hgg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.11.0"
+      }
+    },
+    "node_modules/@primevue/core": {
+      "version": "4.5.5",
+      "resolved": "https://registry.npmmirror.com/@primevue/core/-/core-4.5.5.tgz",
+      "integrity": "sha512-JpkXhq1ddc70JdsC3CC4dM+UbeeWuCW/8DpS9dNBfrOk824TLSlRlMEGFyVKqRMn5WPQvYLiy3xXfLQeNdSqhQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@primeuix/styled": "^0.7.4",
+        "@primeuix/utils": "^0.6.2"
+      },
+      "engines": {
+        "node": ">=12.11.0"
+      },
+      "peerDependencies": {
+        "vue": "^3.5.0"
+      }
+    },
+    "node_modules/@primevue/icons": {
+      "version": "4.5.5",
+      "resolved": "https://registry.npmmirror.com/@primevue/icons/-/icons-4.5.5.tgz",
+      "integrity": "sha512-eteOhTdAOXEYE9qW1AOrBBgDxQ2szHJxSkEK1XVdV2TKxGM5FQf03Ovms0VDyZTc16XBIgvwYjXJQS0BPbhPaA==",
+      "license": "MIT",
+      "dependencies": {
+        "@primeuix/utils": "^0.6.2",
+        "@primevue/core": "4.5.5"
+      },
+      "engines": {
+        "node": ">=12.11.0"
+      }
+    },
+    "node_modules/@primevue/themes": {
+      "version": "4.5.4",
+      "resolved": "https://registry.npmmirror.com/@primevue/themes/-/themes-4.5.4.tgz",
+      "integrity": "sha512-rUFZxMHLanTZdvZq4zgZPk+KRBZ3s7fE3bBK32OrZBkHQhEJmkJ7Ftd4w4QFlXyz1B7c+k5invZiOOCjwHXg9Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@primeuix/styled": "^0.7.4",
+        "@primeuix/themes": "^2.0.2"
+      },
+      "engines": {
+        "node": ">=12.11.0"
+      }
+    },
+    "node_modules/@rolldown/binding-android-arm64": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
+      "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-arm64": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
+      "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-x64": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
+      "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-freebsd-x64": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
+      "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
+      "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-gnu": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
+      "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-musl": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
+      "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
+      "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-s390x-gnu": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
+      "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-gnu": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
+      "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-musl": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
+      "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-openharmony-arm64": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
+      "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-wasm32-wasi": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
+      "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
+      "cpu": [
+        "wasm32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/core": "1.10.0",
+        "@emnapi/runtime": "1.10.0",
+        "@napi-rs/wasm-runtime": "^1.1.4"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-arm64-msvc": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
+      "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-x64-msvc": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
+      "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+      "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@tybys/wasm-util": {
+      "version": "0.10.2",
+      "resolved": "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
+      "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "24.12.4",
+      "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.12.4.tgz",
+      "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~7.16.0"
+      }
+    },
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "6.0.7",
+      "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
+      "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@rolldown/pluginutils": "^1.0.1"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "peerDependencies": {
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
+        "vue": "^3.2.25"
+      }
+    },
+    "node_modules/@volar/language-core": {
+      "version": "2.4.28",
+      "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.28.tgz",
+      "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/source-map": "2.4.28"
+      }
+    },
+    "node_modules/@volar/source-map": {
+      "version": "2.4.28",
+      "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.28.tgz",
+      "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@volar/typescript": {
+      "version": "2.4.28",
+      "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.28.tgz",
+      "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/language-core": "2.4.28",
+        "path-browserify": "^1.0.1",
+        "vscode-uri": "^3.0.8"
+      }
+    },
+    "node_modules/@vue/compiler-core": {
+      "version": "3.5.35",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.35.tgz",
+      "integrity": "sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.3",
+        "@vue/shared": "3.5.35",
+        "entities": "^7.0.1",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-dom": {
+      "version": "3.5.35",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.35.tgz",
+      "integrity": "sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-core": "3.5.35",
+        "@vue/shared": "3.5.35"
+      }
+    },
+    "node_modules/@vue/compiler-sfc": {
+      "version": "3.5.35",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.35.tgz",
+      "integrity": "sha512-G5VPMcXTSywXBgtFOZOnHKBxKSrwXUcvY1iaF5/hRcy7t0J6CH/d8ha9F4nzi00Fax1eLV0QHM7v4mQu68jydw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.3",
+        "@vue/compiler-core": "3.5.35",
+        "@vue/compiler-dom": "3.5.35",
+        "@vue/compiler-ssr": "3.5.35",
+        "@vue/shared": "3.5.35",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.21",
+        "postcss": "^8.5.15",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-ssr": {
+      "version": "3.5.35",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.35.tgz",
+      "integrity": "sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.35",
+        "@vue/shared": "3.5.35"
+      }
+    },
+    "node_modules/@vue/language-core": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.3.3.tgz",
+      "integrity": "sha512-X6p+7nfY7vVT6dQwUJ+v0Jfq/lwIfhL2jMi91dQ3ln4hnlGXlxsDu/FNkeyHYgvYtyQy18ZX76IZy7X4diDbiQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/language-core": "2.4.28",
+        "@vue/compiler-dom": "^3.5.0",
+        "@vue/shared": "^3.5.0",
+        "alien-signals": "^3.2.0",
+        "muggle-string": "^0.4.1",
+        "path-browserify": "^1.0.1",
+        "picomatch": "^4.0.4"
+      }
+    },
+    "node_modules/@vue/reactivity": {
+      "version": "3.5.35",
+      "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.35.tgz",
+      "integrity": "sha512-tVc+SsHConvh/Lz64qq1pP3rYArBmK42xonovEcxY74SQtvctZodG/zhq54P5dr38cVuw25d27cPNRdlMidpGQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/shared": "3.5.35"
+      }
+    },
+    "node_modules/@vue/runtime-core": {
+      "version": "3.5.35",
+      "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.35.tgz",
+      "integrity": "sha512-A/xFNX9loIcWDygeQuNCfKuh0CoYBzxhqEMNah5TSFg9Z53DrFYEN2qi5CU9necjM1OWYegYREUTHmXTmhfXtg==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.35",
+        "@vue/shared": "3.5.35"
+      }
+    },
+    "node_modules/@vue/runtime-dom": {
+      "version": "3.5.35",
+      "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.35.tgz",
+      "integrity": "sha512-odrJ1C391dbGnyDRh8U+rnP7J2amIEzfmRk5vXy7xi3aZhEXofTvpi0T4HJb6jlNqQZTNPR5MPHSB3RHNkIORA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.35",
+        "@vue/runtime-core": "3.5.35",
+        "@vue/shared": "3.5.35",
+        "csstype": "^3.2.3"
+      }
+    },
+    "node_modules/@vue/server-renderer": {
+      "version": "3.5.35",
+      "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.35.tgz",
+      "integrity": "sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-ssr": "3.5.35",
+        "@vue/shared": "3.5.35"
+      },
+      "peerDependencies": {
+        "vue": "3.5.35"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.5.35",
+      "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.35.tgz",
+      "integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==",
+      "license": "MIT"
+    },
+    "node_modules/@vue/tsconfig": {
+      "version": "0.9.1",
+      "resolved": "https://registry.npmmirror.com/@vue/tsconfig/-/tsconfig-0.9.1.tgz",
+      "integrity": "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "typescript": ">= 5.8",
+        "vue": "^3.4.0"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        },
+        "vue": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/agent-base": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
+      "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6.0.0"
+      }
+    },
+    "node_modules/alien-signals": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.2.1.tgz",
+      "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "license": "MIT"
+    },
+    "node_modules/axios": {
+      "version": "1.16.1",
+      "resolved": "https://registry.npmmirror.com/axios/-/axios-1.16.1.tgz",
+      "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
+      "license": "MIT",
+      "dependencies": {
+        "follow-redirects": "^1.16.0",
+        "form-data": "^4.0.5",
+        "https-proxy-agent": "^5.0.1",
+        "proxy-from-env": "^2.1.0"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "license": "MIT"
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/entities": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
+      "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+      "license": "MIT"
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.16.0",
+      "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz",
+      "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz",
+      "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.2",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/https-proxy-agent": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+      "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "6",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/lightningcss": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz",
+      "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+      "dev": true,
+      "license": "MPL-2.0",
+      "dependencies": {
+        "detect-libc": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "lightningcss-android-arm64": "1.32.0",
+        "lightningcss-darwin-arm64": "1.32.0",
+        "lightningcss-darwin-x64": "1.32.0",
+        "lightningcss-freebsd-x64": "1.32.0",
+        "lightningcss-linux-arm-gnueabihf": "1.32.0",
+        "lightningcss-linux-arm64-gnu": "1.32.0",
+        "lightningcss-linux-arm64-musl": "1.32.0",
+        "lightningcss-linux-x64-gnu": "1.32.0",
+        "lightningcss-linux-x64-musl": "1.32.0",
+        "lightningcss-win32-arm64-msvc": "1.32.0",
+        "lightningcss-win32-x64-msvc": "1.32.0"
+      }
+    },
+    "node_modules/lightningcss-android-arm64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+      "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-arm64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+      "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-x64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+      "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-freebsd-x64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+      "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm-gnueabihf": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+      "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-gnu": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+      "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-musl": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+      "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-gnu": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+      "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-musl": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+      "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-arm64-msvc": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+      "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-x64-msvc": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+      "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/muggle-string": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
+      "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.12",
+      "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz",
+      "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/path-browserify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
+      "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz",
+      "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.15",
+      "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
+      "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.12",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/primeicons": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmmirror.com/primeicons/-/primeicons-7.0.0.tgz",
+      "integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==",
+      "license": "MIT"
+    },
+    "node_modules/primevue": {
+      "version": "4.5.5",
+      "resolved": "https://registry.npmmirror.com/primevue/-/primevue-4.5.5.tgz",
+      "integrity": "sha512-Kv5REIewCdP806QaoU+4nBXfmpzOGFKkZ9qH4KsL6MjiAQVc4PUzypt8erl4r3Vzh3nr3aWZIxkxYRRsLGiX2A==",
+      "license": "MIT",
+      "dependencies": {
+        "@primeuix/styled": "^0.7.4",
+        "@primeuix/styles": "^2.0.3",
+        "@primeuix/utils": "^0.6.2",
+        "@primevue/core": "4.5.5",
+        "@primevue/icons": "4.5.5"
+      },
+      "engines": {
+        "node": ">=12.11.0"
+      }
+    },
+    "node_modules/proxy-from-env": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+      "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/rolldown": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.3.tgz",
+      "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@oxc-project/types": "=0.133.0",
+        "@rolldown/pluginutils": "^1.0.0"
+      },
+      "bin": {
+        "rolldown": "bin/cli.mjs"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "optionalDependencies": {
+        "@rolldown/binding-android-arm64": "1.0.3",
+        "@rolldown/binding-darwin-arm64": "1.0.3",
+        "@rolldown/binding-darwin-x64": "1.0.3",
+        "@rolldown/binding-freebsd-x64": "1.0.3",
+        "@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
+        "@rolldown/binding-linux-arm64-gnu": "1.0.3",
+        "@rolldown/binding-linux-arm64-musl": "1.0.3",
+        "@rolldown/binding-linux-ppc64-gnu": "1.0.3",
+        "@rolldown/binding-linux-s390x-gnu": "1.0.3",
+        "@rolldown/binding-linux-x64-gnu": "1.0.3",
+        "@rolldown/binding-linux-x64-musl": "1.0.3",
+        "@rolldown/binding-openharmony-arm64": "1.0.3",
+        "@rolldown/binding-wasm32-wasi": "1.0.3",
+        "@rolldown/binding-win32-arm64-msvc": "1.0.3",
+        "@rolldown/binding-win32-x64-msvc": "1.0.3"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.17",
+      "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz",
+      "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "dev": true,
+      "license": "0BSD",
+      "optional": true
+    },
+    "node_modules/typescript": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmmirror.com/typescript/-/typescript-6.0.3.tgz",
+      "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+      "devOptional": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "7.16.0",
+      "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz",
+      "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/vite": {
+      "version": "8.0.16",
+      "resolved": "https://registry.npmmirror.com/vite/-/vite-8.0.16.tgz",
+      "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "lightningcss": "^1.32.0",
+        "picomatch": "^4.0.4",
+        "postcss": "^8.5.15",
+        "rolldown": "1.0.3",
+        "tinyglobby": "^0.2.17"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^20.19.0 || >=22.12.0",
+        "@vitejs/devtools": "^0.1.18",
+        "esbuild": "^0.27.0 || ^0.28.0",
+        "jiti": ">=1.21.0",
+        "less": "^4.0.0",
+        "sass": "^1.70.0",
+        "sass-embedded": "^1.70.0",
+        "stylus": ">=0.54.8",
+        "sugarss": "^5.0.0",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "@vitejs/devtools": {
+          "optional": true
+        },
+        "esbuild": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vscode-uri": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz",
+      "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/vue": {
+      "version": "3.5.35",
+      "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.35.tgz",
+      "integrity": "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.35",
+        "@vue/compiler-sfc": "3.5.35",
+        "@vue/runtime-dom": "3.5.35",
+        "@vue/server-renderer": "3.5.35",
+        "@vue/shared": "3.5.35"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-tsc": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.3.3.tgz",
+      "integrity": "sha512-SWUEG7YRUeDJHT7Xsuhf02elYX2gxPzzAII7OxDAh4KNOr4QHQ0Lls0YfnaO5GNd560CwVa2HTfdqmA5MqvRqQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/typescript": "2.4.28",
+        "@vue/language-core": "3.3.3"
+      },
+      "bin": {
+        "vue-tsc": "bin/vue-tsc.js"
+      },
+      "peerDependencies": {
+        "typescript": ">=5.0.0"
+      }
+    }
+  }
+}

+ 26 - 0
frontend/package.json

@@ -0,0 +1,26 @@
+{
+  "name": "frontend",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vue-tsc -b && vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@primevue/themes": "^4.5.4",
+    "axios": "^1.16.1",
+    "primeicons": "^7.0.0",
+    "primevue": "^4.5.5",
+    "vue": "^3.5.34"
+  },
+  "devDependencies": {
+    "@types/node": "^24.12.3",
+    "@vitejs/plugin-vue": "^6.0.6",
+    "@vue/tsconfig": "^0.9.1",
+    "typescript": "~6.0.2",
+    "vite": "^8.0.12",
+    "vue-tsc": "^3.2.8"
+  }
+}

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/public/favicon.svg


+ 24 - 0
frontend/public/icons.svg

@@ -0,0 +1,24 @@
+<svg xmlns="http://www.w3.org/2000/svg">
+  <symbol id="bluesky-icon" viewBox="0 0 16 17">
+    <g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
+    <defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
+  </symbol>
+  <symbol id="discord-icon" viewBox="0 0 20 19">
+    <path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
+  </symbol>
+  <symbol id="documentation-icon" viewBox="0 0 21 20">
+    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
+    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
+    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
+  </symbol>
+  <symbol id="github-icon" viewBox="0 0 19 19">
+    <path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
+  </symbol>
+  <symbol id="social-icon" viewBox="0 0 20 20">
+    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
+    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
+  </symbol>
+  <symbol id="x-icon" viewBox="0 0 19 19">
+    <path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
+  </symbol>
+</svg>

+ 302 - 0
frontend/src/App.vue

@@ -0,0 +1,302 @@
+<script setup lang="ts">
+import { ref, onMounted, onUnmounted } from 'vue'
+import { serviceApi } from './api/service'
+import type { ServiceConfig } from './api/types'
+import ServiceCard from './components/ServiceCard.vue'
+import ServiceFormDialog from './components/ServiceFormDialog.vue'
+import LogDialog from './components/LogDialog.vue'
+import PortKillDialog from './components/PortKillDialog.vue'
+
+const services = ref<ServiceConfig[]>([])
+const loading = ref(false)
+const showFormDialog = ref(false)
+const showLogDialog = ref(false)
+const editingService = ref<ServiceConfig | null>(null)
+const logServiceId = ref<string>('')
+const logServiceName = ref<string>('')
+const logContent = ref('')
+const logLoading = ref(false)
+const showPortDialog = ref(false)
+const portServiceId = ref<string>('')
+const portServiceName = ref<string>('')
+
+let refreshTimer: ReturnType<typeof setInterval> | null = null
+
+const fetchServices = async () => {
+  try {
+    services.value = await serviceApi.list()
+  } catch (e) {
+    console.error('Failed to fetch services', e)
+  }
+}
+
+const handleCreate = () => {
+  editingService.value = null
+  showFormDialog.value = true
+}
+
+const handleEdit = (service: ServiceConfig) => {
+  editingService.value = { ...service }
+  showFormDialog.value = true
+}
+
+const handleDelete = async (id: string) => {
+  if (!confirm('确定要删除此服务吗?')) return
+  try {
+    await serviceApi.delete(id)
+    await fetchServices()
+  } catch (e) {
+    alert('删除失败: ' + (e as Error).message)
+  }
+}
+
+const handleStart = async (id: string) => {
+  try {
+    await serviceApi.start(id)
+    await fetchServices()
+  } catch (e: any) {
+    const msg = e?.response?.data?.error || e.message
+    alert('启动失败: ' + msg)
+  }
+}
+
+const handleStop = async (id: string) => {
+  try {
+    await serviceApi.stop(id)
+    await fetchServices()
+  } catch (e: any) {
+    const msg = e?.response?.data?.error || e.message
+    alert('停止失败: ' + msg)
+  }
+}
+
+const handleViewLog = async (service: ServiceConfig) => {
+  logServiceId.value = service.id!
+  logServiceName.value = service.name
+  logContent.value = ''
+  showLogDialog.value = true
+  logLoading.value = true
+  try {
+    const res = await serviceApi.getLog(service.id!)
+    logContent.value = res.log
+  } catch (e) {
+    logContent.value = '获取日志失败'
+  } finally {
+    logLoading.value = false
+  }
+}
+
+const handleStartAll = async () => {
+  loading.value = true
+  try {
+    await serviceApi.startAll()
+    await fetchServices()
+  } catch (e: any) {
+    alert('一键启动失败: ' + e.message)
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleStopAll = async () => {
+  loading.value = true
+  try {
+    await serviceApi.stopAll()
+    await fetchServices()
+  } catch (e: any) {
+    alert('一键停止失败: ' + e.message)
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleFormSave = async (data: ServiceConfig) => {
+  try {
+    if (editingService.value?.id) {
+      await serviceApi.update(editingService.value.id, data)
+    } else {
+      await serviceApi.create(data)
+    }
+    showFormDialog.value = false
+    await fetchServices()
+  } catch (e: any) {
+    const msg = e?.response?.data?.error || e.message
+    alert('保存失败: ' + msg)
+  }
+}
+
+const refreshLog = async () => {
+  if (!logServiceId.value) return
+  logLoading.value = true
+  try {
+    const res = await serviceApi.getLog(logServiceId.value)
+    logContent.value = res.log
+  } catch {
+    // ignore
+  } finally {
+    logLoading.value = false
+  }
+}
+
+const handleCheckPort = (service: ServiceConfig) => {
+  portServiceId.value = service.id!
+  portServiceName.value = service.name
+  showPortDialog.value = true
+}
+
+const handlePortKilled = async () => {
+  await fetchServices()
+}
+
+const runningCount = () => services.value.filter((s) => s.status === 'RUNNING').length
+const totalCount = () => services.value.length
+
+onMounted(() => {
+  fetchServices()
+  refreshTimer = setInterval(fetchServices, 5000)
+})
+
+onUnmounted(() => {
+  if (refreshTimer) clearInterval(refreshTimer)
+})
+</script>
+
+<template>
+  <div class="app-layout">
+    <header class="app-header">
+      <div class="header-left">
+        <h1 class="app-title">
+          <i class="pi pi-cog" style="margin-right: 8px"></i>
+          服务管理中心
+        </h1>
+        <span class="status-badge">
+          {{ runningCount() }} / {{ totalCount() }} 运行中
+        </span>
+      </div>
+      <div class="header-actions">
+        <button class="btn btn-outline" @click="handleStartAll" :disabled="loading">
+          <i class="pi pi-play"></i> 一键启动
+        </button>
+        <button class="btn btn-outline btn-danger" @click="handleStopAll" :disabled="loading">
+          <i class="pi pi-stop"></i> 一键停止
+        </button>
+        <button class="btn btn-primary" @click="handleCreate">
+          <i class="pi pi-plus"></i> 创建服务
+        </button>
+      </div>
+    </header>
+
+    <main class="app-main">
+      <div v-if="services.length === 0" class="empty-state">
+        <i class="pi pi-inbox" style="font-size: 48px; color: #ccc"></i>
+        <p>暂无服务配置</p>
+        <p style="color: #999; font-size: 14px">点击「创建服务」按钮添加你的第一个服务</p>
+      </div>
+
+      <div class="service-grid" v-else>
+        <ServiceCard
+          v-for="svc in services"
+          :key="svc.id"
+          :service="svc"
+          @start="handleStart(svc.id!)"
+          @stop="handleStop(svc.id!)"
+          @edit="handleEdit(svc)"
+          @delete="handleDelete(svc.id!)"
+          @view-log="handleViewLog(svc)"
+          @check-port="handleCheckPort(svc)"
+        />
+      </div>
+    </main>
+
+    <ServiceFormDialog
+      v-model:visible="showFormDialog"
+      :service="editingService"
+      @save="handleFormSave"
+    />
+
+    <LogDialog
+      v-model:visible="showLogDialog"
+      :service-name="logServiceName"
+      :log-content="logContent"
+      :loading="logLoading"
+      @refresh="refreshLog"
+    />
+
+    <PortKillDialog
+      v-model:visible="showPortDialog"
+      :service-id="portServiceId"
+      :service-name="portServiceName"
+      @killed="handlePortKilled"
+    />
+  </div>
+</template>
+
+<style scoped>
+.app-layout {
+  min-height: 100vh;
+  display: flex;
+  flex-direction: column;
+}
+.app-header {
+  background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
+  color: #fff;
+  padding: 16px 32px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+  position: sticky;
+  top: 0;
+  z-index: 100;
+}
+.header-left {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+}
+.app-title {
+  font-size: 20px;
+  font-weight: 600;
+  margin: 0;
+  display: flex;
+  align-items: center;
+}
+.status-badge {
+  background: rgba(255, 255, 255, 0.15);
+  padding: 4px 12px;
+  border-radius: 12px;
+  font-size: 13px;
+  font-weight: 500;
+}
+.header-actions {
+  display: flex;
+  gap: 10px;
+}
+.btn {
+  padding: 8px 16px;
+  border-radius: 6px;
+  font-size: 14px;
+  font-weight: 500;
+  cursor: pointer;
+  border: none;
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  transition: all 0.2s;
+}
+.btn:disabled { opacity: 0.5; cursor: not-allowed; }
+.btn-primary { background: #4f46e5; color: #fff; }
+.btn-primary:hover:not(:disabled) { background: #4338ca; }
+.btn-outline { background: rgba(255,255,255,0.1); color: #fff; border: 1px solid rgba(255,255,255,0.3); }
+.btn-outline:hover:not(:disabled) { background: rgba(255,255,255,0.2); }
+.btn-danger:hover:not(:disabled) { background: rgba(239,68,68,0.3); border-color: rgba(239,68,68,0.5); }
+.app-main { flex: 1; padding: 24px 32px; max-width: 1400px; width: 100%; margin: 0 auto; }
+.empty-state { text-align: center; padding: 80px 20px; color: #999; }
+.service-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 16px; }
+@media (max-width: 768px) {
+  .app-header { padding: 12px 16px; flex-direction: column; gap: 12px; }
+  .header-actions { flex-wrap: wrap; justify-content: center; }
+  .app-main { padding: 16px; }
+  .service-grid { grid-template-columns: 1fr; }
+}
+</style>

+ 61 - 0
frontend/src/api/service.ts

@@ -0,0 +1,61 @@
+import axios from 'axios'
+import type { ServiceConfig, PortCheckResult, KillResult } from './types'
+
+const api = axios.create({
+  baseURL: '/api/services',
+  timeout: 30000,
+})
+
+export const serviceApi = {
+  list(): Promise<ServiceConfig[]> {
+    return api.get('').then((r) => r.data)
+  },
+
+  get(id: string): Promise<ServiceConfig> {
+    return api.get(`/${id}`).then((r) => r.data)
+  },
+
+  create(config: ServiceConfig): Promise<ServiceConfig> {
+    return api.post('', config).then((r) => r.data)
+  },
+
+  update(id: string, config: ServiceConfig): Promise<ServiceConfig> {
+    return api.put(`/${id}`, config).then((r) => r.data)
+  },
+
+  delete(id: string): Promise<void> {
+    return api.delete(`/${id}`)
+  },
+
+  start(id: string): Promise<{ message: string }> {
+    return api.post(`/${id}/start`).then((r) => r.data)
+  },
+
+  stop(id: string): Promise<{ message: string }> {
+    return api.post(`/${id}/stop`).then((r) => r.data)
+  },
+
+  startAll(): Promise<Record<string, string>> {
+    return api.post('/start-all').then((r) => r.data)
+  },
+
+  stopAll(): Promise<Record<string, string>> {
+    return api.post('/stop-all').then((r) => r.data)
+  },
+
+  getLog(id: string): Promise<{ log: string }> {
+    return api.get(`/${id}/log`).then((r) => r.data)
+  },
+
+  getWebUrl(id: string): Promise<{ url?: string }> {
+    return api.get(`/${id}/web-url`).then((r) => r.data)
+  },
+
+  checkPorts(id: string): Promise<PortCheckResult[]> {
+    return api.get(`/${id}/port-check`).then((r) => r.data)
+  },
+
+  killPortProcess(id: string, pid: number): Promise<KillResult> {
+    return api.post(`/${id}/kill-port/${pid}`).then((r) => r.data)
+  },
+}

+ 55 - 0
frontend/src/api/types.ts

@@ -0,0 +1,55 @@
+export interface CustomVariable {
+  name: string
+  value: string
+}
+
+export interface SshConfig {
+  host: string
+  port?: number
+  username: string
+  password: string
+}
+
+export type TerminalType = 'CMD' | 'POWERSHELL' | 'GIT_BASH' | 'SSH'
+export type RunStatus = 'STOPPED' | 'RUNNING' | 'STARTING' | 'STOPPING' | 'ERROR'
+
+export interface ServiceConfig {
+  id?: string
+  name: string
+  description?: string
+  startupCommand: string
+  workDir?: string
+  terminalType: TerminalType
+  sshConfig?: SshConfig
+  variables?: CustomVariable[]
+  webUrl?: string
+  logEncoding?: string
+  createdAt?: string
+  updatedAt?: string
+  status?: RunStatus
+  pid?: number | null
+  statusMessage?: string | null
+  lastStartedAt?: string | null
+  lastStoppedAt?: string | null
+}
+
+/** 占用端口的进程信息 */
+export interface OccupiedProcess {
+  pid: number
+  processName: string
+  commandLine: string
+}
+
+/** 端口检查结果 */
+export interface PortCheckResult {
+  variableName: string
+  port: number
+  occupied: boolean
+  processes: OccupiedProcess[]
+}
+
+/** 杀死进程结果 */
+export interface KillResult {
+  success: boolean
+  message: string
+}

BIN
frontend/src/assets/hero.png


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/src/assets/vite.svg


+ 1 - 0
frontend/src/assets/vue.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

+ 95 - 0
frontend/src/components/HelloWorld.vue

@@ -0,0 +1,95 @@
+<script setup lang="ts">
+import { ref } from 'vue'
+import viteLogo from '../assets/vite.svg'
+import heroImg from '../assets/hero.png'
+import vueLogo from '../assets/vue.svg'
+
+const count = ref(0)
+</script>
+
+<template>
+  <section id="center">
+    <div class="hero">
+      <img :src="heroImg" class="base" width="170" height="179" alt="" />
+      <img :src="vueLogo" class="framework" alt="Vue logo" />
+      <img :src="viteLogo" class="vite" alt="Vite logo" />
+    </div>
+    <div>
+      <h1>Get started</h1>
+      <p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
+    </div>
+    <button type="button" class="counter" @click="count++">
+      Count is {{ count }}
+    </button>
+  </section>
+
+  <div class="ticks"></div>
+
+  <section id="next-steps">
+    <div id="docs">
+      <svg class="icon" role="presentation" aria-hidden="true">
+        <use href="/icons.svg#documentation-icon"></use>
+      </svg>
+      <h2>Documentation</h2>
+      <p>Your questions, answered</p>
+      <ul>
+        <li>
+          <a href="https://vite.dev/" target="_blank">
+            <img class="logo" :src="viteLogo" alt="" />
+            Explore Vite
+          </a>
+        </li>
+        <li>
+          <a href="https://vuejs.org/" target="_blank">
+            <img class="button-icon" :src="vueLogo" alt="" />
+            Learn more
+          </a>
+        </li>
+      </ul>
+    </div>
+    <div id="social">
+      <svg class="icon" role="presentation" aria-hidden="true">
+        <use href="/icons.svg#social-icon"></use>
+      </svg>
+      <h2>Connect with us</h2>
+      <p>Join the Vite community</p>
+      <ul>
+        <li>
+          <a href="https://github.com/vitejs/vite" target="_blank">
+            <svg class="button-icon" role="presentation" aria-hidden="true">
+              <use href="/icons.svg#github-icon"></use>
+            </svg>
+            GitHub
+          </a>
+        </li>
+        <li>
+          <a href="https://chat.vite.dev/" target="_blank">
+            <svg class="button-icon" role="presentation" aria-hidden="true">
+              <use href="/icons.svg#discord-icon"></use>
+            </svg>
+            Discord
+          </a>
+        </li>
+        <li>
+          <a href="https://x.com/vite_js" target="_blank">
+            <svg class="button-icon" role="presentation" aria-hidden="true">
+              <use href="/icons.svg#x-icon"></use>
+            </svg>
+            X.com
+          </a>
+        </li>
+        <li>
+          <a href="https://bsky.app/profile/vite.dev" target="_blank">
+            <svg class="button-icon" role="presentation" aria-hidden="true">
+              <use href="/icons.svg#bluesky-icon"></use>
+            </svg>
+            Bluesky
+          </a>
+        </li>
+      </ul>
+    </div>
+  </section>
+
+  <div class="ticks"></div>
+  <section id="spacer"></section>
+</template>

+ 94 - 0
frontend/src/components/LogDialog.vue

@@ -0,0 +1,94 @@
+<script setup lang="ts">
+import { ref, watch, nextTick } from 'vue'
+
+const props = defineProps<{
+  visible: boolean
+  serviceName: string
+  logContent: string
+  loading: boolean
+}>()
+
+const emit = defineEmits<{
+  'update:visible': [value: boolean]
+  refresh: []
+}>()
+
+const logPre = ref<HTMLPreElement | null>(null)
+
+function scrollToBottom() {
+  // 双重保障:nextTick 等 Vue DOM 更新 + setTimeout 等浏览器完成布局
+  nextTick(() => {
+    setTimeout(() => {
+      if (logPre.value) {
+        logPre.value.scrollTop = logPre.value.scrollHeight
+      }
+    }, 50)
+  })
+}
+
+// loading 从 true 变 false 时,<pre> 刚挂载,滚到底部
+watch(() => props.loading, (newVal, oldVal) => {
+  if (oldVal && !newVal) scrollToBottom()
+})
+// 对话框打开时滚到底部
+watch(() => props.visible, (val) => { if (val) scrollToBottom() })
+</script>
+
+<template>
+  <div v-if="visible" class="dialog-overlay">
+    <div class="dialog-content">
+      <div class="dialog-header">
+        <h2>{{ serviceName }} - 运行日志</h2>
+        <div class="header-actions">
+          <button class="refresh-btn" @click="emit('refresh')">
+            <i class="pi pi-refresh"></i> 刷新
+          </button>
+          <button class="close-btn" @click="emit('update:visible', false)">
+            <i class="pi pi-times"></i>
+          </button>
+        </div>
+      </div>
+      <div class="dialog-body">
+        <div v-if="loading" class="loading">加载中...</div>
+        <pre v-else ref="logPre" class="log-content">{{ logContent || '暂无日志' }}</pre>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.dialog-overlay {
+  position: fixed; inset: 0; background: rgba(0,0,0,0.5);
+  display: flex; justify-content: center; align-items: flex-start;
+  padding-top: 40px; z-index: 1000; overflow-y: auto;
+}
+.dialog-content {
+  background: #fff; border-radius: 16px; width: 800px; max-width: 95vw;
+  max-height: 80vh; display: flex; flex-direction: column;
+  box-shadow: 0 20px 60px rgba(0,0,0,0.2); margin-bottom: 40px;
+}
+.dialog-header {
+  display: flex; justify-content: space-between; align-items: center;
+  padding: 16px 24px; border-bottom: 1px solid #e5e7eb;
+}
+.dialog-header h2 { font-size: 16px; font-weight: 600; margin: 0; }
+.header-actions { display: flex; gap: 8px; }
+.refresh-btn {
+  background: none; border: 1px solid #d1d5db; padding: 4px 12px;
+  border-radius: 6px; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 4px;
+}
+.refresh-btn:hover { background: #f3f4f6; }
+.close-btn {
+  background: none; border: none; font-size: 18px; cursor: pointer;
+  color: #9ca3af; padding: 4px; border-radius: 4px;
+}
+.close-btn:hover { color: #374151; background: #f3f4f6; }
+.dialog-body { padding: 0; overflow: hidden; flex: 1; }
+.loading { padding: 40px; text-align: center; color: #9ca3af; }
+.log-content {
+  background: #1e1e2e; color: #cdd6f4; padding: 16px 20px;
+  font-family: 'Consolas', 'Courier New', monospace; font-size: 13px;
+  line-height: 1.6; overflow-y: auto; max-height: 60vh; margin: 0;
+  white-space: pre-wrap; word-break: break-all;
+}
+</style>

+ 260 - 0
frontend/src/components/PortKillDialog.vue

@@ -0,0 +1,260 @@
+<script setup lang="ts">
+import { ref, watch } from 'vue'
+import type { PortCheckResult } from '../api/types'
+import { serviceApi } from '../api/service'
+
+const props = defineProps<{
+  visible: boolean
+  serviceId: string
+  serviceName: string
+}>()
+
+const emit = defineEmits<{
+  'update:visible': [value: boolean]
+  killed: []
+}>()
+
+const loading = ref(false)
+const portResults = ref<PortCheckResult[]>([])
+const killLoading = ref<number | null>(null)
+const errorMsg = ref('')
+
+watch(() => props.visible, async (val) => {
+  if (val && props.serviceId) {
+    await checkPorts()
+  }
+})
+
+const checkPorts = async () => {
+  loading.value = true
+  errorMsg.value = ''
+  portResults.value = []
+  try {
+    portResults.value = await serviceApi.checkPorts(props.serviceId)
+  } catch (e: any) {
+    errorMsg.value = e?.response?.data?.error || e.message || '查询失败'
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleKill = async (pid: number) => {
+  const confirmed = confirm(`确定要终止进程 PID=${pid} 吗?\n\n此操作不可撤销,该进程将被强制终止。`)
+  if (!confirmed) return
+
+  killLoading.value = pid
+  try {
+    const res = await serviceApi.killPortProcess(props.serviceId, pid)
+    if (res.success) {
+      alert(res.message)
+      // 重新检查端口
+      await checkPorts()
+      emit('killed')
+    } else {
+      alert('操作失败: ' + res.message)
+    }
+  } catch (e: any) {
+    alert('操作失败: ' + (e?.response?.data?.error || e.message))
+  } finally {
+    killLoading.value = null
+  }
+}
+
+const handleClose = () => {
+  emit('update:visible', false)
+}
+</script>
+
+<template>
+  <div v-if="visible" class="dialog-overlay">
+    <div class="dialog-content">
+      <div class="dialog-header">
+        <h2>端口冲突管理 - {{ serviceName }}</h2>
+        <button class="close-btn" @click="handleClose">
+          <i class="pi pi-times"></i>
+        </button>
+      </div>
+
+      <div class="dialog-body">
+        <div v-if="loading" class="loading-state">
+          <i class="pi pi-spin pi-spinner" style="font-size: 24px"></i>
+          <span>正在检测端口占用...</span>
+        </div>
+
+        <div v-else-if="errorMsg" class="error-state">
+          <i class="pi pi-exclamation-circle"></i>
+          <span>{{ errorMsg }}</span>
+        </div>
+
+        <div v-else-if="portResults.length === 0" class="empty-state">
+          <i class="pi pi-info-circle"></i>
+          <span>未检测到端口类变量(如 SERVER_PORT、PORT 等)</span>
+        </div>
+
+        <div v-else class="port-results">
+          <div
+            v-for="result in portResults"
+            :key="result.port"
+            class="port-card"
+            :class="{ occupied: result.occupied, free: !result.occupied }"
+          >
+            <div class="port-header">
+              <div class="port-info">
+                <span class="port-number">:{{ result.port }}</span>
+                <span class="port-var">{{ result.variableName }}</span>
+              </div>
+              <span class="port-status" :class="result.occupied ? 'status-occupied' : 'status-free'">
+                <span class="status-dot"></span>
+                {{ result.occupied ? '被占用' : '空闲' }}
+              </span>
+            </div>
+
+            <div v-if="result.occupied && result.processes.length > 0" class="process-list">
+              <div v-for="proc in result.processes" :key="proc.pid" class="process-item">
+                <div class="process-info">
+                  <div class="process-main">
+                    <span class="process-pid">PID: {{ proc.pid }}</span>
+                    <span class="process-name">{{ proc.processName }}</span>
+                  </div>
+                  <div v-if="proc.commandLine" class="process-cmd" :title="proc.commandLine">
+                    {{ proc.commandLine }}
+                  </div>
+                </div>
+                <button
+                  class="kill-btn"
+                  :disabled="killLoading === proc.pid"
+                  @click="handleKill(proc.pid)"
+                >
+                  <i :class="killLoading === proc.pid ? 'pi pi-spin pi-spinner' : 'pi pi-trash'"></i>
+                  {{ killLoading === proc.pid ? '终止中...' : '终止进程' }}
+                </button>
+              </div>
+            </div>
+
+            <div v-else-if="result.occupied" class="process-list">
+              <p class="no-detail">端口被占用但无法获取进程详情</p>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div class="dialog-footer">
+        <button class="btn btn-refresh" @click="checkPorts" :disabled="loading">
+          <i class="pi pi-refresh"></i> 重新检测
+        </button>
+        <button class="btn btn-cancel" @click="handleClose">关闭</button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.dialog-overlay {
+  position: fixed; inset: 0; background: rgba(0,0,0,0.5);
+  display: flex; justify-content: center; align-items: flex-start;
+  padding-top: 60px; z-index: 1000; overflow-y: auto;
+}
+.dialog-content {
+  background: #fff; border-radius: 16px; width: 600px; max-width: 95vw;
+  max-height: 80vh; display: flex; flex-direction: column;
+  box-shadow: 0 20px 60px rgba(0,0,0,0.2); margin-bottom: 40px;
+}
+.dialog-header {
+  display: flex; justify-content: space-between; align-items: center;
+  padding: 20px 24px; border-bottom: 1px solid #e5e7eb;
+}
+.dialog-header h2 { font-size: 17px; font-weight: 600; margin: 0; }
+.close-btn {
+  background: none; border: none; font-size: 18px; cursor: pointer;
+  color: #9ca3af; padding: 4px; border-radius: 4px;
+}
+.close-btn:hover { color: #374151; background: #f3f4f6; }
+.dialog-body { padding: 20px 24px; overflow-y: auto; flex: 1; }
+
+.loading-state, .error-state, .empty-state {
+  display: flex; flex-direction: column; align-items: center;
+  gap: 12px; padding: 40px 20px; color: #6b7280;
+}
+.error-state { color: #ef4444; }
+
+.port-results { display: flex; flex-direction: column; gap: 12px; }
+
+.port-card {
+  border: 1px solid #e5e7eb; border-radius: 10px; padding: 16px;
+  transition: all 0.15s;
+}
+.port-card.occupied {
+  border-color: #fbbf24; background: #fffbeb;
+}
+.port-card.free {
+  border-color: #34d399; background: #ecfdf5;
+}
+.port-header {
+  display: flex; justify-content: space-between; align-items: center;
+}
+.port-info { display: flex; align-items: center; gap: 10px; }
+.port-number {
+  font-size: 20px; font-weight: 700; font-family: 'Consolas', monospace;
+  color: #1a1a2e;
+}
+.port-var {
+  font-size: 12px; padding: 2px 8px; border-radius: 4px;
+  background: #e5e7eb; color: #4b5563; font-family: 'Consolas', monospace;
+}
+.port-status {
+  display: inline-flex; align-items: center; gap: 6px;
+  font-size: 13px; font-weight: 600; padding: 3px 10px; border-radius: 8px;
+}
+.status-dot {
+  width: 7px; height: 7px; border-radius: 50%;
+}
+.status-occupied { color: #d97706; background: #fef3c7; }
+.status-occupied .status-dot { background: #d97706; }
+.status-free { color: #059669; background: #d1fae5; }
+.status-free .status-dot { background: #059669; }
+
+.process-list { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
+.process-item {
+  display: flex; justify-content: space-between; align-items: center;
+  padding: 10px 12px; background: #fff; border: 1px solid #f3f4f6;
+  border-radius: 8px; gap: 12px;
+}
+.process-info { flex: 1; min-width: 0; }
+.process-main { display: flex; align-items: center; gap: 8px; }
+.process-pid {
+  font-family: 'Consolas', monospace; font-weight: 600;
+  color: #4f46e5; font-size: 13px;
+}
+.process-name {
+  font-size: 13px; color: #374151; font-weight: 500;
+}
+.process-cmd {
+  font-size: 12px; color: #9ca3af; margin-top: 4px;
+  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+  font-family: 'Consolas', monospace;
+}
+.no-detail { color: #9ca3af; font-size: 13px; margin: 0; }
+
+.kill-btn {
+  padding: 6px 14px; border-radius: 6px; font-size: 12px; font-weight: 600;
+  cursor: pointer; border: 1px solid #fca5a5; background: #fff;
+  color: #ef4444; display: inline-flex; align-items: center; gap: 4px;
+  transition: all 0.15s; white-space: nowrap;
+}
+.kill-btn:hover:not(:disabled) { background: #fef2f2; border-color: #ef4444; }
+.kill-btn:disabled { opacity: 0.6; cursor: not-allowed; }
+
+.dialog-footer {
+  padding: 16px 24px; border-top: 1px solid #e5e7eb;
+  display: flex; justify-content: space-between; gap: 10px;
+}
+.btn {
+  padding: 8px 18px; border-radius: 8px; font-size: 14px; font-weight: 500;
+  cursor: pointer; border: none; display: inline-flex; align-items: center; gap: 6px;
+}
+.btn:disabled { opacity: 0.5; cursor: not-allowed; }
+.btn-cancel { background: #f3f4f6; color: #4b5563; }
+.btn-cancel:hover { background: #e5e7eb; }
+.btn-refresh { background: #4f46e5; color: #fff; }
+.btn-refresh:hover:not(:disabled) { background: #4338ca; }
+</style>

+ 332 - 0
frontend/src/components/ServiceCard.vue

@@ -0,0 +1,332 @@
+<script setup lang="ts">
+import type { ServiceConfig } from '../api/types'
+import { serviceApi } from '../api/service'
+import { computed } from 'vue'
+
+const props = defineProps<{ service: ServiceConfig }>()
+const emit = defineEmits<{
+  start: []
+  stop: []
+  edit: []
+  delete: []
+  viewLog: []
+  checkPort: []
+}>()
+
+const statusLabel: Record<string, string> = {
+  STOPPED: '已停止',
+  RUNNING: '运行中',
+  STARTING: '启动中',
+  STOPPING: '停止中',
+  ERROR: '错误',
+}
+
+const statusColor: Record<string, string> = {
+  STOPPED: '#6b7280',
+  RUNNING: '#10b981',
+  STARTING: '#f59e0b',
+  STOPPING: '#f59e0b',
+  ERROR: '#ef4444',
+}
+
+const terminalLabel: Record<string, string> = {
+  CMD: 'CMD',
+  POWERSHELL: 'PowerShell',
+  GIT_BASH: 'Git Bash',
+  SSH: 'SSH',
+}
+
+const formatDate = (d?: string | null) => {
+  if (!d) return '-'
+  return new Date(d).toLocaleString('zh-CN')
+}
+
+const handleCardClick = async () => {
+  if (props.service.status !== 'RUNNING' || !props.service.webUrl) return
+  try {
+    const res = await serviceApi.getWebUrl(props.service.id!)
+    if (res.url) {
+      window.open(res.url, '_blank')
+    }
+  } catch {
+    // 静默处理
+  }
+}
+
+const isCardClickable = computed(() => props.service.status === 'RUNNING' && !!props.service.webUrl)
+
+/** 检测变量中是否包含端口类变量 */
+const PORT_VAR_NAMES = ['SERVER_PORT', 'PORT', 'SERVERPORT', 'SERVICE_PORT', 'HTTP_PORT', 'HTTPS_PORT', 'APP_PORT', 'API_PORT', 'WEB_PORT', 'TOMCAT_PORT', 'JETTY_PORT']
+
+const hasPortVars = computed(() => {
+  if (!props.service.variables || props.service.variables.length === 0) return false
+  return props.service.variables.some(v => {
+    const normalized = v.name.toUpperCase().replace(/_/g, '')
+    return PORT_VAR_NAMES.some(n => n.replace(/_/g, '') === normalized)
+      && v.value && /^\d{1,5}$/.test(v.value.trim())
+  })
+})
+</script>
+
+<template>
+  <div
+    class="service-card"
+    :class="{
+      running: service.status === 'RUNNING',
+      error: service.status === 'ERROR',
+      clickable: isCardClickable,
+    }"
+    @click="handleCardClick"
+  >
+    <div class="card-header">
+      <div class="card-title-row">
+        <h3 class="card-name">{{ service.name }}</h3>
+        <span class="status-pill" :style="{ background: statusColor[service.status || 'STOPPED'] + '20', color: statusColor[service.status || 'STOPPED'] }">
+          <span class="status-dot" :style="{ background: statusColor[service.status || 'STOPPED'] }"></span>
+          {{ statusLabel[service.status || 'STOPPED'] }}
+        </span>
+        <span v-if="service.webUrl && service.status === 'RUNNING'" class="web-icon" title="点击卡片打开 Web 界面">
+          <i class="pi pi-external-link"></i>
+        </span>
+      </div>
+      <p v-if="service.description" class="card-desc">{{ service.description }}</p>
+    </div>
+
+    <div class="card-body">
+      <div class="info-grid">
+        <div class="info-item">
+          <span class="info-label">终端类型</span>
+          <span class="info-value">{{ terminalLabel[service.terminalType] || service.terminalType }}</span>
+        </div>
+        <div class="info-item" v-if="service.pid">
+          <span class="info-label">PID</span>
+          <span class="info-value mono">{{ service.pid }}</span>
+        </div>
+        <div class="info-item" v-if="service.workDir">
+          <span class="info-label">工作目录</span>
+          <span class="info-value path" :title="service.workDir">{{ service.workDir }}</span>
+        </div>
+        <div class="info-item" v-if="service.lastStartedAt">
+          <span class="info-label">最后启动</span>
+          <span class="info-value">{{ formatDate(service.lastStartedAt) }}</span>
+        </div>
+        <div class="info-item" v-if="service.statusMessage">
+          <span class="info-label">状态信息</span>
+          <span class="info-value error-text">{{ service.statusMessage }}</span>
+        </div>
+      </div>
+
+      <div class="vars-section" v-if="service.variables && service.variables.length > 0">
+        <span class="info-label">自定义变量</span>
+        <div class="vars-list">
+          <span v-for="v in service.variables" :key="v.name" class="var-tag">
+            {{ v.name }}={{ v.value }}
+          </span>
+        </div>
+      </div>
+    </div>
+
+    <div class="card-footer" @click.stop>
+      <button
+        v-if="service.status !== 'RUNNING' && service.status !== 'STARTING' && service.status !== 'STOPPING'"
+        class="card-btn btn-start"
+        @click="emit('start')"
+      >
+        <i class="pi pi-play"></i> 启动
+      </button>
+      <button
+        v-if="service.status === 'RUNNING' || service.status === 'STARTING'"
+        class="card-btn btn-stop"
+        @click="emit('stop')"
+      >
+        <i class="pi pi-stop"></i> 停止
+      </button>
+      <button class="card-btn btn-log" @click="emit('viewLog')">
+        <i class="pi pi-file"></i> 日志
+      </button>
+      <button v-if="hasPortVars" class="card-btn btn-port" @click="emit('checkPort')">
+        <i class="pi pi-shield"></i> 端口
+      </button>
+      <button class="card-btn btn-edit" @click="emit('edit')">
+        <i class="pi pi-pencil"></i> 编辑
+      </button>
+      <button class="card-btn btn-delete" @click="emit('delete')">
+        <i class="pi pi-trash"></i> 删除
+      </button>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.service-card {
+  background: #fff;
+  border-radius: 12px;
+  box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.06);
+  border: 1px solid #e5e7eb;
+  transition: box-shadow 0.2s, border-color 0.2s;
+  display: flex;
+  flex-direction: column;
+}
+.service-card:hover {
+  box-shadow: 0 4px 12px rgba(0,0,0,0.1);
+}
+.service-card.running {
+  border-left: 4px solid #10b981;
+}
+.service-card.error {
+  border-left: 4px solid #ef4444;
+}
+.service-card.clickable {
+  cursor: pointer;
+}
+.service-card.clickable:hover {
+  border-color: #4f46e5;
+  box-shadow: 0 4px 16px rgba(79, 70, 229, 0.15);
+}
+
+.web-icon {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 24px;
+  height: 24px;
+  border-radius: 6px;
+  background: #eef2ff;
+  color: #4f46e5;
+  font-size: 12px;
+}
+
+.card-header {
+  padding: 16px 20px 0;
+}
+.card-title-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 12px;
+}
+.card-name {
+  font-size: 16px;
+  font-weight: 600;
+  margin: 0;
+  color: #1a1a2e;
+}
+.status-pill {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 3px 10px;
+  border-radius: 10px;
+  font-size: 12px;
+  font-weight: 600;
+  white-space: nowrap;
+}
+.status-dot {
+  width: 7px;
+  height: 7px;
+  border-radius: 50%;
+  animation: pulse 2s infinite;
+}
+@keyframes pulse {
+  0%, 100% { opacity: 1; }
+  50% { opacity: 0.4; }
+}
+.card-desc {
+  font-size: 13px;
+  color: #6b7280;
+  margin-top: 4px;
+}
+
+.card-body {
+  padding: 12px 20px;
+  flex: 1;
+}
+.info-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 8px 16px;
+}
+.info-item {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+.info-label {
+  font-size: 11px;
+  color: #9ca3af;
+  text-transform: uppercase;
+  letter-spacing: 0.5px;
+}
+.info-value {
+  font-size: 13px;
+  color: #374151;
+  font-weight: 500;
+}
+.info-value.mono {
+  font-family: 'Consolas', monospace;
+  color: #4f46e5;
+}
+.info-value.path {
+  font-size: 12px;
+  word-break: break-all;
+  color: #6b7280;
+}
+.error-text {
+  color: #ef4444;
+  font-size: 12px;
+}
+
+.vars-section {
+  margin-top: 8px;
+}
+.vars-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  margin-top: 4px;
+}
+.var-tag {
+  background: #f3f4f6;
+  color: #4b5563;
+  padding: 2px 8px;
+  border-radius: 4px;
+  font-size: 12px;
+  font-family: 'Consolas', monospace;
+}
+
+.card-footer {
+  padding: 10px 20px 14px;
+  display: flex;
+  gap: 5px;
+  flex-wrap: wrap;
+  justify-content: center;
+  border-top: 1px solid #f3f4f6;
+}
+.card-btn {
+  padding: 3px 8px;
+  border-radius: 5px;
+  font-size: 12px;
+  cursor: pointer;
+  border: 1px solid #e5e7eb;
+  background: #fff;
+  color: #374151;
+  display: inline-flex;
+  align-items: center;
+  gap: 3px;
+  transition: all 0.15s;
+  white-space: nowrap;
+}
+.card-btn:hover { background: #f9fafb; }
+.btn-start { border-color: #10b981; color: #10b981; }
+.btn-start:hover { background: #ecfdf5; }
+.btn-stop { border-color: #f59e0b; color: #d97706; }
+.btn-stop:hover { background: #fffbeb; }
+.btn-log { border-color: #6366f1; color: #6366f1; }
+.btn-log:hover { background: #eef2ff; }
+.btn-port { border-color: #8b5cf6; color: #8b5cf6; }
+.btn-port:hover { background: #f5f3ff; }
+.btn-edit { border-color: #3b82f6; color: #3b82f6; }
+.btn-edit:hover { background: #eff6ff; }
+.btn-delete { border-color: #ef4444; color: #ef4444; }
+.btn-delete:hover { background: #fef2f2; }
+</style>

+ 305 - 0
frontend/src/components/ServiceFormDialog.vue

@@ -0,0 +1,305 @@
+<script setup lang="ts">
+import { ref, watch } from 'vue'
+import type { ServiceConfig, TerminalType } from '../api/types'
+
+const props = defineProps<{
+  visible: boolean
+  service: ServiceConfig | null
+}>()
+
+const emit = defineEmits<{
+  'update:visible': [value: boolean]
+  save: [config: ServiceConfig]
+}>()
+
+const form = ref<ServiceConfig>(createEmptyForm())
+
+function createEmptyForm(): ServiceConfig {
+  return {
+    name: '',
+    description: '',
+    startupCommand: '',
+    workDir: '',
+    terminalType: 'CMD',
+    sshConfig: { host: '', port: 22, username: '', password: '' },
+    variables: [],
+    webUrl: '',
+    logEncoding: 'UTF-8',
+  }
+}
+
+watch(() => props.visible, (val) => {
+  if (val) {
+    if (props.service) {
+      form.value = {
+        ...props.service,
+        variables: props.service.variables?.map(v => ({ ...v })) || [],
+        sshConfig: props.service.sshConfig ? { ...props.service.sshConfig } : { host: '', port: 22, username: '', password: '' },
+      }
+    } else {
+      form.value = createEmptyForm()
+    }
+  }
+})
+
+const addVariable = () => {
+  form.value.variables = form.value.variables || []
+  form.value.variables.push({ name: '', value: '' })
+}
+
+const removeVariable = (index: number) => {
+  form.value.variables?.splice(index, 1)
+}
+
+const terminalOptions = [
+  { label: 'CMD', value: 'CMD' as TerminalType },
+  { label: 'PowerShell', value: 'POWERSHELL' as TerminalType },
+  { label: 'Git Bash', value: 'GIT_BASH' as TerminalType },
+  { label: 'SSH', value: 'SSH' as TerminalType },
+]
+
+const handleClose = () => {
+  emit('update:visible', false)
+}
+
+const handleSubmit = () => {
+  if (!form.value.name.trim()) {
+    alert('请输入服务名称')
+    return
+  }
+  if (!form.value.startupCommand.trim()) {
+    alert('请输入启动命令')
+    return
+  }
+
+  const data: ServiceConfig = {
+    name: form.value.name.trim(),
+    description: form.value.description?.trim(),
+    startupCommand: form.value.startupCommand.trim(),
+    workDir: form.value.workDir?.trim(),
+    terminalType: form.value.terminalType,
+    variables: form.value.variables?.filter(v => v.name.trim()),
+    webUrl: form.value.webUrl?.trim() || undefined,
+    logEncoding: form.value.logEncoding || 'UTF-8',
+  }
+
+  if (form.value.terminalType === 'SSH') {
+    data.sshConfig = form.value.sshConfig
+  }
+
+  emit('save', data)
+}
+</script>
+
+<template>
+  <div v-if="visible" class="dialog-overlay">
+    <div class="dialog-content">
+      <div class="dialog-header">
+        <h2>{{ service?.id ? '编辑服务' : '创建服务' }}</h2>
+        <button class="close-btn" @click="handleClose">
+          <i class="pi pi-times"></i>
+        </button>
+      </div>
+
+      <div class="dialog-body">
+        <div class="form-section">
+          <h3>基本信息</h3>
+          <div class="form-group">
+            <label>服务名称 <span class="required">*</span></label>
+            <input v-model="form.name" type="text" placeholder="例如:用户服务" />
+          </div>
+          <div class="form-group">
+            <label>服务描述</label>
+            <input v-model="form.description" type="text" placeholder="可选的描述信息" />
+          </div>
+        </div>
+
+        <div class="form-section">
+          <h3>启动命令 <span class="required">*</span></h3>
+          <div class="form-group">
+            <label>
+              多行命令,每行一条。使用 <code>[...]</code> 标记可选片段,<code v-pre>{{}}</code> 引用自定义变量
+            </label>
+            <textarea
+              v-model="form.startupCommand"
+              rows="5"
+              placeholder="mvn package&#10;java -jar [--server.port={{SERVER_PORT}}] target/app-1.0.0.jar"
+            ></textarea>
+          </div>
+        </div>
+
+        <div class="form-section">
+          <h3>工作目录</h3>
+          <div class="form-group">
+            <label>选择或填写项目目录路径</label>
+            <input v-model="form.workDir" type="text" placeholder="例如:D:/projects/my-service" />
+          </div>
+        </div>
+
+        <div class="form-section">
+          <h3>Web 界面地址</h3>
+          <div class="form-group">
+            <label>
+              服务启动后的 Web 访问地址(可选)。支持 <code v-pre>{{VAR}}</code> 引用变量,<code v-pre>{{VAR:默认值}}</code> 设置默认值
+            </label>
+            <input v-model="form.webUrl" type="text" placeholder="例如:http://localhost:{{SERVER_PORT:8080}}/index.html" />
+          </div>
+        </div>
+
+        <div class="form-section">
+          <h3>日志编码</h3>
+          <div class="form-group">
+            <label>服务进程输出的字符编码,影响日志读取时的解码方式</label>
+            <select v-model="form.logEncoding" class="encoding-select">
+              <option value="UTF-8">UTF-8(默认)</option>
+              <option value="GB18030">GB18030</option>
+              <option value="GBK">GBK</option>
+              <option value="GB2312">GB2312</option>
+              <option value="Big5">Big5(繁体中文)</option>
+              <option value="ISO-8859-1">ISO-8859-1</option>
+              <option value="US-ASCII">US-ASCII</option>
+            </select>
+          </div>
+        </div>
+
+        <div class="form-section">
+          <h3>终端类型</h3>
+          <div class="terminal-options">
+            <label
+              v-for="opt in terminalOptions"
+              :key="opt.value"
+              class="terminal-option"
+              :class="{ active: form.terminalType === opt.value }"
+            >
+              <input type="radio" v-model="form.terminalType" :value="opt.value" />
+              <span>{{ opt.label }}</span>
+            </label>
+          </div>
+        </div>
+
+        <div class="form-section" v-if="form.terminalType === 'SSH'">
+          <h3>SSH 配置</h3>
+          <div class="form-row">
+            <div class="form-group flex-1">
+              <label>Host</label>
+              <input v-model="form.sshConfig!.host" type="text" placeholder="192.168.1.100" />
+            </div>
+            <div class="form-group" style="width: 100px">
+              <label>端口</label>
+              <input v-model.number="form.sshConfig!.port" type="number" placeholder="22" />
+            </div>
+          </div>
+          <div class="form-row">
+            <div class="form-group flex-1">
+              <label>用户名</label>
+              <input v-model="form.sshConfig!.username" type="text" placeholder="root" />
+            </div>
+            <div class="form-group flex-1">
+              <label>密码</label>
+              <input v-model="form.sshConfig!.password" type="password" placeholder="密码" />
+            </div>
+          </div>
+        </div>
+
+        <div class="form-section">
+          <h3>
+            自定义变量
+            <button class="add-var-btn" @click="addVariable">
+              <i class="pi pi-plus"></i> 添加变量
+            </button>
+          </h3>
+          <div v-if="form.variables && form.variables.length > 0" class="vars-form">
+            <div v-for="(v, i) in form.variables" :key="i" class="var-row">
+              <input v-model="v.name" type="text" placeholder="变量名 (如 SERVER_PORT)" class="var-name" />
+              <span class="var-eq">=</span>
+              <input v-model="v.value" type="text" placeholder="值 (如 8080)" class="var-value" />
+              <button class="remove-var-btn" @click="removeVariable(i)">
+                <i class="pi pi-times"></i>
+              </button>
+            </div>
+          </div>
+          <p v-else class="no-vars">暂无自定义变量</p>
+        </div>
+      </div>
+
+      <div class="dialog-footer">
+        <button class="btn btn-cancel" @click="handleClose">取消</button>
+        <button class="btn btn-submit" @click="handleSubmit">
+          {{ service?.id ? '保存修改' : '创建服务' }}
+        </button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.dialog-overlay {
+  position: fixed; inset: 0; background: rgba(0,0,0,0.5);
+  display: flex; justify-content: center; align-items: flex-start;
+  padding-top: 40px; z-index: 1000; overflow-y: auto;
+}
+.dialog-content {
+  background: #fff; border-radius: 16px; width: 640px; max-width: 95vw;
+  max-height: 85vh; display: flex; flex-direction: column;
+  box-shadow: 0 20px 60px rgba(0,0,0,0.2); margin-bottom: 40px;
+}
+.dialog-header {
+  display: flex; justify-content: space-between; align-items: center;
+  padding: 20px 24px; border-bottom: 1px solid #e5e7eb;
+}
+.dialog-header h2 { font-size: 18px; font-weight: 600; margin: 0; }
+.close-btn {
+  background: none; border: none; font-size: 18px; cursor: pointer;
+  color: #9ca3af; padding: 4px; border-radius: 4px;
+}
+.close-btn:hover { color: #374151; background: #f3f4f6; }
+.dialog-body { padding: 20px 24px; overflow-y: auto; flex: 1; }
+.form-section { margin-bottom: 24px; }
+.form-section h3 {
+  font-size: 14px; font-weight: 600; color: #374151; margin: 0 0 12px;
+  display: flex; align-items: center; gap: 8px;
+}
+.form-group { margin-bottom: 12px; }
+.form-group label {
+  display: block; font-size: 13px; font-weight: 500; color: #4b5563; margin-bottom: 4px;
+}
+.required { color: #ef4444; }
+input[type="text"], input[type="number"], input[type="password"], textarea, select {
+  width: 100%; padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 8px;
+  font-size: 14px; transition: border-color 0.2s; outline: none; font-family: inherit;
+}
+input:focus, textarea:focus, select:focus { border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79,70,229,0.1); }
+.encoding-select { cursor: pointer; background: #fff; }
+textarea { font-family: 'Consolas', monospace; resize: vertical; }
+.form-row { display: flex; gap: 12px; }
+.flex-1 { flex: 1; }
+.terminal-options { display: flex; gap: 10px; flex-wrap: wrap; }
+.terminal-option {
+  display: flex; align-items: center; gap: 6px; padding: 8px 16px;
+  border: 2px solid #e5e7eb; border-radius: 8px; cursor: pointer; font-size: 14px; transition: all 0.15s;
+}
+.terminal-option input { display: none; }
+.terminal-option.active { border-color: #4f46e5; background: #eef2ff; color: #4f46e5; font-weight: 600; }
+.add-var-btn {
+  background: none; border: 1px solid #d1d5db; padding: 2px 10px; border-radius: 6px;
+  font-size: 12px; cursor: pointer; color: #4f46e5; display: inline-flex; align-items: center; gap: 4px;
+}
+.add-var-btn:hover { background: #eef2ff; }
+.vars-form { display: flex; flex-direction: column; gap: 8px; }
+.var-row { display: flex; align-items: center; gap: 8px; }
+.var-name { flex: 1; font-family: 'Consolas', monospace !important; }
+.var-eq { font-weight: 700; color: #9ca3af; }
+.var-value { flex: 1; font-family: 'Consolas', monospace !important; }
+.remove-var-btn { background: none; border: none; color: #ef4444; cursor: pointer; padding: 4px 8px; border-radius: 4px; }
+.remove-var-btn:hover { background: #fef2f2; }
+.no-vars { color: #9ca3af; font-size: 13px; }
+.dialog-footer {
+  padding: 16px 24px; border-top: 1px solid #e5e7eb;
+  display: flex; justify-content: flex-end; gap: 10px;
+}
+.btn { padding: 8px 20px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; border: none; }
+.btn-cancel { background: #f3f4f6; color: #4b5563; }
+.btn-cancel:hover { background: #e5e7eb; }
+.btn-submit { background: #4f46e5; color: #fff; }
+.btn-submit:hover { background: #4338ca; }
+</style>

+ 17 - 0
frontend/src/main.ts

@@ -0,0 +1,17 @@
+import { createApp } from 'vue'
+import PrimeVue from 'primevue/config'
+import Aura from '@primevue/themes/aura'
+import 'primeicons/primeicons.css'
+import './style.css'
+import App from './App.vue'
+
+const app = createApp(App)
+app.use(PrimeVue, {
+  theme: {
+    preset: Aura,
+    options: {
+      darkModeSelector: '.dark-mode',
+    },
+  },
+})
+app.mount('#app')

+ 16 - 0
frontend/src/style.css

@@ -0,0 +1,16 @@
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+}
+
+body {
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+  background: #f0f2f5;
+  color: #1a1a2e;
+  line-height: 1.6;
+}
+
+#app {
+  min-height: 100vh;
+}

+ 14 - 0
frontend/tsconfig.app.json

@@ -0,0 +1,14 @@
+{
+  "extends": "@vue/tsconfig/tsconfig.dom.json",
+  "compilerOptions": {
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+    "types": ["vite/client"],
+
+    /* Linting */
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "erasableSyntaxOnly": true,
+    "noFallthroughCasesInSwitch": true
+  },
+  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
+}

+ 7 - 0
frontend/tsconfig.json

@@ -0,0 +1,7 @@
+{
+  "files": [],
+  "references": [
+    { "path": "./tsconfig.app.json" },
+    { "path": "./tsconfig.node.json" }
+  ]
+}

+ 24 - 0
frontend/tsconfig.node.json

@@ -0,0 +1,24 @@
+{
+  "compilerOptions": {
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+    "target": "es2023",
+    "lib": ["ES2023"],
+    "module": "esnext",
+    "types": ["node"],
+    "skipLibCheck": true,
+
+    /* Bundler mode */
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "verbatimModuleSyntax": true,
+    "moduleDetection": "force",
+    "noEmit": true,
+
+    /* Linting */
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "erasableSyntaxOnly": true,
+    "noFallthroughCasesInSwitch": true
+  },
+  "include": ["vite.config.ts"]
+}

+ 15 - 0
frontend/vite.config.ts

@@ -0,0 +1,15 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+export default defineConfig({
+  plugins: [vue()],
+  server: {
+    port: 5173,
+    proxy: {
+      '/api': {
+        target: 'http://localhost:9091',
+        changeOrigin: true,
+      },
+    },
+  },
+})

+ 126 - 0
run.bat

@@ -0,0 +1,126 @@
+@echo off
+chcp 65001 >nul 2>&1
+setlocal
+
+:: ============================================
+::  服务管理中心 - 构建 + 打包 + 运行 一键脚本
+:: ============================================
+
+set "SCRIPT_DIR=%~dp0"
+set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%"
+
+:: 支持参数: run / build / clean / help
+set "ACTION=%~1"
+if "%ACTION%"=="" set "ACTION=run"
+
+if "%ACTION%"=="help" goto :usage
+if "%ACTION%"=="-h" goto :usage
+if "%ACTION%"=="--help" goto :usage
+
+echo.
+echo ============================================
+echo   服务管理中心 - %ACTION%
+echo ============================================
+echo.
+
+:: ---------- 检查环境 ----------
+where java >nul 2>&1
+if %errorlevel% neq 0 (
+    echo [ERROR] 未找到 java,请确保 Java 17+ 已安装并加入 PATH
+    exit /b 1
+)
+
+where mvn >nul 2>&1
+if %errorlevel% neq 0 (
+    echo [ERROR] 未找到 mvn,请确保 Maven 已安装并加入 PATH
+    exit /b 1
+)
+
+where node >nul 2>&1
+if %errorlevel% neq 0 (
+    echo [ERROR] 未找到 node,请确保 Node.js 18+ 已安装并加入 PATH
+    exit /b 1
+)
+
+:: ---------- 清理 ----------
+if "%ACTION%"=="clean" (
+    echo [1/3] 清理前端构建产物...
+    if exist "%SCRIPT_DIR%\frontend\dist" rmdir /s /q "%SCRIPT_DIR%\frontend\dist"
+    if exist "%SCRIPT_DIR%\backend\src\main\resources\static" rmdir /s /q "%SCRIPT_DIR%\backend\src\main\resources\static"
+
+    echo [2/3] 清理后端构建产物...
+    if exist "%SCRIPT_DIR%\backend\target" rmdir /s /q "%SCRIPT_DIR%\backend\target"
+
+    echo [3/3] 清理运行时数据...
+    if exist "%SCRIPT_DIR%\data" rmdir /s /q "%SCRIPT_DIR%\data"
+
+    echo.
+    echo [DONE] 清理完成
+    exit /b 0
+)
+
+:: ---------- 构建前端 ----------
+echo [1/4] 安装前端依赖...
+cd /d "%SCRIPT_DIR%\frontend"
+call npm install --silent 2>nul
+if %errorlevel% neq 0 (
+    echo [ERROR] 前端依赖安装失败
+    exit /b 1
+)
+
+echo [2/4] 构建前端...
+call npx vite build
+if %errorlevel% neq 0 (
+    echo [ERROR] 前端构建失败
+    exit /b 1
+)
+
+echo [3/4] 复制前端产物到后端静态资源目录...
+if exist "%SCRIPT_DIR%\backend\src\main\resources\static" rmdir /s /q "%SCRIPT_DIR%\backend\src\main\resources\static"
+xcopy /e /i /q /y "%SCRIPT_DIR%\frontend\dist" "%SCRIPT_DIR%\backend\src\main\resources\static" >nul
+
+:: ---------- 打包后端 ----------
+echo [4/4] 打包后端 (mvn package)...
+cd /d "%SCRIPT_DIR%\backend"
+call mvn package -DskipTests -q
+if %errorlevel% neq 0 (
+    echo [ERROR] 后端打包失败
+    exit /b 1
+)
+
+echo.
+echo [DONE] 构建打包完成!
+echo.
+
+:: ---------- 运行 ----------
+if "%ACTION%"=="build" (
+    echo 提示: 使用 run.bat run 启动服务
+    exit /b 0
+)
+
+echo ----------------------------------------
+echo   启动服务 (端口 3945)
+echo   访问 http://localhost:3945
+echo   按 Ctrl+C 停止
+echo ----------------------------------------
+echo.
+
+cd /d "%SCRIPT_DIR%\backend"
+java -jar target\service-management-1.0.0.jar
+
+endlocal
+exit /b 0
+
+:: ---------- 帮助 ----------
+:usage
+echo.
+echo   用法: run.bat [命令]
+echo.
+echo   命令:
+echo     (无参数)   构建前端 + 打包后端 + 启动服务
+echo     build      仅构建前端 + 打包后端(不启动)
+echo     run        构建 + 打包 + 启动(同默认行为)
+echo     clean      清理所有构建产物和运行时数据
+echo     help       显示此帮助信息
+echo.
+exit /b 0

+ 110 - 0
run.sh

@@ -0,0 +1,110 @@
+#!/usr/bin/env bash
+# ============================================
+#  服务管理中心 - 构建 + 打包 + 运行 一键脚本
+# ============================================
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+ACTION="${1:-run}"
+
+# ---------- 颜色 ----------
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+CYAN='\033[0;36m'
+NC='\033[0m'
+
+info()  { echo -e "${CYAN}[INFO]${NC} $*"; }
+ok()    { echo -e "${GREEN}[OK]${NC} $*"; }
+err()   { echo -e "${RED}[ERROR]${NC} $*" >&2; }
+
+# ---------- 帮助 ----------
+show_usage() {
+    echo ""
+    echo "  用法: ./run.sh [命令]"
+    echo ""
+    echo "  命令:"
+    echo "    (无参数)   构建前端 + 打包后端 + 启动服务"
+    echo "    build      仅构建前端 + 打包后端(不启动)"
+    echo "    run        构建 + 打包 + 启动(同默认行为)"
+    echo "    clean      清理所有构建产物和运行时数据"
+    echo "    help       显示此帮助信息"
+    echo ""
+    exit 0
+}
+
+case "$ACTION" in
+    help|-h|--help) show_usage ;;
+esac
+
+echo ""
+echo "============================================"
+echo "  服务管理中心 - $ACTION"
+echo "============================================"
+echo ""
+
+# ---------- 检查环境 ----------
+check_cmd() {
+    if ! command -v "$1" &>/dev/null; then
+        err "未找到 $1,请确保已安装并加入 PATH"
+        exit 1
+    fi
+}
+
+check_cmd java
+check_cmd mvn
+check_cmd node
+
+# ---------- 清理 ----------
+if [ "$ACTION" = "clean" ]; then
+    info "清理前端构建产物..."
+    rm -rf "$SCRIPT_DIR/frontend/dist"
+    rm -rf "$SCRIPT_DIR/backend/src/main/resources/static"
+
+    info "清理后端构建产物..."
+    rm -rf "$SCRIPT_DIR/backend/target"
+
+    info "清理运行时数据..."
+    rm -rf "$SCRIPT_DIR/data"
+
+    echo ""
+    ok "清理完成"
+    exit 0
+fi
+
+# ---------- 构建前端 ----------
+info "[1/4] 安装前端依赖..."
+cd "$SCRIPT_DIR/frontend"
+npm install --silent 2>/dev/null
+
+info "[2/4] 构建前端..."
+npx vite build
+
+info "[3/4] 复制前端产物到后端静态资源目录..."
+rm -rf "$SCRIPT_DIR/backend/src/main/resources/static"
+cp -r "$SCRIPT_DIR/frontend/dist" "$SCRIPT_DIR/backend/src/main/resources/static"
+
+# ---------- 打包后端 ----------
+info "[4/4] 打包后端 (mvn package)..."
+cd "$SCRIPT_DIR/backend"
+mvn package -DskipTests -q
+
+echo ""
+ok "构建打包完成!"
+echo ""
+
+if [ "$ACTION" = "build" ]; then
+    echo "提示: 使用 ./run.sh run 启动服务"
+    exit 0
+fi
+
+# ---------- 运行 ----------
+echo "----------------------------------------"
+echo "  启动服务 (端口 3945)"
+echo "  访问 http://localhost:3945"
+echo "  按 Ctrl+C 停止"
+echo "----------------------------------------"
+echo ""
+
+cd "$SCRIPT_DIR/backend"
+exec java -jar target/service-management-1.0.0.jar

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно