feat: 项目编辑、成员编辑、性能优化及UI改进
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s

- 新增项目编辑功能(修改名称、标识)
- 团队成员页面增加编辑按钮(管理员可修改姓名、邮箱、角色)
- 项目详情接口性能优化:批量查询替代N+1,Git数据按仓库名过滤(8s→0.2s)
- 侧边栏和图表改为显示项目名称而非标识
- 同步日志按时间倒序排列

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
zyc 2026-04-14 11:21:48 +08:00
parent 4283824533
commit 10ed4f090d
7 changed files with 190 additions and 38 deletions

View File

@ -154,6 +154,28 @@ adminRoutes.post('/admin/projects', zValidator('json', createProjectSchema), asy
return c.json({ code: 0, data: { id }, message: 'success' }, 201);
});
const updateProjectSchema = z.object({
name: z.string().min(1).max(200).optional(),
identifier: z.string().min(1).max(20).toUpperCase().optional(),
});
adminRoutes.patch('/admin/projects/:id', zValidator('json', updateProjectSchema), async (c) => {
const id = c.req.param('id');
const data = c.req.valid('json');
const project = await db.query.projects.findFirst({ where: eq(projects.id, id) });
if (!project) {
throw new AppError(40402, 'Project not found', 404);
}
const updateData: Record<string, any> = { updatedAt: new Date() };
if (data.name) updateData.name = data.name;
if (data.identifier) updateData.identifier = data.identifier;
await db.update(projects).set(updateData).where(eq(projects.id, id));
return c.json({ code: 0, data: { id }, message: 'success' });
});
adminRoutes.delete('/admin/projects/:id', async (c) => {
const id = c.req.param('id');
await db.delete(projects).where(eq(projects.id, id));

View File

@ -4,7 +4,7 @@ import { z } from 'zod';
import { v4 as uuid } from 'uuid';
import { db } from '../db/index';
import { projects, sprintSnapshots, milestones, taskSnapshots, gitCommits, gitPRs, users, objectives, keyResults, projectRepos, krLogs } from '../db/schema';
import { eq, and, desc, gte } from 'drizzle-orm';
import { eq, and, desc, gte, inArray } from 'drizzle-orm';
import { requireRole } from '../middleware/role';
import { AppError } from '../middleware/error-handler';
import { getAllowedProjectIds } from '../services/permissions';
@ -182,32 +182,47 @@ projectRoutes.get('/projects/:id', async (c) => {
})),
};
// OKR for this project
// OKR for this project (batch queries to avoid N+1)
const projectObjectives = await db.select().from(objectives)
.where(eq(objectives.projectId, projectId));
const objIds = projectObjectives.map(o => o.id);
const allKRsForProject = objIds.length > 0
? await db.select().from(keyResults).where(inArray(keyResults.objectiveId, objIds))
: [];
const krIds = allKRsForProject.map(kr => kr.id);
const allLogsForProject = krIds.length > 0
? await db.select().from(krLogs).where(inArray(krLogs.krId, krIds)).orderBy(desc(krLogs.createdAt))
: [];
// Group KRs by objective, logs by KR
const krsByObj = new Map<string, typeof allKRsForProject>();
for (const kr of allKRsForProject) {
if (!krsByObj.has(kr.objectiveId)) krsByObj.set(kr.objectiveId, []);
krsByObj.get(kr.objectiveId)!.push(kr);
}
const logsByKR = new Map<string, typeof allLogsForProject>();
for (const log of allLogsForProject) {
if (!logsByKR.has(log.krId)) logsByKR.set(log.krId, []);
const arr = logsByKR.get(log.krId)!;
if (arr.length < 5) arr.push(log);
}
const okrData = [];
let totalOKRProgress = 0;
for (const obj of projectObjectives) {
const krs = await db.select().from(keyResults)
.where(eq(keyResults.objectiveId, obj.id));
const owner = obj.ownerId
? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) })
: null;
const krs = krsByObj.get(obj.id) || [];
okrData.push({
id: obj.id,
title: obj.title,
ownerId: obj.ownerId || null,
ownerName: owner?.displayName || '未指定',
ownerName: obj.ownerId ? (userMap.get(obj.ownerId) || '未指定') : '未指定',
period: obj.period,
startDate: obj.startDate || null,
endDate: obj.endDate || null,
progress: obj.progress || 0,
keyResults: await Promise.all(krs.map(async kr => {
const logs = await db.select().from(krLogs)
.where(eq(krLogs.krId, kr.id))
.orderBy(desc(krLogs.createdAt))
.limit(5);
keyResults: krs.map(kr => {
const logs = logsByKR.get(kr.id) || [];
const wasPostponed = logs.some(l => l.action === 'postponed');
const lastPostponeReason = logs.find(l => l.action === 'postponed')?.detail || null;
return {
@ -226,7 +241,7 @@ projectRoutes.get('/projects/:id', async (c) => {
? Math.round(((kr.currentValue || 0) / kr.targetValue) * 100)
: 0,
};
})),
}),
});
totalOKRProgress += obj.progress || 0;
}
@ -247,15 +262,13 @@ projectRoutes.get('/projects/:id', async (c) => {
return cleaned;
}));
// 获取该项目所有 Git 数据(不限时间范围)
const allCommits = await db.select().from(gitCommits);
const allPRs = await db.select().from(gitPRs);
const recentCommits = boundRepoNames.size > 0
? allCommits.filter(c => boundRepoNames.has(c.repoName))
// 获取该项目绑定仓库的 Git 数据(按仓库名过滤,避免全表扫描)
const boundRepoNamesList = Array.from(boundRepoNames);
const recentCommits = boundRepoNamesList.length > 0
? await db.select().from(gitCommits).where(inArray(gitCommits.repoName, boundRepoNamesList))
: [];
const recentPRs = boundRepoNames.size > 0
? allPRs.filter(p => boundRepoNames.has(p.repoName))
const recentPRs = boundRepoNamesList.length > 0
? await db.select().from(gitPRs).where(inArray(gitPRs.repoName, boundRepoNamesList))
: [];
const weeklyTrend: { weekStart: string; commits: number; prs: number }[] = [];

