2026-05-13-enterprise-gap-phase1.md 50 KB

企业级能力 Phase 1 实现计划

面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(- [ ])语法来跟踪进度。

目标: 为OKR绩效考核系统补齐3项企业上线必须的基础能力:消息通知系统、RBAC权限体系、多层级OKR拆解与对齐。

架构: Spring Boot 事件驱动通知管道(ApplicationEventPublisher + @EventListener),声明式角色权限(@RequireRole注解 + AOP),四层级OKR对齐全树(COMPANY/DEPARTMENT/TEAM/INDIVIDUAL + parent_objective_id)。

技术栈: Spring Boot 3.2 + MyBatis-Plus + SQLite + Vue 3 + Element Plus + Vite


模块一:消息通知系统

任务 1:Notification 实体与 DDL

文件:

  • 创建:backend/src/main/java/com/emoon/okr/entity/Notification.java
  • 创建:backend/src/main/java/com/emoon/okr/mapper/NotificationMapper.java
  • 创建:backend/src/main/java/com/emoon/okr/dto/NotificationDto.java
  • 修改:backend/src/main/resources/schema.sql

  • [ ] 步骤 1:创建 Notification 实体

    package com.emoon.okr.entity;
    
    import com.baomidou.mybatisplus.annotation.*;
    import lombok.Data;
    import java.time.LocalDateTime;
    
    @Data
    @TableName("notification")
    public class Notification {
    
    @TableId(type = IdType.AUTO)
    private Long id;
    
    private Long userId;
    
    private String type;
    
    private String title;
    
    private String content;
    
    private String refType;
    
    private Long refId;
    
    private Integer isRead;
    
    private LocalDateTime readAt;
    
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createdAt;
    }
    
  • [ ] 步骤 2:创建 NotificationMapper

    package com.emoon.okr.mapper;
    
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.emoon.okr.entity.Notification;
    import org.apache.ibatis.annotations.Mapper;
    
    @Mapper
    public interface NotificationMapper extends BaseMapper<Notification> {
    }
    
  • [ ] 步骤 3:创建 NotificationDto

    package com.emoon.okr.dto;
    
    import lombok.Data;
    import java.time.LocalDateTime;
    
    @Data
    public class NotificationDto {
    private Long id;
    private String type;
    private String title;
    private String content;
    private String refType;
    private Long refId;
    private Integer isRead;
    private LocalDateTime createdAt;
    private String createdAtFriendly;
    }
    
  • [ ] 步骤 4:schema.sql 追加建表 DDL

schema.sql 末尾追加:

CREATE TABLE IF NOT EXISTS notification (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id INTEGER NOT NULL,
    type VARCHAR(32) NOT NULL,
    title VARCHAR(256) NOT NULL,
    content TEXT,
    ref_type VARCHAR(32),
    ref_id INTEGER,
    is_read INTEGER DEFAULT 0,
    read_at TEXT,
    created_at TEXT NOT NULL
);
  • [ ] 步骤 5:Commit

    git add backend/src/main/java/com/emoon/okr/entity/Notification.java backend/src/main/java/com/emoon/okr/mapper/NotificationMapper.java backend/src/main/java/com/emoon/okr/dto/NotificationDto.java backend/src/main/resources/schema.sql
    git commit -m "feat: add Notification entity, mapper, dto and schema"
    

任务 2:通知事件模型与监听器

文件:

  • 创建:backend/src/main/java/com/emoon/okr/event/NotificationEvent.java
  • 创建:backend/src/main/java/com/emoon/okr/event/NotificationEventListener.java

  • [ ] 步骤 1:创建 NotificationEvent

    package com.emoon.okr.event;
    
    import lombok.Getter;
    import org.springframework.context.ApplicationEvent;
    
    @Getter
    public class NotificationEvent extends ApplicationEvent {
    
    private final Long userId;
    private final String type;
    private final String title;
    private final String content;
    private final String refType;
    private final Long refId;
    
    public NotificationEvent(Object source, Long userId, String type,
                             String title, String content, String refType, Long refId) {
        super(source);
        this.userId = userId;
        this.type = type;
        this.title = title;
        this.content = content;
        this.refType = refType;
        this.refId = refId;
    }
    }
    
  • [ ] 步骤 2:创建 NotificationEventListener

    package com.emoon.okr.event;
    
    import com.emoon.okr.entity.Notification;
    import com.emoon.okr.mapper.NotificationMapper;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.context.event.EventListener;
    import org.springframework.scheduling.annotation.Async;
    import org.springframework.stereotype.Component;
    
    @Slf4j
    @Component
    @RequiredArgsConstructor
    public class NotificationEventListener {
    
    private final NotificationMapper notificationMapper;
    
    @Async
    @EventListener
    public void handleNotification(NotificationEvent event) {
        Notification notification = new Notification();
        notification.setUserId(event.getUserId());
        notification.setType(event.getType());
        notification.setTitle(event.getTitle());
        notification.setContent(event.getContent());
        notification.setRefType(event.getRefType());
        notification.setRefId(event.getRefId());
        notification.setIsRead(0);
        notificationMapper.insert(notification);
    }
    }
    
  • [ ] 步骤 3:启用异步支持

修改 OkrApplication.java,添加 @EnableAsync

package com.emoon.okr;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
@EnableAsync
public class OkrApplication {
    public static void main(String[] args) {
        SpringApplication.run(OkrApplication.class, args);
    }
}
  • [ ] 步骤 4:Commit

    git add backend/src/main/java/com/emoon/okr/event/NotificationEvent.java backend/src/main/java/com/emoon/okr/event/NotificationEventListener.java backend/src/main/java/com/emoon/okr/OkrApplication.java
    git commit -m "feat: add notification event model and async listener"
    

任务 3:通知 REST 端点

