Răsfoiți Sursa

1. 新增了服务分类管理功能,支持无限层级的分类树,支持分类的创建、重命名、删除及添加子分类;
2. 新增了左侧分类树侧边栏组件,支持展开/折叠、右键菜单、分类内服务计数显示及按分类筛选服务;
3. 优化了进程输出编码的识别与默认值选择,新增系统默认编码自动检测,中文 Windows 默认推荐 GB18030,并在服务表单中优化了编码选择的提示文案;
4. 修复了 ServiceFormDialog 组件因变量初始化顺序错误(TDZ)导致编辑按钮无法弹出对话框的问题;
5. 修复了分类树服务计数在切换分类时错误归零的问题,计数现基于全量服务列表计算。

weisijie 2 luni în urmă
părinte
comite
f5de827b6c

+ 71 - 0
backend/src/main/java/com/svcman/controller/CategoryController.java

@@ -0,0 +1,71 @@
+package com.svcman.controller;
+
+import com.svcman.model.Category;
+import com.svcman.repository.CategoryRepository;
+import com.svcman.repository.ServiceConfigRepository;
+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/categories")
+@RequiredArgsConstructor
+public class CategoryController {
+
+    private final CategoryRepository categoryRepository;
+    private final ServiceConfigRepository serviceConfigRepository;
+
+    @GetMapping
+    public List<Category> listCategories() {
+        return categoryRepository.findAll();
+    }
+
+    @PostMapping
+    public ResponseEntity<Category> createCategory(@RequestBody Category category) {
+        if (category.getName() == null || category.getName().isBlank()) {
+            return ResponseEntity.badRequest().build();
+        }
+        log.info("创建分类: {}", category.getName());
+        return ResponseEntity.ok(categoryRepository.save(category));
+    }
+
+    @PutMapping("/{id}")
+    public ResponseEntity<Category> updateCategory(
+            @PathVariable String id, @RequestBody Category category) {
+        if (!categoryRepository.findById(id).isPresent()) {
+            return ResponseEntity.notFound().build();
+        }
+        category.setId(id);
+        log.info("更新分类: id={}", id);
+        return ResponseEntity.ok(categoryRepository.save(category));
+    }
+
+    @DeleteMapping("/{id}")
+    public ResponseEntity<Void> deleteCategory(@PathVariable String id) {
+        log.info("删除分类: id={}", id);
+        // 将该分类下的服务的 categoryId 置为 null
+        serviceConfigRepository.findAll().stream()
+                .filter(s -> id.equals(s.getCategoryId()))
+                .forEach(s -> {
+                    s.setCategoryId(null);
+                    serviceConfigRepository.save(s);
+                });
+        // 删除分类
+        categoryRepository.deleteById(id);
+        return ResponseEntity.noContent().build();
+    }
+
+    @ExceptionHandler(Exception.class)
+    public ResponseEntity<Map<String, String>> handleInternal(Exception e) {
+        log.error("分类操作错误", e);
+        return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
+    }
+}

+ 12 - 1
backend/src/main/java/com/svcman/controller/ServiceController.java

@@ -30,7 +30,11 @@ public class ServiceController {
     // ===== CRUD =====
     // ===== CRUD =====
 
 
     @GetMapping
     @GetMapping
-    public List<ServiceConfig> listServices() {
+    public List<ServiceConfig> listServices(
+            @RequestParam(value = "categoryId", required = false) String categoryId) {
+        if (categoryId != null) {
+            return configService.listByCategoryId(categoryId);
+        }
         return configService.listAll();
         return configService.listAll();
     }
     }
 
 
@@ -126,6 +130,13 @@ public class ServiceController {
         return ResponseEntity.ok(Map.of("log", log));
         return ResponseEntity.ok(Map.of("log", log));
     }
     }
 
 
+    // ===== 系统信息 =====
+
+    @GetMapping("/system-default-encoding")
+    public ResponseEntity<Map<String, String>> getSystemDefaultEncoding() {
+        return ResponseEntity.ok(Map.of("encoding", ProcessManager.getSystemDefaultEncoding()));
+    }
+
     // ===== 异常处理 =====
     // ===== 异常处理 =====
 
 
     @ExceptionHandler(IllegalArgumentException.class)
     @ExceptionHandler(IllegalArgumentException.class)

+ 31 - 0
backend/src/main/java/com/svcman/model/Category.java

@@ -0,0 +1,31 @@
+package com.svcman.model;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 分类实体,支持无限层级树形结构
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class Category {
+
+    /** 唯一标识 */
+    private String id;
+
+    /** 分类名称 */
+    private String name;
+
+    /** 父分类 ID(null 表示根分类) */
+    private String parentId;
+
+    /** 排序序号,越小越靠前 */
+    @Builder.Default
+    private Integer sortOrder = 0;
+}

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

