feat: OKR看板重设计 + 周切换 + 恢复选日期 + 项目列表优化

- OKR看板:按项目分组大卡片 + 目标/任务折叠展开 + 统计栏 + 按项目筛选
- KR状态文本标签(已取消/已暂停/已延期)替代emoji
- 本周关键结果支持前后周切换(‹ 本周 ›)+ 日期范围显示
- 恢复暂停任务时必须选择新截止日期
- 项目列表仓库名只显示短名不显示完整URL
- 项目详情标题合并到进度条卡片内

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
zyc 2026-04-10 15:07:37 +08:00
parent f88e2d9ab0
commit 58fe2b1ea8
10 changed files with 376 additions and 322 deletions

View File

@ -104,12 +104,15 @@ okrRoutes.post('/okr/key-results/:id/pause',
); );
// POST /api/okr/key-results/:id/resume — 恢复 // POST /api/okr/key-results/:id/resume — 恢复
const resumeSchema = z.object({ newEndDate: z.string().min(1) });
okrRoutes.post('/okr/key-results/:id/resume', okrRoutes.post('/okr/key-results/:id/resume',
requireRole('admin', 'manager', 'developer'), requireRole('admin', 'manager', 'developer'),
zValidator('json', resumeSchema),
async (c) => { async (c) => {
const krId = c.req.param('id'); const krId = c.req.param('id');
const { newEndDate } = c.req.valid('json');
const user = c.get('user'); const user = c.get('user');
const result = await okrService.resumeKR(krId, user.sub, user.displayName); const result = await okrService.resumeKR(krId, newEndDate, user.sub, user.displayName);
return c.json({ code: 0, data: result, message: 'success' }); return c.json({ code: 0, data: result, message: 'success' });
} }
); );

View File

@ -153,9 +153,10 @@ overviewRoutes.get('/overview', async (c) => {
}); });
} }
// 6. 本周待完成 KR截止日期在本周范围内且未完成的 // 6. 指定周的 KR支持 weekOffset 参数0=本周1=下周,-1=上周)
const weekStart = dayjs().startOf('week'); const weekOffset = parseInt(c.req.query('weekOffset') || '0');
const weekEnd = dayjs().endOf('week'); const weekStart = dayjs().startOf('week').add(weekOffset, 'week');
const weekEnd = dayjs().endOf('week').add(weekOffset, 'week');
const allKRsRaw = await db.select().from(keyResults); const allKRsRaw = await db.select().from(keyResults);
const allObjsMap = new Map((await db.select().from(objectives)).map(o => [o.id, o])); const allObjsMap = new Map((await db.select().from(objectives)).map(o => [o.id, o]));
@ -296,7 +297,11 @@ overviewRoutes.get('/overview', async (c) => {
weeklyCodeActivity, weeklyCodeActivity,
okrProgress, okrProgress,
urgentKRs, urgentKRs,
weeklyKRStats: { total: weeklyTotal, completed: weeklyCompleted, avgProgress: weeklyAvgProgress }, weeklyKRStats: {
total: weeklyTotal, completed: weeklyCompleted, avgProgress: weeklyAvgProgress,
weekOffset,
weekLabel: weekStart.format('MM/DD') + ' ~ ' + weekEnd.format('MM/DD'),
},
overdueKRs: overdueList, overdueKRs: overdueList,
recentCommits: await getRecentCommits(), recentCommits: await getRecentCommits(),
}, },

View File