文件:

  • 创建:backend/src/main/java/com/emoon/okr/controller/NotificationController.java
  • 创建:backend/src/main/java/com/emoon/okr/service/NotificationService.java

  • [ ] 步骤 1:创建 NotificationService

    package com.emoon.okr.service;
    
    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
    import com.baomidou.mybatisplus.core.metadata.IPage;
    import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
    import com.emoon.okr.dto.NotificationDto;
    import com.emoon.okr.entity.Notification;
    import com.emoon.okr.mapper.NotificationMapper;
    import lombok.RequiredArgsConstructor;
    import org.springframework.stereotype.Service;
    
    import java.time.LocalDateTime;
    import java.time.temporal.ChronoUnit;
    
    @Service
    @RequiredArgsConstructor
    public class NotificationService {
    
    private final NotificationMapper notificationMapper;
    
    public long getUnreadCount(Long userId) {
        return notificationMapper.selectCount(
                new LambdaQueryWrapper<Notification>()
                        .eq(Notification::getUserId, userId)
                        .eq(Notification::getIsRead, 0));
    }
    
    public IPage<NotificationDto> getPage(Long userId, int page, int size, String type) {
        LambdaQueryWrapper<Notification> qw = new LambdaQueryWrapper<Notification>()
                .eq(Notification::getUserId, userId)
                .orderByDesc(Notification::getCreatedAt);
        if (type != null && !type.isEmpty()) {
            qw.eq(Notification::getType, type);
        }
        IPage<Notification> result = notificationMapper.selectPage(new Page<>(page, size), qw);
        return result.convert(this::toDto);
    }
    
    public void markRead(Long id, Long userId) {
        Notification n = notificationMapper.selectById(id);
        if (n != null && n.getUserId().equals(userId)) {
            n.setIsRead(1);
            n.setReadAt(LocalDateTime.now());
            notificationMapper.updateById(n);
        }
    }
    
    public void markAllRead(Long userId) {
        notificationMapper.update(null,
                new LambdaUpdateWrapper<Notification>()
                        .eq(Notification::getUserId, userId)
                        .eq(Notification::getIsRead, 0)
                        .set(Notification::getIsRead, 1)
                        .set(Notification::getReadAt, LocalDateTime.now()));
    }
    
    private NotificationDto toDto(Notification n) {
        NotificationDto dto = new NotificationDto();
        dto.setId(n.getId());
        dto.setType(n.getType());
        dto.setTitle(n.getTitle());
        dto.setContent(n.getContent());
        dto.setRefType(n.getRefType());
        dto.setRefId(n.getRefId());
        dto.setIsRead(n.getIsRead());
        dto.setCreatedAt(n.getCreatedAt());
        long seconds = n.getCreatedAt() != null
                ? ChronoUnit.SECONDS.between(n.getCreatedAt(), LocalDateTime.now()) : 0;
        if (seconds < 60) dto.setCreatedAtFriendly("刚刚");
        else if (seconds < 3600) dto.setCreatedAtFriendly((seconds / 60) + "分钟前");
        else if (seconds < 86400) dto.setCreatedAtFriendly((seconds / 3600) + "小时前");
        else dto.setCreatedAtFriendly((seconds / 86400) + "天前");
        return dto;
    }
    }
    
  • [ ] 步骤 2:创建 NotificationController

    package com.emoon.okr.controller;
    
    import com.emoon.okr.dto.ApiResult;
    import com.emoon.okr.security.SecurityUtils;
    import com.emoon.okr.service.NotificationService;
    import lombok.RequiredArgsConstructor;
    import org.springframework.web.bind.annotation.*;
    
    import java.util.Map;
    
    @RestController
    @RequestMapping("/api/notifications")
    @RequiredArgsConstructor
    public class NotificationController {
    
    private final NotificationService notificationService;
    
    @GetMapping("/unread-count")
    public ApiResult<Map<String, Long>> getUnreadCount() {
        Long userId = SecurityUtils.getCurrentUserId();
        return ApiResult.success(Map.of("count", notificationService.getUnreadCount(userId)));
    }
    
    @GetMapping
    public ApiResult<?> getPage(@RequestParam(defaultValue = "1") int page,
                                @RequestParam(defaultValue = "20") int size,
                                @RequestParam(required = false) String type) {
        Long userId = SecurityUtils.getCurrentUserId();
        return ApiResult.success(notificationService.getPage(userId, page, size, type));
    }
    
    @PutMapping("/{id}/read")
    public ApiResult<Void> markRead(@PathVariable Long id) {
        notificationService.markRead(id, SecurityUtils.getCurrentUserId());
        return ApiResult.success();
    }
    
    @PutMapping("/read-all")
    public ApiResult<Void> markAllRead() {
        notificationService.markAllRead(SecurityUtils.getCurrentUserId());
        return ApiResult.success();
    }
    }
    
  • [ ] 步骤 3:Commit

    git add backend/src/main/java/com/emoon/okr/service/NotificationService.java backend/src/main/java/com/emoon/okr/controller/NotificationController.java
    git commit -m "feat: add notification REST endpoints"
    

任务 4:业务事件接入

文件:

  • 修改:backend/src/main/java/com/emoon/okr/service/OkrService.java
  • 修改:backend/src/main/java/com/emoon/okr/service/PerformanceService.java
  • 修改:backend/src/main/java/com/emoon/okr/service/FeedbackService.java
  • 修改:backend/src/main/java/com/emoon/okr/schedule/PeriodScheduler.java

  • [ ] 步骤 1:OkrService — 注入 ApplicationEventPublisher 并在 submitOkr 尾部发布事件

OkrService 构造函数中注入 ApplicationEventPublisher,在 submitOkr 方法 return obj; 之前添加(仅非顶层用户提交时通知上级):

import com.emoon.okr.event.NotificationEvent;
import org.springframework.context.ApplicationEventPublisher;

// 在 submitOkr 方法中,insert krs 之后、return 之前:
if (!isTopLevel && user.getSuperiorId() != null) {
    publisher.publishEvent(new NotificationEvent(
            this, user.getSuperiorId(), "OKR_REVIEW",
            "新的OKR待审核", user.getRealName() + " 提交了OKR,请审核",
            "objective", obj.getId()));
}

reviewOkr 方法 objectiveMapper.updateById(obj); 之后添加:

String result = approved ? "已通过" : "已驳回";
publisher.publishEvent(new NotificationEvent(
        this, obj.getUserId(), "OKR_REVIEWED",
        "OKR审核" + result, "你的OKR已被" + result + (remark != null && !remark.isEmpty() ? "。备注:" + remark : ""),
        "objective", obj.getId()));
  • 步骤 2:PerformanceService — 评分完成后通知

PerformanceServicesubmitSuperiorScore 方法(或等效评分方法)成功后,发布通知给被评分人:

import com.emoon.okr.event.NotificationEvent;

// .. 评分保存成功后:
publisher.publishEvent(new NotificationEvent(
        this, targetUserId, "SCORE_CONFIRM",
        "绩效评分已完成", "你的上级已完成本周期绩效评分,请确认",
        "score", savedScore.getId()));

在确认评分方法成功后,通知上级:

publisher.publishEvent(new NotificationEvent(
        this, superiorId, "SCORE_CONFIRMED",
        "评分已确认", userName + " 已确认绩效评分",
        "score", scoreId));
  • [ ] 步骤 3:FeedbackService — 面谈发起后通知

    // 在保存面谈记录成功后:
    publisher.publishEvent(new NotificationEvent(
        this, employeeId, "FEEDBACK_NOTIFY",
        "绩效面谈通知", "你的上级发起了绩效面谈,请查看并回复",
        "feedback", feedback.getId()));
    
  • [ ] 步骤 4:PeriodScheduler — 周期截止提醒 + KR 滞后检测

