feat: 实现需求管理、加班记录、成员/角色管理模块

- 需求模块:完整 CRUD、状态流转(采纳/拒绝/关闭)、详情抽屉、产品→项目级联选择
- 加班记录:产品→项目→版本三级联动、月份筛选(MonthPicker)、CSV 导出
- 成员管理:左右布局(部门树+成员列表)、手机号脱敏、初始密码自动生成及规则设置
- 角色管理:卡片列表、系统角色保护、CRUD
- 通用组件:FilterSelect 下拉、MonthPicker 月份选择器、Pagination 分页
- 样式统一:状态标签加 border、日期输入现代化、筛选组件风格一致

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Script Generator
2026-06-09 18:16:18 +08:00
parent 24ba61f929
commit 9a0b16a8f1
36 changed files with 4355 additions and 272 deletions

View File

@@ -0,0 +1,69 @@
import { Stage, Role, STAGES, STAGE_INDEX } from '@/lib/stage';
import type { RoleProgress } from '@/lib/derive';
export function CapsuleStages({ currentStage, progress }: {
currentStage?: Stage;
progress?: RoleProgress[];
}) {
const currentIdx = currentStage !== undefined
? (currentStage === 'released' ? STAGES.length : STAGE_INDEX[currentStage])
: -1;
const progressMap = (progress ?? []).reduce<Record<Role, { percent: number; daysSpent: number }>>((acc, p) => {
acc[p.role] = { percent: p.percent, daysSpent: p.daysSpent };
return acc;
}, {} as Record<Role, { percent: number; daysSpent: number }>);
const stageRoleMap: Record<Stage, Role[]> = {
requirement: ['product'],
product_design: ['product'],
ui_design: ['ui'],
dev: ['frontend', 'backend'],
integration: ['frontend', 'backend'],
testing: ['testing'],
released: [],
};
function getStageInfo(stage: Stage) {
const roles = stageRoleMap[stage];
if (roles.length === 0) return { percent: 0, days: 0, hasData: false };
const items = roles.map((r) => progressMap[r]).filter(Boolean);
if (items.length === 0) return { percent: 0, days: 0, hasData: false };
const percent = Math.round(items.reduce((s, i) => s + i.percent, 0) / items.length);
const days = Math.max(...items.map((i) => i.daysSpent));
return { percent, days, hasData: true };
}
return (
<div className="flex rounded-lg border border-[var(--line)] overflow-hidden bg-[var(--bg-card)]">
{STAGES.map((stage, idx) => {
const isCompleted = idx < currentIdx;
const isCurrent = idx === currentIdx;
const info = getStageInfo(stage.key);
return (
<div
key={stage.key}
className={`flex-1 flex flex-col ${idx < STAGES.length - 1 ? 'border-r border-[var(--line-soft)]' : ''}`}
>
<div className="flex items-center justify-between px-2 py-1.5 min-h-[28px]">
<span className={`text-[10px] font-medium leading-tight ${isCurrent ? 'text-[var(--ink)]' : isCompleted ? 'text-[var(--ink-soft)]' : 'text-[var(--ink-muted)]'}`}>
{stage.label}
</span>
<span className={`text-[10px] leading-tight ${isCompleted ? 'text-emerald-600' : isCurrent ? 'text-blue-600 font-medium' : 'text-[var(--ink-muted)]'}`}>
{isCompleted ? (info.hasData ? `${info.days}` : '-') : isCurrent ? (info.hasData ? `${info.percent}%` : '-') : ''}
</span>
</div>
<div className="h-[3px] w-full bg-zinc-50">
{isCompleted && <div className="h-full bg-zinc-700 w-full" />}
{isCurrent && info.hasData && (
<div className="h-full bg-blue-100 w-full">
<div className="h-full bg-blue-500 transition-all" style={{ width: `${info.percent}%` }} />
</div>
)}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
interface TrendPoint {
date: string;
score: number;
}
interface HealthTrendProps {
data: TrendPoint[];
}
function getColor(score: number) {
if (score >= 90) return '#10b981';
if (score >= 75) return '#3b82f6';
if (score >= 60) return '#f59e0b';
if (score >= 35) return '#f97316';
return '#ef4444';
}
export function HealthTrend({ data }: HealthTrendProps) {
if (data.length < 2) return null;
const w = 160;
const h = 48;
const pad = { top: 4, right: 4, bottom: 12, left: 4 };
const chartW = w - pad.left - pad.right;
const chartH = h - pad.top - pad.bottom;
const scores = data.map((d) => d.score);
const min = Math.max(0, Math.min(...scores) - 5);
const max = Math.min(100, Math.max(...scores) + 5);
const range = max - min || 1;
const points = data.map((d, i) => ({
x: pad.left + (i / (data.length - 1)) * chartW,
y: pad.top + chartH - ((d.score - min) / range) * chartH,
...d,
}));
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
const lastPoint = points[points.length - 1];
const firstPoint = points[0];
const trend = lastPoint.score - firstPoint.score;
return (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-[var(--ink-muted)]">{data.length}</span>
<span className={`text-[10px] font-medium ${trend > 0 ? 'text-emerald-600' : trend < 0 ? 'text-red-600' : 'text-[var(--ink-muted)]'}`}>
{trend > 0 ? '↑' : trend < 0 ? '↓' : '→'} {trend > 0 ? '+' : ''}{trend}
</span>
</div>
<svg width={w} height={h} className="w-full">
<path d={pathD} fill="none" stroke={getColor(lastPoint.score)} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" opacity="0.8" />
{points.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r="2" fill={getColor(p.score)} />
))}
<text x={firstPoint.x} y={h - 1} textAnchor="start" fontSize="8" fill="var(--ink-muted)">{firstPoint.date.slice(5)}</text>
<text x={lastPoint.x} y={h - 1} textAnchor="end" fontSize="8" fill="var(--ink-muted)">{lastPoint.date.slice(5)}</text>
</svg>
</div>
);
}
export function generateMockTrend(currentScore: number, days: number = 7): TrendPoint[] {
const result: TrendPoint[] = [];
const now = new Date();
for (let i = days - 1; i >= 0; i--) {
const date = new Date(now);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().slice(0, 10);
const drift = (days - 1 - i) * (currentScore < 60 ? -1.5 : 0.5);
const jitter = Math.round((Math.random() - 0.5) * 6);
const score = Math.max(0, Math.min(100, Math.round(currentScore - drift + jitter)));
result.push({ date: dateStr, score });
}
result[result.length - 1].score = currentScore;
return result;
}

View File

@@ -0,0 +1,23 @@
import { Role, ROLES } from '@/lib/stage';
export function MemberChips({ members }: { members: { role: Role; name: string }[] }) {
if (!members || members.length === 0) return null;
const grouped = ROLES.reduce<Record<Role, string[]>>((acc, r) => {
acc[r.key] = members.filter((m) => m.role === r.key).map((m) => m.name);
return acc;
}, {} as Record<Role, string[]>);
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]">
{ROLES.map((r) => {
const names = grouped[r.key];
if (!names || names.length === 0) return null;
return (
<span key={r.key} className="inline-flex items-center gap-1 text-[var(--ink-soft)]">
<span className="font-medium text-[var(--ink-muted)]">{r.label}</span>
{names.join('/')}
</span>
);
})}
</div>
);
}