| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775 |
- <template>
- <div class="app-page okr-workbench">
- <!-- v2 工具栏顶栏(替代旧版标题区)-->
- <div class="data-toolbar">
- <span class="data-toolbar__title">我的 OKR</span>
- <template v-if="period">
- <span class="data-toolbar__sep">|</span>
- <span class="data-toolbar__meta">{{ period.name }}</span>
- <span class="status-label">
- <span class="status-label__dot" :class="periodStatusDotClass"></span>
- {{ periodStatusLabel }}
- </span>
- <span class="data-toolbar__spacer"></span>
- <span class="data-toolbar__meta" v-if="daysLeft !== null">距周期结束 {{ daysLeft }} 天</span>
- <el-button v-if="period?.status === 'OKR_ALIGN' && myOkr" size="small" @click="startEdit">编辑 OKR</el-button>
- <el-button v-else-if="period?.status === 'OKR_ALIGN' && !myOkr && canSubmit" type="primary" size="small" @click="openCreateForm">制定 OKR</el-button>
- <el-button v-else-if="period?.status === 'ASSESSING'" type="warning" size="small" @click="$router.push('/scores/my')">去完成自评</el-button>
- </template>
- </div>
- <PeriodBanner v-if="periods.length" :periods="periods" v-model="selectedPeriodId" />
- <div v-if="!selectedPeriodId" class="okr-empty">暂无进行中的考核周期</div>
- <template v-else>
- <!-- 我的 OKR 表格视图(OKR_ALIGN 有 myOkr / EXECUTING)-->
- <div v-if="(period?.status === 'OKR_ALIGN' || period?.status === 'EXECUTING') && myOkr" class="data-table">
- <div class="data-table__header" style="grid-template-columns: 36px 1fr 56px 72px 120px 80px;">
- <span></span>
- <span>关键结果</span>
- <span>权重</span>
- <span>进度</span>
- <span>当前/目标</span>
- <span>状态</span>
- </div>
- <div v-for="(kr, i) in myOkr.keyResults" :key="kr.id"
- class="data-table__row data-table__row--clickable"
- style="grid-template-columns: 36px 1fr 56px 72px 120px 80px;"
- @click="openKrDrawer(kr)">
- <span style="color: var(--color-accent); font-weight: 700;">KR{{ i+1 }}</span>
- <span style="color: var(--color-text);">
- {{ kr.title }}
- <span v-if="kr.sourceKrId" class="kr-source-tag">承接上级</span>
- <span v-if="kr.assignedToName" class="kr-source-tag">指派给 {{ kr.assignedToName }}</span>
- </span>
- <span>{{ kr.weight }}</span>
- <span style="color: var(--color-brand); font-weight: 600;">{{ krPercent(kr) }}%</span>
- <span style="color: var(--color-text-secondary);">{{ kr.currentValue }}/{{ kr.targetValue }}</span>
- <span class="status-label" style="font-size: 11px;">
- <span class="status-dot" :class="krStatusDotClass(kr)"></span>
- {{ krStatusLabel(kr.status) }}
- </span>
- </div>
- </div>
- <!-- OKR 被驳回提示 -->
- <div v-if="period?.status === 'OKR_ALIGN' && myOkr?.status === 'REJECTED'" class="state-banner banner-REJECTED">
- 你的 OKR 已被驳回,请修改后重新提交。
- <el-button type="primary" size="small" @click="startEdit" style="margin-left:12px">修改并重新提交</el-button>
- </div>
- <!-- OKR_ALIGN 空态:无 myOkr -->
- <template v-if="period?.status === 'OKR_ALIGN' && !myOkr">
- <div v-if="!canSubmit" class="state-banner banner-waiting">
- 等待上级先制定 OKR · {{ superiorName }} 完成并审核通过后,你可对齐并提交。
- </div>
- <template v-else>
- <!-- 上级 OKR 表格化(对齐参考)-->
- <div v-if="superiorOkrs.length" style="margin-top: 20px;">
- <div class="section-band">上级 OKR · 对齐参考</div>
- <div class="data-table">
- <div class="data-table__header" style="grid-template-columns: 36px 1fr 120px;">
- <span></span>
- <span>关键结果</span>
- <span>指派</span>
- </div>
- <template v-for="o in superiorOkrs" :key="o.id">
- <div v-for="(kr, i) in o.keyResults" :key="kr.id"
- class="data-table__row"
- style="grid-template-columns: 36px 1fr 120px;">
- <span style="color: var(--color-accent); font-weight: 700;">KR{{ i+1 }}</span>
- <span style="color: var(--color-text);">{{ kr.title }}</span>
- <span>
- <span v-if="kr.assignedTo === auth.user?.id" class="kr-source-tag kr-source-tag--mine">指派给我</span>
- </span>
- </div>
- </template>
- </div>
- </div>
- <div v-else class="state-banner banner-top-level">
- 你是最高层级 · 制定公司级 OKR(1个O + 3条KR,团队将基于此对齐)
- </div>
- </template>
- </template>
- <!-- EXECUTING 锁定提示 -->
- <div v-if="period?.status === 'EXECUTING' && myOkr" class="state-banner banner-EXECUTING" style="margin-top: 12px;">
- OKR 已锁定 · 仅可更新进度和标记阻塞,点击 KR 行在抽屉中更新。
- </div>
- <!-- 待审核 OKR 表格化 -->
- <div v-if="pendingReviews.length" style="margin-top: 20px;">
- <div class="section-band">待审核 OKR · {{ pendingReviews.length }} 条</div>
- <div class="data-table">
- <div class="data-table__header" style="grid-template-columns: 36px 1fr 100px 120px;">
- <span></span>
- <span>关键结果</span>
- <span>提交人</span>
- <span>操作</span>
- </div>
- <template v-for="o in pendingReviews" :key="o.id">
- <div v-for="(kr, i) in o.keyResults" :key="kr.id"
- class="data-table__row"
- style="grid-template-columns: 36px 1fr 100px 120px;">
- <span style="color: var(--color-accent); font-weight: 700;">KR{{ i+1 }}</span>
- <span style="color: var(--color-text);">{{ kr.title }}</span>
- <span v-if="i === 0" :rowspan="o.keyResults.length" style="color: var(--color-text-secondary);">{{ o.userName }}</span>
- <span v-else></span>
- <span v-if="i === 0">
- <el-button size="small" type="success" @click="doReview(o.id, true)">通过</el-button>
- <el-button size="small" type="danger" @click="doReview(o.id, false)">驳回</el-button>
- </span>
- </div>
- </template>
- </div>
- </div>
- </template>
- <!-- 侧边抽屉:KR 详情 / 进度更新 -->
- <el-drawer v-model="drawerVisible" title="关键结果详情" size="420px" direction="rtl">
- <div v-if="drawerKr" class="side-drawer__content">
- <div class="uppercase-label">关键结果</div>
- <div style="margin: 4px 0 16px; font-weight: 600; color: var(--color-text);">{{ drawerKr.title }}</div>
- <div class="metric-bar">
- <div class="metric-bar__head">
- <span class="metric-bar__label">当前进度</span>
- <span class="metric-bar__value">{{ drawerKr.currentValue }} / {{ drawerKr.targetValue }}
- <span class="metric-bar__value--muted">({{ krPercent(drawerKr) }}%)</span>
- </span>
- </div>
- <div class="metric-bar__track">
- <div class="metric-bar__fill" :style="{ width: krPercent(drawerKr) + '%', background: 'var(--color-accent)' }"></div>
- </div>
- </div>
- <!-- 进度更新(仅 EXECUTING 阶段)-->
- <template v-if="period?.status === 'EXECUTING' && drawerKr.status !== 'COMPLETED'">
- <div class="uppercase-label" style="margin-top: 20px;">更新进度</div>
- <div style="font-size: 11px; color: var(--color-text-muted); margin: 4px 0 8px;">
- 当前值:{{ drawerKr.currentValue }}
- </div>
- <el-input-number v-model="drawerKr._newValue" :min="0" :max="drawerKr.targetValue*2" style="width: 100%;"
- :placeholder="'新值(当前 ' + drawerKr.currentValue + ')'" />
- <div class="uppercase-label" style="margin-top: 16px;">状态</div>
- <el-select v-model="drawerKr._newStatus" style="width: 100%; margin-top: 4px;">
- <el-option label="进行中" value="IN_PROGRESS" />
- <el-option label="阻塞" value="BLOCKED" />
- <el-option label="已完成" value="COMPLETED" />
- <el-option label="未开始" value="NOT_STARTED" />
- </el-select>
- <el-button type="primary" style="width: 100%; margin-top: 16px;"
- :disabled="drawerKr._newValue == null" @click="submitDrawerProgress">保存进度</el-button>
- <div v-if="drawerKr.status === 'BLOCKED'" style="margin-top: 12px; font-size: 11px; color: var(--color-warning);">
- 阻塞项,请说明原因与所需支持。上级已收到提醒。
- </div>
- </template>
- <div v-else-if="drawerKr.status === 'COMPLETED'" class="uppercase-label"
- style="margin-top: 20px; color: var(--color-success);">
- 该 KR 已完成
- </div>
- </div>
- </el-drawer>
- <!-- Create OKR Dialog -->
- <el-dialog v-model="showForm" :title="editingOkr ? '编辑 OKR' : '制定个人 OKR'" width="720px" top="4vh" :close-on-click-modal="false">
- <div class="dialog-rules">
- <div class="dr-item"><b>1个O(目标)</b>:定性方向,15-30字,不含数字指标,必须对齐上级方向</div>
- <div class="dr-item"><b>2~5条KR</b>(推荐3条):可量化结果,10-25字,必须有验收标准</div>
- <div class="dr-item"><b>权重之和 = 100</b>:每条KR分配权重,总和必须为100</div>
- </div>
- <!-- Position template reference in dialog -->
- <div v-if="positionTemplate" class="dialog-kpi-ref">
- <div class="dkr-title">你的岗位指标参考({{ positionTemplate.name }})</div>
- <div class="dkr-tags">
- <el-tag v-for="(item, i) in parseKpiItems(positionTemplate.itemsJson)" :key="i"
- size="small" type="info" effect="plain">
- {{ item.name }}({{ item.maxScore }}分)
- </el-tag>
- </div>
- <div class="dkr-hint">你的 OKR 目标应与上述岗位 KPI 方向保持一致</div>
- </div>
- <!-- Superior's OKR reference — KR-level alignment -->
- <div v-if="superiorOkrs.length" class="align-section">
- <div class="align-head">
- <div class="align-head-left">
-
- <span class="align-head-title">对齐上级 OKR</span>
- <span class="align-head-hint">· 勾选你要承接的关键结果,将自动创建引用条目</span>
- </div>
- <el-tag v-if="mandatoryKrCount" type="warning" effect="plain" size="small">
- {{ mandatoryKrCount }} 项指派给我
- </el-tag>
- </div>
- <div v-for="o in superiorOkrs" :key="o.id" class="align-o-card">
- <div class="align-o-head">
- <span class="align-o-icon">O</span>
- <span class="align-o-title">{{ o.title }}</span>
- </div>
- <div class="align-kr-list">
- <div v-for="(kr, i) in o.keyResults" :key="kr.id"
- class="align-kr-row"
- :class="{
- 'is-mandatory': kr.assignedTo === auth.user?.id,
- 'is-selected': isKrAligned(kr.id) && kr.assignedTo !== auth.user?.id
- }"
- @click="toggleAlignKr(kr, o)">
- <div class="align-kr-left">
- <span class="align-checkbox" :class="{ checked: isKrAligned(kr.id), locked: kr.assignedTo === auth.user?.id }">
- <span v-if="isKrAligned(kr.id)">已选</span>
- </span>
- <span class="align-kr-num">KR{{ i+1 }}</span>
- <span class="align-kr-title">{{ kr.title }}</span>
- </div>
- <div class="align-kr-right">
- <span class="align-kr-target">目标 {{ kr.targetValue }}</span>
- <el-tag v-if="kr.assignedTo === auth.user?.id" type="warning" effect="plain" size="small">
- 指派给我
- </el-tag>
- <span v-else-if="isKrAligned(kr.id)" class="align-check">已选</span>
- </div>
- </div>
- </div>
- </div>
- </div>
- <el-divider />
- <!-- O Input -->
- <div class="o-section">
- <div class="o-label">目标 O <span class="o-hint">(定性方向,15-30字,不含数字)</span></div>
- <el-input v-model="newOkr.title" type="textarea" :autosize="{ minRows: 1, maxRows: 3 }" :placeholder="currentHints.oHint" size="large" maxlength="35" show-word-limit />
- </div>
- <!-- Parent objective alignment -->
- <div class="parent-align-section">
- <div class="parent-align-label">对齐目标</div>
- <el-tree-select
- v-model="newOkr.parentObjectiveId"
- :data="alignmentOptions"
- :props="{ children: 'children', label: 'title', value: 'id' }"
- placeholder="选择对齐的上级目标(可选)"
- clearable
- check-strictly
- style="width:100%"
- />
- </div>
- <!-- Dynamic KRs with source reference -->
- <div class="kr-section">
- <div class="kr-section-title">
- 关键结果 KR({{ newOkr.krs.length }} 条)
- <span class="weight-sum" :class="{ 'weight-ok': weightSum === 100, 'weight-bad': weightSum !== 100 }">
- 权重合计:{{ weightSum }} / 100
- </span>
- </div>
- <div v-for="(kr, i) in newOkr.krs" :key="i" class="kr-form-card" :class="{ 'has-source': kr._sourceKrId }">
- <div v-if="kr._sourceInfo" class="kr-source-bar">
-
- <span class="kr-source-text">承接上级 {{ kr._sourceInfo.userName }} 的 {{ kr._sourceInfo.krTitle }}</span>
- </div>
- <div class="kr-form-head">
- <span class="kr-form-num">KR{{ i+1 }}</span>
- <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 />
- <div class="kr-form-right">
- <span class="kr-form-label-sm">权重</span>
- <el-input-number v-model="kr.weight" :min="0" :max="100" :step="5" size="large" controls-position="right" style="width:90px" />
- <span class="kr-form-label-sm">目标值</span>
- <el-input-number v-model="kr.targetValue" :min="0" :max="999" placeholder="目标" controls-position="right" style="width:110px" />
- <el-button v-if="newOkr.krs.length > 2" @click="removeKr(i)" type="danger" circle :icon="'Delete'" size="small" />
- </div>
- </div>
- <div class="kr-form-extra">
- <span class="kr-form-label">@指派</span>
- <el-select v-model="kr.assignedTo" clearable filterable placeholder="指派给下属(可选)" size="small" style="width:180px">
- <el-option v-for="s in subordinates" :key="s.id" :label="s.realName" :value="s.id" />
- </el-select>
- </div>
- </div>
- <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">
- 新增 KR
- </el-button>
- </div>
- <div class="dialog-footer-hint">
- 提交后由直属上级审核。审核通过后锁定,周期内不可修改。
- </div>
- <template #footer>
- <el-button @click="showForm = false">取消</el-button>
- <el-button type="primary" size="large" @click="handleSubmit" :loading="submitting">
- {{ editingOkr ? '保存修改' : '提交审核' }}
- </el-button>
- </template>
- </el-dialog>
- </div>
- </template>
- <script setup>
- import { ref, reactive, computed, onMounted, watch } from 'vue'
- import { useRoute } from 'vue-router'
- import { periodApi, okrApi, orgApi, kpiApi } from '../../api'
- import { useAuthStore } from '../../stores/auth'
- import { ElMessage } from 'element-plus'
- import PeriodBanner from '../../components/PeriodBanner.vue'
- const auth = useAuthStore()
- const route = useRoute()
- const periods = ref([])
- const superiorOkrs = ref([])
- const myOkr = ref(null)
- const pendingReviews = ref([])
- const canSubmit = ref(false)
- const subordinates = ref([])
- const positionTemplate = ref(null)
- const selectedPeriodId = ref(null)
- const showForm = ref(false)
- const editingOkr = ref(false)
- const submitting = ref(false)
- const alignmentOptions = ref([])
- async function loadAlignmentOptions() {
- try {
- alignmentOptions.value = await okrApi.getTree(selectedPeriodId.value)
- } catch {
- alignmentOptions.value = []
- }
- }
- // Position-specific OKR hints
- const okrHints = {
- 'CEO / 总经理': {
- oHint: '本周期推动公司____战略落地,实现业务增长',
- krs: ['完成____营收目标,环比增长____%', '核心业务线____交付满意度≥____', '完成____关键岗位招聘,团队留存率≥____%']
- },
- '前端开发工程师': {
- oHint: '本周期提升前端工程化水平与用户体验',
- krs: ['完成____核心模块/页面开发并全量上线', '负责模块线上零严重Bug,Bug闭环≤24h', '组件库覆盖率达____%,文档完整']
- },
- '后端开发工程师': {
- oHint: '本周期支撑业务系统稳定高效运行',
- krs: ['完成____核心API开发并上线,文档齐全', '负责模块线上零P0事故,接口响应≤____ms', '数据库慢查询优化,查询耗时降低____%']
- },
- '全栈开发工程师': {
- oHint: '本周期完成____模块端到端交付与优化',
- krs: ['完成____功能前后端联调并全量上线', '系统整体可用性≥____%,无严重线上事故', '完成____技术方案设计与落地,输出文档']
- },
- '移动端开发工程师': {
- oHint: '本周期保障App核心体验与版本交付',
- krs: ['完成v____版本迭代开发并通过审核上架', 'Crash率<____%,启动耗时<____s', '核心流程用户投诉环比降低____%']
- },
- '产品经理': {
- oHint: '本周期推动____产品方向落地,提升用户价值',
- krs: ['完成____核心需求迭代,NPS≥____', '输出____竞品分析报告与下周期规划', '跨部门协作满意度≥____分']
- },
- '人力资源总监': {
- oHint: '本周期完善人才体系,支撑业务增长',
- krs: ['核心岗位到岗率≥____%,招聘周期≤____天', '员工满意度≥____分,离职率<____%', '完成____场培训,覆盖率达____%']
- },
- '财务总监': {
- oHint: '本周期保障财务合规与资金安全',
- krs: ['月度报表____号前完成,差异率<____%', '预算执行偏差率<____%,成本节约____万元', '完成____项税务/审计合规工作,零风险']
- },
- '区域经理': {
- oHint: '本周期推动区域市场占有率与营收增长',
- krs: ['区域营收达成____万元,同比增长____%', '新拓客户____家,老客户续约率≥____%', '区域团队人效提升____%,人员零流失']
- },
- '运营总监': {
- oHint: '本周期驱动核心业务指标与用户增长',
- krs: ['DAU/MAU ____万,环比增长____%', '用户7日留存率≥____%,转化率≥____%', '完成____场运营活动,ROI≥____']
- },
- '市场营销总监': {
- oHint: '本周期提升品牌影响力与获客效率',
- krs: ['品牌曝光量____万,自然流量占比≥____%', '线索获取成本<____元,转化率≥____%', '产出____篇高质量内容,全网阅读≥____万']
- },
- '医疗合规总监': {
- oHint: '本周期保障医疗合规体系有效运行',
- krs: ['完成____项法规落地,合规检查零问题', '数据安全事件=0,隐私合规审计通过', '完成全员合规培训____场,覆盖率100%']
- }
- }
- const currentHints = computed(() => {
- const pos = auth.user?.position || ''
- for (const [key, val] of Object.entries(okrHints)) {
- if (pos.includes(key) || key.includes(pos)) return val
- }
- // fallback generic
- return {
- oHint: '本周期支撑核心业务目标高质量达成',
- krs: ['完成____核心任务并交付验收', '负责模块零问题,闭环时效≤____', '完成____落地,覆盖率达____%']
- }
- })
- const newOkr = reactive({
- title: '',
- krs: [
- { title: '', targetValue: 100, weight: 34, assignedTo: null },
- { title: '', targetValue: 100, weight: 33, assignedTo: null },
- { title: '', targetValue: 100, weight: 33, assignedTo: null }
- ],
- alignedIds: [],
- parentObjectiveId: null
- })
- const weightSum = computed(() => newOkr.krs.reduce((s, k) => s + (k.weight || 0), 0))
- const activePeriods = computed(() => periods.value.filter(p =>
- p.status !== 'ARCHIVED'
- ))
- const period = computed(() => periods.value.find(p => p.id === selectedPeriodId.value))
- const superiorName = computed(() => superiorOkrs.value[0]?.userName || '(上级)')
- // --- KR-level alignment ---
- // Check if a superior KR is aligned (has a corresponding entry in newOkr.krs)
- function isKrAligned(superiorKrId) {
- return newOkr.krs.some(k => k._sourceKrId === superiorKrId)
- }
- // Toggle alignment: add/remove a KR entry referencing the superior's KR
- function toggleAlignKr(superiorKr, superiorO) {
- if (superiorKr.assignedTo === auth.user?.id) return // mandatory, can't untoggle
- const existingIdx = newOkr.krs.findIndex(k => k._sourceKrId === superiorKr.id)
- if (existingIdx >= 0) {
- // Remove the aligned KR entry
- newOkr.krs.splice(existingIdx, 1)
- } else {
- // Add a new KR entry referencing this superior KR
- newOkr.krs.push({
- title: '承接:' + superiorKr.title,
- targetValue: superiorKr.targetValue,
- weight: 0,
- assignedTo: null,
- _sourceKrId: superiorKr.id,
- _sourceInfo: {
- userName: superiorO.userName || superiorName.value,
- krTitle: superiorKr.title
- }
- })
- }
- }
- // Remove a KR — but not if it's mandatory (has @me source)
- function removeKr(i) {
- const kr = newOkr.krs[i]
- if (kr._sourceKrId) {
- // Check if it's mandatory
- for (const o of superiorOkrs.value) {
- const srcKr = o.keyResults?.find(k => k.id === kr._sourceKrId)
- if (srcKr && srcKr.assignedTo === auth.user?.id) return // mandatory, can't remove
- }
- }
- newOkr.krs.splice(i, 1)
- }
- // Count mandatory KRs
- const mandatoryKrCount = computed(() => {
- let count = 0
- for (const o of superiorOkrs.value) {
- count += (o.keyResults || []).filter(k => k.assignedTo === auth.user?.id).length
- }
- return count
- })
- // On dialog open, auto-add mandatory @me KRs if not already present
- function initAlignment() {
- for (const o of superiorOkrs.value) {
- for (const kr of (o.keyResults || [])) {
- if (kr.assignedTo === auth.user?.id) {
- const exists = newOkr.krs.some(k => k._sourceKrId === kr.id)
- if (!exists) {
- newOkr.krs.push({
- title: '承接:' + kr.title,
- targetValue: kr.targetValue,
- weight: 0,
- assignedTo: null,
- _sourceKrId: kr.id,
- _sourceInfo: { userName: o.userName || superiorName.value, krTitle: kr.title }
- })
- }
- }
- }
- }
- }
- onMounted(async () => {
- periods.value = await periodApi.list()
- // Respect periodId from query param first, then default to first active
- const qPeriodId = route.query.periodId ? Number(route.query.periodId) : null
- if (qPeriodId && periods.value.some(p => p.id === qPeriodId)) {
- selectPeriod(qPeriodId)
- } else if (activePeriods.value.length) {
- selectPeriod(activePeriods.value[0].id)
- } else if (periods.value.length) {
- selectPeriod(periods.value[0].id)
- }
- try { subordinates.value = await orgApi.getMySubordinates() } catch { subordinates.value = [] }
- if (auth.user?.position) {
- try { positionTemplate.value = await kpiApi.getTemplateByPosition(auth.user.position) } catch { positionTemplate.value = null }
- }
- })
- function selectPeriod(pid) {
- selectedPeriodId.value = pid
- load()
- }
- function parseKpiItems(json) {
- try { return JSON.parse(json) || [] } catch { return [] }
- }
- async function load() {
- if (!selectedPeriodId.value) return
- const pid = selectedPeriodId.value
- try { superiorOkrs.value = await okrApi.getSuperiorOkrs(pid) } catch { superiorOkrs.value = [] }
- try { myOkr.value = await okrApi.getMyOkr(pid) } catch { myOkr.value = null }
- try { const r = await okrApi.canSubmit(pid); canSubmit.value = r.canSubmit } catch { canSubmit.value = false }
- try { pendingReviews.value = await okrApi.getPendingReviews(pid) } catch { pendingReviews.value = [] }
- loadAlignmentOptions()
- if (myOkr.value) {
- myOkr.value.keyResults?.forEach(kr => { kr._newValue = kr.currentValue; kr._newStatus = kr.status })
- }
- }
- function openCreateForm() {
- editingOkr.value = false
- resetForm()
- initAlignment()
- showForm.value = true
- }
- function startEdit() {
- editingOkr.value = true
- if (myOkr.value) {
- newOkr.title = myOkr.value.title
- // Pre-compute aligned IDs from the OKR's alignedObjectives
- const existingAligned = myOkr.value.alignedObjectives?.map(a => a.id) || []
- // Also add mandatory (assigned-to-me) KR-level alignments from superior OKRs
- const mandatoryIds = new Set(existingAligned)
- if (superiorOkrs.value.length) {
- for (const o of superiorOkrs.value) {
- const hasMandatoryKR = o.keyResults?.some(kr => kr.assignedTo === auth.user?.id)
- if (hasMandatoryKR) mandatoryIds.add(o.id)
- }
- }
- newOkr.alignedIds = [...mandatoryIds]
- newOkr.krs = (myOkr.value.keyResults || []).map(k => ({
- title: k.title || '',
- targetValue: k.targetValue || 100,
- weight: k.weight || Math.floor(100 / (myOkr.value.keyResults?.length || 3)),
- assignedTo: k.assignedTo || null,
- _sourceKrId: k.sourceKrId || null,
- _sourceInfo: k.sourceKrId ? findSourceInfo(k.sourceKrId, k.sourceKrTitle, k.sourceKrUserName) : null
- }))
- }
- initAlignment()
- showForm.value = true
- }
- function findSourceInfo(sourceKrId, sourceKrTitle, sourceKrUserName) {
- // Look up in loaded superior OKRs for full info
- for (const o of superiorOkrs.value) {
- const kr = (o.keyResults || []).find(k => k.id === sourceKrId)
- if (kr) return { userName: o.userName, krTitle: kr.title }
- }
- // Fallback: use stored info from backend
- if (sourceKrTitle) return { userName: sourceKrUserName || '', krTitle: sourceKrTitle }
- return null
- }
- async function handleSubmit() {
- if (!newOkr.title) { ElMessage.warning('请填写目标 O'); return }
- const filledKrs = newOkr.krs.filter(k => k.title)
- if (filledKrs.length < 2) { ElMessage.warning('至少填写2条KR'); return }
- const totalWeight = newOkr.krs.reduce((s, k) => s + (k.weight || 0), 0)
- if (totalWeight !== 100) { ElMessage.warning('KR权重之和必须为100,当前为' + totalWeight); return }
- submitting.value = true
- try {
- const payload = {
- periodId: selectedPeriodId.value,
- title: newOkr.title,
- keyResults: filledKrs.map(k => ({ title: k.title, targetValue: k.targetValue, weight: k.weight, assignedTo: k.assignedTo, sourceKrId: k._sourceKrId || null })),
- alignedObjectiveIds: newOkr.alignedIds,
- parentObjectiveId: newOkr.parentObjectiveId || null
- }
- if (editingOkr.value && myOkr.value) {
- await okrApi.update(myOkr.value.id, payload)
- } else {
- await okrApi.submit(payload)
- }
- ElMessage.success(editingOkr.value ? 'OKR 已更新' : 'OKR 已提交,等待上级审核')
- showForm.value = false; editingOkr.value = false
- resetForm()
- load()
- } finally { submitting.value = false }
- }
- function resetForm() {
- newOkr.title = ''
- newOkr.krs = [
- { title: '', targetValue: 100, weight: 34, assignedTo: null },
- { title: '', targetValue: 100, weight: 33, assignedTo: null },
- { title: '', targetValue: 100, weight: 33, assignedTo: null }
- ]
- newOkr.alignedIds = []
- newOkr.parentObjectiveId = null
- }
- async function updateProgress(kr) {
- await okrApi.updateProgress(kr.id, { newValue: kr._newValue, status: kr._newStatus, remark: '' })
- ElMessage.success('进度已更新'); load()
- }
- // v2 侧边抽屉:KR 详情 / 进度更新
- const drawerVisible = ref(false)
- const drawerKr = ref(null)
- function openKrDrawer(kr) {
- // 复制一份并初始化 updateProgress 机制所需的临时字段
- drawerKr.value = { ...kr, _newValue: kr.currentValue, _newStatus: kr.status }
- drawerVisible.value = true
- }
- async function submitDrawerProgress() {
- if (!drawerKr.value) return
- await updateProgress(drawerKr.value)
- drawerVisible.value = false
- }
- // v2 工具栏状态显示
- const periodStatusLabel = computed(() => {
- const map = { DRAFT: '草稿', OKR_ALIGN: '对齐中', EXECUTING: '执行中', ASSESSING: '考评中', ARCHIVED: '已归档' }
- return map[period.value?.status] || '—'
- })
- const periodStatusDotClass = computed(() => {
- const map = { EXECUTING: 'status-label__dot--active', OKR_ALIGN: 'status-label__dot--warn', ASSESSING: 'status-label__dot--warn', ARCHIVED: 'status-label__dot--info' }
- return map[period.value?.status] || ''
- })
- const daysLeft = computed(() => {
- if (!period.value?.endDate) return null
- const diff = Math.ceil((new Date(period.value.endDate) - new Date()) / 86400000)
- return diff > 0 ? diff : 0
- })
- function krStatusDotClass(kr) {
- if (kr.status === 'BLOCKED') return 'status-dot--warn'
- if (kr.status === 'COMPLETED') return 'status-dot--active'
- return 'status-dot--info'
- }
- async function doReview(id, approved) {
- await okrApi.review(id, { approved, remark: '' })
- ElMessage.success(approved ? '已通过' : '已驳回'); load()
- }
- function krPercent(kr) { if (!kr.targetValue || kr.targetValue === 0) return 0; return Math.min((kr.currentValue / kr.targetValue) * 100, 100) }
- function krStatusLabel(s) { return { NOT_STARTED: '未开始', IN_PROGRESS: '进行中', COMPLETED: '已完成', BLOCKED: '阻塞' }[s] || s }
- </script>
- <style scoped>
- .page { width: 100%; }
- /* v2 状态标签色点变体 */
- .status-label__dot--active { background: var(--color-success); }
- .status-label__dot--warn { background: var(--color-warning); }
- .status-label__dot--info { background: var(--color-text-muted); }
- .status-dot--active { background: var(--color-success); }
- .status-dot--warn { background: var(--color-warning); }
- .status-dot--info { background: var(--color-text-muted); }
- .okr-empty {
- display: flex; align-items: center; justify-content: center;
- height: 240px; color: var(--color-text-muted); font-size: var(--font-size-base);
- }
- /* 状态提示横幅(v2,驳回/等待/锁定提示)*/
- .state-banner {
- padding: 12px 16px; border-radius: var(--radius-sm); margin-bottom: 12px;
- display: flex; align-items: center; gap: 10px; font-size: var(--font-size-sm);
- }
- .banner-EXECUTING { background: var(--color-surface-tint-success); color: var(--color-success); border: 1px solid var(--color-border-soft); }
- .banner-REJECTED { background: var(--color-surface-tint-danger); color: var(--color-danger); border: 1px solid var(--color-border-soft); }
- .banner-waiting { background: var(--color-surface-tint-warning); color: var(--color-warning); border: 1px solid var(--color-border-soft); }
- .banner-top-level { background: var(--color-surface-tint-success); color: var(--color-success); border: 1px solid var(--color-border-soft); }
- /* KR 来源标签(承接上级/指派)*/
- .kr-source-tag {
- display: inline-block; margin-left: 6px; padding: 1px 6px;
- background: var(--color-surface-tint-info); color: var(--color-medical);
- border-radius: 3px; font-size: 10px; font-weight: 500;
- }
- .kr-source-tag--mine {
- background: var(--color-surface-tint-warning); color: var(--color-warning);
- }
- /* Dialog */
- .dialog-rules { background: var(--color-surface-tint-info); border-radius: var(--radius-md); padding: 14px 18px; margin-bottom: 16px; }
- .dr-item { font-size: var(--font-size-sm); color: var(--color-text); line-height: 1.8; }
- /* Alignment section */
- .align-section { background: var(--color-surface); border: 1px solid var(--color-border-soft); border-radius: var(--radius-md); margin-bottom: 20px; overflow: hidden; }
- .align-head { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid var(--color-border-soft); }
- .align-head-left { display: flex; align-items: center; gap: 6px; }
- .align-head-icon { font-size: var(--font-size-xl); font-weight: 700; color: var(--color-brand); }
- .align-head-title { font-size: 15px; font-weight: 700; color: var(--color-text); }
- .align-head-hint { font-size: var(--font-size-sm); color: var(--color-text-muted); }
- .align-o-card { padding: 0; border-bottom: 1px solid var(--color-border-soft); }
- .align-o-card:last-child { border-bottom: none; }
- .align-o-head { display: flex; align-items: center; gap: 10px; padding: 14px 22px 8px; }
- .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; }
- .align-o-title { font-size: var(--font-size-base); font-weight: 600; color: var(--color-text); }
- .align-kr-list { padding: 0 22px 14px; display: flex; flex-direction: column; gap: 6px; }
- .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; }
- .align-kr-row:hover { background: var(--color-surface-subtle); }
- .align-kr-row.is-selected { background: var(--color-surface-tint-brand); border-color: var(--color-border); }
- .align-kr-row.is-mandatory { background: var(--color-surface-tint-warning); border-color: var(--color-border-soft); cursor: default; }
- .align-kr-left { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
- .align-kr-num { font-size: 11px; font-weight: 700; color: var(--color-text-muted); width: 30px; flex-shrink: 0; letter-spacing: 0; }
- .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; }
- .align-checkbox.checked { background: var(--color-brand); border-color: var(--color-brand); color: var(--color-white); }
- .align-checkbox.locked { background: var(--color-surface-tint-warning); border-color: var(--color-warning); color: var(--color-warning); cursor: default; }
- .align-kr-title { font-size: var(--font-size-sm); color: var(--color-text); }
- .align-kr-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
- .align-kr-target { font-size: var(--font-size-xs); color: var(--color-text-muted); }
- .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; }
- .align-footer strong { color: var(--color-brand); }
- .parent-align-section { margin-bottom: 20px; }
- .parent-align-label { font-weight: 600; font-size: 15px; color: var(--color-text); margin-bottom: 8px; }
- .o-section { margin-bottom: 20px; }
- .o-label { font-weight: 600; font-size: 15px; color: var(--color-text); margin-bottom: 8px; }
- .o-hint { font-size: var(--font-size-xs); color: var(--color-text-muted); font-weight: 400; }
- .kr-section { margin-bottom: 16px; }
- .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; }
- .weight-sum { font-size: var(--font-size-sm); padding: 2px 10px; border-radius: var(--radius-md); }
- .weight-ok { background: var(--color-surface-tint-success); color: var(--color-success); font-weight: 700; }
- .weight-bad { background: var(--color-surface-tint-danger); color: var(--color-danger); font-weight: 700; }
- .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); }
- .kr-form-card.has-source { border-left: 3px solid var(--color-brand); }
- .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); }
- .kr-source-icon { color: var(--color-brand); font-weight: 700; }
- .kr-source-text { color: var(--color-text-secondary); }
- .kr-form-head { display: flex; align-items: flex-start; gap: 10px; }
- .kr-form-head .el-textarea { flex: 1; }
- .kr-form-num { font-size: var(--font-size-sm); font-weight: 700; color: var(--color-accent); width: 36px; }
- .kr-form-right { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
- .kr-form-label-sm { font-size: 11px; color: var(--color-text-muted); }
- .kr-form-extra { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
- .kr-form-label { font-size: var(--font-size-xs); color: var(--color-text-muted); }
- .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; }
- .dkr-title { font-size: var(--font-size-sm); font-weight: 600; color: var(--color-brand); margin-bottom: 6px; }
- .dkr-tags { display: flex; flex-wrap: wrap; gap: 4px; }
- .dkr-hint { font-size: 11px; color: var(--color-text-muted); margin-top: 6px; }
- .dialog-footer-hint { text-align: center; font-size: var(--font-size-xs); color: var(--color-text-muted); margin-top: 12px; }
- </style>
|