cyrus 7 months ago
parent
commit
2b2245b4ef
46 changed files with 2426 additions and 445 deletions
  1. 1 0
      backend/src/main/java/com/wenshu/platform/config/K8sOrchestratorProperties.java
  2. 5 0
      backend/src/main/java/com/wenshu/platform/controller/ops/AlertController.java
  3. 0 5
      backend/src/main/java/com/wenshu/platform/controller/resource/LifecycleController.java
  4. 11 0
      backend/src/main/java/com/wenshu/platform/controller/taskbuild/TaskBuildWorkflowController.java
  5. 11 0
      backend/src/main/java/com/wenshu/platform/controller/taskexec/WorkflowController.java
  6. 1 1
      backend/src/main/java/com/wenshu/platform/model/enums/StockMachineStatus.java
  7. 47 0
      backend/src/main/java/com/wenshu/platform/service/resource/JschSshConnectivityVerifier.java
  8. 6 0
      backend/src/main/java/com/wenshu/platform/service/resource/SshConnectivityVerifier.java
  9. 115 0
      backend/src/main/java/com/wenshu/platform/service/resource/StockJoiningStatusReconciler.java
  10. 34 14
      backend/src/main/java/com/wenshu/platform/service/resource/StockService.java
  11. 39 0
      backend/src/main/java/com/wenshu/platform/service/resource/k8s/HttpK8sOrchestratorClient.java
  12. 2 0
      backend/src/main/java/com/wenshu/platform/service/resource/k8s/K8sOrchestratorClient.java
  13. 7 0
      backend/src/main/java/com/wenshu/platform/service/resource/k8s/NodeInventorySnapshot.java
  14. 92 0
      backend/src/main/java/com/wenshu/platform/service/taskbuild/TaskBuildWorkflowService.java
  15. 20 17
      backend/src/main/resources/db/test-audit-log-data.sql
  16. 48 0
      backend/src/test/java/com/wenshu/platform/config/audit/AuditAspectTest.java
  17. 77 0
      backend/src/test/java/com/wenshu/platform/service/resource/StockJoiningStatusReconcilerTest.java
  18. 134 6
      backend/src/test/java/com/wenshu/platform/service/resource/StockServiceTest.java
  19. 44 0
      backend/src/test/java/com/wenshu/platform/service/taskbuild/TaskBuildWorkflowServiceTest.java
  20. 185 0
      docs/stock-machine-addition.md
  21. 24 0
      frontend/nginx.conf
  22. BIN
      frontend/public/logo1.png
  23. 7 0
      frontend/src/App.vue
  24. 1 1
      frontend/src/api/stock.js
  25. 474 0
      frontend/src/components/UiCascadeSelect.vue
  26. 336 0
      frontend/src/components/UiSelect.vue
  27. 17 34
      frontend/src/router/index.js
  28. 58 2
      frontend/src/styles/member-theme.css
  29. 13 0
      frontend/src/utils/displayId.js
  30. 38 15
      frontend/src/views/ops/AlertEventPage.vue
  31. 37 40
      frontend/src/views/ops/AlertPage.vue
  32. 102 73
      frontend/src/views/ops/AlertRulePage.vue
  33. 67 78
      frontend/src/views/ops/AuditLogPage.vue
  34. 27 23
      frontend/src/views/resource-management/ClusterConsolePage.vue
  35. 18 9
      frontend/src/views/resource-management/ClusterPage.vue
  36. 2 1
      frontend/src/views/resource-management/StockConsolePage.vue
  37. 93 40
      frontend/src/views/resource-management/StockPage.vue
  38. 55 12
      frontend/src/views/settings/MemberManagementPage.vue
  39. 10 7
      frontend/src/views/task-build/CodeTaskBuildPage.vue
  40. 3 2
      frontend/src/views/task-build/DialogTaskBuildPage.vue
  41. 81 43
      frontend/src/views/task-build/TaskBuildWorkflowListPage.vue
  42. 3 2
      frontend/src/views/task-exec/TaskInstanceLogPage.vue
  43. 3 2
      frontend/src/views/task-exec/TaskInstanceResultPage.vue
  44. 18 16
      frontend/src/views/task-exec/WorkflowExecutionPage.vue
  45. 24 2
      k8s-orchestrator/internal/controller/clusteroperation_controller.go
  46. 36 0
      k8s-orchestrator/internal/controller/clusteroperation_controller_test.go

+ 1 - 0
backend/src/main/java/com/wenshu/platform/config/K8sOrchestratorProperties.java

@@ -14,5 +14,6 @@ public class K8sOrchestratorProperties {
     private int waitTimeoutMs = 300000;
     private int pollIntervalMs = 2000;
     private int reconcileCreateDelayMs = 5000;
+    private int reconcileJoinDelayMs = 5000;
     private int reconcileBatchSize = 100;
 }

+ 5 - 0
backend/src/main/java/com/wenshu/platform/controller/ops/AlertController.java

@@ -63,6 +63,11 @@ public class AlertController {
     }
 
     @PutMapping("/rules/{ruleId}")
+    @Auditable(
+            actionType = "ALERT_RULE_UPDATE",
+            targetType = "ALERT_RULE",
+            targetIdExpr = "#arg0",
+            detailExpr = "'update alert rule'")
     public AlertRuleResp updateRule(@PathVariable Long ruleId, @RequestBody AlertRuleCreateReq req) {
         return alertRuleService.updateRule(ruleId, AlertRuleConverter.toDO(req));
     }

+ 0 - 5
backend/src/main/java/com/wenshu/platform/controller/resource/LifecycleController.java

@@ -117,11 +117,6 @@ public class LifecycleController {
     }
 
     @PutMapping("/{clusterId}/nodes/{nodeId}")