@ -282,6 +282,15 @@ projectRoutes.get('/projects/:id', async (c) => {
milestones: milestoneData, milestones: milestoneData,
taskMatrix, taskMatrix,
gitActivity, gitActivity,
recentCommitList: recentCommits
.sort((a, b) => dayjs(b.committedAt).valueOf() - dayjs(a.committedAt).valueOf())
.slice(0, 15)
.map(c => ({
sha: c.sha?.slice(0, 7) || '',
message: (c.message || '').split('\n')[0].slice(0, 60),
authorName: c.userId ? (userMap.get(c.userId) || c.authorName) : c.authorName,
committedAt: c.committedAt instanceof Date ? c.committedAt.toISOString() : c.committedAt,
})),
okr: { okr: {
objectives: okrData, objectives: okrData,
overallProgress: avgOKRProgress, overallProgress: avgOKRProgress,

View File

@ -301,16 +301,18 @@ export async function pauseKR(krId: string, reason: string, operatorId: string,
// ── 恢复 ── // ── 恢复 ──
export async function resumeKR(krId: string, operatorId: string, operatorName: string) { export async function resumeKR(krId: string, newEndDate: string, operatorId: string, operatorName: string) {
const kr = await db.query.keyResults.findFirst({ where: eq(keyResults.id, krId) }); const kr = await db.query.keyResults.findFirst({ where: eq(keyResults.id, krId) });
if (!kr) throw new AppError(40404, 'Key result not found', 404); if (!kr) throw new AppError(40404, 'Key result not found', 404);
await db.update(keyResults).set({ status: 'active', updatedAt: new Date() }) const oldEndDate = kr.endDate || '未设置';
await db.update(keyResults).set({ status: 'active', endDate: newEndDate, updatedAt: new Date() })
.where(eq(keyResults.id, krId)); .where(eq(keyResults.id, krId));
await addKRLog(krId, 'resumed', '恢复为进行中', operatorId, operatorName); await addKRLog(krId, 'resumed', `恢复为进行中,截止日期 ${oldEndDate}${newEndDate}`, operatorId, operatorName);
await recalcObjectiveProgress(kr.objectiveId); await recalcObjectiveProgress(kr.objectiveId);
return { id: krId, status: 'active' }; await recalcObjectiveDates(kr.objectiveId);
return { id: krId, status: 'active', endDate: newEndDate };
} }
// ── 取消 ── // ── 取消 ──

View File

@ -32,8 +32,8 @@ export function pauseKRApi(krId: string, data: { reason: string }) {
return request.post(`/api/okr/key-results/${krId}/pause`, data); return request.post(`/api/okr/key-results/${krId}/pause`, data);
} }
export function resumeKRApi(krId: string) { export function resumeKRApi(krId: string, data: { newEndDate: string }) {
return request.post(`/api/okr/key-results/${krId}/resume`); return request.post(`/api/okr/key-results/${krId}/resume`, data);
} }
export function cancelKRApi(krId: string, data: { reason: string }) { export function cancelKRApi(krId: string, data: { reason: string }) {

View File

@ -53,7 +53,7 @@ const weeklyOptions = computed(() => {
const found = (w.byUser || []).find((u: any) => u.userId === userId); const found = (w.byUser || []).find((u: any) => u.userId === userId);
return found?.commits || 0; return found?.commits || 0;
}), }),
itemStyle: { color: CHART_COLORS[i % CHART_COLORS.length], borderRadius: i === userSet.size - 1 ? [3, 3, 0, 0] : [0, 0, 0, 0] }, itemStyle: { color: ['#3B5998', '#0D9668', '#D4920A', '#7C4DBA', '#DC2626', '#06B6D4', '#8B5CF6', '#EC4899'][i % 8], borderRadius: i === userSet.size - 1 ? [3, 3, 0, 0] : [0, 0, 0, 0] },
})); }));
return { return {

View File

@ -1,318 +1,272 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { NSpin, NButton, NInputNumber, NModal, NForm, NFormItem, NInput, NSelect, useMessage } from 'naive-ui'; import { useRouter } from 'vue-router';
import { getOKRApi, updateKeyResultApi, createObjectiveApi, createKeyResultApi, deleteObjectiveApi, deleteKeyResultApi } from '@/api/okr'; import { NSpin, NTag, NSelect } from 'naive-ui';
import { getAdminUsersApi, getAdminProjectsApi } from '@/api/admin'; import { getOKRApi } from '@/api/okr';
import { useAuthStore } from '@/stores/auth'; import { getAdminProjectsApi } from '@/api/admin';
import FilterBar from '@/components/shared/FilterBar.vue';
import EmptyState from '@/components/shared/EmptyState.vue'; import EmptyState from '@/components/shared/EmptyState.vue';
const authStore = useAuthStore(); const router = useRouter();
const message = useMessage();
const loading = ref(true); const loading = ref(true);
const okrData = ref<any>(null); const okrData = ref<any>(null);
const currentPeriod = ref<string>('');
//
const allUsers = ref<any[]>([]);
const allProjects = ref<any[]>([]); const allProjects = ref<any[]>([]);
const selectedProject = ref<string | null>(null);
const expandedCards = ref<Set<string>>(new Set());
const expandedObjs = ref<Set<string>>(new Set());
const userOptions = computed(() => allUsers.value.map(u => ({ value: u.id, label: u.displayName }))); function toggleCard(name: string) {
const projectOptions = computed(() => allProjects.value.map(p => ({ value: p.id, label: `${p.identifier} - ${p.name}` }))); expandedCards.value.has(name) ? expandedCards.value.delete(name) : expandedCards.value.add(name);
}
function toggleObj(id: string) {
expandedObjs.value.has(id) ? expandedObjs.value.delete(id) : expandedObjs.value.add(id);
}
function isCardExpanded(name: string) { return expandedCards.value.has(name); }
function isObjExpanded(id: string) { return expandedObjs.value.has(id); }
// //
const periodOptions = computed(() => { function expandAll() {
const now = new Date(); for (const card of projectCards.value) {
const year = now.getFullYear(); expandedCards.value.add(card.projectName);
const q = Math.ceil((now.getMonth() + 1) / 3); for (const obj of card.objectives) expandedObjs.value.add(obj.id);
const opts = [];
for (let y = year; y >= year - 1; y--) {
for (let qi = 4; qi >= 1; qi--) {
opts.push({ value: `${y}-Q${qi}`, label: `${y} 年 Q${qi}` });
} }
} }
return opts; function collapseAll() {
}); expandedCards.value.clear();
expandedObjs.value.clear();
}
async function loadData(filters?: { period?: string }) { async function loadData() {
loading.value = true; loading.value = true;
try { try {
const res = await getOKRApi({ period: filters?.period || currentPeriod.value || undefined }); const [okrRes, projRes] = await Promise.all([
okrData.value = res.data.data; getOKRApi(),
getAdminProjectsApi(),
]);
okrData.value = okrRes.data.data;
allProjects.value = projRes.data.data || [];
} catch (err) { } catch (err) {
console.error('Failed to load OKR:', err); console.error('Failed to load OKR:', err);
} finally { } finally {
loading.value = false; loading.value = false;
//
setTimeout(expandAll, 0);
} }
} }
async function loadMeta() { onMounted(loadData);
try {
const [usersRes, projectsRes] = await Promise.all([
getAdminUsersApi(),
getAdminProjectsApi(),
]);
allUsers.value = usersRes.data.data || [];
allProjects.value = projectsRes.data.data || [];
} catch { /* ignore */ }
}
onMounted(() => { const projectFilterOptions = computed(() => [
loadData(); { value: null, label: '全部项目' },
loadMeta(); ...allProjects.value.map(p => ({ value: p.id, label: `${p.identifier} - ${p.name}` })),
]);
//
const projectCards = computed(() => {
const objs = okrData.value?.objectives || [];
const map = new Map<string, { projectId: string; projectName: string; objectives: any[]; totalProgress: number }>();
for (const obj of objs) {
const pid = obj.projectName || '未关联项目';
if (!map.has(pid)) {
map.set(pid, { projectId: '', projectName: pid, objectives: [], totalProgress: 0 });
}
map.get(pid)!.objectives.push(obj);
}
// ID
for (const [, card] of map) {
const proj = allProjects.value.find(p => p.name === card.projectName);
if (proj) card.projectId = proj.id;
card.totalProgress = card.objectives.length > 0
? Math.round(card.objectives.reduce((s: number, o: any) => s + (o.progress || 0), 0) / card.objectives.length)
: 0;
}
let result = Array.from(map.values());
//
if (selectedProject.value) {
const projName = allProjects.value.find(p => p.id === selectedProject.value)?.name;
if (projName) result = result.filter(c => c.projectName === projName);
}
return result;
}); });
function handleFilterChange(filters: { period?: string }) { //
currentPeriod.value = filters.period || ''; const stats = computed(() => {
loadData(filters); const cards = projectCards.value;
const totalObj = cards.reduce((s, c) => s + c.objectives.length, 0);
const allKRs = cards.flatMap(c => c.objectives.flatMap((o: any) => o.keyResults || []));
const totalKR = allKRs.length;
const completedKR = allKRs.filter((kr: any) => kr.currentValue >= kr.targetValue).length;
const avgProgress = totalObj > 0
? Math.round(cards.reduce((s, c) => s + c.totalProgress * c.objectives.length, 0) / totalObj)
: 0;
return { totalObj, totalKR, completedKR, avgProgress, projectCount: cards.length };
});
function statusColor(p: number) {
return p >= 70 ? '#0D9668' : p >= 40 ? 'var(--color-primary-hex)' : '#DC2626';
} }
// function krTag(kr: any) {
const showCreateObj = ref(false); if (kr.status === 'cancelled') return { label: '已取消', type: 'error' as const };
const newObj = ref({ title: '', ownerId: '', projectId: '', period: '' }); if (kr.status === 'paused') return { label: '已暂停', type: 'warning' as const };
if (kr.wasPostponed) return { label: '已延期', type: 'info' as const };
async function handleCreateObjective() { if (kr.currentValue >= kr.targetValue) return { label: '已完成', type: 'success' as const };
if (!newObj.value.title || !newObj.value.ownerId || !newObj.value.projectId || !newObj.value.period) { return null;
message.warning('请填写完整的目标信息');
return;
}
try {
await createObjectiveApi(newObj.value);
message.success('目标创建成功');
showCreateObj.value = false;
newObj.value = { title: '', ownerId: '', projectId: '', period: '' };
loadData();
} catch (err: any) {
message.error(err.response?.data?.message || '创建目标失败');
}
}
//
const showCreateKR = ref(false);
const currentObjId = ref('');
const currentObjTitle = ref('');
const newKR = ref({ title: '', targetValue: 100, unit: '%', weight: 1 });
function openAddKR(objId: string, objTitle: string) {
currentObjId.value = objId;
currentObjTitle.value = objTitle;
newKR.value = { title: '', targetValue: 100, unit: '%', weight: 1 };
showCreateKR.value = true;
}
async function handleCreateKR() {
if (!newKR.value.title || !newKR.value.targetValue) {
message.warning('请填写关键结果标题和目标值');
return;
}
try {
await createKeyResultApi(currentObjId.value, newKR.value);
message.success('关键结果添加成功');
showCreateKR.value = false;
loadData();
} catch (err: any) {
message.error(err.response?.data?.message || '添加关键结果失败');
}
}
// /
async function handleUpdateKR(krId: string, newValue: number) {
try {
await updateKeyResultApi(krId, { currentValue: newValue });
message.success('进度已更新');
loadData();
} catch {
message.error('更新失败');
}
}
async function handleDeleteObjective(id: string) {
if (!confirm('确定删除该目标及其所有关键结果?')) return;
try {
await deleteObjectiveApi(id);
message.success('目标已删除');
loadData();
} catch {
message.error('删除失败');
}
}
async function handleDeleteKR(id: string) {
try {
await deleteKeyResultApi(id);
message.success('关键结果已删除');
loadData();
} catch {
message.error('删除失败');
}
}
function clampProgress(value: number): number {
return Math.min(Math.max(value, 0), 100);
} }
</script> </script>
<template> <template>
<div class="okr-page"> <div class="okr-page">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-4)"> <!-- 顶部统计 + 筛选 -->
<FilterBar <div class="top-bar">
:show-project-filter="false" <div class="stats" v-if="!loading">
:show-period-filter="true" <span class="stat"><b class="tabular-nums">{{ stats.projectCount }}</b> 个项目</span>
@filter-change="handleFilterChange" <span class="stat-dot">·</span>
style="flex:1" <span class="stat"><b class="tabular-nums">{{ stats.totalObj }}</b> 个目标</span>
<span class="stat-dot">·</span>
<span class="stat"><b class="tabular-nums">{{ stats.totalKR }}</b> 个任务</span>
<span class="stat-dot">·</span>
<span class="stat">已完成 <b class="tabular-nums" style="color:#0D9668">{{ stats.completedKR }}</b></span>
<span class="stat-dot">·</span>
<span class="stat">进度 <b class="tabular-nums">{{ stats.avgProgress }}%</b></span>
</div>
<div style="display:flex;align-items:center;gap:8px">
<span class="toggle-all" @click="expandAll">全部展开</span>
<span class="toggle-all" @click="collapseAll">全部折叠</span>
<NSelect
v-model:value="selectedProject"
:options="projectFilterOptions"
size="small"
style="width:200px"
placeholder="筛选项目"
clearable
/> />
<NButton v-if="authStore.isManagerOrAbove" type="primary" @click="showCreateObj = true" style="margin-left:var(--space-3)"> </div>
创建目标
</NButton>
</div> </div>
<NSpin :show="loading"> <NSpin :show="loading">
<div v-if="okrData?.objectives?.length" class="okr-list"> <div v-if="projectCards.length" class="card-list">
<div v-for="obj in okrData.objectives" :key="obj.id" class="objective-card"> <!-- 每个项目一个大卡片 -->
<div class="obj-header"> <div v-for="card in projectCards" :key="card.projectName" class="project-card">
<!-- 项目头部点击折叠 -->
<div class="pc-header" @click="toggleCard(card.projectName)">
<div class="pc-title-row">
<div style="display:flex;align-items:center;gap:8px">
<span class="arrow" :class="{ 'arrow-open': isCardExpanded(card.projectName) }"></span>
<h3 class="pc-title">{{ card.projectName }}</h3>
<span class="pc-count">{{ card.objectives.length }} 个目标</span>
</div>
<div class="pc-pct" :style="{ color: statusColor(card.totalProgress) }">
<span class="tabular-nums" style="font-size:22px;font-weight:700">{{ card.totalProgress }}</span><span style="font-size:11px">%</span>
</div>
</div>
<div class="pc-bar">
<div class="pc-bar-fill" :style="{ width: Math.min(card.totalProgress, 100) + '%', background: statusColor(card.totalProgress) }" />
</div>
</div>
<!-- 目标列表可折叠 -->
<div class="obj-list" v-show="isCardExpanded(card.projectName)">
<div v-for="obj in card.objectives" :key="obj.id" class="obj-section">
<div class="obj-row" @click="toggleObj(obj.id)" style="cursor:pointer">
<div class="obj-info"> <div class="obj-info">
<h3 class="obj-title">{{ obj.title }}</h3> <span class="arrow-sm" :class="{ 'arrow-open': isObjExpanded(obj.id) }"></span>
<span class="obj-meta">{{ obj.ownerName }} · {{ obj.projectName }} · {{ obj.period }}</span> <span class="obj-name">{{ obj.title }}</span>
</div> <span class="obj-kr-count">{{ obj.keyResults?.length || 0 }} </span>
<div class="obj-progress">
<span class="progress-value tabular-nums">{{ Math.round(obj.progress) }}%</span>
<div class="progress-bar-bg" style="width: 120px">
<div
class="progress-bar-fill"
:style="{
width: clampProgress(obj.progress) + '%',
background: obj.progress >= 70 ? 'var(--color-success)' : 'var(--color-primary-hex)',
}"
/>
</div>
</div>
<div v-if="authStore.isManagerOrAbove" style="display:flex;gap:4px">
<NButton size="tiny" type="info" quaternary @click="openAddKR(obj.id, obj.title)">+ 关键结果</NButton>
<NButton v-if="authStore.isAdmin" size="tiny" type="error" quaternary @click="handleDeleteObjective(obj.id)">删除</NButton>
</div> </div>
<span class="obj-meta" style="margin-right:8px">{{ obj.ownerName }} · {{ obj.startDate || '' }} ~ {{ obj.endDate || '' }}</span>
<span class="obj-pct tabular-nums" :style="{ color: statusColor(obj.progress) }">{{ Math.round(obj.progress) }}%</span>
</div> </div>
<div class="kr-list"> <!-- KR 列表可折叠 -->
<div v-for="kr in obj.keyResults" :key="kr.id" class="kr-item"> <div class="kr-rows" v-show="isObjExpanded(obj.id)">
<span class="kr-title">{{ kr.title }}</span> <div v-for="kr in obj.keyResults" :key="kr.id" class="kr-row" :class="{ 'kr-inactive': kr.status === 'cancelled' || kr.status === 'paused' }">
<div class="kr-progress"> <span class="kr-name" :class="{ 'kr-strike': kr.status === 'cancelled' }">
<div class="progress-bar-bg" style="flex: 1"> {{ kr.title }}
<div <span v-if="kr.status === 'cancelled'" class="kr-status-text status-cancelled">已取消</span>
class="progress-bar-fill" <span v-else-if="kr.status === 'paused'" class="kr-status-text status-paused">已暂停</span>
:style="{ <span v-else-if="kr.wasPostponed" class="kr-status-text status-postponed">已延期</span>
width: clampProgress(kr.progress) + '%',
background: kr.progress > 100 ? 'var(--color-success)' : 'var(--color-primary-hex)',
}"
/>
</div>
<span class="kr-values tabular-nums">
<template v-if="authStore.isManagerOrAbove">
<NInputNumber
:value="kr.currentValue"
@update:value="(v: number | null) => handleUpdateKR(kr.id, v || 0)"
:min="0"
size="tiny"
style="width: 80px"
/>
</template>
<template v-else>{{ kr.currentValue }}</template>
/ {{ kr.targetValue }} {{ kr.unit }}
</span> </span>
<span class="kr-pct tabular-nums" :class="{ 'kr-pct-exceeded': kr.progress > 100 }">{{ kr.progress }}%</span> <div class="kr-right">
<div class="kr-mini-bar">
<div class="kr-mini-fill" :style="{ width: Math.min(kr.progress || kr.currentValue, 100) + '%', background: kr.status === 'cancelled' ? '#9CA3AF' : kr.currentValue >= kr.targetValue ? '#0D9668' : kr.status === 'paused' ? '#9CA3AF' : 'var(--color-primary-hex)' }" />
</div> </div>
<NButton v-if="authStore.isAdmin" size="tiny" type="error" quaternary @click="handleDeleteKR(kr.id)">X</NButton> <span class="kr-pct tabular-nums">{{ kr.progress || kr.currentValue }}%</span>
</div>
<div v-if="!obj.keyResults?.length" class="kr-empty">
暂无关键结果
<a v-if="authStore.isManagerOrAbove" href="#" @click.prevent="openAddKR(obj.id, obj.title)" style="color:var(--color-primary-hex)">点击添加</a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<EmptyState v-else-if="!loading" title="暂无 OKR" description="点击「创建目标」开始制定你的 OKR。" /> </div>
</div>
</div>
<EmptyState v-else-if="!loading" title="暂无 OKR" description="在项目明细页创建目标和任务。" />
</NSpin> </NSpin>
<!-- 创建目标弹窗 -->
<NModal v-model:show="showCreateObj" title="创建目标 (Objective)" preset="dialog" positive-text="创建" @positive-click="handleCreateObjective">
<NForm>
<NFormItem label="目标标题">
<NInput v-model:value="newObj.title" placeholder="例如Q2 完成 Avatar 2.0 核心功能" />
</NFormItem>
<NFormItem label="负责人">
<NSelect v-model:value="newObj.ownerId" :options="userOptions" placeholder="选择负责人" filterable />
</NFormItem>
<NFormItem label="关联项目">
<NSelect v-model:value="newObj.projectId" :options="projectOptions" placeholder="选择项目" filterable />
</NFormItem>
<NFormItem label="所属周期">
<NSelect v-model:value="newObj.period" :options="periodOptions" placeholder="选择季度" />
</NFormItem>
</NForm>
</NModal>
<!-- 添加关键结果弹窗 -->
<NModal v-model:show="showCreateKR" :title="`添加关键结果 → ${currentObjTitle}`" preset="dialog" positive-text="添加" @positive-click="handleCreateKR">
<NForm>
<NFormItem label="关键结果">
<NInput v-model:value="newKR.title" placeholder="例如Sprint 交付率达到 85%" />
</NFormItem>
<NFormItem label="目标值">
<NInputNumber v-model:value="newKR.targetValue" :min="0" style="width:100%" placeholder="例如85" />
</NFormItem>
<NFormItem label="单位">
<NSelect v-model:value="newKR.unit" :options="[
{ value: '%', label: '百分比 (%)' },
{ value: '个', label: '个' },
{ value: '天', label: '天' },
{ value: '小时', label: '小时' },
{ value: 'Bug/点', label: 'Bug/点' },
]" />
</NFormItem>
<NFormItem label="权重">
<NInputNumber v-model:value="newKR.weight" :min="0.1" :max="10" :step="0.5" style="width:100%" />
</NFormItem>
</NForm>
<div style="color: var(--color-text-tertiary); font-size: 12px; margin-top: 8px;">
权重用于计算目标总进度加权平均权重越大该关键结果对目标进度的影响越大
</div>
</NModal>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.okr-list { display: flex; flex-direction: column; gap: var(--space-4); } /* 顶部 */
.top-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-4); flex-wrap: wrap; gap: var(--space-3); }
.stats { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--color-text-secondary); }
.stats b { color: var(--color-text-primary); }
.stat-dot { color: var(--color-border); }
.toggle-all { font-size: 12px; color: var(--color-text-muted); cursor: pointer; }
.toggle-all:hover { color: var(--color-primary-hex); }
.objective-card { /* 卡片列表 */
background: var(--color-bg-card); .card-list { display: flex; flex-direction: column; gap: var(--space-4); }
border: 1px solid var(--color-border);
border-radius: var(--radius-card); /* 项目卡片 */
padding: var(--space-5); .project-card {
background: var(--color-bg-card); border: 1px solid var(--color-border);
border-radius: var(--radius-card); overflow: hidden;
} }
.obj-header { .pc-header { padding: var(--space-4) var(--space-5) var(--space-3); cursor: pointer; user-select: none; }
display: flex; .pc-header:hover { background: rgba(0,0,0,0.01); }
align-items: center; .pc-count { font-size: 12px; color: var(--color-text-muted); }
gap: var(--space-4);
margin-bottom: var(--space-4); .arrow { font-size: 10px; color: var(--color-text-muted); transition: transform 0.2s; display: inline-block; }
.arrow.arrow-open { transform: rotate(90deg); }
.arrow-sm { font-size: 8px; color: var(--color-text-muted); transition: transform 0.2s; display: inline-block; margin-right: 4px; }
.arrow-sm.arrow-open { transform: rotate(90deg); }
.pc-title-row { display: flex; justify-content: space-between; align-items: center; }
.pc-title { font-size: 16px; font-weight: 700; margin: 0; }
.pc-bar { height: 4px; background: #F0F0F0; border-radius: 2px; overflow: hidden; margin-top: 8px; }
.pc-bar-fill { height: 100%; border-radius: 2px; transition: width 0.4s; }
/* 目标列表 */
.obj-list { padding: 0 var(--space-5) var(--space-4); }
.obj-section { padding: var(--space-3) 0; border-top: 1px solid var(--color-border); }
.obj-section:first-child { border-top: none; }
.obj-row { display: flex; align-items: center; margin-bottom: var(--space-2); }
.obj-info { flex: 1; min-width: 0; display: flex; align-items: center; gap: 4px; }
.obj-name { font-size: 14px; font-weight: 600; }
.obj-kr-count { font-size: 11px; color: var(--color-text-muted); }
.obj-meta { font-size: 11px; color: var(--color-text-muted); }
.obj-pct { font-size: 15px; font-weight: 700; flex-shrink: 0; margin-left: var(--space-3); }
/* KR 行 */
.kr-rows { display: flex; flex-direction: column; gap: 4px; padding-left: var(--space-3); }
.kr-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; }
.kr-row.kr-inactive { opacity: 0.5; }
.kr-name { flex: 1; font-size: 12px; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.kr-strike { text-decoration: line-through; color: var(--color-text-muted); }
.kr-status-text { font-size: 10px; margin-left: 4px; padding: 0 4px; border-radius: 3px; font-weight: 600; vertical-align: middle; }
.status-cancelled { color: #DC2626; background: rgba(220,38,38,0.08); }
.status-paused { color: #B47D08; background: rgba(212,146,10,0.08); }
.status-postponed { color: var(--color-primary-hex); background: rgba(59,89,152,0.08); }
.kr-right { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.kr-mini-bar { width: 60px; height: 3px; background: #E5E7EB; border-radius: 2px; overflow: hidden; }
.kr-mini-fill { height: 100%; border-radius: 2px; }
.kr-pct { font-size: 11px; font-weight: 600; color: var(--color-text-secondary); width: 32px; text-align: right; }
@media (max-width: 768px) {
.top-bar { flex-direction: column; align-items: flex-start; }
.stats { flex-wrap: wrap; }
} }
.obj-info { flex: 1; }
.obj-title { font-size: 16px; font-weight: 700; margin: 0 0 4px; }
.obj-meta { font-size: 12px; color: var(--color-text-secondary); }
.obj-progress { display: flex; align-items: center; gap: var(--space-2); }
.progress-value { font-size: 18px; font-weight: 700; color: var(--color-primary-hex); }
.kr-list { display: flex; flex-direction: column; gap: var(--space-3); padding-left: var(--space-4); border-left: 2px solid var(--color-border); }
.kr-item { display: flex; align-items: center; gap: var(--space-3); }
.kr-title { width: 200px; font-size: 13px; flex-shrink: 0; }
.kr-progress { flex: 1; display: flex; align-items: center; gap: var(--space-2); }
.kr-values { font-size: 12px; color: var(--color-text-secondary); white-space: nowrap; }
.kr-pct { width: 48px; text-align: right; font-size: 13px; font-weight: 600; }
.kr-pct-exceeded { color: var(--color-success); }
.kr-empty { font-size: 13px; color: var(--color-text-tertiary); padding: var(--space-2) 0; }
.progress-bar-bg { height: 6px; background: #F0F0F0; border-radius: 3px; overflow: hidden; }
.progress-bar-fill { height: 100%; background: var(--color-primary-hex); border-radius: 3px; transition: width 0.3s; }
</style> </style>

View File

@ -13,11 +13,24 @@ const router = useRouter();
const loading = ref(true); const loading = ref(true);
const overviewData = ref<any>(null); const overviewData = ref<any>(null);
const projectOptions = ref<Array<{ value: string; label: string }>>([]); const projectOptions = ref<Array<{ value: string; label: string }>>([]);
const weekOffset = ref(0);
function weekLabel(offset: number) {
if (offset === 0) return '本周';
if (offset === 1) return '下周';
if (offset === -1) return '上周';
return offset > 0 ? `${offset}周后` : `${-offset}周前`;
}
async function changeWeek(delta: number) {
weekOffset.value += delta;
await loadData();
}
async function loadData(filters?: { period?: string; projectIds?: string[] }) { async function loadData(filters?: { period?: string; projectIds?: string[] }) {
loading.value = true; loading.value = true;
try { try {
const params: any = {}; const params: any = { weekOffset: weekOffset.value };
if (filters?.period) params.period = filters.period; if (filters?.period) params.period = filters.period;
if (filters?.projectIds) params.projectIds = filters.projectIds.join(','); if (filters?.projectIds) params.projectIds = filters.projectIds.join(',');
@ -132,14 +145,22 @@ function formatCommitTime(isoStr: string) {
<!-- Panel 3: 本周关键结果 --> <!-- Panel 3: 本周关键结果 -->
<DataCard> <DataCard>
<template #header> <template #header>
<div style="display:flex;justify-content:space-between;align-items:center;width:100%">
<div> <div>
<div style="font-weight:600;font-size:15px">本周关键结果</div> <div style="display:flex;align-items:center;gap:8px">
<span class="week-nav" @click="changeWeek(-1)"></span>
<span style="font-weight:600;font-size:15px">{{ weekLabel(weekOffset) }}关键结果</span>
<span class="week-nav" @click="changeWeek(1)"></span>
</div>
<div style="font-size:12px;color:var(--color-text-secondary);margin-top:2px" v-if="overviewData.weeklyKRStats"> <div style="font-size:12px;color:var(--color-text-secondary);margin-top:2px" v-if="overviewData.weeklyKRStats">
{{ overviewData.weeklyKRStats.weekLabel }} ·
{{ overviewData.weeklyKRStats.total }} · {{ overviewData.weeklyKRStats.total }} ·
已完成 <span style="color:#0D9668;font-weight:600">{{ overviewData.weeklyKRStats.completed }}</span> · 已完成 <span style="color:#0D9668;font-weight:600">{{ overviewData.weeklyKRStats.completed }}</span> ·
整体进度 <span style="font-weight:600">{{ overviewData.weeklyKRStats.avgProgress }}%</span> 进度 <span style="font-weight:600">{{ overviewData.weeklyKRStats.avgProgress }}%</span>
</div> </div>
</div> </div>
<span v-if="weekOffset !== 0" class="week-reset" @click="weekOffset = 0; loadData()">回到本周</span>
</div>
</template> </template>
<div class="urgent-kr-list"> <div class="urgent-kr-list">
<div v-for="kr in overviewData.urgentKRs" :key="kr.id" class="urgent-kr-item" :class="'kr-st-' + kr.displayStatus"> <div v-for="kr in overviewData.urgentKRs" :key="kr.id" class="urgent-kr-item" :class="'kr-st-' + kr.displayStatus">
@ -238,6 +259,11 @@ function formatCommitTime(isoStr: string) {
.badge-paused { background: rgba(212,146,10,0.08); color: #B47D08; } .badge-paused { background: rgba(212,146,10,0.08); color: #B47D08; }
.badge-active { background: rgba(59,89,152,0.08); color: var(--color-primary-hex); } .badge-active { background: rgba(59,89,152,0.08); color: var(--color-primary-hex); }
.week-nav { font-size: 18px; font-weight: 700; color: var(--color-text-muted); cursor: pointer; padding: 0 4px; user-select: none; line-height: 1; }
.week-nav:hover { color: var(--color-primary-hex); }
.week-reset { font-size: 12px; color: var(--color-primary-hex); cursor: pointer; }
.week-reset:hover { text-decoration: underline; }
.urgent-kr-row { display: flex; align-items: center; gap: 12px; } .urgent-kr-row { display: flex; align-items: center; gap: 12px; }
.urgent-kr-left { flex: 1; min-width: 0; } .urgent-kr-left { flex: 1; min-width: 0; }
.urgent-kr-title { font-size: 13px; font-weight: 500; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .urgent-kr-title { font-size: 13px; font-weight: 500; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

View File

@ -185,6 +185,11 @@ const reasonAction = ref<'pause' | 'cancel'>('pause');
const reasonKRId = ref(''); const reasonKRId = ref('');
const reasonText = ref(''); const reasonText = ref('');
//
const showResumeModal = ref(false);
const resumeKRId = ref('');
const resumeEndDate = ref<number | null>(null);
// //
const showLogsModal = ref(false); const showLogsModal = ref(false);
const logsKRTitle = ref(''); const logsKRTitle = ref('');
@ -201,7 +206,9 @@ function handleKRMenu(key: string, kr: any) {
reasonText.value = ''; reasonText.value = '';
showReasonModal.value = true; showReasonModal.value = true;
} else if (key === 'resume') { } else if (key === 'resume') {
doResume(kr.id); resumeKRId.value = kr.id;
resumeEndDate.value = null;
showResumeModal.value = true;
} }
} }
@ -236,10 +243,15 @@ async function doReasonAction() {
} catch { message.error('操作失败'); } } catch { message.error('操作失败'); }
} }
async function doResume(krId: string) { async function doResume() {
if (!resumeEndDate.value) {
message.warning('请选择新的截止日期');
return;
}
try { try {
await resumeKRApi(krId); await resumeKRApi(resumeKRId.value, { newEndDate: tsToDateStr(resumeEndDate.value) });
message.success('已恢复'); message.success('已恢复');
showResumeModal.value = false;
loadData(); loadData();
} catch { message.error('恢复失败'); } } catch { message.error('恢复失败'); }
} }
@ -286,6 +298,18 @@ const { chartRef: gitMiniRef } = useECharts(gitMiniChartOptions);
function clamp(v: number) { return Math.min(Math.max(v, 0), 100); } function clamp(v: number) { return Math.min(Math.max(v, 0), 100); }
function formatTime(isoStr: string) {
if (!isoStr) return '';
const d = new Date(isoStr);
const now = new Date();
const diffH = Math.floor((now.getTime() - d.getTime()) / 3600000);
const diffD = Math.floor((now.getTime() - d.getTime()) / 86400000);
if (diffH < 1) return '刚刚';
if (diffH < 24) return diffH + '小时前';
if (diffD < 7) return diffD + '天前';
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
}
// Objective admin // Objective admin
function canEditObj(obj: any): boolean { function canEditObj(obj: any): boolean {
if (authStore.isAdmin) return true; if (authStore.isAdmin) return true;
@ -297,16 +321,14 @@ function canEditObj(obj: any): boolean {
<div class="project-detail-page"> <div class="project-detail-page">
<NSpin :show="loading"> <NSpin :show="loading">
<template v-if="data"> <template v-if="data">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-2)"> <!-- 项目标题 + 整体进度 -->
<h2>{{ data.project?.identifier }} - {{ data.project?.name }}</h2> <div class="overall-progress">
<div style="display:flex;justify-content:space-between;align-items:center">
<h2 style="margin:0;font-size:18px">{{ data.project?.identifier }} - {{ data.project?.name }}</h2>
<span class="tabular-nums" style="font-size:22px;font-weight:700;color:var(--color-primary-hex)" v-if="data.okr?.objectives?.length">{{ data.okr.overallProgress }}%</span>
</div> </div>
<div v-if="data.okr?.objectives?.length" style="margin-top:8px">
<!-- 项目整体进度 --> <NProgress type="line" :percentage="clamp(data.okr.overallProgress)" :show-indicator="false" style="width:100%" />
<div class="overall-progress" v-if="data.okr?.objectives?.length">
<span style="color:var(--color-text-secondary);font-size:14px">项目整体进度</span>
<div style="display:flex;align-items:center;gap:var(--space-3);margin-top:4px">
<NProgress type="line" :percentage="clamp(data.okr.overallProgress)" indicator-placement="inside" style="flex:1" />
<span class="tabular-nums" style="font-size:20px;font-weight:700;color:var(--color-primary-hex)">{{ data.okr.overallProgress }}%</span>
</div> </div>
</div> </div>
@ -314,12 +336,12 @@ function canEditObj(obj: any): boolean {
<!-- 左侧OKR --> <!-- 左侧OKR -->
<DataCard> <DataCard>
<template #header> <template #header>
<div style="display:flex;justify-content:space-between;align-items:center"> <div style="display:flex;justify-content:space-between;align-items:center;width:100%">
<div> <div style="display:flex;align-items:baseline;gap:8px">
<div style="font-weight:600;font-size:15px">OKR 目标进度</div> <span style="font-weight:600;font-size:15px">OKR 目标进度</span>
<div style="font-size:12px;color:var(--color-text-secondary)">{{ data.okr?.objectives?.length || 0 }} 个目标</div> <span style="font-size:12px;color:var(--color-text-muted)">{{ data.okr?.objectives?.length || 0 }} 个目标</span>
</div> </div>
<NButton v-if="authStore.canEdit" type="primary" size="small" @click="openCreateObj()">创建目标</NButton> <NButton v-if="authStore.canEdit" type="primary" size="tiny" @click="openCreateObj()">+ 创建目标</NButton>
</div> </div>
</template> </template>
@ -331,9 +353,9 @@ function canEditObj(obj: any): boolean {
<div class="obj-meta">{{ obj.ownerName }} · {{ obj.startDate || '未设置' }} ~ {{ obj.endDate || '未设置' }} · {{ obj.period }}</div> <div class="obj-meta">{{ obj.ownerName }} · {{ obj.startDate || '未设置' }} ~ {{ obj.endDate || '未设置' }} · {{ obj.period }}</div>
</div> </div>
<NTag size="small" :type="obj.progress >= 70 ? 'success' : 'default'" round>{{ Math.round(obj.progress) }}%</NTag> <NTag size="small" :type="obj.progress >= 70 ? 'success' : 'default'" round>{{ Math.round(obj.progress) }}%</NTag>
<div v-if="canEditObj(obj)" style="display:flex;gap:2px;margin-left:8px"> <div v-if="canEditObj(obj)" style="display:flex;align-items:center;gap:4px;margin-left:6px">
<NButton size="tiny" type="info" quaternary @click="openAddKR(obj.id, obj.title)">+任务</NButton> <NButton size="tiny" text type="info" @click="openAddKR(obj.id, obj.title)">+任务</NButton>
<NButton size="tiny" type="error" quaternary @click="handleDeleteObj(obj.id)">×</NButton> <NButton size="tiny" text type="error" @click="handleDeleteObj(obj.id)">×</NButton>
</div> </div>
</div> </div>
@ -412,12 +434,23 @@ function canEditObj(obj: any): boolean {
</div> </div>
</DataCard> </DataCard>
<!-- 项目信息 --> <!-- 最近提交 -->
<DataCard title="项目信息"> <DataCard>
<div class="info-grid"> <template #header>
<div class="info-item"><span class="info-label">项目标识</span><span class="info-value">{{ data.project?.identifier }}</span></div> <div style="display:flex;justify-content:space-between;align-items:center;width:100%">
<div class="info-item"><span class="info-label">OKR 目标数</span><span class="info-value">{{ data.okr?.objectives?.length || 0 }}</span></div> <span style="font-weight:600;font-size:15px">最近提交</span>
<div class="info-item"><span class="info-label">绑定仓库</span><span class="info-value">{{ data.gitActivity?.boundRepos?.length || 0 }} </span></div> <span style="font-size:12px;color:var(--color-text-muted)">{{ data.project?.identifier }} · {{ data.gitActivity?.recentCommits || 0 }} </span>
</div>
</template>
<div>
<div class="commit-list" v-if="data.recentCommitList?.length">
<div v-for="c in data.recentCommitList" :key="c.sha" class="commit-item">
<span class="commit-sha">{{ c.sha }}</span>
<span class="commit-msg">{{ c.message }}</span>
<span class="commit-info">{{ c.authorName }} · {{ formatTime(c.committedAt) }}</span>
</div>
</div>
<div v-else style="font-size:12px;color:var(--color-text-muted);padding:var(--space-2) 0">暂无提交记录</div>
</div> </div>
</DataCard> </DataCard>
</div> </div>
@ -462,6 +495,18 @@ function canEditObj(obj: any): boolean {
</NForm> </NForm>
</NModal> </NModal>
<!-- 恢复弹窗 -->
<NModal v-model:show="showResumeModal" title="恢复任务" preset="dialog" positive-text="确认恢复" @positive-click="doResume">
<NForm>
<NFormItem label="新截止日期" required>
<NDatePicker v-model:value="resumeEndDate" type="date" style="width:100%" clearable />
</NFormItem>
</NForm>
<div style="font-size:12px;color:var(--color-text-tertiary);margin-top:4px">
任务暂停后恢复请重新设置截止日期
</div>
</NModal>
<!-- 操作日志弹窗 --> <!-- 操作日志弹窗 -->
<NModal v-model:show="showLogsModal" :title="`操作日志 — ${logsKRTitle}`" preset="card" style="max-width:500px"> <NModal v-model:show="showLogsModal" :title="`操作日志 — ${logsKRTitle}`" preset="card" style="max-width:500px">
<NTimeline v-if="logsData.length"> <NTimeline v-if="logsData.length">
@ -530,8 +575,10 @@ function canEditObj(obj: any): boolean {
.kr-pct-ro { font-size:13px;font-weight:600;white-space:nowrap; } .kr-pct-ro { font-size:13px;font-weight:600;white-space:nowrap; }
.kr-empty { font-size:13px;color:var(--color-text-muted);padding:var(--space-2) 0; } .kr-empty { font-size:13px;color:var(--color-text-muted);padding:var(--space-2) 0; }
.info-grid { display:flex;flex-direction:column;gap:var(--space-3); } .commit-list { display:flex;flex-direction:column;gap:0;max-height:300px;overflow-y:auto; }
.info-item { display:flex;justify-content:space-between; } .commit-item { display:flex;align-items:baseline;gap:8px;padding:4px 0;border-bottom:1px solid rgba(0,0,0,0.04);font-size:12px; }
.info-label { font-size:13px;color:var(--color-text-secondary); } .commit-item:last-child { border-bottom:none; }
.info-value { font-size:13px;font-weight:600; } .commit-sha { color:var(--color-primary-hex);font-family:monospace;font-size:11px;flex-shrink:0; }
.commit-msg { flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--color-text-primary); }
.commit-info { color:var(--color-text-muted);white-space:nowrap;flex-shrink:0;font-size:11px; }
</style> </style>

View File

@ -127,6 +127,16 @@ async function handleDelete(id: string, name: string) {
} }
} }
// URL
function extractRepoName(raw: string) {
let cleaned = raw.trim().replace(/\.git$/, '');
if (cleaned.includes('://')) {
try { const parts = new URL(cleaned).pathname.split('/').filter(Boolean); return parts[parts.length - 1] || cleaned; } catch {}
}
if (cleaned.includes('/')) return cleaned.split('/').pop() || cleaned;
return cleaned;
}
// //
const columns = [ const columns = [
{ title: '标识', key: 'identifier', width: 100 }, { title: '标识', key: 'identifier', width: 100 },
@ -134,26 +144,24 @@ const columns = [
{ {
title: '绑定仓库', title: '绑定仓库',
key: 'repos', key: 'repos',
width: 220, width: 160,
render: (row: any) => { render: (row: any) => {
const repos = row.repos || []; const repos = row.repos || [];
if (!repos.length) { if (!repos.length) return h('span', { style: 'color:var(--color-text-muted);font-size:12px' }, '未绑定');
return h('span', { style: 'color:var(--color-text-muted);font-size:12px' }, '未绑定');
}
return h('div', { style: 'display:flex;flex-wrap:wrap;gap:4px' }, return h('div', { style: 'display:flex;flex-wrap:wrap;gap:4px' },
repos.map((r: any) => h(NTag, { size: 'small', type: 'info', round: true }, { default: () => r.repoName })) repos.map((r: any) => h(NTag, { size: 'small', type: 'info', round: true }, { default: () => extractRepoName(r.repoName) }))
); );
}, },
}, },
{ {
title: '操作', title: '操作',
key: 'actions', key: 'actions',
width: 240, width: 200,
render: (row: any) => { render: (row: any) => {
return h('div', { style: 'display:flex;gap:8px' }, [ return h('div', { style: 'display:flex;gap:6px' }, [
h(NButton, { size: 'tiny', type: 'info', onClick: () => router.push(`/projects/${row.id}`) }, { default: () => '查看' }), h(NButton, { size: 'tiny', type: 'info', onClick: () => router.push(`/projects/${row.id}`) }, { default: () => '查看' }),
canCreate canCreate
? h(NButton, { size: 'tiny', type: 'default', onClick: () => openRepoModal(row) }, { default: () => '管理仓库' }) ? h(NButton, { size: 'tiny', type: 'default', onClick: () => openRepoModal(row) }, { default: () => '仓库' })
: null, : null,
canCreate canCreate
? h(NButton, { size: 'tiny', type: 'error', onClick: () => handleDelete(row.id, row.name) }, { default: () => '删除' }) ? h(NButton, { size: 'tiny', type: 'error', onClick: () => handleDelete(row.id, row.name) }, { default: () => '删除' })