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:
@@ -1,17 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Search, Tag, Plus, X, ChevronDown } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Search, Tag, Plus, X, ChevronDown, MoreHorizontal, Pause, Play, XCircle } from 'lucide-react';
|
||||
import { useProductStore } from '@/stores/useProductStore';
|
||||
import { flattenVersions, flattenProjects } from '@/lib/derive';
|
||||
import type { VersionWithContext } from '@/lib/derive';
|
||||
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_DOT } from '@/lib/version-status';
|
||||
import { STAGES } from '@/lib/stage';
|
||||
import { ROLE_LABEL } from '@/lib/stage';
|
||||
import { calcOverallProgress } from '@/lib/risk';
|
||||
import { calcHealthScore, getHealthLevel, calcRiskTags, HEALTH_LEVEL_COLOR, HEALTH_LEVEL_DOT, getTagStyle } from '@/lib/health';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
|
||||
const STATUS_TABS: { key: VersionStatus | 'all'; label: string }[] = [
|
||||
type Priority = 'P0' | 'P1' | 'P2' | 'P3' | 'P4';
|
||||
|
||||
const PRIORITY_COLORS: Record<Priority, string> = {
|
||||
P0: 'bg-red-500/10 text-red-600',
|
||||
P1: 'bg-orange-500/10 text-orange-600',
|
||||
P2: 'bg-blue-500/10 text-blue-600',
|
||||
P3: 'bg-zinc-100 text-zinc-600',
|
||||
P4: 'bg-zinc-100 text-zinc-500',
|
||||
};
|
||||
|
||||
const PRIORITY_ORDER: Record<Priority, number> = {
|
||||
P0: 0,
|
||||
P1: 1,
|
||||
P2: 2,
|
||||
P3: 3,
|
||||
P4: 4,
|
||||
};
|
||||
|
||||
const STATUS_TABS: { key: string; label: string }[] = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'developing', label: '开发中' },
|
||||
{ key: 'planned', label: '规划中' },
|
||||
{ key: 'requirement', label: '调研' },
|
||||
{ key: 'product_design', label: '产品设计' },
|
||||
{ key: 'ui_design', label: 'UI设计' },
|
||||
{ key: 'dev', label: '开发' },
|
||||
{ key: 'integration', label: '联调' },
|
||||
{ key: 'testing', label: '测试' },
|
||||
{ key: 'released', label: '已发布' },
|
||||
{ key: 'paused', label: '已暂停' },
|
||||
{ key: 'closed', label: '已关闭' },
|
||||
{ key: 'planned', label: '规划中' },
|
||||
];
|
||||
|
||||
function parseVersionNumber(name: string): number[] {
|
||||
@@ -34,19 +65,57 @@ function compareVersionsDesc(a: string, b: string): number {
|
||||
|
||||
function sortVersions(versions: VersionWithContext[]): VersionWithContext[] {
|
||||
return [...versions].sort((a, b) => {
|
||||
const projCmp = a.projectName.localeCompare(b.projectName, 'zh-CN');
|
||||
if (projCmp !== 0) return projCmp;
|
||||
return compareVersionsDesc(a.name, b.name);
|
||||
// Sort by priority desc (P0 first)
|
||||
const pa = PRIORITY_ORDER[(a.priority ?? 'P2') as Priority] ?? 2;
|
||||
const pb = PRIORITY_ORDER[(b.priority ?? 'P2') as Priority] ?? 2;
|
||||
if (pa !== pb) return pa - pb;
|
||||
// Then by createdAt desc
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
function getStageLabel(version: VersionWithContext): string {
|
||||
if (version.status === 'planned') return '规划中';
|
||||
if (version.status === 'released') return '已发布';
|
||||
if (version.status === 'paused') return '已暂停';
|
||||
if (version.status === 'closed') return '已关闭';
|
||||
if (version.currentStage) {
|
||||
const stage = STAGES.find((s) => s.key === version.currentStage);
|
||||
return stage?.label ?? '-';
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
|
||||
const STAGE_ROLE_MAP: Record<string, string[]> = {
|
||||
requirement: ['product'],
|
||||
product_design: ['product'],
|
||||
ui_design: ['ui'],
|
||||
dev: ['frontend', 'backend'],
|
||||
integration: ['frontend', 'backend'],
|
||||
testing: ['testing'],
|
||||
released: [],
|
||||
};
|
||||
|
||||
function getStageProgress(version: VersionWithContext): number {
|
||||
if (!version.currentStage || !version.progress) return 0;
|
||||
const roles = STAGE_ROLE_MAP[version.currentStage] || [];
|
||||
if (roles.length === 0) return 0;
|
||||
const items = roles.map((r) => version.progress!.find((p) => p.role === r)).filter(Boolean);
|
||||
if (items.length === 0) return 0;
|
||||
return Math.round(items.reduce((s, i) => s + i!.percent, 0) / items.length);
|
||||
}
|
||||
|
||||
export default function VersionsPage() {
|
||||
const { overview, fetchOverview, createVersion } = useProductStore();
|
||||
const router = useRouter();
|
||||
const { overview, fetchOverview, createVersion, updateVersion } = useProductStore();
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusTab, setStatusTab] = useState<VersionStatus | 'all'>('all');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [projectFilter, setProjectFilter] = useState('all');
|
||||
const [priorityFilter, setPriorityFilter] = useState('all');
|
||||
const [projectDropdownOpen, setProjectDropdownOpen] = useState(false);
|
||||
const [priorityDropdownOpen, setPriorityDropdownOpen] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||
|
||||
@@ -54,14 +123,40 @@ export default function VersionsPage() {
|
||||
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const list = allVersions.filter((v) => {
|
||||
if (statusTab !== 'all' && v.status !== statusTab) return false;
|
||||
if (projectFilter !== 'all' && v.projectName !== projectFilter) return false;
|
||||
let list = allVersions.filter((v) => {
|
||||
if (search && !v.name.toLowerCase().includes(search.toLowerCase())) return false;
|
||||
if (projectFilter !== 'all' && v.projectName !== projectFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (statusFilter !== 'all') {
|
||||
if (['planned', 'released', 'paused', 'closed'].includes(statusFilter)) {
|
||||
list = list.filter(v => v.status === statusFilter);
|
||||
} else {
|
||||
list = list.filter(v => v.status === 'developing' && v.currentStage === statusFilter);
|
||||
}
|
||||
}
|
||||
|
||||
if (priorityFilter !== 'all') {
|
||||
list = list.filter(v => (v.priority ?? 'P2') === priorityFilter);
|
||||
}
|
||||
|
||||
return sortVersions(list);
|
||||
}, [allVersions, search, statusTab, projectFilter]);
|
||||
}, [allVersions, search, statusFilter, projectFilter, priorityFilter]);
|
||||
|
||||
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
|
||||
|
||||
const handleAction = async (version: VersionWithContext, action: 'pause' | 'resume' | 'close') => {
|
||||
setOpenMenuId(null);
|
||||
const statusMap: Record<string, VersionStatus> = {
|
||||
pause: 'paused',
|
||||
resume: 'developing',
|
||||
close: 'closed',
|
||||
};
|
||||
if (updateVersion) {
|
||||
await updateVersion(version.productId, version.id, { status: statusMap[action] });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -77,40 +172,63 @@ export default function VersionsPage() {
|
||||
</header>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex shrink-0 items-center gap-3 border-b border-[var(--line)] bg-[var(--bg-card)] px-5 py-3">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-3 border-b border-[var(--line)] bg-[var(--bg-card)] px-5 py-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
|
||||
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="搜索版本号" className="h-8 w-64 rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]" />
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{STATUS_TABS.map((tab) => (
|
||||
<button key={tab.key} onClick={() => setStatusTab(tab.key)} className={`h-8 rounded-lg px-3 text-[12.5px] transition-colors ${statusTab === tab.key ? 'bg-[var(--accent)] text-white' : 'border border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)]'}`}>
|
||||
<button key={tab.key} onClick={() => setStatusFilter(tab.key)} className={`h-8 rounded-lg px-3 text-[12px] transition-colors ${statusFilter === tab.key ? 'bg-[var(--accent)] text-white' : 'border border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)]'}`}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Project dropdown */}
|
||||
<div className="relative">
|
||||
<button onClick={() => setProjectDropdownOpen(!projectDropdownOpen)} className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12.5px] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors">
|
||||
<button onClick={() => setProjectDropdownOpen(!projectDropdownOpen)} className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors">
|
||||
{projectFilter === 'all' ? '全部项目' : projectFilter}
|
||||
<ChevronDown className="h-3 w-3" strokeWidth={2} />
|
||||
</button>
|
||||
{projectDropdownOpen && (
|
||||
<div className="absolute left-0 top-full z-10 mt-1 min-w-[140px] rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
|
||||
<button onClick={() => { setProjectFilter('all'); setProjectDropdownOpen(false); }} className={`block w-full px-3 py-1.5 text-left text-[12.5px] transition-colors ${projectFilter === 'all' ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]'}`}>
|
||||
全部项目
|
||||
</button>
|
||||
{allProjects.map((p) => (
|
||||
<button key={p.id} onClick={() => { setProjectFilter(p.name); setProjectDropdownOpen(false); }} className={`block w-full px-3 py-1.5 text-left text-[12.5px] transition-colors ${projectFilter === p.name ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]'}`}>
|
||||
{p.name}
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setProjectDropdownOpen(false)} />
|
||||
<div className="absolute left-0 top-full z-20 mt-1 min-w-[140px] rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
|
||||
<button onClick={() => { setProjectFilter('all'); setProjectDropdownOpen(false); }} className={`block w-full px-3 py-1.5 text-left text-[12px] transition-colors ${projectFilter === 'all' ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]'}`}>
|
||||
全部项目
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{allProjects.map((p) => (
|
||||
<button key={p.id} onClick={() => { setProjectFilter(p.name); setProjectDropdownOpen(false); }} className={`block w-full px-3 py-1.5 text-left text-[12px] transition-colors ${projectFilter === p.name ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]'}`}>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-3 text-[11.5px] text-[var(--ink-muted)]">
|
||||
<span className="flex items-center gap-1.5"><span className="inline-block h-1.5 w-1.5 rounded-full bg-blue-500" />开发中</span>
|
||||
<span className="flex items-center gap-1.5"><span className="inline-block h-1.5 w-1.5 rounded-full bg-orange-500" />规划中</span>
|
||||
<span className="flex items-center gap-1.5"><span className="inline-block h-1.5 w-1.5 rounded-full bg-zinc-300" />已发布</span>
|
||||
|
||||
{/* Priority dropdown */}
|
||||
<div className="relative">
|
||||
<button onClick={() => setPriorityDropdownOpen(!priorityDropdownOpen)} className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors">
|
||||
{priorityFilter === 'all' ? '全部优先级' : priorityFilter}
|
||||
<ChevronDown className="h-3 w-3" strokeWidth={2} />
|
||||
</button>
|
||||
{priorityDropdownOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setPriorityDropdownOpen(false)} />
|
||||
<div className="absolute left-0 top-full z-20 mt-1 min-w-[100px] rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
|
||||
<button onClick={() => { setPriorityFilter('all'); setPriorityDropdownOpen(false); }} className={`block w-full px-3 py-1.5 text-left text-[12px] transition-colors ${priorityFilter === 'all' ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]'}`}>
|
||||
全部优先级
|
||||
</button>
|
||||
{(['P0', 'P1', 'P2', 'P3', 'P4'] as Priority[]).map((p) => (
|
||||
<button key={p} onClick={() => { setPriorityFilter(p); setPriorityDropdownOpen(false); }} className={`block w-full px-3 py-1.5 text-left text-[12px] transition-colors ${priorityFilter === p ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]'}`}>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -119,26 +237,43 @@ export default function VersionsPage() {
|
||||
<div className="px-5 py-4">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] py-20 text-center">
|
||||
<p className="text-[14px] font-medium text-[var(--ink-soft)]">没有找到匹配的版本</p>
|
||||
<p className="mt-1.5 text-[12.5px] text-[var(--ink-muted)]">尝试调整筛选条件</p>
|
||||
<p className="text-[13px] font-medium text-[var(--ink-soft)]">没有找到匹配的版本</p>
|
||||
<p className="mt-1.5 text-[12px] text-[var(--ink-muted)]">尝试调整筛选条件</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||
<>
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||
<table className="w-full text-left text-[13px]">
|
||||
<thead>
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
|
||||
<th className="px-4 py-2.5 text-[11.5px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">版本号</th>
|
||||
<th className="px-4 py-2.5 text-[11.5px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">状态</th>
|
||||
<th className="px-4 py-2.5 text-[11.5px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">项目</th>
|
||||
<th className="px-4 py-2.5 text-[11.5px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">产品</th>
|
||||
<th className="px-4 py-2.5 text-[11.5px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">创建时间</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">版本号</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">优先级</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">当前阶段</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">阶段进度</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">整体进度</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">健康度</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">风险标签</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">截止日期</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">负责人</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((v) => <VersionRow key={v.id} version={v} />)}
|
||||
{paged.map((v) => (
|
||||
<VersionRow
|
||||
key={v.id}
|
||||
version={v}
|
||||
openMenuId={openMenuId}
|
||||
setOpenMenuId={setOpenMenuId}
|
||||
onNavigate={() => router.push(`/versions/${v.id}`)}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,25 +283,169 @@ export default function VersionsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function VersionRow({ version }: { version: VersionWithContext }) {
|
||||
const dotCls = VERSION_STATUS_DOT[version.status];
|
||||
interface VersionRowProps {
|
||||
version: VersionWithContext;
|
||||
openMenuId: string | null;
|
||||
setOpenMenuId: (id: string | null) => void;
|
||||
onNavigate: () => void;
|
||||
onAction: (version: VersionWithContext, action: 'pause' | 'resume' | 'close') => void;
|
||||
}
|
||||
|
||||
function VersionRow({ version, openMenuId, setOpenMenuId, onNavigate, onAction }: VersionRowProps) {
|
||||
const priority = (version.priority ?? 'P2') as Priority;
|
||||
const overallProgress = calcOverallProgress(version.progress);
|
||||
const healthScore = calcHealthScore(version.status, version.startDate, version.expectedReleaseDate, version.progress);
|
||||
const healthLevel = getHealthLevel(healthScore);
|
||||
const riskTags = calcRiskTags(version.status, version.startDate, version.expectedReleaseDate, version.progress, version.currentStage, version.members);
|
||||
const isMenuOpen = openMenuId === version.id;
|
||||
|
||||
// Members: show first 2 with role label, then +N
|
||||
const members = version.members ?? [];
|
||||
const displayMembers = members.slice(0, 2);
|
||||
const extraCount = members.length - 2;
|
||||
|
||||
return (
|
||||
<tr className="border-b border-[var(--line-soft)] last:border-0 transition-colors hover:bg-[var(--bg-subtle)]">
|
||||
<td className="px-4 py-3 font-medium text-[var(--ink)]">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 版本号 */}
|
||||
<td className="px-4 py-3">
|
||||
<button onClick={onNavigate} className="flex items-center gap-2 font-medium text-[var(--ink)] hover:text-[var(--accent)] transition-colors">
|
||||
<Tag className="h-3.5 w-3.5 text-[var(--ink-muted)]" strokeWidth={1.75} />
|
||||
{version.name}
|
||||
</div>
|
||||
</button>
|
||||
</td>
|
||||
|
||||
{/* 优先级 */}
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center gap-1.5 text-[12px] text-[var(--ink-soft)]">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${dotCls}`} />
|
||||
{VERSION_STATUS_LABEL[version.status]}
|
||||
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${PRIORITY_COLORS[priority]}`}>
|
||||
{priority}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--ink-soft)]">{version.projectName}</td>
|
||||
<td className="px-4 py-3 text-[var(--ink-muted)]">{version.productName}</td>
|
||||
<td className="px-4 py-3 text-[var(--ink-muted)] tabular-nums">{new Date(version.createdAt).toLocaleDateString('zh-CN')}</td>
|
||||
|
||||
{/* 当前阶段 */}
|
||||
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
|
||||
{getStageLabel(version)}
|
||||
</td>
|
||||
|
||||
{/* 阶段进度 */}
|
||||
<td className="px-4 py-3">
|
||||
{version.status === 'developing' && version.currentStage ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-16 rounded-full bg-zinc-100">
|
||||
<div className="h-1.5 rounded-full bg-blue-500 transition-all" style={{ width: `${getStageProgress(version)}%` }} />
|
||||
</div>
|
||||
<span className="text-[11px] tabular-nums text-[var(--ink-muted)]">{getStageProgress(version)}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[12px] text-[var(--ink-muted)]">-</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* 整体进度 */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-20 rounded-full bg-zinc-100">
|
||||
<div
|
||||
className="h-1.5 rounded-full bg-blue-500 transition-all"
|
||||
style={{ width: `${overallProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[11px] tabular-nums text-[var(--ink-muted)]">{overallProgress}%</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* 健康度 */}
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1.5 text-[12px] font-semibold tabular-nums ${HEALTH_LEVEL_COLOR[healthLevel]}`}>
|
||||
<span className={`h-2 w-2 rounded-full ${HEALTH_LEVEL_DOT[healthLevel]}`} />
|
||||
{healthScore}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* 风险标签 */}
|
||||
<td className="px-4 py-3">
|
||||
{riskTags.length === 0 ? (
|
||||
<span className="text-[11px] text-[var(--ink-muted)]">-</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{riskTags.slice(0, 3).map((tag) => (
|
||||
<span key={tag.key} className={`inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium ${getTagStyle(tag.severity)}`}>
|
||||
{tag.label}
|
||||
</span>
|
||||
))}
|
||||
{riskTags.length > 3 && (
|
||||
<span className="text-[10px] text-[var(--ink-muted)]">+{riskTags.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* 截止日期 */}
|
||||
<td className="px-4 py-3 text-[var(--ink-muted)] tabular-nums">
|
||||
{version.expectedReleaseDate
|
||||
? new Date(version.expectedReleaseDate).toISOString().slice(0, 10)
|
||||
: '-'}
|
||||
</td>
|
||||
|
||||
{/* 负责人 */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1 text-[11px] text-[var(--ink-soft)]">
|
||||
{displayMembers.map((m, i) => (
|
||||
<span key={i} className="whitespace-nowrap">
|
||||
{ROLE_LABEL[m.role]}:{m.name}
|
||||
</span>
|
||||
))}
|
||||
{extraCount > 0 && (
|
||||
<span className="whitespace-nowrap text-[var(--ink-muted)]">+{extraCount}</span>
|
||||
)}
|
||||
{members.length === 0 && <span className="text-[var(--ink-muted)]">-</span>}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* 操作 */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpenMenuId(isMenuOpen ? null : version.id)}
|
||||
className="rounded-lg p-1.5 text-[var(--ink-muted)] hover:bg-[var(--bg-hover)] transition-colors"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" strokeWidth={1.75} />
|
||||
</button>
|
||||
{isMenuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpenMenuId(null)} />
|
||||
<div className="absolute right-0 top-full z-20 mt-1 min-w-[120px] rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
|
||||
{version.status === 'developing' && (
|
||||
<button
|
||||
onClick={() => onAction(version, 'pause')}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px] text-[var(--ink-soft)] hover:bg-[var(--bg-hover)] transition-colors"
|
||||
>
|
||||
<Pause className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||||
暂停
|
||||
</button>
|
||||
)}
|
||||
{version.status === 'paused' && (
|
||||
<button
|
||||
onClick={() => onAction(version, 'resume')}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px] text-[var(--ink-soft)] hover:bg-[var(--bg-hover)] transition-colors"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||||
恢复
|
||||
</button>
|
||||
)}
|
||||
{version.status !== 'closed' && version.status !== 'released' && (
|
||||
<button
|
||||
onClick={() => onAction(version, 'close')}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px] text-red-600 hover:bg-[var(--bg-hover)] transition-colors"
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||||
关闭
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -176,7 +455,7 @@ type IterationType = 'major' | 'minor' | 'patch';
|
||||
interface NewVersionModalProps {
|
||||
overview: any[];
|
||||
onClose: () => void;
|
||||
onCreate: (productId: string, data: { name: string; status: VersionStatus }) => void;
|
||||
onCreate: (productId: string, data: { name: string; status: VersionStatus; priority?: Priority; expectedReleaseDate?: string }) => void;
|
||||
}
|
||||
|
||||
function getNextVersion(existingVersions: any[], projectName: string, type: IterationType): string {
|
||||
@@ -224,6 +503,8 @@ function NewVersionModal({ overview, onClose, onCreate }: NewVersionModalProps)
|
||||
const [iterationType, setIterationType] = useState<IterationType | ''>('');
|
||||
const [versionNumber, setVersionNumber] = useState('');
|
||||
const [status, setStatus] = useState<VersionStatus>('planned');
|
||||
const [priority, setPriority] = useState<Priority>('P2');
|
||||
const [expectedReleaseDate, setExpectedReleaseDate] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const selectedProduct = overview.find((p: any) => p.id === productId);
|
||||
@@ -269,7 +550,12 @@ function NewVersionModal({ overview, onClose, onCreate }: NewVersionModalProps)
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onCreate(productId, { name: `${projectName}V${versionNumber}`, status });
|
||||
await onCreate(productId, {
|
||||
name: `${projectName}V${versionNumber}`,
|
||||
status,
|
||||
priority,
|
||||
...(expectedReleaseDate ? { expectedReleaseDate } : {}),
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setSubmitting(false);
|
||||
@@ -289,7 +575,7 @@ function NewVersionModal({ overview, onClose, onCreate }: NewVersionModalProps)
|
||||
<div className="space-y-4">
|
||||
{/* 产品选择 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12.5px] font-medium text-[var(--ink-soft)]">产品选择<span className="text-red-500">*</span></label>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]">产品选择<span className="text-red-500">*</span></label>
|
||||
<select value={productId} onChange={(e) => handleProductChange(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]">
|
||||
<option value="">请选择产品</option>
|
||||
{overview.map((p: any) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
@@ -298,7 +584,7 @@ function NewVersionModal({ overview, onClose, onCreate }: NewVersionModalProps)
|
||||
|
||||
{/* 项目选择 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12.5px] font-medium text-[var(--ink-soft)]">项目选择<span className="text-red-500">*</span></label>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]">项目选择<span className="text-red-500">*</span></label>
|
||||
<select value={projectId} onChange={(e) => handleProjectChange(e.target.value)} disabled={!productId} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] disabled:opacity-50 focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]">
|
||||
<option value="">请选择项目</option>
|
||||
{projects.map((p: any) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
@@ -307,7 +593,7 @@ function NewVersionModal({ overview, onClose, onCreate }: NewVersionModalProps)
|
||||
|
||||
{/* 迭代类型 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12.5px] font-medium text-[var(--ink-soft)]">迭代类型<span className="text-red-500">*</span></label>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]">迭代类型<span className="text-red-500">*</span></label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{([
|
||||
{ key: 'major' as const, label: '大版本', hint: 'X.0' },
|
||||
@@ -315,8 +601,8 @@ function NewVersionModal({ overview, onClose, onCreate }: NewVersionModalProps)
|
||||
{ key: 'patch' as const, label: '小版本', hint: '1.2.X' },
|
||||
]).map((opt) => (
|
||||
<button key={opt.key} onClick={() => handleIterationChange(opt.key)} disabled={!projectId} className={`flex flex-col items-center gap-0.5 rounded-lg border px-3 py-2 transition-colors disabled:opacity-50 ${iterationType === opt.key ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}>
|
||||
<span className="text-[12.5px] font-medium">{opt.label}</span>
|
||||
<span className="text-[10.5px] text-[var(--ink-muted)] tabular-nums">{opt.hint}</span>
|
||||
<span className="text-[12px] font-medium">{opt.label}</span>
|
||||
<span className="text-[10px] text-[var(--ink-muted)] tabular-nums">{opt.hint}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -324,27 +610,50 @@ function NewVersionModal({ overview, onClose, onCreate }: NewVersionModalProps)
|
||||
|
||||
{/* 版本号 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12.5px] font-medium text-[var(--ink-soft)]">版本号</label>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]">版本号</label>
|
||||
<input value={versionNumber} onChange={(e) => handleVersionInput(e.target.value)} placeholder="1.0" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] tabular-nums text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]" />
|
||||
{projectName && versionNumber && (
|
||||
<p className="mt-1.5 text-[11.5px] text-[var(--ink-muted)]">将创建:<span className="font-medium text-[var(--ink-soft)]">{projectName}V{versionNumber}</span></p>
|
||||
<p className="mt-1.5 text-[11px] text-[var(--ink-muted)]">将创建:<span className="font-medium text-[var(--ink-soft)]">{projectName}V{versionNumber}</span></p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 状态 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12.5px] font-medium text-[var(--ink-soft)]">状态</label>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]">状态</label>
|
||||
<div className="flex gap-2">
|
||||
{([
|
||||
{ key: 'planned' as VersionStatus, label: '规划中' },
|
||||
{ key: 'developing' as VersionStatus, label: '开发中' },
|
||||
]).map((opt) => (
|
||||
<button key={opt.key} onClick={() => setStatus(opt.key)} className={`flex-1 h-9 rounded-lg border px-3 text-[12.5px] transition-colors ${status === opt.key ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}>
|
||||
<button key={opt.key} onClick={() => setStatus(opt.key)} className={`flex-1 h-9 rounded-lg border px-3 text-[12px] transition-colors ${status === opt.key ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 优先级 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]">优先级</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['P0', 'P1', 'P2', 'P3', 'P4'] as Priority[]).map((p) => (
|
||||
<button key={p} onClick={() => setPriority(p)} className={`h-9 rounded-lg border px-3 text-[12px] font-medium transition-colors ${priority === p ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 截止日期 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]">截止日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={expectedReleaseDate}
|
||||
onChange={(e) => setExpectedReleaseDate(e.target.value)}
|
||||
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
|
||||
Reference in New Issue
Block a user