cyrus 5 hónapja
szülő
commit
37027618e9
69 módosított fájl, 3551 hozzáadás és 470 törlés
  1. 4 0
      backend/pom.xml
  2. 14 0
      backend/src/main/java/com/wenshu/platform/config/AutoScalingQuartzProperties.java
  3. 33 0
      backend/src/main/java/com/wenshu/platform/config/auth/AuthContextInterceptor.java
  4. 83 0
      backend/src/main/java/com/wenshu/platform/controller/auth/AdminUserController.java
  5. 20 0
      backend/src/main/java/com/wenshu/platform/controller/ops/AlertController.java
  6. 3 2
      backend/src/main/java/com/wenshu/platform/controller/resource/StockTerminalWebSocketHandler.java
  7. 3 1
      backend/src/main/java/com/wenshu/platform/dao/DialogSessionDAO.java
  8. 20 0
      backend/src/main/java/com/wenshu/platform/dao/OrganizationDAO.java
  9. 6 0
      backend/src/main/java/com/wenshu/platform/dao/ScalingRuleDAO.java
  10. 8 0
      backend/src/main/java/com/wenshu/platform/dao/UserDAO.java
  11. 0 1
      backend/src/main/java/com/wenshu/platform/model/dataobject/DialogSessionDO.java
  12. 17 0
      backend/src/main/java/com/wenshu/platform/model/dataobject/OrganizationDO.java
  13. 15 2
      backend/src/main/java/com/wenshu/platform/model/enums/UserRole.java
  14. 0 1
      backend/src/main/java/com/wenshu/platform/model/query/StockQuery.java
  15. 12 0
      backend/src/main/java/com/wenshu/platform/model/req/AdminUserCreateReq.java
  16. 9 0
      backend/src/main/java/com/wenshu/platform/model/req/AdminUserResetPasswordReq.java
  17. 11 0
      backend/src/main/java/com/wenshu/platform/model/req/AdminUserUpdateReq.java
  18. 10 0
      backend/src/main/java/com/wenshu/platform/model/req/InviteCodeUpdateReq.java
  19. 1 1
      backend/src/main/java/com/wenshu/platform/model/req/UserRegisterReq.java
  20. 10 0
      backend/src/main/java/com/wenshu/platform/model/resp/InviteCodeResp.java
  21. 1 0
      backend/src/main/java/com/wenshu/platform/model/resp/UserProfileResp.java
  22. 23 0
      backend/src/main/java/com/wenshu/platform/service/auth/AuthContextSupport.java
  23. 272 56
      backend/src/main/java/com/wenshu/platform/service/auth/AuthService.java
  24. 7 1
      backend/src/main/java/com/wenshu/platform/service/resource/AutoScalingService.java
  25. 4 4
      backend/src/main/java/com/wenshu/platform/service/resource/ClusterService.java
  26. 2 2
      backend/src/main/java/com/wenshu/platform/service/resource/ConfigService.java
  27. 30 4
      backend/src/main/java/com/wenshu/platform/service/resource/ResourceAuthSupport.java
  28. 64 8
      backend/src/main/java/com/wenshu/platform/service/resource/ScalingService.java
  29. 31 5
      backend/src/main/java/com/wenshu/platform/service/resource/StockService.java
  30. 93 0
      backend/src/main/java/com/wenshu/platform/service/resource/autoscaling/ScalingExecutionService.java
  31. 103 0
      backend/src/main/java/com/wenshu/platform/service/resource/autoscaling/ScalingRuleEvaluationService.java
  32. 297 0
      backend/src/main/java/com/wenshu/platform/service/resource/autoscaling/quartz/AutoScalingQuartzScheduler.java
  33. 58 0
      backend/src/main/java/com/wenshu/platform/service/resource/autoscaling/quartz/CronScalingRuleJob.java
  34. 58 0
      backend/src/main/java/com/wenshu/platform/service/resource/autoscaling/quartz/MetricScalingRuleJob.java
  35. 32 0
      backend/src/main/java/com/wenshu/platform/service/resource/autoscaling/quartz/QuartzScalingRuleKeys.java
  36. 130 0
      backend/src/main/java/com/wenshu/platform/service/resource/autoscaling/quartz/ScalingCronExpressionSupport.java
  37. 6 3
      backend/src/main/java/com/wenshu/platform/service/taskbuild/DialogBuildService.java
  38. 20 0
      backend/src/main/resources/application.yml
  39. 74 0
      backend/src/main/resources/db/schema.sql
  40. 44 0
      backend/src/main/resources/mapper/auth/OrganizationDAO.xml
  41. 43 0
      backend/src/main/resources/mapper/auth/UserDAO.xml
  42. 14 0
      backend/src/main/resources/mapper/resource/ScalingRuleDAO.xml
  43. 20 4
      backend/src/test/java/com/wenshu/platform/service/auth/AuthServiceTest.java
  44. 113 0
      backend/src/test/java/com/wenshu/platform/service/resource/autoscaling/ScalingExecutionServiceTest.java
  45. 71 0
      backend/src/test/java/com/wenshu/platform/service/resource/autoscaling/ScalingRuleEvaluationServiceTest.java
  46. 37 0
      backend/src/test/java/com/wenshu/platform/service/resource/autoscaling/quartz/ScalingCronExpressionSupportTest.java
  47. 25 0
      frontend/src/api/adminUsers.js
  48. 70 10
      frontend/src/components/Sidebar.vue
  49. 1 0
      frontend/src/main.js
  50. 38 14
      frontend/src/router/index.js
  51. 123 0
      frontend/src/styles/member-theme.css
  52. 101 68
      frontend/src/views/auth/LoginPage.vue
  53. 147 140
      frontend/src/views/auth/RegisterPage.vue
  54. 20 11
      frontend/src/views/ops/AlertEventPage.vue
  55. 1 1
      frontend/src/views/ops/AlertPage.vue
  56. 1 1
      frontend/src/views/ops/AlertRulePage.vue
  57. 25 9
      frontend/src/views/ops/AuditLogPage.vue
  58. 72 52
      frontend/src/views/resource-management/ClusterConsolePage.vue
  59. 90 8
      frontend/src/views/resource-management/ClusterPage.vue
  60. 1 1
      frontend/src/views/resource-management/StockConsolePage.vue
  61. 28 17
      frontend/src/views/resource-management/StockPage.vue
  62. 739 0
      frontend/src/views/settings/MemberManagementPage.vue
  63. 90 21
      frontend/src/views/settings/SettingsPage.vue
  64. 4 4
      frontend/src/views/task-build/CodeTaskBuildPage.vue
  65. 1 1
      frontend/src/views/task-build/DialogTaskBuildPage.vue
  66. 1 1
      frontend/src/views/task-build/TaskBuildWorkflowListPage.vue
  67. 29 9
      frontend/src/views/task-exec/TaskInstanceLogPage.vue
  68. 1 1
      frontend/src/views/task-exec/TaskInstanceResultPage.vue
  69. 17 6
      frontend/src/views/task-exec/WorkflowExecutionPage.vue

+ 4 - 0
backend/pom.xml

@@ -40,6 +40,10 @@
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-websocket</artifactId>
         </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-quartz</artifactId>
+        </dependency>
         <dependency>
             <groupId>org.mybatis.spring.boot</groupId>
             <artifactId>mybatis-spring-boot-starter</artifactId>

+ 14 - 0
backend/src/main/java/com/wenshu/platform/config/AutoScalingQuartzProperties.java

@@ -0,0 +1,14 @@
+package com.wenshu.platform.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "autoscaling.quartz")
+public class AutoScalingQuartzProperties {
+
+    private String timezone = "Asia/Shanghai";
+    private int metricPollIntervalSeconds = 60;
+}

+ 33 - 0
backend/src/main/java/com/wenshu/platform/config/auth/AuthContextInterceptor.java

@@ -4,6 +4,8 @@ import java.util.Collections;
 import java.util.Locale;
 
 import com.wenshu.platform.model.bo.LoginUserBO;
+import com.wenshu.platform.model.enums.UserRole;
+import com.wenshu.platform.service.auth.ForbiddenException;
 import com.wenshu.platform.service.auth.AuthService;
 import com.wenshu.platform.service.auth.UnauthorizedException;
 import jakarta.servlet.http.HttpServletRequest;
@@ -32,6 +34,7 @@ public class AuthContextInterceptor implements HandlerInterceptor {
         }
 
         LoginUserBO loginUser = authService.requireLoginUser(authorization);
