MyOkrView.vue 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775
  1. <template>
  2. <div class="app-page okr-workbench">
  3. <!-- v2 工具栏顶栏(替代旧版标题区)-->
  4. <div class="data-toolbar">
  5. <span class="data-toolbar__title">我的 OKR</span>
  6. <template v-if="period">
  7. <span class="data-toolbar__sep">|</span>
  8. <span class="data-toolbar__meta">{{ period.name }}</span>
  9. <span class="status-label">
  10. <span class="status-label__dot" :class="periodStatusDotClass"></span>
  11. {{ periodStatusLabel }}
  12. </span>
  13. <span class="data-toolbar__spacer"></span>
  14. <span class="data-toolbar__meta" v-if="daysLeft !== null">距周期结束 {{ daysLeft }} 天</span>
  15. <el-button v-if="period?.status === 'OKR_ALIGN' && myOkr" size="small" @click="startEdit">编辑 OKR</el-button>
  16. <el-button v-else-if="period?.status === 'OKR_ALIGN' && !myOkr && canSubmit" type="primary" size="small" @click="openCreateForm">制定 OKR</el-button>
  17. <el-button v-else-if="period?.status === 'ASSESSING'" type="warning" size="small" @click="$router.push('/scores/my')">去完成自评</el-button>
  18. </template>
  19. </div>
  20. <PeriodBanner v-if="periods.length" :periods="periods" v-model="selectedPeriodId" />
  21. <div v-if="!selectedPeriodId" class="okr-empty">暂无进行中的考核周期</div>
  22. <template v-else>
  23. <!-- 我的 OKR 表格视图(OKR_ALIGN 有 myOkr / EXECUTING)-->
  24. <div v-if="(period?.status === 'OKR_ALIGN' || period?.status === 'EXECUTING') && myOkr" class="data-table">
  25. <div class="data-table__header" style="grid-template-columns: 36px 1fr 56px 72px 120px 80px;">
  26. <span></span>
  27. <span>关键结果</span>
  28. <span>权重</span>
  29. <span>进度</span>
  30. <span>当前/目标</span>
  31. <span>状态</span>
  32. </div>
  33. <div v-for="(kr, i) in myOkr.keyResults" :key="kr.id"
  34. class="data-table__row data-table__row--clickable"
  35. style="grid-template-columns: 36px 1fr 56px 72px 120px 80px;"
  36. @click="openKrDrawer(kr)">
  37. <span style="color: var(--color-accent); font-weight: 700;">KR{{ i+1 }}</span>
  38. <span style="color: var(--color-text);">
  39. {{ kr.title }}
  40. <span v-if="kr.sourceKrId" class="kr-source-tag">承接上级</span>
  41. <span v-if="kr.assignedToName" class="kr-source-tag">指派给 {{ kr.assignedToName }}</span>
  42. </span>
  43. <span>{{ kr.weight }}</span>
  44. <span style="color: var(--color-brand); font-weight: 600;">{{ krPercent(kr) }}%</span>
  45. <span style="color: var(--color-text-secondary);">{{ kr.currentValue }}/{{ kr.targetValue }}</span>
  46. <span class="status-label" style="font-size: 11px;">
  47. <span class="status-dot" :class="krStatusDotClass(kr)"></span>
  48. {{ krStatusLabel(kr.status) }}
  49. </span>
  50. </div>
  51. </div>
  52. <!-- OKR 被驳回提示 -->
  53. <div v-if="period?.status === 'OKR_ALIGN' && myOkr?.status === 'REJECTED'" class="state-banner banner-REJECTED">
  54. 你的 OKR 已被驳回,请修改后重新提交。
  55. <el-button type="primary" size="small" @click="startEdit" style="margin-left:12px">修改并重新提交</el-button>
  56. </div>
  57. <!-- OKR_ALIGN 空态:无 myOkr -->
  58. <template v-if="period?.status === 'OKR_ALIGN' && !myOkr">
  59. <div v-if="!canSubmit" class="state-banner banner-waiting">
  60. 等待上级先制定 OKR · {{ superiorName }} 完成并审核通过后,你可对齐并提交。
  61. </div>
  62. <template v-else>
  63. <!-- 上级 OKR 表格化(对齐参考)-->
  64. <div v-if="superiorOkrs.length" style="margin-top: 20px;">
  65. <div class="section-band">上级 OKR · 对齐参考</div>
  66. <div class="data-table">
  67. <div class="data-table__header" style="grid-template-columns: 36px 1fr 120px;">
  68. <span></span>
  69. <span>关键结果</span>
  70. <span>指派</span>
  71. </div>
  72. <template v-for="o in superiorOkrs" :key="o.id">
  73. <div v-for="(kr, i) in o.keyResults" :key="kr.id"
  74. class="data-table__row"
  75. style="grid-template-columns: 36px 1fr 120px;">
  76. <span style="color: var(--color-accent); font-weight: 700;">KR{{ i+1 }}</span>
  77. <span style="color: var(--color-text);">{{ kr.title }}</span>
  78. <span>
  79. <span v-if="kr.assignedTo === auth.user?.id" class="kr-source-tag kr-source-tag--mine">指派给我</span>
  80. </span>
  81. </div>
  82. </template>
  83. </div>
  84. </div>
  85. <div v-else class="state-banner banner-top-level">
  86. 你是最高层级 · 制定公司级 OKR(1个O + 3条KR,团队将基于此对齐)
  87. </div>
  88. </template>
  89. </template>
  90. <!-- EXECUTING 锁定提示 -->
  91. <div v-if="period?.status === 'EXECUTING' && myOkr" class="state-banner banner-EXECUTING" style="margin-top: 12px;">
  92. OKR 已锁定 · 仅可更新进度和标记阻塞,点击 KR 行在抽屉中更新。
  93. </div>
  94. <!-- 待审核 OKR 表格化 -->
  95. <div v-if="pendingReviews.length" style="margin-top: 20px;">
  96. <div class="section-band">待审核 OKR · {{ pendingReviews.length }} 条</div>
  97. <div class="data-table">
  98. <div class="data-table__header" style="grid-template-columns: 36px 1fr 100px 120px;">
  99. <span></span>
  100. <span>关键结果</span>
  101. <span>提交人</span>
  102. <span>操作</span>
  103. </div>
  104. <template v-for="o in pendingReviews" :key="o.id">
  105. <div v-for="(kr, i) in o.keyResults" :key="kr.id"
  106. class="data-table__row"
  107. style="grid-template-columns: 36px 1fr 100px 120px;">
  108. <span style="color: var(--color-accent); font-weight: 700;">KR{{ i+1 }}</span>
  109. <span style="color: var(--color-text);">{{ kr.title }}</span>
  110. <span v-if="i === 0" :rowspan="o.keyResults.length" style="color: var(--color-text-secondary);">{{ o.userName }}</span>
  111. <span v-else></span>
  112. <span v-if="i === 0">
  113. <el-button size="small" type="success" @click="doReview(o.id, true)">通过</el-button>
  114. <el-button size="small" type="danger" @click="doReview(o.id, false)">驳回</el-button>
  115. </span>
  116. </div>
  117. </template>
  118. </div>
  119. </div>
  120. </template>
  121. <!-- 侧边抽屉:KR 详情 / 进度更新 -->
  122. <el-drawer v-model="drawerVisible" title="关键结果详情" size="420px" direction="rtl">
  123. <div v-if="drawerKr" class="side-drawer__content">
  124. <div class="uppercase-label">关键结果</div>
  125. <div style="margin: 4px 0 16px; font-weight: 600; color: var(--color-text);">{{ drawerKr.title }}</div>
  126. <div class="metric-bar">
  127. <div class="metric-bar__head">
  128. <span class="metric-bar__label">当前进度</span>
  129. <span class="metric-bar__value">{{ drawerKr.currentValue }} / {{ drawerKr.targetValue }}
  130. <span class="metric-bar__value--muted">({{ krPercent(drawerKr) }}%)</span>
  131. </span>
  132. </div>
  133. <div class="metric-bar__track">
  134. <div class="metric-bar__fill" :style="{ width: krPercent(drawerKr) + '%', background: 'var(--color-accent)' }"></div>
  135. </div>
  136. </div>
  137. <!-- 进度更新(仅 EXECUTING 阶段)-->
  138. <template v-if="period?.status === 'EXECUTING' && drawerKr.status !== 'COMPLETED'">
  139. <div class="uppercase-label" style="margin-top: 20px;">更新进度</div>
  140. <div style="font-size: 11px; color: var(--color-text-muted); margin: 4px 0 8px;">
  141. 当前值:{{ drawerKr.currentValue }}
  142. </div>
  143. <el-input-number v-model="drawerKr._newValue" :min="0" :max="drawerKr.targetValue*2" style="width: 100%;"
  144. :placeholder="'新值(当前 ' + drawerKr.currentValue + ')'" />
  145. <div class="uppercase-label" style="margin-top: 16px;">状态</div>
  146. <el-select v-model="drawerKr._newStatus" style="width: 100%; margin-top: 4px;">
  147. <el-option label="进行中" value="IN_PROGRESS" />
  148. <el-option label="阻塞" value="BLOCKED" />
  149. <el-option label="已完成" value="COMPLETED" />
  150. <el-option label="未开始" value="NOT_STARTED" />
  151. </el-select>
  152. <el-button type="primary" style="width: 100%; margin-top: 16px;"
  153. :disabled="drawerKr._newValue == null" @click="submitDrawerProgress">保存进度</el-button>
  154. <div v-if="drawerKr.status === 'BLOCKED'" style="margin-top: 12px; font-size: 11px; color: var(--color-warning);">
  155. 阻塞项,请说明原因与所需支持。上级已收到提醒。
  156. </div>
  157. </template>
  158. <div v-else-if="drawerKr.status === 'COMPLETED'" class="uppercase-label"
  159. style="margin-top: 20px; color: var(--color-success);">
  160. 该 KR 已完成
  161. </div>
  162. </div>
  163. </el-drawer>
  164. <!-- Create OKR Dialog -->
  165. <el-dialog v-model="showForm" :title="editingOkr ? '编辑 OKR' : '制定个人 OKR'" width="720px" top="4vh" :close-on-click-modal="false">
  166. <div class="dialog-rules">
  167. <div class="dr-item"><b>1个O(目标)</b>:定性方向,15-30字,不含数字指标,必须对齐上级方向</div>
  168. <div class="dr-item"><b>2~5条KR</b>(推荐3条):可量化结果,10-25字,必须有验收标准</div>
  169. <div class="dr-item"><b>权重之和 = 100</b>:每条KR分配权重,总和必须为100</div>
  170. </div>
  171. <!-- Position template reference in dialog -->
  172. <div v-if="positionTemplate" class="dialog-kpi-ref">
  173. <div class="dkr-title">你的岗位指标参考({{ positionTemplate.name }})</div>
  174. <div class="dkr-tags">
  175. <el-tag v-for="(item, i) in parseKpiItems(positionTemplate.itemsJson)" :key="i"
  176. size="small" type="info" effect="plain">
  177. {{ item.name }}({{ item.maxScore }}分)
  178. </el-tag>
  179. </div>
  180. <div class="dkr-hint">你的 OKR 目标应与上述岗位 KPI 方向保持一致</div>
  181. </div>
  182. <!-- Superior's OKR reference — KR-level alignment -->
  183. <div v-if="superiorOkrs.length" class="align-section">
  184. <div class="align-head">
  185. <div class="align-head-left">
  186. <span class="align-head-title">对齐上级 OKR</span>
  187. <span class="align-head-hint">· 勾选你要承接的关键结果,将自动创建引用条目</span>
  188. </div>
  189. <el-tag v-if="mandatoryKrCount" type="warning" effect="plain" size="small">
  190. {{ mandatoryKrCount }} 项指派给我
  191. </el-tag>
  192. </div>
  193. <div v-for="o in superiorOkrs" :key="o.id" class="align-o-card">
  194. <div class="align-o-head">
  195. <span class="align-o-icon">O</span>
  196. <span class="align-o-title">{{ o.title }}</span>
  197. </div>
  198. <div class="align-kr-list">
  199. <div v-for="(kr, i) in o.keyResults" :key="kr.id"
  200. class="align-kr-row"
  201. :class="{
  202. 'is-mandatory': kr.assignedTo === auth.user?.id,
  203. 'is-selected': isKrAligned(kr.id) && kr.assignedTo !== auth.user?.id
  204. }"
  205. @click="toggleAlignKr(kr, o)">
  206. <div class="align-kr-left">
  207. <span class="align-checkbox" :class="{ checked: isKrAligned(kr.id), locked: kr.assignedTo === auth.user?.id }">
  208. <span v-if="isKrAligned(kr.id)">已选</span>
  209. </span>
  210. <span class="align-kr-num">KR{{ i+1 }}</span>
  211. <span class="align-kr-title">{{ kr.title }}</span>
  212. </div>
  213. <div class="align-kr-right">
  214. <span class="align-kr-target">目标 {{ kr.targetValue }}</span>
  215. <el-tag v-if="kr.assignedTo === auth.user?.id" type="warning" effect="plain" size="small">
  216. 指派给我
  217. </el-tag>
  218. <span v-else-if="isKrAligned(kr.id)" class="align-check">已选</span>
  219. </div>
  220. </div>
  221. </div>
  222. </div>
  223. </div>
  224. <el-divider />
  225. <!-- O Input -->
  226. <div class="o-section">
  227. <div class="o-label">目标 O <span class="o-hint">(定性方向,15-30字,不含数字)</span></div>
  228. <el-input v-model="newOkr.title" type="textarea" :autosize="{ minRows: 1, maxRows: 3 }" :placeholder="currentHints.oHint" size="large" maxlength="35" show-word-limit />
  229. </div>
  230. <!-- Parent objective alignment -->
  231. <div class="parent-align-section">
  232. <div class="parent-align-label">对齐目标</div>
  233. <el-tree-select
  234. v-model="newOkr.parentObjectiveId"
  235. :data="alignmentOptions"
  236. :props="{ children: 'children', label: 'title', value: 'id' }"
  237. placeholder="选择对齐的上级目标(可选)"
  238. clearable
  239. check-strictly
  240. style="width:100%"
  241. />
  242. </div>
  243. <!-- Dynamic KRs with source reference -->
  244. <div class="kr-section">
  245. <div class="kr-section-title">
  246. 关键结果 KR({{ newOkr.krs.length }} 条)
  247. <span class="weight-sum" :class="{ 'weight-ok': weightSum === 100, 'weight-bad': weightSum !== 100 }">
  248. 权重合计:{{ weightSum }} / 100
  249. </span>
  250. </div>
  251. <div v-for="(kr, i) in newOkr.krs" :key="i" class="kr-form-card" :class="{ 'has-source': kr._sourceKrId }">
  252. <div v-if="kr._sourceInfo" class="kr-source-bar">
  253. <span class="kr-source-text">承接上级 {{ kr._sourceInfo.userName }} 的 {{ kr._sourceInfo.krTitle }}</span>
  254. </div>
  255. <div class="kr-form-head">
  256. <span class="kr-form-num">KR{{ i+1 }}</span>
  257. <el-input v-model="kr.title" type="textarea" :autosize="{ minRows: 1, maxRows: 3 }" :placeholder="currentHints.krs[Math.min(i, currentHints.krs.length-1)]" size="large" style="flex:1" maxlength="28" show-word-limit />
  258. <div class="kr-form-right">
  259. <span class="kr-form-label-sm">权重</span>
  260. <el-input-number v-model="kr.weight" :min="0" :max="100" :step="5" size="large" controls-position="right" style="width:90px" />
  261. <span class="kr-form-label-sm">目标值</span>
  262. <el-input-number v-model="kr.targetValue" :min="0" :max="999" placeholder="目标" controls-position="right" style="width:110px" />
  263. <el-button v-if="newOkr.krs.length > 2" @click="removeKr(i)" type="danger" circle :icon="'Delete'" size="small" />
  264. </div>
  265. </div>
  266. <div class="kr-form-extra">
  267. <span class="kr-form-label">@指派</span>
  268. <el-select v-model="kr.assignedTo" clearable filterable placeholder="指派给下属(可选)" size="small" style="width:180px">
  269. <el-option v-for="s in subordinates" :key="s.id" :label="s.realName" :value="s.id" />
  270. </el-select>
  271. </div>
  272. </div>
  273. <el-button v-if="newOkr.krs.length < 5" @click="newOkr.krs.push({ title: '', targetValue: 100, weight: 0, assignedTo: null })" size="small" style="margin-top:8px">
  274. 新增 KR
  275. </el-button>
  276. </div>
  277. <div class="dialog-footer-hint">
  278. 提交后由直属上级审核。审核通过后锁定,周期内不可修改。
  279. </div>
  280. <template #footer>
  281. <el-button @click="showForm = false">取消</el-button>
  282. <el-button type="primary" size="large" @click="handleSubmit" :loading="submitting">
  283. {{ editingOkr ? '保存修改' : '提交审核' }}
  284. </el-button>
  285. </template>
  286. </el-dialog>
  287. </div>
  288. </template>
  289. <script setup>
  290. import { ref, reactive, computed, onMounted, watch } from 'vue'
  291. import { useRoute } from 'vue-router'
  292. import { periodApi, okrApi, orgApi, kpiApi } from '../../api'
  293. import { useAuthStore } from '../../stores/auth'
  294. import { ElMessage } from 'element-plus'
  295. import PeriodBanner from '../../components/PeriodBanner.vue'
  296. const auth = useAuthStore()
  297. const route = useRoute()
  298. const periods = ref([])
  299. const superiorOkrs = ref([])
  300. const myOkr = ref(null)
  301. const pendingReviews = ref([])
  302. const canSubmit = ref(false)
  303. const subordinates = ref([])
  304. const positionTemplate = ref(null)
  305. const selectedPeriodId = ref(null)
  306. const showForm = ref(false)
  307. const editingOkr = ref(false)
  308. const submitting = ref(false)
  309. const alignmentOptions = ref([])
  310. async function loadAlignmentOptions() {
  311. try {
  312. alignmentOptions.value = await okrApi.getTree(selectedPeriodId.value)
  313. } catch {
  314. alignmentOptions.value = []
  315. }
  316. }
  317. // Position-specific OKR hints
  318. const okrHints = {
  319. 'CEO / 总经理': {
  320. oHint: '本周期推动公司____战略落地,实现业务增长',
  321. krs: ['完成____营收目标,环比增长____%', '核心业务线____交付满意度≥____', '完成____关键岗位招聘,团队留存率≥____%']
  322. },
  323. '前端开发工程师': {
  324. oHint: '本周期提升前端工程化水平与用户体验',
  325. krs: ['完成____核心模块/页面开发并全量上线', '负责模块线上零严重Bug,Bug闭环≤24h', '组件库覆盖率达____%,文档完整']
  326. },
  327. '后端开发工程师': {
  328. oHint: '本周期支撑业务系统稳定高效运行',
  329. krs: ['完成____核心API开发并上线,文档齐全', '负责模块线上零P0事故,接口响应≤____ms', '数据库慢查询优化,查询耗时降低____%']
  330. },
  331. '全栈开发工程师': {
  332. oHint: '本周期完成____模块端到端交付与优化',
  333. krs: ['完成____功能前后端联调并全量上线', '系统整体可用性≥____%,无严重线上事故', '完成____技术方案设计与落地,输出文档']
  334. },
  335. '移动端开发工程师': {
  336. oHint: '本周期保障App核心体验与版本交付',
  337. krs: ['完成v____版本迭代开发并通过审核上架', 'Crash率<____%,启动耗时<____s', '核心流程用户投诉环比降低____%']
  338. },
  339. '产品经理': {
  340. oHint: '本周期推动____产品方向落地,提升用户价值',
  341. krs: ['完成____核心需求迭代,NPS≥____', '输出____竞品分析报告与下周期规划', '跨部门协作满意度≥____分']
  342. },
  343. '人力资源总监': {
  344. oHint: '本周期完善人才体系,支撑业务增长',
  345. krs: ['核心岗位到岗率≥____%,招聘周期≤____天', '员工满意度≥____分,离职率<____%', '完成____场培训,覆盖率达____%']
  346. },
  347. '财务总监': {
  348. oHint: '本周期保障财务合规与资金安全',
  349. krs: ['月度报表____号前完成,差异率<____%', '预算执行偏差率<____%,成本节约____万元', '完成____项税务/审计合规工作,零风险']
  350. },
  351. '区域经理': {
  352. oHint: '本周期推动区域市场占有率与营收增长',
  353. krs: ['区域营收达成____万元,同比增长____%', '新拓客户____家,老客户续约率≥____%', '区域团队人效提升____%,人员零流失']
  354. },
  355. '运营总监': {
  356. oHint: '本周期驱动核心业务指标与用户增长',
  357. krs: ['DAU/MAU ____万,环比增长____%', '用户7日留存率≥____%,转化率≥____%', '完成____场运营活动,ROI≥____']
  358. },
  359. '市场营销总监': {
  360. oHint: '本周期提升品牌影响力与获客效率',
  361. krs: ['品牌曝光量____万,自然流量占比≥____%', '线索获取成本<____元,转化率≥____%', '产出____篇高质量内容,全网阅读≥____万']
  362. },
  363. '医疗合规总监': {
  364. oHint: '本周期保障医疗合规体系有效运行',
  365. krs: ['完成____项法规落地,合规检查零问题', '数据安全事件=0,隐私合规审计通过', '完成全员合规培训____场,覆盖率100%']
  366. }
  367. }
  368. const currentHints = computed(() => {
  369. const pos = auth.user?.position || ''
  370. for (const [key, val] of Object.entries(okrHints)) {
  371. if (pos.includes(key) || key.includes(pos)) return val
  372. }
  373. // fallback generic
  374. return {
  375. oHint: '本周期支撑核心业务目标高质量达成',
  376. krs: ['完成____核心任务并交付验收', '负责模块零问题,闭环时效≤____', '完成____落地,覆盖率达____%']
  377. }
  378. })
  379. const newOkr = reactive({
  380. title: '',
  381. krs: [
  382. { title: '', targetValue: 100, weight: 34, assignedTo: null },
  383. { title: '', targetValue: 100, weight: 33, assignedTo: null },
  384. { title: '', targetValue: 100, weight: 33, assignedTo: null }
  385. ],
  386. alignedIds: [],
  387. parentObjectiveId: null
  388. })
  389. const weightSum = computed(() => newOkr.krs.reduce((s, k) => s + (k.weight || 0), 0))
  390. const activePeriods = computed(() => periods.value.filter(p =>
  391. p.status !== 'ARCHIVED'
  392. ))
  393. const period = computed(() => periods.value.find(p => p.id === selectedPeriodId.value))
  394. const superiorName = computed(() => superiorOkrs.value[0]?.userName || '(上级)')
  395. // --- KR-level alignment ---
  396. // Check if a superior KR is aligned (has a corresponding entry in newOkr.krs)
  397. function isKrAligned(superiorKrId) {
  398. return newOkr.krs.some(k => k._sourceKrId === superiorKrId)
  399. }
  400. // Toggle alignment: add/remove a KR entry referencing the superior's KR
  401. function toggleAlignKr(superiorKr, superiorO) {
  402. if (superiorKr.assignedTo === auth.user?.id) return // mandatory, can't untoggle
  403. const existingIdx = newOkr.krs.findIndex(k => k._sourceKrId === superiorKr.id)
  404. if (existingIdx >= 0) {
  405. // Remove the aligned KR entry
  406. newOkr.krs.splice(existingIdx, 1)
  407. } else {
  408. // Add a new KR entry referencing this superior KR
  409. newOkr.krs.push({
  410. title: '承接:' + superiorKr.title,
  411. targetValue: superiorKr.targetValue,
  412. weight: 0,
  413. assignedTo: null,
  414. _sourceKrId: superiorKr.id,
  415. _sourceInfo: {
  416. userName: superiorO.userName || superiorName.value,
  417. krTitle: superiorKr.title
  418. }
  419. })
  420. }
  421. }
  422. // Remove a KR — but not if it's mandatory (has @me source)
  423. function removeKr(i) {
  424. const kr = newOkr.krs[i]
  425. if (kr._sourceKrId) {
  426. // Check if it's mandatory
  427. for (const o of superiorOkrs.value) {
  428. const srcKr = o.keyResults?.find(k => k.id === kr._sourceKrId)
  429. if (srcKr && srcKr.assignedTo === auth.user?.id) return // mandatory, can't remove
  430. }
  431. }
  432. newOkr.krs.splice(i, 1)
  433. }
  434. // Count mandatory KRs
  435. const mandatoryKrCount = computed(() => {
  436. let count = 0
  437. for (const o of superiorOkrs.value) {
  438. count += (o.keyResults || []).filter(k => k.assignedTo === auth.user?.id).length
  439. }
  440. return count
  441. })
  442. // On dialog open, auto-add mandatory @me KRs if not already present
  443. function initAlignment() {
  444. for (const o of superiorOkrs.value) {
  445. for (const kr of (o.keyResults || [])) {
  446. if (kr.assignedTo === auth.user?.id) {
  447. const exists = newOkr.krs.some(k => k._sourceKrId === kr.id)
  448. if (!exists) {
  449. newOkr.krs.push({
  450. title: '承接:' + kr.title,
  451. targetValue: kr.targetValue,
  452. weight: 0,
  453. assignedTo: null,
  454. _sourceKrId: kr.id,
  455. _sourceInfo: { userName: o.userName || superiorName.value, krTitle: kr.title }
  456. })
  457. }
  458. }
  459. }
  460. }
  461. }
  462. onMounted(async () => {
  463. periods.value = await periodApi.list()
  464. // Respect periodId from query param first, then default to first active
  465. const qPeriodId = route.query.periodId ? Number(route.query.periodId) : null
  466. if (qPeriodId && periods.value.some(p => p.id === qPeriodId)) {
  467. selectPeriod(qPeriodId)
  468. } else if (activePeriods.value.length) {
  469. selectPeriod(activePeriods.value[0].id)
  470. } else if (periods.value.length) {
  471. selectPeriod(periods.value[0].id)
  472. }
  473. try { subordinates.value = await orgApi.getMySubordinates() } catch { subordinates.value = [] }
  474. if (auth.user?.position) {
  475. try { positionTemplate.value = await kpiApi.getTemplateByPosition(auth.user.position) } catch { positionTemplate.value = null }
  476. }
  477. })
  478. function selectPeriod(pid) {
  479. selectedPeriodId.value = pid
  480. load()
  481. }
  482. function parseKpiItems(json) {
  483. try { return JSON.parse(json) || [] } catch { return [] }
  484. }
  485. async function load() {
  486. if (!selectedPeriodId.value) return
  487. const pid = selectedPeriodId.value
  488. try { superiorOkrs.value = await okrApi.getSuperiorOkrs(pid) } catch { superiorOkrs.value = [] }
  489. try { myOkr.value = await okrApi.getMyOkr(pid) } catch { myOkr.value = null }
  490. try { const r = await okrApi.canSubmit(pid); canSubmit.value = r.canSubmit } catch { canSubmit.value = false }
  491. try { pendingReviews.value = await okrApi.getPendingReviews(pid) } catch { pendingReviews.value = [] }
  492. loadAlignmentOptions()
  493. if (myOkr.value) {
  494. myOkr.value.keyResults?.forEach(kr => { kr._newValue = kr.currentValue; kr._newStatus = kr.status })
  495. }
  496. }
  497. function openCreateForm() {
  498. editingOkr.value = false
  499. resetForm()
  500. initAlignment()
  501. showForm.value = true
  502. }
  503. function startEdit() {
  504. editingOkr.value = true
  505. if (myOkr.value) {
  506. newOkr.title = myOkr.value.title
  507. // Pre-compute aligned IDs from the OKR's alignedObjectives
  508. const existingAligned = myOkr.value.alignedObjectives?.map(a => a.id) || []
  509. // Also add mandatory (assigned-to-me) KR-level alignments from superior OKRs
  510. const mandatoryIds = new Set(existingAligned)
  511. if (superiorOkrs.value.length) {
  512. for (const o of superiorOkrs.value) {
  513. const hasMandatoryKR = o.keyResults?.some(kr => kr.assignedTo === auth.user?.id)
  514. if (hasMandatoryKR) mandatoryIds.add(o.id)
  515. }
  516. }
  517. newOkr.alignedIds = [...mandatoryIds]
  518. newOkr.krs = (myOkr.value.keyResults || []).map(k => ({
  519. title: k.title || '',
  520. targetValue: k.targetValue || 100,
  521. weight: k.weight || Math.floor(100 / (myOkr.value.keyResults?.length || 3)),
  522. assignedTo: k.assignedTo || null,
  523. _sourceKrId: k.sourceKrId || null,
  524. _sourceInfo: k.sourceKrId ? findSourceInfo(k.sourceKrId, k.sourceKrTitle, k.sourceKrUserName) : null
  525. }))
  526. }
  527. initAlignment()
  528. showForm.value = true
  529. }
  530. function findSourceInfo(sourceKrId, sourceKrTitle, sourceKrUserName) {
  531. // Look up in loaded superior OKRs for full info
  532. for (const o of superiorOkrs.value) {
  533. const kr = (o.keyResults || []).find(k => k.id === sourceKrId)
  534. if (kr) return { userName: o.userName, krTitle: kr.title }
  535. }
  536. // Fallback: use stored info from backend
  537. if (sourceKrTitle) return { userName: sourceKrUserName || '', krTitle: sourceKrTitle }
  538. return null
  539. }
  540. async function handleSubmit() {
  541. if (!newOkr.title) { ElMessage.warning('请填写目标 O'); return }
  542. const filledKrs = newOkr.krs.filter(k => k.title)
  543. if (filledKrs.length < 2) { ElMessage.warning('至少填写2条KR'); return }
  544. const totalWeight = newOkr.krs.reduce((s, k) => s + (k.weight || 0), 0)
  545. if (totalWeight !== 100) { ElMessage.warning('KR权重之和必须为100,当前为' + totalWeight); return }
  546. submitting.value = true
  547. try {
  548. const payload = {
  549. periodId: selectedPeriodId.value,
  550. title: newOkr.title,
  551. keyResults: filledKrs.map(k => ({ title: k.title, targetValue: k.targetValue, weight: k.weight, assignedTo: k.assignedTo, sourceKrId: k._sourceKrId || null })),
  552. alignedObjectiveIds: newOkr.alignedIds,
  553. parentObjectiveId: newOkr.parentObjectiveId || null
  554. }
  555. if (editingOkr.value && myOkr.value) {
  556. await okrApi.update(myOkr.value.id, payload)
  557. } else {
  558. await okrApi.submit(payload)
  559. }
  560. ElMessage.success(editingOkr.value ? 'OKR 已更新' : 'OKR 已提交,等待上级审核')
  561. showForm.value = false; editingOkr.value = false
  562. resetForm()
  563. load()
  564. } finally { submitting.value = false }
  565. }
  566. function resetForm() {
  567. newOkr.title = ''
  568. newOkr.krs = [
  569. { title: '', targetValue: 100, weight: 34, assignedTo: null },
  570. { title: '', targetValue: 100, weight: 33, assignedTo: null },
  571. { title: '', targetValue: 100, weight: 33, assignedTo: null }
  572. ]
  573. newOkr.alignedIds = []
  574. newOkr.parentObjectiveId = null
  575. }
  576. async function updateProgress(kr) {
  577. await okrApi.updateProgress(kr.id, { newValue: kr._newValue, status: kr._newStatus, remark: '' })
  578. ElMessage.success('进度已更新'); load()
  579. }
  580. // v2 侧边抽屉:KR 详情 / 进度更新
  581. const drawerVisible = ref(false)
  582. const drawerKr = ref(null)
  583. function openKrDrawer(kr) {
  584. // 复制一份并初始化 updateProgress 机制所需的临时字段
  585. drawerKr.value = { ...kr, _newValue: kr.currentValue, _newStatus: kr.status }
  586. drawerVisible.value = true
  587. }
  588. async function submitDrawerProgress() {
  589. if (!drawerKr.value) return
  590. await updateProgress(drawerKr.value)
  591. drawerVisible.value = false
  592. }
  593. // v2 工具栏状态显示
  594. const periodStatusLabel = computed(() => {
  595. const map = { DRAFT: '草稿', OKR_ALIGN: '对齐中', EXECUTING: '执行中', ASSESSING: '考评中', ARCHIVED: '已归档' }
  596. return map[period.value?.status] || '—'
  597. })
  598. const periodStatusDotClass = computed(() => {
  599. const map = { EXECUTING: 'status-label__dot--active', OKR_ALIGN: 'status-label__dot--warn', ASSESSING: 'status-label__dot--warn', ARCHIVED: 'status-label__dot--info' }
  600. return map[period.value?.status] || ''
  601. })
  602. const daysLeft = computed(() => {
  603. if (!period.value?.endDate) return null
  604. const diff = Math.ceil((new Date(period.value.endDate) - new Date()) / 86400000)
  605. return diff > 0 ? diff : 0
  606. })
  607. function krStatusDotClass(kr) {
  608. if (kr.status === 'BLOCKED') return 'status-dot--warn'
  609. if (kr.status === 'COMPLETED') return 'status-dot--active'
  610. return 'status-dot--info'
  611. }
  612. async function doReview(id, approved) {
  613. await okrApi.review(id, { approved, remark: '' })
  614. ElMessage.success(approved ? '已通过' : '已驳回'); load()
  615. }
  616. function krPercent(kr) { if (!kr.targetValue || kr.targetValue === 0) return 0; return Math.min((kr.currentValue / kr.targetValue) * 100, 100) }
  617. function krStatusLabel(s) { return { NOT_STARTED: '未开始', IN_PROGRESS: '进行中', COMPLETED: '已完成', BLOCKED: '阻塞' }[s] || s }
  618. </script>
  619. <style scoped>
  620. .page { width: 100%; }
  621. /* v2 状态标签色点变体 */
  622. .status-label__dot--active { background: var(--color-success); }
  623. .status-label__dot--warn { background: var(--color-warning); }
  624. .status-label__dot--info { background: var(--color-text-muted); }
  625. .status-dot--active { background: var(--color-success); }
  626. .status-dot--warn { background: var(--color-warning); }
  627. .status-dot--info { background: var(--color-text-muted); }
  628. .okr-empty {
  629. display: flex; align-items: center; justify-content: center;
  630. height: 240px; color: var(--color-text-muted); font-size: var(--font-size-base);
  631. }
  632. /* 状态提示横幅(v2,驳回/等待/锁定提示)*/
  633. .state-banner {
  634. padding: 12px 16px; border-radius: var(--radius-sm); margin-bottom: 12px;
  635. display: flex; align-items: center; gap: 10px; font-size: var(--font-size-sm);
  636. }
  637. .banner-EXECUTING { background: var(--color-surface-tint-success); color: var(--color-success); border: 1px solid var(--color-border-soft); }
  638. .banner-REJECTED { background: var(--color-surface-tint-danger); color: var(--color-danger); border: 1px solid var(--color-border-soft); }
  639. .banner-waiting { background: var(--color-surface-tint-warning); color: var(--color-warning); border: 1px solid var(--color-border-soft); }
  640. .banner-top-level { background: var(--color-surface-tint-success); color: var(--color-success); border: 1px solid var(--color-border-soft); }
  641. /* KR 来源标签(承接上级/指派)*/
  642. .kr-source-tag {
  643. display: inline-block; margin-left: 6px; padding: 1px 6px;
  644. background: var(--color-surface-tint-info); color: var(--color-medical);
  645. border-radius: 3px; font-size: 10px; font-weight: 500;
  646. }
  647. .kr-source-tag--mine {
  648. background: var(--color-surface-tint-warning); color: var(--color-warning);
  649. }
  650. /* Dialog */
  651. .dialog-rules { background: var(--color-surface-tint-info); border-radius: var(--radius-md); padding: 14px 18px; margin-bottom: 16px; }
  652. .dr-item { font-size: var(--font-size-sm); color: var(--color-text); line-height: 1.8; }
  653. /* Alignment section */
  654. .align-section { background: var(--color-surface); border: 1px solid var(--color-border-soft); border-radius: var(--radius-md); margin-bottom: 20px; overflow: hidden; }
  655. .align-head { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid var(--color-border-soft); }
  656. .align-head-left { display: flex; align-items: center; gap: 6px; }
  657. .align-head-icon { font-size: var(--font-size-xl); font-weight: 700; color: var(--color-brand); }
  658. .align-head-title { font-size: 15px; font-weight: 700; color: var(--color-text); }
  659. .align-head-hint { font-size: var(--font-size-sm); color: var(--color-text-muted); }
  660. .align-o-card { padding: 0; border-bottom: 1px solid var(--color-border-soft); }
  661. .align-o-card:last-child { border-bottom: none; }
  662. .align-o-head { display: flex; align-items: center; gap: 10px; padding: 14px 22px 8px; }
  663. .align-o-icon { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); background: var(--color-brand); color: var(--color-white); font-size: var(--font-size-xs); font-weight: 700; }
  664. .align-o-title { font-size: var(--font-size-base); font-weight: 600; color: var(--color-text); }
  665. .align-kr-list { padding: 0 22px 14px; display: flex; flex-direction: column; gap: 6px; }
  666. .align-kr-row { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-radius: var(--radius-md); cursor: pointer; transition: all 0.15s; border: 1px solid transparent; }
  667. .align-kr-row:hover { background: var(--color-surface-subtle); }
  668. .align-kr-row.is-selected { background: var(--color-surface-tint-brand); border-color: var(--color-border); }
  669. .align-kr-row.is-mandatory { background: var(--color-surface-tint-warning); border-color: var(--color-border-soft); cursor: default; }
  670. .align-kr-left { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
  671. .align-kr-num { font-size: 11px; font-weight: 700; color: var(--color-text-muted); width: 30px; flex-shrink: 0; letter-spacing: 0; }
  672. .align-checkbox { min-width: 34px; height: 18px; border-radius: var(--radius-xs); border: 1px solid var(--color-border); display: inline-flex; align-items: center; justify-content: center; font-size: 10px; color: transparent; flex-shrink: 0; transition: all 0.15s; cursor: pointer; padding: 0 4px; }
  673. .align-checkbox.checked { background: var(--color-brand); border-color: var(--color-brand); color: var(--color-white); }
  674. .align-checkbox.locked { background: var(--color-surface-tint-warning); border-color: var(--color-warning); color: var(--color-warning); cursor: default; }
  675. .align-kr-title { font-size: var(--font-size-sm); color: var(--color-text); }
  676. .align-kr-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
  677. .align-kr-target { font-size: var(--font-size-xs); color: var(--color-text-muted); }
  678. .align-footer { padding: 12px 22px; background: var(--color-surface-subtle); border-top: 1px solid var(--color-border-soft); font-size: var(--font-size-sm); color: var(--color-text-secondary); text-align: center; }
  679. .align-footer strong { color: var(--color-brand); }
  680. .parent-align-section { margin-bottom: 20px; }
  681. .parent-align-label { font-weight: 600; font-size: 15px; color: var(--color-text); margin-bottom: 8px; }
  682. .o-section { margin-bottom: 20px; }
  683. .o-label { font-weight: 600; font-size: 15px; color: var(--color-text); margin-bottom: 8px; }
  684. .o-hint { font-size: var(--font-size-xs); color: var(--color-text-muted); font-weight: 400; }
  685. .kr-section { margin-bottom: 16px; }
  686. .kr-section-title { font-weight: 600; font-size: 15px; color: var(--color-text); margin-bottom: 12px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
  687. .weight-sum { font-size: var(--font-size-sm); padding: 2px 10px; border-radius: var(--radius-md); }
  688. .weight-ok { background: var(--color-surface-tint-success); color: var(--color-success); font-weight: 700; }
  689. .weight-bad { background: var(--color-surface-tint-danger); color: var(--color-danger); font-weight: 700; }
  690. .kr-form-card { background: var(--color-surface-subtle); border-radius: var(--radius-md); padding: 14px 16px; margin-bottom: 10px; border: 1px solid var(--color-border-soft); }
  691. .kr-form-card.has-source { border-left: 3px solid var(--color-brand); }
  692. .kr-source-bar { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; padding: 6px 10px; background: var(--color-surface-tint-brand); border-radius: var(--radius-sm); font-size: var(--font-size-xs); }
  693. .kr-source-icon { color: var(--color-brand); font-weight: 700; }
  694. .kr-source-text { color: var(--color-text-secondary); }
  695. .kr-form-head { display: flex; align-items: flex-start; gap: 10px; }
  696. .kr-form-head .el-textarea { flex: 1; }
  697. .kr-form-num { font-size: var(--font-size-sm); font-weight: 700; color: var(--color-accent); width: 36px; }
  698. .kr-form-right { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
  699. .kr-form-label-sm { font-size: 11px; color: var(--color-text-muted); }
  700. .kr-form-extra { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
  701. .kr-form-label { font-size: var(--font-size-xs); color: var(--color-text-muted); }
  702. .dialog-kpi-ref { background: var(--color-surface-tint-info); border: 1px solid var(--color-border-soft); border-radius: var(--radius-md); padding: 12px 16px; margin-bottom: 12px; }
  703. .dkr-title { font-size: var(--font-size-sm); font-weight: 600; color: var(--color-brand); margin-bottom: 6px; }
  704. .dkr-tags { display: flex; flex-wrap: wrap; gap: 4px; }
  705. .dkr-hint { font-size: 11px; color: var(--color-text-muted); margin-top: 6px; }
  706. .dialog-footer-hint { text-align: center; font-size: var(--font-size-xs); color: var(--color-text-muted); margin-top: 12px; }
  707. </style>