# 企业级能力 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/yimeng/okr/entity/Notification.java`
- 创建：`backend/src/main/java/com/yimeng/okr/mapper/NotificationMapper.java`
- 创建：`backend/src/main/java/com/yimeng/okr/dto/NotificationDto.java`
- 修改：`backend/src/main/resources/schema.sql`

- [ ] **步骤 1：创建 Notification 实体**

```java
package com.yimeng.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**

```java
package com.yimeng.okr.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yimeng.okr.entity.Notification;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface NotificationMapper extends BaseMapper<Notification> {
}
```

- [ ] **步骤 3：创建 NotificationDto**

```java
package com.yimeng.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` 末尾追加：

```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**

```bash
git add backend/src/main/java/com/yimeng/okr/entity/Notification.java backend/src/main/java/com/yimeng/okr/mapper/NotificationMapper.java backend/src/main/java/com/yimeng/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/yimeng/okr/event/NotificationEvent.java`
- 创建：`backend/src/main/java/com/yimeng/okr/event/NotificationEventListener.java`

- [ ] **步骤 1：创建 NotificationEvent**

```java
package com.yimeng.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**

```java
package com.yimeng.okr.event;

import com.yimeng.okr.entity.Notification;
import com.yimeng.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`：

```java
package com.yimeng.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**

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

---

### 任务 3：通知 REST 端点

**文件：**
- 创建：`backend/src/main/java/com/yimeng/okr/controller/NotificationController.java`
- 创建：`backend/src/main/java/com/yimeng/okr/service/NotificationService.java`

- [ ] **步骤 1：创建 NotificationService**

```java
package com.yimeng.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.yimeng.okr.dto.NotificationDto;
import com.yimeng.okr.entity.Notification;
import com.yimeng.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**

```java
package com.yimeng.okr.controller;

import com.yimeng.okr.dto.ApiResult;
import com.yimeng.okr.security.SecurityUtils;
import com.yimeng.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**

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

---

### 任务 4：业务事件接入

**文件：**
- 修改：`backend/src/main/java/com/yimeng/okr/service/OkrService.java`
- 修改：`backend/src/main/java/com/yimeng/okr/service/PerformanceService.java`
- 修改：`backend/src/main/java/com/yimeng/okr/service/FeedbackService.java`
- 修改：`backend/src/main/java/com/yimeng/okr/schedule/PeriodScheduler.java`

- [ ] **步骤 1：OkrService — 注入 ApplicationEventPublisher 并在 submitOkr 尾部发布事件**

在 `OkrService` 构造函数中注入 `ApplicationEventPublisher`，在 `submitOkr` 方法 `return obj;` 之前添加（仅非顶层用户提交时通知上级）：

```java
import com.yimeng.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);` 之后添加：

```java
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 — 评分完成后通知**

在 `PerformanceService` 的 `submitSuperiorScore` 方法（或等效评分方法）成功后，发布通知给被评分人：

```java
import com.yimeng.okr.event.NotificationEvent;

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

在确认评分方法成功后，通知上级：

```java
publisher.publishEvent(new NotificationEvent(
        this, superiorId, "SCORE_CONFIRMED",
        "评分已确认", userName + " 已确认绩效评分",
        "score", scoreId));
```

- [ ] **步骤 3：FeedbackService — 面谈发起后通知**

```java
// 在保存面谈记录成功后:
publisher.publishEvent(new NotificationEvent(
        this, employeeId, "FEEDBACK_NOTIFY",
        "绩效面谈通知", "你的上级发起了绩效面谈，请查看并回复",
        "feedback", feedback.getId()));
```

- [ ] **步骤 4：PeriodScheduler — 周期截止提醒 + KR 滞后检测**

在 `PeriodScheduler` 的定时任务中追加：

```java
import com.yimeng.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**

```bash
git add backend/src/main/java/com/yimeng/okr/service/OkrService.java backend/src/main/java/com/yimeng/okr/service/PerformanceService.java backend/src/main/java/com/yimeng/okr/service/FeedbackService.java backend/src/main/java/com/yimeng/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**

```javascript
// 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` 区域，在用户信息之前插入：

```html
<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>` 增加通知逻辑：

```javascript
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**

```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 数组中追加：

```javascript
{ path: 'notifications', name: 'Notifications', component: () => import('../views/notification/NotificationView.vue'), meta: { title: '消息通知' } }
```

- [ ] **步骤 5：Commit**

```bash
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/yimeng/okr/enums/UserRole.java`
- 修改：`backend/src/main/java/com/yimeng/okr/entity/SysUser.java`
- 修改：`backend/src/main/resources/schema.sql`

- [ ] **步骤 1：扩展 UserRole 枚举**

```java
package com.yimeng.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` 中添加：

```java
private String scopes;  // JSON array: ["dept:3","dept:5"]
```

- [ ] **步骤 3：schema.sql 追加 ALTER**

在 `schema.sql` 的 `CREATE TABLE sys_user` 之后追加（SQLite 不支持 ADD COLUMN 的 IF NOT EXISTS，在 DatabaseInitializer 中处理）：

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

- [ ] **步骤 4：Commit**

```bash
git add backend/src/main/java/com/yimeng/okr/enums/UserRole.java backend/src/main/java/com/yimeng/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/yimeng/okr/config/DatabaseInitializer.java`

- [ ] **步骤 1：DatabaseInitializer 追加列迁移**

在 `DatabaseInitializer` 的 `runSchema()` 方法中追加 ALTER TABLE 逻辑（用 try-catch 忽略列已存在错误）：

