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:
79
apps/web/components/version/HealthTrend.tsx
Normal file
79
apps/web/components/version/HealthTrend.tsx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user