Files
ftb-project-management/apps/web/app/admin/management/page.tsx

133 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useEffect, useMemo, useState } from 'react';
import { Activity, AlertTriangle, Blocks, Gauge, Users } from 'lucide-react';
import { RouteGuard } from '@/components/auth/Guard';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore';
interface ManagementOverview {
activeVersionCount: number;
overdueItemCount: number;
blockedItemCount: number;
riskCounts: Record<string, number>;
memberLoads: Array<{ memberId: string; openItemCount: number }>;
activeVersions: Array<{ id: string; name: string; projectId?: string | null; releaseDate?: string | null }>;
overdueItems: Array<{ type: string; id: string; title: string; versionId: string; ownerId?: string | null; dueAt?: string | null }>;
blockedItems: Array<{ type: string; id: string; title: string; versionId: string; ownerId?: string | null }>;
highRiskVersions: Array<{ versionId: string; riskLevel: string; riskScore: number }>;
}
const TYPE_LABEL: Record<string, string> = {
version_plan: '计划',
dev_task: '开发',
test_case: '测试',
bug: 'Bug',
};
function ManagementPageInner() {
const user = useAuthStore((s) => s.user);
const role = useMemberStore((s) => s.roles.find((item) => item.id === user?.roleId));
const [overview, setOverview] = useState<ManagementOverview | null>(null);
const [loading, setLoading] = useState(false);
const permissions = useMemo(() => role?.permissions ?? [], [role?.permissions]);
useEffect(() => {
if (!user?.id) return;
setLoading(true);
const params = new URLSearchParams({ actorId: user.id });
if (permissions.length > 0) params.set('permissions', permissions.join(','));
api.get<ManagementOverview>(`/management/overview?${params.toString()}`)
.then(setOverview)
.catch(() => setOverview(null))
.finally(() => setLoading(false));
}, [permissions, user?.id]);
const riskTotal = overview ? Object.values(overview.riskCounts).reduce((sum, count) => sum + count, 0) : 0;
return (
<div className="min-h-full bg-[var(--bg)] p-6">
<div className="mx-auto max-w-6xl space-y-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-[18px] font-semibold text-[var(--ink)]"></h1>
<p className="mt-1 text-[12px] text-[var(--ink-muted)]"> AppData</p>
</div>
<div className="rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-3 py-1.5 text-[11px] text-[var(--ink-muted)]">
{loading ? '刷新中' : '已同步'}
</div>
</div>
<div className="grid grid-cols-4 gap-3">
<Metric icon={Activity} label="活跃版本" value={overview?.activeVersionCount ?? 0} />
<Metric icon={AlertTriangle} label="逾期事项" value={overview?.overdueItemCount ?? 0} tone="warn" />
<Metric icon={Blocks} label="阻塞事项" value={overview?.blockedItemCount ?? 0} tone="danger" />
<Metric icon={Gauge} label="风险版本" value={riskTotal} tone="risk" />
</div>
<div className="grid grid-cols-[1.15fr_0.85fr] gap-4">
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
<div className="border-b border-[var(--line)] px-4 py-3 text-[13px] font-semibold text-[var(--ink)]"></div>
<div className="divide-y divide-[var(--line)]">
{[...(overview?.overdueItems ?? []), ...(overview?.blockedItems ?? [])].slice(0, 12).map((item) => (
<div key={`${item.type}-${item.id}`} className="grid grid-cols-[72px_1fr_120px] gap-3 px-4 py-2.5 text-[12px]">
<span className="rounded bg-[var(--bg-subtle)] px-2 py-1 text-center text-[11px] text-[var(--ink-muted)]">{TYPE_LABEL[item.type] ?? item.type}</span>
<span className="min-w-0 truncate text-[var(--ink)]">{item.title}</span>
<span className="truncate text-right text-[var(--ink-muted)]">{item.ownerId ?? '-'}</span>
</div>
))}
{(!overview || (overview.overdueItems.length + overview.blockedItems.length) === 0) && (
<div className="px-4 py-8 text-center text-[12px] text-[var(--ink-muted)]"></div>
)}
</div>
</section>
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
<div className="flex items-center gap-2 border-b border-[var(--line)] px-4 py-3 text-[13px] font-semibold text-[var(--ink)]">
<Users className="h-4 w-4 text-[var(--accent)]" />
</div>
<div className="divide-y divide-[var(--line)]">
{(overview?.memberLoads ?? []).slice(0, 10).map((item) => (
<div key={item.memberId} className="flex items-center gap-3 px-4 py-2.5">
<span className="min-w-0 flex-1 truncate text-[12px] text-[var(--ink)]">{item.memberId}</span>
<span className="rounded bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-700">{item.openItemCount}</span>
</div>
))}
{(!overview || overview.memberLoads.length === 0) && (
<div className="px-4 py-8 text-center text-[12px] text-[var(--ink-muted)]"></div>
)}
</div>
</section>
</div>
</div>
</div>
);
}
function Metric({ icon: Icon, label, value, tone = 'neutral' }: { icon: any; label: string; value: number; tone?: 'neutral' | 'warn' | 'danger' | 'risk' }) {
const toneClass = {
neutral: 'text-[var(--accent)] bg-blue-50',
warn: 'text-orange-700 bg-orange-50',
danger: 'text-red-700 bg-red-50',
risk: 'text-purple-700 bg-purple-50',
}[tone];
return (
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-[12px] text-[var(--ink-muted)]">{label}</span>
<span className={`rounded-md p-1.5 ${toneClass}`}><Icon className="h-4 w-4" /></span>
</div>
<div className="text-[26px] font-semibold tabular-nums text-[var(--ink)]">{value}</div>
</div>
);
}
export default function ManagementPage() {
return (
<RouteGuard permission="management:view">
<ManagementPageInner />
</RouteGuard>
);
}