```java
// 在 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` 中追加：

```java
import com.yimeng.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**

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

---

### 任务 8：@RequireRole 注解与 AOP 切面

**文件：**
- 创建：`backend/src/main/java/com/yimeng/okr/annotation/RequireRole.java`
- 创建：`backend/src/main/java/com/yimeng/okr/aspect/RoleAspect.java`

- [ ] **步骤 1：创建 @RequireRole 注解**

```java
package com.yimeng.okr.annotation;

import com.yimeng.okr.enums.UserRole;
import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
    UserRole[] value() default {UserRole.SUPER_ADMIN};
}
```

- [ ] **步骤 2：创建 RoleAspect**

```java
package com.yimeng.okr.aspect;

import com.yimeng.okr.annotation.RequireRole;
import com.yimeng.okr.enums.UserRole;
import com.yimeng.okr.exception.BusinessException;
import com.yimeng.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.yimeng.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 方法开头添加：

```java
if (!SecurityUtils.hasRole(UserRole.HR_ADMIN)) {
    throw new BusinessException(403, "无权限访问");
}
```

- [ ] **步骤 3：Commit**

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

---

### 任务 9：Controller 层权限加固

**文件：**
- 修改：`backend/src/main/java/com/yimeng/okr/controller/OrganizationController.java`
- 修改：`backend/src/main/java/com/yimeng/okr/controller/AssessmentPeriodController.java`
- 修改：`backend/src/main/java/com/yimeng/okr/controller/KpiController.java`
- 修改：`backend/src/main/java/com/yimeng/okr/controller/DimensionController.java`
- 修改：`backend/src/main/java/com/yimeng/okr/controller/SystemController.java`

- [ ] **步骤 1：OrganizationController 方法加权限检查**

在 `createDept`, `updateDept`, `deleteDept`, `createUser`, `updateUser`, `deleteUser`, `updateSuperior`, `updateDepartment` 方法开头添加：

```java
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**

```bash
git add backend/src/main/java/com/yimeng/okr/controller/OrganizationController.java backend/src/main/java/com/yimeng/okr/controller/AssessmentPeriodController.java backend/src/main/java/com/yimeng/okr/controller/KpiController.java backend/src/main/java/com/yimeng/okr/controller/DimensionController.java backend/src/main/java/com/yimeng/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` 中扩展：

```javascript
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"`：

```html
<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` 中的角色显示：

```html
<span class="user-info">{{ auth.user?.realName }} · {{ auth.roleLabel }}</span>
```

- [ ] **步骤 3：OrgView 用户列表增加角色选择器**

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

```html
<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**

```bash
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/yimeng/okr/entity/OkrObjective.java`
- 修改：`backend/src/main/java/com/yimeng/okr/dto/OkrDetailDto.java`
- 修改：`backend/src/main/resources/schema.sql`
- 修改：`backend/src/main/java/com/yimeng/okr/config/DatabaseInitializer.java`

- [ ] **步骤 1：OkrObjective 增加字段**

```java
private String level;           // COMPANY / DEPARTMENT / TEAM / INDIVIDUAL
private Long parentObjectiveId; // 对齐的上级目标ID
private Integer sortOrder;      // 同级排序
private Double progress;        // 汇总进度 0-100
```

- [ ] **步骤 2：OkrDetailDto 增加字段**

```java
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.java` 的 `runSchema()` 方法中追加：

```java
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**

```bash
git add backend/src/main/java/com/yimeng/okr/entity/OkrObjective.java backend/src/main/java/com/yimeng/okr/dto/OkrDetailDto.java backend/src/main/java/com/yimeng/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/yimeng/okr/service/OkrService.java`

- [ ] **步骤 1：提交层级 OKR 方法**

在 `OkrService` 中新增 `submitHierarchyOkr` 方法：

```java
@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 对齐树查询方法**

```java
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：孤立目标查询**

```java
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` 方法末尾追加：

```java
// 重算 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**

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

---

### 任务 13：OkrController 新端点

**文件：**
- 修改：`backend/src/main/java/com/yimeng/okr/controller/OkrController.java`

- [ ] **步骤 1：追加层级 OKR 相关端点**

```java
@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**

```bash
git add backend/src/main/java/com/yimeng/okr/controller/OkrController.java backend/src/main/java/com/yimeng/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**

```javascript
// 在 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**

```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 追加路由**

```javascript
{ path: 'okr/alignment', name: 'OkrAlignment', component: () => import('../views/okr/AlignmentView.vue'), meta: { title: 'OKR 对齐' } }
```

- [ ] **步骤 4：MainLayout.vue 菜单追加对齐入口**

```html
<el-menu-item index="/okr/alignment">
  <el-icon><Connection /></el-icon>
  <span>OKR 对齐</span>
</el-menu-item>
```

- [ ] **步骤 5：Commit**

```bash
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 时可用）：

```html
<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：

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

const alignmentOptions = ref([])

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

将 `parentObjectiveId` 纳入提交表单数据。

- [ ] **步骤 2：Commit**

```bash
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/yimeng/okr/config/DatabaseInitializer.java`

- [ ] **步骤 1：验证 DatabaseInitializer 迁移完整性**

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

- [ ] **步骤 2：全量构建验证**

```bash
cd backend && mvn clean compile -DskipTests
cd frontend && npm run build
```

- [ ] **步骤 3：启动验证**

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

- [ ] **步骤 4：Commit**

```bash
git add backend/src/main/java/com/yimeng/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()` 映射一致