+        enforceRoleAccess(request.getMethod(), request.getRequestURI(), loginUser.getRole());
         UsernamePasswordAuthenticationToken authentication =
                 new UsernamePasswordAuthenticationToken(
                         loginUser,
@@ -46,6 +49,36 @@ public class AuthContextInterceptor implements HandlerInterceptor {
         SecurityContextHolder.clearContext();
     }
 
+    private void enforceRoleAccess(String requestMethod, String requestUri, String roleText) {
+        UserRole role = UserRole.fromInput(roleText);
+        if (role == null) {
+            throw new ForbiddenException("Invalid role");
+        }
+        if (requestUri == null) {
+            return;
+        }
+        if (requestUri.startsWith("/api/admin/users")) {
+            if (!role.isAdmin()) {
+                throw new ForbiddenException("ADMIN role required");
+            }
+            return;
+        }
+        if (requestUri.startsWith("/api/task-build") || requestUri.startsWith("/api/task-exec")) {
+            return;
+        }
+        if (requestUri.startsWith("/api/auth")) {
+            return;
+        }
+        if (role == UserRole.ANALYST
+                && "GET".equalsIgnoreCase(requestMethod)
+                && (requestUri.startsWith("/api/resource") || requestUri.startsWith("/api/ops"))) {
+            return;
+        }
+        if (!role.canOperateResources()) {
+            throw new ForbiddenException("Insufficient role permission");
+        }
+    }
+
     private String buildRole(String role) {
         if (!StringUtils.hasText(role)) {
             return "ROLE_USER";

+ 83 - 0
backend/src/main/java/com/wenshu/platform/controller/auth/AdminUserController.java

@@ -0,0 +1,83 @@
+package com.wenshu.platform.controller.auth;
+
+import java.util.List;
+
+import com.wenshu.platform.model.req.AdminUserCreateReq;
+import com.wenshu.platform.model.req.AdminUserResetPasswordReq;
+import com.wenshu.platform.model.req.AdminUserUpdateReq;
+import com.wenshu.platform.model.req.InviteCodeUpdateReq;
+import com.wenshu.platform.model.resp.InviteCodeResp;
+import com.wenshu.platform.model.resp.UserProfileResp;
+import com.wenshu.platform.service.auth.AuthService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/admin/users")
+public class AdminUserController {
+
+    private final AuthService authService;
+
+    @GetMapping
+    public List<UserProfileResp> listUsers(
+            @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization) {
+        return authService.listUsers(authorization);
+    }
+
+    @PostMapping
+    public UserProfileResp createUser(
+            @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization,
+            @RequestBody AdminUserCreateReq req) {
+        return authService.createUserByAdmin(authorization, req);
+    }
+
+    @PutMapping("/{userId}")
+    public UserProfileResp updateUser(
+            @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization,
+            @PathVariable Long userId,
+            @RequestBody AdminUserUpdateReq req) {
+        return authService.updateUserByAdmin(authorization, userId, req);
+    }
+
+    @PostMapping("/{userId}/reset-password")
+    @ResponseStatus(HttpStatus.NO_CONTENT)
+    public void resetPassword(
+            @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization,
+            @PathVariable Long userId,
+            @RequestBody AdminUserResetPasswordReq req) {
+        authService.resetPasswordByAdmin(authorization, userId, req);
+    }
+
+    @DeleteMapping("/{userId}")
+    @ResponseStatus(HttpStatus.NO_CONTENT)
+    public void deleteUser(
+            @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization,
+            @PathVariable Long userId) {
+        authService.deleteUserByAdmin(authorization, userId);
+    }
+
+    @GetMapping("/invite-codes")
+    public InviteCodeResp getInviteCodes(
+            @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization) {
+        return authService.getInviteCodes(authorization);
+    }
+
+    @PutMapping("/invite-codes")
+    public InviteCodeResp updateInviteCodes(
+            @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization,
+            @RequestBody InviteCodeUpdateReq req) {
+        return authService.updateInviteCodes(authorization, req);
+    }
+}

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

@@ -1,5 +1,6 @@
 package com.wenshu.platform.controller.ops;
 
+import com.wenshu.platform.config.audit.Auditable;
 import com.wenshu.platform.model.bo.AlertEventHandleBO;
 import com.wenshu.platform.model.converter.AlertEventConverter;
 import com.wenshu.platform.model.converter.AlertRuleConverter;
@@ -37,6 +38,10 @@ public class AlertController {
     private final AlertEventService alertEventService;
 
     @PostMapping("/rules")
+    @Auditable(
+            actionType = "ALERT_RULE_CREATE",
+            targetType = "ALERT_RULE",
+            detailExpr = "'create alert rule'")
     public AlertRuleResp createRule(@RequestBody AlertRuleCreateReq req) {
         return alertRuleService.createRule(AlertRuleConverter.toDO(req));
     }
@@ -48,6 +53,11 @@ public class AlertController {
 
     @DeleteMapping("/rules/{ruleId}")
     @ResponseStatus(HttpStatus.NO_CONTENT)
+    @Auditable(
+            actionType = "ALERT_RULE_DELETE",
+            targetType = "ALERT_RULE",
+            targetIdExpr = "#arg0",
+            detailExpr = "'delete alert rule'")
     public void deleteRule(@PathVariable Long ruleId) {
         alertRuleService.deleteRule(ruleId);
     }
@@ -58,6 +68,11 @@ public class AlertController {
     }
 
     @PutMapping("/rules/{ruleId}/enabled")
+    @Auditable(
+            actionType = "ALERT_RULE_TOGGLE",
+            targetType = "ALERT_RULE",
+            targetIdExpr = "#arg0",
+            detailExpr = "#arg1.enabled ? 'enable alert rule' : 'disable alert rule'")
     public AlertRuleResp toggleRule(@PathVariable Long ruleId, @RequestBody AlertRuleToggleReq req) {
         if (req == null || req.getEnabled() == null) {
             throw new IllegalArgumentException("enabled cannot be null");
@@ -78,6 +93,11 @@ public class AlertController {
     }
 
     @PostMapping("/events/{eventId}/handle")
+    @Auditable(
+            actionType = "ALERT_EVENT_HANDLE",
+            targetType = "ALERT_EVENT",
+            targetIdExpr = "#arg0",
+            detailExpr = "'handle alert event'")
     public AlertEventResp handleEvent(@PathVariable Long eventId, @RequestBody(required = false) AlertEventHandleReq req) {
         AlertEventHandleBO handleBO = AlertEventConverter.toHandleBO(req);
         return alertEventService.handleEvent(eventId, handleBO);

+ 3 - 2
backend/src/main/java/com/wenshu/platform/controller/resource/StockTerminalWebSocketHandler.java

@@ -153,8 +153,9 @@ public class StockTerminalWebSocketHandler extends TextWebSocketHandler {
             throw new IllegalArgumentException("token is required");
         }
         LoginUserBO loginUser = authService.requireLoginUser("Bearer " + token);
-        if (!UserRole.OPS.getCode().equalsIgnoreCase(loginUser.getRole())) {
-            throw new ForbiddenException("OPS role required");
+        UserRole role = UserRole.fromInput(loginUser.getRole());
+        if (role == null || !role.canOperateResources()) {
+            throw new ForbiddenException("DEVELOPER or ADMIN role required");
         }
         return loginUser;
     }

+ 3 - 1
backend/src/main/java/com/wenshu/platform/dao/DialogSessionDAO.java

@@ -14,7 +14,9 @@ public interface DialogSessionDAO {
 
     DialogSessionDO findBySessionId(@Param("sessionId") Long sessionId);
 
-    List<DialogSessionDO> findByUserId(@Param("userId") Long userId, @Param("limit") Integer limit);
+    List<DialogSessionDO> findByUserId(
+            @Param("userId") Long userId,
+            @Param("limit") Integer limit);
 
     int deleteBySessionId(@Param("sessionId") Long sessionId);
 

+ 20 - 0
backend/src/main/java/com/wenshu/platform/dao/OrganizationDAO.java

@@ -0,0 +1,20 @@
+package com.wenshu.platform.dao;
+
+import com.wenshu.platform.model.dataobject.OrganizationDO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+@Mapper
+public interface OrganizationDAO {
+
+    OrganizationDO findById(@Param("organizationId") Long organizationId);
+
+    OrganizationDO findByCode(@Param("organizationCode") String organizationCode);
+
+    OrganizationDO findByInviteCode(@Param("inviteCode") String inviteCode);
+
+    int updateInviteCodes(
+            @Param("organizationId") Long organizationId,
+            @Param("analystInviteCode") String analystInviteCode,
+            @Param("developerInviteCode") String developerInviteCode);
+}

+ 6 - 0
backend/src/main/java/com/wenshu/platform/dao/ScalingRuleDAO.java

@@ -16,6 +16,12 @@ public interface ScalingRuleDAO {
 
     ScalingRuleDO findById(@Param("ruleId") Long ruleId);
 
+    List<ScalingRuleDO> findEnabled();
+
+    List<ScalingRuleDO> findEnabledByClusterIdAndTriggerType(
+            @Param("clusterId") Long clusterId,
+            @Param("triggerType") String triggerType);
+
     List<ScalingRuleDO> find(@Param("query") ScalingRuleQuery query);
 
     long count(@Param("query") ScalingRuleQuery query);

+ 8 - 0
backend/src/main/java/com/wenshu/platform/dao/UserDAO.java

@@ -9,7 +9,15 @@ public interface UserDAO {
 
     UserDO findByUsername(@Param("username") String username);
 
+    UserDO findByUserId(@Param("userId") Long userId);
+
+    java.util.List<UserDO> findAll();
+
     int save(@Param("user") UserDO user);
 
     int updateByUserId(@Param("user") UserDO user);
+
+    int updateAdminFieldsByUserId(@Param("user") UserDO user);
+
+    int deleteByUserId(@Param("userId") Long userId);
 }

+ 0 - 1
backend/src/main/java/com/wenshu/platform/model/dataobject/DialogSessionDO.java

@@ -13,4 +13,3 @@ public class DialogSessionDO {
     private LocalDateTime createdTime;
     private LocalDateTime updatedTime;
 }
-

+ 17 - 0
backend/src/main/java/com/wenshu/platform/model/dataobject/OrganizationDO.java

@@ -0,0 +1,17 @@
+package com.wenshu.platform.model.dataobject;
+
+import java.time.LocalDateTime;
+
+import lombok.Data;
+
+@Data
+public class OrganizationDO {
+
+    private Long organizationId;
+    private String organizationCode;
+    private String organizationName;
+    private String analystInviteCode;
+    private String developerInviteCode;
+    private Integer status;
+    private LocalDateTime createdAt;
+}

+ 15 - 2
backend/src/main/java/com/wenshu/platform/model/enums/UserRole.java

@@ -3,8 +3,9 @@ package com.wenshu.platform.model.enums;
 import java.util.Locale;
 
 public enum UserRole {
-    ANALYST("ANALYST"),
-    OPS("OPS");
+    ADMIN("ADMIN"),
+    DEVELOPER("DEVELOPER"),
+    ANALYST("ANALYST");
 
     private final String code;
 
@@ -25,6 +26,10 @@ public enum UserRole {
             return null;
         }
         String upper = normalized.toUpperCase(Locale.ROOT);
+        if ("OPS".equals(upper)) {
+            // Backward compatibility: legacy OPS role maps to DEVELOPER.
+            return DEVELOPER;
+        }
         for (UserRole role : values()) {
             if (role.code.equals(upper)) {
                 return role;
@@ -32,4 +37,12 @@ public enum UserRole {
         }
         return null;
     }
+
+    public boolean isAdmin() {
+        return this == ADMIN;
+    }
+
+    public boolean canOperateResources() {
+        return this == ADMIN || this == DEVELOPER;
+    }
 }

+ 0 - 1
backend/src/main/java/com/wenshu/platform/model/query/StockQuery.java

@@ -12,4 +12,3 @@ public class StockQuery {
     private Integer offset;
     private Integer limit;
 }
-

+ 12 - 0
backend/src/main/java/com/wenshu/platform/model/req/AdminUserCreateReq.java

@@ -0,0 +1,12 @@
+package com.wenshu.platform.model.req;
+
+import lombok.Data;
+
+@Data
+public class AdminUserCreateReq {
+
+    private String username;
+    private String password;
+    private String email;
+    private String role;
+}

+ 9 - 0
backend/src/main/java/com/wenshu/platform/model/req/AdminUserResetPasswordReq.java

@@ -0,0 +1,9 @@
+package com.wenshu.platform.model.req;
+
+import lombok.Data;
+
+@Data
+public class AdminUserResetPasswordReq {
+
+    private String password;
+}

+ 11 - 0
backend/src/main/java/com/wenshu/platform/model/req/AdminUserUpdateReq.java

@@ -0,0 +1,11 @@
+package com.wenshu.platform.model.req;
+
+import lombok.Data;
+
+@Data
+public class AdminUserUpdateReq {
+
+    private String role;
+    private String email;
+    private Integer status;
+}

+ 10 - 0
backend/src/main/java/com/wenshu/platform/model/req/InviteCodeUpdateReq.java

@@ -0,0 +1,10 @@
+package com.wenshu.platform.model.req;
+
+import lombok.Data;
+
+@Data
+public class InviteCodeUpdateReq {
+
+    private String analystInviteCode;
+    private String developerInviteCode;
+}

+ 1 - 1
backend/src/main/java/com/wenshu/platform/model/req/UserRegisterReq.java

@@ -8,5 +8,5 @@ public class UserRegisterReq {
     private String username;
     private String password;
     private String email;
-    private String role;
+    private String inviteCode;
 }

+ 10 - 0
backend/src/main/java/com/wenshu/platform/model/resp/InviteCodeResp.java

@@ -0,0 +1,10 @@
+package com.wenshu.platform.model.resp;
+
+import lombok.Data;
+
+@Data
+public class InviteCodeResp {
+
+    private String analystInviteCode;
+    private String developerInviteCode;
+}

+ 1 - 0
backend/src/main/java/com/wenshu/platform/model/resp/UserProfileResp.java

@@ -9,4 +9,5 @@ public class UserProfileResp {
     private String username;
     private String role;
     private String email;
+    private Integer status;
 }

+ 23 - 0
backend/src/main/java/com/wenshu/platform/service/auth/AuthContextSupport.java

@@ -0,0 +1,23 @@
+package com.wenshu.platform.service.auth;
+
+import com.wenshu.platform.model.bo.LoginUserBO;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+public final class AuthContextSupport {
+
+    private AuthContextSupport() {
+    }
+
+    public static LoginUserBO requireLoginUser() {
+        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+        if (authentication == null) {
+            throw new UnauthorizedException("Please login first");
+        }
+        Object principal = authentication.getPrincipal();
+        if (!(principal instanceof LoginUserBO loginUserBO) || loginUserBO.getUserId() == null) {
+            throw new UnauthorizedException("Please login first");
+        }
+        return loginUserBO;
+    }
+}

+ 272 - 56
backend/src/main/java/com/wenshu/platform/service/auth/AuthService.java

@@ -1,14 +1,23 @@
 package com.wenshu.platform.service.auth;
 
 import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
 
+import com.wenshu.platform.dao.OrganizationDAO;
 import com.wenshu.platform.dao.UserDAO;
 import com.wenshu.platform.model.bo.LoginUserBO;
+import com.wenshu.platform.model.dataobject.OrganizationDO;
 import com.wenshu.platform.model.dataobject.UserDO;
 import com.wenshu.platform.model.enums.UserRole;
+import com.wenshu.platform.model.req.AdminUserCreateReq;
+import com.wenshu.platform.model.req.AdminUserResetPasswordReq;
+import com.wenshu.platform.model.req.AdminUserUpdateReq;
+import com.wenshu.platform.model.req.InviteCodeUpdateReq;
 import com.wenshu.platform.model.req.UserLoginReq;
 import com.wenshu.platform.model.req.UserRegisterReq;
 import com.wenshu.platform.model.req.UserUpdateReq;
+import com.wenshu.platform.model.resp.InviteCodeResp;
 import com.wenshu.platform.model.resp.UserLoginResp;
 import com.wenshu.platform.model.resp.UserProfileResp;
 import lombok.RequiredArgsConstructor;
@@ -25,9 +34,12 @@ public class AuthService {
     private static final int EMAIL_MAX_LENGTH = 128;
     private static final int PASSWORD_MIN_LENGTH = 6;
     private static final int PASSWORD_MAX_LENGTH = 64;
+    private static final int INVITE_CODE_MAX_LENGTH = 64;
     private static final int USER_ENABLED = 1;
+    private static final long INVITE_CONFIG_ID = 1L;
 
     private final UserDAO userDAO;
+    private final OrganizationDAO organizationDAO;
     private final AuthTokenService authTokenService;
     private final PasswordEncoder passwordEncoder;
 
@@ -35,35 +47,20 @@ public class AuthService {
         if (req == null) {
             throw new IllegalArgumentException("request body cannot be null");
         }
-
-        String username = normalizeUsername(req.getUsername());
-        String password = req.getPassword();
-        String email = normalizeText(req.getEmail());
-        UserRole role = validateRegisterInput(username, password, email, req.getRole());
-
-        UserDO existingUser = userDAO.findByUsername(username);
-        if (existingUser != null) {
-            throw new IllegalArgumentException("username already exists");
+        String inviteCode = normalizeText(req.getInviteCode());
+        if (!StringUtils.hasText(inviteCode)) {
+            throw new IllegalArgumentException("inviteCode cannot be empty");
         }
-
-        UserDO newUser = new UserDO();
-        newUser.setUsername(username);
-        newUser.setPasswordHash(passwordEncoder.encode(password));
-        newUser.setRole(role.getCode());
-        newUser.setEmail(email);
-        newUser.setStatus(USER_ENABLED);
-        newUser.setCreatedAt(LocalDateTime.now());
-        userDAO.save(newUser);
-
-        LoginUserBO loginUser = authTokenService.createSession(newUser);
-        UserLoginResp resp = new UserLoginResp();
-        resp.setToken(loginUser.getToken());
-        resp.setExpiresAt(loginUser.getExpiresAt());
-        resp.setUserId(loginUser.getUserId());
-        resp.setUsername(loginUser.getUsername());
-        resp.setRole(loginUser.getRole());
-        resp.setEmail(loginUser.getEmail());
-        return resp;
+        if (inviteCode.length() > INVITE_CODE_MAX_LENGTH) {
+            throw new IllegalArgumentException("inviteCode length exceeds " + INVITE_CODE_MAX_LENGTH);
+        }
+        OrganizationDO organization = organizationDAO.findByInviteCode(inviteCode);
+        if (organization == null) {
+            throw new IllegalArgumentException("invalid inviteCode");
+        }
+        UserRole role = resolveInviteRole(organization, inviteCode);
+        UserDO created = createUserInternal(req.getUsername(), req.getPassword(), req.getEmail(), role.getCode());
+        return buildLoginResp(authTokenService.createSession(created));
     }
 
     public UserLoginResp login(UserLoginReq req) {
@@ -86,26 +83,12 @@ public class AuthService {
         if (!isPasswordValid(password, userDO.getPasswordHash())) {
             throw new UnauthorizedException("Invalid username or password");
         }
-
-        LoginUserBO loginUser = authTokenService.createSession(userDO);
-        UserLoginResp resp = new UserLoginResp();
-        resp.setToken(loginUser.getToken());
-        resp.setExpiresAt(loginUser.getExpiresAt());
-        resp.setUserId(loginUser.getUserId());
-        resp.setUsername(loginUser.getUsername());
-        resp.setRole(loginUser.getRole());
-        resp.setEmail(loginUser.getEmail());
-        return resp;
+        return buildLoginResp(authTokenService.createSession(userDO));
     }
 
     public UserProfileResp getCurrentUser(String authorization) {
         LoginUserBO loginUser = requireLoginUser(authorization);
-        UserProfileResp profileResp = new UserProfileResp();
-        profileResp.setUserId(loginUser.getUserId());
-        profileResp.setUsername(loginUser.getUsername());
-        profileResp.setRole(loginUser.getRole());
-        profileResp.setEmail(loginUser.getEmail());
-        return profileResp;
+        return toUserProfile(loginUser, resolveUserStatus(loginUser.getUserId()));
     }
 
     public UserProfileResp updateCurrentUser(String authorization, UserUpdateReq req) {
@@ -125,10 +108,7 @@ public class AuthService {
             throw new IllegalArgumentException("email length exceeds " + EMAIL_MAX_LENGTH);
         }
         if (StringUtils.hasText(newPassword)) {
-            if (newPassword.length() < PASSWORD_MIN_LENGTH || newPassword.length() > PASSWORD_MAX_LENGTH) {
-                throw new IllegalArgumentException(
-                        "password length must be between " + PASSWORD_MIN_LENGTH + " and " + PASSWORD_MAX_LENGTH);
-            }
+            validatePassword(newPassword);
         }
 
         UserDO update = new UserDO();
@@ -149,6 +129,7 @@ public class AuthService {
         resp.setUsername(effectiveUsername);
         resp.setRole(loginUser.getRole());
         resp.setEmail(effectiveEmail);
+        resp.setStatus(resolveUserStatus(loginUser.getUserId()));
         return resp;
     }
 
@@ -157,6 +138,139 @@ public class AuthService {
         authTokenService.invalidate(token);
     }
 
+    public List<UserProfileResp> listUsers(String authorization) {
+        requireAdminUser(authorization);
+        return userDAO.findAll().stream()
+                .map(this::toUserProfile)
+                .toList();
+    }
+
+    public UserProfileResp createUserByAdmin(String authorization, AdminUserCreateReq req) {
+        requireAdminUser(authorization);
+        if (req == null) {
+            throw new IllegalArgumentException("request body cannot be null");
+        }
+        UserDO created = createUserInternal(req.getUsername(), req.getPassword(), req.getEmail(), req.getRole());
+        return toUserProfile(created);
+    }
+
+    public UserProfileResp updateUserByAdmin(String authorization, Long userId, AdminUserUpdateReq req) {
+        LoginUserBO adminUser = requireAdminUser(authorization);
+        if (userId == null) {
+            throw new IllegalArgumentException("userId cannot be null");
+        }
+        if (req == null) {
+            throw new IllegalArgumentException("request body cannot be null");
+        }
+        UserDO target = requireUserExists(userId);
+
+        String normalizedEmail = normalizeText(req.getEmail());
+        if (normalizedEmail != null && normalizedEmail.length() > EMAIL_MAX_LENGTH) {
+            throw new IllegalArgumentException("email length exceeds " + EMAIL_MAX_LENGTH);
+        }
+
+        UserDO update = new UserDO();
+        update.setUserId(userId);
+        update.setEmail(normalizedEmail);
+        update.setStatus(normalizeStatus(req.getStatus()));
+        if (StringUtils.hasText(req.getRole())) {
+            UserRole role = UserRole.fromInput(req.getRole());
+            if (role == null) {
+                throw new IllegalArgumentException("role must be ADMIN, DEVELOPER or ANALYST");
+            }
+            update.setRole(role.getCode());
+        }
+
+        if (userId.equals(adminUser.getUserId()) && update.getStatus() != null && update.getStatus() <= 0) {
+            throw new IllegalArgumentException("admin cannot disable self");
+        }
+        if (userId.equals(adminUser.getUserId()) && UserRole.ANALYST.getCode().equals(update.getRole())) {
+            throw new IllegalArgumentException("admin cannot change self role to ANALYST");
+        }
+
+        userDAO.updateAdminFieldsByUserId(update);
+
+        UserDO reloaded = userDAO.findByUserId(target.getUserId());
+        return toUserProfile(reloaded);
+    }
+
+    public void resetPasswordByAdmin(String authorization, Long userId, AdminUserResetPasswordReq req) {
+        requireAdminUser(authorization);
+        if (userId == null) {
+            throw new IllegalArgumentException("userId cannot be null");
+        }
+        if (req == null) {
+            throw new IllegalArgumentException("request body cannot be null");
+        }
+        requireUserExists(userId);
+        validatePassword(req.getPassword());
+
+        UserDO update = new UserDO();
+        update.setUserId(userId);
+        update.setPasswordHash(passwordEncoder.encode(req.getPassword()));
+        userDAO.updateAdminFieldsByUserId(update);
+    }
+
+    public void deleteUserByAdmin(String authorization, Long userId) {
+        LoginUserBO adminUser = requireAdminUser(authorization);
+        if (userId == null) {
+            throw new IllegalArgumentException("userId cannot be null");
+        }
+        if (userId.equals(adminUser.getUserId())) {
+            throw new IllegalArgumentException("admin cannot delete self");
+        }
+        requireUserExists(userId);
+        int deleted = userDAO.deleteByUserId(userId);
+        if (deleted <= 0) {
+            throw new IllegalStateException("User changed, please retry");
+        }
+    }
+
+    public InviteCodeResp getInviteCodes(String authorization) {
+        requireAdminUser(authorization);
+        OrganizationDO organization = organizationDAO.findById(INVITE_CONFIG_ID);
+        if (organization == null) {
+            throw new IllegalStateException("invite code config does not exist");
+        }
+        String analystCode = normalizeText(organization.getAnalystInviteCode());
+        String developerCode = normalizeText(organization.getDeveloperInviteCode());
+        if (!StringUtils.hasText(analystCode) || !StringUtils.hasText(developerCode)) {
+            analystCode = generateInviteCode("ANALYST");
+            developerCode = generateInviteCode("DEVELOPER");
+            while (analystCode.equals(developerCode)) {
+                developerCode = generateInviteCode("DEVELOPER");
+            }
+            organizationDAO.updateInviteCodes(INVITE_CONFIG_ID, analystCode, developerCode);
+        }
+        InviteCodeResp resp = new InviteCodeResp();
+        resp.setAnalystInviteCode(analystCode);
+        resp.setDeveloperInviteCode(developerCode);
+        return resp;
+    }
+
+    public InviteCodeResp updateInviteCodes(String authorization, InviteCodeUpdateReq req) {
+        requireAdminUser(authorization);
+        if (req == null) {
+            throw new IllegalArgumentException("request body cannot be null");
+        }
+        String analystCode = normalizeText(req.getAnalystInviteCode());
+        String developerCode = normalizeText(req.getDeveloperInviteCode());
+        if (!StringUtils.hasText(analystCode) || !StringUtils.hasText(developerCode)) {
+            throw new IllegalArgumentException("invite codes cannot be empty");
+        }
+        if (analystCode.length() > INVITE_CODE_MAX_LENGTH || developerCode.length() > INVITE_CODE_MAX_LENGTH) {
+            throw new IllegalArgumentException("invite code length exceeds " + INVITE_CODE_MAX_LENGTH);
+        }
+        if (analystCode.equals(developerCode)) {
+            throw new IllegalArgumentException("analyst and developer invite codes must be different");
+        }
+        organizationDAO.updateInviteCodes(INVITE_CONFIG_ID, analystCode, developerCode);
+        InviteCodeResp resp = new InviteCodeResp();
+        resp.setAnalystInviteCode(analystCode);
+        resp.setDeveloperInviteCode(developerCode);
+        return resp;
+    }
+
     public LoginUserBO requireLoginUser(String authorization) {
         String token = parseBearerToken(authorization);
         LoginUserBO loginUser = authTokenService.resolveValidUser(token);
@@ -166,6 +280,15 @@ public class AuthService {
         return loginUser;
     }
 
+    public LoginUserBO requireAdminUser(String authorization) {
+        LoginUserBO loginUser = requireLoginUser(authorization);
+        UserRole role = UserRole.fromInput(loginUser.getRole());
+        if (role == null || !role.isAdmin()) {
+            throw new ForbiddenException("ADMIN role required");
+        }
+        return loginUser;
+    }
+
     public String parseBearerToken(String authorization) {
         if (!StringUtils.hasText(authorization)) {
             return null;
@@ -181,6 +304,79 @@ public class AuthService {
         return StringUtils.hasText(token) ? token : null;
     }
 
+    private UserDO createUserInternal(String rawUsername, String rawPassword, String rawEmail, String rawRole) {
+        String username = normalizeUsername(rawUsername);
+        String email = normalizeText(rawEmail);
+        validateRegisterInput(username, rawPassword, email, rawRole);
+        UserRole role = UserRole.fromInput(rawRole);
+
+        UserDO existingUser = userDAO.findByUsername(username);
+        if (existingUser != null) {
+            throw new IllegalArgumentException("username already exists");
+        }
+
+        UserDO newUser = new UserDO();
+        newUser.setUsername(username);
+        newUser.setPasswordHash(passwordEncoder.encode(rawPassword));
+        newUser.setRole(role.getCode());
+        newUser.setEmail(email);
+        newUser.setStatus(USER_ENABLED);
+        newUser.setCreatedAt(LocalDateTime.now());
+        userDAO.save(newUser);
+        return newUser;
+    }
+
+    private UserDO requireUserExists(Long userId) {
+        UserDO target = userDAO.findByUserId(userId);
+        if (target == null) {
+            throw new IllegalArgumentException("User does not exist");
+        }
+        return target;
+    }
+
+    private UserLoginResp buildLoginResp(LoginUserBO loginUser) {
+        UserLoginResp resp = new UserLoginResp();
+        resp.setToken(loginUser.getToken());
+        resp.setExpiresAt(loginUser.getExpiresAt());
+        resp.setUserId(loginUser.getUserId());
+        resp.setUsername(loginUser.getUsername());
+        resp.setRole(loginUser.getRole());
+        resp.setEmail(loginUser.getEmail());
+        return resp;
+    }
+
+    private UserProfileResp toUserProfile(LoginUserBO loginUser, Integer userStatus) {
+        UserProfileResp profileResp = new UserProfileResp();
+        profileResp.setUserId(loginUser.getUserId());
+        profileResp.setUsername(loginUser.getUsername());
+        profileResp.setRole(loginUser.getRole());
+        profileResp.setEmail(loginUser.getEmail());
+        profileResp.setStatus(userStatus);
+        return profileResp;
+    }
+
+    private UserProfileResp toUserProfile(UserDO userDO) {
+        UserProfileResp profileResp = new UserProfileResp();
+        profileResp.setUserId(userDO.getUserId());
+        profileResp.setUsername(userDO.getUsername());
+        profileResp.setRole(userDO.getRole());
+        profileResp.setEmail(userDO.getEmail());
+        profileResp.setStatus(userDO.getStatus());
+        return profileResp;
+    }
+
+    private Integer resolveUserStatus(Long userId) {
+        UserDO user = userDAO.findByUserId(userId);
+        return user == null ? USER_ENABLED : user.getStatus();
+    }
+
+    private Integer normalizeStatus(Integer status) {
+        if (status == null) {
+            return null;
+        }
+        return status > 0 ? 1 : 0;
+    }
+
     private String normalizeUsername(String username) {
         if (!StringUtils.hasText(username)) {
             return null;
@@ -195,7 +391,7 @@ public class AuthService {
         return text.trim();
     }
 
-    private UserRole validateRegisterInput(String username, String password, String email, String roleText) {
+    private void validateRegisterInput(String username, String password, String email, String roleText) {
         if (!StringUtils.hasText(username)) {
             throw new IllegalArgumentException("username cannot be empty");
         }
@@ -203,6 +399,19 @@ public class AuthService {
             throw new IllegalArgumentException("username length exceeds " + USERNAME_MAX_LENGTH);
         }
 
+        validatePassword(password);
+
+        if (StringUtils.hasText(email) && email.length() > EMAIL_MAX_LENGTH) {
+            throw new IllegalArgumentException("email length exceeds " + EMAIL_MAX_LENGTH);
+        }
+
+        UserRole role = UserRole.fromInput(roleText);
+        if (role == null) {
+            throw new IllegalArgumentException("role must be ADMIN, DEVELOPER or ANALYST");
+        }
+    }
+
+    private void validatePassword(String password) {
         if (!StringUtils.hasText(password)) {
             throw new IllegalArgumentException("password cannot be empty");
         }
@@ -210,16 +419,23 @@ public class AuthService {
             throw new IllegalArgumentException(
                     "password length must be between " + PASSWORD_MIN_LENGTH + " and " + PASSWORD_MAX_LENGTH);
         }
+    }
 
-        if (StringUtils.hasText(email) && email.length() > EMAIL_MAX_LENGTH) {
-            throw new IllegalArgumentException("email length exceeds " + EMAIL_MAX_LENGTH);
+    private UserRole resolveInviteRole(OrganizationDO organization, String inviteCode) {
+        String analystCode = normalizeText(organization.getAnalystInviteCode());
+        String developerCode = normalizeText(organization.getDeveloperInviteCode());
+        if (inviteCode.equals(analystCode)) {
+            return UserRole.ANALYST;
         }
-
-        UserRole role = UserRole.fromInput(roleText);
-        if (role == null) {
-            throw new IllegalArgumentException("role must be ANALYST or OPS");
+        if (inviteCode.equals(developerCode)) {
+            return UserRole.DEVELOPER;
         }
-        return role;
+        throw new IllegalArgumentException("invalid inviteCode");
+    }
+
+    private String generateInviteCode(String rolePrefix) {
+        String randomPart = UUID.randomUUID().toString().replace("-", "").substring(0, 10).toUpperCase();
+        return rolePrefix + "-" + randomPart;
     }
 
     private boolean isPasswordValid(String rawPassword, String passwordHash) {

+ 7 - 1
backend/src/main/java/com/wenshu/platform/service/resource/AutoScalingService.java

@@ -14,6 +14,8 @@ import com.wenshu.platform.model.enums.ScalingTriggerType;
 import com.wenshu.platform.model.query.ScalingRuleQuery;
 import com.wenshu.platform.model.resp.PageResp;
 import com.wenshu.platform.model.resp.ScalingRuleResp;
+import com.wenshu.platform.service.resource.autoscaling.quartz.AutoScalingQuartzScheduler;
+import com.wenshu.platform.service.resource.autoscaling.quartz.ScalingCronExpressionSupport;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -31,6 +33,7 @@ public class AutoScalingService {
 
     private final ScalingRuleDAO scalingRuleDAO;
     private final ClusterDAO clusterDAO;
+    private final AutoScalingQuartzScheduler autoScalingQuartzScheduler;
 
     @Transactional(rollbackFor = Exception.class)
     public ScalingRuleResp createRule(ScalingRuleDO inputRule) {
@@ -38,6 +41,7 @@ public class AutoScalingService {
         ScalingRuleDO rule = normalizeRule(inputRule);
         validateRule(rule);
         scalingRuleDAO.save(rule);
+        autoScalingQuartzScheduler.scheduleRuleAfterCommit(rule);
         return ScalingConverter.toRuleResp(rule);
     }
 
@@ -52,10 +56,11 @@ public class AutoScalingService {
             throw new IllegalArgumentException("Scaling rule does not exist");
         }
         scalingRuleDAO.delete(ruleId);
+        autoScalingQuartzScheduler.unscheduleRuleAfterCommit(ruleId);
     }
 
     public PageResp<ScalingRuleResp> listRules(ScalingRuleQuery query) {
-        ResourceAuthSupport.requireOpsRole();
+        ResourceAuthSupport.requireReadRole();
         ScalingRuleQuery normalizedQuery = normalizeRuleQuery(query);
         long total = scalingRuleDAO.count(normalizedQuery);
         List<ScalingRuleResp> items = ScalingConverter.toRuleRespList(scalingRuleDAO.find(normalizedQuery));
@@ -115,6 +120,7 @@ public class AutoScalingService {
             if (!StringUtils.hasText(rule.getCronExpression())) {
                 throw new IllegalArgumentException("cronExpression cannot be empty for CRON trigger");
             }
+            ScalingCronExpressionSupport.normalizeToQuartzExpression(rule.getCronExpression());
         } else if (ScalingTriggerType.METRIC.getCode().equals(rule.getTriggerType())) {
             if (!StringUtils.hasText(rule.getMetricName())) {
                 throw new IllegalArgumentException("metricName cannot be empty for METRIC trigger");

+ 4 - 4
backend/src/main/java/com/wenshu/platform/service/resource/ClusterService.java

@@ -84,7 +84,7 @@ public class ClusterService {
     private final TransactionTemplate transactionTemplate;
 
     public PageResp<ClusterResp> listClusters(ClusterQuery query) {
-        ResourceAuthSupport.requireOpsRole();
+        ResourceAuthSupport.requireReadRole();
         ClusterQuery normalizedQuery = normalizeQuery(query);
         long total = clusterDAO.count(normalizedQuery);
         List<ClusterResp> items = ClusterConverter.toRespList(clusterDAO.find(normalizedQuery));
@@ -93,12 +93,12 @@ public class ClusterService {
     }
 
     public ClusterResp getCluster(Long clusterId) {
-        ResourceAuthSupport.requireOpsRole();
+        ResourceAuthSupport.requireReadRole();
         return ClusterConverter.toResp(requireCluster(clusterId));
     }
 
     public List<ClusterNodeResp> listClusterNodes(Long clusterId) {
-        ResourceAuthSupport.requireOpsRole();
+        ResourceAuthSupport.requireReadRole();
         requireCluster(clusterId);
         List<ClusterNodeBO> nodes = clusterNodeDAO.findDetailsByClusterId(clusterId);
         assignNodeRoles(nodes);
@@ -134,7 +134,7 @@ public class ClusterService {
     }
 
     public List<ClusterComponentVersionsResp> listComponentVersions() {
-        ResourceAuthSupport.requireOpsRole();
+        ResourceAuthSupport.requireReadRole();
         return SUPPORTED_COMPONENT_TYPE_LIST.stream()
                 .map(componentType -> ClusterConverter.toComponentVersionsResp(
                         componentType,

+ 2 - 2
backend/src/main/java/com/wenshu/platform/service/resource/ConfigService.java

@@ -120,13 +120,13 @@ public class ConfigService {
     private final StarRocksConfigApplyService starRocksConfigApplyService;
 
     public ConfigVersionResp getCurrentConfig(Long clusterId) {
-        ResourceAuthSupport.requireOpsRole();
+        ResourceAuthSupport.requireReadRole();
         requireUpdatableCluster(clusterId);
         return sanitizeManagedKeysForResponse(ConfigConverter.toResp(configVersionDAO.findActiveByClusterId(clusterId)));
     }
 
     public List<ConfigVersionResp> listConfigHistory(Long clusterId) {
-        ResourceAuthSupport.requireOpsRole();
+        ResourceAuthSupport.requireReadRole();
         requireUpdatableCluster(clusterId);
         return ConfigConverter.toRespList(configVersionDAO.findByClusterId(clusterId)).stream()
                 .map(this::sanitizeManagedKeysForResponse)

+ 30 - 4
backend/src/main/java/com/wenshu/platform/service/resource/ResourceAuthSupport.java

@@ -2,6 +2,7 @@ package com.wenshu.platform.service.resource;
 
 import com.wenshu.platform.service.auth.ForbiddenException;
 import com.wenshu.platform.service.auth.UnauthorizedException;
+import com.wenshu.platform.model.enums.UserRole;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.GrantedAuthority;
 import org.springframework.security.core.context.SecurityContextHolder;
@@ -17,12 +18,37 @@ public final class ResourceAuthSupport {
         if (authentication == null || authentication.getAuthorities() == null) {
             throw new UnauthorizedException("Please login first");
         }
-        boolean hasOpsRole = authentication.getAuthorities().stream()
+        boolean hasPermission = authentication.getAuthorities().stream()
                 .map(GrantedAuthority::getAuthority)
                 .filter(StringUtils::hasText)
-                .anyMatch(authority -> "ROLE_OPS".equalsIgnoreCase(authority.trim()));
-        if (!hasOpsRole) {
-            throw new ForbiddenException("OPS role required");
+                .anyMatch(authority -> {
+                    String normalized = authority.trim().toUpperCase();
+                    return ("ROLE_" + UserRole.ADMIN.getCode()).equals(normalized)
+                            || ("ROLE_" + UserRole.DEVELOPER.getCode()).equals(normalized)
+                            || "ROLE_OPS".equals(normalized);
+                });
+        if (!hasPermission) {
+            throw new ForbiddenException("DEVELOPER or ADMIN role required");
+        }
+    }
+
+    public static void requireReadRole() {
+        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+        if (authentication == null || authentication.getAuthorities() == null) {
+            throw new UnauthorizedException("Please login first");
+        }
+        boolean hasPermission = authentication.getAuthorities().stream()
+                .map(GrantedAuthority::getAuthority)
+                .filter(StringUtils::hasText)
+                .anyMatch(authority -> {
+                    String normalized = authority.trim().toUpperCase();
+                    return ("ROLE_" + UserRole.ADMIN.getCode()).equals(normalized)
+                            || ("ROLE_" + UserRole.DEVELOPER.getCode()).equals(normalized)
+                            || ("ROLE_" + UserRole.ANALYST.getCode()).equals(normalized)
+                            || "ROLE_OPS".equals(normalized);
+                });
+        if (!hasPermission) {
+            throw new ForbiddenException("ANALYST, DEVELOPER or ADMIN role required");
         }
     }
 }

+ 64 - 8
backend/src/main/java/com/wenshu/platform/service/resource/ScalingService.java

@@ -50,6 +50,7 @@ public class ScalingService {
     private static final String EVENT_TYPE_SCALE_OUT = "SCALE_OUT";
     private static final String EVENT_TYPE_SCALE_IN = "SCALE_IN";
     private static final String EVENT_TRIGGER_MANUAL = "MANUAL";
+    private static final String EVENT_TRIGGER_AUTO = "AUTO";
     private static final String EVENT_STATUS_RUNNING = "RUNNING";
     private static final String EVENT_STATUS_SUCCESS = "SUCCESS";
     private static final String EVENT_STATUS_FAILED = "FAILED";
@@ -63,6 +64,35 @@ public class ScalingService {
 
     public ScalingEventResp manualScaleOut(Long clusterId, ClusterScaleOutBO inputBO) {
         ResourceAuthSupport.requireOpsRole();
+        return executeScaleOut(clusterId, inputBO, null, EVENT_TRIGGER_MANUAL);
+    }
+
+    public ScalingEventResp manualScaleIn(Long clusterId, ClusterScaleInBO inputBO) {
+        ResourceAuthSupport.requireOpsRole();
+        return executeScaleIn(clusterId, inputBO, null, EVENT_TRIGGER_MANUAL);
+    }
+
+    public ScalingEventResp autoScaleOutByRule(Long clusterId, Long scaleRuleId, Integer count, String triggerSource) {
+        ClusterScaleOutBO request = new ClusterScaleOutBO();
+        request.setSelectMode(ScalingSelectMode.AUTO.getCode());
+        request.setCount(count);
+        request.setMachineIds(List.of());
+        return executeScaleOut(clusterId, request, scaleRuleId, normalizeTriggerSource(triggerSource));
+    }
+
+    public ScalingEventResp autoScaleInByRule(Long clusterId, Long scaleRuleId, Integer count, String triggerSource) {
+        ClusterScaleInBO request = new ClusterScaleInBO();
+        request.setSelectMode(ScalingSelectMode.AUTO.getCode());
+        request.setCount(count);
+        request.setNodeIds(List.of());
+        return executeScaleIn(clusterId, request, scaleRuleId, normalizeTriggerSource(triggerSource));
+    }
+
+    private ScalingEventResp executeScaleOut(
+            Long clusterId,
+            ClusterScaleOutBO inputBO,
+            Long scaleRuleId,
+            String triggerSource) {
         ClusterScaleOutBO request = normalizeScaleOutRequest(inputBO);
         ClusterDO cluster = requireCluster(clusterId);
         ensureClusterRunning(cluster);
@@ -74,7 +104,12 @@ public class ScalingService {
         NodeSpec requiredSpec = resolveRequiredSpec(currentNodes);
         List<StockInfoDO> selectedMachines = selectScaleOutMachines(request, requiredSpec);
 
-        ScalingEventDO event = createManualEvent(clusterId, EVENT_TYPE_SCALE_OUT, nodesBefore);
+        ScalingEventDO event = createScalingEvent(
+                clusterId,
+                scaleRuleId,
+                EVENT_TYPE_SCALE_OUT,
+                triggerSource,
+                nodesBefore);
         try {
             Integer nodesAfter = transactionTemplate.execute(status -> {
                 enterScaling(clusterId);
@@ -92,8 +127,11 @@ public class ScalingService {
         }
     }
 
-    public ScalingEventResp manualScaleIn(Long clusterId, ClusterScaleInBO inputBO) {
-        ResourceAuthSupport.requireOpsRole();
+    private ScalingEventResp executeScaleIn(
+            Long clusterId,
+            ClusterScaleInBO inputBO,
+            Long scaleRuleId,
+            String triggerSource) {
         ClusterScaleInBO request = normalizeScaleInRequest(inputBO);
         ClusterDO cluster = requireCluster(clusterId);
         ensureClusterRunning(cluster);
@@ -109,7 +147,12 @@ public class ScalingService {
             throw new IllegalArgumentException("target nodes cannot be less than " + MIN_CLUSTER_NODE_COUNT);
         }
 
-        ScalingEventDO event = createManualEvent(clusterId, EVENT_TYPE_SCALE_IN, nodesBefore);
+        ScalingEventDO event = createScalingEvent(
+                clusterId,
+                scaleRuleId,
+                EVENT_TYPE_SCALE_IN,
+                triggerSource,
+                nodesBefore);
         try {
             Integer nodesAfter = transactionTemplate.execute(status -> {
                 enterScaling(clusterId);
@@ -128,7 +171,7 @@ public class ScalingService {
     }
 
     public PageResp<ScalingEventResp> listScalingEvents(ScalingEventQuery query) {
-        ResourceAuthSupport.requireOpsRole();
+        ResourceAuthSupport.requireReadRole();
         ScalingEventQuery normalizedQuery = normalizeQuery(query);
         long total = scalingEventDAO.count(normalizedQuery);
         List<ScalingEventResp> items = ScalingConverter.toEventRespList(scalingEventDAO.find(normalizedQuery));
@@ -534,12 +577,25 @@ public class ScalingService {
         }
     }
 
-    private ScalingEventDO createManualEvent(Long clusterId, String eventType, int nodesBefore) {
+    private String normalizeTriggerSource(String triggerSource) {
+        String normalized = normalizeUpperText(triggerSource);
+        if (!StringUtils.hasText(normalized)) {
+            return EVENT_TRIGGER_AUTO;
+        }
+        return normalized;
+    }
+
+    private ScalingEventDO createScalingEvent(
+            Long clusterId,
+            Long scaleRuleId,
+            String eventType,
+            String triggerSource,
+            int nodesBefore) {
         ScalingEventDO event = new ScalingEventDO();
         event.setClusterId(clusterId);
-        event.setScaleRuleId(null);
+        event.setScaleRuleId(scaleRuleId);
         event.setEventType(eventType);
-        event.setTriggerSource(EVENT_TRIGGER_MANUAL);
+        event.setTriggerSource(triggerSource);
         event.setNodesBefore(nodesBefore);
         event.setNodesAfter(null);
         event.setStatus(EVENT_STATUS_RUNNING);

+ 31 - 5
backend/src/main/java/com/wenshu/platform/service/resource/StockService.java

@@ -23,6 +23,7 @@ import com.wenshu.platform.model.bo.StockCommandExecuteResultBO;
 import com.wenshu.platform.model.converter.StockConverter;
 import com.wenshu.platform.model.dataobject.StockInfoDO;
 import com.wenshu.platform.model.enums.StockMachineStatus;
+import com.wenshu.platform.model.enums.UserRole;
 import com.wenshu.platform.model.query.StockQuery;
 import com.wenshu.platform.model.req.StockProbeReq;
 import com.wenshu.platform.model.resp.PageResp;
@@ -93,7 +94,7 @@ public class StockService {
     private final K8sOrchestratorClient k8sOrchestratorClient;
 
     public PageResp<StockMachineResp> listMachines(StockQuery query) {
-        requireOpsRole();
+        requireReadRole();
         StockQuery normalizedQuery = normalizeQuery(query);
         long total = stockDAO.count(normalizedQuery);
         List<StockMachineResp> items = StockConverter.toRespList(stockDAO.find(normalizedQuery));
@@ -434,12 +435,37 @@ public class StockService {
         if (authentication == null || authentication.getAuthorities() == null) {
             throw new UnauthorizedException("Please login first");
         }
-        boolean hasOpsRole = authentication.getAuthorities().stream()
+        boolean hasPermission = authentication.getAuthorities().stream()
+                .map(GrantedAuthority::getAuthority)
+                .filter(StringUtils::hasText)
+                .anyMatch(authority -> {
+                    String normalized = authority.trim().toUpperCase(Locale.ROOT);
+                    return ("ROLE_" + UserRole.ADMIN.getCode()).equals(normalized)
+                            || ("ROLE_" + UserRole.DEVELOPER.getCode()).equals(normalized)
+                            || "ROLE_OPS".equals(normalized);
+                });
+        if (!hasPermission) {
+            throw new ForbiddenException("DEVELOPER or ADMIN role required");
+        }
+    }
+
+    private void requireReadRole() {
+        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+        if (authentication == null || authentication.getAuthorities() == null) {
+            throw new UnauthorizedException("Please login first");
+        }
+        boolean hasPermission = authentication.getAuthorities().stream()
                 .map(GrantedAuthority::getAuthority)
                 .filter(StringUtils::hasText)
-                .anyMatch(authority -> "ROLE_OPS".equalsIgnoreCase(authority.trim()));
-        if (!hasOpsRole) {
-            throw new ForbiddenException("OPS role required");
+                .anyMatch(authority -> {
+                    String normalized = authority.trim().toUpperCase(Locale.ROOT);
+                    return ("ROLE_" + UserRole.ADMIN.getCode()).equals(normalized)
+                            || ("ROLE_" + UserRole.DEVELOPER.getCode()).equals(normalized)
+                            || ("ROLE_" + UserRole.ANALYST.getCode()).equals(normalized)
+                            || "ROLE_OPS".equals(normalized);
+                });
+        if (!hasPermission) {
+            throw new ForbiddenException("ANALYST, DEVELOPER or ADMIN role required");
         }
     }
 

+ 93 - 0
backend/src/main/java/com/wenshu/platform/service/resource/autoscaling/ScalingExecutionService.java

@@ -0,0 +1,93 @@
+package com.wenshu.platform.service.resource.autoscaling;
+
+import java.util.Locale;
+import java.util.List;
+
+import com.wenshu.platform.dao.ClusterDAO;
+import com.wenshu.platform.dao.ClusterNodeDAO;
+import com.wenshu.platform.model.dataobject.ClusterDO;
+import com.wenshu.platform.model.dataobject.ClusterNodeDO;
+import com.wenshu.platform.model.dataobject.ScalingRuleDO;
+import com.wenshu.platform.service.resource.ScalingService;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+@Service
+@RequiredArgsConstructor
+public class ScalingExecutionService {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(ScalingExecutionService.class);
+    private static final String COMPONENT_STARROCKS = "STARROCKS";
+
+    private final ClusterNodeDAO clusterNodeDAO;
+    private final ClusterDAO clusterDAO;
+    private final ScalingService scalingService;
+
+    public void executeRuleScaling(ScalingRuleDO rule, String triggerSource) {
+        if (rule == null || rule.getScaleRuleId() == null || rule.getClusterId() == null || rule.getTargetNodes() == null) {
+            return;
+        }
+        List<ClusterNodeDO> currentNodes = clusterNodeDAO.findByClusterId(rule.getClusterId());
+        int currentNodeCount = resolveCurrentTargetNodeCount(rule.getClusterId(), currentNodes);
+        int targetNodeCount = rule.getTargetNodes();
+        if (targetNodeCount == currentNodeCount) {
+            LOGGER.info(
+                    "Skip auto scaling because current target nodes already match rule target. ruleId={}, clusterId={}, targetNodes={}",
+                    rule.getScaleRuleId(),
+                    rule.getClusterId(),
+                    targetNodeCount);
+            return;
+        }
+
+        if (targetNodeCount > currentNodeCount) {
+            int delta = targetNodeCount - currentNodeCount;
+            scalingService.autoScaleOutByRule(rule.getClusterId(), rule.getScaleRuleId(), delta, triggerSource);
+            return;
+        }
+
+        int delta = currentNodeCount - targetNodeCount;
+        scalingService.autoScaleInByRule(rule.getClusterId(), rule.getScaleRuleId(), delta, triggerSource);
+    }
+
+    private int resolveCurrentTargetNodeCount(Long clusterId, List<ClusterNodeDO> currentNodes) {
+        int totalNodeCount = currentNodes == null ? 0 : currentNodes.size();
+        ClusterDO cluster = clusterDAO.findById(clusterId);
+        String componentType = normalizeUpperText(cluster == null ? null : cluster.getComponentType());
+        if (!COMPONENT_STARROCKS.equals(componentType)) {
+            return totalNodeCount;
+        }
+        int backendNodeCount = countStarRocksBackendNodes(currentNodes);
+        if (backendNodeCount > 0) {
+            return backendNodeCount;
+        }
+        // Backward-compatible fallback for legacy rows without role-like node names.
+        return Math.max(totalNodeCount - 1, 0);
+    }
+
+    private int countStarRocksBackendNodes(List<ClusterNodeDO> nodes) {
+        if (nodes == null || nodes.isEmpty()) {
+            return 0;
+        }
+        return (int) nodes.stream()
+                .filter(this::isStarRocksBackendNode)
+                .count();
+    }
+
+    private boolean isStarRocksBackendNode(ClusterNodeDO node) {
+        if (node == null || !StringUtils.hasText(node.getNodeName())) {
+            return false;
+        }
+        String lowerNodeName = node.getNodeName().trim().toLowerCase(Locale.ROOT);
+        return lowerNodeName.contains("-backend-");
+    }
+
+    private String normalizeUpperText(String value) {
+        if (!StringUtils.hasText(value)) {
+            return null;
+        }
+        return value.trim().toUpperCase(Locale.ROOT);
+    }
+}

+ 103 - 0
backend/src/main/java/com/wenshu/platform/service/resource/autoscaling/ScalingRuleEvaluationService.java

@@ -0,0 +1,103 @@
+package com.wenshu.platform.service.resource.autoscaling;
+
+import java.text.ParseException;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+import java.util.List;
+import java.util.OptionalLong;
+
+import com.wenshu.platform.config.AutoScalingQuartzProperties;
+import com.wenshu.platform.dao.ScalingRuleDAO;
+import com.wenshu.platform.model.dataobject.ScalingRuleDO;
+import com.wenshu.platform.model.enums.ScalingTriggerType;
+import com.wenshu.platform.service.resource.autoscaling.quartz.ScalingCronExpressionSupport;
+import lombok.RequiredArgsConstructor;
+import org.quartz.CronExpression;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class ScalingRuleEvaluationService {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(ScalingRuleEvaluationService.class);
+
+    private final ScalingRuleDAO scalingRuleDAO;
+    private final AutoScalingQuartzProperties quartzProperties;
+
+    public boolean shouldExecuteCronRule(ScalingRuleDO rule, Date fireTime) {
+        if (rule == null || rule.getScaleRuleId() == null || rule.getClusterId() == null) {
+            return false;
+        }
+        if (!ScalingTriggerType.CRON.getCode().equals(rule.getTriggerType())) {
+            return false;
+        }
+        List<ScalingRuleDO> clusterCronRules = scalingRuleDAO.findEnabledByClusterIdAndTriggerType(
+                rule.getClusterId(), ScalingTriggerType.CRON.getCode());
+        if (clusterCronRules.isEmpty()) {
+            return false;
+        }
+
+        Date executeTime = fireTime == null ? new Date() : fireTime;
+        OptionalLong minRuleId = clusterCronRules.stream()
+                .filter(candidate -> matchSameMinute(candidate, executeTime))
+                .map(ScalingRuleDO::getScaleRuleId)
+                .filter(id -> id != null && id > 0)
+                .mapToLong(Long::longValue)
+                .min();
+        if (minRuleId.isEmpty()) {
+            return false;
+        }
+        boolean winner = minRuleId.getAsLong() == rule.getScaleRuleId();
+        if (!winner) {
+            LOGGER.info(
+                    "Skip cron autoscaling because another rule has higher priority in same minute. clusterId={}, ruleId={}, winnerRuleId={}, fireTime={}",
+                    rule.getClusterId(),
+                    rule.getScaleRuleId(),
+                    minRuleId.getAsLong(),
+                    executeTime);
+        }
+        return winner;
+    }
+
+    public boolean shouldExecuteMetricRule(ScalingRuleDO rule, Date fireTime) {
+        if (rule == null || !ScalingTriggerType.METRIC.getCode().equals(rule.getTriggerType())) {
+            return false;
+        }
+        LOGGER.debug(
+                "Metric autoscaling evaluation extension point executed. ruleId={}, clusterId={}, fireTime={}",
+                rule.getScaleRuleId(),
+                rule.getClusterId(),
+                fireTime);
+        return false;
+    }
+
+    private boolean matchSameMinute(ScalingRuleDO rule, Date fireTime) {
+        if (rule == null) {
+            return false;
+        }
+        try {
+            String quartzExpression = ScalingCronExpressionSupport.normalizeToQuartzExpression(rule.getCronExpression());
+            CronExpression cronExpression = new CronExpression(quartzExpression);
+            ZoneId zoneId = ZoneId.of(quartzProperties.getTimezone());
+            cronExpression.setTimeZone(java.util.TimeZone.getTimeZone(zoneId));
+
+            ZonedDateTime fireZonedTime = fireTime.toInstant().atZone(zoneId);
+            ZonedDateTime minuteStart = fireZonedTime.truncatedTo(ChronoUnit.MINUTES);
+            ZonedDateTime minuteEnd = minuteStart.plusMinutes(1).minusNanos(1);
+            Date previousSecond = Date.from(minuteStart.minusSeconds(1).toInstant());
+            Date next = cronExpression.getNextValidTimeAfter(previousSecond);
+            return next != null && !next.after(Date.from(minuteEnd.toInstant()));
+        } catch (IllegalArgumentException | ParseException ex) {
+            LOGGER.warn(
+                    "Invalid cron expression in scaling rule, skip arbitration check. ruleId={}, cron={}",
+                    rule.getScaleRuleId(),
+                    rule.getCronExpression(),
+                    ex);
+            return false;
+        }
+    }
+}

+ 297 - 0
backend/src/main/java/com/wenshu/platform/service/resource/autoscaling/quartz/AutoScalingQuartzScheduler.java

@@ -0,0 +1,297 @@
+package com.wenshu.platform.service.resource.autoscaling.quartz;
+
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.TimeZone;
+
+import com.wenshu.platform.config.AutoScalingQuartzProperties;
+import com.wenshu.platform.dao.ScalingRuleDAO;
+import com.wenshu.platform.model.dataobject.ScalingRuleDO;
+import com.wenshu.platform.model.enums.ScalingTriggerType;
+import lombok.RequiredArgsConstructor;
+import org.quartz.CronScheduleBuilder;
+import org.quartz.Job;
+import org.quartz.JobBuilder;
+import org.quartz.JobDetail;
+import org.quartz.JobKey;
+import org.quartz.Scheduler;
+import org.quartz.SchedulerException;
+import org.quartz.SimpleScheduleBuilder;
+import org.quartz.Trigger;
+import org.quartz.TriggerBuilder;
+import org.quartz.impl.matchers.GroupMatcher;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+import org.springframework.util.StringUtils;
+
+@Service
+@RequiredArgsConstructor
+public class AutoScalingQuartzScheduler {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(AutoScalingQuartzScheduler.class);
+
+    private final Scheduler scheduler;
+    private final ScalingRuleDAO scalingRuleDAO;
+    private final AutoScalingQuartzProperties quartzProperties;
+
+    @EventListener(ApplicationReadyEvent.class)
+    public void syncEnabledRulesOnStartup() {
+        syncEnabledRules();
+    }
+
+    public void scheduleRuleAfterCommit(ScalingRuleDO rule) {
+        if (rule == null || rule.getScaleRuleId() == null) {
+            return;
+        }
+        runAfterCommit(() -> scheduleRule(rule));
+    }
+
+    public void unscheduleRuleAfterCommit(Long ruleId) {
+        if (ruleId == null || ruleId <= 0) {
+            return;
+        }
+        runAfterCommit(() -> unscheduleRule(ruleId));
+    }
+
+    public void syncEnabledRules() {
+        List<ScalingRuleDO> enabledRules = scalingRuleDAO.findEnabled();
+        Set<Long> enabledRuleIds = new HashSet<>();
+        for (ScalingRuleDO rule : enabledRules) {
+            if (rule != null && rule.getScaleRuleId() != null) {
+                enabledRuleIds.add(rule.getScaleRuleId());
+            }
+        }
+        int failedRules = 0;
+
+        try {
+            Set<JobKey> jobKeys = scheduler.getJobKeys(GroupMatcher.jobGroupEquals(QuartzScalingRuleKeys.JOB_GROUP));
+            for (JobKey jobKey : jobKeys) {
+                Long ruleId = parseRuleId(jobKey);
+                if (ruleId != null && !enabledRuleIds.contains(ruleId)) {
+                    scheduler.deleteJob(jobKey);
+                }
+            }
+            for (ScalingRuleDO rule : enabledRules) {
+                try {
+                    scheduleRule(rule);
+                } catch (RuntimeException ex) {
+                    failedRules++;
+                    LOGGER.error(
+                            "Skip autoscaling rule restore because Quartz schedule sync failed. ruleId={}, clusterId={}, triggerType={}, reason={}",
+                            rule == null ? null : rule.getScaleRuleId(),
+                            rule == null ? null : rule.getClusterId(),
+                            rule == null ? null : rule.getTriggerType(),
+                            ex.getMessage(),
+                            ex);
+                }
+            }
+            LOGGER.info(
+                    "Synchronized autoscaling Quartz jobs. enabledRules={}, failedRules={}",
+                    enabledRuleIds.size(),
+                    failedRules);
+        } catch (SchedulerException ex) {
+            throw new IllegalStateException("Failed to synchronize autoscaling Quartz jobs", ex);
+        }
+    }
+
+    private void scheduleRule(ScalingRuleDO rule) {
+        if (rule == null || rule.getScaleRuleId() == null) {
+            return;
+        }
+        validateRuleForScheduling(rule);
+
+        JobKey jobKey = QuartzScalingRuleKeys.jobKey(rule.getScaleRuleId());
+        Trigger trigger = buildTrigger(rule, jobKey);
+        JobDetail jobDetail = JobBuilder.newJob(resolveJobClass(rule.getTriggerType()))
+                .withIdentity(jobKey)
+                .usingJobData(QuartzScalingRuleKeys.JOB_DATA_RULE_ID, rule.getScaleRuleId())
+                .build();
+
+        try {
+            if (scheduler.checkExists(jobKey)) {
+                scheduler.deleteJob(jobKey);
+            }
+            scheduler.scheduleJob(jobDetail, trigger);
+            LOGGER.info(
+                    "Scheduled autoscaling rule in Quartz. ruleId={}, clusterId={}, triggerType={}",
+                    rule.getScaleRuleId(),
+                    rule.getClusterId(),
+                    rule.getTriggerType());
+        } catch (SchedulerException ex) {
+            throw new IllegalStateException("Failed to schedule autoscaling rule: " + rule.getScaleRuleId(), ex);
+        }
+    }
+
+    private void unscheduleRule(Long ruleId) {
+        JobKey jobKey = QuartzScalingRuleKeys.jobKey(ruleId);
+        try {
+            scheduler.deleteJob(jobKey);
+            LOGGER.info("Deleted autoscaling Quartz job. ruleId={}", ruleId);
+        } catch (SchedulerException ex) {
+            throw new IllegalStateException("Failed to unschedule autoscaling rule: " + ruleId, ex);
+        }
+    }
+
+    private Trigger buildTrigger(ScalingRuleDO rule, JobKey jobKey) {
+        String normalizedTriggerType = ScalingTriggerType.normalizeCode(rule.getTriggerType());
+        if (ScalingTriggerType.CRON.getCode().equals(normalizedTriggerType)) {
+            String quartzExpression = ScalingCronExpressionSupport.normalizeToQuartzExpression(rule.getCronExpression());
+            CronScheduleBuilder scheduleBuilder = CronScheduleBuilder.cronSchedule(quartzExpression)
+                    .inTimeZone(resolveTimeZone())
+                    .withMisfireHandlingInstructionDoNothing();
+            return TriggerBuilder.newTrigger()
+                    .forJob(jobKey)
+                    .withIdentity(QuartzScalingRuleKeys.cronTriggerKey(rule.getScaleRuleId()))
+                    .withSchedule(scheduleBuilder)
+                    .build();
+        }
+
+        if (ScalingTriggerType.METRIC.getCode().equals(normalizedTriggerType)) {
+            int interval = Math.max(30, quartzProperties.getMetricPollIntervalSeconds());
+            if (rule.getDurationSeconds() != null && rule.getDurationSeconds() > 0) {
+                interval = Math.max(interval, rule.getDurationSeconds());
+            }
+            SimpleScheduleBuilder scheduleBuilder = SimpleScheduleBuilder.simpleSchedule()
+                    .repeatForever()
+                    .withIntervalInSeconds(interval)
+                    .withMisfireHandlingInstructionNextWithRemainingCount();
+            return TriggerBuilder.newTrigger()
+                    .forJob(jobKey)
+                    .withIdentity(QuartzScalingRuleKeys.metricTriggerKey(rule.getScaleRuleId()))
+                    .startNow()
+                    .withSchedule(scheduleBuilder)
+                    .build();
+        }
+
+        throw new IllegalArgumentException("Unsupported triggerType: " + rule.getTriggerType());
+    }
+
+    private void validateRuleForScheduling(ScalingRuleDO rule) {
+        List<String> missingFields = new ArrayList<>();
+        if (rule.getScaleRuleId() == null || rule.getScaleRuleId() <= 0) {
+            missingFields.add("scale_rule_id");
+        }
+        if (rule.getClusterId() == null || rule.getClusterId() <= 0) {
+            missingFields.add("cluster_id");
+        }
+        if (rule.getTargetNodes() == null || rule.getTargetNodes() <= 0) {
+            missingFields.add("target_nodes");
+        }
+        if (!StringUtils.hasText(rule.getTriggerType())) {
+            missingFields.add("trigger_type");
+        }
+
+        String normalizedTriggerType = ScalingTriggerType.normalizeCode(rule.getTriggerType());
+        if (StringUtils.hasText(rule.getTriggerType()) && normalizedTriggerType == null) {
+            throw new IllegalArgumentException(String.format(
+                    "Cannot restore autoscaling rule %s: unsupported trigger_type=%s",
+                    rule.getScaleRuleId(),
+                    rule.getTriggerType()));
+        }
+
+        if (ScalingTriggerType.CRON.getCode().equals(normalizedTriggerType)) {
+            if (!StringUtils.hasText(rule.getCronExpression())) {
+                missingFields.add("cron_expression");
+            }
+            if (missingFields.isEmpty()) {
+                try {
+                    ScalingCronExpressionSupport.normalizeToQuartzExpression(rule.getCronExpression());
+                } catch (IllegalArgumentException ex) {
+                    throw new IllegalArgumentException(String.format(
+                            "Cannot restore autoscaling rule %s: invalid cron_expression=%s",
+                            rule.getScaleRuleId(),
+                            rule.getCronExpression()), ex);
+                }
+            }
+        }
+
+        if (ScalingTriggerType.METRIC.getCode().equals(normalizedTriggerType)) {
+            if (!StringUtils.hasText(rule.getMetricName())) {
+                missingFields.add("metric_name");
+            }
+            if (rule.getThresholdValue() == null) {
+                missingFields.add("threshold_value");
+            }
+            if (!StringUtils.hasText(rule.getComparisonOperator())) {
+                missingFields.add("comparison_operator");
+            }
+            if (rule.getDurationSeconds() == null) {
+                missingFields.add("duration_seconds");
+            } else if (rule.getDurationSeconds() <= 0) {
+                throw new IllegalArgumentException(String.format(
+                        "Cannot restore autoscaling rule %s: duration_seconds must be greater than 0",
+                        rule.getScaleRuleId()));
+            }
+        }
+
+        if (!missingFields.isEmpty()) {
+            throw new IllegalArgumentException(String.format(
+                    "Cannot restore autoscaling rule %s: missing required fields [%s]",
+                    rule.getScaleRuleId(),
+                    String.join(", ", missingFields)));
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private Class<? extends Job> resolveJobClass(String triggerType) {
+        String normalizedTriggerType = ScalingTriggerType.normalizeCode(triggerType);
+        if (ScalingTriggerType.CRON.getCode().equals(normalizedTriggerType)) {
+            return CronScalingRuleJob.class;
+        }
+        if (ScalingTriggerType.METRIC.getCode().equals(normalizedTriggerType)) {
+            return MetricScalingRuleJob.class;
+        }
+        throw new IllegalArgumentException("Unsupported triggerType: " + triggerType);
+    }
+
+    private Long parseRuleId(JobKey jobKey) {
+        if (jobKey == null || jobKey.getName() == null) {
+            return null;
+        }
+        String prefix = QuartzScalingRuleKeys.JOB_NAME_PREFIX;
+        if (!jobKey.getName().startsWith(prefix)) {
+            return null;
+        }
+        String idPart = jobKey.getName().substring(prefix.length());
+        try {
+            return Long.parseLong(idPart);
+        } catch (NumberFormatException ex) {
+            return null;
+        }
+    }
+
+    private TimeZone resolveTimeZone() {
+        return TimeZone.getTimeZone(ZoneId.of(quartzProperties.getTimezone()));
+    }
+
+    private void runAfterCommit(Runnable action) {
+        if (TransactionSynchronizationManager.isSynchronizationActive()
+                && TransactionSynchronizationManager.isActualTransactionActive()) {
+            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                @Override
+                public void afterCommit() {
+                    safelyRun(action);
+                }
+            });
+            return;
+        }
+        safelyRun(action);
+    }
+
+    private void safelyRun(Runnable action) {
+        try {
+            action.run();
+        } catch (RuntimeException ex) {
+            LOGGER.error("Failed to synchronize autoscaling Quartz schedule", ex);
+            throw ex;
+        }
+    }
+}

+ 58 - 0
backend/src/main/java/com/wenshu/platform/service/resource/autoscaling/quartz/CronScalingRuleJob.java

@@ -0,0 +1,58 @@
+package com.wenshu.platform.service.resource.autoscaling.quartz;
+
+import java.util.Date;
+
+import com.wenshu.platform.dao.ScalingRuleDAO;
+import com.wenshu.platform.model.dataobject.ScalingRuleDO;
+import com.wenshu.platform.model.enums.ScalingTriggerType;
+import com.wenshu.platform.service.resource.autoscaling.ScalingExecutionService;
+import com.wenshu.platform.service.resource.autoscaling.ScalingRuleEvaluationService;
+import org.quartz.DisallowConcurrentExecution;
+import org.quartz.Job;
+import org.quartz.JobExecutionContext;
+import org.quartz.JobExecutionException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+@Component
+@DisallowConcurrentExecution
+public class CronScalingRuleJob implements Job {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(CronScalingRuleJob.class);
+
+    @Autowired
+    private ScalingRuleDAO scalingRuleDAO;
+
+    @Autowired
+    private ScalingRuleEvaluationService scalingRuleEvaluationService;
+
+    @Autowired
+    private ScalingExecutionService scalingExecutionService;
+
+    @Override
+    public void execute(JobExecutionContext context) throws JobExecutionException {
+        Long ruleId = context.getMergedJobDataMap().getLong(QuartzScalingRuleKeys.JOB_DATA_RULE_ID);
+        if (ruleId == null || ruleId <= 0) {
+            return;
+        }
+
+        ScalingRuleDO rule = scalingRuleDAO.findById(ruleId);
+        if (rule == null || !Integer.valueOf(1).equals(rule.getIsEnabled())) {
+            return;
+        }
+
+        Date fireTime = context.getFireTime();
+        if (!scalingRuleEvaluationService.shouldExecuteCronRule(rule, fireTime)) {
+            return;
+        }
+
+        try {
+            scalingExecutionService.executeRuleScaling(rule, ScalingTriggerType.CRON.getCode());
+        } catch (RuntimeException ex) {
+            LOGGER.error("Failed to execute CRON scaling rule. ruleId={}, clusterId={}", ruleId, rule.getClusterId(), ex);
+            throw new JobExecutionException(ex, false);
+        }
+    }
+}

+ 58 - 0
backend/src/main/java/com/wenshu/platform/service/resource/autoscaling/quartz/MetricScalingRuleJob.java

@@ -0,0 +1,58 @@
+package com.wenshu.platform.service.resource.autoscaling.quartz;
+
+import java.util.Date;
+
+import com.wenshu.platform.dao.ScalingRuleDAO;
+import com.wenshu.platform.model.dataobject.ScalingRuleDO;
+import com.wenshu.platform.model.enums.ScalingTriggerType;
+import com.wenshu.platform.service.resource.autoscaling.ScalingExecutionService;
+import com.wenshu.platform.service.resource.autoscaling.ScalingRuleEvaluationService;
+import org.quartz.DisallowConcurrentExecution;
+import org.quartz.Job;
+import org.quartz.JobExecutionContext;
+import org.quartz.JobExecutionException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+@Component
+@DisallowConcurrentExecution
+public class MetricScalingRuleJob implements Job {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(MetricScalingRuleJob.class);
+
+    @Autowired
+    private ScalingRuleDAO scalingRuleDAO;
+
+    @Autowired
+    private ScalingRuleEvaluationService scalingRuleEvaluationService;
+
+    @Autowired
+    private ScalingExecutionService scalingExecutionService;
+
+    @Override
+    public void execute(JobExecutionContext context) throws JobExecutionException {
+        Long ruleId = context.getMergedJobDataMap().getLong(QuartzScalingRuleKeys.JOB_DATA_RULE_ID);
+        if (ruleId == null || ruleId <= 0) {
+            return;
+        }
+
+        ScalingRuleDO rule = scalingRuleDAO.findById(ruleId);
+        if (rule == null || !Integer.valueOf(1).equals(rule.getIsEnabled())) {
+            return;
+        }
+
+        Date fireTime = context.getFireTime();
+        if (!scalingRuleEvaluationService.shouldExecuteMetricRule(rule, fireTime)) {
+            return;
+        }
+
+        try {
+            scalingExecutionService.executeRuleScaling(rule, ScalingTriggerType.METRIC.getCode());
+        } catch (RuntimeException ex) {
+            LOGGER.error("Failed to execute METRIC scaling rule. ruleId={}, clusterId={}", ruleId, rule.getClusterId(), ex);
+            throw new JobExecutionException(ex, false);
+        }
+    }
+}

+ 32 - 0
backend/src/main/java/com/wenshu/platform/service/resource/autoscaling/quartz/QuartzScalingRuleKeys.java

@@ -0,0 +1,32 @@
+package com.wenshu.platform.service.resource.autoscaling.quartz;
+
+import org.quartz.JobKey;
+import org.quartz.TriggerKey;
+
+public final class QuartzScalingRuleKeys {
+
+    public static final String JOB_GROUP = "AUTO_SCALING_RULE";
+    public static final String CRON_TRIGGER_GROUP = "AUTO_SCALING_RULE_CRON";
+    public static final String METRIC_TRIGGER_GROUP = "AUTO_SCALING_RULE_METRIC";
+
+    public static final String JOB_NAME_PREFIX = "rule-";
+    public static final String CRON_TRIGGER_PREFIX = "cron-";
+    public static final String METRIC_TRIGGER_PREFIX = "metric-";
+
+    public static final String JOB_DATA_RULE_ID = "scaleRuleId";
+
+    private QuartzScalingRuleKeys() {
+    }
+
+    public static JobKey jobKey(Long ruleId) {
+        return new JobKey(JOB_NAME_PREFIX + ruleId, JOB_GROUP);
+    }
+
+    public static TriggerKey cronTriggerKey(Long ruleId) {
+        return new TriggerKey(CRON_TRIGGER_PREFIX + ruleId, CRON_TRIGGER_GROUP);
+    }
+
+    public static TriggerKey metricTriggerKey(Long ruleId) {
+        return new TriggerKey(METRIC_TRIGGER_PREFIX + ruleId, METRIC_TRIGGER_GROUP);
+    }
+}

+ 130 - 0
backend/src/main/java/com/wenshu/platform/service/resource/autoscaling/quartz/ScalingCronExpressionSupport.java

@@ -0,0 +1,130 @@
+package com.wenshu.platform.service.resource.autoscaling.quartz;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+import org.quartz.CronExpression;
+import org.springframework.util.StringUtils;
+
+public final class ScalingCronExpressionSupport {
+
+    private ScalingCronExpressionSupport() {
+    }
+
+    public static String normalizeToQuartzExpression(String rawExpression) {
+        if (!StringUtils.hasText(rawExpression)) {
+            throw new IllegalArgumentException("cronExpression cannot be empty for CRON trigger");
+        }
+        String expression = rawExpression.trim();
+        String[] fields = expression.split("\\s+");
+        String quartzExpression;
+        if (fields.length == 5) {
+            quartzExpression = toQuartzSixField(fields);
+        } else if (fields.length == 6 || fields.length == 7) {
+            quartzExpression = expression;
+        } else {
+            throw new IllegalArgumentException("Unsupported cron expression, expect 5/6/7 fields: " + rawExpression);
+        }
+        if (!CronExpression.isValidExpression(quartzExpression)) {
+            throw new IllegalArgumentException("Invalid cron expression: " + rawExpression);
+        }
+        return quartzExpression;
+    }
+
+    private static String toQuartzSixField(String[] fields) {
+        String minute = fields[0];
+        String hour = fields[1];
+        String dayOfMonth = fields[2];
+        String month = fields[3];
+        String dayOfWeek = normalizeQuartzDayOfWeek(fields[4]);
+
+        boolean anyDayOfMonth = isAnyDayField(dayOfMonth);
+        boolean anyDayOfWeek = isAnyDayField(dayOfWeek);
+        if (!anyDayOfMonth && !anyDayOfWeek) {
+            throw new IllegalArgumentException("5-field cron cannot specify both day-of-month and day-of-week");
+        }
+
+        String quartzDayOfMonth = dayOfMonth;
+        String quartzDayOfWeek = dayOfWeek;
+        if (anyDayOfMonth && anyDayOfWeek) {
+            quartzDayOfMonth = "*";
+            quartzDayOfWeek = "?";
+        } else if (anyDayOfMonth) {
+            quartzDayOfMonth = "?";
+        } else {
+            quartzDayOfWeek = "?";
+        }
+
+        return String.join(" ", "0", minute, hour, quartzDayOfMonth, month, quartzDayOfWeek);
+    }
+
+    private static boolean isAnyDayField(String value) {
+        return "*".equals(value) || "?".equals(value);
+    }
+
+    private static String normalizeQuartzDayOfWeek(String rawValue) {
+        if (!StringUtils.hasText(rawValue)) {
+            return rawValue;
+        }
+        String value = rawValue.trim().toUpperCase(Locale.ROOT);
+        if (isAnyDayField(value) || value.contains("L") || value.contains("#")) {
+            return value;
+        }
+
+        String[] groups = value.split(",");
+        List<String> normalizedGroups = new ArrayList<>(groups.length);
+        for (String group : groups) {
+            normalizedGroups.add(normalizeQuartzDayOfWeekGroup(group));
+        }
+        return String.join(",", normalizedGroups);
+    }
+
+    private static String normalizeQuartzDayOfWeekGroup(String group) {
+        if (!StringUtils.hasText(group)) {
+            throw new IllegalArgumentException("Invalid day-of-week cron field");
+        }
+        String normalized = group.trim().toUpperCase(Locale.ROOT);
+        int slashIndex = normalized.indexOf('/');
+        if (slashIndex >= 0) {
+            String base = normalized.substring(0, slashIndex);
+            String step = normalized.substring(slashIndex + 1);
+            if (!StringUtils.hasText(step)) {
+                throw new IllegalArgumentException("Invalid day-of-week cron field");
+            }
+            if ("*".equals(base)) {
+                return normalized;
+            }
+            return normalizeQuartzDayOfWeekGroup(base) + "/" + step;
+        }
+        int dashIndex = normalized.indexOf('-');
+        if (dashIndex >= 0) {
+            String start = normalized.substring(0, dashIndex);
+            String end = normalized.substring(dashIndex + 1);
+            return normalizeQuartzDayToken(start) + "-" + normalizeQuartzDayToken(end);
+        }
+        return normalizeQuartzDayToken(normalized);
+    }
+
+    private static String normalizeQuartzDayToken(String token) {
+        if (!StringUtils.hasText(token)) {
+            throw new IllegalArgumentException("Invalid day-of-week cron field");
+        }
+        String value = token.trim().toUpperCase(Locale.ROOT);
+        if (value.chars().allMatch(Character::isDigit)) {
+            int unixDow = Integer.parseInt(value);
+            if (unixDow < 0 || unixDow > 7) {
+                throw new IllegalArgumentException("Unsupported day-of-week value: " + token);
+            }
+            return String.valueOf(unixToQuartzDayOfWeek(unixDow));
+        }
+        return value;
+    }
+
+    private static int unixToQuartzDayOfWeek(int unixDow) {
+        if (unixDow == 0 || unixDow == 7) {
+            return 1;
+        }
+        return unixDow + 1;
+    }
+}

+ 6 - 3
backend/src/main/java/com/wenshu/platform/service/taskbuild/DialogBuildService.java

@@ -18,6 +18,7 @@ import com.wenshu.platform.model.dataobject.DialogMessageDO;
 import com.wenshu.platform.model.dataobject.DialogSessionDO;
 import com.wenshu.platform.model.resp.CodeBuildCreateTaskResp;
 import com.wenshu.platform.service.auth.UnauthorizedException;
+import com.wenshu.platform.service.auth.AuthContextSupport;
 import lombok.RequiredArgsConstructor;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -49,8 +50,8 @@ public class DialogBuildService {
     private final ExecutorService knowledgeExecutor = Executors.newSingleThreadExecutor();
 
     public List<DialogSessionDO> listSessions(Integer limit) {
-        Long userId = resolveCurrentUserId();
-        return dialogSessionDAO.findByUserId(userId, resolveSessionLimit(limit));
+        LoginUserBO loginUser = AuthContextSupport.requireLoginUser();
+        return dialogSessionDAO.findByUserId(loginUser.getUserId(), resolveSessionLimit(limit));
     }
 
     public List<DialogMessageDO> getHistory(Long sessionId, Integer limit) {
@@ -201,7 +202,9 @@ public class DialogBuildService {
 
     private DialogSessionDO requireSessionOwner(Long sessionId, Long userId) {
         DialogSessionDO session = dialogSessionDAO.findBySessionId(sessionId);
-        if (session == null || session.getUserId() == null || !session.getUserId().equals(userId)) {
+        if (session == null
+                || session.getUserId() == null
+                || !session.getUserId().equals(userId)) {
             throw new IllegalArgumentException("session not found");
         }
         return session;

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

@@ -16,12 +16,32 @@ spring:
     multipart:
       max-file-size: 500MB
       max-request-size: 500MB
+  quartz:
+    job-store-type: memory
+    wait-for-jobs-to-complete-on-shutdown: true
+    overwrite-existing-jobs: false
+    properties:
+      org:
+        quartz:
+          scheduler:
+            instanceName: WenshuAutoScalingScheduler
+          threadPool:
+            class: org.quartz.simpl.SimpleThreadPool
+            threadCount: 5
+            threadPriority: 5
+          jobStore:
+            misfireThreshold: 60000
 
 mybatis:
   mapper-locations: classpath*:mapper/**/*.xml
   configuration:
     map-underscore-to-camel-case: true
 
+autoscaling:
+  quartz:
+    timezone: Asia/Shanghai
+    metric-poll-interval-seconds: 60
+
 audit:
   log:
     query-max-page-size: 200

+ 74 - 0
backend/src/main/resources/db/schema.sql

@@ -1,5 +1,24 @@
+CREATE TABLE IF NOT EXISTS organization (
+  organization_id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  organization_code VARCHAR(64) NOT NULL,
+  organization_name VARCHAR(128) NOT NULL,
+  analyst_invite_code VARCHAR(64),
+  developer_invite_code VARCHAR(64),
+  status TINYINT NOT NULL DEFAULT 1,
+  created_at DATETIME NOT NULL,
+  UNIQUE KEY uk_organization_code (organization_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+ALTER TABLE organization ADD COLUMN IF NOT EXISTS analyst_invite_code VARCHAR(64);
+ALTER TABLE organization ADD COLUMN IF NOT EXISTS developer_invite_code VARCHAR(64);
+
+INSERT INTO organization (organization_id, organization_code, organization_name, status, created_at)
+VALUES (1, 'default', 'Default Organization', 1, NOW())
+ON DUPLICATE KEY UPDATE organization_name = VALUES(organization_name), status = VALUES(status);
+
 CREATE TABLE IF NOT EXISTS sys_user (
   user_id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  organization_id BIGINT,
   username VARCHAR(64),
   password_hash VARCHAR(128),
   role VARCHAR(32),
@@ -9,8 +28,12 @@ CREATE TABLE IF NOT EXISTS sys_user (
   UNIQUE KEY uk_sys_user_username (username)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
+ALTER TABLE sys_user ADD COLUMN IF NOT EXISTS organization_id BIGINT;
+UPDATE sys_user SET organization_id = 1 WHERE organization_id IS NULL;
+
 CREATE TABLE IF NOT EXISTS cluster (
   cluster_id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  organization_id BIGINT,
   cluster_name VARCHAR(64),
   description VARCHAR(256),
   component_type VARCHAR(32),
@@ -19,8 +42,12 @@ CREATE TABLE IF NOT EXISTS cluster (
   created_time DATETIME
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
+ALTER TABLE cluster ADD COLUMN IF NOT EXISTS organization_id BIGINT;
+UPDATE cluster SET organization_id = 1 WHERE organization_id IS NULL;
+
 CREATE TABLE IF NOT EXISTS stock_info (
   machine_id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  organization_id BIGINT,
   ip_address VARCHAR(45),
   cpu_cores TINYINT,
   cpu_model VARCHAR(64),
@@ -32,11 +59,15 @@ CREATE TABLE IF NOT EXISTS stock_info (
   description VARCHAR(256)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
+ALTER TABLE stock_info ADD COLUMN IF NOT EXISTS organization_id BIGINT;
+UPDATE stock_info SET organization_id = 1 WHERE organization_id IS NULL;
+
 ALTER TABLE stock_info ADD COLUMN ssh_username VARCHAR(64);
 ALTER TABLE stock_info ADD COLUMN ssh_password_cipher VARCHAR(512);
 
 CREATE TABLE IF NOT EXISTS dialog_session (
   session_id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  organization_id BIGINT,
   user_id BIGINT,
   title VARCHAR(256),
   created_time DATETIME,
@@ -44,6 +75,9 @@ CREATE TABLE IF NOT EXISTS dialog_session (
   KEY idx_dialog_session_user_id (user_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
+ALTER TABLE dialog_session ADD COLUMN IF NOT EXISTS organization_id BIGINT;
+UPDATE dialog_session SET organization_id = 1 WHERE organization_id IS NULL;
+
 CREATE TABLE IF NOT EXISTS dialog_message (
   message_id BIGINT AUTO_INCREMENT PRIMARY KEY,
   session_id BIGINT,
@@ -69,6 +103,7 @@ CREATE TABLE IF NOT EXISTS knowledge_document (
 
 CREATE TABLE IF NOT EXISTS workflow_definition (
   workflow_id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  organization_id BIGINT,
   workflow_name VARCHAR(64),
   description VARCHAR(256),
   dag_json TEXT,
@@ -77,6 +112,9 @@ CREATE TABLE IF NOT EXISTS workflow_definition (
   failure_strategy VARCHAR(20)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
+ALTER TABLE workflow_definition ADD COLUMN IF NOT EXISTS organization_id BIGINT;
+UPDATE workflow_definition SET organization_id = 1 WHERE organization_id IS NULL;
+
 CREATE TABLE IF NOT EXISTS task_definition (
   task_id BIGINT AUTO_INCREMENT PRIMARY KEY,
   task_name VARCHAR(64),
@@ -92,6 +130,7 @@ CREATE TABLE IF NOT EXISTS task_definition (
 
 CREATE TABLE IF NOT EXISTS workflow_instance (
   workflow_instance_id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  organization_id BIGINT,
   workflow_id BIGINT,
   workflow_name VARCHAR(64),
   start_time DATETIME,
@@ -101,8 +140,12 @@ CREATE TABLE IF NOT EXISTS workflow_instance (
   KEY idx_workflow_instance_workflow_id (workflow_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
+ALTER TABLE workflow_instance ADD COLUMN IF NOT EXISTS organization_id BIGINT;
+UPDATE workflow_instance SET organization_id = 1 WHERE organization_id IS NULL;
+
 CREATE TABLE IF NOT EXISTS task_instance (
   task_instance_id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  organization_id BIGINT,
   workflow_instance_id BIGINT,
   task_id BIGINT,
   task_name VARCHAR(64),
@@ -117,6 +160,9 @@ CREATE TABLE IF NOT EXISTS task_instance (
   KEY idx_task_instance_task_id (task_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
+ALTER TABLE task_instance ADD COLUMN IF NOT EXISTS organization_id BIGINT;
+UPDATE task_instance SET organization_id = 1 WHERE organization_id IS NULL;
+
 ALTER TABLE task_instance ADD COLUMN IF NOT EXISTS engine_task_id VARCHAR(128);
 ALTER TABLE task_instance ADD COLUMN IF NOT EXISTS execution_script MEDIUMTEXT;
 ALTER TABLE workflow_instance ADD COLUMN IF NOT EXISTS workflow_name VARCHAR(64);
@@ -135,6 +181,7 @@ ALTER TABLE task_instance DROP COLUMN IF EXISTS result_updated_at;
 
 CREATE TABLE IF NOT EXISTS config_version (
   config_id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  organization_id BIGINT,
   cluster_id BIGINT,
   version_no INT,
   config_content JSON,
@@ -144,8 +191,12 @@ CREATE TABLE IF NOT EXISTS config_version (
   KEY idx_config_version_cluster_id (cluster_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
+ALTER TABLE config_version ADD COLUMN IF NOT EXISTS organization_id BIGINT;
+UPDATE config_version SET organization_id = 1 WHERE organization_id IS NULL;
+
 CREATE TABLE IF NOT EXISTS cluster_node (
   node_id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  organization_id BIGINT,
   node_name VARCHAR(128),
   machine_id BIGINT,
   cluster_id BIGINT,
@@ -159,8 +210,12 @@ CREATE TABLE IF NOT EXISTS cluster_node (
   KEY idx_cluster_node_cluster_id (cluster_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
+ALTER TABLE cluster_node ADD COLUMN IF NOT EXISTS organization_id BIGINT;
+UPDATE cluster_node SET organization_id = 1 WHERE organization_id IS NULL;
+
 CREATE TABLE IF NOT EXISTS auto_scaling_rule (
   scale_rule_id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  organization_id BIGINT,
   scale_rule_name VARCHAR(128),
   cluster_id BIGINT,
   target_nodes INT,
@@ -174,8 +229,12 @@ CREATE TABLE IF NOT EXISTS auto_scaling_rule (
   KEY idx_auto_scaling_rule_cluster_id (cluster_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
+ALTER TABLE auto_scaling_rule ADD COLUMN IF NOT EXISTS organization_id BIGINT;
+UPDATE auto_scaling_rule SET organization_id = 1 WHERE organization_id IS NULL;
+
 CREATE TABLE IF NOT EXISTS scaling_event (
   scale_event_id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  organization_id BIGINT,
   cluster_id BIGINT,
   scale_rule_id BIGINT,
   event_type VARCHAR(16),
@@ -189,8 +248,12 @@ CREATE TABLE IF NOT EXISTS scaling_event (
   KEY idx_scaling_event_scale_rule_id (scale_rule_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
+ALTER TABLE scaling_event ADD COLUMN IF NOT EXISTS organization_id BIGINT;
+UPDATE scaling_event SET organization_id = 1 WHERE organization_id IS NULL;
+
 CREATE TABLE IF NOT EXISTS audit_log (
   log_id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  organization_id BIGINT,
   action_time DATETIME,
   action_type VARCHAR(64),
   target_type VARCHAR(64),
@@ -205,8 +268,12 @@ CREATE TABLE IF NOT EXISTS audit_log (
   KEY idx_audit_log_result_status (result_status)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
+ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS organization_id BIGINT;
+UPDATE audit_log SET organization_id = 1 WHERE organization_id IS NULL;
+
 CREATE TABLE IF NOT EXISTS alert_rule (
   alert_rule_id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  organization_id BIGINT,
   alert_rule_name VARCHAR(128),
   severity_level VARCHAR(16),
   target_id BIGINT,
@@ -221,8 +288,12 @@ CREATE TABLE IF NOT EXISTS alert_rule (
   is_enabled TINYINT
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
+ALTER TABLE alert_rule ADD COLUMN IF NOT EXISTS organization_id BIGINT;
+UPDATE alert_rule SET organization_id = 1 WHERE organization_id IS NULL;
+
 CREATE TABLE IF NOT EXISTS alert_event (
   alert_event_id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  organization_id BIGINT,
   triggered_time DATETIME,
   alert_rule_id BIGINT,
   current_value DOUBLE,
@@ -232,3 +303,6 @@ CREATE TABLE IF NOT EXISTS alert_event (
   handled_time DATETIME,
   KEY idx_alert_event_rule_id (alert_rule_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+ALTER TABLE alert_event ADD COLUMN IF NOT EXISTS organization_id BIGINT;
+UPDATE alert_event SET organization_id = 1 WHERE organization_id IS NULL;

+ 44 - 0
backend/src/main/resources/mapper/auth/OrganizationDAO.xml

@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.wenshu.platform.dao.OrganizationDAO">
+
+    <sql id="base_select">
+        SELECT
+            organization_id,
+            organization_code,
+            organization_name,
+            analyst_invite_code,
+            developer_invite_code,
+            status,
+            created_at
+        FROM organization
+    </sql>
+
+    <select id="findById" resultType="com.wenshu.platform.model.dataobject.OrganizationDO">
+        <include refid="base_select"/>
+        WHERE organization_id = #{organizationId}
+        LIMIT 1
+    </select>
+
+    <select id="findByCode" resultType="com.wenshu.platform.model.dataobject.OrganizationDO">
+        <include refid="base_select"/>
+        WHERE organization_code = #{organizationCode}
+        LIMIT 1
+    </select>
+
+    <select id="findByInviteCode" resultType="com.wenshu.platform.model.dataobject.OrganizationDO">
+        <include refid="base_select"/>
+        WHERE analyst_invite_code = #{inviteCode}
+           OR developer_invite_code = #{inviteCode}
+        LIMIT 1
+    </select>
+
+    <update id="updateInviteCodes">
+        UPDATE organization
+        SET analyst_invite_code = #{analystInviteCode},
+            developer_invite_code = #{developerInviteCode}
+        WHERE organization_id = #{organizationId}
+    </update>
+</mapper>

+ 43 - 0
backend/src/main/resources/mapper/auth/UserDAO.xml

@@ -32,6 +32,22 @@
         WHERE user_id = #{user.userId}
     </update>
 
+    <update id="updateAdminFieldsByUserId">
+        UPDATE sys_user
+        <set>
+            <if test="user.role != null">role = #{user.role},</if>
+            <if test="user.email != null">email = #{user.email},</if>
+            <if test="user.status != null">status = #{user.status},</if>
+            <if test="user.passwordHash != null">password_hash = #{user.passwordHash},</if>
+        </set>
+        WHERE user_id = #{user.userId}
+    </update>
+
+    <delete id="deleteByUserId">
+        DELETE FROM sys_user
+        WHERE user_id = #{userId}
+    </delete>
+
     <select id="findByUsername" resultType="com.wenshu.platform.model.dataobject.UserDO">
         SELECT
             user_id,
@@ -45,4 +61,31 @@
         WHERE username = #{username}
         LIMIT 1
     </select>
+
+    <select id="findByUserId" resultType="com.wenshu.platform.model.dataobject.UserDO">
+        SELECT
+            user_id,
+            username,
+            password_hash,
+            role,
+            email,
+            status,
+            created_at
+        FROM sys_user
+        WHERE user_id = #{userId}
+        LIMIT 1
+    </select>
+
+    <select id="findAll" resultType="com.wenshu.platform.model.dataobject.UserDO">
+        SELECT
+            user_id,
+            username,
+            password_hash,
+            role,
+            email,
+            status,
+            created_at
+        FROM sys_user
+        ORDER BY user_id DESC
+    </select>
 </mapper>

+ 14 - 0
backend/src/main/resources/mapper/resource/ScalingRuleDAO.xml

@@ -57,6 +57,20 @@
         LIMIT 1
     </select>
 
+    <select id="findEnabled" resultType="com.wenshu.platform.model.dataobject.ScalingRuleDO">
+        <include refid="base_select"/>
+        WHERE is_enabled = 1
+        ORDER BY scale_rule_id ASC
+    </select>
+
+    <select id="findEnabledByClusterIdAndTriggerType" resultType="com.wenshu.platform.model.dataobject.ScalingRuleDO">
+        <include refid="base_select"/>
+        WHERE is_enabled = 1
+          AND cluster_id = #{clusterId}
+          AND trigger_type = #{triggerType}
+        ORDER BY scale_rule_id ASC
+    </select>
+
     <sql id="query_where">
         <where>
             <if test="query.clusterId != null">

+ 20 - 4
backend/src/test/java/com/wenshu/platform/service/auth/AuthServiceTest.java

@@ -4,10 +4,11 @@ import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.*;
 
+import com.wenshu.platform.dao.OrganizationDAO;
 import com.wenshu.platform.dao.UserDAO;
 import com.wenshu.platform.model.bo.LoginUserBO;
+import com.wenshu.platform.model.dataobject.OrganizationDO;
 import com.wenshu.platform.model.dataobject.UserDO;
-import com.wenshu.platform.model.enums.UserRole;
 import com.wenshu.platform.model.req.UserLoginReq;
 import com.wenshu.platform.model.req.UserRegisterReq;
 import com.wenshu.platform.model.resp.UserLoginResp;
@@ -28,6 +29,9 @@ class AuthServiceTest {
     @Mock
     private UserDAO userDAO;
 
+    @Mock
+    private OrganizationDAO organizationDAO;
+
     @Mock
     private AuthTokenService authTokenService;
 
@@ -36,7 +40,7 @@ class AuthServiceTest {
 
     @BeforeEach
     void setUp() {
-        authService = new AuthService(userDAO, authTokenService, passwordEncoder);
+        authService = new AuthService(userDAO, organizationDAO, authTokenService, passwordEncoder);
     }
 
     @Test
@@ -45,9 +49,15 @@ class AuthServiceTest {
         req.setUsername("newuser");
         req.setPassword("password123");
         req.setEmail("newuser@example.com");
-        req.setRole("ANALYST");
+        req.setInviteCode("dev-code");
+
+        OrganizationDO organization = new OrganizationDO();
+        organization.setOrganizationId(1L);
+        organization.setDeveloperInviteCode("dev-code");
+        organization.setAnalystInviteCode("ana-code");
 
         when(userDAO.findByUsername("newuser")).thenReturn(null);
+        when(organizationDAO.findByInviteCode("dev-code")).thenReturn(organization);
         when(passwordEncoder.encode("password123")).thenReturn("encoded_pwd");
 
         LoginUserBO loginUserBO = new LoginUserBO();
@@ -73,8 +83,14 @@ class AuthServiceTest {
         UserRegisterReq req = new UserRegisterReq();
         req.setUsername("existing");
         req.setPassword("password123");
-        req.setRole("ANALYST");
+        req.setInviteCode("ana-code");
+
+        OrganizationDO organization = new OrganizationDO();
+        organization.setOrganizationId(1L);
+        organization.setDeveloperInviteCode("dev-code");
+        organization.setAnalystInviteCode("ana-code");
 
+        when(organizationDAO.findByInviteCode("ana-code")).thenReturn(organization);
         when(userDAO.findByUsername("existing")).thenReturn(new UserDO());
 
         assertThrows(IllegalArgumentException.class, () -> authService.register(req));

+ 113 - 0
backend/src/test/java/com/wenshu/platform/service/resource/autoscaling/ScalingExecutionServiceTest.java

@@ -0,0 +1,113 @@
+package com.wenshu.platform.service.resource.autoscaling;
+
+import java.util.List;
+
+import com.wenshu.platform.dao.ClusterDAO;
+import com.wenshu.platform.dao.ClusterNodeDAO;
+import com.wenshu.platform.model.dataobject.ClusterDO;
+import com.wenshu.platform.model.dataobject.ClusterNodeDO;
+import com.wenshu.platform.model.dataobject.ScalingRuleDO;
+import com.wenshu.platform.service.resource.ScalingService;
+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;
+
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class ScalingExecutionServiceTest {
+
+    @Mock
+    private ClusterNodeDAO clusterNodeDAO;
+    @Mock
+    private ClusterDAO clusterDAO;
+    @Mock
+    private ScalingService scalingService;
+
+    @InjectMocks
+    private ScalingExecutionService scalingExecutionService;
+
+    @Test
+    void executeRuleScaling_StarRocksTargetNodesShouldUseBackendCount() {
+        ScalingRuleDO rule = buildRule(9001L, 18L, 2);
+        when(clusterDAO.findById(18L)).thenReturn(buildCluster(18L, "STARROCKS"));
+        when(clusterNodeDAO.findByClusterId(18L)).thenReturn(List.of(
+                buildNode(1L, "starrocks-frontend-1"),
+                buildNode(2L, "starrocks-backend-1"),
+                buildNode(3L, "starrocks-backend-2")));
+
+        scalingExecutionService.executeRuleScaling(rule, "CRON");
+
+        verify(scalingService, never()).autoScaleOutByRule(18L, 9001L, 1, "CRON");
+        verify(scalingService, never()).autoScaleInByRule(18L, 9001L, 1, "CRON");
+    }
+
+    @Test
+    void executeRuleScaling_StarRocksShouldScaleOutByBackendDelta() {
+        ScalingRuleDO rule = buildRule(9002L, 18L, 2);
+        when(clusterDAO.findById(18L)).thenReturn(buildCluster(18L, "STARROCKS"));
+        when(clusterNodeDAO.findByClusterId(18L)).thenReturn(List.of(
+                buildNode(1L, "starrocks-frontend-1"),
+                buildNode(2L, "starrocks-backend-1")));
+
+        scalingExecutionService.executeRuleScaling(rule, "CRON");
+
+        verify(scalingService).autoScaleOutByRule(18L, 9002L, 1, "CRON");
+        verify(scalingService, never()).autoScaleInByRule(18L, 9002L, 1, "CRON");
+    }
+
+    @Test
+    void executeRuleScaling_StarRocksShouldScaleInByBackendDelta() {
+        ScalingRuleDO rule = buildRule(9003L, 18L, 2);
+        when(clusterDAO.findById(18L)).thenReturn(buildCluster(18L, "STARROCKS"));
+        when(clusterNodeDAO.findByClusterId(18L)).thenReturn(List.of(
+                buildNode(1L, "starrocks-frontend-1"),
+                buildNode(2L, "starrocks-backend-1"),
+                buildNode(3L, "starrocks-backend-2"),
+                buildNode(4L, "starrocks-backend-3")));
+
+        scalingExecutionService.executeRuleScaling(rule, "CRON");
+
+        verify(scalingService).autoScaleInByRule(18L, 9003L, 1, "CRON");
+        verify(scalingService, never()).autoScaleOutByRule(18L, 9003L, 1, "CRON");
+    }
+
+    @Test
+    void executeRuleScaling_SparkShouldKeepTotalNodeSemantics() {
+        ScalingRuleDO rule = buildRule(9004L, 5L, 3);
+        when(clusterDAO.findById(5L)).thenReturn(buildCluster(5L, "SPARK"));
+        when(clusterNodeDAO.findByClusterId(5L)).thenReturn(List.of(
+                buildNode(1L, "spark-node-1"),
+                buildNode(2L, "spark-node-2")));
+
+        scalingExecutionService.executeRuleScaling(rule, "CRON");
+
+        verify(scalingService).autoScaleOutByRule(5L, 9004L, 1, "CRON");
+    }
+
+    private ScalingRuleDO buildRule(Long ruleId, Long clusterId, Integer targetNodes) {
+        ScalingRuleDO rule = new ScalingRuleDO();
+        rule.setScaleRuleId(ruleId);
+        rule.setClusterId(clusterId);
+        rule.setTargetNodes(targetNodes);
+        return rule;
+    }
+
+    private ClusterDO buildCluster(Long clusterId, String componentType) {
+        ClusterDO cluster = new ClusterDO();
+        cluster.setClusterId(clusterId);
+        cluster.setComponentType(componentType);
+        return cluster;
+    }
+
+    private ClusterNodeDO buildNode(Long nodeId, String nodeName) {
+        ClusterNodeDO node = new ClusterNodeDO();
+        node.setNodeId(nodeId);
+        node.setNodeName(nodeName);
+        return node;
+    }
+}

+ 71 - 0
backend/src/test/java/com/wenshu/platform/service/resource/autoscaling/ScalingRuleEvaluationServiceTest.java

@@ -0,0 +1,71 @@
+package com.wenshu.platform.service.resource.autoscaling;
+
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.Date;
+import java.util.List;
+
+import com.wenshu.platform.config.AutoScalingQuartzProperties;
+import com.wenshu.platform.dao.ScalingRuleDAO;
+import com.wenshu.platform.model.dataobject.ScalingRuleDO;
+import com.wenshu.platform.model.enums.ScalingTriggerType;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class ScalingRuleEvaluationServiceTest {
+
+    @Mock
+    private ScalingRuleDAO scalingRuleDAO;
+
+    private ScalingRuleEvaluationService scalingRuleEvaluationService;
+
+    @BeforeEach
+    void setUp() {
+        AutoScalingQuartzProperties properties = new AutoScalingQuartzProperties();
+        properties.setTimezone("Asia/Shanghai");
+        scalingRuleEvaluationService = new ScalingRuleEvaluationService(scalingRuleDAO, properties);
+    }
+
+    @Test
+    void shouldExecuteCronRule_ShouldPickSmallestRuleIdForSameMinute() {
+        ScalingRuleDO ruleA = buildCronRule(100L, 3001L, "0 2 * * *");
+        ScalingRuleDO ruleB = buildCronRule(101L, 3001L, "0 2 * * *");
+
+        when(scalingRuleDAO.findEnabledByClusterIdAndTriggerType(3001L, ScalingTriggerType.CRON.getCode()))
+                .thenReturn(List.of(ruleA, ruleB));
+
+        Date fireTime = Date.from(ZonedDateTime.of(2026, 3, 28, 2, 0, 5, 0, ZoneId.of("Asia/Shanghai")).toInstant());
+        assertTrue(scalingRuleEvaluationService.shouldExecuteCronRule(ruleA, fireTime));
+        assertFalse(scalingRuleEvaluationService.shouldExecuteCronRule(ruleB, fireTime));
+    }
+
+    @Test
+    void shouldExecuteCronRule_ShouldIgnoreRuleOutsideCurrentMinute() {
+        ScalingRuleDO currentMinuteRule = buildCronRule(100L, 3001L, "0 2 * * *");
+        ScalingRuleDO differentMinuteRule = buildCronRule(99L, 3001L, "1 2 * * *");
+
+        when(scalingRuleDAO.findEnabledByClusterIdAndTriggerType(3001L, ScalingTriggerType.CRON.getCode()))
+                .thenReturn(List.of(differentMinuteRule, currentMinuteRule));
+
+        Date fireTime = Date.from(ZonedDateTime.of(2026, 3, 28, 2, 0, 0, 0, ZoneId.of("Asia/Shanghai")).toInstant());
+        assertTrue(scalingRuleEvaluationService.shouldExecuteCronRule(currentMinuteRule, fireTime));
+    }
+
+    private ScalingRuleDO buildCronRule(Long ruleId, Long clusterId, String cron) {
+        ScalingRuleDO rule = new ScalingRuleDO();
+        rule.setScaleRuleId(ruleId);
+        rule.setClusterId(clusterId);
+        rule.setIsEnabled(1);
+        rule.setTriggerType(ScalingTriggerType.CRON.getCode());
+        rule.setCronExpression(cron);
+        return rule;
+    }
+}

+ 37 - 0
backend/src/test/java/com/wenshu/platform/service/resource/autoscaling/quartz/ScalingCronExpressionSupportTest.java

@@ -0,0 +1,37 @@
+package com.wenshu.platform.service.resource.autoscaling.quartz;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class ScalingCronExpressionSupportTest {
+
+    @Test
+    void normalizeToQuartzExpression_ShouldConvertFiveFieldCron() {
+        String expression = ScalingCronExpressionSupport.normalizeToQuartzExpression("0 2 * * *");
+
+        assertEquals("0 0 2 * * ?", expression);
+    }
+
+    @Test
+    void normalizeToQuartzExpression_ShouldKeepSixFieldCron() {
+        String expression = ScalingCronExpressionSupport.normalizeToQuartzExpression("0 0 2 * * ?");
+
+        assertEquals("0 0 2 * * ?", expression);
+    }
+
+    @Test
+    void normalizeToQuartzExpression_ShouldConvertUnixSunday() {
+        String expression = ScalingCronExpressionSupport.normalizeToQuartzExpression("0 3 * * 0");
+
+        assertEquals("0 0 3 ? * 1", expression);
+    }
+
+    @Test
+    void normalizeToQuartzExpression_ShouldRejectInvalidCron() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ScalingCronExpressionSupport.normalizeToQuartzExpression("0 0 2 10 * 1"));
+    }
+}

+ 25 - 0
frontend/src/api/adminUsers.js

@@ -0,0 +1,25 @@
+import http from '../utils/http';
+
+export function listUsers() {
+  return http.get('/api/admin/users');
+}
+
+export function createUser(payload) {
+  return http.post('/api/admin/users', payload);
+}
+
+export function updateUser(userId, payload) {
+  return http.put(`/api/admin/users/${userId}`, payload);
+}
+
+export function deleteUser(userId) {
+  return http.delete(`/api/admin/users/${userId}`);
+}
+
+export function getInviteCodes() {
+  return http.get('/api/admin/users/invite-codes');
+}
+
+export function updateInviteCodes(payload) {
+  return http.put('/api/admin/users/invite-codes', payload);
+}

+ 70 - 10
frontend/src/components/Sidebar.vue

@@ -22,6 +22,7 @@
           </span>
         </li>
         <li
+          v-if="canAccessTaskBuild"
           class="menu-item"
           :class="{ active: route.path === '/task-build/code' }"
           @click="navigateTo('/task-build/code')"
@@ -32,6 +33,7 @@
           </span>
         </li>
         <li
+          v-if="canAccessTaskBuild"
           class="menu-item"
           :class="{ active: route.path === '/task-build/workflows' }"
           @click="navigateTo('/task-build/workflows')"
@@ -53,8 +55,8 @@
         </li>
       </ul>
 
-      <div class="sidebar-section">资源管理</div>
-      <ul class="menu-list">
+      <div v-if="canAccessResourceOps" class="sidebar-section">资源管理</div>
+      <ul v-if="canAccessResourceOps" class="menu-list">
         <li
           class="menu-item"
           :class="{ active: route.path.startsWith('/resource/clusters') }"
@@ -77,8 +79,8 @@
         </li>
       </ul>
 
-      <div class="sidebar-section">系统运维</div>
-      <ul class="menu-list">
+      <div v-if="canAccessResourceOps" class="sidebar-section">系统运维</div>
+      <ul v-if="canAccessResourceOps" class="menu-list">
         <li
           class="menu-item"
           :class="{ active: route.path === '/ops/audit-log' }"
@@ -102,13 +104,29 @@
       </ul>
     </div>
 
-    <div class="user-profile" ref="userProfileRef" @click="toggleMenu">
+    <div class="user-profile" ref="userProfileRef">
       <div class="user-avatar">{{ userInitial }}</div>
       <div class="user-info-text">
         <div class="user-id">{{ user.username || '…' }}</div>
         <div class="user-role">{{ user.role || '' }}</div>
       </div>
+      <button
+        class="user-more-btn"
+        type="button"
+        aria-label="打开用户菜单"
+        @click.stop="toggleMenu"
+      >
+        <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
+          <circle cx="6" cy="12" r="1.8" />
+          <circle cx="12" cy="12" r="1.8" />
+          <circle cx="18" cy="12" r="1.8" />
+        </svg>
+      </button>
       <div v-if="showMenu" class="logout-menu" @click.stop>
+        <button v-if="isAdmin" class="logout-item settings-item" @click="handleMemberManagement">
+          <svg class="menu-action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
+          成员管理
+        </button>
         <button class="logout-item settings-item" @click="handleSettings">
           <svg class="menu-action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
           设置
@@ -130,8 +148,13 @@ import { getMe, logout } from '../api/auth';
 const router = useRouter();
 const route = useRoute();
 
-const user = reactive({ username: '', role: '' });
+const cachedRole = (localStorage.getItem('role') || '').toUpperCase();
+const user = reactive({ username: '', role: cachedRole });
 const userInitial = computed(() => user.username?.[0]?.toUpperCase() || '?');
+const normalizedRole = computed(() => (user.role || '').toUpperCase());
+const isAdmin = computed(() => normalizedRole.value === 'ADMIN');
+const canAccessTaskBuild = computed(() => ['ADMIN', 'DEVELOPER', 'ANALYST'].includes(normalizedRole.value));
+const canAccessResourceOps = computed(() => ['ADMIN', 'DEVELOPER', 'ANALYST'].includes(normalizedRole.value));
 const showMenu = ref(false);
 const userProfileRef = ref(null);
 const mobileMenuOpen = ref(false);
@@ -159,9 +182,16 @@ function handleSettings() {
   router.push('/settings');
 }
 
+function handleMemberManagement() {
+  showMenu.value = false;
+  mobileMenuOpen.value = false;
+  router.push('/settings/members');
+}
+
 async function handleLogout() {
   try { await logout(); } catch { /* ignore */ }
   localStorage.removeItem('token');
+  localStorage.removeItem('role');
   router.push('/login');
 }
 
@@ -169,6 +199,7 @@ onMounted(() => {
   getMe().then(({ data }) => { 
     user.username = data.username; 
     user.role = data.role; 
+    localStorage.setItem('role', data.role || '');
   }).catch(() => {});
   document.addEventListener('click', closeMenu);
 });
@@ -266,13 +297,9 @@ onUnmounted(() => {
   display: flex;
   align-items: center;
   gap: 12px;
-  cursor: pointer;
   user-select: none;
-  transition: background 0.15s;
 }
 
-.user-profile:hover { background: #f5f7fa; }
-
 .user-avatar {
   width: 36px; height: 36px; border-radius: 50%; background: #409eff; color: #fff;
   display: flex; align-items: center; justify-content: center; font-size: 16px; font-weight: 600; flex-shrink: 0;
@@ -282,6 +309,39 @@ onUnmounted(() => {
 .user-id { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
 .user-role { font-size: 12px; color: #909399; }
 
+.user-more-btn {
+  width: 28px;
+  height: 28px;
+  border: none;
+  border-radius: 8px;
+  background: transparent;
+  color: #55627d;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  flex-shrink: 0;
+  padding: 0;
+}
+
+.user-more-btn svg {
+  width: 16px;
+  height: 16px;
+}
+
+.user-more-btn:hover {
+  color: #2f5fbe;
+  background: #f3f7ff;
+}
+
+.user-more-btn:focus,
+.user-more-btn:focus-visible,
+.user-more-btn:active {
+  outline: none;
+  box-shadow: none;
+  background: transparent;
+}
+
 .logout-menu {
   position: absolute; bottom: calc(100% + 6px); left: 16px; right: 16px;
   background: #fff; border: 1px solid #ebeef5; border-radius: 6px;

+ 1 - 0
frontend/src/main.js

@@ -1,5 +1,6 @@
 import { createApp } from 'vue';
 import App from './App.vue';
 import router from './router';
+import './styles/member-theme.css';
 
 createApp(App).use(router).mount('#app');

+ 38 - 14
frontend/src/router/index.js

@@ -15,6 +15,7 @@ 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 = [
   {
@@ -36,72 +37,92 @@ const routes = [
   {
     path: '/task-build/code',
     name: 'CodeTaskBuild',
-    component: CodeTaskBuildPage
+    component: CodeTaskBuildPage,
+    meta: { roles: ['ADMIN', 'DEVELOPER', 'ANALYST'] }
   },
   {
     path: '/task-build/dialog',
     name: 'DialogTaskBuild',
-    component: DialogTaskBuildPage
+    component: DialogTaskBuildPage,
+    meta: { roles: ['ADMIN', 'DEVELOPER', 'ANALYST'] }
   },
   {
     path: '/task-build/workflows',
     name: 'TaskBuildWorkflows',
-    component: TaskBuildWorkflowListPage
+    component: TaskBuildWorkflowListPage,
+    meta: { roles: ['ADMIN', 'DEVELOPER', 'ANALYST'] }
   },
   {
     path: '/task-exec/workflows',
     name: 'TaskExecWorkflows',
-    component: WorkflowExecutionPage
+    component: WorkflowExecutionPage,
+    meta: { roles: ['ADMIN', 'DEVELOPER', 'ANALYST'] }
   },
   {
     path: '/task-exec/workflows/:workflowInstanceId/tasks/:taskInstanceId/logs',
     name: 'TaskInstanceLog',
-    component: TaskInstanceLogPage
+    component: TaskInstanceLogPage,
+    meta: { roles: ['ADMIN', 'DEVELOPER', 'ANALYST'] }
   },
   {
     path: '/task-exec/workflows/:workflowInstanceId/tasks/:taskInstanceId/results',
     name: 'TaskInstanceResult',
-    component: TaskInstanceResultPage
+    component: TaskInstanceResultPage,
+    meta: { roles: ['ADMIN', 'DEVELOPER', 'ANALYST'] }
   },
   {
     path: '/resource/clusters',
     name: 'Clusters',
-    component: ClusterPage
+    component: ClusterPage,
+    meta: { roles: ['ADMIN', 'DEVELOPER', 'ANALYST'] }
   },
   {
     path: '/resource/clusters/:clusterId',
     name: 'ClusterConsole',
-    component: ClusterConsolePage
+    component: ClusterConsolePage,
+    meta: { roles: ['ADMIN', 'DEVELOPER', 'ANALYST'] }
   },
   {
     path: '/ops/audit-log',
     name: 'AuditLog',
-    component: AuditLogPage
+    component: AuditLogPage,
+    meta: { roles: ['ADMIN', 'DEVELOPER', 'ANALYST'] }
   },
   {
     path: '/ops/alerts',
     name: 'AlertRules',
-    component: AlertRulePage
+    component: AlertRulePage,
+    meta: { roles: ['ADMIN', 'DEVELOPER', 'ANALYST'] }
   },
   {
     path: '/ops/alerts/events',
     name: 'AlertEvents',
-    component: AlertEventPage
+    component: AlertEventPage,
+    meta: { roles: ['ADMIN', 'DEVELOPER', 'ANALYST'] }
   },
   {
     path: '/resource/stocks',
     name: 'Stocks',
-    component: StockPage
+    component: StockPage,
+    meta: { roles: ['ADMIN', 'DEVELOPER', 'ANALYST'] }
   },
   {
     path: '/resource/stocks/:machineId/console',
     name: 'StockConsole',
-    component: StockConsolePage
+    component: StockConsolePage,
+    meta: { roles: ['ADMIN', 'DEVELOPER', 'ANALYST'] }
   },
   {
     path: '/settings',
     name: 'Settings',
-    component: SettingsPage
+    component: SettingsPage,
+    meta: { roles: ['ADMIN', 'DEVELOPER'] }
+  },
+  {
+    path: '/settings/members',
+    name: 'MemberManagement',
+    component: MemberManagementPage,
+    meta: { roles: ['ADMIN'] }
   }
 ];
 
@@ -112,10 +133,13 @@ const router = createRouter({
 
 router.beforeEach((to, _from, next) => {
   const token = localStorage.getItem('token');
+  const role = (localStorage.getItem('role') || '').toUpperCase();
   if (!to.meta.public && !token) {
     next({ name: 'Login' });
   } else if (to.name === 'Login' && token) {
     next('/');
+  } else if (to.meta?.roles && role && !to.meta.roles.includes(role)) {
+    next('/');
   } else {
     next();
   }

+ 123 - 0
frontend/src/styles/member-theme.css

@@ -0,0 +1,123 @@
+.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%),
+    #f3f6fb;
+  --member-card-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.95), #ffffff);
+  --member-title: #162447;
+  --member-text: #1f2f55;
+  --member-border: #e7ebf4;
+  --member-border-soft: #edf1f8;
+  --accent: #0080ff !important;
+  --accent-soft: #eaf4ff !important;
+  --accent-hover: #f5f8ff !important;
+}
+
+.member-theme.page-root,
+.member-theme.page-layout {
+  background: var(--member-bg) !important;
+  color: var(--member-text) !important;
+}
+
+.member-theme .page-header h1,
+.member-theme .panel-header h1,
+.member-theme .panel-header h2,
+.member-theme .settings-title {
+  color: var(--member-title) !important;
+  font-weight: 700 !important;
+  letter-spacing: 0.02em;
+}
+
+.member-theme .panel,
+.member-theme .content-panel,
+.member-theme .settings-card,
+.member-theme .modal {
+  background: var(--member-card-bg) !important;
+  border: 1px solid var(--member-border) !important;
+  border-radius: 14px !important;
+  box-shadow: none !important;
+}
+
+.member-theme .page-header,
+.member-theme .panel-header,
+.member-theme .card-head,
+.member-theme .modal-header {
+  border-bottom: 1px solid var(--member-border-soft) !important;
+}
+
+.member-theme .card-title,
+.member-theme .panel-title,
+.member-theme .detail-title,
+.member-theme .area-title,
+.member-theme .column-title {
+  color: var(--member-text) !important;
+}
+
+.member-theme .btn {
+  min-height: 36px !important;
+  padding-top: 7px !important;
+  padding-bottom: 7px !important;
+  border-radius: 8px !important;
+  font-weight: 600 !important;
+}
+
+.member-theme .btn-primary {
+  border: none !important;
+  background: #0080ff !important;
+  color: #ffffff !important;
+  box-shadow: 0 6px 14px rgba(0, 128, 255, 0.22) !important;
+}
+
+.member-theme .btn-primary:hover:not(:disabled) {
+  transform: translateY(-1px);
+  background: #0072e6 !important;
+}
+
+.member-theme .btn-default {
+  background: #ffffff !important;
+  border: 1px solid #d8dfed !important;
+  color: #243356 !important;
+}
+
+.member-theme .btn-default:hover:not(:disabled) {
+  border-color: #8ca4d9 !important;
+  color: #24489a !important;
+}
+
+.member-theme .btn-danger {
+  background: #fff8f8 !important;
+  color: #c73737 !important;
+  border-color: #f0c9c9 !important;
+}
+
+.member-theme .btn-danger:hover:not(:disabled) {
+  border-color: #da7a7a !important;
+  color: #a41919 !important;
+}
+
+.member-theme input,
+.member-theme select,
+.member-theme textarea {
+  border-radius: 8px !important;
+  border-color: #ccd6ea !important;
+  color: #223355 !important;
+}
+
+.member-theme input:focus,
+.member-theme select:focus,
+.member-theme textarea:focus {
+  border-color: #0080ff !important;
+  box-shadow: 0 0 0 3px rgba(0, 128, 255, 0.16) !important;
+}
+
+.member-theme th {
+  background: #f5f8ff !important;
+  color: #5b6f98 !important;
+}
+
+.member-theme .top-toast,
+.member-theme .success-toast,
+.member-theme .toast-box {
+  border-radius: 8px !important;
+  box-shadow: 0 6px 16px rgba(15, 23, 42, 0.12) !important;
+}

+ 101 - 68
frontend/src/views/auth/LoginPage.vue

@@ -1,9 +1,12 @@
 <template>
-  <div class="login-container">
-    <div class="login-card">
-      <img src="/logo.png" class="login-logo" alt="SEECODER" />
+  <div class="auth-shell">
+    <div class="auth-card">
+      <div class="auth-brand">
+        <img src="/logo.png" class="auth-logo" alt="SEECODER" />
+        <h1>欢迎回来</h1>
+      </div>
 
-      <form class="login-form" @submit.prevent="handleLogin">
+      <form class="auth-form" @submit.prevent="handleLogin">
         <div class="form-group">
           <label for="username">用户名</label>
           <input
@@ -30,7 +33,7 @@
 
         <div v-if="errorMsg" class="error-msg">{{ errorMsg }}</div>
 
-        <button type="submit" class="login-btn" :disabled="loading">
+        <button type="submit" class="auth-btn" :disabled="loading">
           <span v-if="loading" class="spinner"></span>
           {{ loading ? '登录中...' : '登录' }}
         </button>
@@ -66,6 +69,7 @@ async function handleLogin() {
   try {
     const { data } = await login(form.username, form.password);
     localStorage.setItem('token', data.token);
+    localStorage.setItem('role', data.role || '');
     router.push('/');
   } catch (err) {
     const msg = err.response?.data?.message;
@@ -74,7 +78,7 @@ async function handleLogin() {
     } else if (err.response?.status === 400) {
       errorMsg.value = '请输入用户名和密码';
     } else {
-      errorMsg.value = '登录失败,请稍后重试';
+      errorMsg.value = msg || '登录失败,请稍后重试';
     }
   } finally {
     loading.value = false;
@@ -83,133 +87,151 @@ async function handleLogin() {
 </script>
 
 <style scoped>
-.login-container {
+.auth-shell {
   min-height: 100vh;
   display: flex;
   align-items: center;
   justify-content: center;
-  background: #f0f2f5;
+  padding: 24px;
+  box-sizing: border-box;
+  background:
+    radial-gradient(circle at 8% 10%, rgba(49, 106, 255, 0.14), transparent 36%),
+    radial-gradient(circle at 88% 88%, rgba(28, 177, 147, 0.12), transparent 34%),
+    #f3f6fb;
 }
 
-.login-card {
-  background: #fff;
-  border-radius: 8px;
-  box-shadow: 0 2px 16px rgba(0, 0, 0, 0.1);
-  padding: 48px 40px;
-  width: 360px;
+.auth-card {
+  width: 100%;
+  max-width: 420px;
+  padding: 34px 32px 28px;
+  border-radius: 18px;
+  border: 1px solid #e7ebf4;
+  background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), #ffffff);
+  box-shadow: 0 10px 34px rgba(22, 36, 71, 0.08);
 }
 
-.login-logo {
-  display: block;
-  width: 90%;
+.auth-brand {
+  margin-bottom: 24px;
+  text-align: center;
+}
+
+.auth-logo {
+  width: 280px;
   height: auto;
-  object-fit: contain;
-  margin: 0 auto 20px;
+  margin: 0 auto 14px;
+  display: block;
 }
 
-.login-title {
-  margin: 0 0 32px;
-  font-size: 22px;
-  font-weight: 600;
-  color: #1a1a2e;
-  text-align: center;
+.auth-brand h1 {
+  margin: 0;
+  font-size: 24px;
+  color: #162447;
+  letter-spacing: 0.01em;
 }
 
-.login-form {
+.auth-form {
   display: flex;
   flex-direction: column;
-  gap: 20px;
+  gap: 16px;
 }
 
 .form-group {
   display: flex;
   flex-direction: column;
-  gap: 6px;
+  gap: 7px;
 }
 
 .form-group label {
   font-size: 13px;
-  font-weight: 500;
-  color: #595959;
+  font-weight: 600;
+  color: #3d5078;
 }
 
 .form-group input {
-  padding: 10px 12px;
-  border: 1px solid #d9d9d9;
-  border-radius: 6px;
+  height: 42px;
+  padding: 0 12px;
+  border: 1px solid #ccd6ea;
+  border-radius: 8px;
   font-size: 14px;
-  color: #1a1a1a;
+  color: #223355;
   outline: none;
-  transition: border-color 0.2s;
+  transition: border-color 0.2s, box-shadow 0.2s;
 }
 
 .form-group input:focus {
-  border-color: #4096ff;
-  box-shadow: 0 0 0 2px rgba(64, 150, 255, 0.2);
+  border-color: #3f6fd8;
+  box-shadow: 0 0 0 3px rgba(63, 111, 216, 0.15);
 }
 
 .form-group input:disabled {
-  background: #f5f5f5;
+  background: #f4f7fc;
+  color: #8ea0c2;
   cursor: not-allowed;
 }
 
 .error-msg {
-  padding: 8px 12px;
-  background: #fff2f0;
-  border: 1px solid #ffccc7;
-  border-radius: 6px;
+  padding: 10px 12px;
+  border: 1px solid #f2cccc;
+  border-radius: 8px;
+  background: #fff3f3;
+  color: #bb2f2f;
   font-size: 13px;
-  color: #cf1322;
 }
 
-.login-btn {
-  display: flex;
+.auth-btn {
+  margin-top: 2px;
+  height: 42px;
+  border: none;
+  border-radius: 8px;
+  background: #0080ff;
+  color: #ffffff;
+  font-size: 14px;
+  font-weight: 600;
+  cursor: pointer;
+  display: inline-flex;
   align-items: center;
   justify-content: center;
   gap: 8px;
-  padding: 11px;
-  background: #1677ff;
-  color: #fff;
-  border: none;
-  border-radius: 6px;
-  font-size: 15px;
-  font-weight: 500;
-  cursor: pointer;
-  transition: background 0.2s;
-  margin-top: 4px;
+  box-shadow: 0 6px 14px rgba(0, 128, 255, 0.22);
+  transition: transform 0.2s, opacity 0.2s;
 }
 
-.login-btn:hover:not(:disabled) {
-  background: #4096ff;
+.auth-btn:hover:not(:disabled) {
+  transform: translateY(-1px);
+  background: #0072e6;
 }
 
-.login-btn:disabled {
-  background: #91caff;
+.auth-btn:disabled {
+  opacity: 0.6;
   cursor: not-allowed;
+  box-shadow: none;
 }
 
 .spinner {
   width: 14px;
   height: 14px;
   border: 2px solid rgba(255, 255, 255, 0.4);
-  border-top-color: #fff;
+  border-top-color: #ffffff;
   border-radius: 50%;
   animation: spin 0.7s linear infinite;
 }
 
 @keyframes spin {
-  to { transform: rotate(360deg); }
+  to {
+    transform: rotate(360deg);
+  }
 }
 
 .switch-link {
-  margin: 20px 0 0;
+  margin: 16px 0 0;
   text-align: center;
   font-size: 13px;
-  color: #8c8c8c;
+  color: #6e81a9;
 }
 
 .switch-link a {
-  color: #1677ff;
+  color: #2f68ff;
+  font-weight: 600;
   text-decoration: none;
 }
 
@@ -218,10 +240,21 @@ async function handleLogin() {
 }
 
 @media (max-width: 768px) {
-  .login-card {
-    width: calc(100vw - 40px);
-    max-width: 360px;
-    padding: 32px 24px;
+  .auth-shell {
+    padding: 16px;
+  }
+
+  .auth-card {
+    padding: 26px 18px 20px;
+    border-radius: 14px;
+  }
+
+  .auth-logo {
+    width: 220px;
+  }
+
+  .auth-brand h1 {
+    font-size: 21px;
   }
 }
 </style>

+ 147 - 140
frontend/src/views/auth/RegisterPage.vue

@@ -1,10 +1,12 @@
 <template>
-  <div class="login-container">
-    <div class="login-card">
-      <img src="/logo.png" class="login-logo" alt="SEECODER" />
-      <p class="login-subtitle">创建账号</p>
-
-      <form class="login-form" @submit.prevent="handleRegister">
+  <div class="auth-shell">
+    <div class="auth-card">
+      <div class="auth-brand">
+        <img src="/logo.png" class="auth-logo" alt="SEECODER" />
+        <h1>创建账号</h1>
+      </div>
+
+      <form class="auth-form" @submit.prevent="handleRegister">
         <div class="form-group">
           <label for="username">用户名</label>
           <input
@@ -18,12 +20,14 @@
         </div>
 
         <div class="form-group">
-          <label>角色</label>
-          <div class="role-toggle" :class="{ disabled: loading }">
-            <div class="role-slider" :class="form.role === 'OPS' ? 'slide-right' : 'slide-left'"></div>
-            <button type="button" class="role-option" :class="{ active: form.role === 'ANALYST' }" :disabled="loading" @click="form.role = 'ANALYST'">分析师</button>
-            <button type="button" class="role-option" :class="{ active: form.role === 'OPS' }" :disabled="loading" @click="form.role = 'OPS'">运维</button>
-          </div>
+          <label for="inviteCode">邀请码</label>
+          <input
+            id="inviteCode"
+            v-model="form.inviteCode"
+            type="text"
+            placeholder="请输入邀请码"
+            :disabled="loading"
+          />
         </div>
 
         <div class="form-group">
@@ -78,7 +82,7 @@
 
         <div v-if="errorMsg" class="error-msg">{{ errorMsg }}</div>
 
-        <button type="submit" class="login-btn" :disabled="loading">
+        <button type="submit" class="auth-btn" :disabled="loading">
           <span v-if="loading" class="spinner"></span>
           {{ loading ? '注册中...' : '注册' }}
         </button>
@@ -98,7 +102,7 @@ import { register } from '../../api/auth';
 
 const router = useRouter();
 
-const form = reactive({ username: '', role: 'ANALYST', email: '', password: '', confirmPassword: '' });
+const form = reactive({ username: '', inviteCode: '', email: '', password: '', confirmPassword: '' });
 const loading = ref(false);
 const errorMsg = ref('');
 const emailTouched = ref(false);
@@ -116,9 +120,9 @@ const strength = computed(() => {
   if (/[0-9]/.test(p)) score++;
   if (/[^a-zA-Z0-9]/.test(p)) score++;
   if (p.length >= 12) score++;
-  if (score <= 2) return { level: 'weak',   text: '强度:弱', width: '33%' };
+  if (score <= 2) return { level: 'weak', text: '强度:弱', width: '33%' };
   if (score <= 3) return { level: 'medium', text: '强度:中', width: '66%' };
-  return             { level: 'strong', text: '强度:强', width: '100%' };
+  return { level: 'strong', text: '强度:强', width: '100%' };
 });
 
 async function handleRegister() {
@@ -126,7 +130,7 @@ async function handleRegister() {
   emailTouched.value = true;
   confirmTouched.value = true;
 
-  if (!form.username || !form.email || !form.password || !form.confirmPassword) {
+  if (!form.username || !form.inviteCode || !form.email || !form.password || !form.confirmPassword) {
     errorMsg.value = '请填写所有必填项';
     return;
   }
@@ -145,8 +149,14 @@ async function handleRegister() {
 
   loading.value = true;
   try {
-    const { data } = await register({ username: form.username, role: form.role, email: form.email, password: form.password });
+    const { data } = await register({
+      username: form.username,
+      inviteCode: form.inviteCode,
+      email: form.email,
+      password: form.password
+    });
     localStorage.setItem('token', data.token);
+    localStorage.setItem('role', data.role || '');
     router.push('/');
   } catch (err) {
     const msg = err.response?.data?.message;
@@ -162,133 +172,97 @@ async function handleRegister() {
 </script>
 
 <style scoped>
-.login-container {
+.auth-shell {
   min-height: 100vh;
   display: flex;
   align-items: center;
   justify-content: center;
-  background: #f0f2f5;
+  padding: 24px;
+  box-sizing: border-box;
+  background:
+    radial-gradient(circle at 8% 10%, rgba(49, 106, 255, 0.14), transparent 36%),
+    radial-gradient(circle at 88% 88%, rgba(28, 177, 147, 0.12), transparent 34%),
+    #f3f6fb;
 }
 
-.login-card {
-  background: #fff;
-  border-radius: 8px;
-  box-shadow: 0 2px 16px rgba(0, 0, 0, 0.1);
-  padding: 48px 40px;
-  width: 360px;
+.auth-card {
+  width: 100%;
+  max-width: 440px;
+  padding: 34px 32px 28px;
+  border-radius: 18px;
+  border: 1px solid #e7ebf4;
+  background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), #ffffff);
+  box-shadow: 0 10px 34px rgba(22, 36, 71, 0.08);
 }
 
-.login-logo {
-  display: block;
-  width: 90%;
-  height: auto;
-  object-fit: contain;
-  margin: 0 auto 20px;
+.auth-brand {
+  margin-bottom: 24px;
+  text-align: center;
 }
 
-.login-title {
-  margin: 0 0 4px;
-  font-size: 22px;
-  font-weight: 600;
-  color: #1a1a2e;
-  text-align: center;
+.auth-logo {
+  width: 280px;
+  height: auto;
+  margin: 0 auto 14px;
+  display: block;
 }
 
-.login-subtitle {
-  margin: 0 0 32px;
-  font-size: 14px;
-  color: #8c8c8c;
-  text-align: center;
+.auth-brand h1 {
+  margin: 0;
+  font-size: 24px;
+  color: #162447;
+  letter-spacing: 0.01em;
 }
 
-.login-form {
+.auth-form {
   display: flex;
   flex-direction: column;
-  gap: 20px;
+  gap: 14px;
 }
 
 .form-group {
   display: flex;
   flex-direction: column;
-  gap: 6px;
+  gap: 7px;
 }
 
 .form-group label {
   font-size: 13px;
-  font-weight: 500;
-  color: #595959;
-}
-
-.role-toggle {
-  position: relative;
-  display: flex;
-  background: #f0f0f0;
-  border-radius: 8px;
-  padding: 3px;
-}
-
-.role-slider {
-  position: absolute;
-  top: 3px;
-  bottom: 3px;
-  width: calc(50% - 3px);
-  background: #fff;
-  border-radius: 6px;
-  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
-  transition: left 0.22s ease;
-}
-
-.role-slider.slide-left  { left: 3px; }
-.role-slider.slide-right { left: calc(50%); }
-
-.role-option {
-  position: relative;
-  flex: 1;
-  padding: 8px 0;
-  border: none;
-  background: none;
-  font-size: 14px;
-  color: #8c8c8c;
-  cursor: pointer;
-  border-radius: 6px;
-  transition: color 0.22s;
-  z-index: 1;
+  font-weight: 600;
+  color: #3d5078;
 }
 
-.role-option.active { color: #1a1a1a; font-weight: 500; }
-.role-option:disabled { cursor: not-allowed; }
-.role-toggle.disabled { opacity: 0.6; }
-
 .form-group input {
-  padding: 10px 12px;
-  border: 1px solid #d9d9d9;
-  border-radius: 6px;
+  height: 42px;
+  padding: 0 12px;
+  border: 1px solid #ccd6ea;
+  border-radius: 8px;
   font-size: 14px;
-  color: #1a1a1a;
+  color: #223355;
   outline: none;
-  transition: border-color 0.2s;
+  transition: border-color 0.2s, box-shadow 0.2s;
 }
 
 .form-group input:focus {
-  border-color: #4096ff;
-  box-shadow: 0 0 0 2px rgba(64, 150, 255, 0.2);
+  border-color: #3f6fd8;
+  box-shadow: 0 0 0 3px rgba(63, 111, 216, 0.15);
 }
 
 .form-group input:disabled {
-  background: #f5f5f5;
+  background: #f4f7fc;
+  color: #8ea0c2;
   cursor: not-allowed;
 }
 
 .form-group input.input-error {
-  border-color: #ff4d4f;
+  border-color: #db5f5f;
 }
 
 .field-error {
   font-size: 12px;
-  color: #ff4d4f;
+  color: #c24a4a;
 }
 
-/* 密码强度条 */
 .strength-bar {
   display: flex;
   align-items: center;
@@ -297,88 +271,110 @@ async function handleRegister() {
 
 .strength-track {
   flex: 1;
-  height: 4px;
-  background: #f0f0f0;
-  border-radius: 2px;
+  height: 5px;
+  border-radius: 999px;
   overflow: hidden;
+  background: #e8edf8;
 }
 
 .strength-fill {
   height: 100%;
-  border-radius: 2px;
-  transition: width 0.3s, background 0.3s;
+  border-radius: 999px;
+  transition: width 0.25s, background 0.25s;
+}
+
+.strength-fill.weak {
+  background: #d85858;
 }
 
-.strength-fill.weak   { background: #ff4d4f; }
-.strength-fill.medium { background: #faad14; }
-.strength-fill.strong { background: #52c41a; }
+.strength-fill.medium {
+  background: #e0a23b;
+}
+
+.strength-fill.strong {
+  background: #2ca06f;
+}
 
 .strength-label {
   font-size: 12px;
   white-space: nowrap;
 }
 
-.strength-label.weak   { color: #ff4d4f; }
-.strength-label.medium { color: #faad14; }
-.strength-label.strong { color: #52c41a; }
+.strength-label.weak {
+  color: #c24a4a;
+}
+
+.strength-label.medium {
+  color: #b8832e;
+}
+
+.strength-label.strong {
+  color: #247b56;
+}
 
 .error-msg {
-  padding: 8px 12px;
-  background: #fff2f0;
-  border: 1px solid #ffccc7;
-  border-radius: 6px;
+  padding: 10px 12px;
+  border: 1px solid #f2cccc;
+  border-radius: 8px;
+  background: #fff3f3;
+  color: #bb2f2f;
   font-size: 13px;
-  color: #cf1322;
 }
 
-.login-btn {
-  display: flex;
+.auth-btn {
+  margin-top: 2px;
+  height: 42px;
+  border: none;
+  border-radius: 8px;
+  background: #0080ff;
+  color: #ffffff;
+  font-size: 14px;
+  font-weight: 600;
+  cursor: pointer;
+  display: inline-flex;
   align-items: center;
   justify-content: center;
   gap: 8px;
-  padding: 11px;
-  background: #1677ff;
-  color: #fff;
-  border: none;
-  border-radius: 6px;
-  font-size: 15px;
-  font-weight: 500;
-  cursor: pointer;
-  transition: background 0.2s;
-  margin-top: 4px;
+  box-shadow: 0 6px 14px rgba(0, 128, 255, 0.22);
+  transition: transform 0.2s, opacity 0.2s;
 }
 
-.login-btn:hover:not(:disabled) {
-  background: #4096ff;
+.auth-btn:hover:not(:disabled) {
+  transform: translateY(-1px);
+  background: #0072e6;
 }
 
-.login-btn:disabled {
-  background: #91caff;
+.auth-btn:disabled {
+  opacity: 0.6;
   cursor: not-allowed;
+  box-shadow: none;
 }
 
 .spinner {
   width: 14px;
   height: 14px;
   border: 2px solid rgba(255, 255, 255, 0.4);
-  border-top-color: #fff;
+  border-top-color: #ffffff;
   border-radius: 50%;
   animation: spin 0.7s linear infinite;
 }
 
 @keyframes spin {
-  to { transform: rotate(360deg); }
+  to {
+    transform: rotate(360deg);
+  }
 }
 
 .switch-link {
-  margin: 20px 0 0;
+  margin: 16px 0 0;
   text-align: center;
   font-size: 13px;
-  color: #8c8c8c;
+  color: #6e81a9;
 }
 
 .switch-link a {
-  color: #1677ff;
+  color: #2f68ff;
+  font-weight: 600;
   text-decoration: none;
 }
 
@@ -387,10 +383,21 @@ async function handleRegister() {
 }
 
 @media (max-width: 768px) {
-  .login-card {
-    width: calc(100vw - 40px);
-    max-width: 360px;
-    padding: 32px 24px;
+  .auth-shell {
+    padding: 16px;
+  }
+
+  .auth-card {
+    padding: 26px 18px 20px;
+    border-radius: 14px;
+  }
+
+  .auth-logo {
+    width: 220px;
+  }
+
+  .auth-brand h1 {
+    font-size: 21px;
   }
 }
 </style>

+ 20 - 11
frontend/src/views/ops/AlertEventPage.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="page-root">
+  <div class="page-root member-theme">
     <Sidebar />
 
     <main class="main-content">
@@ -51,10 +51,10 @@
             <label>处理状态</label>
             <select v-model="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>
+              <option value="UNREAD">未处理</option>
+              <option value="PROCESSING">处理中</option>
+              <option value="HANDLED">已处理</option>
+              <option value="IGNORED">已忽略</option>
             </select>
           </div>
           <div class="filter-actions">
@@ -79,17 +79,16 @@
                 <th>阈值</th>
                 <th>通知渠道</th>
                 <th>处理状态</th>
-                <th>处理人 ID</th>
                 <th>处理时间</th>
                 <th>操作</th>
               </tr>
             </thead>
             <tbody>
               <tr v-if="loading">
-                <td colspan="12" class="empty-row">加载中...</td>
+                <td colspan="11" class="empty-row">加载中...</td>
               </tr>
               <tr v-else-if="events.length === 0">
-                <td colspan="12" class="empty-row">暂无数据</td>
+                <td colspan="11" class="empty-row">暂无数据</td>
               </tr>
               <tr v-for="item in events" :key="item.alertEventId">
                 <td class="id-text">{{ item.alertEventId }}</td>
@@ -100,8 +99,7 @@
                 <td>{{ item.currentValue ?? '-' }}</td>
                 <td>{{ item.thresholdValue ?? '-' }}</td>
                 <td>{{ item.notifyChannel || '-' }}</td>
-                <td><span :class="handleStatusClass(item.handleStatus)">{{ item.handleStatus }}</span></td>
-                <td>{{ item.handlerId ?? '-' }}</td>
+                <td><span :class="handleStatusClass(item.handleStatus)">{{ handleStatusLabel(item.handleStatus) }}</span></td>
                 <td>{{ formatTime(item.handledTime) }}</td>
                 <td class="action-cell">
                   <button
@@ -173,7 +171,7 @@
                   <span v-if="status === 'IGNORED'" class="status-icon">🔕</span>
                 </div>
                 <div class="status-info">
-                  <span class="status-name">{{ status }}</span>
+                  <span class="status-name">{{ handleStatusLabel(status) }}</span>
                   <span class="status-desc">{{ getStatusDesc(status) }}</span>
                 </div>
               </div>
@@ -294,6 +292,16 @@ function getStatusDesc(status) {
   }
 }
 
+function handleStatusLabel(status) {
+  const mapping = {
+    UNREAD: '未处理',
+    PROCESSING: '处理中',
+    HANDLED: '已处理',
+    IGNORED: '已忽略'
+  };
+  return mapping[status] || status || '-';
+}
+
 async function submitHandle() {
   dialog.submitting = true;
   dialog.error = '';
@@ -548,6 +556,7 @@ tbody tr:hover { background: var(--accent-hover); }
   font-size: 12px;
   font-weight: 500;
   color: #fff;
+  white-space: nowrap;
 }
 
 .badge-critical { background: #722ed1; color: #fff; }

+ 1 - 1
frontend/src/views/ops/AlertPage.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="page-root">
+  <div class="page-root member-theme">
         <Sidebar />
 
     <main class="main-content">

+ 1 - 1
frontend/src/views/ops/AlertRulePage.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="page-root">
+  <div class="page-root member-theme">
     <Sidebar />
     <div v-if="successMessage" class="success-toast">{{ successMessage }}</div>
 

+ 25 - 9
frontend/src/views/ops/AuditLogPage.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="page-root">
+  <div class="page-root member-theme">
     <Sidebar />
 
     <main class="main-content">
@@ -64,8 +64,8 @@
             <label>结果状态</label>
             <select v-model="filters.resultStatus">
               <option value="">全部</option>
-              <option value="SUCCESS">SUCCESS</option>
-              <option value="FAILED">FAILED</option>
+              <option value="SUCCESS">成功</option>
+              <option value="FAILED">失败</option>
             </select>
           </div>
           <div class="filter-actions">
@@ -107,7 +107,7 @@
                 <td>{{ item.operatorId ?? '-' }}</td>
                 <td>
                   <span :class="item.resultStatus === 'SUCCESS' ? 'status-badge status-success' : 'status-badge status-failed'">
-                    {{ item.resultStatus }}
+                    {{ auditResultStatusLabel(item.resultStatus) }}
                   </span>
                 </td>
                 <td class="detail-cell col-remark">{{ item.detail || '-' }}</td>
@@ -158,6 +158,11 @@ const datePickerLocale = zhCN;
 const datePickerActionRow = Object.freeze({ selectBtnLabel: '确定', cancelBtnLabel: '取消' });
 
 const availableActionTypes = [
+  'CLUSTER_CREATE',
+  'CLUSTER_START',
+  'CLUSTER_STOP',
+  'CLUSTER_RESTART',
+  'CLUSTER_DELETE',
   'WORKFLOW_SUBMIT',
   'WORKFLOW_DISPATCH',
   'TASK_RETRY',
@@ -166,13 +171,16 @@ const availableActionTypes = [
   'INSTANCE_START',
   'INSTANCE_STOP',
   'INSTANCE_RESTART',
-  'SCALE_OUT',
-  'SCALE_IN',
+  'CLUSTER_SCALE_OUT',
+  'CLUSTER_SCALE_IN',
   'STOCK_CREATE',
   'STOCK_UPDATE',
   'STOCK_DELETE',
+  'SCALING_RULE_CREATE',
+  'SCALING_RULE_DELETE',
   'ALERT_RULE_CREATE',
-  'ALERT_RULE_DISABLE',
+  'ALERT_RULE_DELETE',
+  'ALERT_RULE_TOGGLE',
   'ALERT_EVENT_HANDLE'
 ];
 
@@ -181,7 +189,8 @@ const availableTargetTypes = [
   'WORKFLOW_INSTANCE',
   'TASK_INSTANCE',
   'CLUSTER',
-  'MACHINE',
+  'STOCK_MACHINE',
+  'SCALING_RULE',
   'ALERT_RULE',
   'ALERT_EVENT'
 ];
@@ -289,6 +298,14 @@ function formatDisplayTime(value) {
   return value.replace('T', ' ');
 }
 
+function auditResultStatusLabel(status) {
+  const mapping = {
+    SUCCESS: '成功',
+    FAILED: '失败'
+  };
+  return mapping[status] || status || '-';
+}
+
 onMounted(() => {
   fetchLogs();
 });
@@ -632,4 +649,3 @@ tbody tr:hover {
   }
 }
 </style>
-

+ 72 - 52
frontend/src/views/resource-management/ClusterConsolePage.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="page-root">
+  <div class="page-root member-theme">
         <Sidebar />
 
     <main class="main-content">
@@ -21,25 +21,38 @@
         <div v-if="clusterError" class="error-banner"><span>{{ clusterError }}</span><button class="error-banner-close" @click="clusterError = ''">&times;</button></div>
         <div class="info-header">
           <div class="cluster-title">
-            <span class="cluster-name">{{ cluster.clusterName }}</span>
+            <span v-if="!isEditingCluster" class="cluster-name">{{ cluster.clusterName }}</span>
+            <input
+              v-else
+              v-model.trim="clusterForm.clusterName"
+              class="inline-cluster-name-input"
+              type="text"
+              placeholder="请输入集群名称"
+            />
             <span v-if="cluster.version" class="version-tag">{{ cluster.version }}</span>
             <span :class="['status-badge', resolveClusterStatusClass(cluster.status)]">
               {{ resolveClusterStatusLabel(cluster.status) }}
             </span>
           </div>
           <div class="action-buttons">
-            <button class="btn btn-default" :disabled="lifecycleLoading" @click="openEditClusterDialog">编辑信息</button>
-            <template v-if="cluster.status === 'RUNNING'">
+            <template v-if="isEditingCluster">
+              <button class="btn btn-default" :disabled="clusterSubmitting" @click="cancelEditCluster">取消</button>
+              <button class="btn btn-primary" :disabled="clusterSubmitting" @click="handleSaveCluster">
+                {{ clusterSubmitting ? '保存中...' : '保存' }}
+              </button>
+            </template>
+            <button v-else class="btn btn-default" :disabled="lifecycleLoading" @click="startEditCluster">编辑信息</button>
+            <template v-if="!isEditingCluster && cluster.status === 'RUNNING'">
               <button class="btn btn-danger" :disabled="lifecycleLoading || !isClusterRunning" @click="handleStop">
                 {{ lifecycleLoading ? '处理中...' : '停止' }}
               </button>
               <button class="btn btn-default" :disabled="lifecycleLoading || !isClusterRunning" @click="handleRestart">重启</button>
             </template>
-            <template v-else-if="isBusyStatus(cluster.status)">
+            <template v-else-if="!isEditingCluster && isBusyStatus(cluster.status)">
               <button class="btn btn-default" disabled>{{ cluster.status === CLUSTER_STATUS_SCALING ? '扩缩容中...' : '创建中...' }}</button>
               <button class="btn btn-danger" disabled>销毁</button>
             </template>
-            <template v-else>
+            <template v-else-if="!isEditingCluster">
               <button class="btn btn-primary" :disabled="lifecycleLoading" @click="handleStart">
                 {{ lifecycleLoading ? '处理中...' : '启动' }}
               </button>
@@ -47,6 +60,7 @@
             </template>
           </div>
         </div>
+        <div v-if="clusterDialogError" class="error-banner"><span>{{ clusterDialogError }}</span><button class="error-banner-close" @click="clusterDialogError = ''">&times;</button></div>
         <div class="info-grid">
           <div class="info-item">
             <span class="info-label">集群 ID</span>
@@ -58,7 +72,15 @@
           </div>
           <div class="info-item">
             <span class="info-label">备注</span>
-            <span class="info-value">{{ cluster.description || '-' }}</span>
+            <span v-if="!isEditingCluster" class="info-value">{{ cluster.description || '-' }}</span>
+            <input
+              v-else
+              v-model.trim="clusterForm.description"
+              class="inline-info-input"
+              type="text"
+              placeholder="请输入备注(可空)"
+              maxlength="50"
+            />
           </div>
           <div class="info-item">
             <span class="info-label">创建时间</span>
@@ -89,7 +111,7 @@
           <div class="panel-title">节点列表</div>
           <div v-if="cluster.componentType?.toUpperCase() === 'SPARK'" class="panel-actions">
             <button class="btn btn-primary btn-xs" :disabled="!isClusterRunning" @click="openScaleOutDialog">扩容</button>
-            <button class="btn btn-danger btn-xs" :disabled="!isClusterRunning" @click="openScaleInDialog">缩容</button>
+            <button class="btn btn-primary btn-xs" :disabled="!isClusterRunning" @click="openScaleInDialog">缩容</button>
           </div>
         </div>
         <div v-if="nodeError" class="error-banner"><span>{{ nodeError }}</span><button class="error-banner-close" @click="nodeError = ''">&times;</button></div>
@@ -165,7 +187,7 @@
               <span class="column-title">Backend 节点</span>
               <div class="panel-actions">
                 <button class="btn btn-primary btn-xs" :disabled="!isClusterRunning" @click="openScaleOutDialog">扩容</button>
-                <button class="btn btn-danger btn-xs" :disabled="!isClusterRunning" @click="openScaleInDialog">缩容</button>
+                <button class="btn btn-primary btn-xs" :disabled="!isClusterRunning" @click="openScaleInDialog">缩容</button>
               </div>
             </div>
             <div class="node-table-wrapper mini-table">
@@ -428,35 +450,6 @@
       </template>
     </main>
 
-    <!-- Edit Cluster Dialog -->
-    <div v-if="showEditClusterDialog" class="modal-overlay" @click.self="closeEditClusterDialog">
-      <div class="modal modal-compact">
-        <div class="modal-header">
-          <h2>编辑集群信息</h2>
-          <button class="modal-close" @click="closeEditClusterDialog">×</button>
-        </div>
-        <div class="modal-body">
-          <div class="form-col">
-            <div class="form-item">
-              <label>集群名称 <span class="required">*</span></label>
-              <input v-model.trim="clusterForm.clusterName" type="text" placeholder="请输入集群名称" />
-            </div>
-            <div class="form-item">
-              <label>备注</label>
-              <input v-model.trim="clusterForm.description" type="text" placeholder="请输入备注(可空)" maxlength="50" />
-            </div>
-          </div>
-          <div v-if="clusterDialogError" class="error-banner"><span>{{ clusterDialogError }}</span><button class="error-banner-close" @click="clusterDialogError = ''">&times;</button></div>
-        </div>
-        <div class="modal-footer">
-          <button class="btn btn-default" :disabled="clusterSubmitting" @click="closeEditClusterDialog">取消</button>
-          <button class="btn btn-primary" :disabled="clusterSubmitting" @click="handleSaveCluster">
-            {{ clusterSubmitting ? '保存中...' : '保存' }}
-          </button>
-        </div>
-      </div>
-    </div>
-
     <!-- Add Rule Dialog -->
     <div v-if="showAddRuleDialog" class="modal-overlay" @click.self="closeAddRuleDialog">
       <div class="modal">
@@ -482,16 +475,16 @@
               <div class="trigger-type-switch" role="group" aria-label="触发类型切换">
                   <button
                     class="trigger-type-btn"
-                    :class="{ active: ruleForm.triggerType === 'METRIC' }"
+                    :class="{ active: ruleForm.triggerType === 'CRON' }"
                     type="button"
-                    @click="ruleForm.triggerType = 'METRIC'"
-                  >指标触发</button>
+                    @click="ruleForm.triggerType = 'CRON'"
+                  >按时间</button>
                   <button
                     class="trigger-type-btn"
-                    :class="{ active: ruleForm.triggerType === 'CRON' }"
+                    :class="{ active: ruleForm.triggerType === 'METRIC' }"
                     type="button"
-                    @click="ruleForm.triggerType = 'CRON'"
-                  >定时触发</button>
+                    @click="ruleForm.triggerType = 'METRIC'"
+                  >按指标</button>
               </div>
             </div>
             <!-- METRIC fields -->
@@ -640,7 +633,7 @@
             </div>
             </div>        <div class="modal-footer">
           <button class="btn btn-default" :disabled="scalingSubmitting" @click="showScaleInDialog = false">取消</button>
-          <button class="btn btn-danger" :disabled="scalingSubmitting || !isClusterRunning" @click="handleScaleIn">
+          <button class="btn btn-primary" :disabled="scalingSubmitting || !isClusterRunning" @click="handleScaleIn">
             {{ scalingSubmitting ? '提交中...' : '确认缩容' }}
           </button>
         </div>
@@ -787,7 +780,7 @@ const cluster = reactive({});
 const isClusterRunning = computed(() => isRunningStatus(cluster.status));
 const clusterError = ref('');
 const lifecycleLoading = ref(false);
-const showEditClusterDialog = ref(false);
+const isEditingCluster = ref(false);
 const clusterSubmitting = ref(false);
 const clusterDialogError = ref('');
 const clusterForm = reactive({ clusterName: '', description: '' });
@@ -862,18 +855,18 @@ async function handleDestroy() {
   }
 }
 
-function openEditClusterDialog() {
+function startEditCluster() {
   clusterDialogError.value = '';
   Object.assign(clusterForm, {
     clusterName: cluster.clusterName || '',
     description: cluster.description || ''
   });
-  showEditClusterDialog.value = true;
+  isEditingCluster.value = true;
 }
 
-function closeEditClusterDialog() {
+function cancelEditCluster() {
   if (clusterSubmitting.value) return;
-  showEditClusterDialog.value = false;
+  isEditingCluster.value = false;
 }
 
 async function handleSaveCluster() {
@@ -895,7 +888,7 @@ async function handleSaveCluster() {
     };
     const { data } = await updateCluster(clusterId, payload);
     Object.assign(cluster, data || {});
-    showEditClusterDialog.value = false;
+    isEditingCluster.value = false;
   } catch (err) {
     clusterDialogError.value = err?.response?.data?.message || '更新集群信息失败';
   } finally {
@@ -1276,7 +1269,7 @@ const addingRule = ref(false);
 const addRuleError = ref('');
 
 const ruleFormDefaults = () => ({
-  scaleRuleName: '', targetNodes: null, triggerType: 'METRIC',
+  scaleRuleName: '', targetNodes: null, triggerType: 'CRON',
   metricName: 'cpu_usage', thresholdValue: null, comparisonOperator: '>=', durationSeconds: null,
   cronExpression: ''
 });
@@ -1713,6 +1706,20 @@ onUnmounted(() => {
 .info-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; flex-wrap: wrap; gap: 12px; }
 .cluster-title { display: flex; align-items: center; gap: 10px; }
 .cluster-name { font-size: 18px; font-weight: 600; }
+.inline-cluster-name-input {
+  width: 220px;
+  min-width: 160px;
+  padding: 5px 10px;
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
+  font-size: 16px;
+  font-weight: 600;
+  color: var(--text-main);
+}
+.inline-cluster-name-input:focus {
+  outline: none;
+  border-color: var(--accent);
+}
 .version-tag { font-size: 12px; padding: 2px 8px; background: #f4f4f5; border-radius: 4px; color: var(--text-muted); }
 .status-badge { padding: 3px 12px; border-radius: 4px; font-size: 12px; font-weight: 500; }
 .badge-running { background: #67c23a; color: #fff; }
@@ -1726,6 +1733,19 @@ onUnmounted(() => {
 .info-item.full-width { grid-column: 1 / -1; flex-direction: row; gap: 12px; align-items: baseline; }
 .info-label { font-size: 12px; color: var(--text-muted); }
 .info-value { font-size: 13px; color: var(--text-main); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.inline-info-input {
+  width: 220px;
+  max-width: 100%;
+  padding: 6px 10px;
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
+  font-size: 13px;
+  color: var(--text-main);
+}
+.inline-info-input:focus {
+  outline: none;
+  border-color: var(--accent);
+}
 .id-text { font-family: monospace; font-size: 12px; color: var(--text-muted); }
 .mono-text { font-family: monospace; font-size: 12px; }
 

+ 90 - 8
frontend/src/views/resource-management/ClusterPage.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="page-root">
+  <div class="page-root member-theme">
     <Sidebar />
 
     <main class="main-content">
@@ -155,11 +155,15 @@
               节点配置
               <span
                 v-if="selectedEngine === 'STARROCKS'"
-                class="section-help-icon question-circle soft"
-                title="FE/BE 节点组内机器配置应保持一致,不一致时将按最小配置生效。"
+                class="help-popover-wrap"
                 aria-label="StarRocks 节点配置说明"
                 tabindex="0"
-              >?</span>
+              >
+                <span class="section-help-icon question-circle soft">?</span>
+                <span class="help-popover" role="tooltip">
+                  FE/BE 节点组内机器配置应保持一致,不一致时将按最小配置生效。
+                </span>
+              </span>
             </div>
             <div v-if="currentEngine.lockedCounts" class="locked-counts-tip">
               {{ currentEngine.name }} 固定部署 1 个节点
@@ -170,11 +174,15 @@
                   {{ currentEngine.roleLabels[role] }} 节点数
                   <span
                     v-if="getStarRocksRoleHelpText(role)"
-                    class="field-help-icon question-circle soft"
-                    :title="getStarRocksRoleHelpText(role)"
+                    :class="['help-popover-wrap', { 'help-popover-wrap-top': isStarRocksBeRole(role) }]"
                     :aria-label="`${currentEngine.roleLabels[role]} 节点说明`"
                     tabindex="0"
-                  >?</span>
+                  >
+                    <span class="field-help-icon question-circle soft">?</span>
+                    <span class="help-popover" role="tooltip">
+                      {{ getStarRocksRoleHelpText(role) }}
+                    </span>
+                  </span>
                 </label>
                 <input v-model.number="nodeCounts[role]" type="number" min="1" max="20" />
                 <div v-if="isStarRocksFeRole(role)" class="field-tip">Frontend 节点数必须为奇数(1/3/5...)</div>
@@ -454,6 +462,10 @@ function isStarRocksFeRole(role) {
   return selectedEngine.value === "STARROCKS" && role === "fe";
 }
 
+function isStarRocksBeRole(role) {
+  return selectedEngine.value === 'STARROCKS' && role === 'be';
+}
+
 function getStarRocksRoleHelpText(role) {
   if (selectedEngine.value !== 'STARROCKS') return '';
   if (role === 'fe') {
@@ -905,7 +917,7 @@ onMounted(() => {
 }
 .modal-close:hover { color: var(--text-main); }
 
-.modal-body { padding: 24px; overflow-y: auto; flex: 1; }
+.modal-body { padding: 24px 32px 24px 24px; overflow-y: auto; flex: 1; }
 
 .modal-footer {
   padding: 16px 24px; border-top: 1px solid var(--border-color);
@@ -1073,6 +1085,27 @@ onMounted(() => {
   outline-offset: 2px;
 }
 
+.help-popover-wrap {
+  position: relative;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  overflow: visible;
+}
+
+.help-popover-wrap:hover .help-popover,
+.help-popover-wrap:focus-visible .help-popover,
+.help-popover-wrap:focus-within .help-popover {
+  display: block;
+}
+
+.help-popover-wrap-top:hover .help-popover,
+.help-popover-wrap-top:focus-visible .help-popover,
+.help-popover-wrap-top:focus-within .help-popover {
+  display: block;
+}
+
 .section-help-icon {
   width: 15px;
   height: 15px;
@@ -1107,6 +1140,55 @@ onMounted(() => {
   font-weight: 700;
 }
 
+.help-popover {
+  display: none;
+  position: absolute;
+  top: 50%;
+  left: calc(100% + 10px);
+  transform: translateY(-50%);
+  width: 220px;
+  padding: 10px 12px;
+  border-radius: 10px;
+  border: 1px solid #dbe5f6;
+  background: rgba(255, 255, 255, 0.98);
+  color: #334567;
+  font-size: 12px;
+  font-weight: 400;
+  line-height: 1.6;
+  white-space: normal;
+  text-transform: none;
+  letter-spacing: 0;
+  box-shadow: 0 12px 28px rgba(31, 47, 85, 0.14);
+  z-index: 30;
+}
+
+.help-popover::before {
+  content: '';
+  position: absolute;
+  right: 100%;
+  top: 50%;
+  width: 10px;
+  height: 10px;
+  background: rgba(255, 255, 255, 0.98);
+  border-top: 1px solid #dbe5f6;
+  border-left: 1px solid #dbe5f6;
+  transform: translate(50%, -50%) rotate(-45deg);
+}
+
+.help-popover-wrap-top .help-popover {
+  top: auto;
+  bottom: calc(100% + 10px);
+  left: 50%;
+  transform: translateX(-50%);
+}
+
+.help-popover-wrap-top .help-popover::before {
+  right: auto;
+  left: 50%;
+  top: 100%;
+  transform: translate(-50%, -50%) rotate(135deg);
+}
+
 .field-tip {
   font-size: 11px;
   color: var(--text-muted);

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

@@ -1,5 +1,5 @@
 <template>
-  <div class="page-root">
+  <div class="page-root member-theme">
     <Sidebar />
 
     <main class="main-content">

+ 28 - 17
frontend/src/views/resource-management/StockPage.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="page-root">
+  <div class="page-root member-theme">
     <Sidebar />
 
     <main class="main-content">
@@ -19,9 +19,9 @@
             <label>状态</label>
             <select v-model="filters.status">
               <option value="">全部</option>
-              <option value="IDLE">IDLE</option>
-              <option value="WORKING">WORKING</option>
-              <option value="OFFLINE">OFFLINE</option>
+              <option value="IDLE">空闲</option>
+              <option value="WORKING">工作中</option>
+              <option value="OFFLINE">离线</option>
             </select>
           </div>
           <div class="filter-item">
@@ -77,7 +77,7 @@
                   </button>
                   <span v-else>-</span>
                 </td>
-                <td><span :class="statusBadgeClass(item.status)">{{ item.status }}</span></td>
+                <td><span :class="statusBadgeClass(item.status)">{{ stockStatusLabel(item.status) }}</span></td>
                 <td class="desc-text col-remark">{{ item.description || '-' }}</td>
                 <td class="action-cell">
                   <button
@@ -93,7 +93,7 @@
                   <button
                     class="btn btn-danger btn-sm"
                     :disabled="item.status === 'WORKING'"
-                    :title="item.status === 'WORKING' ? 'WORKING 状态不可删除' : ''"
+                    :title="item.status === 'WORKING' ? '工作中状态不可删除' : ''"
                     @click="handleDelete(item)"
                   >删除</button>
                 </td>
@@ -125,15 +125,17 @@
         <div class="form-two-col">
           <div class="form-col">
             <div class="form-field">
-              <label>IP 地址 *</label>
+              <label>IP 地址 <span class="required">*</span></label>
               <input v-model.trim="form.ipAddress" type="text" placeholder="例如 10.0.0.11" :disabled="dialog.isEdit" />
             </div>
             <div class="form-field">
-              <label>SSH 用户名 *</label>
+              <label>SSH 用户名 <span class="required">*</span></label>
               <input v-model.trim="form.sshUsername" type="text" placeholder="例如 root / ubuntu" autocomplete="off" :disabled="isWorkingEdit" />
             </div>
             <div class="form-field">
-              <label>{{ dialog.isEdit ? 'SSH 密码(留空不修改)' : 'SSH 密码 *' }}</label>
+              <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" />
             </div>
             <div class="form-field">
@@ -143,8 +145,8 @@
             <div v-if="dialog.isEdit && !isWorkingEdit" class="form-field">
               <label>状态</label>
               <select v-model="form.status">
-                <option value="IDLE">IDLE</option>
-                <option value="OFFLINE">OFFLINE</option>
+                <option value="IDLE">空闲</option>
+                <option value="OFFLINE">离线</option>
               </select>
             </div>
           </div>
@@ -161,20 +163,20 @@
               <input v-model.trim="form.cpuModel" type="text" placeholder="例如 Intel" :disabled="isWorkingEdit" />
             </div>
             <div class="form-field">
-              <label>CPU 核心数 *</label>
+              <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" />
             </div>
             <div class="form-field">
-              <label>内存 (GB) *</label>
+              <label>内存 (GB) <span class="required">*</span></label>
               <input v-model.number="form.ramGb" type="number" min="4" step="4" placeholder="例如 320" :disabled="isWorkingEdit" />
             </div>
             <div class="form-field">
-              <label>SSD (GB) *</label>
+              <label>SSD (GB) <span class="required">*</span></label>
               <input v-model.number="form.ssdGb" type="number" min="1024" step="1024" placeholder="例如 2048" :disabled="isWorkingEdit" />
             </div>
           </div>
         </div>
-        <div v-if="isWorkingEdit" class="tips-banner">当前机器为 WORKING,仅可修改备注。</div>
+        <div v-if="isWorkingEdit" 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">
@@ -454,6 +456,15 @@ function statusBadgeClass(status) {
   };
 }
 
+function stockStatusLabel(status) {
+  const mapping = {
+    IDLE: '空闲',
+    WORKING: '工作中',
+    OFFLINE: '离线'
+  };
+  return mapping[status] || status || '-';
+}
+
 function goToClusterConsole(clusterId) {
   if (!clusterId) {
     return;
@@ -858,6 +869,8 @@ tbody tr:hover { background: var(--accent-hover); }
   color: var(--text-main);
 }
 
+.required { color: var(--danger); }
+
 .form-field-full { grid-column: 1 / -1; }
 
 .modal-actions {
@@ -953,5 +966,3 @@ tbody tr:hover { background: var(--accent-hover); }
   }
 }
 </style>
-
-

+ 739 - 0
frontend/src/views/settings/MemberManagementPage.vue

@@ -0,0 +1,739 @@
+<template>
+  <div class="page-layout member-theme">
+    <Sidebar />
+    <div class="page-main">
+      <transition name="toast-fade">
+        <div v-if="userMsg" :class="['top-toast', userMsgType]">{{ userMsg }}</div>
+      </transition>
+      <div class="settings-container">
+        <div class="page-head">
+          <h2 class="settings-title">成员管理</h2>
+        </div>
+
+        <div class="settings-card">
+          <div class="card-head">
+            <h3 class="card-title">邀请码配置</h3>
+            <div class="card-actions">
+              <button v-if="!inviteEditMode" class="btn btn-default" @click="inviteEditMode = true">编辑</button>
+              <template v-else>
+                <button class="btn btn-default" @click="handleGenerateInviteCodes">随机生成</button>
+                <button class="btn btn-primary" @click="handleSaveInviteCodes">保存</button>
+                <button class="btn btn-default" @click="handleCancelInviteEdit">取消</button>
+              </template>
+            </div>
+          </div>
+          <div class="form-grid">
+            <div class="form-item">
+              <label class="label-with-help">
+                分析师邀请码
+                <span
+                  class="role-help-wrap"
+                  :aria-label="getRolePermissionTip('ANALYST')"
+                  tabindex="0"
+                >
+                  <span class="role-help">?</span>
+                  <span class="role-help-popover" role="tooltip">
+                    {{ getRolePermissionTip('ANALYST') }}
+                  </span>
+                </span>
+              </label>
+              <input
+                v-if="inviteEditMode"
+                v-model="inviteCodes.analystInviteCode"
+                class="form-input"
+                placeholder="分析师邀请码"
+              />
+              <div v-else class="readonly-value">{{ inviteCodes.analystInviteCode || '-' }}</div>
+            </div>
+            <div class="form-item">
+              <label class="label-with-help">
+                开发者邀请码
+                <span
+                  class="role-help-wrap"
+                  :aria-label="getRolePermissionTip('DEVELOPER')"
+                  tabindex="0"
+                >
+                  <span class="role-help">?</span>
+                  <span class="role-help-popover" role="tooltip">
+                    {{ getRolePermissionTip('DEVELOPER') }}
+                  </span>
+                </span>
+              </label>
+              <input
+                v-if="inviteEditMode"
+                v-model="inviteCodes.developerInviteCode"
+                class="form-input"
+                placeholder="开发者邀请码"
+              />
+              <div v-else class="readonly-value">{{ inviteCodes.developerInviteCode || '-' }}</div>
+            </div>
+          </div>
+        </div>
+
+        <div class="settings-card">
+          <div class="card-head card-head-simple">
+            <h3 class="card-title">成员列表</h3>
+          </div>
+          <div class="user-table">
+            <table>
+              <thead>
+                <tr>
+                  <th>用户名</th>
+                  <th>角色</th>
+                  <th>邮箱</th>
+                  <th>操作</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr v-for="item in users" :key="item.userId">
+                  <td>
+                    <div class="username">{{ item.username }}</div>
+                  </td>
+                  <td>
+                    <div class="role-cell">
+                    <select
+                      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>
+                    <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>
+                    </div>
+                  </td>
+                  <td>
+                    <input v-if="isEditing(item)" v-model="userEdits[item.userId].email" class="inline-input" />
+                    <span v-else class="email-text">{{ item.email || '-' }}</span>
+                  </td>
+                  <td class="inline-actions">
+                    <template v-if="isEditing(item)">
+                      <button class="btn btn-primary btn-sm" @click="handleUpdateUser(item)">保存</button>
+                      <button class="btn btn-default btn-sm" @click="cancelUserEdit(item)">取消</button>
+                    </template>
+                    <button v-else class="btn btn-default btn-sm" @click="startUserEdit(item)">编辑</button>
+                    <button class="btn btn-danger btn-sm" @click="handleDeleteUser(item)">删除</button>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { onMounted, reactive, ref } from 'vue';
+import { useRouter } from 'vue-router';
+import Sidebar from '../../components/Sidebar.vue';
+import { getMe } from '../../api/auth';
+import { deleteUser, getInviteCodes, listUsers, updateInviteCodes, updateUser } from '../../api/adminUsers';
+import { strongConfirm } from '../../utils/strongConfirm';
+
+const router = useRouter();
+const users = ref([]);
+const userMsg = ref('');
+const userMsgType = ref('success');
+const inviteCodes = reactive({ analystInviteCode: '', developerInviteCode: '' });
+const inviteEditMode = ref(false);
+const editingUserId = ref(null);
+const userEdits = reactive({});
+
+onMounted(async () => {
+  try {
+    const { data } = await getMe();
+    if ((data.role || '').toUpperCase() !== 'ADMIN') {
+      router.push('/');
+      return;
+    }
+  } catch {
+    router.push('/');
+    return;
+  }
+  await loadInviteCodes();
+  await loadUsers();
+});
+
+async function loadInviteCodes() {
+  try {
+    const { data } = await getInviteCodes();
+    inviteCodes.analystInviteCode = data?.analystInviteCode || '';
+    inviteCodes.developerInviteCode = data?.developerInviteCode || '';
+  } catch {
+    inviteCodes.analystInviteCode = '';
+    inviteCodes.developerInviteCode = '';
+  }
+}
+
+async function handleSaveInviteCodes() {
+  if (!inviteCodes.analystInviteCode || !inviteCodes.developerInviteCode) {
+    userMsg.value = '请填写两类邀请码';
+    userMsgType.value = 'error';
+    return;
+  }
+  try {
+    await updateInviteCodes({
+      analystInviteCode: inviteCodes.analystInviteCode,
+      developerInviteCode: inviteCodes.developerInviteCode
+    });
+    userMsg.value = '邀请码保存成功';
+    userMsgType.value = 'success';
+    inviteEditMode.value = false;
+  } catch (e) {
+    userMsg.value = e?.response?.data?.message || '邀请码保存失败';
+    userMsgType.value = 'error';
+  } finally {
+    setTimeout(() => { userMsg.value = ''; }, 3000);
+  }
+}
+
+async function handleCancelInviteEdit() {
+  inviteEditMode.value = false;
+  await loadInviteCodes();
+}
+
+function generateInviteCode(length = 6) {
+  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
+  let code = '';
+  for (let i = 0; i < length; i += 1) {
+    code += chars[Math.floor(Math.random() * chars.length)];
+  }
+  return code;
+}
+
+function handleGenerateInviteCodes() {
+  inviteCodes.analystInviteCode = generateInviteCode(6);
+  inviteCodes.developerInviteCode = generateInviteCode(6);
+}
+
+async function loadUsers() {
+  try {
+    const { data } = await listUsers();
+    users.value = data || [];
+    editingUserId.value = null;
+    Object.keys(userEdits).forEach((key) => delete userEdits[key]);
+  } catch {
+    users.value = [];
+  }
+}
+
+function isEditing(item) {
+  return editingUserId.value === item.userId && !!userEdits[item.userId];
+}
+
+function startUserEdit(item) {
+  editingUserId.value = item.userId;
+  userEdits[item.userId] = {
+    role: item.role || 'DEVELOPER',
+    email: item.email || ''
+  };
+}
+
+function isAdminRole(item) {
+  return (item.role || '').toUpperCase() === 'ADMIN';
+}
+
+function renderRoleLabel(role) {
+  const normalized = (role || '').toUpperCase();
+  if (normalized === 'DEVELOPER' || normalized === 'OPS') return 'DEVELOPER';
+  if (normalized === 'ANALYST') return 'ANALYST';
+  if (normalized === 'ADMIN') return 'ADMIN';
+  return normalized || '-';
+}
+
+function getRolePermissionTip(role) {
+  const normalized = (role || '').toUpperCase();
+  if (normalized === 'ADMIN') return '管理员:可管理成员、系统配置和全部业务功能。';
+  if (normalized === 'DEVELOPER' || normalized === 'OPS') return '开发者:可使用任务构建、资源管理与系统运维能力。';
+  if (normalized === 'ANALYST') return '分析师:可使用数据分析与任务相关能力,不含管理类权限。';
+  return '角色权限说明';
+}
+
+function cancelUserEdit(item) {
+  if (editingUserId.value === item.userId) editingUserId.value = null;
+  delete userEdits[item.userId];
+}
+
+async function handleUpdateUser(item) {
+  const draft = userEdits[item.userId];
+  if (!draft) return;
+  const wasAdmin = isAdminRole(item);
+  if (!wasAdmin && (draft.role || '').toUpperCase() === 'ADMIN') {
+    userMsg.value = '不允许将角色修改为 ADMIN';
+    userMsgType.value = 'error';
+    setTimeout(() => { userMsg.value = ''; }, 3000);
+    return;
+  }
+  try {
+    await updateUser(item.userId, { role: draft.role, email: draft.email });
+    item.role = draft.role;
+    item.email = draft.email;
+    userMsg.value = `已保存 ${item.username}`;
+    userMsgType.value = 'success';
+    cancelUserEdit(item);
+  } catch (e) {
+    userMsg.value = e?.response?.data?.message || '保存失败';
+    userMsgType.value = 'error';
+  } finally {
+    setTimeout(() => { userMsg.value = ''; }, 3000);
+  }
+}
+
+async function handleDeleteUser(item) {
+  const ok = await strongConfirm(`确认删除成员「${item.username}」?`, {
+    title: '删除成员',
+    confirmText: '确认删除',
+    cancelText: '取消',
+    confirmVariant: 'danger'
+  });
+  if (!ok) return;
+  try {
+    await deleteUser(item.userId);
+    await loadUsers();
+    userMsg.value = `已删除 ${item.username}`;
+    userMsgType.value = 'success';
+  } catch (e) {
+    userMsg.value = e?.response?.data?.message || '删除失败';
+    userMsgType.value = 'error';
+  } finally {
+    setTimeout(() => { userMsg.value = ''; }, 3000);
+  }
+}
+</script>
+
+<style scoped>
+.page-layout {
+  display: flex;
+  height: 100vh;
+  overflow: hidden;
+  gap: 8px;
+  padding: 8px;
+  background:
+    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%),
+    #f3f6fb;
+  box-sizing: border-box;
+}
+
+.page-main {
+  flex: 1;
+  overflow-y: auto;
+  min-width: 0;
+  scrollbar-width: thin;
+  scrollbar-color: #c0c4cc transparent;
+}
+
+.page-main::-webkit-scrollbar {
+  width: 6px;
+}
+
+.page-main::-webkit-scrollbar-thumb {
+  background-color: #c0c4cc;
+  border-radius: 4px;
+}
+
+.settings-container {
+  width: 100%;
+  max-width: none;
+  padding: 24px 0 40px;
+  box-sizing: border-box;
+}
+
+.settings-title {
+  font-size: 28px;
+  font-weight: 700;
+  color: #162447;
+  margin: 0;
+  letter-spacing: 0.02em;
+}
+
+.page-head {
+  margin: 0 0 16px;
+}
+
+.settings-card {
+  background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), #ffffff);
+  border-radius: 14px;
+  padding: 24px;
+  margin-bottom: 18px;
+  border: 1px solid #e7ebf4;
+  box-shadow: none;
+}
+
+.card-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #1f2f55;
+  margin: 0;
+}
+
+.card-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 18px;
+  padding-bottom: 12px;
+  border-bottom: 1px solid #edf1f8;
+}
+
+.card-head-simple {
+  margin-bottom: 14px;
+}
+
+.card-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.form-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 16px;
+}
+
+.form-item {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.form-item label {
+  font-size: 13px;
+  color: #4b5d84;
+  font-weight: 500;
+}
+
+.label-with-help {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.role-help-wrap {
+  position: relative;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  outline: none;
+}
+
+.role-help-wrap:hover .role-help-popover,
+.role-help-wrap:focus-visible .role-help-popover,
+.role-help-wrap:focus-within .role-help-popover {
+  display: block;
+}
+
+.role-help-wrap:focus-visible .role-help {
+  box-shadow: 0 0 0 3px rgba(63, 111, 216, 0.18);
+}
+
+.role-help {
+  width: 18px;
+  height: 18px;
+  border-radius: 50%;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  background: #eef4ff;
+  border: 1px solid #c8d7f6;
+  color: #3f6fd8;
+  font-size: 12px;
+  font-weight: 700;
+  line-height: 1;
+  cursor: help;
+  transition: border-color 0.2s, background 0.2s, color 0.2s, box-shadow 0.2s;
+}
+
+.role-help-wrap:hover .role-help,
+.role-help-wrap:focus-visible .role-help,
+.role-help-wrap:focus-within .role-help {
+  background: #e2edff;
+  border-color: #9eb8ee;
+  color: #2f5ed0;
+}
+
+.role-help-popover {
+  display: none;
+  position: absolute;
+  top: 50%;
+  left: calc(100% + 10px);
+  transform: translateY(-50%);
+  width: 220px;
+  padding: 10px 12px;
+  border-radius: 10px;
+  border: 1px solid #dbe5f6;
+  background: rgba(255, 255, 255, 0.98);
+  color: #334567;
+  font-size: 12px;
+  font-weight: 400;
+  line-height: 1.6;
+  white-space: normal;
+  box-shadow: 0 12px 28px rgba(31, 47, 85, 0.14);
+  z-index: 20;
+}
+
+.role-help-popover::before {
+  content: '';
+  position: absolute;
+  right: 100%;
+  top: 50%;
+  width: 10px;
+  height: 10px;
+  background: rgba(255, 255, 255, 0.98);
+  border-top: 1px solid #dbe5f6;
+  border-left: 1px solid #dbe5f6;
+  transform: translate(50%, -50%) rotate(-45deg);
+}
+
+.form-input {
+  padding: 10px 12px;
+  border: 1px solid #ccd6ea;
+  border-radius: 8px;
+  font-size: 14px;
+  color: #223355;
+  outline: none;
+  transition: border-color 0.2s, box-shadow 0.2s;
+}
+
+.form-input:focus,
+.inline-select:focus,
+.inline-input:focus {
+  border-color: #3f6fd8;
+  box-shadow: 0 0 0 3px rgba(63, 111, 216, 0.15);
+}
+
+.readonly-value {
+  min-height: 40px;
+  display: flex;
+  align-items: center;
+  padding: 0 12px;
+  border: 1px solid #e1e7f3;
+  border-radius: 8px;
+  color: #2d426e;
+  background: #f8faff;
+}
+
+.btn {
+  height: 34px;
+  padding: 0 14px;
+  border-radius: 8px;
+  border: 1px solid #d8dfed;
+  background: #fff;
+  color: #243356;
+  font-size: 13px;
+  font-weight: 600;
+  cursor: pointer;
+  transition: all 0.2s;
+}
+
+.btn:hover {
+  transform: translateY(-1px);
+}
+
+.btn-sm {
+  height: 30px;
+  padding: 0 12px;
+}
+
+.btn-default:hover {
+  border-color: #8ca4d9;
+  color: #24489a;
+}
+
+.btn-primary {
+  border: none;
+  background: linear-gradient(135deg, #2f68ff, #4783ff);
+  color: #fff;
+  box-shadow: 0 8px 16px rgba(47, 104, 255, 0.24);
+}
+
+.btn-danger {
+  color: #c73737;
+  border-color: #f0c9c9;
+  background: #fff8f8;
+}
+
+.btn-danger:hover {
+  border-color: #da7a7a;
+  color: #a41919;
+}
+
+.top-toast {
+  position: fixed;
+  top: 20px;
+  left: 50%;
+  transform: translateX(-50%);
+  z-index: 3000;
+  min-width: 260px;
+  max-width: 70vw;
+  padding: 10px 16px;
+  border-radius: 8px;
+  text-align: center;
+  font-size: 14px;
+  font-weight: 600;
+  border: 1px solid #d4dff4;
+  background: #f3f7ff;
+  color: #1f3d7a;
+  box-shadow: 0 6px 16px rgba(15, 23, 42, 0.12);
+  pointer-events: none;
+}
+
+.top-toast.success {
+  color: #1f6b40;
+  border-color: #b8ebcd;
+  background: #eaf9ef;
+}
+
+.top-toast.error {
+  color: #bb2f2f;
+  border-color: #f2cccc;
+  background: #fff3f3;
+}
+
+.toast-fade-enter-active,
+.toast-fade-leave-active {
+  transition: opacity 0.2s ease, transform 0.2s ease;
+}
+
+.toast-fade-enter-from,
+.toast-fade-leave-to {
+  opacity: 0;
+  transform: translate(-50%, -8px);
+}
+
+.user-table {
+  overflow-x: auto;
+  border-radius: 10px;
+  border: 1px solid #e8edf7;
+}
+
+.user-table table {
+  width: 100%;
+  border-collapse: collapse;
+  background: #fff;
+}
+
+.user-table thead {
+  background: #f6f8fd;
+}
+
+.user-table th,
+.user-table td {
+  padding: 12px 10px;
+  border-bottom: 1px solid #eef2f9;
+  text-align: left;
+  font-size: 13px;
+  color: #2d3f69;
+}
+
+.user-table th {
+  color: #50618a;
+  font-weight: 600;
+}
+
+.username {
+  font-weight: 600;
+  color: #1f2f55;
+}
+
+.role-tag {
+  display: inline-flex;
+  align-items: center;
+  height: 26px;
+  padding: 0 10px;
+  border-radius: 999px;
+  background: #eef4ff;
+  color: #244799;
+  font-weight: 600;
+}
+
+.role-tag-admin {
+  background: #f1f3f8;
+  color: #4f5e80;
+}
+
+.role-cell {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  width: 100%;
+}
+
+.role-help {
+  width: 16px;
+  height: 16px;
+  border: 1px solid #b8c4dd;
+  border-radius: 50%;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 11px;
+  font-weight: 700;
+  color: #5a6f98;
+  background: #f7f9fe;
+  cursor: help;
+  line-height: 1;
+  transition: transform 0.2s ease, background 0.2s ease, color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
+}
+
+.role-help-wrap:hover .role-help {
+  transform: translateY(-1px) scale(1.08);
+  background: #eaf2ff;
+  color: #345da8;
+  border-color: #9eb5e7;
+  box-shadow: 0 4px 10px rgba(47, 95, 190, 0.18);
+}
+
+.role-help-wrap {
+  display: inline-flex;
+  align-items: center;
+}
+
+.email-text {
+  color: #3e4f77;
+}
+
+.inline-select,
+.inline-input {
+  width: 100%;
+  min-width: 120px;
+  box-sizing: border-box;
+  padding: 7px 8px;
+  border: 1px solid #cdd7ea;
+  border-radius: 7px;
+  color: #243356;
+  background: #fff;
+  outline: none;
+}
+
+.inline-actions {
+  display: flex;
+  gap: 8px;
+  white-space: nowrap;
+}
+
+@media (max-width: 900px) {
+  .form-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .settings-title {
+    font-size: 24px;
+  }
+
+  .settings-card {
+    padding: 18px;
+  }
+
+  .card-head {
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 10px;
+  }
+}
+</style>

+ 90 - 21
frontend/src/views/settings/SettingsPage.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="page-layout">
+  <div class="page-layout member-theme">
     <Sidebar />
     <div class="page-main">
       <div class="settings-container">
@@ -12,25 +12,34 @@
           <p class="card-hint">
             用于对话构建数据分析任务(该模型需要支持MCP)
           </p>
+          <p class="card-warning">
+            注意:大模型配置为全局共用,修改后会影响所有成员。
+          </p>
           <div class="form-grid">
             <div class="form-item full-width">
               <label>API 地址</label>
-              <input v-model="llm.url" type="text" class="form-input" placeholder="例如:https://api.openai.com/v1" />
+              <input v-model="llm.url" :disabled="!llmEditMode || savingLLM || testingLLM" type="text" class="form-input" placeholder="例如:https://api.openai.com/v1" />
             </div>
             <div class="form-item">
               <label>模型名称</label>
-              <input v-model="llm.model" type="text" class="form-input" placeholder="例如:gpt-4o" />
+              <input v-model="llm.model" :disabled="!llmEditMode || savingLLM || testingLLM" type="text" class="form-input" placeholder="例如:gpt-4o" />
             </div>
             <div class="form-item">
               <label>API Key</label>
-              <input v-model="llm.key" type="password" class="form-input" placeholder="例如:sk-..." />
+              <input v-model="llm.key" :disabled="!llmEditMode || savingLLM || testingLLM" type="password" class="form-input" placeholder="例如:sk-..." />
             </div>
           </div>
           <div class="form-actions">
-            <button class="btn-primary" @click="saveLLM" :disabled="savingLLM">
-              {{ savingLLM ? '保存中…' : '保存' }}
-            </button>
-            <button class="btn-secondary" @click="testLLM" :disabled="testingLLM">
+            <template v-if="!llmEditMode">
+              <button class="btn-primary" @click="startEditLLM">编辑</button>
+            </template>
+            <template v-else>
+              <button class="btn-primary" @click="saveLLM" :disabled="savingLLM">
+                {{ savingLLM ? '保存中…' : '保存' }}
+              </button>
+              <button class="btn-secondary" @click="cancelEditLLM" :disabled="savingLLM || testingLLM">取消</button>
+            </template>
+            <button class="btn-secondary" @click="testLLM" :disabled="testingLLM || savingLLM">
               {{ testingLLM ? '测试中…' : '测试连通性' }}
             </button>
             <span v-if="llmMsg" class="save-msg" :class="llmMsgType">{{ llmMsg }}</span>
@@ -42,28 +51,35 @@
           <div class="form-grid">
             <div class="form-item">
               <label>用户名</label>
-              <input v-model="profile.username" type="text" class="form-input" placeholder="用户名" />
+              <input v-model="profile.username" :disabled="!profileEditMode || savingProfile" type="text" class="form-input" placeholder="用户名" />
             </div>
             <div class="form-item">
               <label>邮箱</label>
-              <input v-model="profile.email" type="email" class="form-input" placeholder="邮箱地址" />
+              <input v-model="profile.email" :disabled="!profileEditMode || savingProfile" type="email" class="form-input" placeholder="邮箱地址" />
             </div>
             <div class="form-item">
               <label>新密码</label>
-              <input v-model="profile.password" type="password" class="form-input" placeholder="留空则不修改" autocomplete="new-password" />
+              <input v-model="profile.password" :disabled="!profileEditMode || savingProfile" type="password" class="form-input" placeholder="留空则不修改" autocomplete="new-password" />
             </div>
             <div class="form-item">
               <label>确认新密码</label>
-              <input v-model="profile.passwordConfirm" type="password" class="form-input" :class="{ 'input-error': passwordMismatch }" placeholder="再次输入新密码" autocomplete="new-password" />
+              <input v-model="profile.passwordConfirm" :disabled="!profileEditMode || savingProfile" type="password" class="form-input" :class="{ 'input-error': passwordMismatch }" placeholder="再次输入新密码" autocomplete="new-password" />
             </div>
           </div>
           <div class="form-actions">
-            <button class="btn-primary" @click="saveProfile" :disabled="savingProfile">
-              {{ savingProfile ? '保存中…' : '保存' }}
-            </button>
+            <template v-if="!profileEditMode">
+              <button class="btn-primary" @click="startEditProfile">编辑</button>
+            </template>
+            <template v-else>
+              <button class="btn-primary" @click="saveProfile" :disabled="savingProfile">
+                {{ savingProfile ? '保存中…' : '保存' }}
+              </button>
+              <button class="btn-secondary" @click="cancelEditProfile" :disabled="savingProfile">取消</button>
+            </template>
             <span v-if="profileMsg" class="save-msg" :class="profileMsgType">{{ profileMsg }}</span>
           </div>
         </div>
+
       </div>
     </div>
   </div>
@@ -79,6 +95,8 @@ const profile = reactive({ username: '', email: '', password: '', passwordConfir
 const savingProfile = ref(false);
 const profileMsg = ref('');
 const profileMsgType = ref('success');
+const profileEditMode = ref(false);
+const profileSnapshot = reactive({ username: '', email: '' });
 const passwordMismatch = computed(() =>
   profile.password && profile.passwordConfirm && profile.password !== profile.passwordConfirm
 );
@@ -88,22 +106,44 @@ const llmMsg = ref('');
 const llmMsgType = ref('success');
 const savingLLM = ref(false);
 const testingLLM = ref(false);
+const llmEditMode = ref(false);
+const llmSnapshot = reactive({ url: '', model: '', key: '' });
 
 onMounted(() => {
   getMe().then(({ data }) => {
     profile.username = data.username || '';
     profile.email = data.email || '';
+    profileSnapshot.username = profile.username;
+    profileSnapshot.email = profile.email;
   }).catch(() => {});
 
   getLLMConfig().then(({ data }) => {
     llm.url = data.url || '';
     llm.model = data.model || '';
     llm.key = data.key || '';
+    llmSnapshot.url = llm.url;
+    llmSnapshot.model = llm.model;
+    llmSnapshot.key = llm.key;
   }).catch(() => {});
 
 });
 
+function startEditProfile() {
+  profileEditMode.value = true;
+  profile.password = '';
+  profile.passwordConfirm = '';
+}
+
+function cancelEditProfile() {
+  profile.username = profileSnapshot.username;
+  profile.email = profileSnapshot.email;
+  profile.password = '';
+  profile.passwordConfirm = '';
+  profileEditMode.value = false;
+}
+
 async function saveProfile() {
+  if (!profileEditMode.value) return;
   if (profile.password && profile.password !== profile.passwordConfirm) {
     profileMsg.value = '两次密码输入不一致';
     profileMsgType.value = 'error';
@@ -116,8 +156,11 @@ async function saveProfile() {
     const payload = { username: profile.username, email: profile.email };
     if (profile.password) payload.password = profile.password;
     await updateMe(payload);
+    profileSnapshot.username = profile.username;
+    profileSnapshot.email = profile.email;
     profile.password = '';
     profile.passwordConfirm = '';
+    profileEditMode.value = false;
     profileMsg.value = '保存成功';
     profileMsgType.value = 'success';
   } catch (e) {
@@ -129,6 +172,17 @@ async function saveProfile() {
   }
 }
 
+function startEditLLM() {
+  llmEditMode.value = true;
+}
+
+function cancelEditLLM() {
+  llm.url = llmSnapshot.url;
+  llm.model = llmSnapshot.model;
+  llm.key = llmSnapshot.key;
+  llmEditMode.value = false;
+}
+
 async function testLLM() {
   testingLLM.value = true;
   llmMsg.value = '';
@@ -146,10 +200,15 @@ async function testLLM() {
 }
 
 async function saveLLM() {
+  if (!llmEditMode.value) return;
   savingLLM.value = true;
   llmMsg.value = '';
   try {
     await saveLLMConfig({ url: llm.url, model: llm.model, key: llm.key });
+    llmSnapshot.url = llm.url;
+    llmSnapshot.model = llm.model;
+    llmSnapshot.key = llm.key;
+    llmEditMode.value = false;
     llmMsg.value = '保存成功';
     llmMsgType.value = 'success';
   } catch (e) {
@@ -181,7 +240,8 @@ async function saveLLM() {
 }
 
 .settings-container {
-  max-width: 720px;
+  width: 100%;
+  max-width: 1200px;
   padding: 24px 0;
 }
 
@@ -257,8 +317,9 @@ async function saveLLM() {
 }
 
 .btn-primary {
+  min-height: 38px;
   padding: 8px 20px;
-  background: #409eff;
+  background: #0080ff;
   color: #fff;
   border: none;
   border-radius: 6px;
@@ -267,8 +328,9 @@ async function saveLLM() {
   transition: background 0.2s;
 }
 
+
 .btn-primary:hover:not(:disabled) {
-  background: #337ecc;
+  background: #0072e6;
 }
 
 .btn-primary:disabled {
@@ -277,10 +339,11 @@ async function saveLLM() {
 }
 
 .btn-secondary {
+  min-height: 38px;
   padding: 8px 20px;
   background: #fff;
-  color: #409eff;
-  border: 1px solid #409eff;
+  color: #0080ff;
+  border: 1px solid #0080ff;
   border-radius: 6px;
   font-size: 14px;
   cursor: pointer;
@@ -288,7 +351,7 @@ async function saveLLM() {
 }
 
 .btn-secondary:hover:not(:disabled) {
-  background: #ecf5ff;
+  background: #eaf4ff;
 }
 
 .btn-secondary:disabled {
@@ -309,6 +372,12 @@ async function saveLLM() {
   margin: -8px 0 16px;
 }
 
+.card-warning {
+  font-size: 13px;
+  color: #e6a23c;
+  margin: -8px 0 16px;
+}
+
 @media (max-width: 768px) {
   .page-main {
     padding: 56px 10px 10px !important;

+ 4 - 4
frontend/src/views/task-build/CodeTaskBuildPage.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="page-root">
+  <div class="page-root member-theme">
         <Sidebar />
 
     <main class="main-content">
@@ -22,7 +22,7 @@
 
         <div class="workflow-config-bar">
           <div class="config-item workflow-title-config">
-            <label>工作流名称</label>
+            <label>工作流名称 <span class="required">*</span></label>
             <input v-model.trim="workflow.name" class="workflow-name-input" placeholder="输入工作流名称..." maxlength="64" />
           </div>
           <div class="config-item workflow-desc-config">
@@ -34,7 +34,7 @@
             <input v-model.number="workflow.timeoutSeconds" type="number" min="1" />
           </div>
           <div class="config-item">
-            <label>失败策略</label>
+            <label>失败策略 <span class="required">*</span></label>
             <select v-model="workflow.failureStrategy">
               <option value="STOP">终止</option>
               <option value="CONTINUE">跳过</option>
@@ -93,7 +93,7 @@
               <div class="sidebar-form">
                 <div class="form-section">
                   <div class="form-item">
-                    <label>任务标识</label>
+                    <label>任务标识 <span class="required">*</span></label>
                     <input :value="selectedTask.id" type="text" disabled class="input-readonly" />
                   </div>
                   <div class="form-item">

+ 1 - 1
frontend/src/views/task-build/DialogTaskBuildPage.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="page-root">
+  <div class="page-root member-theme">
     <Sidebar />
 
     <main class="main-content">

+ 1 - 1
frontend/src/views/task-build/TaskBuildWorkflowListPage.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="page-root">
+  <div class="page-root member-theme">
     <Sidebar />
     <div v-if="topSuccessMessage" class="success-toast">{{ topSuccessMessage }}</div>
 

+ 29 - 9
frontend/src/views/task-exec/TaskInstanceLogPage.vue

@@ -1,11 +1,17 @@
 <template>
-  <div class="page-root">
+  <div class="page-root member-theme">
     <Sidebar />
 
     <main class="main-content">
       <section class="panel">
         <div class="panel-header">
-          <h1>任务日志</h1>
+          <div class="header-title">
+            <h1>任务日志</h1>
+            <div class="meta-row">
+              <span>工作流实例: WFI-{{ workflowInstanceId || '-' }}</span>
+              <span>任务实例: TI-{{ taskInstanceId || '-' }}</span>
+            </div>
+          </div>
           <div class="header-actions">
             <button class="btn btn-default" @click="goBack">返回任务执行</button>
             <button class="btn btn-primary" :disabled="state.loading || state.downloading" @click="handleDownload">
@@ -14,11 +20,6 @@
           </div>
         </div>
 
-        <div class="meta-row">
-          <span>工作流实例: WFI-{{ workflowInstanceId || '-' }}</span>
-          <span>任务实例: TI-{{ taskInstanceId || '-' }}</span>
-        </div>
-
         <div v-if="state.error" class="error-banner">
           <span>{{ state.error }}</span>
           <button class="error-banner-close" @click="state.error = ''">&times;</button>
@@ -195,7 +196,9 @@ watch(
   align-items: center;
   justify-content: space-between;
   gap: 12px;
-  margin-bottom: 12px;
+  margin-bottom: 16px;
+  padding-bottom: 12px;
+  border-bottom: 1px solid #ebeef5;
 }
 
 .panel-header h1 {
@@ -203,6 +206,13 @@ watch(
   font-size: 20px;
 }
 
+.header-title {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  min-width: 0;
+}
+
 .header-actions {
   display: flex;
   gap: 8px;
@@ -213,7 +223,7 @@ watch(
   gap: 18px;
   font-size: 13px;
   color: #606266;
-  margin-bottom: 12px;
+  white-space: nowrap;
 }
 
 .placeholder {
@@ -301,5 +311,15 @@ watch(
     flex-direction: column;
     align-items: flex-start;
   }
+
+  .header-title {
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 8px;
+  }
+
+  .meta-row {
+    white-space: normal;
+  }
 }
 </style>

+ 1 - 1
frontend/src/views/task-exec/TaskInstanceResultPage.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="page-root">
+  <div class="page-root member-theme">
     <Sidebar />
 
     <main class="main-content">

+ 17 - 6
frontend/src/views/task-exec/WorkflowExecutionPage.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="page-root">
+  <div class="page-root member-theme">
     <Sidebar />
     <div v-if="detail.success" class="success-toast">{{ detail.success }}</div>
 
@@ -18,10 +18,10 @@
             <label>状态</label>
             <select v-model="filters.state">
               <option value="">全部</option>
-              <option value="RUNNING">RUNNING</option>
-              <option value="SUCCESS">SUCCESS</option>
-              <option value="FAILED">FAILED</option>
-              <option value="TERMINATED">TERMINATED</option>
+              <option value="RUNNING">运行中</option>
+              <option value="SUCCESS">成功</option>
+              <option value="FAILED">失败</option>
+              <option value="TERMINATED">已终止</option>
             </select>
           </div>
           <div class="filter-actions">
@@ -329,7 +329,18 @@ const visibleWorkflowList = computed(() => {
 });
 
 function workflowStateLabel(state) {
-  return state || '-';
+  const mapping = {
+    RUNNING: '运行中',
+    SUCCESS: '成功',
+    FAILED: '失败',
+    TERMINATED: '已终止',
+    PENDING: '等待中',
+    SUBMITTED: '已提交',
+    TIMEOUT: '已超时',
+    SKIPPED: '已跳过',
+    KILLED: '已终止'
+  };
+  return mapping[state] || state || '-';
 }
 
 function workflowStateClass(state) {