feat(权限): 观察者角色支持项目级查看权限
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m37s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m37s
- 新增 user_project_permissions 表,管理观察者可查看的项目 - 管理员可在用户管理页面为观察者分配项目权限 - 所有数据接口(总览、项目、OKR、Git活动)按分配的项目过滤 - 未分配项目的观察者看到空数据 - 同步日志按时间倒序排列 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d6dc0a882e
commit
4283824533
14
backend/drizzle/0001_add_user_project_permissions.sql
Normal file
14
backend/drizzle/0001_add_user_project_permissions.sql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS `user_project_permissions` (
|
||||||
|
`id` varchar(50) NOT NULL PRIMARY KEY,
|
||||||
|
`user_id` varchar(50) NOT NULL,
|
||||||
|
`project_id` varchar(50) NOT NULL,
|
||||||
|
`created_at` datetime NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_upp_user` ON `user_project_permissions` (`user_id`);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_upp_project` ON `user_project_permissions` (`project_id`);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `uniq_upp_user_project` ON `user_project_permissions` (`user_id`, `project_id`);
|
||||||
@ -218,6 +218,18 @@ export const projectRepos = mysqlTable('project_repos', {
|
|||||||
repoIdx: index('idx_project_repos_repo').on(table.repoName),
|
repoIdx: index('idx_project_repos_repo').on(table.repoName),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ── User Project Permissions (观察者可查看项目) ──
|
||||||
|
export const userProjectPermissions = mysqlTable('user_project_permissions', {
|
||||||
|
id: varchar('id', { length: 50 }).primaryKey(),
|
||||||
|
userId: varchar('user_id', { length: 50 }).references(() => users.id).notNull(),
|
||||||
|
projectId: varchar('project_id', { length: 50 }).references(() => projects.id).notNull(),
|
||||||
|
createdAt: datetime('created_at').notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
userIdx: index('idx_upp_user').on(table.userId),
|
||||||
|
projectIdx: index('idx_upp_project').on(table.projectId),
|
||||||
|
userProjectIdx: uniqueIndex('uniq_upp_user_project').on(table.userId, table.projectId),
|
||||||
|
}));
|
||||||
|
|
||||||
// ── Sync Logs ──
|
// ── Sync Logs ──
|
||||||
export const syncLogs = mysqlTable('sync_logs', {
|
export const syncLogs = mysqlTable('sync_logs', {
|
||||||
id: varchar('id', { length: 50 }).primaryKey(),
|
id: varchar('id', { length: 50 }).primaryKey(),
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, desc } from 'drizzle-orm';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import { db } from '../db/index';
|
import { db } from '../db/index';
|
||||||
import { users, authorMappings, syncLogs, projects, projectRepos, gitCommits, gitPRs } from '../db/schema';
|
import { users, authorMappings, syncLogs, projects, projectRepos, gitCommits, gitPRs, userProjectPermissions } from '../db/schema';
|
||||||
import { requireRole } from '../middleware/role';
|
import { requireRole } from '../middleware/role';
|
||||||
import { AppError } from '../middleware/error-handler';
|
import { AppError } from '../middleware/error-handler';
|
||||||
|
|
||||||
@ -18,6 +18,12 @@ adminRoutes.use('/admin/*', requireRole('admin'));
|
|||||||
|
|
||||||
adminRoutes.get('/admin/users', async (c) => {
|
adminRoutes.get('/admin/users', async (c) => {
|
||||||
const allUsers = await db.select().from(users);
|
const allUsers = await db.select().from(users);
|
||||||
|
const allPerms = await db.select().from(userProjectPermissions);
|
||||||
|
const permsByUser = new Map<string, string[]>();
|
||||||
|
for (const p of allPerms) {
|
||||||
|
if (!permsByUser.has(p.userId)) permsByUser.set(p.userId, []);
|
||||||
|
permsByUser.get(p.userId)!.push(p.projectId);
|
||||||
|
}
|
||||||
return c.json({
|
return c.json({
|
||||||
code: 0,
|
code: 0,
|
||||||
data: allUsers.map(u => ({
|
data: allUsers.map(u => ({
|
||||||
@ -27,6 +33,7 @@ adminRoutes.get('/admin/users', async (c) => {
|
|||||||
role: u.role,
|
role: u.role,
|
||||||
planeUserId: u.planeUserId,
|
planeUserId: u.planeUserId,
|
||||||
gitUsername: u.gitUsername,
|
gitUsername: u.gitUsername,
|
||||||
|
allowedProjectIds: permsByUser.get(u.id) || [],
|
||||||
createdAt: u.createdAt.toISOString(),
|
createdAt: u.createdAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
message: 'success',
|
message: 'success',
|
||||||
@ -249,6 +256,49 @@ adminRoutes.delete('/admin/author-mappings/:id', async (c) => {
|
|||||||
return c.json({ code: 0, data: null, message: 'success' });
|
return c.json({ code: 0, data: null, message: 'success' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── User Project Permissions (观察者项目权限) ──
|
||||||
|
|
||||||
|
// GET /api/admin/users/:id/projects — 获取用户可查看的项目列表
|
||||||
|
adminRoutes.get('/admin/users/:id/projects', async (c) => {
|
||||||
|
const userId = c.req.param('id');
|
||||||
|
const perms = await db.select().from(userProjectPermissions)
|
||||||
|
.where(eq(userProjectPermissions.userId, userId));
|
||||||
|
return c.json({
|
||||||
|
code: 0,
|
||||||
|
data: perms.map(p => p.projectId),
|
||||||
|
message: 'success',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/admin/users/:id/projects — 设置用户可查看的项目(全量替换)
|
||||||
|
const setUserProjectsSchema = z.object({
|
||||||
|
projectIds: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.put('/admin/users/:id/projects', zValidator('json', setUserProjectsSchema), async (c) => {
|
||||||
|
const userId = c.req.param('id');
|
||||||
|
const { projectIds } = c.req.valid('json');
|
||||||
|
|
||||||
|
// 先删除现有权限
|
||||||
|
await db.delete(userProjectPermissions)
|
||||||
|
.where(eq(userProjectPermissions.userId, userId));
|
||||||
|
|
||||||
|
// 批量插入新权限
|
||||||
|
if (projectIds.length > 0) {
|
||||||
|
const now = new Date();
|
||||||
|
await db.insert(userProjectPermissions).values(
|
||||||
|
projectIds.map(projectId => ({
|
||||||
|
id: uuid(),
|
||||||
|
userId,
|
||||||
|
projectId,
|
||||||
|
createdAt: now,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ code: 0, data: { projectIds }, message: 'success' });
|
||||||
|
});
|
||||||
|
|
||||||
// ── Sync ──
|
// ── Sync ──
|
||||||
|
|
||||||
adminRoutes.post('/admin/sync/trigger', async (c) => {
|
adminRoutes.post('/admin/sync/trigger', async (c) => {
|
||||||
@ -266,7 +316,7 @@ adminRoutes.get('/admin/sync/logs', async (c) => {
|
|||||||
const page = parseInt(c.req.query('page') || '1');
|
const page = parseInt(c.req.query('page') || '1');
|
||||||
const pageSize = parseInt(c.req.query('pageSize') || '20');
|
const pageSize = parseInt(c.req.query('pageSize') || '20');
|
||||||
|
|
||||||
const allLogs = await db.select().from(syncLogs).orderBy(syncLogs.syncedAt);
|
const allLogs = await db.select().from(syncLogs).orderBy(desc(syncLogs.syncedAt));
|
||||||
const total = allLogs.length;
|
const total = allLogs.length;
|
||||||
const items = allLogs.slice((page - 1) * pageSize, page * pageSize);
|
const items = allLogs.slice((page - 1) * pageSize, page * pageSize);
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { db } from '../db/index';
|
import { db } from '../db/index';
|
||||||
import { gitCommits, gitPRs, users } from '../db/schema';
|
import { gitCommits, gitPRs, users, projectRepos } from '../db/schema';
|
||||||
import { eq, and, gte, desc } from 'drizzle-orm';
|
import { eq, and, gte, desc, inArray } from 'drizzle-orm';
|
||||||
import { AppError } from '../middleware/error-handler';
|
import { AppError } from '../middleware/error-handler';
|
||||||
|
import { getAllowedProjectIds } from '../services/permissions';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
export const gitRoutes = new Hono();
|
export const gitRoutes = new Hono();
|
||||||
@ -13,8 +14,25 @@ gitRoutes.get('/git/activity', async (c) => {
|
|||||||
const queryUserId = c.req.query('userId');
|
const queryUserId = c.req.query('userId');
|
||||||
const weeks = parseInt(c.req.query('weeks') || '12');
|
const weeks = parseInt(c.req.query('weeks') || '12');
|
||||||
|
|
||||||
|
// 观察者:允许访问但限定到已分配项目的仓库
|
||||||
|
let allowedRepoNames: Set<string> | null = null;
|
||||||
if (user.role === 'viewer') {
|
if (user.role === 'viewer') {
|
||||||
throw new AppError(40103, 'Insufficient permissions', 403);
|
const allowedIds = await getAllowedProjectIds(user);
|
||||||
|
if (allowedIds !== null && allowedIds.length > 0) {
|
||||||
|
const repos = await db.select().from(projectRepos)
|
||||||
|
.where(inArray(projectRepos.projectId, allowedIds));
|
||||||
|
allowedRepoNames = new Set(repos.map(r => {
|
||||||
|
let cleaned = r.repoName.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;
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// 没有分配任何项目,返回空数据
|
||||||
|
return c.json({ code: 0, data: { heatmap: [], stats: { totalCommits: 0, activeContributors: 0, thisMonthCommits: 0, activeRepos: 0 }, weeklyTrend: [] }, message: 'success' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let targetUserId: string | undefined;
|
let targetUserId: string | undefined;
|
||||||
@ -30,7 +48,12 @@ gitRoutes.get('/git/activity', async (c) => {
|
|||||||
const commitQuery = targetUserId
|
const commitQuery = targetUserId
|
||||||
? db.select().from(gitCommits).where(and(eq(gitCommits.userId, targetUserId), gte(gitCommits.committedAt, startDate)))
|
? db.select().from(gitCommits).where(and(eq(gitCommits.userId, targetUserId), gte(gitCommits.committedAt, startDate)))
|
||||||
: db.select().from(gitCommits).where(gte(gitCommits.committedAt, startDate));
|
: db.select().from(gitCommits).where(gte(gitCommits.committedAt, startDate));
|
||||||
const commits = await commitQuery;
|
let commits = await commitQuery;
|
||||||
|
|
||||||
|
// 观察者:过滤到分配项目的仓库
|
||||||
|
if (allowedRepoNames) {
|
||||||
|
commits = commits.filter(c => allowedRepoNames!.has(c.repoName));
|
||||||
|
}
|
||||||
|
|
||||||
// Heatmap(按天)
|
// Heatmap(按天)
|
||||||
const dayMap: Record<string, { commits: number; additions: number; deletions: number }> = {};
|
const dayMap: Record<string, { commits: number; additions: number; deletions: number }> = {};
|
||||||
@ -49,7 +72,10 @@ gitRoutes.get('/git/activity', async (c) => {
|
|||||||
const heatmap = Object.entries(dayMap).map(([date, data]) => ({ date, ...data }));
|
const heatmap = Object.entries(dayMap).map(([date, data]) => ({ date, ...data }));
|
||||||
|
|
||||||
// 统计指标(替代原来的 PR 指标)
|
// 统计指标(替代原来的 PR 指标)
|
||||||
const allCommits = await db.select().from(gitCommits);
|
let allCommits = await db.select().from(gitCommits);
|
||||||
|
if (allowedRepoNames) {
|
||||||
|
allCommits = allCommits.filter(c => allowedRepoNames!.has(c.repoName));
|
||||||
|
}
|
||||||
const thisMonthStart = dayjs().startOf('month').toDate();
|
const thisMonthStart = dayjs().startOf('month').toDate();
|
||||||
const thisMonthCommits = allCommits.filter(c => dayjs(c.committedAt).isAfter(thisMonthStart));
|
const thisMonthCommits = allCommits.filter(c => dayjs(c.committedAt).isAfter(thisMonthStart));
|
||||||
const activeContributors = new Set(allCommits.filter(c => c.userId).map(c => c.userId)).size;
|
const activeContributors = new Set(allCommits.filter(c => c.userId).map(c => c.userId)).size;
|
||||||
|
|||||||
@ -2,14 +2,25 @@ import { Hono } from 'hono';
|
|||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { requireRole } from '../middleware/role';
|
import { requireRole } from '../middleware/role';
|
||||||
|
import { getAllowedProjectIds } from '../services/permissions';
|
||||||
import * as okrService from '../services/okr';
|
import * as okrService from '../services/okr';
|
||||||
|
|
||||||
export const okrRoutes = new Hono();
|
export const okrRoutes = new Hono();
|
||||||
|
|
||||||
// GET /api/okr
|
// GET /api/okr
|
||||||
okrRoutes.get('/okr', async (c) => {
|
okrRoutes.get('/okr', async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
const period = c.req.query('period');
|
const period = c.req.query('period');
|
||||||
const data = await okrService.getOKRByPeriod(period || undefined);
|
const data = await okrService.getOKRByPeriod(period || undefined);
|
||||||
|
|
||||||
|
// 观察者:只返回已分配项目的 OKR
|
||||||
|
const allowedIds = await getAllowedProjectIds(user);
|
||||||
|
if (allowedIds !== null) {
|
||||||
|
data.objectives = data.objectives.filter(
|
||||||
|
(obj: any) => obj.projectId && allowedIds.includes(obj.projectId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({ code: 0, data, message: 'success' });
|
return c.json({ code: 0, data, message: 'success' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,16 +1,36 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { db } from '../db/index';
|
import { db } from '../db/index';
|
||||||
import { projects, gitCommits, gitPRs, objectives, keyResults, users, projectRepos, krLogs } from '../db/schema';
|
import { projects, gitCommits, gitPRs, objectives, keyResults, users, projectRepos, krLogs } from '../db/schema';
|
||||||
import { eq, desc, gte } from 'drizzle-orm';
|
import { eq, desc, gte, inArray } from 'drizzle-orm';
|
||||||
|
import { getAllowedProjectIds } from '../services/permissions';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
/** 根据项目绑定的仓库名,提取纯仓库名 Set */
|
||||||
|
async function getAllowedRepoNames(allowedProjectIds: string[]): Promise<Set<string>> {
|
||||||
|
if (allowedProjectIds.length === 0) return new Set();
|
||||||
|
const repos = await db.select().from(projectRepos)
|
||||||
|
.where(inArray(projectRepos.projectId, allowedProjectIds));
|
||||||
|
return new Set(repos.map(r => {
|
||||||
|
let cleaned = r.repoName.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;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export const overviewRoutes = new Hono();
|
export const overviewRoutes = new Hono();
|
||||||
|
|
||||||
async function getRecentCommits() {
|
async function getRecentCommits(allowedRepos?: Set<string>) {
|
||||||
const recent = await db.select().from(gitCommits).orderBy(desc(gitCommits.committedAt)).limit(15);
|
const recent = await db.select().from(gitCommits).orderBy(desc(gitCommits.committedAt)).limit(50);
|
||||||
const allUsers = await db.select().from(users);
|
const allUsers = await db.select().from(users);
|
||||||
const userMap = new Map(allUsers.map(u => [u.id, u.displayName]));
|
const userMap = new Map(allUsers.map(u => [u.id, u.displayName]));
|
||||||
return recent.map(c => ({
|
let filtered = recent;
|
||||||
|
if (allowedRepos) {
|
||||||
|
filtered = recent.filter(c => allowedRepos.has(c.repoName));
|
||||||
|
}
|
||||||
|
return filtered.slice(0, 15).map(c => ({
|
||||||
sha: c.sha?.slice(0, 7) || '',
|
sha: c.sha?.slice(0, 7) || '',
|
||||||
message: (c.message || '').split('\n')[0].slice(0, 60),
|
message: (c.message || '').split('\n')[0].slice(0, 60),
|
||||||
authorName: c.userId ? userMap.get(c.userId) || c.authorName : c.authorName,
|
authorName: c.userId ? userMap.get(c.userId) || c.authorName : c.authorName,
|
||||||
@ -20,15 +40,39 @@ async function getRecentCommits() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
overviewRoutes.get('/overview', async (c) => {
|
overviewRoutes.get('/overview', async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
const period = c.req.query('period');
|
const period = c.req.query('period');
|
||||||
const projectIds = c.req.query('projectIds')?.split(',').filter(Boolean) || [];
|
let projectIds = c.req.query('projectIds')?.split(',').filter(Boolean) || [];
|
||||||
|
|
||||||
|
// 观察者:强制限定到已分配的项目
|
||||||
|
const allowedIds = await getAllowedProjectIds(user);
|
||||||
|
let mustFilterByProject = false;
|
||||||
|
if (allowedIds !== null) {
|
||||||
|
mustFilterByProject = true;
|
||||||
|
if (projectIds.length > 0) {
|
||||||
|
projectIds = projectIds.filter(id => allowedIds.includes(id));
|
||||||
|
} else {
|
||||||
|
projectIds = allowedIds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 观察者:获取可访问的仓库名(用于过滤 git 相关数据)
|
||||||
|
const allowedRepos = mustFilterByProject ? await getAllowedRepoNames(projectIds) : undefined;
|
||||||
|
// 观察者:获取可访问项目下的 objective IDs(用于过滤 KR 数据)
|
||||||
|
let allowedObjIds: Set<string> | undefined;
|
||||||
|
if (mustFilterByProject) {
|
||||||
|
const objs = projectIds.length > 0
|
||||||
|
? (await db.select().from(objectives)).filter(o => o.projectId && projectIds.includes(o.projectId))
|
||||||
|
: [];
|
||||||
|
allowedObjIds = new Set(objs.map(o => o.id));
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 各项目 OKR 整体进度(替代 Sprint 交付率)
|
// 1. 各项目 OKR 整体进度(替代 Sprint 交付率)
|
||||||
const allProjects = await db.select().from(projects);
|
const allProjects = await db.select().from(projects);
|
||||||
const projectOKRProgress: { projectId: string; name: string; identifier: string; progress: number; objectiveCount: number }[] = [];
|
const projectOKRProgress: { projectId: string; name: string; identifier: string; progress: number; objectiveCount: number }[] = [];
|
||||||
|
|
||||||
for (const proj of allProjects) {
|
for (const proj of allProjects) {
|
||||||
if (projectIds.length > 0 && !projectIds.includes(proj.id)) continue;
|
if ((mustFilterByProject || projectIds.length > 0) && !projectIds.includes(proj.id)) continue;
|
||||||
let projObjectives = await db.select().from(objectives)
|
let projObjectives = await db.select().from(objectives)
|
||||||
.where(eq(objectives.projectId, proj.id));
|
.where(eq(objectives.projectId, proj.id));
|
||||||
if (period) {
|
if (period) {
|
||||||
@ -49,7 +93,7 @@ overviewRoutes.get('/overview', async (c) => {
|
|||||||
// 2. KR 完成状态分布(替代任务状态分布)
|
// 2. KR 完成状态分布(替代任务状态分布)
|
||||||
const allKRs = await db.select().from(keyResults);
|
const allKRs = await db.select().from(keyResults);
|
||||||
let filteredKRs = allKRs;
|
let filteredKRs = allKRs;
|
||||||
if (projectIds.length > 0) {
|
if (mustFilterByProject || projectIds.length > 0) {
|
||||||
const projObjIds = new Set(
|
const projObjIds = new Set(
|
||||||
(await db.select().from(objectives))
|
(await db.select().from(objectives))
|
||||||
.filter(o => o.projectId && projectIds.includes(o.projectId))
|
.filter(o => o.projectId && projectIds.includes(o.projectId))
|
||||||
@ -81,10 +125,15 @@ overviewRoutes.get('/overview', async (c) => {
|
|||||||
|
|
||||||
// 4. Weekly Code Activity (last 12 weeks)
|
// 4. Weekly Code Activity (last 12 weeks)
|
||||||
const twelveWeeksAgo = dayjs().subtract(12, 'week').startOf('week').toDate();
|
const twelveWeeksAgo = dayjs().subtract(12, 'week').startOf('week').toDate();
|
||||||
const commits = await db.select().from(gitCommits)
|
let commits = await db.select().from(gitCommits)
|
||||||
.where(gte(gitCommits.committedAt, twelveWeeksAgo));
|
.where(gte(gitCommits.committedAt, twelveWeeksAgo));
|
||||||
const prs = await db.select().from(gitPRs)
|
let prs = await db.select().from(gitPRs)
|
||||||
.where(gte(gitPRs.createdAt, twelveWeeksAgo));
|
.where(gte(gitPRs.createdAt, twelveWeeksAgo));
|
||||||
|
// 观察者:按项目绑定仓库过滤
|
||||||
|
if (allowedRepos) {
|
||||||
|
commits = commits.filter(c => allowedRepos.has(c.repoName));
|
||||||
|
prs = prs.filter(p => allowedRepos.has(p.repoName));
|
||||||
|
}
|
||||||
const allUsers = await db.select().from(users);
|
const allUsers = await db.select().from(users);
|
||||||
|
|
||||||
const weekMap: Record<string, Record<string, { commits: number; prs: number }>> = {};
|
const weekMap: Record<string, Record<string, { commits: number; prs: number }>> = {};
|
||||||
@ -126,7 +175,7 @@ overviewRoutes.get('/overview', async (c) => {
|
|||||||
? await db.select().from(objectives).where(eq(objectives.period, period))
|
? await db.select().from(objectives).where(eq(objectives.period, period))
|
||||||
: await db.select().from(objectives);
|
: await db.select().from(objectives);
|
||||||
|
|
||||||
if (projectIds.length > 0) {
|
if (mustFilterByProject || projectIds.length > 0) {
|
||||||
allObjectives = allObjectives.filter(o => o.projectId && projectIds.includes(o.projectId));
|
allObjectives = allObjectives.filter(o => o.projectId && projectIds.includes(o.projectId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,8 +207,12 @@ overviewRoutes.get('/overview', async (c) => {
|
|||||||
const weekStart = dayjs().startOf('week').add(weekOffset, 'week');
|
const weekStart = dayjs().startOf('week').add(weekOffset, 'week');
|
||||||
const weekEnd = dayjs().endOf('week').add(weekOffset, 'week');
|
const weekEnd = dayjs().endOf('week').add(weekOffset, 'week');
|
||||||
|
|
||||||
const allKRsRaw = await db.select().from(keyResults);
|
let 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]));
|
||||||
|
// 观察者:过滤到已分配项目的 KR
|
||||||
|
if (allowedObjIds) {
|
||||||
|
allKRsRaw = allKRsRaw.filter(kr => allowedObjIds!.has(kr.objectiveId));
|
||||||
|
}
|
||||||
|
|
||||||
const thisWeekKRs = allKRsRaw.filter(kr => {
|
const thisWeekKRs = allKRsRaw.filter(kr => {
|
||||||
if (!kr.endDate) return false;
|
if (!kr.endDate) return false;
|
||||||
@ -303,7 +356,7 @@ overviewRoutes.get('/overview', async (c) => {
|
|||||||
weekLabel: weekStart.format('MM/DD') + ' ~ ' + weekEnd.format('MM/DD'),
|
weekLabel: weekStart.format('MM/DD') + ' ~ ' + weekEnd.format('MM/DD'),
|
||||||
},
|
},
|
||||||
overdueKRs: overdueList,
|
overdueKRs: overdueList,
|
||||||
recentCommits: await getRecentCommits(),
|
recentCommits: await getRecentCommits(allowedRepos),
|
||||||
},
|
},
|
||||||
message: 'success',
|
message: 'success',
|
||||||
});
|
});
|
||||||
|
|||||||
@ -7,13 +7,19 @@ import { projects, sprintSnapshots, milestones, taskSnapshots, gitCommits, gitPR
|
|||||||
import { eq, and, desc, gte } from 'drizzle-orm';
|
import { eq, and, desc, gte } from 'drizzle-orm';
|
||||||
import { requireRole } from '../middleware/role';
|
import { requireRole } from '../middleware/role';
|
||||||
import { AppError } from '../middleware/error-handler';
|
import { AppError } from '../middleware/error-handler';
|
||||||
|
import { getAllowedProjectIds } from '../services/permissions';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
export const projectRoutes = new Hono();
|
export const projectRoutes = new Hono();
|
||||||
|
|
||||||
// GET /api/projects — 所有登录用户都能查
|
// GET /api/projects — 所有登录用户都能查(观察者仅返回已分配的项目)
|
||||||
projectRoutes.get('/projects', async (c) => {
|
projectRoutes.get('/projects', async (c) => {
|
||||||
const allProjects = await db.select().from(projects);
|
const user = c.get('user');
|
||||||
|
const allowedIds = await getAllowedProjectIds(user);
|
||||||
|
let allProjects = await db.select().from(projects);
|
||||||
|
if (allowedIds !== null) {
|
||||||
|
allProjects = allProjects.filter(p => allowedIds.includes(p.id));
|
||||||
|
}
|
||||||
return c.json({
|
return c.json({
|
||||||
code: 0,
|
code: 0,
|
||||||
data: allProjects.map(p => ({
|
data: allProjects.map(p => ({
|
||||||
@ -99,6 +105,13 @@ projectRoutes.delete('/project-repos/:id',
|
|||||||
// GET /api/projects/:id
|
// GET /api/projects/:id
|
||||||
projectRoutes.get('/projects/:id', async (c) => {
|
projectRoutes.get('/projects/:id', async (c) => {
|
||||||
const projectId = c.req.param('id');
|
const projectId = c.req.param('id');
|
||||||
|
const user = c.get('user');
|
||||||
|
|
||||||
|
// 观察者只能查看已分配的项目
|
||||||
|
const allowedIds = await getAllowedProjectIds(user);
|
||||||
|
if (allowedIds !== null && !allowedIds.includes(projectId)) {
|
||||||
|
throw new AppError(40103, 'Insufficient permissions', 403);
|
||||||
|
}
|
||||||
|
|
||||||
const project = await db.query.projects.findFirst({
|
const project = await db.query.projects.findFirst({
|
||||||
where: eq(projects.id, projectId),
|
where: eq(projects.id, projectId),
|
||||||
|
|||||||
@ -27,6 +27,7 @@ export async function getOKRByPeriod(period?: string) {
|
|||||||
id: obj.id,
|
id: obj.id,
|
||||||
title: obj.title,
|
title: obj.title,
|
||||||
ownerName: owner?.displayName || '未指定',
|
ownerName: owner?.displayName || '未指定',
|
||||||
|
projectId: obj.projectId || null,
|
||||||
projectName: project?.name || '未关联项目',
|
projectName: project?.name || '未关联项目',
|
||||||
period: obj.period,
|
period: obj.period,
|
||||||
startDate: obj.startDate || null,
|
startDate: obj.startDate || null,
|
||||||
|
|||||||
21
backend/src/services/permissions.ts
Normal file
21
backend/src/services/permissions.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/index';
|
||||||
|
import { userProjectPermissions } from '../db/schema';
|
||||||
|
import type { JWTPayload } from '../middleware/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取观察者(viewer)可查看的项目 ID 列表。
|
||||||
|
* - viewer 角色:返回 user_project_permissions 中分配的项目 ID 列表
|
||||||
|
* - 其他角色:返回 null,表示可查看所有项目
|
||||||
|
*/
|
||||||
|
export async function getAllowedProjectIds(user: JWTPayload): Promise<string[] | null> {
|
||||||
|
if (user.role !== 'viewer') {
|
||||||
|
return null; // 非 viewer,不做项目级过滤
|
||||||
|
}
|
||||||
|
|
||||||
|
const perms = await db.select({ projectId: userProjectPermissions.projectId })
|
||||||
|
.from(userProjectPermissions)
|
||||||
|
.where(eq(userProjectPermissions.userId, user.sub));
|
||||||
|
|
||||||
|
return perms.map(p => p.projectId);
|
||||||
|
}
|
||||||
@ -65,3 +65,12 @@ export function triggerSyncApi(data?: { source?: string }) {
|
|||||||
export function getSyncLogsApi(params?: { page?: number; pageSize?: number }) {
|
export function getSyncLogsApi(params?: { page?: number; pageSize?: number }) {
|
||||||
return request.get('/api/admin/sync/logs', { params });
|
return request.get('/api/admin/sync/logs', { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User Project Permissions (观察者项目权限)
|
||||||
|
export function getUserProjectsApi(userId: string) {
|
||||||
|
return request.get(`/api/admin/users/${userId}/projects`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUserProjectsApi(userId: string, projectIds: string[]) {
|
||||||
|
return request.put(`/api/admin/users/${userId}/projects`, { projectIds });
|
||||||
|
}
|
||||||
|
|||||||
@ -53,7 +53,7 @@ const router = createRouter({
|
|||||||
path: 'git',
|
path: 'git',
|
||||||
name: 'GitActivity',
|
name: 'GitActivity',
|
||||||
component: () => import('@/views/GitActivity.vue'),
|
component: () => import('@/views/GitActivity.vue'),
|
||||||
meta: { roles: ['admin', 'manager', 'developer'] },
|
meta: { roles: ['admin', 'manager', 'developer', 'viewer'] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin',
|
path: 'admin',
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, h } from 'vue';
|
import { ref, computed, onMounted, h } from 'vue';
|
||||||
import { NTabs, NTabPane, NDataTable, NButton, NModal, NForm, NFormItem, NInput, NSelect, NSpin, NTag, useMessage } from 'naive-ui';
|
import { NTabs, NTabPane, NDataTable, NButton, NModal, NForm, NFormItem, NInput, NSelect, NSpin, NTag, useMessage } from 'naive-ui';
|
||||||
import { getAdminUsersApi, createUserApi, deleteUserApi, getAuthorMappingsApi, createMappingApi, deleteMappingApi, getSyncLogsApi, triggerSyncApi } from '@/api/admin';
|
import { getAdminUsersApi, createUserApi, deleteUserApi, getAuthorMappingsApi, createMappingApi, deleteMappingApi, getSyncLogsApi, triggerSyncApi, getAdminProjectsApi, setUserProjectsApi } from '@/api/admin';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
@ -58,23 +58,86 @@ function formatDate(value: unknown): string {
|
|||||||
const userColumns = [
|
const userColumns = [
|
||||||
{ title: '姓名', key: 'displayName' },
|
{ title: '姓名', key: 'displayName' },
|
||||||
{ title: '邮箱', key: 'email' },
|
{ title: '邮箱', key: 'email' },
|
||||||
{ title: '角色', key: 'role', width: 100 },
|
{
|
||||||
|
title: '角色', key: 'role', width: 100,
|
||||||
|
render: (row: any) => {
|
||||||
|
const roleMap: Record<string, string> = { admin: '管理员', manager: '经理', developer: '开发者', viewer: '观察者' };
|
||||||
|
return roleMap[row.role] || row.role;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '可查看项目', key: 'allowedProjects', width: 120,
|
||||||
|
render: (row: any) => {
|
||||||
|
if (row.role !== 'viewer') return '-';
|
||||||
|
const count = (row.allowedProjectIds || []).length;
|
||||||
|
return count > 0 ? `${count} 个项目` : '未分配';
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '创建时间', key: 'createdAt', width: 180,
|
title: '创建时间', key: 'createdAt', width: 180,
|
||||||
render: (row: any) => formatDate(row.createdAt),
|
render: (row: any) => formatDate(row.createdAt),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作', key: 'actions', width: 100,
|
title: '操作', key: 'actions', width: 180,
|
||||||
render: (row: any) => {
|
render: (row: any) => {
|
||||||
return h(
|
const buttons = [];
|
||||||
|
if (row.role === 'viewer') {
|
||||||
|
buttons.push(h(
|
||||||
|
NButton,
|
||||||
|
{ size: 'tiny', type: 'info', onClick: () => openProjectPermModal(row), style: 'margin-right: 8px' },
|
||||||
|
{ default: () => '分配项目' },
|
||||||
|
));
|
||||||
|
}
|
||||||
|
buttons.push(h(
|
||||||
NButton,
|
NButton,
|
||||||
{ size: 'tiny', type: 'error', onClick: () => handleDeleteUser(row.id) },
|
{ size: 'tiny', type: 'error', onClick: () => handleDeleteUser(row.id) },
|
||||||
{ default: () => '删除' },
|
{ default: () => '删除' },
|
||||||
);
|
));
|
||||||
|
return buttons;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Project permissions (观察者项目权限)
|
||||||
|
const showProjectPermModal = ref(false);
|
||||||
|
const permEditUserId = ref('');
|
||||||
|
const permEditUserName = ref('');
|
||||||
|
const permSelectedProjects = ref<string[]>([]);
|
||||||
|
const projectsLoading = ref(false);
|
||||||
|
const allProjects = ref<any[]>([]);
|
||||||
|
|
||||||
|
async function loadProjects() {
|
||||||
|
projectsLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getAdminProjectsApi();
|
||||||
|
allProjects.value = res.data.data;
|
||||||
|
} finally {
|
||||||
|
projectsLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectOptions = computed(() =>
|
||||||
|
allProjects.value.map(p => ({ value: p.id, label: `${p.name} (${p.identifier || p.id.slice(0, 8)})` }))
|
||||||
|
);
|
||||||
|
|
||||||
|
function openProjectPermModal(user: any) {
|
||||||
|
permEditUserId.value = user.id;
|
||||||
|
permEditUserName.value = user.displayName;
|
||||||
|
permSelectedProjects.value = user.allowedProjectIds || [];
|
||||||
|
showProjectPermModal.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveProjectPerms() {
|
||||||
|
try {
|
||||||
|
await setUserProjectsApi(permEditUserId.value, permSelectedProjects.value);
|
||||||
|
message.success('项目权限已更新');
|
||||||
|
showProjectPermModal.value = false;
|
||||||
|
loadUsers();
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.message || '更新项目权限失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Mappings tab
|
// Mappings tab
|
||||||
const mappingsLoading = ref(false);
|
const mappingsLoading = ref(false);
|
||||||
const mappingsData = ref<any[]>([]);
|
const mappingsData = ref<any[]>([]);
|
||||||
@ -211,6 +274,7 @@ onMounted(() => {
|
|||||||
loadUsers();
|
loadUsers();
|
||||||
loadMappings();
|
loadMappings();
|
||||||
loadLogs();
|
loadLogs();
|
||||||
|
loadProjects();
|
||||||
});
|
});
|
||||||
|
|
||||||
const roleOptions = [
|
const roleOptions = [
|
||||||
@ -273,6 +337,20 @@ const roleOptions = [
|
|||||||
</NForm>
|
</NForm>
|
||||||
</NModal>
|
</NModal>
|
||||||
|
|
||||||
|
<!-- 分配项目权限弹窗 -->
|
||||||
|
<NModal v-model:show="showProjectPermModal" :title="`分配项目权限 - ${permEditUserName}`" preset="dialog" positive-text="保存" @positive-click="handleSaveProjectPerms">
|
||||||
|
<div style="margin-bottom: 12px; color: var(--color-text-tertiary); font-size: 13px;">
|
||||||
|
选择该观察者可查看的项目。未分配项目的观察者将无法查看任何数据。
|
||||||
|
</div>
|
||||||
|
<NSelect
|
||||||
|
v-model:value="permSelectedProjects"
|
||||||
|
:options="projectOptions"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
placeholder="选择可查看的项目"
|
||||||
|
/>
|
||||||
|
</NModal>
|
||||||
|
|
||||||
<!-- 添加映射弹窗 -->
|
<!-- 添加映射弹窗 -->
|
||||||
<NModal v-model:show="showMappingModal" title="添加作者映射" preset="dialog" positive-text="创建" @positive-click="handleCreateMapping">
|
<NModal v-model:show="showMappingModal" title="添加作者映射" preset="dialog" positive-text="创建" @positive-click="handleCreateMapping">
|
||||||
<NForm>
|
<NForm>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user