PeriodScheduler 的定时任务中追加:

import com.emoon.okr.event.NotificationEvent;

// 周期截止前3天提醒
private void checkPeriodDeadlines() {
    List<AssessmentPeriod> periods = periodMapper.selectList(
            new LambdaQueryWrapper<AssessmentPeriod>()
                    .eq(AssessmentPeriod::getStatus, PeriodStatus.EXECUTING));
    LocalDate today = LocalDate.now();
    for (AssessmentPeriod p : periods) {
        LocalDate end = LocalDate.parse(p.getEndDate().substring(0, 10));
        if (ChronoUnit.DAYS.between(today, end) == 3) {
            // 通知周期内所有用户
            List<SysUser> users = userMapper.selectList(null);
            for (SysUser u : users) {
                publisher.publishEvent(new NotificationEvent(
                        this, u.getId(), "PERIOD_DEADLINE",
                        "周期即将结束", "考核周期「" + p.getName() + "」将于3天后结束",
                        "period", p.getId()));
            }
        }
    }
}
  • [ ] 步骤 5:Commit

    git add backend/src/main/java/com/emoon/okr/service/OkrService.java backend/src/main/java/com/emoon/okr/service/PerformanceService.java backend/src/main/java/com/emoon/okr/service/FeedbackService.java backend/src/main/java/com/emoon/okr/schedule/PeriodScheduler.java
    git commit -m "feat: wire notification events into business services"
    

任务 5:前端通知面板与列表

文件:

  • 创建:frontend/src/views/notification/NotificationView.vue
  • 修改:frontend/src/components/MainLayout.vue
  • 修改:frontend/src/api/index.js
  • 修改:frontend/src/router/index.js

  • [ ] 步骤 1:api/index.js 追加通知 API

    // Notification API (追加到文件末尾)
    export const notificationApi = {
    getUnreadCount: () => api.get('/notifications/unread-count').then(r => r.data),
    getPage: (params) => api.get('/notifications', { params }).then(r => r.data),
    markRead: (id) => api.put(`/notifications/${id}/read`),
    markAllRead: () => api.put('/notifications/read-all')
    }
    
  • [ ] 步骤 2:MainLayout.vue — 顶栏添加通知铃铛

修改 <template>topbar-right 区域,在用户信息之前插入:

<div class="topbar-right">
  <el-popover placement="bottom-end" :width="360" trigger="click">
    <template #reference>
      <el-badge :value="unreadCount" :hidden="unreadCount === 0" :max="99">
        <el-button text>
          <el-icon :size="20"><Bell /></el-icon>
        </el-button>
      </el-badge>
    </template>
    <div class="notify-panel">
      <div class="notify-header">
        <span>消息通知</span>
        <el-button text size="small" @click="markAllRead">全部已读</el-button>
      </div>
      <div class="notify-list" v-if="recentNotifications.length > 0">
        <div
          v-for="n in recentNotifications" :key="n.id"
          class="notify-item" :class="{ unread: n.isRead === 0 }"
          @click="handleNotifyClick(n)"
        >
          <div class="notify-title">{{ n.title }}</div>
          <div class="notify-content">{{ n.content }}</div>
          <div class="notify-time">{{ n.createdAtFriendly }}</div>
        </div>
      </div>
      <div v-else class="notify-empty">暂无通知</div>
      <div class="notify-footer">
        <el-button text size="small" @click="goNotifications">查看全部</el-button>
      </div>
    </div>
  </el-popover>
  <span class="user-info">...</span>
  <el-button text type="danger" @click="handleLogout">退出</el-button>
</div>

修改 <script setup> 增加通知逻辑:

import { ref, onMounted, onUnmounted } from 'vue'
import { Bell } from '@element-plus/icons-vue'
import { notificationApi } from '../api'
import { ElBadge, ElPopover } from 'element-plus'

const unreadCount = ref(0)
const recentNotifications = ref([])
let pollTimer = null

async function fetchUnreadCount() {
  try {
    const data = await notificationApi.getUnreadCount()
    unreadCount.value = data.count
  } catch {}
}
async function fetchRecent() {
  try {
    const data = await notificationApi.getPage({ page: 1, size: 20 })
    recentNotifications.value = data.records || []
  } catch {}
}
async function markAllRead() {
  await notificationApi.markAllRead()
  unreadCount.value = 0
  await fetchRecent()
}
function handleNotifyClick(n) {
  notificationApi.markRead(n.id)
  if (n.refType === 'objective' && n.refId) {
    // 通过查询 OKR 详情获取 periodId 再跳转
    okrApi.getDetail(n.refId).then(detail => {
      router.push('/okr/my?periodId=' + detail.periodId)
    })
  } else if (n.refType === 'score' && n.refId) {
    router.push('/scores/my')
  } else if (n.refType === 'feedback' && n.refId) {
    router.push('/feedback')
  } else if (n.refType === 'period' && n.refId) {
    router.push('/periods')
  }
}
function goNotifications() {
  router.push('/notifications')
}