@@ -64,6 +64,9 @@ public class ServiceConfig {
     @Builder.Default
     @Builder.Default
     private String logEncoding = "UTF-8";
     private String logEncoding = "UTF-8";
 
 
+    /** 所属分类 ID */
+    private String categoryId;
+
     /** 创建时间 */
     /** 创建时间 */
     private Instant createdAt;
     private Instant createdAt;
 
 

+ 86 - 0
backend/src/main/java/com/svcman/repository/CategoryRepository.java

@@ -0,0 +1,86 @@
+package com.svcman.repository;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.svcman.model.Category;
+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 CategoryRepository {
+
+    private final File dataFile;
+    private final ObjectMapper objectMapper;
+    private final Map<String, Category> store = new ConcurrentHashMap<>();
+
+    public CategoryRepository(@Value("${app.data-dir}") String dataDir) {
+        this.dataFile = new File(dataDir, "categories.json");
+        this.objectMapper = new ObjectMapper();
+        this.objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
+    }
+
+    @PostConstruct
+    public void init() {
+        if (dataFile.exists()) {
+            try {
+                Category[] categories = objectMapper.readValue(dataFile, Category[].class);
+                for (Category cat : categories) {
+                    store.put(cat.getId(), cat);
+                }
+                log.info("已加载 {} 个分类", store.size());
+            } catch (IOException e) {
+                log.error("加载分类数据失败", e);
+            }
+        } else {
+            log.info("分类数据文件不存在,将从空配置开始");
+        }
+    }
+
+    public List<Category> findAll() {
+        return new ArrayList<>(store.values());
+    }
+
+    public Optional<Category> findById(String id) {
+        return Optional.ofNullable(store.get(id));
+    }
+
+    public Category save(Category category) {
+        if (category.getId() == null || category.getId().isBlank()) {
+            category.setId(UUID.randomUUID().toString());
+        }
+        store.put(category.getId(), category);
+        persist();
+        log.info("分类已保存: id={}, name={}", category.getId(), category.getName());
+        return category;
+    }
+
+    public boolean deleteById(String id) {
+        Category removed = store.remove(id);
+        if (removed != null) {
+            persist();
+            log.info("分类已删除: id={}, name={}", id, removed.getName());
+            return true;
+        }
+        return false;
+    }
+
+    private void persist() {
+        try {
+            dataFile.getParentFile().mkdirs();
+            objectMapper.writeValue(dataFile, store.values());
+        } catch (IOException e) {
+            log.error("持久化分类数据失败", e);
+        }
+    }
+}

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

@@ -152,6 +152,22 @@ public class ProcessManager {
         return StandardCharsets.UTF_8;
         return StandardCharsets.UTF_8;
     }
     }
 
 
+    /**
+     * 获取操作系统默认的子进程输出编码。
+     * 中文 Windows 系统代码页为 GBK(936),子进程 stdout 默认输出 GBK 字节。
+     * Linux/Mac 默认 UTF-8。
+     */
+    public static String getSystemDefaultEncoding() {
+        String fileEncoding = System.getProperty("file.encoding", "UTF-8");
+        // file.encoding 在中文 Windows 上通常是 GBK
+        if (fileEncoding.equalsIgnoreCase("GBK")
+                || fileEncoding.equalsIgnoreCase("GB2312")
+                || fileEncoding.equalsIgnoreCase("GB18030")) {
+            return "GB18030"; // GB18030 是 GBK 的超集
+        }
+        return "UTF-8";
+    }
+
     /**
     /**
      * 启动服务
      * 启动服务
      */
      */

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

@@ -8,6 +8,7 @@ import org.springframework.stereotype.Service;
 
 
 import java.time.Instant;
 import java.time.Instant;
 import java.util.List;
 import java.util.List;