View File

@ -37,6 +37,10 @@ export function createProjectApi(data: { name: string; identifier: string }) {
return request.post('/api/projects', data);
}
export function updateProjectApi(id: string, data: { name?: string; identifier?: string }) {
return request.patch(`/api/admin/projects/${id}`, data);
}
export function deleteProjectApi(id: string) {
return request.delete(`/api/projects/${id}`);
}

View File

@ -18,7 +18,7 @@ const chartOptions = computed(() => {
const sorted = [...props.projects].sort(
(a, b) => b.currentCycleProgress - a.currentCycleProgress,
);
const names = sorted.map((p) => `${p.identifier} ${p.name}`.trim());
const names = sorted.map((p) => p.name);
const values = sorted.map((p) => p.currentCycleProgress);
const bgValues = sorted.map(() => 100);
@ -31,7 +31,7 @@ const chartOptions = computed(() => {
const project = sorted[idx];
if (!project) return '';
return `
<strong>${project.identifier} ${project.name}</strong><br/>
<strong>${project.name}</strong><br/>
进度: ${project.currentCycleProgress}%<br/>
${project.completedPoints}/${project.totalPoints}
`;

View File

@ -170,7 +170,7 @@ const roleTagType = computed(() => {
:class="{ active: route.path === `/projects/${proj.projectId}` }"
@click="handleProjectSelect(proj.projectId)"
>
<span class="submenu-label">{{ proj.identifier || '' }} {{ proj.name }}</span>
<span class="submenu-label">{{ proj.name }}</span>
</div>
<div v-if="!projectList.length" class="submenu-item submenu-empty">
暂无项目

View File

@ -3,18 +3,27 @@
* B-17 fix: New Member List page.
* Displays all team members with role badges and links to their detail pages.
*/
import { ref, onMounted } from 'vue';
import { ref, onMounted, h } from 'vue';
import { useRouter } from 'vue-router';
import { NSpin, NDataTable, NTag } from 'naive-ui';
import { NSpin, NDataTable, NTag, NButton, NModal, NForm, NFormItem, NInput, NSelect, useMessage } from 'naive-ui';
import { getMemberListApi } from '@/api/members';
import { updateUserApi } from '@/api/admin';
import DataCard from '@/components/shared/DataCard.vue';
import EmptyState from '@/components/shared/EmptyState.vue';
const router = useRouter();
const message = useMessage();
const loading = ref(true);
const members = ref<any[]>([]);
onMounted(async () => {
const userRole = (() => {
try { return JSON.parse(localStorage.getItem('user') || '{}').role || 'viewer'; }
catch { return 'viewer'; }
})();
const isAdmin = userRole === 'admin';
async function loadMembers() {
loading.value = true;
try {
const res = await getMemberListApi();
members.value = res.data.data || [];
@ -23,7 +32,47 @@ onMounted(async () => {
} finally {
loading.value = false;
}
});
}
onMounted(loadMembers);
//
const showEditModal = ref(false);
const editForm = ref({ id: '', displayName: '', email: '', role: '' });
function openEditModal(row: any, e: Event) {
e.stopPropagation();
editForm.value = { id: row.id, displayName: row.displayName, email: row.email, role: row.role };
showEditModal.value = true;
}
async function handleSaveEdit() {
if (!editForm.value.displayName) {
message.warning('姓名不能为空');
return;
}
try {
await updateUserApi(editForm.value.id, {
displayName: editForm.value.displayName,
email: editForm.value.email,
role: editForm.value.role,
});
message.success('成员信息已更新');
showEditModal.value = false;
loadMembers();
} catch (err: any) {
message.error(err.response?.data?.message || '更新失败');
}
}
const roleOptions = [
{ value: 'admin', label: '管理员' },
{ value: 'manager', label: '经理' },
{ value: 'developer', label: '开发者' },
{ value: 'viewer', label: '观察者' },
];
const roleLabels: Record<string, string> = { admin: '管理员', manager: '经理', developer: '开发者', viewer: '观察者' };
function roleTagType(role: string) {
if (role === 'admin') return 'info';
@ -51,9 +100,21 @@ const columns = [
type: roleTagType(row.role),
size: 'small',
round: true,
}, { default: () => row.role });
}, { default: () => roleLabels[row.role] || row.role });
},
},
...(isAdmin ? [{
title: '操作',
key: 'actions',
width: 80,
render: (row: any) => {
return h(NButton, {
size: 'tiny',
type: 'warning',
onClick: (e: Event) => openEditModal(row, e),
}, { default: () => '编辑' });
},
}] : []),
];
function handleRowClick(row: any) {
@ -61,11 +122,6 @@ function handleRowClick(row: any) {
}
</script>
<script lang="ts">
import { h } from 'vue';
export default {};
</script>
<template>
<div class="member-list-page">
<h2 style="margin-bottom: var(--space-5)">团队成员</h2>
@ -84,6 +140,21 @@ export default {};
</DataCard>
<EmptyState v-else-if="!loading" title="暂无成员" description="未找到团队成员。" />
</NSpin>
<!-- 编辑成员弹窗 -->
<NModal v-model:show="showEditModal" title="编辑成员" preset="dialog" positive-text="保存" @positive-click="handleSaveEdit">
<NForm>
<NFormItem label="姓名">
<NInput v-model:value="editForm.displayName" placeholder="请输入姓名" />
</NFormItem>
<NFormItem label="邮箱">
<NInput v-model:value="editForm.email" placeholder="请输入邮箱" />
</NFormItem>
<NFormItem label="角色">
<NSelect v-model:value="editForm.role" :options="roleOptions" />
</NFormItem>
</NForm>
</NModal>
</div>
</template>

View File

@ -2,7 +2,7 @@
import { ref, onMounted, h } from 'vue';
import { useRouter } from 'vue-router';
import { NSpin, NDataTable, NButton, NModal, NForm, NFormItem, NInput, NTag, NEmpty, useMessage } from 'naive-ui';
import { getAdminProjectsApi, createProjectApi, deleteProjectApi, bindRepoApi, getProjectReposApi, unbindRepoApi } from '@/api/admin';
import { getAdminProjectsApi, createProjectApi, updateProjectApi, deleteProjectApi, bindRepoApi, getProjectReposApi, unbindRepoApi } from '@/api/admin';
import DataCard from '@/components/shared/DataCard.vue';
import EmptyState from '@/components/shared/EmptyState.vue';
@ -115,6 +115,33 @@ async function handleUnbindRepo(bindingId: string) {
}
}
//
const showEditModal = ref(false);
const editProject = ref({ id: '', name: '', identifier: '' });
function openEditModal(project: any) {
editProject.value = { id: project.id, name: project.name, identifier: project.identifier || '' };
showEditModal.value = true;
}
async function handleEditProject() {
if (!editProject.value.name) {
message.warning('项目名称不能为空');
return;
}
try {
await updateProjectApi(editProject.value.id, {
name: editProject.value.name,
identifier: editProject.value.identifier.toUpperCase(),
});
message.success('项目已更新');
showEditModal.value = false;
loadProjects();
} catch (err: any) {
message.error(err.response?.data?.message || '更新失败');
}
}
//
async function handleDelete(id: string, name: string) {
if (!confirm(`确定删除项目「${name}」吗?`)) return;
@ -156,14 +183,17 @@ const columns = [
{
title: '操作',
key: 'actions',
width: 200,
width: 250,
render: (row: any) => {
return h('div', { style: 'display:flex;gap:6px' }, [
h(NButton, { size: 'tiny', type: 'info', onClick: () => router.push(`/projects/${row.id}`) }, { default: () => '查看' }),
canCreate
? h(NButton, { size: 'tiny', type: 'default', onClick: () => openRepoModal(row) }, { default: () => '仓库' })
? h(NButton, { size: 'tiny', type: 'warning', onClick: () => openEditModal(row) }, { default: () => '编辑' })
: null,
canCreate
? h(NButton, { size: 'tiny', type: 'default', onClick: () => openRepoModal(row) }, { default: () => '仓库' })
: null,
userRole === 'admin'
? h(NButton, { size: 'tiny', type: 'error', onClick: () => handleDelete(row.id, row.name) }, { default: () => '删除' })
: null,
]);
@ -204,6 +234,18 @@ const columns = [
</div>
</NModal>
<!-- 编辑项目弹窗 -->
<NModal v-model:show="showEditModal" title="编辑项目" preset="dialog" positive-text="保存" @positive-click="handleEditProject">
<NForm>
<NFormItem label="项目名称" required>
<NInput v-model:value="editProject.name" placeholder="项目名称" />
</NFormItem>
<NFormItem label="项目标识" required>
<NInput v-model:value="editProject.identifier" placeholder="大写英文缩写" />
</NFormItem>
</NForm>
</NModal>
<!-- 管理仓库弹窗 -->
<NModal v-model:show="showRepoModal" :title="`管理仓库 — ${editingProject?.name || ''}`" preset="dialog" :show-icon="false">
<div v-if="editingProject">