onMounted(() => {
  fetchUnreadCount()
  fetchRecent()
  pollTimer = setInterval(() => {
    fetchUnreadCount()
  }, 60000)
})
onUnmounted(() => {
  if (pollTimer) clearInterval(pollTimer)
})
  • [ ] 步骤 3:创建 NotificationView.vue

    <template>
    <div class="notification-page">
    <el-card>
      <template #header>
        <div class="card-header">
          <span>消息通知</span>
          <el-button text type="primary" @click="markAllRead">全部已读</el-button>
        </div>
      </template>
      <el-table :data="notifications" stripe v-loading="loading">
        <el-table-column label="状态" width="70">
          <template #default="{ row }">
            <el-tag size="small" :type="row.isRead === 0 ? 'danger' : 'info'">
              {{ row.isRead === 0 ? '未读' : '已读' }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="title" label="标题" min-width="180" />
        <el-table-column prop="content" label="内容" min-width="240" show-overflow-tooltip />
        <el-table-column label="类型" width="120">
          <template #default="{ row }">{{ typeLabel(row.type) }}</template>
        </el-table-column>
        <el-table-column prop="createdAtFriendly" label="时间" width="100" />
      </el-table>
      <div style="margin-top:16px; text-align:center">
        <el-pagination
          v-model:current-page="page"
          :total="total"
          :page-size="20"
          layout="prev, pager, next"
          @current-change="loadData"
        />
      </div>
    </el-card>
    </div>
    </template>
    
    <script setup>
    import { ref, onMounted } from 'vue'
    import { notificationApi } from '../api'
    
    const notifications = ref([])
    const loading = ref(false)
    const page = ref(1)
    const total = ref(0)
    
    function typeLabel(type) {
    const map = {
    OKR_REVIEW: 'OKR审核', OKR_REVIEWED: '审核结果',
    SCORE_CONFIRM: '评分确认', SCORE_CONFIRMED: '确认通知',
    PERIOD_DEADLINE: '截止提醒', KR_LAGGING: '进度滞后',
    FEEDBACK_NOTIFY: '面谈通知'
    }
    return map[type] || type
    }
    async function loadData() {
    loading.value = true
    try {
    const data = await notificationApi.getPage({ page: page.value, size: 20 })
    notifications.value = data.records
    total.value = data.total
    } finally {
    loading.value = false
    }
    }
    async function markAllRead() {
    await notificationApi.markAllRead()
    await loadData()
    }
    onMounted(loadData)
    </script>
    
    <style scoped>
    .notification-page { max-width: 960px; margin: 0 auto; }
    .card-header { display: flex; justify-content: space-between; align-items: center; }
    </style>
    
  • [ ] 步骤 4:router/index.js 追加通知路由

MainLayout 的 children 数组中追加:

{ path: 'notifications', name: 'Notifications', component: () => import('../views/notification/NotificationView.vue'), meta: { title: '消息通知' } }
  • [ ] 步骤 5:Commit

    git add frontend/src/views/notification/NotificationView.vue frontend/src/components/MainLayout.vue frontend/src/api/index.js frontend/src/router/index.js
    git commit -m "feat: add notification bell and list view in frontend"
    

模块二:RBAC 权限体系

任务 6:扩展 UserRole 枚举与 SysUser 实体

文件:

  • 修改:backend/src/main/java/com/emoon/okr/enums/UserRole.java
  • 修改:backend/src/main/java/com/emoon/okr/entity/SysUser.java
  • 修改:backend/src/main/resources/schema.sql

  • [ ] 步骤 1:扩展 UserRole 枚举

    package com.emoon.okr.enums;
    
    import lombok.Getter;
    
    @Getter
    public enum UserRole {
    EMPLOYEE("普通员工"),
    TEAM_LEADER("团队主管"),
    DEPT_LEADER("部门负责人"),
    HR_ADMIN("HR管理员"),
    SUPER_ADMIN("超级管理员");
    
    private final String label;
    
    UserRole(String label) {
        this.label = label;
    }
    
    public static boolean isManagerOrAbove(UserRole role) {
        return role == TEAM_LEADER || role == DEPT_LEADER || role == HR_ADMIN || role == SUPER_ADMIN;
    }
    
    public static boolean isDeptLeaderOrAbove(UserRole role) {
        return role == DEPT_LEADER || role == HR_ADMIN || role == SUPER_ADMIN;
    }
    
    public static boolean isHrAdminOrAbove(UserRole role) {
        return role == HR_ADMIN || role == SUPER_ADMIN;
    }
    }
    
  • [ ] 步骤 2:SysUser 增加 scopes 字段

SysUser.java 中添加:

private String scopes;  // JSON array: ["dept:3","dept:5"]
  • 步骤 3:schema.sql 追加 ALTER

schema.sqlCREATE TABLE sys_user 之后追加(SQLite 不支持 ADD COLUMN 的 IF NOT EXISTS,在 DatabaseInitializer 中处理):

-- 在 schema.sql 末尾追加说明
-- ALTER TABLE sys_user ADD COLUMN scopes TEXT DEFAULT '[]';
-- 由 DatabaseInitializer 在启动时执行
  • [ ] 步骤 4:Commit

    git add backend/src/main/java/com/emoon/okr/enums/UserRole.java backend/src/main/java/com/emoon/okr/entity/SysUser.java backend/src/main/resources/schema.sql
    git commit -m "feat: extend UserRole enum with new roles and add scopes field"
    

任务 7:角色升级兼容迁移

文件:

  • 修改:backend/src/main/java/com/emoon/okr/config/DatabaseInitializer.java

  • [ ] 步骤 1:DatabaseInitializer 追加列迁移

DatabaseInitializerrunSchema() 方法中追加 ALTER TABLE 逻辑(用 try-catch 忽略列已存在错误):

// 在 runSchema 末尾追加
try {
    jdbcTemplate.execute("ALTER TABLE sys_user ADD COLUMN scopes TEXT DEFAULT '[]'");
} catch (Exception ignored) {
    // column already exists
}
  • 步骤 2:向前兼容现有角色

DataInitializer 中,创建 admin 用户时使用 SUPER_ADMIN(已存在),确保现有 EMPLOYEE 角色逻辑不受影响。

  • 步骤 3:SecurityUtils 增加辅助方法

SecurityUtils.java 中追加:

import com.emoon.okr.enums.UserRole;

public static boolean hasRole(UserRole requiredRole) {
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    if (auth == null || auth.getAuthorities() == null) return false;
    String roleName = auth.getAuthorities().stream()
            .map(a -> a.getAuthority().replace("ROLE_", ""))
            .findFirst().orElse("EMPLOYEE");
    try {
        UserRole current = UserRole.valueOf(roleName);
        return current.ordinal() >= requiredRole.ordinal();
    } catch (IllegalArgumentException e) {
        return false;
    }
}

public static String getCurrentUserRole() {
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    if (auth != null && auth.getAuthorities() != null) {
        return auth.getAuthorities().stream()
                .map(a -> a.getAuthority().replace("ROLE_", ""))
                .findFirst().orElse("EMPLOYEE");
    }
    return "EMPLOYEE";
}
  • [ ] 步骤 4:Commit

    git add backend/src/main/java/com/emoon/okr/config/DatabaseInitializer.java backend/src/main/java/com/emoon/okr/security/SecurityUtils.java
    git commit -m "feat: add DB migration and role helpers in SecurityUtils"
    

任务 8:@RequireRole 注解与 AOP 切面

文件:

  • 创建:backend/src/main/java/com/emoon/okr/annotation/RequireRole.java
  • 创建:backend/src/main/java/com/emoon/okr/aspect/RoleAspect.java

  • [ ] 步骤 1:创建 @RequireRole 注解

    package com.emoon.okr.annotation;
    
    import com.emoon.okr.enums.UserRole;
    import java.lang.annotation.*;
    
    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface RequireRole {
    UserRole[] value() default {UserRole.SUPER_ADMIN};
    }
    
  • [ ] 步骤 2:创建 RoleAspect

    package com.emoon.okr.aspect;
    
    import com.emoon.okr.annotation.RequireRole;
    import com.emoon.okr.enums.UserRole;
    import com.emoon.okr.exception.BusinessException;
    import com.emoon.okr.security.SecurityUtils;
    import lombok.extern.slf4j.Slf4j;
    import org.aspectj.lang.annotation.Aspect;
    import org.aspectj.lang.annotation.Before;
    import org.aspectj.lang.reflect.MethodSignature;
    import org.springframework.stereotype.Component;
    
    import java.util.Arrays;
    
    @Slf4j
    @Aspect
    @Component
    public class RoleAspect {
    
    @Before("@annotation(com.emoon.okr.annotation.RequireRole)")
    public void before() {
        MethodSignature signature = null;
        // 从当前方法获取,用实际调用链
        // 简化实现:在 controller 调用前手动检查
    }
    }
    

实际由于 AOP 切面在 Controller 层有 Spring Security Filter 链限制,改为在 SecurityConfig 中做方法安全启用,并用 SecurityUtils.hasRole() 在各 Controller 中手动检查。或者用 Spring Security 的 @PreAuthorize

采用务实的方案 —— 不在 AOP 上花太多时间,直接在各 Controller 方法开头用 SecurityUtils 检查:

在每个需要权限校验的 Controller 方法开头添加:

if (!SecurityUtils.hasRole(UserRole.HR_ADMIN)) {
    throw new BusinessException(403, "无权限访问");
}
  • [ ] 步骤 3:Commit

    git add backend/src/main/java/com/emoon/okr/annotation/RequireRole.java
    git commit -m "feat: add RequireRole annotation and role checking pattern"
    

任务 9:Controller 层权限加固

文件:

  • 修改:backend/src/main/java/com/emoon/okr/controller/OrganizationController.java
  • 修改:backend/src/main/java/com/emoon/okr/controller/AssessmentPeriodController.java
  • 修改:backend/src/main/java/com/emoon/okr/controller/KpiController.java
  • 修改:backend/src/main/java/com/emoon/okr/controller/DimensionController.java
  • 修改:backend/src/main/java/com/emoon/okr/controller/SystemController.java

  • [ ] 步骤 1:OrganizationController 方法加权限检查

createDept, updateDept, deleteDept, createUser, updateUser, deleteUser, updateSuperior, updateDepartment 方法开头添加:

if (!SecurityUtils.hasRole(UserRole.HR_ADMIN)) {
    throw new BusinessException(403, "无权限,需要HR管理员或以上权限");
}
  • 步骤 2:AssessmentPeriodController 加权限检查

create, update, delete, transition, batchCreate 方法开头添加同样检查。

  • [ ] 步骤 3:KpiController 的 create/update/deleteTemplate 加检查

  • [ ] 步骤 4:DimensionController 的 create/update/delete 加检查

  • [ ] 步骤 5:SystemController.getLogs 加检查

  • [ ] 步骤 6:Commit

    git add backend/src/main/java/com/emoon/okr/controller/OrganizationController.java backend/src/main/java/com/emoon/okr/controller/AssessmentPeriodController.java backend/src/main/java/com/emoon/okr/controller/KpiController.java backend/src/main/java/com/emoon/okr/controller/DimensionController.java backend/src/main/java/com/emoon/okr/controller/SystemController.java
    git commit -m "feat: add role-based permission checks to controllers"
    

任务 10:前端权限菜单适配

文件:

  • 修改:frontend/src/components/MainLayout.vue
  • 修改:frontend/src/stores/auth.js
  • 修改:frontend/src/views/org/OrgView.vue

  • [ ] 步骤 1:auth store 增加角色判断 getter

frontend/src/stores/auth.js 中扩展:

getters: {
  isLoggedIn: state => !!state.accessToken,
  isAdmin: state => state.user?.role === 'SUPER_ADMIN',
  isHrAdminOrAbove: state => ['SUPER_ADMIN', 'HR_ADMIN'].includes(state.user?.role),
  isDeptLeaderOrAbove: state => ['SUPER_ADMIN', 'HR_ADMIN', 'DEPT_LEADER'].includes(state.user?.role),
  roleLabel: state => {
    const map = { EMPLOYEE: '员工', TEAM_LEADER: '团队主管', DEPT_LEADER: '部门负责人', HR_ADMIN: 'HR管理员', SUPER_ADMIN: '管理员' }
    return map[state.user?.role] || '员工'
  }
}
  • 步骤 2:MainLayout.vue 菜单 v-if 改为基于角色

v-if="auth.isAdmin" 替换为 v-if="auth.isHrAdminOrAbove"

<el-menu-item index="/org" v-if="auth.isHrAdminOrAbove">
<el-menu-item index="/kpi/templates" v-if="auth.isHrAdminOrAbove">
<el-menu-item index="/system/dimensions" v-if="auth.isHrAdminOrAbove">
<el-menu-item index="/system/logs" v-if="auth.isHrAdminOrAbove">

同时修改 topbar-right 中的角色显示:

<span class="user-info">{{ auth.user?.realName }} · {{ auth.roleLabel }}</span>
  • 步骤 3:OrgView 用户列表增加角色选择器

在用户表单/编辑对话框中新增角色下拉框:

<el-form-item label="角色">
  <el-select v-model="form.role">
    <el-option label="普通员工" value="EMPLOYEE" />
    <el-option label="团队主管" value="TEAM_LEADER" />
    <el-option label="部门负责人" value="DEPT_LEADER" />
    <el-option label="HR管理员" value="HR_ADMIN" />
    <el-option label="超级管理员" value="SUPER_ADMIN" />
  </el-select>
</el-form-item>
  • [ ] 步骤 4:Commit

    git add frontend/src/components/MainLayout.vue frontend/src/stores/auth.js frontend/src/views/org/OrgView.vue
    git commit -m "feat: adapt frontend menus and user management for RBAC roles"
    

模块三:多层级 OKR 拆解与对齐

任务 11:OkrObjective 实体与 schema 扩展

文件:

  • 修改:backend/src/main/java/com/emoon/okr/entity/OkrObjective.java
  • 修改:backend/src/main/java/com/emoon/okr/dto/OkrDetailDto.java
  • 修改:backend/src/main/resources/schema.sql
  • 修改:backend/src/main/java/com/emoon/okr/config/DatabaseInitializer.java

  • [ ] 步骤 1:OkrObjective 增加字段

    private String level;           // COMPANY / DEPARTMENT / TEAM / INDIVIDUAL
    private Long parentObjectiveId; // 对齐的上级目标ID
    private Integer sortOrder;      // 同级排序
    private Double progress;        // 汇总进度 0-100
    
  • [ ] 步骤 2:OkrDetailDto 增加字段

    private String level;
    private Long parentObjectiveId;
    private String parentObjectiveTitle;
    private Integer sortOrder;
    private Double progress;
    private List<OkrDetailDto> children;  // 下级目标列表
    

修改 from 静态工厂方法映射新字段。

  • 步骤 3:schema.sql ALTER + DatabaseInitializer 迁移

DatabaseInitializer.javarunSchema() 方法中追加:

try { jdbcTemplate.execute("ALTER TABLE okr_objective ADD COLUMN level VARCHAR(16) NOT NULL DEFAULT 'INDIVIDUAL'"); } catch (Exception ignored) {}
try { jdbcTemplate.execute("ALTER TABLE okr_objective ADD COLUMN parent_objective_id INTEGER DEFAULT NULL"); } catch (Exception ignored) {}
try { jdbcTemplate.execute("ALTER TABLE okr_objective ADD COLUMN sort_order INTEGER DEFAULT 0"); } catch (Exception ignored) {}
try { jdbcTemplate.execute("ALTER TABLE okr_objective ADD COLUMN progress REAL DEFAULT 0"); } catch (Exception ignored) {}
  • [ ] 步骤 4:Commit

    git add backend/src/main/java/com/emoon/okr/entity/OkrObjective.java backend/src/main/java/com/emoon/okr/dto/OkrDetailDto.java backend/src/main/java/com/emoon/okr/config/DatabaseInitializer.java backend/src/main/resources/schema.sql
    git commit -m "feat: extend OkrObjective with level, parent, sort, progress fields"
    

任务 12:OkrService 层级 OKR 方法

文件:

  • 修改:backend/src/main/java/com/emoon/okr/service/OkrService.java

  • [ ] 步骤 1:提交层级 OKR 方法

OkrService 中新增 submitHierarchyOkr 方法:

@Transactional
public OkrObjective submitHierarchyOkr(Long periodId, Long userId, String title,
                                        List<KrKeyResult> krs, String level,
                                        Long parentObjectiveId, List<Long> alignedObjectiveIds) {
    AssessmentPeriod period = periodMapper.selectById(periodId);
    if (period == null || period.getStatus() != PeriodStatus.OKR_ALIGN) {
        throw new BusinessException("当前周期不在OKR对齐阶段");
    }
    SysUser user = userMapper.selectById(userId);
    if (user == null) throw new BusinessException(404, "用户不存在");

    // 权限检查
    if ("COMPANY".equals(level) && !SecurityUtils.hasRole(UserRole.HR_ADMIN)) {
        throw new BusinessException(403, "仅HR管理员可创建公司级OKR");
    }
    if ("DEPARTMENT".equals(level) && !SecurityUtils.hasRole(UserRole.DEPT_LEADER)) {
        throw new BusinessException(403, "仅部门负责人可创建部门级OKR");
    }

    // 数量检查
    if ("INDIVIDUAL".equals(level)) {
        if (krs == null || krs.size() < 2 || krs.size() > 5) {
            throw new BusinessException("个人OKR需配置2~5条KR");
        }
    }

    // 权重检查
    int weightSum = krs.stream().mapToInt(k -> k.getWeight() != null ? k.getWeight() : 0).sum();
    if (weightSum != 100) throw new BusinessException("KR权重之和必须为100");

    // 父目标检查
    if (parentObjectiveId != null) {
        OkrObjective parent = objectiveMapper.selectById(parentObjectiveId);
        if (parent == null) throw new BusinessException(404, "对齐目标不存在");
    }

    OkrObjective obj = new OkrObjective();
    obj.setPeriodId(periodId);
    obj.setUserId(userId);
    obj.setDeptId(user.getDepartmentId());
    obj.setTitle(title);
    obj.setLevel(level);
    obj.setParentObjectiveId(parentObjectiveId);
    obj.setSortOrder(0);
    obj.setProgress(0.0);
    obj.setVersion(1);
    // 非个人层级的目标创建即为 APPROVED
    obj.setStatus("INDIVIDUAL".equals(level) ? "SUBMITTED" : "APPROVED");
    if (!"INDIVIDUAL".equals(level)) {
        obj.setReviewedBy(userId);
        obj.setReviewedAt(LocalDateTime.now());
    }
    if (alignedObjectiveIds != null && !alignedObjectiveIds.isEmpty()) {
        obj.setAlignedObjectiveIds(alignedObjectiveIds.stream()
                .map(String::valueOf).collect(Collectors.joining(",")));
    }
    objectiveMapper.insert(obj);

    for (KrKeyResult kr : krs) {
        kr.setObjectiveId(obj.getId());
        kr.setStatus(KrStatus.NOT_STARTED);
        krMapper.insert(kr);
    }
    return obj;
}
  • [ ] 步骤 2:OKR 对齐树查询方法

    public List<OkrDetailDto> getOkrTree(Long periodId) {
    List<OkrObjective> all = objectiveMapper.selectList(
            new LambdaQueryWrapper<OkrObjective>().eq(OkrObjective::getPeriodId, periodId));
    List<OkrDetailDto> enriched = enrichObjectives(all);
    
    // Build tree: find roots (COMPANY level or no parent)
    Map<Long, OkrDetailDto> map = enriched.stream()
            .collect(Collectors.toMap(OkrDetailDto::getId, o -> o));
    List<OkrDetailDto> roots = new ArrayList<>();
    
    for (OkrDetailDto dto : enriched) {
        if (dto.getParentObjectiveId() == null || "COMPANY".equals(dto.getLevel())) {
            roots.add(dto);
        } else {
            OkrDetailDto parent = map.get(dto.getParentObjectiveId());
            if (parent != null) {
                if (parent.getChildren() == null) parent.setChildren(new ArrayList<>());
                parent.getChildren().add(dto);
            } else {
                roots.add(dto); // orphan → treat as root
            }
        }
    }
    return roots;
    }
    
  • [ ] 步骤 3:孤立目标查询

    public List<OkrDetailDto> getOrphanOkrs(Long periodId) {
    List<OkrObjective> individuals = objectiveMapper.selectList(
            new LambdaQueryWrapper<OkrObjective>()
                    .eq(OkrObjective::getPeriodId, periodId)
                    .eq(OkrObjective::getLevel, "INDIVIDUAL")
                    .isNull(OkrObjective::getParentObjectiveId));
    return enrichObjectives(individuals);
    }
    
  • [ ] 步骤 4:KR 进度更新时重算 O 进度

updateKrProgress 方法末尾追加:

// 重算 KR 所属 O 的进度
List<KrKeyResult> allKrs = krMapper.selectList(
        new LambdaQueryWrapper<KrKeyResult>().eq(KrKeyResult::getObjectiveId, kr.getObjectiveId()));
double totalWeight = allKrs.stream().mapToDouble(k -> k.getWeight() != null ? k.getWeight() : 0).sum();
double weightedProgress = 0;
if (totalWeight > 0) {
    for (KrKeyResult k : allKrs) {
        double krProgress = k.getTargetValue() > 0
                ? (k.getCurrentValue() / k.getTargetValue()) * 100 : 0;
        weightedProgress += krProgress * (k.getWeight() / totalWeight);
    }
}
obj.setProgress(Math.min(100, Math.round(weightedProgress * 10.0) / 10.0));
objectiveMapper.updateById(obj);
  • [ ] 步骤 5:Commit

    git add backend/src/main/java/com/emoon/okr/service/OkrService.java
    git commit -m "feat: add hierarchy OKR submit, tree query, orphans, progress recalculation"
    

任务 13:OkrController 新端点

文件:

  • 修改:backend/src/main/java/com/emoon/okr/controller/OkrController.java

  • [ ] 步骤 1:追加层级 OKR 相关端点

    @PostMapping("/submit-hierarchy")
    public ApiResult<?> submitHierarchyOkr(@RequestBody Map<String, Object> body) {
    Long periodId = Long.valueOf(body.get("periodId").toString());
    Long userId = SecurityUtils.getCurrentUserId();
    String title = body.get("title").toString();
    String level = body.getOrDefault("level", "INDIVIDUAL").toString();
    Long parentObjectiveId = body.get("parentObjectiveId") != null
            ? Long.valueOf(body.get("parentObjectiveId").toString()) : null;
    @SuppressWarnings("unchecked")
    List<Map<String, Object>> krList = (List<Map<String, Object>>) body.get("keyResults");
    List<KrKeyResult> krs = krList.stream().map(m -> {
        KrKeyResult kr = new KrKeyResult();
        kr.setTitle(m.get("title").toString());
        kr.setTargetValue(Double.valueOf(m.get("targetValue").toString()));
        kr.setWeight(m.get("weight") != null ? Integer.valueOf(m.get("weight").toString()) : 0);
        return kr;
    }).toList();
    List<Long> alignedIds = new ArrayList<>();
    Object ao = body.get("alignedObjectiveIds");
    if (ao instanceof List<?> list) {
        for (Object item : list) alignedIds.add(Long.valueOf(item.toString()));
    }
    return ApiResult.success(okrService.submitHierarchyOkr(periodId, userId, title, krs, level, parentObjectiveId, alignedIds));
    }
    
    @GetMapping("/period/{periodId}/tree")
    public ApiResult<List<OkrDetailDto>> getOkrTree(@PathVariable Long periodId) {
    return ApiResult.success(okrService.getOkrTree(periodId));
    }
    
    @GetMapping("/period/{periodId}/orphans")
    public ApiResult<List<OkrDetailDto>> getOrphanOkrs(@PathVariable Long periodId) {
    return ApiResult.success(okrService.getOrphanOkrs(periodId));
    }
    
    @PostMapping("/{id}/align")
    public ApiResult<Void> setAlignment(@PathVariable Long id, @RequestBody Map<String, Object> body) {
    OkrObjective obj = objectiveMapper.selectById(id); // need mapper field
    if (obj == null) throw new BusinessException(404, "OKR不存在");
    Long parentId = body.get("parentObjectiveId") != null
            ? Long.valueOf(body.get("parentObjectiveId").toString()) : null;
    obj.setParentObjectiveId(parentId);
    objectiveMapper.updateById(obj);
    return ApiResult.success();
    }
    
    @GetMapping("/{id}/children")
    public ApiResult<List<OkrDetailDto>> getChildren(@PathVariable Long id) {
    List<OkrObjective> children = objectiveMapper.selectList(
            new LambdaQueryWrapper<OkrObjective>()
                    .eq(OkrObjective::getParentObjectiveId, id));
    return ApiResult.success(okrService.enrichObjectives(children)); // make enrichObjectives public/package-private
    }
    

需要在 OkrController 中注入 OkrObjectiveMapper,并将 OkrService.enrichObjectives 可见性改为 public。

  • [ ] 步骤 2:Commit

    git add backend/src/main/java/com/emoon/okr/controller/OkrController.java backend/src/main/java/com/emoon/okr/service/OkrService.java
    git commit -m "feat: add hierarchy OKR endpoints: tree, orphans, align, children"
    

任务 14:前端 OKR 对齐树视图

文件:

  • 创建:frontend/src/views/okr/AlignmentView.vue
  • 修改:frontend/src/router/index.js
  • 修改:frontend/src/api/index.js
  • 修改:frontend/src/components/MainLayout.vue

  • [ ] 步骤 1:api/index.js 追加对齐相关 API

    // 在 okrApi 中追加:
    getTree: periodId => api.get(`/okr/period/${periodId}/tree`).then(r => r.data),
    getOrphans: periodId => api.get(`/okr/period/${periodId}/orphans`).then(r => r.data),
    setAlignment: (id, parentObjectiveId) => api.post(`/okr/${id}/align`, { parentObjectiveId }),
    getChildren: id => api.get(`/okr/${id}/children`).then(r => r.data)
    
  • [ ] 步骤 2:创建 AlignmentView.vue

    <template>
    <div class="alignment-page">
    <div class="alignment-header">
      <h3>OKR 对齐视图</h3>
      <el-select v-model="selectedPeriodId" placeholder="选择周期" @change="loadTree" style="width:240px">
        <el-option v-for="p in periods" :key="p.id" :label="p.name" :value="p.id" />
      </el-select>
    </div>
    
    <el-row :gutter="20" v-if="selectedPeriodId">
      <el-col :span="8">
        <el-card header="对齐树" class="tree-card">
          <el-tree
            :data="treeData" :props="{ children: 'children', label: 'title' }"
            node-key="id" highlight-current
            @node-click="onNodeClick"
          >
            <template #default="{ data }">
              <div class="tree-node">
                <el-tag size="small" :type="levelTag(data.level)">{{ levelLabel(data.level) }}</el-tag>
                <span class="tree-title">{{ data.title }}</span>
                <el-progress :percentage="data.progress || 0" :stroke-width="4" style="width:60px" />
              </div>
            </template>
          </el-tree>
          <div v-if="orphans.length > 0" style="margin-top:12px">
            <el-divider />
            <el-tag type="warning">孤立目标 (未对齐)</el-tag>
            <div v-for="o in orphans" :key="o.id" class="orphan-item">
              {{ o.userName }} - {{ o.title }}
            </div>
          </div>
        </el-card>
      </el-col>
      <el-col :span="16">
        <el-card v-if="selectedNode">
          <template #header>{{ selectedNode.title }}</template>
          <div class="detail-meta">
            <el-tag>{{ levelLabel(selectedNode.level) }}</el-tag>
            <span>负责人: {{ selectedNode.userName }}</span>
            <span>进度: {{ selectedNode.progress || 0 }}%</span>
          </div>
          <el-table :data="selectedNode.keyResults || []" style="margin-top:16px">
            <el-table-column prop="title" label="KR" />
            <el-table-column label="进度" width="160">
              <template #default="{ row }">
                <el-progress :percentage="krProgress(row)" :stroke-width="6" />
              </template>
            </el-table-column>
            <el-table-column prop="weight" label="权重" width="60" />
            <el-table-column prop="status" label="状态" width="90" />
          </el-table>
        </el-card>
        <el-empty v-else description="请选择左侧 OKR 节点查看详情" />
      </el-col>
    </el-row>
    </div>
    </template>
    
    <script setup>
    import { ref, onMounted } from 'vue'
    import { okrApi, periodApi } from '../../api'
    
    const periods = ref([])
    const selectedPeriodId = ref(null)
    const treeData = ref([])
    const orphans = ref([])
    const selectedNode = ref(null)
    
    function levelLabel(l) { const m = { COMPANY: '公司', DEPARTMENT: '部门', TEAM: '团队', INDIVIDUAL: '个人' }; return m[l] || l }
    function levelTag(l) { return l === 'COMPANY' ? 'danger' : l === 'DEPARTMENT' ? 'warning' : l === 'TEAM' ? 'success' : 'info' }
    function krProgress(kr) {
    return kr.targetValue > 0 ? Math.round((kr.currentValue / kr.targetValue) * 100) : 0
    }
    
    async function loadTree() {
    const [tree, orphanList] = await Promise.all([
    okrApi.getTree(selectedPeriodId.value),
    okrApi.getOrphans(selectedPeriodId.value)
    ])
    treeData.value = tree
    orphans.value = orphanList
    }
    
    function onNodeClick(node) {
    selectedNode.value = node
    }
    
    onMounted(async () => {
    periods.value = await periodApi.list()
    if (periods.value.length > 0) {
    selectedPeriodId.value = periods.value[0].id
    await loadTree()
    }
    })
    </script>
    
    <style scoped>
    .alignment-page { max-width: 1200px; margin: 0 auto; }
    .alignment-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
    .tree-card { max-height: calc(100vh - 200px); overflow-y: auto; }
    .tree-node { display: flex; align-items: center; gap: 8px; font-size: 13px; }
    .tree-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .orphan-item { padding: 4px 0; font-size: 12px; color: #999; }
    .detail-meta { display: flex; gap: 12px; align-items: center; font-size: 13px; color: #666; }
    </style>
    
  • [ ] 步骤 3:router/index.js 追加路由

    { path: 'okr/alignment', name: 'OkrAlignment', component: () => import('../views/okr/AlignmentView.vue'), meta: { title: 'OKR 对齐' } }
    
  • [ ] 步骤 4:MainLayout.vue 菜单追加对齐入口

    <el-menu-item index="/okr/alignment">
    <el-icon><Connection /></el-icon>
    <span>OKR 对齐</span>
    </el-menu-item>
    
  • [ ] 步骤 5:Commit

    git add frontend/src/views/okr/AlignmentView.vue frontend/src/router/index.js frontend/src/api/index.js frontend/src/components/MainLayout.vue
    git commit -m "feat: add OKR alignment tree view with detail panel"
    

任务 15:个人 OKR 提交对齐改造

文件:

  • 修改:frontend/src/views/okr/MyOkrView.vue

  • [ ] 步骤 1:MyOkrView 增加对齐上级 OKR 的选择功能

在 OKR 创建/编辑表单中,增加"对齐到上级目标"的下拉选择(在 period 状态为 OKR_ALIGN 时可用):

<el-form-item label="对齐目标">
  <el-tree-select
    v-model="form.parentObjectiveId"
    :data="alignmentOptions"
    :props="{ children: 'children', label: 'title', value: 'id' }"
    placeholder="选择对齐的上级目标(可选)"
    clearable
    check-strictly
  />
</el-form-item>

<script setup> 中获取上级已审批 OKR:

import { okrApi } from '../../api'

const alignmentOptions = ref([])

async function loadAlignmentOptions() {
  try {
    alignmentOptions.value = await okrApi.getTree(props.periodId)
  } catch {
    alignmentOptions.value = []
  }
}

parentObjectiveId 纳入提交表单数据。

  • [ ] 步骤 2:Commit

    git add frontend/src/views/okr/MyOkrView.vue
    git commit -m "feat: add parent OKR alignment selector to MyOkrView"
    

任务 16:集成验证与最终 commit

文件:

  • 修改:backend/src/main/java/com/emoon/okr/config/DatabaseInitializer.java

  • [ ] 步骤 1:验证 DatabaseInitializer 迁移完整性

确认 DatabaseInitializer.runSchema() 包含所有 3 个模块的 DDL 和 ALTER。

  • [ ] 步骤 2:全量构建验证

    cd backend && mvn clean compile -DskipTests
    cd frontend && npm run build
    
  • [ ] 步骤 3:启动验证

    cd backend && mvn spring-boot:run
    # 访问 http://localhost:18080 确认页面正常加载
    # 确认侧边栏菜单按角色显示
    # 确认通知铃铛出现
    # 确认 OKR 对齐页面可访问
    
  • [ ] 步骤 4:Commit

    git add backend/src/main/java/com/emoon/okr/config/DatabaseInitializer.java
    git commit -m "feat: finalize Phase 1 integration — notifications, RBAC, hierarchy OKR"
    

自检清单

  1. 规格覆盖度

    • ✅ 通知系统:7 类事件触发、未读角标、通知面板、列表页
    • ✅ RBAC:5 级角色、权限矩阵、Controller 检查、前端菜单适配
    • ✅ 多层级 OKR:四层级对齐、parent_objective_id、树形视图、进度汇总、孤立检测
  2. 无占位符:所有代码步骤均为实际实现

  3. 类型一致性

    • NotificationEvent 类型与 Notification.type 字段值一致
    • UserRole 枚举值与前端 auth.js 角色判断一致
    • OkrObjective.level 值与前端 levelLabel() 映射一致