+import java.util.stream.Collectors;
 
 
 /**
 /**
  * 服务配置 CRUD 业务层
  * 服务配置 CRUD 业务层
@@ -23,6 +24,12 @@ public class ServiceConfigService {
         return repository.findAll();
         return repository.findAll();
     }
     }
 
 
+    public List<ServiceConfig> listByCategoryId(String categoryId) {
+        return repository.findAll().stream()
+                .filter(s -> categoryId.equals(s.getCategoryId()))
+                .collect(Collectors.toList());
+    }
+
     public ServiceConfig getById(String id) {
     public ServiceConfig getById(String id) {
         return repository.findById(id)
         return repository.findById(id)
                 .orElseThrow(() -> new IllegalArgumentException("服务不存在: " + id));
                 .orElseThrow(() -> new IllegalArgumentException("服务不存在: " + id));

+ 89 - 24
frontend/src/App.vue

@@ -1,11 +1,12 @@
 <script setup lang="ts">
 <script setup lang="ts">
-import { ref, onMounted, onUnmounted } from 'vue'
+import { ref, computed, onMounted, onUnmounted } from 'vue'
 import { serviceApi } from './api/service'
 import { serviceApi } from './api/service'
-import type { ServiceConfig } from './api/types'
+import type { ServiceConfig, Category } from './api/types'
 import ServiceCard from './components/ServiceCard.vue'
 import ServiceCard from './components/ServiceCard.vue'
 import ServiceFormDialog from './components/ServiceFormDialog.vue'
 import ServiceFormDialog from './components/ServiceFormDialog.vue'
 import LogDialog from './components/LogDialog.vue'
 import LogDialog from './components/LogDialog.vue'
 import PortKillDialog from './components/PortKillDialog.vue'
 import PortKillDialog from './components/PortKillDialog.vue'
+import CategoryTree from './components/CategoryTree.vue'
 
 
 const services = ref<ServiceConfig[]>([])
 const services = ref<ServiceConfig[]>([])
 const loading = ref(false)
 const loading = ref(false)
@@ -19,17 +20,50 @@ const logLoading = ref(false)
 const showPortDialog = ref(false)
 const showPortDialog = ref(false)
 const portServiceId = ref<string>('')
 const portServiceId = ref<string>('')
 const portServiceName = ref<string>('')
 const portServiceName = ref<string>('')
+const selectedCategoryId = ref<string | null>(null)
+
+const categoryTreeRef = ref<InstanceType<typeof CategoryTree> | null>(null)
 
 
 let refreshTimer: ReturnType<typeof setInterval> | null = null
 let refreshTimer: ReturnType<typeof setInterval> | null = null
 
 
+/** 计算每个分类下的服务数量 */
+const serviceCounts = computed<Record<string, number>>(() => {
+  const counts: Record<string, number> = {}
+  for (const svc of allServices.value) {
+    if (svc.categoryId) {
+      counts[svc.categoryId] = (counts[svc.categoryId] || 0) + 1
+    }
+  }
+  return counts
+})
+
+/** 获取全量服务(用于统计和「全部」视图) */
+const allServices = ref<ServiceConfig[]>([])
+
 const fetchServices = async () => {
 const fetchServices = async () => {
   try {
   try {
-    services.value = await serviceApi.list()
+    allServices.value = await serviceApi.list()
+    // 根据选中分类过滤显示
+    if (selectedCategoryId.value) {
+      services.value = allServices.value.filter(s => s.categoryId === selectedCategoryId.value)
+    } else {
+      services.value = allServices.value
+    }
   } catch (e) {
   } catch (e) {
     console.error('Failed to fetch services', e)
     console.error('Failed to fetch services', e)
   }
   }
 }
 }
 
 
