feat(产品方案): 优化计划卡片展示和排序
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
calcTotalDuration,
|
||||
calcPlanProgress,
|
||||
calcLinkedReqProgress,
|
||||
sortPlansNewestFirst,
|
||||
PRODUCT_PLAN_KIND_LABEL,
|
||||
PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS,
|
||||
PRODUCT_PLAN_REVIEW_RESULT_LABEL,
|
||||
@@ -71,7 +72,7 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
const [transferPlanId, setTransferPlanId] = useState<string | null>(null);
|
||||
const [transferTo, setTransferTo] = useState('');
|
||||
|
||||
const typePlans = plans.filter((p) => p.versionId === versionId && p.type === planType);
|
||||
const typePlans = sortPlansNewestFirst(plans.filter((p) => p.versionId === versionId && p.type === planType));
|
||||
const totalDuration = calcTotalDuration(typePlans);
|
||||
|
||||
return (
|
||||
@@ -116,36 +117,59 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
onUpdate(plan.id, { status: 'in_progress' });
|
||||
}
|
||||
return (
|
||||
<div key={plan.id} className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-[13px] font-medium text-[var(--ink)]">{plan.title}</span>
|
||||
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium border ${STATUS_STYLE[effectiveStatus]}`}>
|
||||
<div key={plan.id} className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 shadow-[var(--shadow-sm)]">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 truncate text-[14px] font-semibold text-[var(--ink)]" title={plan.title}>{plan.title}</span>
|
||||
<span className={`inline-flex shrink-0 items-center rounded-md px-2 py-0.5 text-[10px] font-medium border ${STATUS_STYLE[effectiveStatus]}`}>
|
||||
{STATUS_LABEL[effectiveStatus]}
|
||||
</span>
|
||||
{plan.type === 'product' && (
|
||||
<span className="inline-flex items-center rounded-md border border-[var(--line)] bg-[var(--bg-subtle)] px-2 py-0.5 text-[10px] font-medium text-[var(--ink-muted)]">
|
||||
<span className="inline-flex shrink-0 items-center rounded-md border border-[var(--line)] bg-[var(--bg-subtle)] px-2 py-0.5 text-[10px] font-medium text-[var(--ink-muted)]">
|
||||
{PRODUCT_PLAN_KIND_LABEL[getProductPlanKind(plan)]}
|
||||
</span>
|
||||
)}
|
||||
{plan.type === 'product' && plan.reviewResult && (
|
||||
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium ${plan.reviewResult === 'passed' ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'}`}>
|
||||
<span className={`inline-flex shrink-0 items-center rounded-md px-2 py-0.5 text-[10px] font-medium ${plan.reviewResult === 'passed' ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'}`}>
|
||||
{PRODUCT_PLAN_REVIEW_RESULT_LABEL[plan.reviewResult]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-[12px] text-[var(--ink-soft)]">
|
||||
<span>计划:{formatDateTime(plan.startTime)} → {formatDateTime(plan.endTime)}</span>
|
||||
{plan.actualStartAt && <span className="text-blue-600">实际开始:{formatDateTime(plan.actualStartAt)}</span>}
|
||||
{plan.completedAt && <span className="text-green-600">完成:{formatDateTime(plan.completedAt)}</span>}
|
||||
<span className="font-medium text-[var(--ink)]">已耗时 {durText}</span>
|
||||
<dl className="mt-3 grid grid-cols-1 gap-x-5 gap-y-2 border-y border-[var(--line)] py-3 text-[12px] sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-[11px] text-[var(--ink-muted)]">负责人</dt>
|
||||
<dd className="mt-0.5 font-medium text-[var(--ink)]">{plan.owner}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-[11px] text-[var(--ink-muted)]">计划时间</dt>
|
||||
<dd className="mt-0.5 font-medium text-[var(--ink)]">{formatDateTime(plan.startTime)} → {formatDateTime(plan.endTime)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[11px] text-[var(--ink-muted)]">已耗时</dt>
|
||||
<dd className="mt-0.5 font-semibold text-[var(--ink)]">{durText}</dd>
|
||||
</div>
|
||||
{plan.actualStartAt && (
|
||||
<div>
|
||||
<dt className="text-[11px] text-[var(--ink-muted)]">实际开始</dt>
|
||||
<dd className="mt-0.5 font-medium text-blue-600">{formatDateTime(plan.actualStartAt)}</dd>
|
||||
</div>
|
||||
{plan.overdueReason && (
|
||||
<div className="mt-1.5 text-[11px] text-red-600 bg-red-50 rounded px-2 py-1 inline-block">超期原因:{plan.overdueReason}</div>
|
||||
)}
|
||||
{(plan.type === 'product' || plan.type === 'ui') && (
|
||||
<div className="mt-1.5 text-[12px] text-[var(--ink-soft)]">负责人:<span className="font-medium text-[var(--ink)]">{plan.owner}</span></div>
|
||||
{plan.completedAt && (
|
||||
<div>
|
||||
<dt className="text-[11px] text-[var(--ink-muted)]">完成时间</dt>
|
||||
<dd className="mt-0.5 font-medium text-green-600">{formatDateTime(plan.completedAt)}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
{plan.overdueReason && (
|
||||
<div className="mt-3 rounded-lg border border-red-100 bg-red-50 px-3 py-2 text-[11px] leading-5 text-red-700">超期原因:{plan.overdueReason}</div>
|
||||
)}
|
||||
{plan.remark && (
|
||||
<div className="mt-3 border-l-2 border-[var(--accent)] pl-3">
|
||||
<div className="text-[11px] font-medium text-[var(--ink-muted)]">备注</div>
|
||||
<div className="mt-1 max-h-20 overflow-y-auto whitespace-pre-wrap pr-1 text-[12px] leading-5 text-[var(--ink-soft)]">{plan.remark}</div>
|
||||
</div>
|
||||
)}
|
||||
{plan.status === 'completed' && plan.resultUrl && (
|
||||
<div className="flex items-center gap-1.5 mt-2">
|
||||
@@ -207,18 +231,26 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
{/* 关联需求 */}
|
||||
{plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0 && requirementOptions.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-medium text-[var(--ink-muted)]">引用需求</div>
|
||||
<div className="mt-0.5 text-[11px] text-[var(--ink-soft)]">
|
||||
已覆盖 {(plan.completedRequirementIds || []).filter((id) => plan.linkedRequirementIds?.includes(id)).length} / {plan.linkedRequirementIds.length}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-[var(--bg-subtle)] overflow-hidden">
|
||||
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds)}%` }} />
|
||||
</div>
|
||||
<span className="text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds)}%</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="max-h-44 space-y-1 overflow-y-auto rounded-lg bg-[var(--bg-subtle)] p-2 pr-1">
|
||||
{plan.linkedRequirementIds.map((rid) => {
|
||||
const req = requirementOptions.find((r) => r.id === rid);
|
||||
const isDone = (plan.completedRequirementIds || []).includes(rid);
|
||||
return req ? (
|
||||
<div key={rid} className="flex items-center gap-2">
|
||||
<div key={rid} className="flex min-w-0 items-center gap-2 rounded-md px-2 py-1 hover:bg-[var(--bg-card)]">
|
||||
<button
|
||||
disabled={!canEditCoverage}
|
||||
onClick={() => {
|
||||
@@ -231,8 +263,8 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
>
|
||||
{isDone && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
|
||||
</button>
|
||||
<span className={`text-[11px] font-mono text-[var(--ink-muted)]`}>{req.code}</span>
|
||||
<span className={`text-[12px] ${isDone ? 'line-through text-[var(--ink-muted)]' : 'text-[var(--ink)]'}`}>{req.title}</span>
|
||||
<span className="shrink-0 text-[11px] font-mono text-[var(--ink-muted)]">{req.code}</span>
|
||||
<span className={`min-w-0 flex-1 truncate text-[12px] ${isDone ? 'line-through text-[var(--ink-muted)]' : 'text-[var(--ink)]'}`} title={req.title}>{req.title}</span>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
|
||||
37
apps/web/lib/version-plan.test.ts
Normal file
37
apps/web/lib/version-plan.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import * as versionPlan from './version-plan';
|
||||
import type { VersionPlan } from './version-plan';
|
||||
|
||||
function plan(patch: Partial<VersionPlan>): VersionPlan {
|
||||
return {
|
||||
id: 'plan-1',
|
||||
versionId: 'version-1',
|
||||
type: 'product',
|
||||
title: '产品方案',
|
||||
owner: 'PM',
|
||||
startTime: '2026-06-25T09:00',
|
||||
endTime: '2026-06-25T18:00',
|
||||
status: 'pending',
|
||||
createdAt: '2026-06-25',
|
||||
addedBy: 'PM',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
test('sortPlansNewestFirst places newly created plans before older plans', () => {
|
||||
const sortPlansNewestFirst = (versionPlan as any).sortPlansNewestFirst as undefined | ((plans: VersionPlan[]) => VersionPlan[]);
|
||||
assert.equal(typeof sortPlansNewestFirst, 'function');
|
||||
|
||||
const plans = [
|
||||
plan({ id: 'plan-100', createdAt: '2026-06-26' }),
|
||||
plan({ id: 'manual-old', createdAt: '2026-06-25' }),
|
||||
plan({ id: 'plan-300', createdAt: '2026-06-26' }),
|
||||
];
|
||||
|
||||
const sorted = (sortPlansNewestFirst as (items: VersionPlan[]) => VersionPlan[])(plans);
|
||||
|
||||
assert.deepEqual(sorted.map((item) => item.id), ['plan-300', 'plan-100', 'manual-old']);
|
||||
assert.deepEqual(plans.map((item) => item.id), ['plan-100', 'manual-old', 'plan-300']);
|
||||
});
|
||||
@@ -78,6 +78,26 @@ export interface VersionPlan {
|
||||
|
||||
export type PlanType = VersionPlan['type'];
|
||||
|
||||
function getPlanCreatedAtTime(plan: VersionPlan): number {
|
||||
const time = new Date(plan.createdAt).getTime();
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
|
||||
function getPlanIdTime(plan: VersionPlan): number {
|
||||
const match = /^plan-(\d+)$/.exec(plan.id);
|
||||
if (!match) return 0;
|
||||
const time = Number(match[1]);
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
|
||||
export function sortPlansNewestFirst(plans: VersionPlan[]): VersionPlan[] {
|
||||
return [...plans].sort((a, b) => {
|
||||
const createdDiff = getPlanCreatedAtTime(b) - getPlanCreatedAtTime(a);
|
||||
if (createdDiff !== 0) return createdDiff;
|
||||
return getPlanIdTime(b) - getPlanIdTime(a);
|
||||
});
|
||||
}
|
||||
|
||||
export function calcPlanProgress(tasks?: PlanTask[]): number {
|
||||
if (!tasks || tasks.length === 0) return 0;
|
||||
const completed = tasks.filter((t) => t.status === 'completed').length;
|
||||
|
||||
Reference in New Issue
Block a user