-    @Auditable(
-            actionType = "CLUSTER_NODE_UPDATE",
-            targetType = "CLUSTER_NODE",
-            targetIdExpr = "#arg1",
-            detailExpr = "'update cluster node'")
     public ClusterNodeResp updateClusterNode(
             @PathVariable Long clusterId,
             @PathVariable Long nodeId,

+ 11 - 0
backend/src/main/java/com/wenshu/platform/controller/taskbuild/TaskBuildWorkflowController.java

@@ -1,5 +1,6 @@
 package com.wenshu.platform.controller.taskbuild;
 
+import com.wenshu.platform.config.audit.Auditable;
 import com.wenshu.platform.model.converter.TaskBuildConverter;
 import com.wenshu.platform.model.query.TaskBuildWorkflowQuery;
 import com.wenshu.platform.model.req.TaskBuildWorkflowQueryReq;
@@ -39,6 +40,11 @@ public class TaskBuildWorkflowController {
     }
 
     @PutMapping("/{workflowId}")
+    @Auditable(
+            actionType = "WORKFLOW_UPDATE",
+            targetType = "WORKFLOW",
+            targetIdExpr = "#arg0",
+            detailExpr = "'update workflow definition'")
     public TaskBuildWorkflowDetailResp updateWorkflow(
             @PathVariable Long workflowId,
             @RequestBody TaskBuildWorkflowUpdateReq req) {
@@ -49,6 +55,11 @@ public class TaskBuildWorkflowController {
 
     @DeleteMapping("/{workflowId}")
     @ResponseStatus(HttpStatus.NO_CONTENT)
+    @Auditable(
+            actionType = "WORKFLOW_DELETE",
+            targetType = "WORKFLOW",
+            targetIdExpr = "#arg0",
+            detailExpr = "'delete workflow definition'")
     public void deleteWorkflow(@PathVariable Long workflowId) {
         taskBuildWorkflowService.deleteWorkflow(workflowId);
     }

+ 11 - 0
backend/src/main/java/com/wenshu/platform/controller/taskexec/WorkflowController.java

@@ -2,6 +2,7 @@ package com.wenshu.platform.controller.taskexec;
 
 import java.nio.charset.StandardCharsets;
 
+import com.wenshu.platform.config.audit.Auditable;
 import com.wenshu.platform.model.converter.WorkflowExecutionConverter;
 import com.wenshu.platform.model.req.WorkflowInstanceQueryReq;
 import com.wenshu.platform.model.req.WorkflowSubmitReq;
@@ -36,6 +37,11 @@ public class WorkflowController {
     private final WorkflowService workflowService;
 
     @PostMapping("/{workflowId}/submit")
+    @Auditable(
+            actionType = "WORKFLOW_SUBMIT",
+            targetType = "WORKFLOW",
+            targetIdExpr = "#arg0",
+            detailExpr = "'submit workflow'")
     public WorkflowSubmitResp submitWorkflow(
             @PathVariable Long workflowId,
             @RequestBody(required = false) WorkflowSubmitReq req) {
@@ -98,6 +104,11 @@ public class WorkflowController {
 
     @PostMapping("/instances/{workflowInstanceId}/terminate")
     @ResponseStatus(HttpStatus.NO_CONTENT)
+    @Auditable(
+            actionType = "WORKFLOW_TERMINATE",
+            targetType = "WORKFLOW_INSTANCE",
+            targetIdExpr = "#arg0",
+            detailExpr = "'terminate workflow instance'")
     public void terminateWorkflow(@PathVariable Long workflowInstanceId) {
         workflowService.terminateWorkflow(workflowInstanceId);
     }

+ 1 - 1
backend/src/main/java/com/wenshu/platform/model/enums/StockMachineStatus.java

@@ -6,6 +6,7 @@ import org.springframework.util.StringUtils;
 
 public enum StockMachineStatus {
     IDLE("IDLE"),
+    JOINING("JOINING"),
     WORKING("WORKING"),
     OFFLINE("OFFLINE");
 
@@ -36,4 +37,3 @@ public enum StockMachineStatus {
         return IDLE.code.equals(normalizeCode(input));
     }
 }
-

+ 47 - 0
backend/src/main/java/com/wenshu/platform/service/resource/JschSshConnectivityVerifier.java

@@ -0,0 +1,47 @@
+package com.wenshu.platform.service.resource;
+
+import com.jcraft.jsch.JSch;
+import com.jcraft.jsch.JSchException;
+import com.jcraft.jsch.Session;
+import java.util.Properties;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+@Component
+public class JschSshConnectivityVerifier implements SshConnectivityVerifier {
+
+    private static final int SSH_CONNECT_TIMEOUT_SECONDS = 10;
+
+    @Override
+    public void verify(String ipAddress, int port, String sshUsername, String sshPassword) {
+        if (!StringUtils.hasText(ipAddress)) {
+            throw new IllegalArgumentException("ipAddress cannot be empty");
+        }
+        if (port <= 0) {
+            throw new IllegalArgumentException("port must be positive");
+        }
+        if (!StringUtils.hasText(sshUsername)) {
+            throw new IllegalArgumentException("sshUsername cannot be empty");
+        }
+        if (!StringUtils.hasText(sshPassword)) {
+            throw new IllegalArgumentException("sshPassword cannot be empty");
+        }
+
+        Session session = null;
+        try {
+            JSch jsch = new JSch();
+            session = jsch.getSession(sshUsername, ipAddress, port);
+            session.setPassword(sshPassword);
+            Properties config = new Properties();
+            config.setProperty("StrictHostKeyChecking", "no");
+            session.setConfig(config);
+            session.connect(SSH_CONNECT_TIMEOUT_SECONDS * 1000);
+        } catch (JSchException ex) {
+            throw new IllegalStateException("SSH连接失败: " + ex.getMessage(), ex);
+        } finally {
+            if (session != null) {
+                session.disconnect();
+            }
+        }
+    }
+}

+ 6 - 0
backend/src/main/java/com/wenshu/platform/service/resource/SshConnectivityVerifier.java

@@ -0,0 +1,6 @@
+package com.wenshu.platform.service.resource;
+
+public interface SshConnectivityVerifier {
+
+    void verify(String ipAddress, int port, String sshUsername, String sshPassword);
+}

+ 115 - 0
backend/src/main/java/com/wenshu/platform/service/resource/StockJoiningStatusReconciler.java

@@ -0,0 +1,115 @@
+package com.wenshu.platform.service.resource;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import com.wenshu.platform.config.K8sOrchestratorProperties;
+import com.wenshu.platform.dao.StockDAO;
+import com.wenshu.platform.model.dataobject.StockInfoDO;
+import com.wenshu.platform.model.enums.StockMachineStatus;
+import com.wenshu.platform.model.query.StockQuery;
+import com.wenshu.platform.service.resource.k8s.K8sOrchestratorClient;
+import com.wenshu.platform.service.resource.k8s.NodeInventorySnapshot;
+import com.wenshu.platform.service.resource.k8s.OrchestratorIdMapper;
+import java.util.stream.Collectors;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+@Component
+public class StockJoiningStatusReconciler {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(StockJoiningStatusReconciler.class);
+
+    private final StockDAO stockDAO;
+    private final K8sOrchestratorClient k8sOrchestratorClient;
+    private final K8sOrchestratorProperties properties;
+
+    public StockJoiningStatusReconciler(
+            StockDAO stockDAO,
+            K8sOrchestratorClient k8sOrchestratorClient,
+            K8sOrchestratorProperties properties) {
+        this.stockDAO = stockDAO;
+        this.k8sOrchestratorClient = k8sOrchestratorClient;
+        this.properties = properties;
+    }
+
+    @Scheduled(fixedDelayString = "${k8s.orchestrator.reconcile-join-delay-ms:5000}")
+    public void reconcileJoiningMachines() {
+        try {
+            int limit = Math.max(properties.getReconcileBatchSize(), 1);
+            List<StockInfoDO> joiningMachines = stockDAO.find(buildJoiningQuery(limit));
+            if (joiningMachines == null || joiningMachines.isEmpty()) {
+                return;
+            }
+
+            Map<String, NodeInventorySnapshot> nodeByName = listNodesByName();
+            if (nodeByName.isEmpty()) {
+                return;
+            }
+
+            for (StockInfoDO machine : joiningMachines) {
+                if (machine == null || machine.getMachineId() == null) {
+                    continue;
+                }
+                if (!StockMachineStatus.JOINING.getCode().equals(machine.getStatus())) {
+                    continue;
+                }
+
+                String nodeName = OrchestratorIdMapper.toNodeId(machine.getMachineId());
+                NodeInventorySnapshot snapshot = nodeByName.get(nodeName);
+                if (snapshot == null) {
+                    continue;
+                }
+                if (!"IDLE".equals(normalizePhase(snapshot.phase()))) {
+                    continue;
+                }
+                int updated = stockDAO.updateStatusByIdAndStatus(
+                        machine.getMachineId(),
+                        StockMachineStatus.JOINING.getCode(),
+                        StockMachineStatus.IDLE.getCode());
+                if (updated > 0) {
+                    LOGGER.info("Stock machine moved from JOINING to IDLE. machineId={}, nodeName={}",
+                            machine.getMachineId(), nodeName);
+                }
+            }
+        } catch (Exception ex) {
+            LOGGER.warn("Reconcile joining machines failed. error={}", ex.getMessage(), ex);
+        }
+    }
+
+    private StockQuery buildJoiningQuery(int limit) {
+        StockQuery query = new StockQuery();
+        query.setStatus(StockMachineStatus.JOINING.getCode());
+        query.setPage(1);
+        query.setSize(limit);
+        query.setOffset(0);
+        query.setLimit(limit);
+        return query;
+    }
+
+    private Map<String, NodeInventorySnapshot> listNodesByName() {
+        List<NodeInventorySnapshot> nodeSnapshots = k8sOrchestratorClient.listNodes();
+        if (nodeSnapshots == null || nodeSnapshots.isEmpty()) {
+            return Map.of();
+        }
+        return nodeSnapshots.stream()
+                .filter(snapshot -> snapshot != null && StringUtils.hasText(snapshot.name()))
+                .collect(Collectors.toMap(
+                        snapshot -> snapshot.name().trim(),
+                        snapshot -> snapshot,
+                        (left, right) -> left,
+                        LinkedHashMap::new));
+    }
+
+    private String normalizePhase(String value) {
+        if (!StringUtils.hasText(value)) {
+            return null;
+        }
+        return value.trim().toUpperCase(Locale.ROOT);
+    }
+}

+ 34 - 14
backend/src/main/java/com/wenshu/platform/service/resource/StockService.java

@@ -34,6 +34,8 @@ import com.wenshu.platform.service.auth.UnauthorizedException;
 import com.wenshu.platform.service.resource.k8s.K8sOrchestratorClient;
 import com.wenshu.platform.service.resource.k8s.OrchestratorIdMapper;
 import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.GrantedAuthority;
 import org.springframework.security.core.context.SecurityContextHolder;
@@ -45,6 +47,8 @@ import org.springframework.util.StringUtils;
 @RequiredArgsConstructor
 public class StockService {
 
+    private static final Logger LOGGER = LoggerFactory.getLogger(StockService.class);
+
     private static final int QUERY_MAX_PAGE_SIZE = 200;
     private static final int IP_ADDRESS_MAX_LENGTH = 45;
     private static final int CPU_CORES_MAX = 127;
@@ -91,6 +95,7 @@ public class StockService {
 
     private final StockDAO stockDAO;
     private final SshCredentialCryptoService sshCredentialCryptoService;
+    private final SshConnectivityVerifier sshConnectivityVerifier;
     private final K8sOrchestratorClient k8sOrchestratorClient;
 
     public PageResp<StockMachineResp> listMachines(StockQuery query) {
@@ -109,12 +114,18 @@ public class StockService {
         validateSshInputOnCreate(normalizedMachine);
         String sshPassword = normalizedMachine.getSshPassword();
         normalizedMachine.setSshPasswordCipher(sshCredentialCryptoService.encrypt(sshPassword));
-        normalizedMachine.setSshPassword(null);
         validateMachine(normalizedMachine);
         ensureIpAddressUnique(normalizedMachine.getIpAddress(), null);
+        sshConnectivityVerifier.verify(
+                normalizedMachine.getIpAddress(),
+                SSH_PORT,
+                normalizedMachine.getSshUsername(),
+                sshPassword);
+        normalizedMachine.setSshPassword(null);
         stockDAO.save(normalizedMachine);
-        String operationId = k8sOrchestratorClient.addNode(
-                OrchestratorIdMapper.toNodeId(normalizedMachine.getMachineId()),
+        String nodeId = OrchestratorIdMapper.toNodeId(normalizedMachine.getMachineId());
+        k8sOrchestratorClient.addNode(
+                nodeId,
                 normalizedMachine.getCpuCores(),
                 normalizedMachine.getRamGb(),
                 normalizedMachine.getSsdGb(),
@@ -122,7 +133,6 @@ public class StockService {
                 normalizedMachine.getSshUsername(),
                 sshPassword,
                 SSH_PORT);
-        k8sOrchestratorClient.waitForOperationSucceeded(operationId);
         return StockConverter.toResp(normalizedMachine);
     }
 
@@ -139,8 +149,8 @@ public class StockService {
         }
         StockInfoDO normalizedPatch = normalizePatch(updateMachine);
 
-        if (StockMachineStatus.WORKING.getCode().equals(currentMachine.getStatus())) {
-            validateWorkingPatch(normalizedPatch);
+        if (isManagedLifecycleStatus(currentMachine.getStatus())) {
+            validateManagedLifecyclePatch(normalizedPatch);
             StockInfoDO workingUpdatedMachine = copyMachine(currentMachine);
             if (normalizedPatch.getDescription() != null) {
                 workingUpdatedMachine.setDescription(normalizedPatch.getDescription());
@@ -179,6 +189,9 @@ public class StockService {
         if (StockMachineStatus.WORKING.getCode().equals(existingMachine.getStatus())) {
             throw new IllegalArgumentException("Working machine cannot be deleted");
         }
+        if (StockMachineStatus.JOINING.getCode().equals(existingMachine.getStatus())) {
+            throw new IllegalArgumentException("Joining machine cannot be deleted");
+        }
         String operationId = k8sOrchestratorClient.deleteNode(OrchestratorIdMapper.toNodeId(machineId));
         k8sOrchestratorClient.waitForOperationSucceeded(operationId);
 
@@ -280,7 +293,7 @@ public class StockService {
         if (rawStatus != null) {
             normalizedStatus = StockMachineStatus.normalizeCode(rawStatus);
             if (normalizedStatus == null) {
-                throw new IllegalArgumentException("status must be IDLE, WORKING or OFFLINE");
+                throw new IllegalArgumentException("status must be IDLE, JOINING, WORKING or OFFLINE");
             }
         }
         StockQuery normalizedQuery = new StockQuery();
@@ -305,7 +318,7 @@ public class StockService {
         normalizedMachine.setSsdGb(inputMachine.getSsdGb());
         normalizedMachine.setSshUsername(normalizeText(inputMachine.getSshUsername()));
         normalizedMachine.setSshPassword(normalizeText(inputMachine.getSshPassword()));
-        normalizedMachine.setStatus(StockMachineStatus.IDLE.getCode());
+        normalizedMachine.setStatus(StockMachineStatus.JOINING.getCode());
         normalizedMachine.setDescription(normalizeText(inputMachine.getDescription()));
         return normalizedMachine;
     }
@@ -380,7 +393,7 @@ public class StockService {
         }
         String normalizedStatus = StockMachineStatus.normalizeCode(machine.getStatus());
         if (normalizedStatus == null) {
-            throw new IllegalArgumentException("status must be IDLE, WORKING or OFFLINE");
+            throw new IllegalArgumentException("status must be IDLE, JOINING, WORKING or OFFLINE");
         }
         machine.setStatus(normalizedStatus);
         if (machine.getDescription() != null && machine.getDescription().length() > DESCRIPTION_MAX_LENGTH) {
@@ -388,7 +401,7 @@ public class StockService {
         }
     }
 
-    private void validateWorkingPatch(StockInfoDO patchMachine) {
+    private void validateManagedLifecyclePatch(StockInfoDO patchMachine) {
         if (patchMachine.getIpAddress() != null
                 || patchMachine.getCpuCores() != null
                 || patchMachine.getCpuModel() != null
@@ -397,7 +410,7 @@ public class StockService {
                 || patchMachine.getSshUsername() != null
                 || patchMachine.getSshPassword() != null
                 || patchMachine.getStatus() != null) {
-            throw new IllegalArgumentException("Working machine can only update description");
+            throw new IllegalArgumentException("Managed machine can only update description");
         }
         if (patchMachine.getDescription() != null && patchMachine.getDescription().length() > DESCRIPTION_MAX_LENGTH) {
             throw new IllegalArgumentException("description length exceeds " + DESCRIPTION_MAX_LENGTH);
@@ -511,14 +524,21 @@ public class StockService {
         }
         String normalized = StockMachineStatus.normalizeCode(status);
         if (normalized == null) {
-            throw new IllegalArgumentException("status must be IDLE, WORKING or OFFLINE");
+            throw new IllegalArgumentException("status must be IDLE, JOINING, WORKING or OFFLINE");
         }
-        if (StockMachineStatus.WORKING.getCode().equals(normalized)) {
-            throw new IllegalArgumentException("status WORKING is managed by cluster lifecycle");
+        if (StockMachineStatus.WORKING.getCode().equals(normalized)
+                || StockMachineStatus.JOINING.getCode().equals(normalized)) {
+            throw new IllegalArgumentException("status WORKING or JOINING is managed by cluster lifecycle");
         }
         return normalized;
     }
 
+    private boolean isManagedLifecycleStatus(String status) {
+        String normalized = StockMachineStatus.normalizeCode(status);
+        return StockMachineStatus.WORKING.getCode().equals(normalized)
+                || StockMachineStatus.JOINING.getCode().equals(normalized);
+    }
+
     private boolean isValidIpAddress(String ipAddress) {
         if (!StringUtils.hasText(ipAddress)) {
             return false;

+ 39 - 0
backend/src/main/java/com/wenshu/platform/service/resource/k8s/HttpK8sOrchestratorClient.java

@@ -8,6 +8,7 @@ import java.net.http.HttpRequest;
 import java.net.http.HttpResponse;
 import java.nio.charset.StandardCharsets;
 import java.time.Duration;
+import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -53,6 +54,44 @@ public class HttpK8sOrchestratorClient implements K8sOrchestratorClient {
         return submitMutation("/v1/spark-clusters", payload);
     }
 
+    @Override
+    public List<NodeInventorySnapshot> listNodes() {
+        String path = "/v1/nodes";
+        try {
+            HttpRequest request = HttpRequest.newBuilder()
+                    .uri(URI.create(baseUrl + path))
+                    .timeout(Duration.ofMillis(Math.max(properties.getTimeoutMs(), 1000)))
+                    .GET()
+                    .build();
+            HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+            if (response.statusCode() < 200 || response.statusCode() >= 300) {
+                throw new IllegalStateException("k8s orchestrator list nodes failed: " + extractError(response.body()));
+            }
+            JsonNode root = objectMapper.readTree(response.body());
+            JsonNode itemsNode = root.path("items");
+            if (!itemsNode.isArray()) {
+                return List.of();
+            }
+            List<NodeInventorySnapshot> nodes = new ArrayList<>(itemsNode.size());
+            for (JsonNode item : itemsNode) {
+                String name = normalizeText(item.path("metadata").path("name").asText(null), false);
+                if (name == null) {
+                    continue;
+                }
+                nodes.add(new NodeInventorySnapshot(
+                        name,
+                        normalizeText(item.path("status").path("phase").asText(null), true),
+                        normalizeText(item.path("status").path("kubernetesNodeName").asText(null), false)));
+            }
+            return nodes;
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new IllegalStateException("k8s orchestrator list nodes interrupted", e);
+        } catch (IOException e) {
+            throw new IllegalStateException("k8s orchestrator list nodes failed: " + e.getMessage(), e);
+        }
+    }
+
     @Override
     public String addNode(
             String nodeId,

+ 2 - 0
backend/src/main/java/com/wenshu/platform/service/resource/k8s/K8sOrchestratorClient.java

@@ -4,6 +4,8 @@ import java.util.List;
 
 public interface K8sOrchestratorClient {
 
+    List<NodeInventorySnapshot> listNodes();
+
     String addNode(
             String nodeId,
             Integer cpu,

+ 7 - 0
backend/src/main/java/com/wenshu/platform/service/resource/k8s/NodeInventorySnapshot.java

@@ -0,0 +1,7 @@
+package com.wenshu.platform.service.resource.k8s;
+
+public record NodeInventorySnapshot(
+        String name,
+        String phase,
+        String kubernetesNodeName) {
+}

+ 92 - 0
backend/src/main/java/com/wenshu/platform/service/taskbuild/TaskBuildWorkflowService.java

@@ -1,5 +1,6 @@
 package com.wenshu.platform.service.taskbuild;
 
+import java.lang.reflect.Method;
 import java.util.ArrayList;
 import java.util.LinkedHashSet;
 import java.util.List;
@@ -12,18 +13,24 @@ import java.util.stream.Collectors;
 import com.wenshu.platform.dao.TaskDefinitionDAO;
 import com.wenshu.platform.dao.WorkflowDefinitionDAO;
 import com.wenshu.platform.dao.WorkflowInstanceDAO;
+import com.wenshu.platform.model.dataobject.AuditLogDO;
 import com.wenshu.platform.model.bo.TaskBuildWorkflowUpdateBO;
 import com.wenshu.platform.model.converter.TaskBuildConverter;
 import com.wenshu.platform.model.dataobject.TaskDefinitionDO;
 import com.wenshu.platform.model.dataobject.TaskBuildWorkflowDO;
 import com.wenshu.platform.model.dataobject.WorkflowDefinitionDO;
+import com.wenshu.platform.model.enums.AuditLogResultStatus;
 import com.wenshu.platform.model.query.TaskBuildWorkflowQuery;
 import com.wenshu.platform.model.resp.TaskBuildWorkflowDetailResp;
 import com.wenshu.platform.model.resp.PageResp;
 import com.wenshu.platform.model.resp.TaskBuildWorkflowResp;
+import com.wenshu.platform.service.ops.AuditLogService;
 import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.ReflectionUtils;
 import org.springframework.util.StringUtils;
 
 @Service
@@ -42,6 +49,7 @@ public class TaskBuildWorkflowService {
     private final TaskDefinitionDAO taskDefinitionDAO;
     private final WorkflowInstanceDAO workflowInstanceDAO;
     private final CodeValidatorFactory codeValidatorFactory;
+    private final AuditLogService auditLogService;
 
     public PageResp<TaskBuildWorkflowResp> listWorkflows(TaskBuildWorkflowQuery query) {
         TaskBuildWorkflowQuery normalizedQuery = normalizeQuery(query);
@@ -280,6 +288,90 @@ public class TaskBuildWorkflowService {
             if (updated <= 0) {
                 throw new IllegalStateException("Failed to update task definition, taskId=" + taskId);
             }
+            writeTaskUpdateLog(existingTask, taskPatch);
+        }
+    }
+
+    private void writeTaskUpdateLog(TaskDefinitionDO existingTask, TaskBuildWorkflowUpdateBO.TaskNodeBO taskPatch) {
+        AuditLogDO log = new AuditLogDO();
+        log.setActionType("TASK_UPDATE");
+        log.setTargetType("TASK_INSTANCE");
+        log.setTargetId(existingTask.getTaskId());
+        log.setOperatorId(resolveOperatorId());
+        log.setResultStatus(AuditLogResultStatus.SUCCESS.getCode());
+        log.setDetail(buildTaskUpdateDetail(existingTask, taskPatch));
+        auditLogService.writeLog(log);
+    }
+
+    private String buildTaskUpdateDetail(TaskDefinitionDO existingTask, TaskBuildWorkflowUpdateBO.TaskNodeBO taskPatch) {
+        List<String> changedFields = new ArrayList<>();
+        if (taskPatch.getTimeoutSeconds() != null) {
+            changedFields.add("timeoutSeconds");
+        }
+        if (taskPatch.getRetryTimes() != null) {
+            changedFields.add("retryTimes");
+        }
+        if (taskPatch.getTaskContent() != null) {
+            changedFields.add("taskContent");
+        }
+        String taskLabel = StringUtils.hasText(existingTask.getTaskName())
+                ? existingTask.getTaskName()
+                : String.valueOf(existingTask.getTaskId());
+        if (changedFields.isEmpty()) {
+            return "update task " + taskLabel;
+        }
+        return "update task " + taskLabel + " fields: " + String.join(", ", changedFields);
+    }
+
+    private Long resolveOperatorId() {
+        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+        if (authentication == null || !authentication.isAuthenticated()) {
+            return null;
+        }
+
+        Long operatorId = resolveFromPrincipal(authentication.getPrincipal());
+        if (operatorId != null) {
+            return operatorId;
+        }
+        return parseLongSilently(authentication.getName());
+    }
+
+    private Long resolveFromPrincipal(Object principal) {
+        if (principal == null) {
+            return null;
+        }
+        if (principal instanceof Number number) {
+            return number.longValue();
+        }
+        if (principal instanceof String principalText) {
+            return parseLongSilently(principalText);
+        }
+
+        Method getUserId = ReflectionUtils.findMethod(principal.getClass(), "getUserId");
+        if (getUserId != null) {
+            Object value = ReflectionUtils.invokeMethod(getUserId, principal);
+            Long parsed = parseLongSilently(String.valueOf(value));
+            if (parsed != null) {
+                return parsed;
+            }
+        }
+
+        Method getId = ReflectionUtils.findMethod(principal.getClass(), "getId");
+        if (getId != null) {
+            Object value = ReflectionUtils.invokeMethod(getId, principal);
+            return parseLongSilently(String.valueOf(value));
+        }
+        return null;
+    }
+
+    private Long parseLongSilently(String value) {
+        if (!StringUtils.hasText(value)) {
+            return null;
+        }
+        try {
+            return Long.parseLong(value.trim());
+        } catch (NumberFormatException ex) {
+            return null;
         }
     }
 

+ 20 - 17
backend/src/main/resources/db/test-audit-log-data.sql

@@ -2,22 +2,25 @@ USE wenshu_platform;
 
 INSERT INTO audit_log (action_time, action_type, target_type, target_id, operator_id, result_status, detail) VALUES
 ('2026-03-17 09:10:12', 'WORKFLOW_SUBMIT', 'WORKFLOW', 10101, 1001, 'SUCCESS', 'Submit workflow wf_sales_daily'),
-('2026-03-17 09:15:44', 'WORKFLOW_DISPATCH', 'WORKFLOW_INSTANCE', 88001, 1001, 'SUCCESS', 'Dispatch finished and running'),
-('2026-03-17 09:16:09', 'TASK_RETRY', 'TASK_INSTANCE', 99001, 1001, 'FAILED', 'task_extract retry #1 failed timeout'),
-('2026-03-17 09:18:20', 'TASK_RETRY', 'TASK_INSTANCE', 99001, 1001, 'SUCCESS', 'task_extract retry #2 succeeded'),
-('2026-03-17 10:02:05', 'CONFIG_UPDATE', 'CLUSTER', 3001, 2001, 'SUCCESS', 'Update Spark executor memory to 8G'),
-('2026-03-17 10:04:27', 'CONFIG_ROLLBACK', 'CLUSTER', 3001, 2001, 'SUCCESS', 'Rollback to config version v12'),
-('2026-03-17 10:12:31', 'INSTANCE_START', 'CLUSTER', 3001, 2001, 'SUCCESS', 'Instance started and healthy'),
-('2026-03-17 10:16:45', 'INSTANCE_STOP', 'CLUSTER', 3002, 2001, 'SUCCESS', 'Stop instance in maintenance window'),
-('2026-03-17 10:18:01', 'INSTANCE_RESTART', 'CLUSTER', 3003, 2001, 'FAILED', 'Restart failed due to node anomaly'),
-('2026-03-17 10:29:19', 'INSTANCE_RESTART', 'CLUSTER', 3003, 2001, 'SUCCESS', 'Restart succeeded after retry'),
-('2026-03-17 11:03:42', 'SCALE_OUT', 'CLUSTER', 3004, 2002, 'SUCCESS', 'Scale out from 8 nodes to 12'),
-('2026-03-17 11:11:26', 'SCALE_IN', 'CLUSTER', 3004, 2002, 'SUCCESS', 'Scale in from 12 nodes to 10'),
-('2026-03-17 11:24:40', 'SCALE_OUT', 'CLUSTER', 3005, 2002, 'FAILED', 'Scale out failed due to low stock'),
-('2026-03-17 11:38:08', 'SCALE_OUT', 'CLUSTER', 3005, 2002, 'SUCCESS', 'Scale out succeeded after stock refill'),
-('2026-03-17 13:06:11', 'STOCK_CREATE', 'MACHINE', 50001, 2003, 'SUCCESS', 'Create machine 10.23.12.7'),
-('2026-03-17 13:18:39', 'STOCK_UPDATE', 'MACHINE', 50001, 2003, 'SUCCESS', 'Update machine rack and notes'),
-('2026-03-17 13:31:57', 'STOCK_DELETE', 'MACHINE', 50007, 2003, 'FAILED', 'Delete blocked because machine allocated'),
+('2026-03-17 09:15:44', 'WORKFLOW_UPDATE', 'WORKFLOW', 10101, 1001, 'SUCCESS', 'Update workflow definition wf_sales_daily'),
+('2026-03-17 09:16:09', 'TASK_UPDATE', 'TASK_INSTANCE', 99001, 1001, 'FAILED', 'task_extract update failed validation'),
+('2026-03-17 09:18:20', 'WORKFLOW_TERMINATE', 'WORKFLOW_INSTANCE', 88001, 1001, 'SUCCESS', 'Terminate workflow instance after rollback'),
+('2026-03-17 09:20:31', 'WORKFLOW_DELETE', 'WORKFLOW', 10102, 1001, 'SUCCESS', 'Delete obsolete workflow wf_tmp_hourly'),
+('2026-03-17 10:02:05', 'CLUSTER_CONFIG_COMMIT', 'CLUSTER_CONFIG', 3001, 2001, 'SUCCESS', 'Commit Spark executor memory to 8G'),
+('2026-03-17 10:04:27', 'CLUSTER_CONFIG_ROLLBACK', 'CLUSTER_CONFIG', 3001, 2001, 'SUCCESS', 'Rollback to config version v12'),
+('2026-03-17 10:12:31', 'CLUSTER_START', 'CLUSTER', 3001, 2001, 'SUCCESS', 'Cluster started and healthy'),
+('2026-03-17 10:16:45', 'CLUSTER_STOP', 'CLUSTER', 3002, 2001, 'SUCCESS', 'Stop cluster in maintenance window'),
+('2026-03-17 10:18:01', 'CLUSTER_RESTART', 'CLUSTER', 3003, 2001, 'FAILED', 'Restart failed due to node anomaly'),
+('2026-03-17 10:29:19', 'CLUSTER_RESTART', 'CLUSTER', 3003, 2001, 'SUCCESS', 'Restart succeeded after retry'),
+('2026-03-17 11:03:42', 'CLUSTER_SCALE_OUT', 'CLUSTER', 3004, 2002, 'SUCCESS', 'Scale out from 8 nodes to 12'),
+('2026-03-17 11:11:26', 'CLUSTER_SCALE_IN', 'CLUSTER', 3004, 2002, 'SUCCESS', 'Scale in from 12 nodes to 10'),
+('2026-03-17 11:24:40', 'CLUSTER_SCALE_OUT', 'CLUSTER', 3005, 2002, 'FAILED', 'Scale out failed due to low stock'),
+('2026-03-17 11:38:08', 'CLUSTER_SCALE_OUT', 'CLUSTER', 3005, 2002, 'SUCCESS', 'Scale out succeeded after stock refill'),
+('2026-03-17 13:06:11', 'STOCK_CREATE', 'STOCK_MACHINE', 50001, 2003, 'SUCCESS', 'Create machine 10.23.12.7'),
+('2026-03-17 13:18:39', 'STOCK_UPDATE', 'STOCK_MACHINE', 50001, 2003, 'SUCCESS', 'Update machine rack and notes'),
+('2026-03-17 13:24:11', 'STOCK_SSH_EXECUTE', 'STOCK_MACHINE', 50001, 2003, 'SUCCESS', 'Execute service restart command'),
+('2026-03-17 13:31:57', 'STOCK_DELETE', 'STOCK_MACHINE', 50007, 2003, 'FAILED', 'Delete blocked because machine allocated'),
 ('2026-03-17 13:45:03', 'ALERT_RULE_CREATE', 'ALERT_RULE', 7001, 2004, 'SUCCESS', 'Create CPU utilization alert rule'),
-('2026-03-17 13:49:22', 'ALERT_RULE_DISABLE', 'ALERT_RULE', 7002, 2004, 'SUCCESS', 'Disable low-priority night alert'),
+('2026-03-17 13:47:40', 'ALERT_RULE_UPDATE', 'ALERT_RULE', 7001, 2004, 'SUCCESS', 'Update CPU utilization threshold'),
+('2026-03-17 13:49:22', 'ALERT_RULE_TOGGLE', 'ALERT_RULE', 7002, 2004, 'SUCCESS', 'Disable low-priority night alert'),
 ('2026-03-17 14:12:49', 'ALERT_EVENT_HANDLE', 'ALERT_EVENT', 90001, 2004, 'SUCCESS', 'Alert event acknowledged and assigned');

+ 48 - 0
backend/src/test/java/com/wenshu/platform/config/audit/AuditAspectTest.java

@@ -0,0 +1,48 @@
+package com.wenshu.platform.config.audit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.lang.reflect.Method;
+
+import com.wenshu.platform.controller.ops.AlertController;
+import com.wenshu.platform.controller.taskbuild.TaskBuildWorkflowController;
+import com.wenshu.platform.model.req.AlertRuleCreateReq;
+import org.junit.jupiter.api.Test;
+
+class AuditAspectTest {
+
+    @Test
+    void updateRule_shouldBeAuditable() throws Exception {
+        assertAuditable(
+                AlertController.class.getMethod("updateRule", Long.class, AlertRuleCreateReq.class),
+                "ALERT_RULE_UPDATE",
+                "ALERT_RULE",
+                "#arg0",
+                "'update alert rule'");
+    }
+
+    @Test
+    void deleteWorkflow_shouldBeAuditable() throws Exception {
+        assertAuditable(
+                TaskBuildWorkflowController.class.getMethod("deleteWorkflow", Long.class),
+                "WORKFLOW_DELETE",
+                "WORKFLOW",
+                "#arg0",
+                "'delete workflow definition'");
+    }
+
+    private void assertAuditable(
+            Method method,
+            String expectedActionType,
+            String expectedTargetType,
+            String expectedTargetIdExpr,
+            String expectedDetailExpr) {
+        Auditable auditable = method.getAnnotation(Auditable.class);
+        assertNotNull(auditable);
+        assertEquals(expectedActionType, auditable.actionType());
+        assertEquals(expectedTargetType, auditable.targetType());
+        assertEquals(expectedTargetIdExpr, auditable.targetIdExpr());
+        assertEquals(expectedDetailExpr, auditable.detailExpr());
+    }
+}

+ 77 - 0
backend/src/test/java/com/wenshu/platform/service/resource/StockJoiningStatusReconcilerTest.java

@@ -0,0 +1,77 @@
+package com.wenshu.platform.service.resource;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+
+import com.wenshu.platform.config.K8sOrchestratorProperties;
+import com.wenshu.platform.dao.StockDAO;
+import com.wenshu.platform.model.dataobject.StockInfoDO;
+import com.wenshu.platform.model.enums.StockMachineStatus;
+import com.wenshu.platform.model.query.StockQuery;
+import com.wenshu.platform.service.resource.k8s.K8sOrchestratorClient;
+import com.wenshu.platform.service.resource.k8s.NodeInventorySnapshot;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class StockJoiningStatusReconcilerTest {
+
+    @Mock
+    private StockDAO stockDAO;
+    @Mock
+    private K8sOrchestratorClient k8sOrchestratorClient;
+    @Mock
+    private K8sOrchestratorProperties properties;
+
+    @InjectMocks
+    private StockJoiningStatusReconciler reconciler;
+
+    @Test
+    void reconcileJoiningMachines_MoveToIdleWhenNodeReady() {
+        StockInfoDO machine = new StockInfoDO();
+        machine.setMachineId(100L);
+        machine.setStatus(StockMachineStatus.JOINING.getCode());
+
+        when(properties.getReconcileBatchSize()).thenReturn(100);
+        when(stockDAO.find(any(StockQuery.class))).thenReturn(List.of(machine));
+        when(k8sOrchestratorClient.listNodes()).thenReturn(List.of(
+                new NodeInventorySnapshot("node-100", "IDLE", "k8s-node-100")));
+        when(stockDAO.updateStatusByIdAndStatus(100L, StockMachineStatus.JOINING.getCode(), StockMachineStatus.IDLE.getCode()))
+                .thenReturn(1);
+
+        reconciler.reconcileJoiningMachines();
+
+        verify(stockDAO).updateStatusByIdAndStatus(
+                100L,
+                StockMachineStatus.JOINING.getCode(),
+                StockMachineStatus.IDLE.getCode());
+    }
+
+    @Test
+    void reconcileJoiningMachines_KeepJoiningWhenNodeNotReady() {
+        StockInfoDO machine = new StockInfoDO();
+        machine.setMachineId(101L);
+        machine.setStatus(StockMachineStatus.JOINING.getCode());
+
+        when(properties.getReconcileBatchSize()).thenReturn(100);
+        when(stockDAO.find(any(StockQuery.class))).thenReturn(List.of(machine));
+        when(k8sOrchestratorClient.listNodes()).thenReturn(List.of(
+                new NodeInventorySnapshot("node-101", "PROVISIONING", null)));
+
+        reconciler.reconcileJoiningMachines();
+
+        verify(stockDAO, never()).updateStatusByIdAndStatus(
+                anyLong(),
+                anyString(),
+                anyString());
+    }
+}

+ 134 - 6
backend/src/test/java/com/wenshu/platform/service/resource/StockServiceTest.java

@@ -35,6 +35,8 @@ class StockServiceTest {
     @Mock
     private SshCredentialCryptoService sshCredentialCryptoService;
     @Mock
+    private SshConnectivityVerifier sshConnectivityVerifier;
+    @Mock
     private K8sOrchestratorClient k8sOrchestratorClient;
 
     @InjectMocks
@@ -82,6 +84,7 @@ class StockServiceTest {
 
         when(sshCredentialCryptoService.encrypt("password123")).thenReturn("cipherText");
         when(stockDAO.findByIpAddress("192.168.1.100")).thenReturn(null);
+        doNothing().when(sshConnectivityVerifier).verify("192.168.1.100", 22, "admin", "password123");
         doAnswer(invocation -> {
             StockInfoDO saved = invocation.getArgument(0);
             saved.setMachineId(1001L);
@@ -94,13 +97,16 @@ class StockServiceTest {
 
         assertNotNull(resp);
         assertEquals("192.168.1.100", resp.getIpAddress());
+        assertEquals(StockMachineStatus.JOINING.getCode(), resp.getStatus());
         verify(stockDAO).save(argThat(savedMachine -> 
             "cipherText".equals(savedMachine.getSshPasswordCipher()) &&
-            savedMachine.getSshPassword() == null
+            savedMachine.getSshPassword() == null &&
+            StockMachineStatus.JOINING.getCode().equals(savedMachine.getStatus())
         ));
+        verify(sshConnectivityVerifier).verify("192.168.1.100", 22, "admin", "password123");
         verify(k8sOrchestratorClient).addNode(eq("node-1001"), eq(4), eq(16), eq(500),
                 eq("192.168.1.100"), eq("admin"), eq("password123"), eq(22));
-        verify(k8sOrchestratorClient).waitForOperationSucceeded("op-node-add-1");
+        verify(k8sOrchestratorClient, never()).waitForOperationSucceeded(anyString());
     }
 
     @Test
@@ -121,7 +127,7 @@ class StockServiceTest {
     }
 
     @Test
-    void addMachine_OnboardingFailed() {
+    void addMachine_AddNodeFailed() {
         StockInfoDO machine = new StockInfoDO();
         machine.setIpAddress("192.168.1.101");
         machine.setCpuCores(4);
@@ -133,17 +139,139 @@ class StockServiceTest {
 
         when(sshCredentialCryptoService.encrypt("password123")).thenReturn("cipherText");
         when(stockDAO.findByIpAddress("192.168.1.101")).thenReturn(null);
+        doNothing().when(sshConnectivityVerifier).verify("192.168.1.101", 22, "admin", "password123");
         doAnswer(invocation -> {
             StockInfoDO saved = invocation.getArgument(0);
             saved.setMachineId(1002L);
             return null;
         }).when(stockDAO).save(any(StockInfoDO.class));
         when(k8sOrchestratorClient.addNode(any(), any(), any(), any(), any(), any(), any(), any()))
-                .thenReturn("op-node-add-2");
-        doThrow(new IllegalStateException("node onboarding failed"))
-                .when(k8sOrchestratorClient).waitForOperationSucceeded("op-node-add-2");
+                .thenThrow(new IllegalStateException("node onboarding failed"));
 
         assertThrows(IllegalStateException.class, () -> stockService.addMachine(machine));
+        verify(k8sOrchestratorClient, never()).waitForOperationSucceeded(anyString());
+    }
+
+    @Test
+    void addMachine_SshConnectionFailed() {
+        StockInfoDO machine = new StockInfoDO();
+        machine.setIpAddress("192.168.1.102");
+        machine.setCpuCores(4);
+        machine.setCpuModel("Intel Xeon");
+        machine.setRamGb(16);
+        machine.setSsdGb(500);
+        machine.setSshUsername("admin");
+        machine.setSshPassword("password123");
+
+        when(sshCredentialCryptoService.encrypt("password123")).thenReturn("cipherText");
+        when(stockDAO.findByIpAddress("192.168.1.102")).thenReturn(null);
+        doThrow(new IllegalStateException("SSH连接失败: timeout"))
+                .when(sshConnectivityVerifier)
+                .verify("192.168.1.102", 22, "admin", "password123");
+
+        IllegalStateException ex = assertThrows(IllegalStateException.class, () -> stockService.addMachine(machine));
+
+        assertTrue(ex.getMessage().contains("SSH连接失败"));
+        verify(sshCredentialCryptoService).encrypt("password123");
+        verify(stockDAO, never()).save(any(StockInfoDO.class));
+        verify(k8sOrchestratorClient, never()).addNode(any(), any(), any(), any(), any(), any(), any(), any());
+    }
+
+    @Test
+    void updateMachine_DescriptionOnly_WhenJoining() {
+        Long machineId = 1L;
+        StockInfoDO current = new StockInfoDO();
+        current.setMachineId(machineId);
+        current.setStatus(StockMachineStatus.JOINING.getCode());
+        current.setIpAddress("192.168.1.100");
+        current.setCpuCores(4);
+        current.setCpuModel("Intel Xeon");
+        current.setRamGb(16);
+        current.setSsdGb(500);
+        current.setSshUsername("admin");
+        current.setSshPasswordCipher("cipherText");
+
+        StockInfoDO update = new StockInfoDO();
+        update.setDescription("Joining description");
+
+        StockInfoDO updated = new StockInfoDO();
+        updated.setMachineId(machineId);
+        updated.setStatus(StockMachineStatus.JOINING.getCode());
+        updated.setDescription("Joining description");
+        updated.setIpAddress("192.168.1.100");
+
+        when(stockDAO.findById(machineId))
+            .thenReturn(current)
+            .thenReturn(updated);
+
+        when(stockDAO.updateWithOptimisticLock(any(), eq(current))).thenReturn(1);
+
+        StockMachineResp resp = stockService.updateMachine(machineId, update);
+
+        assertNotNull(resp);
+        assertEquals("Joining description", resp.getDescription());
+        verify(stockDAO).updateWithOptimisticLock(any(), eq(current));
+    }
+
+    @Test
+    void updateMachine_ForbiddenField_WhenJoining() {
+        Long machineId = 1L;
+        StockInfoDO current = new StockInfoDO();
+        current.setMachineId(machineId);
+        current.setStatus(StockMachineStatus.JOINING.getCode());
+
+        StockInfoDO update = new StockInfoDO();
+        update.setIpAddress("192.168.1.101");
+
+        when(stockDAO.findById(machineId)).thenReturn(current);
+
+        assertThrows(IllegalArgumentException.class, () -> stockService.updateMachine(machineId, update));
+    }
+
+    @Test
+    void deleteMachine_JoiningMachine() {
+        Long machineId = 1L;
+        StockInfoDO current = new StockInfoDO();
+        current.setMachineId(machineId);
+        current.setStatus(StockMachineStatus.JOINING.getCode());
+
+        when(stockDAO.findById(machineId)).thenReturn(current);
+
+        assertThrows(IllegalArgumentException.class, () -> stockService.deleteMachine(machineId));
+    }
+
+    @Test
+    void addMachine_ReturnsJoiningStatus() {
+        StockInfoDO machine = new StockInfoDO();
+        machine.setIpAddress("192.168.1.111");
+        machine.setCpuCores(8);
+        machine.setCpuModel("Intel Xeon");
+        machine.setRamGb(16);
+        machine.setSsdGb(1024);
+        machine.setSshUsername("root");
+        machine.setSshPassword("password123");
+
+        when(sshCredentialCryptoService.encrypt("password123")).thenReturn("cipherText");
+        when(stockDAO.findByIpAddress("192.168.1.111")).thenReturn(null);
+        doNothing().when(sshConnectivityVerifier).verify("192.168.1.111", 22, "root", "password123");
+        doAnswer(invocation -> {
+            StockInfoDO saved = invocation.getArgument(0);
+            saved.setMachineId(1003L);
+            return null;
+        }).when(stockDAO).save(any(StockInfoDO.class));
+        when(k8sOrchestratorClient.addNode(any(), any(), any(), any(), any(), any(), any(), any()))
+                .thenReturn("op-node-add-timeout");
+
+        StockMachineResp resp = stockService.addMachine(machine);
+
+        assertNotNull(resp);
+        assertEquals(1003L, resp.getMachineId());
+        assertEquals("192.168.1.111", resp.getIpAddress());
+        assertEquals(StockMachineStatus.JOINING.getCode(), resp.getStatus());
+        verify(k8sOrchestratorClient, never()).deleteNode(anyString());
+        verify(stockDAO).save(any(StockInfoDO.class));
+        verify(sshConnectivityVerifier).verify("192.168.1.111", 22, "root", "password123");
+        verify(k8sOrchestratorClient, never()).waitForOperationSucceeded(anyString());
     }
 
     @Test

+ 44 - 0
backend/src/test/java/com/wenshu/platform/service/taskbuild/TaskBuildWorkflowServiceTest.java

@@ -2,6 +2,7 @@ package com.wenshu.platform.service.taskbuild;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -11,9 +12,12 @@ import java.util.List;
 import com.wenshu.platform.dao.TaskDefinitionDAO;
 import com.wenshu.platform.dao.WorkflowDefinitionDAO;
 import com.wenshu.platform.dao.WorkflowInstanceDAO;
+import com.wenshu.platform.model.dataobject.AuditLogDO;
+import com.wenshu.platform.model.dataobject.TaskDefinitionDO;
 import com.wenshu.platform.model.bo.TaskBuildWorkflowUpdateBO;
 import com.wenshu.platform.model.dataobject.WorkflowDefinitionDO;
 import com.wenshu.platform.model.resp.TaskBuildWorkflowDetailResp;
+import com.wenshu.platform.service.ops.AuditLogService;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.InjectMocks;
@@ -31,6 +35,8 @@ class TaskBuildWorkflowServiceTest {
     private WorkflowInstanceDAO workflowInstanceDAO;
     @Mock
     private CodeValidatorFactory codeValidatorFactory;
+    @Mock
+    private AuditLogService auditLogService;
 
     @InjectMocks
     private TaskBuildWorkflowService taskBuildWorkflowService;
@@ -80,4 +86,42 @@ class TaskBuildWorkflowServiceTest {
 
         verify(workflowInstanceDAO, never()).updateWorkflowNameByWorkflowId(any(), any());
     }
+
+    @Test
+    void updateWorkflow_ShouldWriteTaskUpdateAuditLog_WhenTaskPatchPresent() {
+        Long workflowId = 1003L;
+
+        WorkflowDefinitionDO existing = new WorkflowDefinitionDO();
+        existing.setWorkflowId(workflowId);
+        existing.setWorkflowName("workflow-a");
+
+        TaskDefinitionDO task = new TaskDefinitionDO();
+        task.setTaskId(2001L);
+        task.setTaskName("task-a");
+        task.setTaskType("SPARK");
+
+        when(workflowDefinitionDAO.findById(workflowId)).thenReturn(existing, existing);
+        when(taskDefinitionDAO.findByWorkflowId(workflowId)).thenReturn(List.of(task), List.of(task));
+        when(taskDefinitionDAO.update(any(TaskDefinitionDO.class))).thenReturn(1);
+
+        TaskBuildWorkflowUpdateBO.TaskNodeBO taskPatch = new TaskBuildWorkflowUpdateBO.TaskNodeBO();
+        taskPatch.setTaskId(2001L);
+        taskPatch.setTimeoutSeconds(1200);
+        taskPatch.setRetryTimes(3);
+        taskPatch.setTaskContent("print('updated')");
+
+        TaskBuildWorkflowUpdateBO patch = new TaskBuildWorkflowUpdateBO();
+        patch.setTasks(List.of(taskPatch));
+
+        taskBuildWorkflowService.updateWorkflow(workflowId, patch);
+
+        verify(auditLogService).writeLog(argThat((AuditLogDO log) ->
+                "TASK_UPDATE".equals(log.getActionType())
+                        && "TASK_INSTANCE".equals(log.getTargetType())
+                        && Long.valueOf(2001L).equals(log.getTargetId())
+                        && log.getDetail() != null
+                        && log.getDetail().contains("timeoutSeconds")
+                        && log.getDetail().contains("retryTimes")
+                        && log.getDetail().contains("taskContent")));
+    }
 }

+ 185 - 0
docs/stock-machine-addition.md

@@ -0,0 +1,185 @@
+# 库存管理新增机器实现说明
+
+本文总结当前仓库中“库存管理 -> 新增机器”的实现方式。这里的“新增机器”不是只往数据库插一条记录,而是“后端落库存 + 调用 orchestrator 下发纳管 + 后端定时回写库存状态”的完整流程。
+
+## 总体链路
+
+1. 前端在库存管理页点击“新增机器”。
+2. 用户填写 IP、SSH 账号密码、CPU/内存/SSD 等信息,必要时先点击“自动获取以下配置”。
+3. 前端调用后端 `POST /api/resource/stocks`。
+4. 后端完成权限校验、字段校验、SSH 密码加密、IP 去重后,先写入 `stock_info`,并将状态置为 `JOINING`。
+5. 后端再调用 k8s orchestrator 的 `addNode` 接口,但不等待纳管完成,接口立即返回。
+6. orchestrator 创建 `ClusterOperation`,随后创建 `NodeInventory`。
+7. `NodeInventory` controller 通过 SSH 登录目标机器,安装 kubeadm 依赖并执行 `kubeadm join`。
+8. 后端定时任务 `StockJoiningStatusReconciler` 轮询 orchestrator 的节点列表,发现对应 `NodeInventory` 进入 `IDLE` 后,把库存状态回写为 `IDLE`。
+
+## 前端实现
+
+入口页面是 [StockPage.vue](/root/wenshu-platform/frontend/src/views/resource-management/StockPage.vue)。
+
+### 1. 新增入口
+
+页面右上角提供“+ 新增机器”按钮,点击后打开创建弹窗。创建弹窗和编辑弹窗共用同一套表单结构。
+
+### 2. 手动填写与自动探测
+
+新增弹窗要求填写以下信息:
+
+- `ipAddress`
+- `sshUsername`
+- `sshPassword`
+- `cpuModel`
+- `cpuCores`
+- `ramGb`
+- `ssdGb`
+- `description`
+
+新增时还提供一个“自动获取以下配置”按钮,会调用 [frontend/src/api/stock.js](/root/wenshu-platform/frontend/src/api/stock.js) 里的 `probeStock()`,对应后端 `POST /api/resource/stocks/probe`。后端会通过 SSH 在目标机器上执行探测脚本,从机器自身读取 CPU、内存和磁盘信息,再回填到表单中。
+
+### 3. 表单提交
+
+提交时前端调用 `createStock(payload)`,即:
+
+- `POST /api/resource/stocks`
+
+前端做了基本校验:
+
+- IP 格式校验
+- SSH 用户名必填
+- 新增时 SSH 密码必填
+- CPU / 内存 / SSD 必填
+- 备注长度限制
+
+## 后端实现
+
+新增机器的入口在 [StockController.java](/root/wenshu-platform/backend/src/main/java/com/wenshu/platform/controller/resource/StockController.java):
+
+- `POST /api/resource/stocks` -> `stockService.addMachine(...)`
+
+真正的业务逻辑在 [StockService.java](/root/wenshu-platform/backend/src/main/java/com/wenshu/platform/service/resource/StockService.java)。
+
+### 1. 权限控制
+
+`addMachine()` 会先调用 `requireOpsRole()`,因此只有以下角色可以新增机器:
+
+- `ADMIN`
+- `DEVELOPER`
+- `OPS`
+
+### 2. 创建前校验
+
+后端会执行以下处理:
+
+- 规范化输入字段,去掉前后空格。
+- 新机器状态固定初始化为 `JOINING`,对应前端展示的“加入中”。
+- 校验 IP 地址合法性,支持 IPv4 和 IPv6。
+- 校验 CPU、内存、SSD、CPU 型号、SSH 用户名、SSH 密码等字段长度和取值范围。
+- 校验 IP 唯一性,避免重复纳管同一 IP。
+- SSH 密码只在创建时接收明文,随后会被加密后存库。
+
+对应的请求 DTO 是 [StockMachineCreateReq.java](/root/wenshu-platform/backend/src/main/java/com/wenshu/platform/model/req/StockMachineCreateReq.java),库存实体是 [StockInfoDO.java](/root/wenshu-platform/backend/src/main/java/com/wenshu/platform/model/dataobject/StockInfoDO.java)。
+
+### 3. 先落库,再异步纳管
+
+`addMachine()` 的核心顺序是:
+
+1. 将 SSH 密码加密,写入 `sshPasswordCipher`。
+2. 清空明文密码字段。
+3. 将机器状态初始化为 `JOINING`。
+4. `stockDAO.save(normalizedMachine)` 插入 `stock_info`。
+5. 通过 `OrchestratorIdMapper.toNodeId(machineId)` 生成固定节点名,格式为 `node-{machineId}`。
+6. 调用 `k8sOrchestratorClient.addNode(...)` 发起纳管。
+7. 由定时 reconciler 调用 orchestrator 的 `GET /v1/nodes`,按 `node-{machineId}` 找到对应 `NodeInventory`。
+8. 看到该 `NodeInventory` 的 `status.phase` 变为 `IDLE` 后,再把库存状态改回 `IDLE`。
+
+这里有一个重要设计点:**库存记录先落库,纳管结果后回写**。如果 orchestrator 短期内还没完成,库存列表会持续显示“加入中”,直到后台 reconciler 看到节点真正就绪。
+
+### 4. 响应数据
+
+创建成功后,后端会返回新增后的机器信息,前端把它插入表格首行,并显示“纳管请求已提交”提示。此时状态一般是“加入中”,不会立即显示为 `IDLE`。
+
+## 数据库落点
+
+库存数据表是 [backend/src/main/resources/db/schema.sql](/root/wenshu-platform/backend/src/main/resources/db/schema.sql) 里的 `stock_info`。
+
+关键字段:
+
+- `machine_id`
+- `ip_address`
+- `cpu_cores`
+- `cpu_model`
+- `ram_gb`
+- `ssd_gb`
+- `ssh_username`
+- `ssh_password_cipher`
+- `status`
+- `description`
+
+对应 MyBatis SQL 在 [StockDAO.xml](/root/wenshu-platform/backend/src/main/resources/mapper/resource/StockDAO.xml):
+
+- `save` 负责插入机器记录
+- `findByIpAddress` 用于 IP 去重
+- `updateStatusByIdAndStatus` 用于状态切换
+- `deleteByIdAndStatus` 用于删除前的状态约束
+
+## Orchestrator 侧实现
+
+后端通过 [HttpK8sOrchestratorClient.java](/root/wenshu-platform/backend/src/main/java/com/wenshu/platform/service/resource/k8s/HttpK8sOrchestratorClient.java) 调用编排器。
+
+`addNode()` 会向 orchestrator 的 `POST /v1/nodes` 发送:
+
+- `nodeId`
+- `cpu`
+- `ram`
+- `ssd`
+- `ipAddress`
+- `sshUser`
+- `sshPassword`
+- `sshPort`
+
+### 1. 创建操作
+
+orchestrator 侧 [OrchestratorService](/root/wenshu-platform/k8s-orchestrator/internal/service/orchestrator.go) 会把这次请求包装成一个 `ClusterOperation`,操作类型是 `AddNode`。
+
+### 2. 创建 NodeInventory
+
+`ClusterOperationReconciler` 在处理 `AddNode` 时,会创建一个 `NodeInventory` 资源,关键字段来自请求:
+
+- `spec.address` -> 机器 IP
+- `spec.capacity` -> CPU / RAM / SSD
+- `spec.sshSecretRef` -> SSH Secret
+- `spec.bootstrap.joinCommandOverride` -> join 命令覆盖参数
+
+相关代码在 [clusteroperation_controller.go](/root/wenshu-platform/k8s-orchestrator/internal/controller/clusteroperation_controller.go) 和 [nodeinventory_types.go](/root/wenshu-platform/k8s-orchestrator/api/v1alpha1/nodeinventory_types.go)。
+
+### 3. NodeInventory 控制器的状态机
+
+`NodeInventoryReconciler` 的状态流转在 [nodeinventory_controller.go](/root/wenshu-platform/k8s-orchestrator/internal/controller/nodeinventory_controller.go):
+
+- `NEW` -> 初始化状态
+- `PROVISIONING` -> 开始 SSH 纳管
+- `IDLE` -> 节点已成功加入,可分配
+- `FAILED` -> 纳管失败,控制器会重试
+
+`ensureNodeJoined()` 会先尝试通过固定节点名或 IP 找到已存在的节点;如果不存在,再读取 SSH Secret,使用 onboarder 执行远程安装和 `kubeadm join`。
+
+### 4. onboarder 做了什么
+
+`SSHOnboarder` 在 [onboarder.go](/root/wenshu-platform/k8s-orchestrator/internal/onboarding/onboarder.go) 里完成三步:
+
+1. 在目标机器上安装 kubeadm、kubelet、containerd 等依赖。
+2. 清理旧的 kubelet/kubeadm 残留状态。
+3. 执行 join 命令,并附加 `--node-name`,保证 Kubernetes 节点名和 `NodeInventory` 名称一致。
+
+## 新增机器时的关键约束
+
+- 机器初始状态固定为 `JOINING`,不会直接写成 `WORKING`。
+- `JOINING` 和 `WORKING` 都属于受管生命周期态,库存新增接口不会允许手工写入。
+- `JOINING` 和 `WORKING` 都属于受管生命周期态,编辑时只允许修改备注。
+- `stock_info.ssh_password_cipher` 只保存加密后的密码,不保存明文。
+- `ip_address` 必须唯一。
+- 如果纳管还没完成,库存记录会保留在 `JOINING` 状态,后台 `StockJoiningStatusReconciler` 会持续轮询直到它变成 `IDLE`。
+
+## 一句话总结
+
+当前的“新增机器”本质上是一个两阶段流程:先把机器作为库存资产写入后端数据库,再交给 k8s orchestrator 通过 `NodeInventory` 完成真实纳管和 Kubernetes 入网。

+ 24 - 0
frontend/nginx.conf

@@ -3,10 +3,34 @@ server {
     server_name _;
     client_max_body_size 500M; 
     resolver 127.0.0.11 valid=10s ipv6=off;
+    sendfile on;
+    tcp_nopush on;
+    gzip on;
+    gzip_vary on;
+    gzip_min_length 1024;
+    gzip_comp_level 5;
+    gzip_types
+        text/plain
+        text/css
+        text/xml
+        application/json
+        application/javascript
+        application/x-javascript
+        application/xml
+        application/xml+rss
+        image/svg+xml;
     
     root /usr/share/nginx/html;
     index index.html;
 
+    location = /index.html {
+        add_header Cache-Control "no-cache";
+    }
+
+    location /assets/ {
+        add_header Cache-Control "public, max-age=31536000, immutable";
+    }
+
     # 前端 SPA 路由(刷新不 404)
     location / {
         try_files $uri $uri/ /index.html;

BIN
frontend/public/logo1.png


+ 7 - 0
frontend/src/App.vue

@@ -15,6 +15,13 @@ body,
   min-height: 100%;
 }
 
+.page-root:not(.member-theme),
+.page-layout:not(.member-theme) {
+  background:
+    radial-gradient(circle at 92% 92%, rgba(48, 186, 255, 0.19), rgba(48, 186, 255, 0) 40%),
+    var(--page-bg, #f5f7fa) !important;
+}
+
 /* ── Global Mobile Responsive ── */
 @media (max-width: 768px) {
   /* Page root layout reset */

+ 1 - 1
frontend/src/api/stock.js

@@ -5,7 +5,7 @@ export function queryStocks(params) {
 }
 
 export function createStock(data) {
-  return http.post('/api/resource/stocks', data);
+  return http.post('/api/resource/stocks', data, { timeout: 330000 });
 }
 
 export function updateStock(machineId, data) {

+ 474 - 0
frontend/src/components/UiCascadeSelect.vue

@@ -0,0 +1,474 @@
+<template>
+  <div ref="rootRef" class="ui-cascade-select" :class="{ 'is-open': open, 'is-disabled': disabled }">
+    <button
+      ref="triggerRef"
+      type="button"
+      class="ui-cascade-select-trigger"
+      :disabled="disabled"
+      @click="toggleOpen"
+    >
+      <span class="ui-cascade-select-label" :class="{ placeholder: !selectedLabel }">
+        {{ selectedLabel || placeholder }}
+      </span>
+      <span class="ui-cascade-select-arrow" aria-hidden="true"></span>
+    </button>
+
+    <Teleport to="body">
+      <transition name="ui-cascade-fade">
+        <div
+          v-if="open"
+          ref="dropdownRef"
+          class="ui-cascade-select-dropdown"
+          :style="dropdownStyle"
+        >
+          <div class="ui-cascade-select-pane">
+            <div class="ui-cascade-select-pane-title">对象类型</div>
+            <button
+              type="button"
+              class="ui-cascade-select-option"
+              :class="{ active: !targetType }"
+              @click="chooseTarget('')"
+            >
+              全部类型
+            </button>
+            <button
+              v-for="group in normalizedGroups"
+              :key="group.value"
+              type="button"
+              class="ui-cascade-select-option"
+              :class="{ active: isSelectedTarget(group.value) }"
+              @click="chooseTarget(group.value)"
+            >
+              {{ group.label }}
+            </button>
+          </div>
+
+          <div class="ui-cascade-select-pane ui-cascade-select-pane-right">
+            <div class="ui-cascade-select-pane-title">{{ rightPaneTitle }}</div>
+            <button
+              type="button"
+              class="ui-cascade-select-option"
+              :class="{ active: !actionType }"
+              @click="chooseAction('')"
+            >
+              全部操作
+            </button>
+            <button
+              v-for="action in visibleActions"
+              :key="action.key"
+              type="button"
+              class="ui-cascade-select-option"
+              :class="{ active: isSelectedAction(action.value) }"
+              @click="chooseAction(action.value)"
+            >
+              {{ action.label }}
+            </button>
+            <div v-if="visibleActions.length === 0" class="ui-cascade-select-empty">
+              暂无可选项
+            </div>
+          </div>
+        </div>
+      </transition>
+    </Teleport>
+  </div>
+</template>
+
+<script setup>
+import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
+
+const props = defineProps({
+  targetType: {
+    type: String,
+    default: ''
+  },
+  actionType: {
+    type: String,
+    default: ''
+  },
+  groups: {
+    type: Array,
+    default: () => []
+  },
+  placeholder: {
+    type: String,
+    default: '选择类型'
+  },
+  disabled: {
+    type: Boolean,
+    default: false
+  }
+});
+
+const emit = defineEmits(['update:targetType', 'update:actionType']);
+
+const open = ref(false);
+const rootRef = ref(null);
+const triggerRef = ref(null);
+const dropdownRef = ref(null);
+const dropdownStyle = ref({
+  position: 'fixed',
+  top: '-9999px',
+  left: '-9999px',
+  width: '0px',
+  minWidth: '0px',
+  maxWidth: 'calc(100vw - 12px)',
+  visibility: 'hidden'
+});
+
+const normalizedGroups = computed(() => props.groups.map(group => ({
+  label: group?.label ?? String(group?.value ?? ''),
+  value: group?.value ?? '',
+  children: Array.isArray(group?.children)
+    ? group.children.map(child => ({
+        label: child?.label ?? String(child?.value ?? ''),
+        value: child?.value ?? '',
+        key: `${group?.value ?? ''}::${child?.value ?? ''}`
+      }))
+    : []
+})));
+
+const allActions = computed(() => normalizedGroups.value.flatMap(group => group.children.map(child => ({
+  label: `${group.label} / ${child.label}`,
+  value: child.value,
+  key: child.key
+}))));
+
+const selectedTargetLabel = computed(() => {
+  if (!props.targetType) {
+    return '';
+  }
+  return normalizedGroups.value.find(group => group.value === props.targetType)?.label || props.targetType;
+});
+
+const selectedActionLabel = computed(() => {
+  if (!props.actionType) {
+    return '';
+  }
+  if (props.targetType) {
+    const group = normalizedGroups.value.find(item => item.value === props.targetType);
+    const child = group?.children.find(item => item.value === props.actionType);
+    return child?.label || props.actionType;
+  }
+  const action = allActions.value.find(item => item.value === props.actionType);
+  return action?.label || props.actionType;
+});
+
+const selectedLabel = computed(() => {
+  if (selectedTargetLabel.value && selectedActionLabel.value) {
+    return `${selectedTargetLabel.value} / ${selectedActionLabel.value}`;
+  }
+  if (selectedTargetLabel.value) {
+    return selectedTargetLabel.value;
+  }
+  if (selectedActionLabel.value) {
+    return selectedActionLabel.value;
+  }
+  return '';
+});
+
+const rightPaneTitle = computed(() => {
+  if (props.targetType) {
+    return `${selectedTargetLabel.value || props.targetType} 的操作类型`;
+  }
+  return '全部操作类型';
+});
+
+const visibleActions = computed(() => {
+  if (props.targetType) {
+    const group = normalizedGroups.value.find(item => item.value === props.targetType);
+    return group?.children || [];
+  }
+  return allActions.value;
+});
+
+function toggleOpen() {
+  if (props.disabled) {
+    return;
+  }
+  open.value = !open.value;
+}
+
+function isSelectedTarget(value) {
+  return Object.is(value, props.targetType);
+}
+
+function isSelectedAction(value) {
+  return Object.is(value, props.actionType);
+}
+
+function chooseTarget(value) {
+  emit('update:targetType', value);
+  if (value) {
+    const group = normalizedGroups.value.find(item => item.value === value);
+    if (props.actionType && !group?.children.some(child => child.value === props.actionType)) {
+      emit('update:actionType', '');
+    }
+  }
+}
+
+function chooseAction(value) {
+  emit('update:actionType', value);
+  open.value = false;
+}
+
+function handleClickOutside(event) {
+  if (!open.value || !rootRef.value) {
+    return;
+  }
+  const target = event.target;
+  if (!rootRef.value.contains(target) && !(dropdownRef.value && dropdownRef.value.contains(target))) {
+    open.value = false;
+  }
+}
+
+function updateDropdownPosition() {
+  if (!open.value || !triggerRef.value) {
+    return;
+  }
+  const rect = triggerRef.value.getBoundingClientRect();
+  const gap = 6;
+  const viewportWidth = window.innerWidth;
+  const viewportHeight = window.innerHeight;
+  const width = Math.min(Math.max(rect.width, 420), viewportWidth - gap * 2);
+  let left = rect.left;
+  if (left + width > viewportWidth - gap) {
+    left = Math.max(gap, viewportWidth - width - gap);
+  }
+  if (left < gap) {
+    left = gap;
+  }
+
+  const spaceBelow = viewportHeight - rect.bottom - gap;
+  const spaceAbove = rect.top - gap;
+  const openUp = spaceBelow < 220 && spaceAbove > spaceBelow;
+  const maxHeight = Math.max(200, Math.min(360, openUp ? spaceAbove : spaceBelow));
+
+  dropdownStyle.value = {
+    position: 'fixed',
+    left: `${left}px`,
+    width: `${width}px`,
+    minWidth: `${width}px`,
+    maxWidth: `calc(100vw - ${gap * 2}px)`,
+    maxHeight: `${maxHeight}px`,
+    zIndex: '2100',
+    visibility: 'visible',
+    ...(openUp
+      ? {
+          top: 'auto',
+          bottom: `${Math.max(gap, viewportHeight - rect.top + gap)}px`
+        }
+      : {
+          top: `${rect.bottom + gap}px`,
+          bottom: 'auto'
+        })
+  };
+}
+
+function handleViewportChange() {
+  updateDropdownPosition();
+}
+
+function addViewportListeners() {
+  window.addEventListener('scroll', handleViewportChange, true);
+  window.addEventListener('resize', handleViewportChange);
+}
+
+function removeViewportListeners() {
+  window.removeEventListener('scroll', handleViewportChange, true);
+  window.removeEventListener('resize', handleViewportChange);
+}
+
+onMounted(() => {
+  document.addEventListener('click', handleClickOutside);
+});
+
+watch(open, async isOpen => {
+  if (isOpen) {
+    dropdownStyle.value = {
+      position: 'fixed',
+      top: '-9999px',
+      left: '-9999px',
+      width: '0px',
+      minWidth: '0px',
+      maxWidth: 'calc(100vw - 12px)',
+      visibility: 'hidden'
+    };
+    await nextTick();
+    if (!open.value) {
+      return;
+    }
+    updateDropdownPosition();
+    addViewportListeners();
+    return;
+  }
+  removeViewportListeners();
+});
+
+watch(
+  () => props.targetType,
+  targetType => {
+    if (!targetType) {
+      return;
+    }
+    const group = normalizedGroups.value.find(item => item.value === targetType);
+    if (props.actionType && !group?.children.some(child => child.value === props.actionType)) {
+      emit('update:actionType', '');
+    }
+  }
+);
+
+onUnmounted(() => {
+  document.removeEventListener('click', handleClickOutside);
+  removeViewportListeners();
+});
+</script>
+
+<style scoped>
+.ui-cascade-select {
+  position: relative;
+  width: 100%;
+}
+
+.ui-cascade-select-trigger {
+  width: 100%;
+  min-height: 36px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  padding: 8px 34px 8px 12px;
+  border: 1px solid #ccd6ea;
+  border-radius: 10px;
+  background: #fff;
+  color: #223355;
+  cursor: pointer;
+  box-sizing: border-box;
+  text-align: left;
+  transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
+}
+
+.ui-cascade-select-trigger:hover:not(:disabled) {
+  border-color: #9eb2d8;
+  background: #f9fbff;
+}
+
+.ui-cascade-select.is-open .ui-cascade-select-trigger {
+  border-color: #0080ff;
+  box-shadow: 0 0 0 3px rgba(0, 128, 255, 0.16);
+  background: #fff;
+}
+
+.ui-cascade-select-trigger:disabled {
+  cursor: not-allowed;
+  color: #8b96ab;
+  border-color: #d9e1f0;
+  background: #f5f7fb;
+}
+
+.ui-cascade-select-label {
+  flex: 1;
+  min-width: 0;
+  text-align: left;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  font-size: 13px;
+  line-height: 1.4;
+}
+
+.ui-cascade-select-label.placeholder {
+  color: #8b96ab;
+}
+
+.ui-cascade-select-arrow {
+  position: absolute;
+  right: 12px;
+  width: 8px;
+  height: 8px;
+  border-right: 2px solid #8ea2c6;
+  border-bottom: 2px solid #8ea2c6;
+  transform: rotate(45deg) translateY(-2px);
+  transition: transform 0.18s ease;
+}
+
+.ui-cascade-select.is-open .ui-cascade-select-arrow {
+  transform: rotate(-135deg) translateY(-1px);
+}
+
+.ui-cascade-select-dropdown {
+  position: fixed;
+  padding: 6px;
+  background: #fff;
+  border: 1px solid #d8e1f2;
+  border-radius: 12px;
+  box-shadow: 0 10px 24px rgba(27, 39, 61, 0.14);
+  overflow: hidden;
+  display: grid;
+  grid-template-columns: minmax(160px, 1fr) minmax(220px, 1.2fr);
+  box-sizing: border-box;
+}
+
+.ui-cascade-select-pane {
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+  max-height: inherit;
+  overflow-y: auto;
+  border-right: 1px solid var(--border-light, #f2f6fc);
+}
+
+.ui-cascade-select-pane-right {
+  border-right: none;
+}
+
+.ui-cascade-select-pane-title {
+  position: sticky;
+  top: 0;
+  z-index: 1;
+  padding: 10px 14px;
+  background: #fff;
+  border-bottom: 1px solid var(--border-light, #f2f6fc);
+  color: var(--text-muted, #909399);
+  font-size: 12px;
+  font-weight: 600;
+}
+
+.ui-cascade-select-option {
+  width: 100%;
+  border-radius: 8px;
+  padding: 8px 10px;
+  border: none;
+  background: transparent;
+  color: #223355;
+  text-align: left;
+  font-size: 13px;
+  line-height: 1.45;
+  cursor: pointer;
+  user-select: none;
+  transition: background-color 0.15s, color 0.15s;
+}
+
+.ui-cascade-select-option:hover,
+.ui-cascade-select-option.active {
+  background: #e9f2ff;
+  color: #0064d6;
+}
+
+.ui-cascade-select-empty {
+  padding: 12px 14px;
+  color: #8b96ab;
+  font-size: 13px;
+}
+
+.ui-cascade-fade-enter-active,
+.ui-cascade-fade-leave-active {
+  transition: opacity 0.14s ease, transform 0.14s ease;
+}
+
+.ui-cascade-fade-enter-from,
+.ui-cascade-fade-leave-to {
+  opacity: 0;
+  transform: translateY(-4px);
+}
+</style>

+ 336 - 0
frontend/src/components/UiSelect.vue

@@ -0,0 +1,336 @@
+<template>
+  <div ref="rootRef" class="ui-select" :class="{ 'is-open': open, 'is-disabled': disabled, 'is-compact': compact }">
+    <button
+      ref="triggerRef"
+      type="button"
+      class="ui-select-trigger"
+      :disabled="disabled"
+      @click="toggleOpen"
+    >
+      <span class="ui-select-label" :class="{ placeholder: !selectedOption }">
+        {{ selectedOption ? selectedOption.label : placeholder }}
+      </span>
+      <span class="ui-select-arrow" aria-hidden="true"></span>
+    </button>
+    <Teleport to="body">
+      <transition name="ui-select-fade">
+        <ul
+          v-if="open"
+          ref="dropdownRef"
+          class="ui-select-dropdown"
+          :style="dropdownStyle"
+        >
+          <li
+            v-for="opt in normalizedOptions"
+            :key="String(opt.value)"
+            class="ui-select-option"
+            :class="{ active: isSelected(opt.value), disabled: !!opt.disabled }"
+            @click="choose(opt)"
+          >
+            {{ opt.label }}
+          </li>
+        </ul>
+      </transition>
+    </Teleport>
+  </div>
+</template>
+
+<script setup>
+import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
+
+const props = defineProps({
+  modelValue: {
+    type: [String, Number, Boolean, null],
+    default: ''
+  },
+  options: {
+    type: Array,
+    default: () => []
+  },
+  placeholder: {
+    type: String,
+    default: '请选择'
+  },
+  disabled: {
+    type: Boolean,
+    default: false
+  },
+  compact: {
+    type: Boolean,
+    default: false
+  }
+});
+
+const emit = defineEmits(['update:modelValue', 'change']);
+
+const open = ref(false);
+const rootRef = ref(null);
+const triggerRef = ref(null);
+const dropdownRef = ref(null);
+const dropdownStyle = ref({
+  position: 'fixed',
+  top: '-9999px',
+  left: '-9999px',
+  minWidth: '0px',
+  maxWidth: 'calc(100vw - 12px)',
+  visibility: 'hidden'
+});
+
+const normalizedOptions = computed(() => props.options.map(item => ({
+  label: item?.label ?? String(item?.value ?? ''),
+  value: item?.value,
+  disabled: !!item?.disabled
+})));
+
+const selectedOption = computed(() => {
+  const option = normalizedOptions.value.find(item => Object.is(item.value, props.modelValue));
+  if (option && option.disabled && (props.modelValue === '' || props.modelValue === null || props.modelValue === undefined)) {
+    return null;
+  }
+  return option || null;
+});
+
+function isSelected(value) {
+  return Object.is(value, props.modelValue);
+}
+
+function toggleOpen() {
+  if (props.disabled) {
+    return;
+  }
+  open.value = !open.value;
+}
+
+function choose(opt) {
+  if (opt.disabled) {
+    return;
+  }
+  emit('update:modelValue', opt.value);
+  emit('change', opt.value);
+  open.value = false;
+}
+
+function handleClickOutside(event) {
+  if (!open.value || !rootRef.value) {
+    return;
+  }
+  const target = event.target;
+  if (!rootRef.value.contains(target) && !(dropdownRef.value && dropdownRef.value.contains(target))) {
+    open.value = false;
+  }
+}
+
+function updateDropdownPosition() {
+  if (!open.value || !triggerRef.value) {
+    return;
+  }
+  const rect = triggerRef.value.getBoundingClientRect();
+  const gap = 6;
+  const viewportWidth = window.innerWidth;
+  const viewportHeight = window.innerHeight;
+  const width = dropdownRef.value?.getBoundingClientRect().width || rect.width;
+  let left = rect.left;
+  if (left + width > viewportWidth - gap) {
+    left = Math.max(gap, viewportWidth - width - gap);
+  }
+  if (left < gap) {
+    left = gap;
+  }
+
+  const spaceBelow = viewportHeight - rect.bottom - gap;
+  const spaceAbove = rect.top - gap;
+  const openUp = spaceBelow < 140 && spaceAbove > spaceBelow;
+  const maxHeight = Math.max(120, Math.min(260, openUp ? spaceAbove : spaceBelow));
+
+  dropdownStyle.value = {
+    position: 'fixed',
+    left: `${left}px`,
+    minWidth: `${width}px`,
+    maxWidth: `calc(100vw - ${gap * 2}px)`,
+    maxHeight: `${maxHeight}px`,
+    zIndex: '2100',
+    visibility: 'visible',
+    ...(openUp
+      ? {
+          top: 'auto',
+          bottom: `${Math.max(gap, viewportHeight - rect.top + gap)}px`
+        }
+      : {
+          top: `${rect.bottom + gap}px`,
+          bottom: 'auto'
+        })
+  };
+}
+
+function handleViewportChange() {
+  updateDropdownPosition();
+}
+
+function addViewportListeners() {
+  window.addEventListener('scroll', handleViewportChange, true);
+  window.addEventListener('resize', handleViewportChange);
+}
+
+function removeViewportListeners() {
+  window.removeEventListener('scroll', handleViewportChange, true);
+  window.removeEventListener('resize', handleViewportChange);
+}
+
+onMounted(() => {
+  document.addEventListener('click', handleClickOutside);
+});
+
+watch(open, async (isOpen) => {
+  if (isOpen) {
+    dropdownStyle.value = {
+      position: 'fixed',
+      top: '-9999px',
+      left: '-9999px',
+      minWidth: '0px',
+      maxWidth: 'calc(100vw - 12px)',
+      visibility: 'hidden'
+    };
+    await nextTick();
+    if (!open.value) {
+      return;
+    }
+    updateDropdownPosition();
+    addViewportListeners();
+    return;
+  }
+  removeViewportListeners();
+});
+
+onUnmounted(() => {
+  document.removeEventListener('click', handleClickOutside);
+  removeViewportListeners();
+});
+</script>
+
+<style scoped>
+.ui-select {
+  position: relative;
+  width: 100%;
+}
+
+.ui-select.is-compact {
+  width: auto;
+  display: inline-block;
+}
+
+.ui-select-trigger {
+  width: 100%;
+  min-height: 36px;
+  padding: 8px 34px 8px 12px;
+  border-radius: 10px;
+  border: 1px solid #ccd6ea;
+  background: #fff;
+  color: #223355;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  text-align: left;
+  transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
+}
+
+.ui-select.is-compact .ui-select-trigger {
+  width: auto;
+  min-width: 76px;
+}
+
+.ui-select:not(.is-disabled) .ui-select-trigger:hover {
+  border-color: #9eb2d8;
+  background: #f9fbff;
+}
+
+.ui-select.is-open .ui-select-trigger {
+  border-color: #0080ff;
+  box-shadow: 0 0 0 3px rgba(0, 128, 255, 0.16);
+  background: #fff;
+}
+
+.ui-select.is-disabled .ui-select-trigger {
+  cursor: not-allowed;
+  color: #8b96ab;
+  border-color: #d9e1f0;
+  background: #f5f7fb;
+}
+
+.ui-select-label {
+  flex: 1;
+  font-size: 13px;
+  line-height: 1.4;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.ui-select-label.placeholder {
+  color: #8b96ab;
+}
+
+.ui-select-arrow {
+  position: absolute;
+  right: 12px;
+  width: 8px;
+  height: 8px;
+  border-right: 2px solid #8ea2c6;
+  border-bottom: 2px solid #8ea2c6;
+  transform: rotate(45deg) translateY(-2px);
+  transition: transform 0.18s ease;
+}
+
+.ui-select.is-open .ui-select-arrow {
+  transform: rotate(-135deg) translateY(-1px);
+}
+
+.ui-select-dropdown {
+  position: fixed;
+  margin: 0;
+  padding: 6px;
+  list-style: none;
+  border-radius: 12px;
+  border: 1px solid #d8e1f2;
+  background: #fff;
+  box-shadow: 0 10px 24px rgba(27, 39, 61, 0.14);
+  max-height: 260px;
+  overflow-y: auto;
+  overflow-x: hidden;
+  box-sizing: border-box;
+}
+
+.ui-select-option {
+  border-radius: 8px;
+  padding: 8px 10px;
+  font-size: 13px;
+  color: #223355;
+  line-height: 1.45;
+  cursor: pointer;
+  user-select: none;
+}
+
+.ui-select-option:hover {
+  background: #f4f8ff;
+}
+
+.ui-select-option.active {
+  background: #e9f2ff;
+  color: #0064d6;
+}
+
+.ui-select-option.disabled {
+  color: #a5afc2;
+  cursor: not-allowed;
+}
+
+.ui-select-fade-enter-active,
+.ui-select-fade-leave-active {
+  transition: opacity 0.14s ease, transform 0.14s ease;
+}
+
+.ui-select-fade-enter-from,
+.ui-select-fade-leave-to {
+  opacity: 0;
+  transform: translateY(-4px);
+}
+</style>

+ 17 - 34
frontend/src/router/index.js

@@ -1,33 +1,16 @@
 import { createRouter, createWebHistory } from 'vue-router';
-import AuditLogPage from '../views/ops/AuditLogPage.vue';
-import AlertEventPage from '../views/ops/AlertEventPage.vue';
-import AlertRulePage from '../views/ops/AlertRulePage.vue';
-import LoginPage from '../views/auth/LoginPage.vue';
-import RegisterPage from '../views/auth/RegisterPage.vue';
-import StockPage from '../views/resource-management/StockPage.vue';
-import ClusterPage from '../views/resource-management/ClusterPage.vue';
-import ClusterConsolePage from '../views/resource-management/ClusterConsolePage.vue';
-import StockConsolePage from '../views/resource-management/StockConsolePage.vue';
-import CodeTaskBuildPage from '../views/task-build/CodeTaskBuildPage.vue';
-import DialogTaskBuildPage from '../views/task-build/DialogTaskBuildPage.vue';
-import TaskBuildWorkflowListPage from '../views/task-build/TaskBuildWorkflowListPage.vue';
-import WorkflowExecutionPage from '../views/task-exec/WorkflowExecutionPage.vue';
-import TaskInstanceLogPage from '../views/task-exec/TaskInstanceLogPage.vue';
-import TaskInstanceResultPage from '../views/task-exec/TaskInstanceResultPage.vue';
-import SettingsPage from '../views/settings/SettingsPage.vue';
-import MemberManagementPage from '../views/settings/MemberManagementPage.vue';
 
 const routes = [
   {
     path: '/login',
     name: 'Login',
-    component: LoginPage,
+    component: () => import('../views/auth/LoginPage.vue'),
     meta: { public: true }
   },
   {
     path: '/register',
     name: 'Register',
-    component: RegisterPage,
+    component: () => import('../views/auth/RegisterPage.vue'),
     meta: { public: true }
   },
   {
@@ -37,91 +20,91 @@ const routes = [
   {
     path: '/task-build/code',
     name: 'CodeTaskBuild',
-    component: CodeTaskBuildPage,
+    component: () => import('../views/task-build/CodeTaskBuildPage.vue'),
     meta: { roles: ['ADMIN', 'DEVELOPER', 'ANALYST'] }
   },
   {
     path: '/task-build/dialog',
     name: 'DialogTaskBuild',
-    component: DialogTaskBuildPage,
+    component: () => import('../views/task-build/DialogTaskBuildPage.vue'),
     meta: { roles: ['ADMIN', 'DEVELOPER', 'ANALYST'] }
   },
   {
     path: '/task-build/workflows',
     name: 'TaskBuildWorkflows',
-    component: TaskBuildWorkflowListPage,
+    component: () => import('../views/task-build/TaskBuildWorkflowListPage.vue'),
     meta: { roles: ['ADMIN', 'DEVELOPER', 'ANALYST'] }
   },
   {
     path: '/task-exec/workflows',
     name: 'TaskExecWorkflows',
-    component: WorkflowExecutionPage,
+    component: () => import('../views/task-exec/WorkflowExecutionPage.vue'),
     meta: { roles: ['ADMIN', 'DEVELOPER', 'ANALYST'] }
   },
   {
     path: '/task-exec/workflows/:workflowInstanceId/tasks/:taskInstanceId/logs',
     name: 'TaskInstanceLog',
-    component: TaskInstanceLogPage,
+    component: () => import('../views/task-exec/TaskInstanceLogPage.vue'),
     meta: { roles: ['ADMIN', 'DEVELOPER', 'ANALYST'] }
   },
   {
     path: '/task-exec/workflows/:workflowInstanceId/tasks/:taskInstanceId/results',
     name: 'TaskInstanceResult',
-    component: TaskInstanceResultPage,
+    component: () => import('../views/task-exec/TaskInstanceResultPage.vue'),
     meta: { roles: ['ADMIN', 'DEVELOPER', 'ANALYST'] }
   },
   {
     path: '/resource/clusters',
     name: 'Clusters',
-    component: ClusterPage,
+    component: () => import('../views/resource-management/ClusterPage.vue'),
     meta: { roles: ['ADMIN', 'DEVELOPER'] }
   },
   {
     path: '/resource/clusters/:clusterId',
     name: 'ClusterConsole',
-    component: ClusterConsolePage,
+    component: () => import('../views/resource-management/ClusterConsolePage.vue'),
     meta: { roles: ['ADMIN', 'DEVELOPER'] }
   },
   {
     path: '/ops/audit-log',
     name: 'AuditLog',
-    component: AuditLogPage,
+    component: () => import('../views/ops/AuditLogPage.vue'),
     meta: { roles: ['ADMIN', 'DEVELOPER'] }
   },
   {
     path: '/ops/alerts',
     name: 'AlertRules',
-    component: AlertRulePage,
+    component: () => import('../views/ops/AlertRulePage.vue'),
     meta: { roles: ['ADMIN', 'DEVELOPER'] }
   },
   {
     path: '/ops/alerts/events',
     name: 'AlertEvents',
-    component: AlertEventPage,
+    component: () => import('../views/ops/AlertEventPage.vue'),
     meta: { roles: ['ADMIN', 'DEVELOPER'] }
   },
   {
     path: '/resource/stocks',
     name: 'Stocks',
-    component: StockPage,
+    component: () => import('../views/resource-management/StockPage.vue'),
     meta: { roles: ['ADMIN', 'DEVELOPER'] }
   },
   {
     path: '/resource/stocks/:machineId/console',
     name: 'StockConsole',
-    component: StockConsolePage,
+    component: () => import('../views/resource-management/StockConsolePage.vue'),
     meta: { roles: ['ADMIN', 'DEVELOPER'] }
   },
   {
     path: '/settings',
     name: 'Settings',
-    component: SettingsPage,
+    component: () => import('../views/settings/SettingsPage.vue'),
     meta: { roles: ['ADMIN', 'DEVELOPER', 'ANALYST'] }
   },
   {
     path: '/settings/members',
     name: 'MemberManagement',
-    component: MemberManagementPage,
+    component: () => import('../views/settings/MemberManagementPage.vue'),
     meta: { roles: ['ADMIN'] }
   }
 ];

+ 58 - 2
frontend/src/styles/member-theme.css

@@ -1,7 +1,7 @@
 .member-theme {
   --member-bg:
     radial-gradient(circle at 10% 10%, rgba(49, 106, 255, 0.1), transparent 38%),
-    radial-gradient(circle at 90% 90%, rgba(28, 177, 147, 0.12), transparent 32%),
+    radial-gradient(circle at 92% 92%, rgba(28, 177, 147, 0.17), transparent 40%),
     #f3f6fb;
   --member-card-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.95), #ffffff);
   --member-title: #162447;
@@ -96,13 +96,57 @@
 }
 
 .member-theme input,
-.member-theme select,
 .member-theme textarea {
   border-radius: 8px !important;
   border-color: #ccd6ea !important;
   color: #223355 !important;
 }
 
+.member-theme select {
+  border-color: #ccd6ea !important;
+  color: #223355 !important;
+}
+
+.member-theme select:not([multiple]):not(.inline-select):not(.executor-select) {
+  min-height: 36px;
+  padding: 8px 38px 8px 12px !important;
+  border-radius: 10px !important;
+  background-color: #ffffff !important;
+  background-image:
+    linear-gradient(45deg, transparent 50%, #8ea2c6 50%),
+    linear-gradient(135deg, #8ea2c6 50%, transparent 50%),
+    linear-gradient(#d8e1f2, #d8e1f2);
+  background-position:
+    calc(100% - 18px) calc(50% - 2px),
+    calc(100% - 12px) calc(50% - 2px),
+    calc(100% - 2.2rem) 50%;
+  background-size: 6px 6px, 6px 6px, 1px 60%;
+  background-repeat: no-repeat;
+  appearance: none;
+  -webkit-appearance: none;
+  -moz-appearance: none;
+  transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
+}
+
+.member-theme select:not([multiple]):not(.inline-select):not(.executor-select):hover {
+  border-color: #9eb2d8 !important;
+  background-color: #f9fbff !important;
+}
+
+.member-theme select[multiple] {
+  border-radius: 10px !important;
+  padding: 8px 12px !important;
+  background-image: none !important;
+  background-color: #ffffff !important;
+}
+
+.member-theme select:disabled {
+  color: #8b96ab !important;
+  border-color: #d9e1f0 !important;
+  background-color: #f5f7fb !important;
+  cursor: not-allowed;
+}
+
 .member-theme input:focus,
 .member-theme select:focus,
 .member-theme textarea:focus {
@@ -110,6 +154,11 @@
   box-shadow: 0 0 0 3px rgba(0, 128, 255, 0.16) !important;
 }
 
+.member-theme select option {
+  background-color: #ffffff;
+  color: #223355;
+}
+
 .member-theme th {
   background: #f5f8ff !important;
   color: #5b6f98 !important;
@@ -121,3 +170,10 @@
   border-radius: 8px !important;
   box-shadow: 0 6px 16px rgba(15, 23, 42, 0.12) !important;
 }
+
+.member-theme .pagination > label {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  white-space: nowrap;
+}

+ 13 - 0
frontend/src/utils/displayId.js

@@ -0,0 +1,13 @@
+export function formatDisplayId(value) {
+  if (value === null || value === undefined) {
+    return '-';
+  }
+
+  const text = String(value).trim();
+  if (!text) {
+    return '-';
+  }
+
+  const normalized = text.replace(/^[A-Za-z]+-/, '');
+  return normalized.startsWith('#') ? normalized : `#${normalized}`;
+}

+ 38 - 15
frontend/src/views/ops/AlertEventPage.vue

@@ -47,15 +47,9 @@
             <label>规则名称</label>
             <input v-model.trim="filters.alertRuleName" type="text" placeholder="例如 cpu_high" />
           </div>
-          <div class="filter-item">
+          <div class="filter-item filter-item-status">
             <label>处理状态</label>
-            <select v-model="filters.handleStatus">
-              <option value="">全部</option>
-              <option value="UNREAD">未处理</option>
-              <option value="PROCESSING">处理中</option>
-              <option value="HANDLED">已处理</option>
-              <option value="IGNORED">已忽略</option>
-            </select>
+            <UiSelect v-model="filters.handleStatus" :options="handleStatusFilterOptions" placeholder="全部" />
           </div>
           <div class="filter-actions">
             <button class="btn btn-primary" :disabled="loading" @click="handleSearch">查询</button>
@@ -91,11 +85,11 @@
                 <td colspan="11" class="empty-row">暂无数据</td>
               </tr>
               <tr v-for="item in events" :key="item.alertEventId">
-                <td class="id-text">{{ item.alertEventId }}</td>
+                <td class="id-text">{{ formatDisplayId(item.alertEventId) }}</td>
                 <td>{{ formatTime(item.triggeredTime) }}</td>
                 <td>{{ item.alertRuleName }}</td>
                 <td><span :class="severityClass(item.severityLevel)">{{ item.severityLevel }}</span></td>
-                <td>{{ item.targetId ?? '-' }}</td>
+                <td>{{ formatDisplayId(item.targetId) }}</td>
                 <td>{{ item.currentValue ?? '-' }}</td>
                 <td>{{ item.thresholdValue ?? '-' }}</td>
                 <td>{{ item.notifyChannel || '-' }}</td>
@@ -114,9 +108,7 @@
         <div class="pagination">
           <label>
             每页
-            <select v-model.number="pageSize" @change="handleSearch">
-              <option v-for="n in [10, 20, 50]" :key="n" :value="n">{{ n }}</option>
-            </select>
+            <UiSelect v-model="pageSize" compact :options="pageSizeOptions" @change="handleSearch" />
             条
           </label>
           <div class="pagination-right">
@@ -134,7 +126,7 @@
         <header class="modal-header">
           <div class="modal-header-content">
             <h3 class="modal-title">处置告警事件</h3>
-            <span class="modal-subtitle">#{{ dialog.eventId }} - {{ dialog.eventDetails?.alertRuleName }}</span>
+            <span class="modal-subtitle">{{ formatDisplayId(dialog.eventId) }} - {{ dialog.eventDetails?.alertRuleName }}</span>
           </div>
           <button class="close-btn" @click="closeDialog">&times;</button>
         </header>
@@ -197,11 +189,13 @@
 import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
 import { useRouter } from 'vue-router';
 import Sidebar from '../../components/Sidebar.vue';
+import UiSelect from '../../components/UiSelect.vue';
 import { VueDatePicker } from '@vuepic/vue-datepicker';
 import '@vuepic/vue-datepicker/dist/main.css';
 import { zhCN } from 'date-fns/locale';
 import { handleAlertEvent, queryAlertEvents } from '../../api/alert';
 import { getMe } from '../../api/auth';
+import { formatDisplayId } from '../../utils/displayId';
 
 const router = useRouter();
 
@@ -216,6 +210,14 @@ const pageSize = ref(10);
 const datePickerFloating = Object.freeze({ strategy: 'fixed' });
 const datePickerLocale = zhCN;
 const datePickerActionRow = Object.freeze({ selectBtnLabel: '确定', cancelBtnLabel: '取消' });
+const pageSizeOptions = [10, 20, 50].map(n => ({ label: String(n), value: n }));
+const handleStatusFilterOptions = [
+  { label: '全部', value: '' },
+  { label: '未处理', value: 'UNREAD' },
+  { label: '处理中', value: 'PROCESSING' },
+  { label: '已处理', value: 'HANDLED' },
+  { label: '已忽略', value: 'IGNORED' }
+];
 
 const filters = reactive({ startTime: null, endTime: null, alertRuleName: '', handleStatus: '' });
 
@@ -453,7 +455,14 @@ onMounted(() => {
 
 .filter-grid {
   display: grid;
-  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+  grid-template-columns:
+    minmax(190px, 1.25fr)
+    minmax(190px, 1.25fr)
+    minmax(220px, 1.6fr)
+    minmax(140px, 0.8fr)
+    auto
+    auto;
+  align-items: end;
   gap: 14px;
 }
 
@@ -461,6 +470,12 @@ onMounted(() => {
   display: flex;
   flex-direction: column;
   gap: 6px;
+  min-width: 0;
+}
+
+.filter-item-status {
+  width: 150px;
+  justify-self: start;
 }
 
 .filter-item label {
@@ -509,6 +524,11 @@ onMounted(() => {
   display: flex;
   align-items: flex-end;
   gap: 8px;
+  white-space: nowrap;
+}
+
+.filter-actions .btn {
+  flex-shrink: 0;
 }
 
 .table-panel {
@@ -856,6 +876,9 @@ tbody tr:hover { background: var(--accent-hover); }
   .filter-grid {
     grid-template-columns: 1fr !important;
   }
+  .filter-item-status {
+    width: 100%;
+  }
   .table-wrapper {
     overflow-x: auto !important;
   }

+ 37 - 40
frontend/src/views/ops/AlertPage.vue

@@ -39,10 +39,10 @@
                   <td colspan="10" class="empty-row">暂无数据</td>
                 </tr>
                 <tr v-for="rule in rules.list" :key="rule.alertRuleId">
-                  <td class="id-text">{{ rule.alertRuleId }}</td>
+                  <td class="id-text">{{ formatDisplayId(rule.alertRuleId) }}</td>
                   <td>{{ rule.alertRuleName }}</td>
                   <td><span :class="severityBadgeClass(rule.severityLevel)">{{ rule.severityLevel }}</span></td>
-                  <td>{{ rule.targetId }}</td>
+                  <td>{{ formatDisplayId(rule.targetId) }}</td>
                   <td>{{ rule.metricName }}</td>
                   <td>{{ rule.comparisonOperator }} {{ rule.thresholdValue }}</td>
                   <td>{{ rule.durationSeconds }}s</td>
@@ -59,9 +59,7 @@
           </div>
           <div class="pagination">
             <label>每页
-              <select v-model.number="rules.pageSize" @change="searchRules">
-                <option v-for="n in [10, 20, 50]" :key="n" :value="n">{{ n }}</option>
-              </select>
+              <UiSelect v-model="rules.pageSize" compact :options="pageSizeOptions" @change="searchRules" />
               条
             </label>
             <div class="pagination-right">
@@ -89,16 +87,10 @@
               <label>规则名称</label>
               <input v-model.trim="events.filters.alertRuleName" type="text" placeholder="输入规则名称" />
             </div>
-            <div class="filter-item">
-              <label>处理状态</label>
-              <select v-model="events.filters.handleStatus">
-                <option value="">全部</option>
-                <option value="UNREAD">UNREAD</option>
-                <option value="PROCESSING">PROCESSING</option>
-                <option value="HANDLED">HANDLED</option>
-                <option value="IGNORED">IGNORED</option>
-              </select>
-            </div>
+          <div class="filter-item">
+            <label>处理状态</label>
+            <UiSelect v-model="events.filters.handleStatus" :options="eventHandleStatusOptions" placeholder="全部" />
+          </div>
             <div class="filter-actions">
               <button class="btn btn-primary" :disabled="events.loading" @click="searchEvents">查询</button>
               <button class="btn btn-default" :disabled="events.loading" @click="resetEvents">重置</button>
@@ -134,16 +126,16 @@
                   <td colspan="12" class="empty-row">暂无数据</td>
                 </tr>
                 <tr v-for="item in events.list" :key="item.alertEventId">
-                  <td class="id-text">{{ item.alertEventId }}</td>
+                  <td class="id-text">{{ formatDisplayId(item.alertEventId) }}</td>
                   <td>{{ formatTime(item.triggeredTime) }}</td>
                   <td>{{ item.alertRuleName }}</td>
                   <td><span :class="severityBadgeClass(item.severityLevel)">{{ item.severityLevel }}</span></td>
-                  <td>{{ item.targetId ?? '-' }}</td>
+                  <td>{{ formatDisplayId(item.targetId) }}</td>
                   <td>{{ item.currentValue ?? '-' }}</td>
                   <td>{{ item.thresholdValue ?? '-' }}</td>
                   <td>{{ item.notifyChannel || '-' }}</td>
                   <td><span :class="handleStatusBadgeClass(item.handleStatus)">{{ item.handleStatus }}</span></td>
-                  <td>{{ item.handlerId ?? '-' }}</td>
+                  <td>{{ formatDisplayId(item.handlerId) }}</td>
                   <td>{{ formatTime(item.handledTime) }}</td>
                   <td><button class="btn btn-primary btn-sm" @click="openHandleDialog(item)">处理</button></td>
                 </tr>
@@ -152,9 +144,7 @@
           </div>
           <div class="pagination">
             <label>每页
-              <select v-model.number="events.pageSize" @change="searchEvents">
-                <option v-for="n in [10, 20, 50]" :key="n" :value="n">{{ n }}</option>
-              </select>
+              <UiSelect v-model="events.pageSize" compact :options="pageSizeOptions" @change="searchEvents" />
               条
             </label>
             <div class="pagination-right">
@@ -201,13 +191,7 @@
           </div>
           <div class="form-field">
             <label>比较运算符 <span class="required">*</span></label>
-            <select v-model="ruleForm.comparisonOperator">
-              <option value=">">></option>
-              <option value=">=">>=</option>
-              <option value="<"><</option>
-              <option value="<="><=</option>
-              <option value="==">==</option>
-            </select>
+            <UiSelect v-model="ruleForm.comparisonOperator" :options="comparisonOperatorOptions" />
           </div>
           <div class="form-field">
             <label>阈值 <span class="required">*</span></label>
@@ -298,15 +282,10 @@
     <!-- Handle event dialog -->
     <div v-if="handleDialog.visible" class="modal-overlay" @click.self="closeHandleDialog">
       <div class="modal modal-sm">
-        <h3 class="modal-title">处理告警事件 #{{ handleDialog.eventId }}</h3>
+        <h3 class="modal-title">处理告警事件 {{ formatDisplayId(handleDialog.eventId) }}</h3>
         <div class="form-field">
           <label>处理状态</label>
-          <select v-model="handleDialog.handleStatus">
-            <option value="UNREAD">UNREAD</option>
-            <option value="PROCESSING">PROCESSING</option>
-            <option value="HANDLED">HANDLED</option>
-            <option value="IGNORED">IGNORED</option>
-          </select>
+          <UiSelect v-model="handleDialog.handleStatus" :options="handleStatusOptions" />
         </div>
         <div v-if="handleDialog.error" class="error-banner" style="margin-top:12px"><span>{{ handleDialog.error }}</span><button class="error-banner-close" @click="handleDialog.error = ''">&times;</button></div>
         <div class="modal-actions">
@@ -324,6 +303,7 @@
 import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
 import { useRouter } from 'vue-router';
 import Sidebar from '../../components/Sidebar.vue';
+import UiSelect from '../../components/UiSelect.vue';
 import { VueDatePicker } from '@vuepic/vue-datepicker';
 import '@vuepic/vue-datepicker/dist/main.css';
 import { zhCN } from 'date-fns/locale';
@@ -338,11 +318,33 @@ import {
 } from '../../api/alert';
 import { getMe, logout } from '../../api/auth';
 import { strongConfirm } from '../../utils/strongConfirm';
+import { formatDisplayId } from '../../utils/displayId';
 
 const router = useRouter();
 const NOTIFY_CHANNEL_OPTIONS = ['EMAIL', 'SMS', 'IM'];
 const datePickerLocale = zhCN;
 const datePickerActionRow = Object.freeze({ selectBtnLabel: '确定', cancelBtnLabel: '取消' });
+const pageSizeOptions = [10, 20, 50].map(n => ({ label: String(n), value: n }));
+const eventHandleStatusOptions = [
+  { label: '全部', value: '' },
+  { label: 'UNREAD', value: 'UNREAD' },
+  { label: 'PROCESSING', value: 'PROCESSING' },
+  { label: 'HANDLED', value: 'HANDLED' },
+  { label: 'IGNORED', value: 'IGNORED' }
+];
+const comparisonOperatorOptions = [
+  { label: '>', value: '>' },
+  { label: '>=', value: '>=' },
+  { label: '<', value: '<' },
+  { label: '<=', value: '<=' },
+  { label: '==', value: '==' }
+];
+const handleStatusOptions = [
+  { label: 'UNREAD', value: 'UNREAD' },
+  { label: 'PROCESSING', value: 'PROCESSING' },
+  { label: 'HANDLED', value: 'HANDLED' },
+  { label: 'IGNORED', value: 'IGNORED' }
+];
 
 const user = reactive({ username: '', role: '', userId: null });
 const userInitial = computed(() => user.username?.[0]?.toUpperCase() || '?');
@@ -1427,8 +1429,3 @@ tbody tr:hover { background: var(--accent-hover); }
   .form-grid { grid-template-columns: 1fr; }
 }
 </style>
-
-
-
-
-

+ 102 - 73
frontend/src/views/ops/AlertRulePage.vue

@@ -39,10 +39,10 @@
                 <td colspan="10" class="empty-row">暂无数据</td>
               </tr>
               <tr v-for="rule in rules" :key="rule.alertRuleId">
-                <td class="id-text">{{ rule.alertRuleId }}</td>
+                <td class="id-text">{{ formatDisplayId(rule.alertRuleId) }}</td>
                 <td>{{ rule.alertRuleName }}</td>
                 <td><span :class="severityClass(rule.severityLevel)">{{ rule.severityLevel }}</span></td>
-                <td>{{ rule.targetId }}</td>
+                <td>{{ formatDisplayId(rule.targetId) }}</td>
                 <td class="metric-cell">{{ rule.metricName }}</td>
                 <td>{{ rule.comparisonOperator }} {{ rule.thresholdValue }}</td>
                 <td>{{ rule.durationSeconds }}s</td>
@@ -69,9 +69,7 @@
         <div class="pagination">
           <label>
             每页
-            <select v-model.number="pageSize" @change="handleSearch">
-              <option v-for="n in [10, 20, 50]" :key="n" :value="n">{{ n }}</option>
-            </select>
+            <UiSelect v-model="pageSize" compact :options="pageSizeOptions" @change="handleSearch" />
             条
           </label>
           <div class="pagination-right">
@@ -93,10 +91,14 @@
             <input v-model.trim="form.alertRuleName" type="text" placeholder="例如 cpu_high" />
           </div>
           <div class="form-field">
+            <label>目标 ID <span class="required">*</span></label>
+            <input v-model.number="form.targetId" type="number" placeholder="例如 3001" />
+          </div>
+          <div class="form-field form-field-full">
             <label>严重级别 <span class="required">*</span></label>
-            <div class="severity-selector">
-              <div 
-                v-for="level in ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']" 
+            <div class="severity-selector severity-selector-compact">
+              <div
+                v-for="level in ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']"
                 :key="level"
                 class="severity-option"
                 :class="[level.toLowerCase(), { active: form.severityLevel === level }]"
@@ -107,39 +109,25 @@
               </div>
             </div>
           </div>
-          <div class="form-field">
-            <label>目标 ID <span class="required">*</span></label>
-            <input v-model.number="form.targetId" type="number" placeholder="例如 3001" />
-          </div>
-          <div class="form-field">
-            <label>指标名称 <span class="required">*</span></label>
-            <input v-model.trim="form.metricName" type="text" placeholder="例如 node_cpu_usage" />
-          </div>
-          <div class="form-field">
-            <label>比较运算符 <span class="required">*</span></label>
-            <select v-model="form.comparisonOperator">
-              <option value=">">></option>
-              <option value=">=">>=</option>
-              <option value="<"><</option>
-              <option value="<="><=</option>
-              <option value="==">==</option>
-            </select>
-          </div>
-          <div class="form-field">
-            <label>阈值 <span class="required">*</span></label>
-            <input v-model.number="form.thresholdValue" type="number" step="any" placeholder="例如 0.9" />
-          </div>
-          <div class="form-field">
-            <label>持续时长(秒) <span class="required">*</span></label>
-            <input v-model.number="form.durationSeconds" type="number" placeholder="例如 120" />
-          </div>
-          <div class="form-field">
-            <label>通知组 ID <span class="required">*</span></label>
-            <input v-model.number="form.notifierId" type="number" placeholder="例如 1001" />
+          <div class="form-field form-field-full">
+            <div class="metric-row">
+              <div class="form-field">
+                <label>指标名称 <span class="required">*</span></label>
+                <input v-model.trim="form.metricName" type="text" placeholder="例如 node_cpu_usage" />
+              </div>
+              <div class="form-field form-field-operator">
+                <label>运算符 <span class="required">*</span></label>
+                <UiSelect v-model="form.comparisonOperator" :options="comparisonOperatorOptions" />
+              </div>
+              <div class="form-field">
+                <label>阈值 <span class="required">*</span></label>
+                <input v-model.number="form.thresholdValue" type="number" step="any" placeholder="例如 0.9" />
+              </div>
+            </div>
           </div>
           <div class="form-field form-field-full">
-            <label>通知渠道 <span class="required">*</span></label>
-            <div class="channel-selector">
+            <label>通知方式 <span class="required">*</span></label>
+            <div class="channel-selector channel-selector-compact">
               <div 
                 v-for="opt in NOTIFY_CHANNEL_OPTIONS" 
                 :key="opt"
@@ -164,7 +152,15 @@
               </div>
             </div>
           </div>
-          <div class="form-field form-field-full">
+          <div class="form-field">
+            <label>持续时长(秒) <span class="required">*</span></label>
+            <input v-model.number="form.durationSeconds" type="number" placeholder="例如 120" />
+          </div>
+          <div class="form-field">
+            <label>通知组 ID <span class="required">*</span></label>
+            <input v-model.number="form.notifierId" type="number" placeholder="例如 1001" />
+          </div>
+          <div class="form-field">
             <label>静默开始(可选)</label>
             <VueDatePicker
               v-model="form.silenceStart"
@@ -179,7 +175,7 @@
               :action-row="datePickerActionRow"
             />
           </div>
-          <div class="form-field form-field-full">
+          <div class="form-field">
             <label>静默结束(可选)</label>
             <VueDatePicker
               v-model="form.silenceEnd"
@@ -194,12 +190,6 @@
               :action-row="datePickerActionRow"
             />
           </div>
-          <div class="form-field form-field-full">
-            <label class="checkbox-label">
-              <input v-model="form.enabled" type="checkbox" />
-              启用规则
-            </label>
-          </div>
         </div>
         <div v-if="dialog.error" class="error-banner"><span>{{ dialog.error }}</span><button class="error-banner-close" @click="dialog.error = ''">&times;</button></div>
         <div class="modal-actions">
@@ -214,9 +204,10 @@
 </template>
 
 <script setup>
-import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
+import { onMounted, onUnmounted, reactive, ref } from 'vue';
 import { useRouter } from 'vue-router';
 import Sidebar from '../../components/Sidebar.vue';
+import UiSelect from '../../components/UiSelect.vue';
 import { VueDatePicker } from '@vuepic/vue-datepicker';
 import '@vuepic/vue-datepicker/dist/main.css';
 import { zhCN } from 'date-fns/locale';
@@ -225,6 +216,7 @@ import {
 } from '../../api/alert';
 import { getMe } from '../../api/auth';
 import { strongConfirm } from '../../utils/strongConfirm';
+import { formatDisplayId } from '../../utils/displayId';
 
 const router = useRouter();
 
@@ -234,6 +226,14 @@ const loading = ref(false);
 const errorMessage = ref('');
 const successMessage = ref('');
 const rules = ref([]);
+const pageSizeOptions = [10, 20, 50].map(n => ({ label: String(n), value: n }));
+const comparisonOperatorOptions = [
+  { label: '>', value: '>' },
+  { label: '>=', value: '>=' },
+  { label: '<', value: '<' },
+  { label: '<=', value: '<=' },
+  { label: '==', value: '==' }
+];
 const NOTIFY_CHANNEL_OPTIONS = ['EMAIL', 'SMS', 'IM'];
 const datePickerFloating = Object.freeze({ strategy: 'fixed' });
 const datePickerLocale = zhCN;
@@ -328,7 +328,7 @@ function emptyForm() {
     notifyChannel: ['EMAIL'],
     silenceStart: null,
     silenceEnd: null,
-    enabled: true
+    enabled: false
   };
 }
 
@@ -744,7 +744,7 @@ tbody tr:hover { background: var(--accent-hover); }
 
 .form-grid {
   display: grid;
-  grid-template-columns: 1fr 1fr;
+  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
   gap: 14px;
 }
 
@@ -752,10 +752,25 @@ tbody tr:hover { background: var(--accent-hover); }
   display: flex;
   flex-direction: column;
   gap: 6px;
+  min-width: 0;
 }
 
 .form-field-full { grid-column: 1 / -1; }
 
+.metric-row {
+  display: grid;
+  grid-template-columns: minmax(0, 1.4fr) minmax(110px, 0.7fr) minmax(0, 1fr);
+  gap: 14px;
+}
+
+.metric-row .form-field {
+  min-width: 0;
+}
+
+.form-field-operator {
+  min-width: 110px;
+}
+
 .form-field label {
   font-size: 13px;
   font-weight: 500;
@@ -778,6 +793,22 @@ tbody tr:hover { background: var(--accent-hover); }
 .form-field input:focus,
 .form-field select:focus { outline: none; border-color: var(--accent); }
 
+:deep(.dp__main),
+:global(.dp__main) {
+  width: 100%;
+}
+
+:deep(.dp__input_wrap),
+:global(.dp__input_wrap) {
+  width: 100%;
+}
+
+:deep(.dp__input),
+:global(.dp__input) {
+  width: 100%;
+  box-sizing: border-box;
+}
+
 :deep(.dp__theme_light),
 :global(.dp__theme_light) {
   --dp-primary-color: #409eff;
@@ -811,6 +842,10 @@ tbody tr:hover { background: var(--accent-hover); }
   gap: 8px;
 }
 
+.severity-selector-compact .severity-option {
+  padding-block: 9px;
+}
+
 .severity-option {
   flex: 1;
   padding: 10px 8px;
@@ -881,13 +916,17 @@ tbody tr:hover { background: var(--accent-hover); }
   gap: 12px;
 }
 
+.channel-selector-compact {
+  gap: 8px;
+}
+
 .channel-item {
   position: relative;
   display: flex;
   flex-direction: column;
   align-items: center;
   justify-content: center;
-  padding: 16px 10px;
+  padding: 12px 8px;
   border: 1px solid var(--border-color);
   border-radius: 10px;
   cursor: pointer;
@@ -911,12 +950,12 @@ tbody tr:hover { background: var(--accent-hover); }
   display: flex;
   flex-direction: column;
   align-items: center;
-  gap: 10px;
+  gap: 8px;
 }
 
 .channel-icon {
-  width: 40px;
-  height: 40px;
+  width: 34px;
+  height: 34px;
   display: flex;
   align-items: center;
   justify-content: center;
@@ -938,13 +977,13 @@ tbody tr:hover { background: var(--accent-hover); }
 }
 
 .channel-label {
-  font-size: 13px;
+  font-size: 12px;
   font-weight: 600;
   color: var(--text-main);
 }
 
 .channel-desc {
-  font-size: 11px;
+  font-size: 10px;
   color: var(--text-muted);
   line-height: 1.2;
 }
@@ -964,23 +1003,6 @@ tbody tr:hover { background: var(--accent-hover); }
   box-shadow: 0 2px 4px rgba(64, 158, 255, 0.3);
 }
 
-.checkbox-label {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  cursor: pointer;
-  font-size: 13px;
-  font-weight: 500;
-  color: var(--text-main);
-}
-
-.checkbox-label input[type="checkbox"] {
-  padding: 0;
-  width: 16px;
-  height: 16px;
-  accent-color: var(--accent);
-}
-
 .modal-actions {
   display: flex;
   gap: 8px;
@@ -1001,6 +1023,7 @@ tbody tr:hover { background: var(--accent-hover); }
   .panel { padding: 16px; }
   .panel-header { flex-direction: column; align-items: flex-start; gap: 10px; }
   .form-grid { grid-template-columns: 1fr; }
+  .metric-row { grid-template-columns: 1fr; }
 }
 
 @media (max-width: 768px) {
@@ -1032,6 +1055,9 @@ tbody tr:hover { background: var(--accent-hover); }
   .form-grid {
     grid-template-columns: 1fr !important;
   }
+  .metric-row {
+    grid-template-columns: 1fr !important;
+  }
   .badge {
     font-size: 11px !important;
     padding: 2px 6px !important;
@@ -1050,5 +1076,8 @@ tbody tr:hover { background: var(--accent-hover); }
   .channel-selector {
     grid-template-columns: 1fr !important;
   }
+  .metric-row {
+    gap: 10px;
+  }
 }
 </style>

+ 67 - 78
frontend/src/views/ops/AuditLogPage.vue

@@ -43,31 +43,18 @@
             />
           </div>
           <div class="filter-item">
-            <label>操作类型</label>
-            <select v-model="filters.actionType">
-              <option value="">全部</option>
-              <option v-for="t in availableActionTypes" :key="t" :value="t">{{ t }}</option>
-            </select>
-          </div>
-          <div class="filter-item">
-            <label>对象类型</label>
-            <select v-model="filters.targetType">
-              <option value="">全部</option>
-              <option v-for="t in availableTargetTypes" :key="t" :value="t">{{ t }}</option>
-            </select>
+            <label>类型</label>
+            <UiCascadeSelect
+              v-model:targetType="filters.targetType"
+              v-model:actionType="filters.actionType"
+              :groups="typeCascadeOptions"
+              placeholder="类型"
+            />
           </div>
           <div class="filter-item">
             <label>操作人 ID</label>
             <input v-model.trim="filters.operatorId" type="text" placeholder="例如 1001" />
           </div>
-          <div class="filter-item">
-            <label>结果状态</label>
-            <select v-model="filters.resultStatus">
-              <option value="">全部</option>
-              <option value="SUCCESS">成功</option>
-              <option value="FAILED">失败</option>
-            </select>
-          </div>
           <div class="filter-actions">
             <button class="btn btn-primary" :disabled="loading" @click="handleSearch">查询</button>
             <button class="btn btn-default" :disabled="loading" @click="handleReset">重置</button>
@@ -99,12 +86,12 @@
                 <td colspan="8" class="empty-row">暂无数据</td>
               </tr>
               <tr v-for="item in logs" :key="item.logId">
-                <td class="id-text">{{ item.logId }}</td>
+                <td class="id-text">{{ formatDisplayId(item.logId) }}</td>
                 <td>{{ formatDisplayTime(item.actionTime) }}</td>
                 <td>{{ item.actionType }}</td>
                 <td>{{ item.targetType }}</td>
-                <td>{{ item.targetId ?? '-' }}</td>
-                <td>{{ item.operatorId ?? '-' }}</td>
+                <td>{{ formatDisplayId(item.targetId) }}</td>
+                <td>{{ formatDisplayId(item.operatorId) }}</td>
                 <td>
                   <span :class="item.resultStatus === 'SUCCESS' ? 'status-badge status-success' : 'status-badge status-failed'">
                     {{ auditResultStatusLabel(item.resultStatus) }}
@@ -119,9 +106,7 @@
         <div class="pagination">
           <label>
             每页
-            <select v-model.number="pageSize" @change="handleSearch">
-              <option v-for="item in [10, 20, 50]" :key="item" :value="item">{{ item }}</option>
-            </select>
+            <UiSelect v-model="pageSize" compact :options="pageSizeOptions" @change="handleSearch" />
             条
           </label>
           <div class="pagination-right">
@@ -136,15 +121,15 @@
 </template>
 
 <script setup>
-import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
-import { useRouter } from 'vue-router';
+import { onMounted, reactive, ref } from 'vue';
 import { VueDatePicker } from '@vuepic/vue-datepicker';
 import '@vuepic/vue-datepicker/dist/main.css';
 import { zhCN } from 'date-fns/locale';
 import Sidebar from '../../components/Sidebar.vue';
+import UiCascadeSelect from '../../components/UiCascadeSelect.vue';
+import UiSelect from '../../components/UiSelect.vue';
 import { exportAuditLogs, queryAuditLogs } from '../../api/auditLog';
-
-const router = useRouter();
+import { formatDisplayId } from '../../utils/displayId';
 
 const loading = ref(false);
 const exporting = ref(false);
@@ -156,52 +141,43 @@ const pageSize = ref(10);
 const datePickerFloating = Object.freeze({ strategy: 'fixed' });
 const datePickerLocale = zhCN;
 const datePickerActionRow = Object.freeze({ selectBtnLabel: '确定', cancelBtnLabel: '取消' });
+const pageSizeOptions = [10, 20, 50].map(item => ({ label: String(item), value: item }));
+
+const actionTypesByTargetType = Object.freeze({
+  WORKFLOW: ['WORKFLOW_SUBMIT', 'WORKFLOW_UPDATE', 'WORKFLOW_DELETE'],
+  WORKFLOW_INSTANCE: ['WORKFLOW_TERMINATE'],
+  TASK_INSTANCE: ['TASK_UPDATE'],
+  CLUSTER: [
+    'CLUSTER_CREATE',
+    'CLUSTER_START',
+    'CLUSTER_STOP',
+    'CLUSTER_RESTART',
+    'CLUSTER_DELETE',
+    'CLUSTER_SCALE_OUT',
+    'CLUSTER_SCALE_IN'
+  ],
+  CLUSTER_CONFIG: ['CLUSTER_CONFIG_COMMIT', 'CLUSTER_CONFIG_ROLLBACK'],
+  STOCK_MACHINE: ['STOCK_CREATE', 'STOCK_UPDATE', 'STOCK_DELETE', 'STOCK_SSH_EXECUTE'],
+  SCALING_RULE: ['SCALING_RULE_CREATE', 'SCALING_RULE_DELETE'],
+  ALERT_RULE: ['ALERT_RULE_CREATE', 'ALERT_RULE_UPDATE', 'ALERT_RULE_DELETE', 'ALERT_RULE_TOGGLE'],
+  ALERT_EVENT: ['ALERT_EVENT_HANDLE']
+});
 
-const availableActionTypes = [
-  'CLUSTER_CREATE',
-  'CLUSTER_START',
-  'CLUSTER_STOP',
-  'CLUSTER_RESTART',
-  'CLUSTER_DELETE',
-  'WORKFLOW_SUBMIT',
-  'WORKFLOW_DISPATCH',
-  'TASK_RETRY',
-  'CONFIG_UPDATE',
-  'CONFIG_ROLLBACK',
-  'INSTANCE_START',
-  'INSTANCE_STOP',
-  'INSTANCE_RESTART',
-  'CLUSTER_SCALE_OUT',
-  'CLUSTER_SCALE_IN',
-  'STOCK_CREATE',
-  'STOCK_UPDATE',
-  'STOCK_DELETE',
-  'SCALING_RULE_CREATE',
-  'SCALING_RULE_DELETE',
-  'ALERT_RULE_CREATE',
-  'ALERT_RULE_DELETE',
-  'ALERT_RULE_TOGGLE',
-  'ALERT_EVENT_HANDLE'
-];
-
-const availableTargetTypes = [
-  'WORKFLOW',
-  'WORKFLOW_INSTANCE',
-  'TASK_INSTANCE',
-  'CLUSTER',
-  'STOCK_MACHINE',
-  'SCALING_RULE',
-  'ALERT_RULE',
-  'ALERT_EVENT'
-];
+const typeCascadeOptions = Object.entries(actionTypesByTargetType).map(([targetType, actionTypes]) => ({
+  label: targetType,
+  value: targetType,
+  children: actionTypes.map(actionType => ({
+    label: actionType,
+    value: actionType
+  }))
+}));
 
 const filters = reactive({
   startTime: null,
   endTime: null,
   actionType: '',
   targetType: '',
-  operatorId: '',
-  resultStatus: ''
+  operatorId: ''
 });
 
 function toISOString(date) {
@@ -217,7 +193,6 @@ function buildParams() {
     actionType: filters.actionType || undefined,
     targetType: filters.targetType || undefined,
     operatorId: filters.operatorId ? Number(filters.operatorId) || undefined : undefined,
-    resultStatus: filters.resultStatus || undefined,
     page: page.value,
     size: pageSize.value
   };
@@ -250,7 +225,6 @@ function handleReset() {
   filters.actionType = '';
   filters.targetType = '';
   filters.operatorId = '';
-  filters.resultStatus = '';
   page.value = 1;
   pageSize.value = 10;
   fetchLogs();
@@ -387,8 +361,9 @@ onMounted(() => {
 
 .filter-grid {
   display: grid;
-  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+  grid-template-columns: 200px 200px minmax(340px, 1fr) 140px auto;
   gap: 14px;
+  align-items: end;
 }
 
 .filter-item {
@@ -417,6 +392,26 @@ onMounted(() => {
   accent-color: var(--accent);
 }
 
+.filter-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  justify-self: start;
+  align-self: end;
+}
+
+@media (max-width: 1280px) {
+  .filter-grid {
+    grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+    align-items: stretch;
+  }
+
+  .filter-actions {
+    justify-self: stretch;
+    justify-content: flex-start;
+  }
+}
+
 /* VueDatePicker 主题色覆盖(组件根和 teleport 到 body 的弹层) */
 :deep(.dp__theme_light),
 :global(.dp__theme_light) {
@@ -452,12 +447,6 @@ onMounted(() => {
   border-color: var(--accent);
 }
 
-.filter-actions {
-  display: flex;
-  align-items: flex-end;
-  gap: 8px;
-}
-
 /* 表格区:不设 overflow,保证 sticky 生效 */
 .table-panel {
   display: flex;

+ 27 - 23
frontend/src/views/resource-management/ClusterConsolePage.vue

@@ -6,7 +6,7 @@
       <div class="breadcrumb">
         <span class="breadcrumb-link" @click="router.push('/resource/clusters')">大数据集群</span>
         <span class="breadcrumb-sep">›</span>
-        <span>{{ cluster.clusterName || clusterId }}</span>
+        <span>{{ cluster.clusterName || formatDisplayId(clusterId) }}</span>
       </div>
 
       <div class="tab-bar">
@@ -64,7 +64,7 @@
         <div class="info-grid">
           <div class="info-item">
             <span class="info-label">集群 ID</span>
-            <span class="info-value id-text">{{ cluster.clusterId }}</span>
+            <span class="info-value id-text">{{ formatDisplayId(cluster.clusterId) }}</span>
           </div>
           <div class="info-item">
             <span class="info-label">组件类型</span>
@@ -134,7 +134,7 @@
               </thead>
               <tbody>
                 <tr v-for="node in sparkNodes" :key="node.nodeId">
-                  <td class="id-text">{{ node.nodeId }}</td>
+                  <td class="id-text">{{ formatDisplayId(node.nodeId) }}</td>
                   <td class="mono-text">{{ node.ipAddress || '-' }}</td>
                   <td class="mono-text">{{ node.cpuCores ?? '-' }}</td>
                   <td class="mono-text">{{ node.ramGb ?? '-' }}</td>
@@ -167,7 +167,7 @@
                 </thead>
                 <tbody>
                   <tr v-for="node in frontNodes" :key="node.nodeId">
-                    <td class="id-text">{{ node.nodeId }}</td>
+                    <td class="id-text">{{ formatDisplayId(node.nodeId) }}</td>
                     <td class="mono-text">{{ node.ipAddress || '-' }}</td>
                     <td class="mono-text">{{ node.cpuCores ?? '-' }}</td>
                     <td class="mono-text">{{ node.ramGb ?? '-' }}</td>
@@ -205,7 +205,7 @@
                 </thead>
                 <tbody>
                   <tr v-for="node in backNodes" :key="node.nodeId">
-                    <td class="id-text">{{ node.nodeId }}</td>
+                    <td class="id-text">{{ formatDisplayId(node.nodeId) }}</td>
                     <td class="mono-text">{{ node.ipAddress || '-' }}</td>
                     <td class="mono-text">{{ node.cpuCores ?? '-' }}</td>
                     <td class="mono-text">{{ node.ramGb ?? '-' }}</td>
@@ -235,7 +235,7 @@
             </thead>
             <tbody>
               <tr v-for="node in clusterNodes" :key="node.nodeId">
-                <td class="id-text">{{ node.nodeId }}</td>
+                <td class="id-text">{{ formatDisplayId(node.nodeId) }}</td>
                 <td class="mono-text">{{ node.nodeRole || '-' }}</td>
                 <td class="mono-text">{{ node.ipAddress || '-' }}</td>
                 <td class="mono-text">{{ node.cpuCores ?? '-' }}</td>
@@ -369,7 +369,7 @@
                 </thead>
                 <tbody>
                   <tr v-for="rule in scalingRules" :key="rule.scaleRuleId">
-                    <td style="color: var(--text-muted);">{{ rule.scaleRuleId }}</td>
+                    <td style="color: var(--text-muted);">{{ formatDisplayId(rule.scaleRuleId) }}</td>
                     <td class="rule-name-td" :title="rule.scaleRuleName">{{ rule.scaleRuleName }}</td>
                     <td>
                       <span :class="['rule-tag', rule.triggerType === 'METRIC' ? 'tag-metric' : 'tag-cron']">
@@ -492,12 +492,7 @@
               <div class="form-row">
                 <div class="form-item">
                   <label>指标名称 <span class="required">*</span></label>
-                  <select v-model="ruleForm.metricName">
-                    <option value="cpu_usage">CPU 使用率</option>
-                    <option value="memory_usage">内存使用率</option>
-                    <option value="disk_usage">磁盘使用率</option>
-                    <option value="task_queue_size">任务队列长度</option>
-                  </select>
+                  <UiSelect v-model="ruleForm.metricName" :options="metricNameOptions" />
                 </div>
                 <div class="form-item">
                   <label>触发持续时间 (秒) <span class="required">*</span></label>
@@ -507,13 +502,7 @@
               <div class="form-row">
                 <div class="form-item">
                   <label>比较运算符 <span class="required">*</span></label>
-                  <select v-model="ruleForm.comparisonOperator">
-                    <option value=">=">>=</option>
-                    <option value=">">></option>
-                    <option value="<="><=</option>
-                    <option value="<"><</option>
-                    <option value="==">=</option>
-                  </select>
+                  <UiSelect v-model="ruleForm.comparisonOperator" :options="comparisonOperatorOptions" />
                 </div>
                 <div class="form-item">
                   <label>阈值 <span class="required">*</span></label>
@@ -577,7 +566,7 @@
               <div v-else class="machine-list-compact">
                 <label v-for="m in idleMachines" :key="m.machineId" class="machine-item-compact">
                   <input type="checkbox" :value="m.machineId" v-model="scaleForm.selectedMachineIds" />
-                  <span class="mono-text">#{{ m.machineId }} - {{ m.ipAddress }} ({{ m.cpuCores }}核/{{ m.ramGb }}GB)</span>
+                  <span class="mono-text">{{ formatDisplayId(m.machineId) }} - {{ m.ipAddress }} ({{ m.cpuCores }}核/{{ m.ramGb }}GB)</span>
                 </label>
               </div>
             </div>
@@ -625,7 +614,7 @@
               <div v-else class="machine-list-compact">
                 <label v-for="n in scalableNodes" :key="n.nodeId" class="machine-item-compact">
                   <input type="checkbox" :value="n.nodeId" v-model="scaleForm.selectedNodeIds" />
-                  <span class="mono-text">ID:{{ n.nodeId }} - {{ n.ipAddress }} ({{ n.nodeRole }})</span>
+                  <span class="mono-text">{{ formatDisplayId(n.nodeId) }} - {{ n.ipAddress }} ({{ n.nodeRole }})</span>
                 </label>
               </div>
               <div class="form-hint">请勾选需要移除的节点。缩容后系统必须保留至少 1 个节点。</div>
@@ -688,7 +677,9 @@
 import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import Sidebar from '../../components/Sidebar.vue';
+import UiSelect from '../../components/UiSelect.vue';
 import { strongConfirm } from '../../utils/strongConfirm';
+import { formatDisplayId } from '../../utils/displayId';
 import {
   startCluster, stopCluster, restartCluster, destroyCluster,
   getCluster, updateCluster, listClusterNodes,
@@ -703,6 +694,19 @@ import cronstrue from 'cronstrue/i18n';
 const router = useRouter();
 const route = useRoute();
 const clusterId = Number(route.params.clusterId);
+const metricNameOptions = [
+  { label: 'CPU 使用率', value: 'cpu_usage' },
+  { label: '内存使用率', value: 'memory_usage' },
+  { label: '磁盘使用率', value: 'disk_usage' },
+  { label: '任务队列长度', value: 'task_queue_size' }
+];
+const comparisonOperatorOptions = [
+  { label: '>=', value: '>=' },
+  { label: '>', value: '>' },
+  { label: '<=', value: '<=' },
+  { label: '<', value: '<' },
+  { label: '=', value: '==' }
+];
 
 const activeTab = ref('detail');
 
@@ -800,7 +804,7 @@ async function fetchCluster() {
     const { data } = await getCluster(clusterId);
     Object.assign(cluster, data || {});
     if (isCreatingStatus(cluster.status)) {
-      const clusterName = cluster.clusterName || `#${clusterId}`;
+      const clusterName = cluster.clusterName || formatDisplayId(clusterId);
       await router.replace({
         path: '/resource/clusters',
         query: { message: `集群「${clusterName}」创建中,控制台暂不可打开` }

+ 18 - 9
frontend/src/views/resource-management/ClusterPage.vue

@@ -35,7 +35,7 @@
           <div class="card-info">
             <div class="info-row">
               <span class="info-label">集群 ID:</span>
-              <span class="info-value id-text">{{ cluster.clusterId }}</span>
+              <span class="info-value id-text">{{ formatDisplayId(cluster.clusterId) }}</span>
             </div>
             <div class="info-row">
               <span class="info-label">版本:</span>
@@ -138,12 +138,12 @@
               </div>
               <div class="form-item">
                 <label>版本</label>
-                <select v-model="createForm.version" :disabled="loadingComponentVersions">
-                  <option v-if="versionsForCurrentEngine.length === 0" value="" disabled>无可用版本</option>
-                  <option v-for="version in versionsForCurrentEngine" :key="version" :value="version">
-                    {{ version }}
-                  </option>
-                </select>
+                <UiSelect
+                  v-model="createForm.version"
+                  :options="versionSelectOptions"
+                  :disabled="versionSelectDisabled"
+                  placeholder="请选择版本"
+                />
               </div>
               <div class="form-item full-width">
                 <label>描述</label>
@@ -225,7 +225,7 @@
                       :disabled="!isChecked(role, m.machineId) && isFull(role)"
                       @change="toggleMachine(role, m.machineId, $event.target.checked)"
                     />
-                    <span class="machine-info">#{{ m.machineId }} · {{ m.ipAddress }}({{ m.cpuCores }}核 / {{ m.ramGb }}GB / SSD {{ m.ssdGb ?? '-' }}GB)</span>
+                    <span class="machine-info">{{ formatDisplayId(m.machineId) }} · {{ m.ipAddress }}({{ m.cpuCores }}核 / {{ m.ramGb }}GB / SSD {{ m.ssdGb ?? '-' }}GB)</span>
                   </label>
                   <div v-if="availableFor(role).length === 0" class="no-machines-tip">无可用机器</div>
                 </div>
@@ -255,7 +255,9 @@
 import { computed, onMounted, reactive, ref, watch } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import Sidebar from '../../components/Sidebar.vue';
+import UiSelect from '../../components/UiSelect.vue';
 import { strongConfirm } from '../../utils/strongConfirm';
+import { formatDisplayId } from '../../utils/displayId';
 import {
   listClusters, listClusterComponentVersions, createCluster, startCluster, stopCluster, restartCluster, destroyCluster
 } from '../../api/cluster';
@@ -364,7 +366,7 @@ async function fetchComponentVersions() {
 
 function goToConsole(cluster) {
   if (isCreatingStatus(cluster?.status)) {
-    errorMessage.value = `集群「${cluster?.clusterName || cluster?.clusterId}」创建中,控制台暂不可打开`;
+    errorMessage.value = `集群「${cluster?.clusterName || formatDisplayId(cluster?.clusterId)}」创建中,控制台暂不可打开`;
     return;
   }
   router.push({ path: `/resource/clusters/${cluster.clusterId}` });
@@ -443,6 +445,13 @@ const componentVersionMap = reactive({});
 
 const currentEngine = computed(() => ENGINES.find(e => e.type === selectedEngine.value) || ENGINES[0]);
 const versionsForCurrentEngine = computed(() => getVersionsForEngine(currentEngine.value.type));
+const versionSelectOptions = computed(() => {
+  if (versionsForCurrentEngine.value.length === 0) {
+    return [{ label: '无可用版本', value: '', disabled: true }];
+  }
+  return versionsForCurrentEngine.value.map(version => ({ label: version, value: version }));
+});
+const versionSelectDisabled = computed(() => loadingComponentVersions.value || versionsForCurrentEngine.value.length === 0);
 
 function getVersionsForEngine(componentType) {
   const dynamicVersions = componentVersionMap[componentType];

+ 2 - 1
frontend/src/views/resource-management/StockConsolePage.vue

@@ -13,7 +13,7 @@
         <div class="meta-grid">
           <div class="meta-item">
             <label>机器 ID</label>
-            <span>{{ machineId }}</span>
+            <span>{{ formatDisplayId(machineId) }}</span>
           </div>
           <div class="meta-item">
             <label>IP 地址</label>
@@ -46,6 +46,7 @@ import { Terminal } from 'xterm';
 import { FitAddon } from '@xterm/addon-fit';
 import 'xterm/css/xterm.css';
 import Sidebar from '../../components/Sidebar.vue';
+import { formatDisplayId } from '../../utils/displayId';
 
 const route = useRoute();
 const router = useRouter();

+ 93 - 40
frontend/src/views/resource-management/StockPage.vue

@@ -17,12 +17,7 @@
         <div class="filter-grid">
           <div class="filter-item">
             <label>状态</label>
-            <select v-model="filters.status">
-              <option value="">全部</option>
-              <option value="IDLE">空闲</option>
-              <option value="WORKING">工作中</option>
-              <option value="OFFLINE">离线</option>
-            </select>
+            <UiSelect v-model="filters.status" :options="stockFilterStatusOptions" placeholder="全部" />
           </div>
           <div class="filter-item">
             <label>IP 地址</label>
@@ -50,7 +45,7 @@
                 <th>所属集群ID</th>
                 <th>状态</th>
                 <th class="col-remark">备注</th>
-                <th>操作</th>
+                <th class="action-column">操作</th>
               </tr>
             </thead>
             <tbody>
@@ -61,7 +56,7 @@
                 <td colspan="10" class="empty-row">暂无数据</td>
               </tr>
               <tr v-for="item in table.list" :key="item.machineId">
-                <td class="id-text">{{ item.machineId }}</td>
+                <td class="id-text">{{ formatDisplayId(item.machineId) }}</td>
                 <td class="ip-text">{{ item.ipAddress }}</td>
                 <td>{{ item.cpuCores }}</td>
                 <td>{{ item.cpuModel }}</td>
@@ -73,7 +68,7 @@
                     class="cluster-link-btn"
                     @click="goToClusterConsole(clusterIdByMachineId[item.machineId])"
                   >
-                    {{ clusterIdByMachineId[item.machineId] }}
+                    {{ formatDisplayId(clusterIdByMachineId[item.machineId]) }}
                   </button>
                   <span v-else>-</span>
                 </td>
@@ -92,8 +87,8 @@
                   >编辑</button>
                   <button
                     class="btn btn-danger btn-sm"
-                    :disabled="item.status === 'WORKING'"
-                    :title="item.status === 'WORKING' ? '工作中状态不可删除' : ''"
+                    :disabled="item.status === 'WORKING' || item.status === 'JOINING'"
+                    :title="item.status === 'WORKING' || item.status === 'JOINING' ? '工作中或加入中状态不可删除' : ''"
                     @click="handleDelete(item)"
                   >删除</button>
                 </td>
@@ -103,9 +98,7 @@
         </div>
         <div class="pagination">
           <label>每页
-            <select v-model.number="table.pageSize" @change="search">
-              <option v-for="n in [10, 20, 50]" :key="n" :value="n">{{ n }}</option>
-            </select>
+            <UiSelect v-model="table.pageSize" compact class="page-size-select" :options="pageSizeOptions" @change="search" />
             条
           </label>
           <div class="pagination-right">
@@ -130,24 +123,21 @@
             </div>
             <div class="form-field">
               <label>SSH 用户名 <span class="required">*</span></label>
-              <input v-model.trim="form.sshUsername" type="text" placeholder="例如 root / ubuntu" autocomplete="off" :disabled="isWorkingEdit" />
+              <input v-model.trim="form.sshUsername" type="text" placeholder="例如 root / ubuntu" autocomplete="off" :disabled="isManagedLifecycleEdit" />
             </div>
             <div class="form-field">
               <label>
                 SSH 密码<span v-if="dialog.isEdit">(留空不修改)</span><span v-else class="required">*</span>
               </label>
-              <input v-model.trim="form.sshPassword" type="password" placeholder="输入 SSH 密码" autocomplete="new-password" :disabled="isWorkingEdit" />
+              <input v-model.trim="form.sshPassword" type="password" placeholder="输入 SSH 密码" autocomplete="new-password" :disabled="isManagedLifecycleEdit" />
             </div>
             <div class="form-field">
               <label>备注</label>
               <input v-model.trim="form.description" type="text" placeholder="例如 rack-a-01" maxlength="50" />
             </div>
-            <div v-if="dialog.isEdit && !isWorkingEdit" class="form-field">
+            <div v-if="dialog.isEdit && !isManagedLifecycleEdit" class="form-field">
               <label>状态</label>
-              <select v-model="form.status">
-                <option value="IDLE">空闲</option>
-                <option value="OFFLINE">离线</option>
-              </select>
+              <UiSelect v-model="form.status" :options="stockEditableStatusOptions" />
             </div>
           </div>
           <div class="form-col">
@@ -159,24 +149,24 @@
               >{{ probe.loading ? '获取中...' : '自动获取以下配置' }}</button>
             </div>
             <div class="form-field">
-              <label>CPU 型号</label>
-              <input v-model.trim="form.cpuModel" type="text" placeholder="例如 Intel" :disabled="isWorkingEdit" />
+              <label>CPU 型号 <span class="required">*</span></label>
+              <input v-model.trim="form.cpuModel" type="text" placeholder="例如 Intel" :disabled="isManagedLifecycleEdit" />
             </div>
             <div class="form-field">
               <label>CPU 核心数 <span class="required">*</span></label>
-              <input v-model.number="form.cpuCores" type="number" min="4" max="127" step="4" placeholder="例如 8" :disabled="isWorkingEdit" />
+              <input v-model.number="form.cpuCores" type="number" min="4" max="127" step="4" placeholder="例如 8" :disabled="isManagedLifecycleEdit" />
             </div>
             <div class="form-field">
               <label>内存 (GB) <span class="required">*</span></label>
-              <input v-model.number="form.ramGb" type="number" min="4" step="4" placeholder="例如 320" :disabled="isWorkingEdit" />
+              <input v-model.number="form.ramGb" type="number" min="4" step="4" placeholder="例如 320" :disabled="isManagedLifecycleEdit" />
             </div>
             <div class="form-field">
               <label>SSD (GB) <span class="required">*</span></label>
-              <input v-model.number="form.ssdGb" type="number" min="1024" step="1024" placeholder="例如 2048" :disabled="isWorkingEdit" />
+              <input v-model.number="form.ssdGb" type="number" min="1024" step="1024" placeholder="例如 2048" :disabled="isManagedLifecycleEdit" />
             </div>
           </div>
         </div>
-        <div v-if="isWorkingEdit" class="tips-banner">当前机器为工作中状态,仅可修改备注。</div>
+        <div v-if="isManagedLifecycleEdit" class="tips-banner">当前机器为工作中或加入中状态,仅可修改备注。</div>
         <div v-if="dialog.error" class="error-banner"><span>{{ dialog.error }}</span><button class="error-banner-close" @click="dialog.error = ''">&times;</button></div>
         <div class="modal-actions">
           <button class="btn btn-primary" :disabled="dialog.submitting" @click="submitForm">
@@ -193,12 +183,29 @@
 import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
 import { useRouter } from 'vue-router';
 import Sidebar from '../../components/Sidebar.vue';
+import UiSelect from '../../components/UiSelect.vue';
 import { strongConfirm } from '../../utils/strongConfirm';
+import { formatDisplayId } from '../../utils/displayId';
 import { createStock, deleteStock, probeStock, queryStocks, updateStock } from '../../api/stock';
 import { listClusters, listClusterNodes } from '../../api/cluster';
 
 const router = useRouter();
 
+const stockFilterStatusOptions = [
+  { label: '全部', value: '' },
+  { label: '空闲', value: 'IDLE' },
+  { label: '加入中', value: 'JOINING' },
+  { label: '工作中', value: 'WORKING' },
+  { label: '离线', value: 'OFFLINE' }
+];
+
+const stockEditableStatusOptions = [
+  { label: '空闲', value: 'IDLE' },
+  { label: '离线', value: 'OFFLINE' }
+];
+
+const pageSizeOptions = [10, 20, 50].map(item => ({ label: String(item), value: item }));
+
 const filters = reactive({ status: '', ipAddress: '' });
 
 const table = reactive({
@@ -286,7 +293,7 @@ function nextPage() {
 }
 
 async function handleDelete(item) {
-  if (!(await strongConfirm(`确认删除机器 ${item.ipAddress}(ID: ${item.machineId})?`))) return;
+  if (!(await strongConfirm(`确认删除机器 ${item.ipAddress}(${formatDisplayId(item.machineId)})?`))) return;
   table.error = '';
   try {
     await deleteStock(item.machineId);
@@ -325,7 +332,7 @@ function emptyForm() {
 const form = reactive(emptyForm());
 const dialog = reactive({ visible: false, isEdit: false, editId: null, editStatus: null, submitting: false, error: '' });
 const probe = reactive({ loading: false });
-const isWorkingEdit = computed(() => dialog.isEdit && dialog.editStatus === 'WORKING');
+const isManagedLifecycleEdit = computed(() => dialog.isEdit && ['WORKING', 'JOINING'].includes(dialog.editStatus));
 
 function openCreateDialog() {
   Object.assign(form, emptyForm());
@@ -374,7 +381,8 @@ async function autoFetchConfig() {
 }
 
 function validateForm() {
-  if (isWorkingEdit.value) return null;
+  if (form.description && form.description.length > 50) return '备注不能超过 50 个字符';
+  if (isManagedLifecycleEdit.value) return null;
   if (!form.ipAddress) return '请输入 IP 地址';
   if (!isValidIpAddress(form.ipAddress)) return 'IP 地址格式不合法';
   if (!form.sshUsername) return '请输入 SSH 用户名';
@@ -383,7 +391,6 @@ function validateForm() {
   if (!form.ramGb) return '请输入内存大小';
   if (!form.ssdGb) return '请输入 SSD 大小';
   if (!dialog.isEdit && !form.sshPassword) return '请输入 SSH 密码';
-  if (form.description && form.description.length > 50) return '备注不能超过 50 个字符';
   return null;
 }
 
@@ -405,7 +412,7 @@ async function submitForm() {
   try {
     if (dialog.isEdit) {
       let payload = { description: form.description };
-      if (!isWorkingEdit.value) {
+      if (!isManagedLifecycleEdit.value) {
         payload = {
           ipAddress: form.ipAddress,
           cpuCores: form.cpuCores,
@@ -437,20 +444,34 @@ async function submitForm() {
       const { data } = await createStock(payload);
       table.list.unshift(data);
       table.total += 1;
-      showToast('纳管成功');
+      showToast('纳管请求已提交');
     }
     dialog.visible = false;
   } catch (err) {
-    dialog.error = err?.response?.data?.message || '提交失败';
+    dialog.error = resolveSubmitError(err);
   } finally {
     dialog.submitting = false;
   }
 }
 
+function resolveSubmitError(err) {
+  const message = err?.response?.data?.message;
+  if (message) {
+    return message;
+  }
+  const errorCode = err?.code || '';
+  const errorText = String(err?.message || '').toLowerCase();
+  if (errorCode === 'ECONNABORTED' || errorText.includes('timeout')) {
+    return 'SSH连接失败:请求超时,请检查目标机器22端口、SSH用户名和密码后重试。';
+  }
+  return '提交失败';
+}
+
 function statusBadgeClass(status) {
   return {
     'status-badge': true,
     'status-idle': status === 'IDLE',
+    'status-joining': status === 'JOINING',
     'status-working': status === 'WORKING',
     'status-offline': status === 'OFFLINE'
   };
@@ -459,6 +480,7 @@ function statusBadgeClass(status) {
 function stockStatusLabel(status) {
   const mapping = {
     IDLE: '空闲',
+    JOINING: '加入中',
     WORKING: '工作中',
     OFFLINE: '离线'
   };
@@ -624,10 +646,8 @@ onUnmounted(() => {
 }
 
 .filter-item input,
-.filter-item select,
 .form-field input,
-.form-field select,
-.pagination select {
+.pagination input {
   padding: 8px 12px;
   border: 1px solid var(--border-color);
   border-radius: 4px;
@@ -639,10 +659,8 @@ onUnmounted(() => {
 }
 
 .filter-item input:focus,
-.filter-item select:focus,
 .form-field input:focus,
-.form-field select:focus,
-.pagination select:focus {
+.pagination input:focus {
   outline: none;
   border-color: var(--accent);
 }
@@ -668,6 +686,7 @@ onUnmounted(() => {
 }
 
 .table-wrapper { overflow-x: auto; }
+.table-wrapper > table { min-width: 1180px; }
 
 table {
   width: 100%;
@@ -732,8 +751,34 @@ tbody tr:hover { background: var(--accent-hover); }
 
 .action-cell {
   display: flex;
+  align-items: center;
   gap: 6px;
   white-space: nowrap;
+  width: 166px;
+  min-width: 166px;
+  padding-left: 8px;
+  padding-right: 4px;
+  position: sticky;
+  right: 0;
+  z-index: 11;
+  background: var(--panel-bg);
+  box-shadow: -8px 0 10px -10px rgba(31, 35, 41, 0.32);
+}
+
+.action-column {
+  width: 166px;
+  min-width: 166px;
+  padding-left: 8px;
+  padding-right: 4px;
+  position: sticky;
+  right: 0;
+  z-index: 12;
+  background: var(--page-bg);
+  box-shadow: -8px 0 10px -10px rgba(31, 35, 41, 0.32);
+}
+
+tbody tr:hover .action-cell {
+  background: var(--accent-hover);
 }
 
 .status-badge {
@@ -746,6 +791,7 @@ tbody tr:hover { background: var(--accent-hover); }
 }
 
 .status-idle    { background: #f0f9eb; color: #67c23a; border: 1px solid #b3e19d; }
+.status-joining { background: #fff7e6; color: #e6a23c; border: 1px solid #f5d08a; }
 .status-working { background: #ecf5ff; color: #409eff; border: 1px solid #a0cfff; }
 .status-offline { background: #f4f4f5; color: #909399; border: 1px solid #d3d4d6; }
 
@@ -766,6 +812,13 @@ tbody tr:hover { background: var(--accent-hover); }
   color: var(--text-regular);
 }
 
+.page-size-select {
+  display: inline-block;
+  width: 76px;
+  margin: 0 6px;
+  vertical-align: middle;
+}
+
 .pagination-right {
   display: flex;
   align-items: center;

+ 55 - 12
frontend/src/views/settings/MemberManagementPage.vue

@@ -91,16 +91,14 @@
                   </td>
                   <td>
                     <div class="role-cell">
-                    <select
+                    <UiSelect
                       v-if="isEditing(item) && !isAdminRole(item)"
                       v-model="userEdits[item.userId].role"
                       class="inline-select"
-                    >
-                      <option value="DEVELOPER">DEVELOPER</option>
-                      <option value="ANALYST">ANALYST</option>
-                    </select>
+                      :options="editableRoleOptions"
+                    />
                     <span v-else-if="isEditing(item) && isAdminRole(item)" class="role-tag role-tag-admin">ADMIN</span>
-                    <span v-else class="role-tag">{{ renderRoleLabel(item.role) }}</span>
+                    <span v-else :class="['role-tag', getRoleTagClass(item.role)]">{{ renderRoleLabel(item.role) }}</span>
                     </div>
                   </td>
                   <td>
@@ -129,6 +127,7 @@
 import { onMounted, reactive, ref } from 'vue';
 import { useRouter } from 'vue-router';
 import Sidebar from '../../components/Sidebar.vue';
+import UiSelect from '../../components/UiSelect.vue';
 import { getMe } from '../../api/auth';
 import { deleteUser, getInviteCodes, listUsers, updateInviteCodes, updateUser } from '../../api/adminUsers';
 import { strongConfirm } from '../../utils/strongConfirm';
@@ -141,6 +140,10 @@ const inviteCodes = reactive({ analystInviteCode: '', developerInviteCode: '' })
 const inviteEditMode = ref(false);
 const editingUserId = ref(null);
 const userEdits = reactive({});
+const editableRoleOptions = [
+  { label: 'DEVELOPER', value: 'DEVELOPER' },
+  { label: 'ANALYST', value: 'ANALYST' }
+];
 
 onMounted(async () => {
   try {
@@ -244,6 +247,14 @@ function renderRoleLabel(role) {
   return normalized || '-';
 }
 
+function getRoleTagClass(role) {
+  const label = renderRoleLabel(role);
+  if (label === 'DEVELOPER') return 'role-tag-developer';
+  if (label === 'ANALYST') return 'role-tag-analyst';
+  if (label === 'ADMIN') return 'role-tag-admin';
+  return 'role-tag-default';
+}
+
 function getRolePermissionTip(role) {
   const normalized = (role || '').toUpperCase();
   if (normalized === 'ADMIN') return '管理员:可管理成员、系统配置和全部业务功能。';
@@ -501,12 +512,16 @@ async function handleDeleteUser(item) {
 }
 
 .form-input:focus,
-.inline-select:focus,
 .inline-input:focus {
   border-color: #3f6fd8;
   box-shadow: 0 0 0 3px rgba(63, 111, 216, 0.15);
 }
 
+.inline-select:focus-within :deep(.ui-select-trigger) {
+  border-color: #3f6fd8;
+  box-shadow: 0 0 0 3px rgba(63, 111, 216, 0.15);
+}
+
 .readonly-value {
   min-height: 40px;
   display: flex;
@@ -647,14 +662,27 @@ async function handleDeleteUser(item) {
   height: 26px;
   padding: 0 10px;
   border-radius: 999px;
-  background: #eef4ff;
-  color: #244799;
   font-weight: 600;
 }
 
+.role-tag-developer {
+  background: #e9f2ff;
+  color: #2450a8;
+}
+
+.role-tag-analyst {
+  background: #eaf9ef;
+  color: #1e7a45;
+}
+
 .role-tag-admin {
-  background: #f1f3f8;
-  color: #4f5e80;
+  background: #f3edff;
+  color: #6a55a3;
+}
+
+.role-tag-default {
+  background: #eef2f8;
+  color: #445879;
 }
 
 .role-cell {
@@ -698,11 +726,18 @@ async function handleDeleteUser(item) {
   color: #3e4f77;
 }
 
-.inline-select,
 .inline-input {
   width: 100%;
   min-width: 120px;
   box-sizing: border-box;
+}
+
+.inline-select {
+  width: 100%;
+  min-width: 120px;
+}
+
+.inline-input {
   padding: 7px 8px;
   border: 1px solid #cdd7ea;
   border-radius: 7px;
@@ -711,6 +746,14 @@ async function handleDeleteUser(item) {
   outline: none;
 }
 
+.inline-select :deep(.ui-select-trigger) {
+  min-height: 34px;
+  padding: 7px 28px 7px 8px;
+  border: 1px solid #cdd7ea;
+  border-radius: 7px;
+  color: #243356;
+}
+
 .inline-actions {
   display: flex;
   gap: 8px;

+ 10 - 7
frontend/src/views/task-build/CodeTaskBuildPage.vue

@@ -35,10 +35,7 @@
           </div>
           <div class="config-item">
             <label>失败策略 <span class="required">*</span></label>
-            <select v-model="workflow.failureStrategy">
-              <option value="STOP">终止</option>
-              <option value="CONTINUE">跳过</option>
-            </select>
+            <UiSelect v-model="workflow.failureStrategy" :options="failureStrategyOptions" />
           </div>
         </div>
 
@@ -67,7 +64,7 @@
                     {{ getTaskLabel(data.taskType) }}
                   </div>
                   <div class="node-body">
-                    <div class="node-id">ID: {{ data.id }}</div>
+                    <div class="node-id">{{ formatDisplayId(data.id) }}</div>
                     <div class="node-name" :title="data.name">{{ data.name || '(未命名任务)' }}</div>
                   </div>
                   <Handle type="target" :position="Position.Left" />
@@ -94,7 +91,7 @@
                 <div class="form-section">
                   <div class="form-item">
                     <label>任务标识 <span class="required">*</span></label>
-                    <input :value="selectedTask.id" type="text" disabled class="input-readonly" />
+                    <input :value="formatDisplayId(selectedTask.id)" type="text" disabled class="input-readonly" />
                   </div>
                   <div class="form-item">
                     <label>任务名称 <span class="required">*</span></label>
@@ -183,11 +180,13 @@
 import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue';
 import { useRouter, onBeforeRouteLeave } from 'vue-router';
 import Sidebar from '../../components/Sidebar.vue';
+import UiSelect from '../../components/UiSelect.vue';
 import DataBrowserPanel from '../../components/DataBrowserPanel.vue';
 import SqlEditor from '../../components/SqlEditor.vue';
 import { getMe, logout } from '../../api/auth';
 import { getEngineTypes, validateScript, createTaskBuild, fixScriptWithAi } from '../../api/taskBuild';
 import { strongConfirm } from '../../utils/strongConfirm';
+import { formatDisplayId } from '../../utils/displayId';
 
 import { VueFlow, useVueFlow, Handle, Position, MarkerType } from '@vue-flow/core';
 import { Background } from '@vue-flow/background';
@@ -224,6 +223,10 @@ const workflow = reactive({
   timeoutSeconds: 1800,
   failureStrategy: 'STOP'
 });
+const failureStrategyOptions = [
+  { label: '终止', value: 'STOP' },
+  { label: '跳过', value: 'CONTINUE' }
+];
 const engineTypes = ref(['SPARK_SQL', 'STARROCKS_SQL']);
 const submitting = ref(false);
 const errorMessage = ref('');
@@ -545,7 +548,7 @@ async function handleSubmit() {
   };
   try {
     const { data } = await createTaskBuild(payload);
-    successMessage.value = `工作流构建成功!ID: ${data.workflowId}`;
+    successMessage.value = `工作流构建成功!${formatDisplayId(data.workflowId)}`;
     lastCommittedSignature.value = getCurrentBuildSignature();
   } catch (err) {
     errorMessage.value = err?.response?.data?.message || '构建失败';

+ 3 - 2
frontend/src/views/task-build/DialogTaskBuildPage.vue

@@ -235,6 +235,7 @@ import {
   listDialogSessions,
 } from '../../api/taskBuild';
 import { strongConfirm } from '../../utils/strongConfirm';
+import { formatDisplayId } from '../../utils/displayId';
 import { marked } from 'marked';
 
 marked.setOptions({ breaks: true, gfm: true });
@@ -528,8 +529,8 @@ async function handleConfirmDraft() {
   try {
     const payload = { sessionId: activeSessionId.value, catalog: selectedDatabase.value, workflowName: currentDraft.value.workflowName, description: currentDraft.value.description, tasks: currentDraft.value.tasks || [], edges: currentDraft.value.edges || [] };
     const { data } = await confirmDialogWorkflow(payload);
-    showToast(`工作流创建成功 ID: ${data?.workflowId}`);
-    messages.value.push({ role: 'AI', content: `✅ 工作流已确认并创建成功!\n工作流 ID: ${data?.workflowId}`, createdAt: new Date().toISOString() });
+    showToast(`工作流创建成功 ${formatDisplayId(data?.workflowId)}`);
+    messages.value.push({ role: 'AI', content: `✅ 工作流已确认并创建成功!\n工作流 ${formatDisplayId(data?.workflowId)}`, createdAt: new Date().toISOString() });
     currentDraft.value = null;
     await fetchSessions(false); await scrollToBottom();
   } catch (error) {

+ 81 - 43
frontend/src/views/task-build/TaskBuildWorkflowListPage.vue

@@ -38,7 +38,7 @@
                 <th>失败策略</th>
                 <th class="col-remark">描述</th>
                 <th>创建时间</th>
-                <th>操作</th>
+                <th class="action-column">操作</th>
               </tr>
             </thead>
             <tbody>
@@ -75,10 +75,12 @@
                   <template v-else>{{ selectedWorkflowId === item.workflowId ? detail.timeoutSeconds : (item.timeoutSeconds ?? '-') }}</template>
                 </td>
                 <td>
-                  <select v-if="selectedWorkflowId === item.workflowId && isEditingWorkflow" v-model="detail.failureStrategy" class="inline-select" @click.stop>
-                    <option value="STOP">终止</option>
-                    <option value="CONTINUE">跳过</option>
-                  </select>
+                  <UiSelect
+                    v-if="selectedWorkflowId === item.workflowId && isEditingWorkflow"
+                    v-model="detail.failureStrategy"
+                    class="inline-select"
+                    :options="failureStrategyOptions"
+                  />
                   <template v-else>{{ selectedWorkflowId === item.workflowId ? formatFailureStrategy(detail.failureStrategy) : formatFailureStrategy(item.failureStrategy) }}</template>
                 </td>
                 <td :title="selectedWorkflowId !== item.workflowId ? item.description : undefined" class="desc-text col-remark">
@@ -142,9 +144,7 @@
 
         <div v-if="!selectedWorkflowId" class="pagination">
           <label>每页
-            <select v-model.number="table.pageSize" @change="search">
-              <option v-for="n in [10, 20, 50]" :key="n" :value="n">{{ n }}</option>
-            </select>
+            <UiSelect v-model="table.pageSize" compact :options="pageSizeOptions" @change="search" />
             条
           </label>
           <div class="pagination-right">
@@ -322,16 +322,12 @@
                   <td>{{ task.taskName || '-' }}</td>
                   <td>{{ taskTypeLabel(task.taskType) }}</td>
                   <td>
-                    <select v-model.number="taskExecutorIdByTaskId[task.taskId]" class="executor-select">
-                      <option :value="null">请选择执行集群</option>
-                      <option
-                        v-for="cluster in clustersByTask(task)"
-                        :key="cluster.clusterId"
-                        :value="cluster.clusterId"
-                      >
-                        #{{ cluster.clusterId }} {{ cluster.clusterName }}
-                      </option>
-                    </select>
+                    <UiSelect
+                      v-model="taskExecutorIdByTaskId[task.taskId]"
+                      class="executor-select"
+                      :options="executorOptionsByTask(task)"
+                      placeholder="请选择执行集群"
+                    />
                     <div v-if="clustersByTask(task).length === 0" class="executor-tip">
                       无可用 {{ taskClusterLabel(task.taskType) }} 运行中集群
                     </div>
@@ -363,6 +359,7 @@
 import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import Sidebar from '../../components/Sidebar.vue';
+import UiSelect from '../../components/UiSelect.vue';
 import SqlEditor from '../../components/SqlEditor.vue';
 import {
   listTaskBuildWorkflows,
@@ -376,6 +373,7 @@ import { listClusters } from '../../api/cluster';
 import { submitWorkflow } from '../../api/taskExec';
 import { strongConfirm } from '../../utils/strongConfirm';
 import { copyTextToClipboard } from '../../utils/clipboard';
+import { formatDisplayId } from '../../utils/displayId';
 
 const router = useRouter();
 const route = useRoute();
@@ -428,6 +426,11 @@ const executeDialog = reactive({
   clusters: []
 });
 const taskExecutorIdByTaskId = reactive({});
+const pageSizeOptions = [10, 20, 50].map(n => ({ label: String(n), value: n }));
+const failureStrategyOptions = [
+  { label: '终止', value: 'STOP' },
+  { label: '跳过', value: 'CONTINUE' }
+];
 
 const DAG_NODE_WIDTH = 160;
 const DAG_NODE_HEIGHT = 84;
@@ -811,6 +814,20 @@ function clustersByTask(task) {
   });
 }
 
+function executorOptionsByTask(task) {
+  const clusters = clustersByTask(task);
+  if (clusters.length === 0) {
+    return [{ label: `无可用 ${taskClusterLabel(task?.taskType)} 运行中集群`, value: null, disabled: true }];
+  }
+  return [
+    { label: '请选择执行集群', value: null, disabled: true },
+    ...clusters.map(cluster => ({
+      label: `#${cluster.clusterId} ${cluster.clusterName}`,
+      value: cluster.clusterId
+    }))
+  ];
+}
+
 function parseExecutorId(raw) {
   if (raw === undefined || raw === null || raw === '') return null;
   const num = Number(raw);
@@ -920,7 +937,7 @@ async function handleSubmitWorkflow() {
     const { data } = await submitWorkflow(executeDialog.workflowId, { taskExecutors });
     const workflowInstanceId = data?.workflowInstanceId;
     table.success = workflowInstanceId
-      ? `工作流已提交执行,实例ID: WFI-${workflowInstanceId}`
+      ? `工作流已提交执行,实例 ${formatDisplayId(workflowInstanceId)}`
       : '工作流已提交执行';
     resetExecuteDialog();
   } catch (err) {
@@ -1426,6 +1443,7 @@ onBeforeUnmount(() => {
 }
 
 .table-wrapper { overflow-x: auto; }
+.table-wrapper > table { min-width: 1120px; }
 
 table {
   width: 100%;
@@ -1511,29 +1529,21 @@ tbody tr.active { background: var(--accent-soft); }
 .inline-select {
   width: 100%;
   min-width: 116px;
+}
+
+.inline-select :deep(.ui-select-trigger) {
+  min-height: 32px;
   padding: 5px 28px 5px 8px;
-  border: 1px solid #d0d5dd;
   border-radius: 6px;
   font-size: 13px;
-  color: var(--text-main);
-  background: #fafbfc url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23909399'/%3E%3C/svg%3E") no-repeat right 8px center;
-  appearance: none;
-  -webkit-appearance: none;
-  outline: none;
-  box-sizing: border-box;
-  cursor: pointer;
-  transition: border-color 0.18s, box-shadow 0.18s, background-color 0.18s;
 }
 
-.inline-select:hover {
-  border-color: var(--accent);
-  background-color: #fff;
+.inline-select :deep(.ui-select-label) {
+  font-size: 13px;
 }
 
-.inline-select:focus {
-  border-color: var(--accent);
-  background-color: #fff;
-  box-shadow: 0 0 0 3px var(--accent-soft);
+.inline-select :deep(.ui-select-dropdown) {
+  min-width: 180px;
 }
 
 .time-text {
@@ -1556,6 +1566,35 @@ tbody tr.active { background: var(--accent-soft); }
   align-items: center;
   gap: 8px;
   white-space: nowrap;
+  width: 166px;
+  min-width: 166px;
+  padding-left: 8px;
+  padding-right: 4px;
+  position: sticky;
+  right: 0;
+  z-index: 11;
+  background: var(--panel-bg);
+  box-shadow: -8px 0 10px -10px rgba(31, 35, 41, 0.32);
+}
+
+.action-column {
+  width: 166px;
+  min-width: 166px;
+  padding-left: 8px;
+  padding-right: 4px;
+  position: sticky;
+  right: 0;
+  z-index: 12;
+  background: var(--page-bg);
+  box-shadow: -8px 0 10px -10px rgba(31, 35, 41, 0.32);
+}
+
+tbody tr:hover .action-cell {
+  background: var(--accent-hover);
+}
+
+tbody tr.active .action-cell {
+  background: var(--accent-soft);
 }
 
 .empty-row {
@@ -2030,17 +2069,16 @@ tbody tr.active { background: var(--accent-soft); }
 .executor-select {
   width: 100%;
   min-width: 220px;
-  padding: 7px 8px;
-  border: 1px solid var(--border-color);
+}
+
+.executor-select :deep(.ui-select-trigger) {
+  min-height: 34px;
+  padding: 7px 10px;
   border-radius: 4px;
-  font-size: 13px;
-  color: var(--text-main);
-  background: #fff;
 }
 
-.executor-select:focus {
-  outline: none;
-  border-color: var(--accent);
+.executor-select :deep(.ui-select-dropdown) {
+  min-width: 220px;
 }
 
 .executor-tip {

+ 3 - 2
frontend/src/views/task-exec/TaskInstanceLogPage.vue

@@ -8,8 +8,8 @@
           <div class="header-title">
             <h1>任务日志</h1>
             <div class="meta-row">
-              <span>工作流实例: WFI-{{ workflowInstanceId || '-' }}</span>
-              <span>任务实例: TI-{{ taskInstanceId || '-' }}</span>
+              <span>工作流实例: {{ formatDisplayId(workflowInstanceId) }}</span>
+              <span>任务实例: {{ formatDisplayId(taskInstanceId) }}</span>
             </div>
           </div>
           <div class="header-actions">
@@ -37,6 +37,7 @@ import { computed, reactive, watch } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import Sidebar from '../../components/Sidebar.vue';
 import { downloadTaskInstanceLog, getTaskInstanceLog } from '../../api/taskExec';
+import { formatDisplayId } from '../../utils/displayId';
 
 const route = useRoute();
 const router = useRouter();

+ 3 - 2
frontend/src/views/task-exec/TaskInstanceResultPage.vue

@@ -15,8 +15,8 @@
         </div>
 
         <div class="meta-row">
-          <span>工作流实例: WFI-{{ workflowInstanceId || '-' }}</span>
-          <span>任务实例: TI-{{ taskInstanceId || '-' }}</span>
+          <span>工作流实例: {{ formatDisplayId(workflowInstanceId) }}</span>
+          <span>任务实例: {{ formatDisplayId(taskInstanceId) }}</span>
         </div>
 
         <div v-if="state.error" class="error-banner">
@@ -54,6 +54,7 @@ import { computed, reactive, watch } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import Sidebar from '../../components/Sidebar.vue';
 import { downloadTaskInstanceResult, getTaskInstanceResult } from '../../api/taskExec';
+import { formatDisplayId } from '../../utils/displayId';
 
 const route = useRoute();
 const router = useRouter();

+ 18 - 16
frontend/src/views/task-exec/WorkflowExecutionPage.vue

@@ -16,13 +16,7 @@
           </div>
           <div class="filter-item">
             <label>状态</label>
-            <select v-model="filters.state">
-              <option value="">全部</option>
-              <option value="RUNNING">运行中</option>
-              <option value="SUCCESS">成功</option>
-              <option value="FAILED">失败</option>
-              <option value="TERMINATED">已终止</option>
-            </select>
+            <UiSelect v-model="filters.state" :options="workflowStateOptions" placeholder="全部" />
           </div>
           <div class="filter-actions">
             <button class="btn btn-primary" :disabled="table.loading" @click="search">查询</button>
@@ -61,9 +55,9 @@
                 :class="{ active: selectedWorkflowInstanceId === item.workflowInstanceId }"
                 @click="selectWorkflow(item)"
               >
-                <td class="id-text">WFI-{{ item.workflowInstanceId }}</td>
+                <td class="id-text">{{ formatDisplayId(item.workflowInstanceId) }}</td>
                 <td class="id-text">
-                  <a class="id-link" href="#" @click.stop.prevent="gotoWorkflowDefinition(item.workflowId)">#{{ item.workflowId }}</a>
+                  <a class="id-link" href="#" @click.stop.prevent="gotoWorkflowDefinition(item.workflowId)">{{ formatDisplayId(item.workflowId) }}</a>
                 </td>
                 <td class="name-text">{{ item.workflowName || '-' }}</td>
                 <td>
@@ -90,9 +84,7 @@
         <div v-if="selectedWorkflowInstanceId" class="focused-row-space"></div>
         <div v-if="!selectedWorkflowInstanceId" class="pagination">
           <label>每页
-            <select v-model.number="table.pageSize" @change="handlePageSizeChange">
-              <option v-for="n in [10, 20, 50]" :key="n" :value="n">{{ n }}</option>
-            </select>
+            <UiSelect v-model="table.pageSize" compact :options="pageSizeOptions" @change="handlePageSizeChange" />
             条
           </label>
           <div class="pagination-right">
@@ -110,7 +102,7 @@
             工作流执行详情
             <span class="title-sub">
               ( 工作流: <span class="highlight">{{ detail.workflowName || '-' }}</span>
-              | 实例ID: WFI-{{ selectedWorkflowInstanceId }} )
+              | 实例ID: {{ formatDisplayId(selectedWorkflowInstanceId) }} )
             </span>
           </h2>
           <div v-if="detail.state === 'RUNNING'" class="header-actions">
@@ -153,7 +145,7 @@
                 @click="selectTask(node)"
               >
                 <span class="node-name">{{ node.taskName || `任务-${node.taskId}` }}</span>
-                <span class="node-id">TI-{{ node.taskInstanceId }}</span>
+                <span class="node-id">{{ formatDisplayId(node.taskInstanceId) }}</span>
               </button>
             </div>
           </div>
@@ -164,7 +156,7 @@
               <h3>{{ selectedTask.taskName || `任务-${selectedTask.taskId}` }}</h3>
               <div class="detail-row">
                 <span class="detail-label">任务实例ID:</span>
-                <span class="detail-value id-text">TI-{{ selectedTask.taskInstanceId }}</span>
+                <span class="detail-value id-text">{{ formatDisplayId(selectedTask.taskInstanceId) }}</span>
               </div>
               <div class="detail-row">
                 <span class="detail-label">任务类型:</span>
@@ -172,7 +164,7 @@
               </div>
               <div class="detail-row">
                 <span class="detail-label">执行集群ID:</span>
-                <span class="detail-value id-text">{{ selectedTask.executorId || '-' }}</span>
+                <span class="detail-value id-text">{{ formatDisplayId(selectedTask.executorId) }}</span>
               </div>
               <div class="detail-row">
                 <span class="detail-label">当前状态:</span>
@@ -253,17 +245,27 @@ import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import Sidebar from '../../components/Sidebar.vue';
 import SqlEditor from '../../components/SqlEditor.vue';
+import UiSelect from '../../components/UiSelect.vue';
 import {
   getWorkflowStatus,
   listWorkflowInstances,
   terminateWorkflow
 } from '../../api/taskExec';
 import { copyTextToClipboard } from '../../utils/clipboard';
+import { formatDisplayId } from '../../utils/displayId';
 
 const filters = reactive({
   workflowName: '',
   state: ''
 });
+const workflowStateOptions = [
+  { label: '全部', value: '' },
+  { label: '运行中', value: 'RUNNING' },
+  { label: '成功', value: 'SUCCESS' },
+  { label: '失败', value: 'FAILED' },
+  { label: '已终止', value: 'TERMINATED' }
+];
+const pageSizeOptions = [10, 20, 50].map(n => ({ label: String(n), value: n }));
 
 const table = reactive({
   list: [],

+ 24 - 2
k8s-orchestrator/internal/controller/clusteroperation_controller.go

@@ -191,8 +191,8 @@ func (r *ClusterOperationReconciler) handleDeleteNode(ctx context.Context, op *o
 	if err != nil {
 		return false, 0, "", "", err
 	}
-	if nodeInv.Status.Phase != orcv1.NodePhaseIdle {
-		return false, 0, nodeID, "", fmt.Errorf("node %s is not idle, current phase=%s", nodeID, nodeInv.Status.Phase)
+	if err := validateNodeDeletable(&nodeInv); err != nil {
+		return false, 0, nodeID, "", err
 	}
 	nodeName := nodeInv.Status.KubernetesNodeName
 	if nodeName == "" {
@@ -217,6 +217,28 @@ func (r *ClusterOperationReconciler) handleDeleteNode(ctx context.Context, op *o
 	return true, 0, nodeID, "Node removed from idle pool and cluster", nil
 }
 
+func validateNodeDeletable(nodeInv *orcv1.NodeInventory) error {
+	if nodeInv == nil {
+		return fmt.Errorf("node inventory is empty")
+	}
+	phase := nodeInv.Status.Phase
+	if phase == orcv1.NodePhaseIdle {
+		return nil
+	}
+	if nodeInv.Status.ClusterRef != "" {
+		return fmt.Errorf("node %s is allocated to cluster %s, current phase=%s",
+			nodeInv.Name,
+			nodeInv.Status.ClusterRef,
+			phase)
+	}
+	switch phase {
+	case orcv1.NodePhaseNew, orcv1.NodePhaseProvisioning, orcv1.NodePhaseFailed:
+		return nil
+	default:
+		return fmt.Errorf("node %s is not deletable, current phase=%s", nodeInv.Name, phase)
+	}
+}
+
 func (r *ClusterOperationReconciler) handleCreateCluster(ctx context.Context, op *orcv1.ClusterOperation) (bool, time.Duration, string, string, error) {
 	if op.Spec.Create == nil {
 		return false, 0, "", "", fmt.Errorf("create payload is required")

+ 36 - 0
k8s-orchestrator/internal/controller/clusteroperation_controller_test.go

@@ -347,6 +347,42 @@ func TestHandleDeleteNode_FallbackDeleteByInternalIP(t *testing.T) {
 	}
 }
 
+func TestHandleDeleteNode_AllowDeleteProvisioningNodeWithoutClusterRef(t *testing.T) {
+	scheme := runtime.NewScheme()
+	utilruntime.Must(corev1.AddToScheme(scheme))
+	utilruntime.Must(orcv1.AddToScheme(scheme))
+
+	nodeInv := &orcv1.NodeInventory{
+		ObjectMeta: metav1.ObjectMeta{Name: "node-24"},
+		Spec:       orcv1.NodeInventorySpec{Address: "1.2.3.6"},
+		Status: orcv1.NodeInventoryStatus{
+			Phase:     orcv1.NodePhaseProvisioning,
+			LastError: "dial tcp 1.2.3.6:22: i/o timeout",
+		},
+	}
+	op := &orcv1.ClusterOperation{
+		Spec: orcv1.ClusterOperationSpec{
+			DeleteNode: "node-24",
+		},
+	}
+
+	fakeClient := ctrlclientfake.NewClientBuilder().WithScheme(scheme).WithObjects(nodeInv).Build()
+	r := &ClusterOperationReconciler{Client: fakeClient, Scheme: scheme}
+
+	done, _, _, _, err := r.handleDeleteNode(context.Background(), op)
+	if err != nil {
+		t.Fatalf("handleDeleteNode returned error: %v", err)
+	}
+	if !done {
+		t.Fatalf("expected delete operation completed")
+	}
+
+	var invAfter orcv1.NodeInventory
+	if err := fakeClient.Get(context.Background(), types.NamespacedName{Name: "node-24"}, &invAfter); !apierrors.IsNotFound(err) {
+		t.Fatalf("expected node inventory deleted, get err=%v", err)
+	}
+}
+
 func TestHandleAddNode_FailedPhaseShouldKeepRetrying(t *testing.T) {
 	scheme := runtime.NewScheme()
 	utilruntime.Must(corev1.AddToScheme(scheme))