+const handleCategorySelect = async (categoryId: string | null) => {
+  selectedCategoryId.value = categoryId
+  // 本地过滤,无需请求后端
+  if (categoryId) {
+    services.value = allServices.value.filter(s => s.categoryId === categoryId)
+  } else {
+    services.value = allServices.value
+  }
+}
+
 const handleCreate = () => {
 const handleCreate = () => {
   editingService.value = null
   editingService.value = null
   showFormDialog.value = true
   showFormDialog.value = true
@@ -151,6 +185,11 @@ const handlePortKilled = async () => {
 const runningCount = () => services.value.filter((s) => s.status === 'RUNNING').length
 const runningCount = () => services.value.filter((s) => s.status === 'RUNNING').length
 const totalCount = () => services.value.length
 const totalCount = () => services.value.length
 
 
+/** 传递给 ServiceFormDialog 的分类列表 */
+const categoriesForSelect = computed<Category[]>(() => {
+  return categoryTreeRef.value?.categories ?? []
+})
+
 onMounted(() => {
 onMounted(() => {
   fetchServices()
   fetchServices()
   refreshTimer = setInterval(fetchServices, 5000)
   refreshTimer = setInterval(fetchServices, 5000)
@@ -186,31 +225,41 @@ onUnmounted(() => {
       </div>
       </div>
     </header>
     </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="app-body">
+      <CategoryTree
+        ref="categoryTreeRef"
+        :service-counts="serviceCounts"
+        @select="handleCategorySelect"
+      />
 
 
-      <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>
+      <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>
+    </div>
 
 
     <ServiceFormDialog
     <ServiceFormDialog
       v-model:visible="showFormDialog"
       v-model:visible="showFormDialog"
       :service="editingService"
       :service="editingService"
+      :categories="categoriesForSelect"
+      :selected-category-id="selectedCategoryId"
       @save="handleFormSave"
       @save="handleFormSave"
     />
     />
 
 
@@ -290,7 +339,23 @@ onUnmounted(() => {
 .btn-outline { background: rgba(255,255,255,0.1); color: #fff; border: 1px solid rgba(255,255,255,0.3); }
 .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-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); }
 .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; }
+
+.app-body {
+  display: flex;
+  flex: 1;
+  overflow: hidden;
+}
+
+.app-main {
+  flex: 1;
+  padding: 24px 32px;
+  max-width: 1400px;
+  width: 100%;
+  margin: 0 auto;
+  overflow-y: auto;
+  height: calc(100vh - 60px);
+}
+
 .empty-state { text-align: center; padding: 80px 20px; color: #999; }
 .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; }
 .service-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 16px; }
 @media (max-width: 768px) {
 @media (max-width: 768px) {

+ 35 - 3
frontend/src/api/service.ts

@@ -1,14 +1,26 @@
 import axios from 'axios'
 import axios from 'axios'
-import type { ServiceConfig, PortCheckResult, KillResult } from './types'
+import type { ServiceConfig, PortCheckResult, KillResult, Category } from './types'
 
 
 const api = axios.create({
 const api = axios.create({
   baseURL: '/api/services',
   baseURL: '/api/services',
   timeout: 30000,
   timeout: 30000,
 })
 })
 
 
+const categoryApi = axios.create({
+  baseURL: '/api/categories',
+  timeout: 30000,
+})
+
+export const systemApi = {
+  getDefaultEncoding(): Promise<{ encoding: string }> {
+    return api.get('/system-default-encoding').then((r) => r.data)
+  },
+}
+
 export const serviceApi = {
 export const serviceApi = {
-  list(): Promise<ServiceConfig[]> {
-    return api.get('').then((r) => r.data)
+  list(categoryId?: string): Promise<ServiceConfig[]> {
+    const params = categoryId ? { categoryId } : {}
+    return api.get('', { params }).then((r) => r.data)
   },
   },
 
 
   get(id: string): Promise<ServiceConfig> {
   get(id: string): Promise<ServiceConfig> {
@@ -59,3 +71,23 @@ export const serviceApi = {
     return api.post(`/${id}/kill-port/${pid}`).then((r) => r.data)
     return api.post(`/${id}/kill-port/${pid}`).then((r) => r.data)
   },
   },
 }
 }
+
+export const categoryApiAxios = categoryApi
+
+export const categoryApiObj = {
+  list(): Promise<Category[]> {
+    return categoryApi.get('').then((r) => r.data)
+  },
+
+  create(cat: Category): Promise<Category> {
+    return categoryApi.post('', cat).then((r) => r.data)
+  },
+
+  update(id: string, cat: Category): Promise<Category> {
+    return categoryApi.put(`/${id}`, cat).then((r) => r.data)
+  },
+
+  delete(id: string): Promise<void> {
+    return categoryApi.delete(`/${id}`)
+  },
+}

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

@@ -24,6 +24,7 @@ export interface ServiceConfig {
   variables?: CustomVariable[]
   variables?: CustomVariable[]
   webUrl?: string
   webUrl?: string
   logEncoding?: string
   logEncoding?: string
+  categoryId?: string
   createdAt?: string
   createdAt?: string
   updatedAt?: string
   updatedAt?: string
   status?: RunStatus
   status?: RunStatus
@@ -53,3 +54,11 @@ export interface KillResult {
   success: boolean
   success: boolean
   message: string
   message: string
 }
 }
+
+/** 分类节点 */
+export interface Category {
+  id: string
+  name: string
+  parentId?: string | null
+  sortOrder?: number
+}

+ 573 - 0
frontend/src/components/CategoryTree.vue

@@ -0,0 +1,573 @@
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+import type { Category } from '../api/types'
+import { categoryApiObj } from '../api/service'
+
+const props = defineProps<{
+  serviceCounts: Record<string, number>
+}>()
+
+const emit = defineEmits<{
+  select: [categoryId: string | null]
+}>()
+
+const categories = ref<Category[]>([])
+const selectedId = ref<string | null>(null)
+const expandedIds = ref<Set<string>>(new Set())
+
+// 内联编辑状态
+const editingId = ref<string | null>(null)
+const editingName = ref('')
+const addingParentId = ref<string | null | 'ROOT'>('NONE') // 'NONE' = 不在添加, 'ROOT' = 根级, 其他 = parentId
+const newName = ref('')
+
+// 右键菜单
+const contextMenu = ref<{ x: number; y: number; categoryId: string } | null>(null)
+
+/** 树节点 */
+interface TreeNode {
+  category: Category
+  children: TreeNode[]
+}
+
+/** 扁平化渲染行 */
+interface FlatRow {
+  category: Category
+  depth: number
+  hasChildren: boolean
+}
+
+/** 将树扁平化为行列表(根据展开状态) */
+const flatRows = computed<FlatRow[]>(() => {
+  const map = new Map<string, TreeNode>()
+  const roots: TreeNode[] = []
+
+  for (const cat of categories.value) {
+    map.set(cat.id, { category: cat, children: [] })
+  }
+  for (const cat of categories.value) {
+    const node = map.get(cat.id)!
+    if (cat.parentId && map.has(cat.parentId)) {
+      map.get(cat.parentId)!.children.push(node)
+    } else {
+      roots.push(node)
+    }
+  }
+
+  const sortNodes = (nodes: TreeNode[]) => {
+    nodes.sort((a, b) => (a.category.sortOrder || 0) - (b.category.sortOrder || 0))
+    nodes.forEach(n => sortNodes(n.children))
+  }
+  sortNodes(roots)
+
+  const result: FlatRow[] = []
+  const flatten = (nodes: TreeNode[], depth: number) => {
+    for (const node of nodes) {
+      result.push({
+        category: node.category,
+        depth,
+        hasChildren: node.children.length > 0,
+      })
+      if (expandedIds.value.has(node.category.id)) {
+        flatten(node.children, depth + 1)
+      }
+    }
+  }
+  flatten(roots, 0)
+  return result
+})
+
+const totalCount = computed(() =>
+  Object.values(props.serviceCounts).reduce((a, b) => a + b, 0)
+)
+
+/** 递归统计分类及子分类下的服务数 */
+const getCategoryCount = (id: string): number => {
+  let count = props.serviceCounts[id] || 0
+  for (const cat of categories.value) {
+    if (cat.parentId === id) count += getCategoryCount(cat.id)
+  }
+  return count
+}
+
+/** 加载分类列表 */
+const fetchCategories = async () => {
+  try {
+    categories.value = await categoryApiObj.list()
+  } catch (e) {
+    console.error('加载分类失败', e)
+  }
+}
+
+/** 选中分类 */
+const selectCategory = (id: string | null) => {
+  selectedId.value = id
+  emit('select', id)
+}
+
+/** 切换展开/折叠 */
+const toggleExpand = (id: string) => {
+  if (expandedIds.value.has(id)) {
+    expandedIds.value.delete(id)
+  } else {
+    expandedIds.value.add(id)
+  }
+}
+
+/** 展开所有父节点(给外部用) */
+const expandParents = (catId: string) => {
+  let current = categories.value.find(c => c.id === catId)
+  while (current?.parentId) {
+    expandedIds.value.add(current.parentId)
+    current = categories.value.find(c => c.id === current!.parentId)
+  }
+}
+
+/** 开始添加 */
+const startAdd = (parentId: string | null) => {
+  addingParentId.value = parentId ?? 'ROOT'
+  newName.value = ''
+  if (parentId) expandedIds.value.add(parentId)
+}
+
+/** 确认添加 */
+const confirmAdd = async () => {
+  const name = newName.value.trim()
+  if (!name) return
+  const parentId = addingParentId.value === 'ROOT' ? null : addingParentId.value
+  try {
+    await categoryApiObj.create({ id: '', name, parentId: parentId ?? undefined, sortOrder: 0 })
+    await fetchCategories()
+  } catch (e) {
+    console.error('添加分类失败', e)
+  }
+  cancelAdd()
+}
+
+const cancelAdd = () => {
+  addingParentId.value = 'NONE'
+  newName.value = ''
+}
+
+/** 开始重命名 */
+const startRename = (cat: Category) => {
+  editingId.value = cat.id
+  editingName.value = cat.name
+  contextMenu.value = null
+}
+
+/** 确认重命名 */
+const confirmRename = async () => {
+  const name = editingName.value.trim()
+  if (!name || !editingId.value) return
+  try {
+    const cat = categories.value.find(c => c.id === editingId.value)
+    if (cat) {
+      await categoryApiObj.update(cat.id, { ...cat, name })
+      await fetchCategories()
+    }
+  } catch (e) {
+    console.error('重命名失败', e)
+  }
+  cancelRename()
+}
+
+const cancelRename = () => {
+  editingId.value = null
+  editingName.value = ''
+}
+
+/** 删除分类 */
+const deleteCategory = async (id: string) => {
+  contextMenu.value = null
+  const cat = categories.value.find(c => c.id === id)
+  if (!cat) return
+  const childCount = categories.value.filter(c => c.parentId === id).length
+  const msg = childCount > 0
+    ? `分类"${cat.name}"下还有 ${childCount} 个子分类,删除后子分类将变为根分类。确定删除?`
+    : `确定删除分类"${cat.name}"?`
+  if (!confirm(msg)) return
+  try {
+    await categoryApiObj.delete(id)
+    await fetchCategories()
+    if (selectedId.value === id) selectCategory(null)
+  } catch (e) {
+    console.error('删除分类失败', e)
+  }
+}
+
+/** 右键菜单 */
+const showContextMenu = (e: MouseEvent, cat: Category) => {
+  e.preventDefault()
+  e.stopPropagation()
+  contextMenu.value = { x: e.clientX, y: e.clientY, categoryId: cat.id }
+}
+
+const closeContextMenu = () => {
+  contextMenu.value = null
+}
+
+/** 获取某行之后插入输入框的判断 */
+const shouldShowAddInput = (row: FlatRow): boolean => {
+  if (addingParentId.value === 'NONE') return false
+  if (addingParentId.value === 'ROOT') return false // 根级在末尾显示
+  return addingParentId.value === row.category.id
+}
+
+const showRootAddInput = computed(() => addingParentId.value === 'ROOT')
+
+defineExpose({ fetchCategories, selectedId, expandParents, categories })
+
+fetchCategories()
+</script>
+
+<template>
+  <div class="category-sidebar" @click="closeContextMenu">
+    <div class="sidebar-header">
+      <span class="sidebar-title">分类</span>
+      <button class="add-root-btn" @click.stop="startAdd(null)" title="添加根分类">
+        <i class="pi pi-plus"></i>
+      </button>
+    </div>
+
+    <div class="tree-container">
+      <!-- 全部服务 -->
+      <div
+        class="tree-item all-item"
+        :class="{ active: selectedId === null }"
+        @click.stop="selectCategory(null)"
+      >
+        <i class="pi pi-th-large tree-icon"></i>
+        <span class="tree-label">全部服务</span>
+        <span class="tree-count">{{ totalCount }}</span>
+      </div>
+
+      <!-- 扁平化渲染树 -->
+      <template v-for="row in flatRows" :key="row.category.id">
+        <!-- 添加子分类输入框(在目标分类下方) -->
+        <div
+          v-if="shouldShowAddInput(row)"
+          class="add-input-row"
+          :style="{ paddingLeft: (12 + (row.depth + 1) * 20) + 'px' }"
+          @click.stop
+        >
+          <input
+            v-model="newName"
+            type="text"
+            placeholder="分类名称"
+            class="add-input"
+            @keyup.enter="confirmAdd"
+            @keyup.escape="cancelAdd"
+            ref="addInputRef"
+          />
+          <button class="inline-btn confirm" @click.stop="confirmAdd"><i class="pi pi-check"></i></button>
+          <button class="inline-btn cancel" @click.stop="cancelAdd"><i class="pi pi-times"></i></button>
+        </div>
+
+        <!-- 分类节点 -->
+        <div
+          class="tree-item"
+          :class="{ active: selectedId === row.category.id }"
+          :style="{ paddingLeft: (12 + row.depth * 20) + 'px' }"
+          @click.stop="selectCategory(row.category.id)"
+          @contextmenu.stop="showContextMenu($event, row.category)"
+        >
+          <span class="tree-toggle" @click.stop="toggleExpand(row.category.id)">
+            <i v-if="row.hasChildren" :class="['pi', expandedIds.has(row.category.id) ? 'pi-chevron-down' : 'pi-chevron-right']"></i>
+            <span v-else class="toggle-placeholder"></span>
+          </span>
+          <i class="pi pi-folder tree-icon"></i>
+
+          <!-- 重命名模式 -->
+          <template v-if="editingId === row.category.id">
+            <input
+              v-model="editingName"
+              type="text"
+              class="add-input"
+              @keyup.enter="confirmRename"
+              @keyup.escape="cancelRename"
+              @click.stop
+            />
+            <button class="inline-btn confirm" @click.stop="confirmRename"><i class="pi pi-check"></i></button>
+            <button class="inline-btn cancel" @click.stop="cancelRename"><i class="pi pi-times"></i></button>
+          </template>
+
+          <!-- 显示模式 -->
+          <template v-else>
+            <span class="tree-label">{{ row.category.name }}</span>
+            <span class="tree-count">{{ getCategoryCount(row.category.id) }}</span>
+            <button class="tree-add-btn" @click.stop="startAdd(row.category.id)" title="添加子分类">
+              <i class="pi pi-plus"></i>
+            </button>
+          </template>
+        </div>
+      </template>
+
+      <!-- 根级添加输入框(在末尾) -->
+      <div v-if="showRootAddInput" class="add-input-row" :style="{ paddingLeft: '32px' }" @click.stop>
+        <input
+          v-model="newName"
+          type="text"
+          placeholder="分类名称"
+          class="add-input"
+          @keyup.enter="confirmAdd"
+          @keyup.escape="cancelAdd"
+        />
+        <button class="inline-btn confirm" @click.stop="confirmAdd"><i class="pi pi-check"></i></button>
+        <button class="inline-btn cancel" @click.stop="cancelAdd"><i class="pi pi-times"></i></button>
+      </div>
+
+      <!-- 空状态 -->
+      <div v-if="flatRows.length === 0 && !showRootAddInput" class="empty-tip">
+        点击 <i class="pi pi-plus" style="font-size: 11px"></i> 添加分类
+      </div>
+    </div>
+
+    <!-- 右键菜单 -->
+    <div
+      v-if="contextMenu"
+      class="context-menu"
+      :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
+      @click.stop
+    >
+      <button @click="startRename(categories.find(c => c.id === contextMenu?.categoryId)!)">
+        <i class="pi pi-pencil"></i> 重命名
+      </button>
+      <button @click="startAdd(contextMenu!.categoryId)">
+        <i class="pi pi-plus"></i> 添加子分类
+      </button>
+      <button class="danger" @click="deleteCategory(contextMenu!.categoryId)">
+        <i class="pi pi-trash"></i> 删除
+      </button>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.category-sidebar {
+  width: 240px;
+  min-width: 240px;
+  background: #fff;
+  border-right: 1px solid #e5e7eb;
+  display: flex;
+  flex-direction: column;
+  height: 100vh;
+  position: sticky;
+  top: 0;
+  user-select: none;
+}
+
+.sidebar-header {
+  padding: 14px 16px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  border-bottom: 1px solid #f3f4f6;
+}
+
+.sidebar-title {
+  font-size: 13px;
+  font-weight: 600;
+  color: #6b7280;
+  text-transform: uppercase;
+  letter-spacing: 0.5px;
+}
+
+.add-root-btn {
+  background: none;
+  border: 1px solid #e5e7eb;
+  border-radius: 4px;
+  padding: 2px 6px;
+  cursor: pointer;
+  color: #6b7280;
+  font-size: 12px;
+  transition: all 0.15s;
+}
+
+.add-root-btn:hover {
+  background: #f3f4f6;
+  color: #4f46e5;
+  border-color: #4f46e5;
+}
+
+.tree-container {
+  flex: 1;
+  overflow-y: auto;
+  padding: 8px 0;
+}
+
+.tree-item {
+  display: flex;
+  align-items: center;
+  padding: 7px 12px;
+  padding-right: 8px;
+  cursor: pointer;
+  gap: 6px;
+  transition: background 0.1s;
+  font-size: 13px;
+  color: #374151;
+}
+
+.tree-item:hover {
+  background: #f9fafb;
+}
+
+.tree-item.active {
+  background: #eef2ff;
+  color: #4f46e5;
+  font-weight: 500;
+}
+
+.tree-item.active .tree-icon,
+.tree-item.active .tree-count {
+  color: #4f46e5;
+}
+
+.tree-item.active .tree-count {
+  background: rgba(79, 70, 229, 0.1);
+}
+
+.all-item {
+  margin-bottom: 4px;
+  border-bottom: 1px solid #f3f4f6;
+  padding-bottom: 10px;
+  padding-left: 16px;
+}
+
+.tree-toggle {
+  width: 16px;
+  height: 16px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 10px;
+  color: #9ca3af;
+  flex-shrink: 0;
+}
+
+.toggle-placeholder {
+  display: inline-block;
+  width: 16px;
+}
+
+.tree-icon {
+  font-size: 13px;
+  color: #9ca3af;
+  flex-shrink: 0;
+}
+
+.tree-label {
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.tree-count {
+  font-size: 11px;
+  color: #9ca3af;
+  background: #f3f4f6;
+  padding: 1px 6px;
+  border-radius: 8px;
+  min-width: 20px;
+  text-align: center;
+  flex-shrink: 0;
+}
+
+.tree-add-btn {
+  opacity: 0;
+  background: none;
+  border: none;
+  padding: 2px;
+  cursor: pointer;
+  color: #9ca3af;
+  font-size: 11px;
+  border-radius: 3px;
+  transition: all 0.15s;
+  flex-shrink: 0;
+}
+
+.tree-item:hover .tree-add-btn {
+  opacity: 1;
+}
+
+.tree-add-btn:hover {
+  color: #4f46e5;
+  background: #eef2ff;
+}
+
+.add-input-row {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 12px;
+}
+
+.add-input {
+  flex: 1;
+  padding: 4px 8px;
+  border: 1px solid #d1d5db;
+  border-radius: 4px;
+  font-size: 12px;
+  outline: none;
+  min-width: 0;
+}
+
+.add-input:focus {
+  border-color: #4f46e5;
+}
+
+.inline-btn {
+  background: none;
+  border: none;
+  padding: 4px;
+  cursor: pointer;
+  border-radius: 3px;
+  font-size: 12px;
+}
+
+.inline-btn.confirm { color: #10b981; }
+.inline-btn.confirm:hover { background: #ecfdf5; }
+.inline-btn.cancel { color: #ef4444; }
+.inline-btn.cancel:hover { background: #fef2f2; }
+
+.context-menu {
+  position: fixed;
+  background: #fff;
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
+  z-index: 2000;
+  padding: 4px;
+  min-width: 140px;
+}
+
+.context-menu button {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  width: 100%;
+  padding: 7px 12px;
+  border: none;
+  background: none;
+  cursor: pointer;
+  font-size: 13px;
+  color: #374151;
+  border-radius: 4px;
+  transition: background 0.1s;
+}
+
+.context-menu button:hover {
+  background: #f3f4f6;
+}
+
+.context-menu button.danger { color: #ef4444; }
+.context-menu button.danger:hover { background: #fef2f2; }
+
+.empty-tip {
+  padding: 12px 16px;
+  color: #9ca3af;
+  font-size: 12px;
+  text-align: center;
+}
+</style>

+ 51 - 8
frontend/src/components/ServiceFormDialog.vue

@@ -1,10 +1,13 @@
 <script setup lang="ts">
 <script setup lang="ts">
-import { ref, watch } from 'vue'
-import type { ServiceConfig, TerminalType } from '../api/types'
+import { ref, watch, computed } from 'vue'
+import type { ServiceConfig, TerminalType, Category } from '../api/types'
+import { systemApi } from '../api/service'
 
 
 const props = defineProps<{
 const props = defineProps<{
   visible: boolean
   visible: boolean
   service: ServiceConfig | null
   service: ServiceConfig | null
+  categories: Category[]
+  selectedCategoryId: string | null
 }>()
 }>()
 
 
 const emit = defineEmits<{
 const emit = defineEmits<{
@@ -12,7 +15,11 @@ const emit = defineEmits<{
   save: [config: ServiceConfig]
   save: [config: ServiceConfig]
 }>()
 }>()
 
 
-const form = ref<ServiceConfig>(createEmptyForm())
+/** 系统默认编码,从后端获取 */
+const systemDefaultEncoding = ref('UTF-8')
+systemApi.getDefaultEncoding().then(res => {
+  systemDefaultEncoding.value = res.encoding
+}).catch(() => {})
 
 
 function createEmptyForm(): ServiceConfig {
 function createEmptyForm(): ServiceConfig {
   return {
   return {
@@ -24,10 +31,13 @@ function createEmptyForm(): ServiceConfig {
     sshConfig: { host: '', port: 22, username: '', password: '' },
     sshConfig: { host: '', port: 22, username: '', password: '' },
     variables: [],
     variables: [],
     webUrl: '',
     webUrl: '',
-    logEncoding: 'UTF-8',
+    logEncoding: systemDefaultEncoding.value,
+    categoryId: props.selectedCategoryId || undefined,
   }
   }
 }
 }
 
 
+const form = ref<ServiceConfig>(createEmptyForm())
+
 watch(() => props.visible, (val) => {
 watch(() => props.visible, (val) => {
   if (val) {
   if (val) {
     if (props.service) {
     if (props.service) {
@@ -62,6 +72,26 @@ const handleClose = () => {
   emit('update:visible', false)
   emit('update:visible', false)
 }
 }
 
 
+/** 将分类树扁平化为带层级前缀的选项列表 */
+const categoryOptions = computed(() => {
+  const result: { id: string; name: string; prefix: string }[] = []
+  const visited = new Set<string>()
+
+  const walk = (parentId: string | null | undefined, depth: number) => {
+    for (const cat of props.categories) {
+      if (cat.parentId === parentId && !visited.has(cat.id)) {
+        visited.add(cat.id)
+        result.push({ id: cat.id, name: cat.name, prefix: '\u00A0\u00A0'.repeat(depth) })
+        walk(cat.id, depth + 1)
+      }
+    }
+  }
+  // 先添加根分类
+  walk(null, 0)
+  walk(undefined, 0)
+  return result
+})
+
 const handleSubmit = () => {
 const handleSubmit = () => {
   if (!form.value.name.trim()) {
   if (!form.value.name.trim()) {
     alert('请输入服务名称')
     alert('请输入服务名称')
@@ -81,6 +111,7 @@ const handleSubmit = () => {
     variables: form.value.variables?.filter(v => v.name.trim()),
     variables: form.value.variables?.filter(v => v.name.trim()),
     webUrl: form.value.webUrl?.trim() || undefined,
     webUrl: form.value.webUrl?.trim() || undefined,
     logEncoding: form.value.logEncoding || 'UTF-8',
     logEncoding: form.value.logEncoding || 'UTF-8',
+    categoryId: form.value.categoryId || undefined,
   }
   }
 
 
   if (form.value.terminalType === 'SSH') {
   if (form.value.terminalType === 'SSH') {
@@ -112,6 +143,15 @@ const handleSubmit = () => {
             <label>服务描述</label>
             <label>服务描述</label>
             <input v-model="form.description" type="text" placeholder="可选的描述信息" />
             <input v-model="form.description" type="text" placeholder="可选的描述信息" />
           </div>
           </div>
+          <div class="form-group">
+            <label>所属分类</label>
+            <select v-model="form.categoryId" class="encoding-select">
+              <option :value="undefined">未分类</option>
+              <option v-for="cat in categoryOptions" :key="cat.id" :value="cat.id">
+                {{ cat.prefix }}{{ cat.name }}
+              </option>
+            </select>
+          </div>
         </div>
         </div>
 
 
         <div class="form-section">
         <div class="form-section">
@@ -147,12 +187,15 @@ const handleSubmit = () => {
         </div>
         </div>
 
 
         <div class="form-section">
         <div class="form-section">
-          <h3>日志编码</h3>
+          <h3>进程输出编码</h3>
           <div class="form-group">
           <div class="form-group">
-            <label>服务进程输出的字符编码,影响日志读取时的解码方式</label>
+            <label>
+              子进程 stdout 的实际编码。中文 Windows 默认输出 GBK,若日志乱码请选择 GB18030;
+              Linux/Mac 或已配置 <code>-Dfile.encoding=UTF-8</code> 的 Java 进程请选择 UTF-8
+            </label>
             <select v-model="form.logEncoding" class="encoding-select">
             <select v-model="form.logEncoding" class="encoding-select">
-              <option value="UTF-8">UTF-8(默认)</option>
-              <option value="GB18030">GB18030</option>
+              <option value="UTF-8">UTF-8(Linux/Mac 默认)</option>
+              <option value="GB18030">GB18030(中文 Windows 默认)</option>
               <option value="GBK">GBK</option>
               <option value="GBK">GBK</option>
               <option value="GB2312">GB2312</option>
               <option value="GB2312">GB2312</option>
               <option value="Big5">Big5(繁体中文)</option>
               <option value="Big5">Big5(繁体中文)</option>