feat(平台): 补齐服务端持久化和AI拆解契约
This commit is contained in:
206
apps/web/components/version/AiDecomposeButton.tsx
Normal file
206
apps/web/components/version/AiDecomposeButton.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Sparkles, Loader2, AlertCircle, RotateCw } from 'lucide-react';
|
||||
import type { VersionPlan } from '@/lib/version-plan';
|
||||
import type { VersionWithContext } from '@/lib/derive';
|
||||
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { api } from '@/lib/api';
|
||||
import { DecomposeReportModal } from './DecomposeReportModal';
|
||||
import type {
|
||||
AgentDecomposeRequest,
|
||||
AgentDecomposeResponse,
|
||||
AgentDecomposeError,
|
||||
} from '@ftb/shared';
|
||||
|
||||
interface Props {
|
||||
plan: VersionPlan;
|
||||
version: VersionWithContext;
|
||||
}
|
||||
|
||||
/** in_progress 视为"卡死"的阈值(秒)— 超过这个时间,按钮允许重新点击 */
|
||||
const STUCK_THRESHOLD_SEC = 240;
|
||||
|
||||
function formatElapsed(sec: number): string {
|
||||
if (sec < 60) return `${sec}s`;
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m}m${s}s`;
|
||||
}
|
||||
|
||||
export function AiDecomposeButton({ plan, version }: Props) {
|
||||
const { updatePlan } = useVersionPlanStore();
|
||||
const { requirements } = useRequirementStore();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<AgentDecomposeResponse | null>(null);
|
||||
const [tick, setTick] = useState(0);
|
||||
const startedAtRef = useRef<number | null>(null);
|
||||
|
||||
// plan 上的状态(持久化在 localStorage)
|
||||
const persistStatus = plan.aiDecomposeStatus;
|
||||
const persistError = plan.aiDecomposeError;
|
||||
const persistAt = plan.aiDecomposeAt;
|
||||
|
||||
// 计算"已用时"
|
||||
const elapsedSec = (() => {
|
||||
if (loading && startedAtRef.current) {
|
||||
return Math.floor((tick - startedAtRef.current) / 1000);
|
||||
}
|
||||
if (persistStatus === 'in_progress' && persistAt) {
|
||||
const start = new Date(persistAt).getTime();
|
||||
return Math.max(0, Math.floor((Date.now() - start) / 1000));
|
||||
}
|
||||
return 0;
|
||||
})();
|
||||
|
||||
const isStaleInProgress = persistStatus === 'in_progress' && elapsedSec > STUCK_THRESHOLD_SEC;
|
||||
const isInProgress = (loading || persistStatus === 'in_progress') && !isStaleInProgress;
|
||||
const isError = persistStatus === 'error' && !loading;
|
||||
const wasCompleted = persistStatus === 'completed' && !loading;
|
||||
|
||||
// tick 每秒更新一次,让"已用时"实时跳
|
||||
useEffect(() => {
|
||||
if (!isInProgress) return;
|
||||
const id = setInterval(() => setTick(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [isInProgress]);
|
||||
|
||||
const linkedReqs = requirements
|
||||
.filter((r) => r.versionId === version.id)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
code: r.code,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
}));
|
||||
|
||||
const handleClick = async () => {
|
||||
if (loading) return;
|
||||
// 即便 persistStatus 是 in_progress,只要超过阈值就允许重新点
|
||||
if (persistStatus === 'in_progress' && !isStaleInProgress) return;
|
||||
|
||||
startedAtRef.current = Date.now();
|
||||
setTick(Date.now());
|
||||
setLoading(true);
|
||||
|
||||
updatePlan(plan.id, {
|
||||
aiDecomposeStatus: 'in_progress',
|
||||
aiDecomposeBy: user?.name,
|
||||
aiDecomposeAt: new Date().toISOString(),
|
||||
aiDecomposeError: undefined,
|
||||
});
|
||||
|
||||
const members = (version.members ?? []).map((m) => ({
|
||||
name: m.name,
|
||||
role: m.role,
|
||||
}));
|
||||
|
||||
const payload: AgentDecomposeRequest = {
|
||||
prototypeUrl: plan.resultUrl || '',
|
||||
requirements: linkedReqs,
|
||||
members,
|
||||
versionId: version.id,
|
||||
planId: plan.id,
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await api.postRaw<AgentDecomposeResponse | AgentDecomposeError>(
|
||||
'/ai/decompose',
|
||||
payload,
|
||||
300000,
|
||||
);
|
||||
if (!resp.ok) {
|
||||
updatePlan(plan.id, {
|
||||
aiDecomposeStatus: 'error',
|
||||
aiDecomposeError: resp.error,
|
||||
});
|
||||
} else {
|
||||
setResult(resp);
|
||||
updatePlan(plan.id, {
|
||||
aiDecomposeStatus: 'completed',
|
||||
aiDecomposeError: undefined,
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = e?.message || '调用 AI 服务失败';
|
||||
updatePlan(plan.id, {
|
||||
aiDecomposeStatus: 'error',
|
||||
aiDecomposeError: msg,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
startedAtRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (plan.type !== 'product' || plan.status !== 'completed' || !plan.resultUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={isInProgress}
|
||||
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-medium disabled:cursor-not-allowed disabled:opacity-70 ${
|
||||
isError
|
||||
? 'border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
|
||||
: 'border-purple-200 bg-purple-50 text-purple-700 hover:bg-purple-100'
|
||||
}`}
|
||||
title={
|
||||
isError
|
||||
? `上次失败:${persistError || '未知错误'}(点击重试)`
|
||||
: wasCompleted
|
||||
? '此前已拆解过,再次点击会重新拆解'
|
||||
: '使用 AI 把原型 + 关联需求拆解成开发任务和测试用例'
|
||||
}
|
||||
>
|
||||
{isInProgress ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
拆解中 {formatElapsed(elapsedSec)}
|
||||
</>
|
||||
) : isError ? (
|
||||
<>
|
||||
<RotateCw className="h-3 w-3" />
|
||||
重试
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{wasCompleted ? '重新 AI 拆解' : 'AI 拆解'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 错误信息:在按钮旁悬浮显示 */}
|
||||
{isError && persistError && (
|
||||
<span className="ml-1 inline-flex items-center gap-1 text-[11px] text-red-600 max-w-[260px]" title={persistError}>
|
||||
<AlertCircle className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{persistError}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 卡死提示:persistStatus 是 in_progress 但已超时 */}
|
||||
{isStaleInProgress && (
|
||||
<span className="ml-1 text-[11px] text-amber-600" title="上次拆解记录残留,点击按钮重新拆解">
|
||||
上次未完成({formatElapsed(elapsedSec)})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<DecomposeReportModal
|
||||
result={result}
|
||||
version={version}
|
||||
plan={plan}
|
||||
requirements={linkedReqs}
|
||||
onClose={() => setResult(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
381
apps/web/components/version/DecomposeReportModal.tsx
Normal file
381
apps/web/components/version/DecomposeReportModal.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { X, CheckCircle2, AlertTriangle, HelpCircle, ListChecks } from 'lucide-react';
|
||||
import type { VersionPlan } from '@/lib/version-plan';
|
||||
import type { VersionWithContext } from '@/lib/derive';
|
||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { findCategoryByCode, resolveCategoryIdFromCode } from '@/lib/task-category';
|
||||
import { addWorkHours } from '@/lib/work-hours';
|
||||
import { clampDevEstimateHours, clampTestCaseEstimateHours } from '@/lib/ai-estimation-policy';
|
||||
import type {
|
||||
AgentDecomposeResponse,
|
||||
AgentDevTaskDraft,
|
||||
AgentTestCaseDraft,
|
||||
} from '@ftb/shared';
|
||||
|
||||
interface Props {
|
||||
result: AgentDecomposeResponse;
|
||||
version: VersionWithContext;
|
||||
plan: VersionPlan;
|
||||
requirements: Array<{ id: string; code: string; title: string; description?: string }>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DecomposeReportModal({ result, version, plan, requirements, onClose }: Props) {
|
||||
const { createTask } = useDevTaskStore();
|
||||
const { createTestCase } = useTestCaseStore();
|
||||
const { categories } = useTaskCategoryStore();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const { result: data, meta } = result;
|
||||
|
||||
const [selectedDevIdx, setSelectedDevIdx] = useState<Set<number>>(
|
||||
new Set(data.devTaskDrafts.map((_, i) => i)),
|
||||
);
|
||||
const [selectedTcIdx, setSelectedTcIdx] = useState<Set<number>>(
|
||||
new Set(data.testCaseDrafts.map((_, i) => i)),
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const reqByCode = useMemo(() => {
|
||||
const m = new Map<string, { id: string; code: string; title: string }>();
|
||||
for (const r of requirements) m.set(r.code, r);
|
||||
return m;
|
||||
}, [requirements]);
|
||||
|
||||
const reqByInternalId = useMemo(() => {
|
||||
const m = new Map<string, { id: string; code: string; title: string }>();
|
||||
for (const r of requirements) m.set(r.id, r);
|
||||
return m;
|
||||
}, [requirements]);
|
||||
|
||||
const toggleDev = (i: number) => {
|
||||
const n = new Set(selectedDevIdx);
|
||||
if (n.has(i)) n.delete(i);
|
||||
else n.add(i);
|
||||
setSelectedDevIdx(n);
|
||||
};
|
||||
const toggleTc = (i: number) => {
|
||||
const n = new Set(selectedTcIdx);
|
||||
if (n.has(i)) n.delete(i);
|
||||
else n.add(i);
|
||||
setSelectedTcIdx(n);
|
||||
};
|
||||
|
||||
const handleAdopt = () => {
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// 把每个 reference 的 requirement 类型 id(AI 给的是 code)映射回内部 id
|
||||
const normalizeRefs = (refs: AgentDevTaskDraft['references'] | AgentTestCaseDraft['references']) =>
|
||||
refs.map((ref) => {
|
||||
if (ref.type === 'requirement') {
|
||||
const r = reqByCode.get(ref.id) ?? reqByInternalId.get(ref.id);
|
||||
if (r) return { ...ref, id: r.id, label: ref.label || `${r.code} ${r.title}` };
|
||||
}
|
||||
return ref;
|
||||
});
|
||||
|
||||
let devCount = 0;
|
||||
for (let i = 0; i < data.devTaskDrafts.length; i++) {
|
||||
if (!selectedDevIdx.has(i)) continue;
|
||||
const draft = data.devTaskDrafts[i];
|
||||
const refs = normalizeRefs(draft.references);
|
||||
const reqRef = refs.find((r) => r.type === 'requirement');
|
||||
const requirementId = reqRef?.id ?? requirements[0]?.id ?? '';
|
||||
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'development');
|
||||
const estimateHours = clampDevEstimateHours(draft.categoryCode, draft.estimateHours);
|
||||
const startISO = new Date().toISOString();
|
||||
const endISO = addWorkHours(startISO, estimateHours);
|
||||
|
||||
createTask({
|
||||
requirementId,
|
||||
title: draft.title,
|
||||
description: draft.description,
|
||||
categoryId,
|
||||
assigneeId: '',
|
||||
reviewerId: undefined,
|
||||
priority: draft.priority,
|
||||
expectedStartAt: startISO,
|
||||
expectedEndAt: endISO,
|
||||
estimateHours,
|
||||
actualStartAt: undefined,
|
||||
actualEndAt: undefined,
|
||||
status: 'todo',
|
||||
blockReason: undefined,
|
||||
blockedById: undefined,
|
||||
predecessorIds: undefined,
|
||||
riskLevel: undefined,
|
||||
delayReason: undefined,
|
||||
overdueVersionReason: undefined,
|
||||
references: refs,
|
||||
aiDraft: true,
|
||||
aiDraftAt: now,
|
||||
createdBy: user?.name || 'AI',
|
||||
} as any);
|
||||
devCount++;
|
||||
}
|
||||
|
||||
let tcCount = 0;
|
||||
for (let i = 0; i < data.testCaseDrafts.length; i++) {
|
||||
if (!selectedTcIdx.has(i)) continue;
|
||||
const draft = data.testCaseDrafts[i];
|
||||
const refs = normalizeRefs(draft.references);
|
||||
const reqRef = refs.find((r) => r.type === 'requirement');
|
||||
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'testing');
|
||||
const estimateHours = clampTestCaseEstimateHours(draft.categoryCode, draft.estimateHours);
|
||||
|
||||
createTestCase({
|
||||
versionId: version.id,
|
||||
requirementId: reqRef?.id,
|
||||
title: draft.title,
|
||||
description: draft.description,
|
||||
categoryId,
|
||||
priority: draft.priority,
|
||||
estimateHours,
|
||||
assigneeId: undefined,
|
||||
references: refs,
|
||||
aiDraft: true,
|
||||
aiDraftAt: now,
|
||||
createdBy: user?.name || 'AI',
|
||||
} as any);
|
||||
tcCount++;
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
setSubmitting(false);
|
||||
|
||||
// 1.5 秒后自动关闭
|
||||
setTimeout(() => onClose(), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
<div
|
||||
className="w-full max-w-3xl max-h-[85vh] flex flex-col rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--line)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<ListChecks className="h-4 w-4 text-purple-600" />
|
||||
<h3 className="text-[14px] font-semibold text-[var(--ink)]">AI 拆解结果</h3>
|
||||
<span className="text-[11px] text-[var(--ink-muted)]">
|
||||
{meta.model} · 输入 {meta.inputTokens} tok · 输出 {meta.outputTokens} tok ·{' '}
|
||||
{(meta.durationMs / 1000).toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)]">
|
||||
<X className="h-4 w-4 text-[var(--ink-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
|
||||
{/* 对账报告 */}
|
||||
<section>
|
||||
<h4 className="text-[12px] font-semibold text-[var(--ink-soft)] mb-2">对账报告</h4>
|
||||
|
||||
{data.report.matched.length > 0 && (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 mb-2">
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />
|
||||
<span className="text-[12px] font-medium text-emerald-700">
|
||||
完美对应({data.report.matched.length})
|
||||
</span>
|
||||
</div>
|
||||
<ul className="text-[12px] text-emerald-800 space-y-1 ml-5">
|
||||
{data.report.matched.map((m, i) => (
|
||||
<li key={i}>
|
||||
{m.reqId} ↔ {m.noteIds.join(', ') || '(仅需求驱动)'} → 拆出 {m.taskCount} 个任务
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.report.reqOnly.length > 0 && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 mb-2">
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-amber-600" />
|
||||
<span className="text-[12px] font-medium text-amber-700">仅需求未见原型</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-amber-700 mb-1">
|
||||
以下需求在原型上未找到对应批注,已按需求文字拆解,请人工核对:
|
||||
</p>
|
||||
<ul className="text-[12px] text-amber-800 ml-5 list-disc">
|
||||
{data.report.reqOnly.map((id, i) => <li key={i}>{id}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.report.noteOnly.length > 0 && (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 mb-2">
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-blue-600" />
|
||||
<span className="text-[12px] font-medium text-blue-700">仅原型未见需求</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-blue-700 mb-1">
|
||||
以下原型批注在需求清单中未提及,已跳过拆解(可能是漏录需求):
|
||||
</p>
|
||||
<ul className="text-[12px] text-blue-800 ml-5 list-disc">
|
||||
{data.report.noteOnly.map((id, i) => <li key={i}>{id}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.report.ambiguous.length > 0 && (
|
||||
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 mb-2">
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<HelpCircle className="h-3.5 w-3.5 text-rose-600" />
|
||||
<span className="text-[12px] font-medium text-rose-700">含糊批注</span>
|
||||
</div>
|
||||
<ul className="text-[12px] text-rose-800 ml-5 space-y-0.5">
|
||||
{data.report.ambiguous.map((a, i) => (
|
||||
<li key={i}>
|
||||
<span className="font-medium">{a.noteId}</span>:{a.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* DevTask 草案 */}
|
||||
<section>
|
||||
<h4 className="text-[12px] font-semibold text-[var(--ink-soft)] mb-2">
|
||||
开发任务草案({data.devTaskDrafts.length})
|
||||
</h4>
|
||||
{data.devTaskDrafts.length === 0 ? (
|
||||
<p className="text-[12px] text-[var(--ink-muted)]">无可生成的任务</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.devTaskDrafts.map((d, i) => (
|
||||
<label
|
||||
key={i}
|
||||
className="flex gap-2 p-3 rounded-lg border border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDevIdx.has(i)}
|
||||
onChange={() => toggleDev(i)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[13px] font-medium text-[var(--ink)]">{d.title}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-subtle)] text-[var(--ink-soft)]">
|
||||
{findCategoryByCode(categories, d.categoryCode)?.name ?? d.categoryCode}
|
||||
</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 text-zinc-600">
|
||||
{d.priority}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--ink-muted)]">
|
||||
{clampDevEstimateHours(d.categoryCode, d.estimateHours)}h
|
||||
</span>
|
||||
</div>
|
||||
{d.description && (
|
||||
<p className="mt-1 text-[12px] text-[var(--ink-soft)] line-clamp-2">{d.description}</p>
|
||||
)}
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{d.references.map((r, j) => (
|
||||
<span
|
||||
key={j}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-purple-50 text-purple-700 border border-purple-200"
|
||||
>
|
||||
{r.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* TestCase 草案 */}
|
||||
<section>
|
||||
<h4 className="text-[12px] font-semibold text-[var(--ink-soft)] mb-2">
|
||||
测试用例草案({data.testCaseDrafts.length})
|
||||
</h4>
|
||||
{data.testCaseDrafts.length === 0 ? (
|
||||
<p className="text-[12px] text-[var(--ink-muted)]">无可生成的用例</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.testCaseDrafts.map((d, i) => (
|
||||
<label
|
||||
key={i}
|
||||
className="flex gap-2 p-3 rounded-lg border border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTcIdx.has(i)}
|
||||
onChange={() => toggleTc(i)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[13px] font-medium text-[var(--ink)]">{d.title}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-subtle)] text-[var(--ink-soft)]">
|
||||
{findCategoryByCode(categories, d.categoryCode)?.name ?? d.categoryCode}
|
||||
</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 text-zinc-600">
|
||||
{d.priority}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--ink-muted)]">
|
||||
{clampTestCaseEstimateHours(d.categoryCode, d.estimateHours)}h
|
||||
</span>
|
||||
</div>
|
||||
<pre className="mt-1 text-[11px] text-[var(--ink-soft)] whitespace-pre-wrap font-sans line-clamp-3">
|
||||
{d.description}
|
||||
</pre>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{d.references.map((r, j) => (
|
||||
<span
|
||||
key={j}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-purple-50 text-purple-700 border border-purple-200"
|
||||
>
|
||||
{r.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-t border-[var(--line)]">
|
||||
<div className="text-[12px] text-[var(--ink-muted)]">
|
||||
已选 {selectedDevIdx.size} 个任务 + {selectedTcIdx.size} 个用例
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdopt}
|
||||
disabled={submitting || submitted || (selectedDevIdx.size === 0 && selectedTcIdx.size === 0)}
|
||||
className="h-8 px-4 rounded-lg text-[12px] font-medium bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{submitted ? '✓ 已采纳' : submitting ? '采纳中...' : '采纳选中'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import type { PlanTask, VersionPlan } from '@/lib/version-plan';
|
||||
import { canEditPlanRequirementCoverage, canTogglePlanChecklist, getPlanCompletionState } from '@/lib/version-plan-workflow';
|
||||
|
||||
interface Props {
|
||||
planId: string;
|
||||
@@ -26,6 +27,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
const [showTransfer, setShowTransfer] = useState(false);
|
||||
const [transferTo, setTransferTo] = useState('');
|
||||
const [resultType, setResultType] = useState<'link' | 'file'>('link');
|
||||
const [resultTitle, setResultTitle] = useState('');
|
||||
const [resultUrl, setResultUrl] = useState('');
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [fileData, setFileData] = useState('');
|
||||
@@ -34,20 +36,22 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
const plan = plans.find((p) => p.id === planId);
|
||||
if (!plan) return null;
|
||||
|
||||
const completionState = getPlanCompletionState(plan);
|
||||
const isResearch = plan.type === 'research';
|
||||
const progress = isResearch ? calcPlanProgress(plan.tasks) : calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds);
|
||||
const linkedReqs = (plan.linkedRequirementIds || []).map((id) => requirements.find((r) => r.id === id)).filter(Boolean) as { id: string; code: string; title: string }[];
|
||||
const canInteract = plan.status === 'in_progress' || (plan.status === 'pending' && plan.startTime && new Date(plan.startTime) <= new Date());
|
||||
const canToggle = canTogglePlanChecklist(plan);
|
||||
const canEditCoverage = canEditPlanRequirementCoverage(plan);
|
||||
|
||||
const handleToggleTask = (task: PlanTask) => {
|
||||
if (!canInteract) return;
|
||||
if (!canToggle) return;
|
||||
const nextStatus = task.status === 'completed' ? 'pending' : 'completed';
|
||||
const updatedTasks = (plan.tasks || []).map((t) => t.id === task.id ? { ...t, status: nextStatus as PlanTask['status'] } : t);
|
||||
updatePlan(plan.id, { tasks: updatedTasks });
|
||||
};
|
||||
|
||||
const handleToggleReq = (reqId: string) => {
|
||||
if (!canInteract) return;
|
||||
if (!canEditCoverage) return;
|
||||
const current = plan.completedRequirementIds || [];
|
||||
const next = current.includes(reqId) ? current.filter((id) => id !== reqId) : [...current, reqId];
|
||||
updatePlan(plan.id, { completedRequirementIds: next });
|
||||
@@ -64,8 +68,13 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
|
||||
const handleSubmitResult = () => {
|
||||
const url = resultType === 'link' ? resultUrl.trim() : fileData;
|
||||
if (!url) return;
|
||||
completePlan(plan.id, { resultType, resultUrl: url, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined });
|
||||
const title = resultTitle.trim();
|
||||
if (!url || !title) return;
|
||||
const response = completePlan(plan.id, { resultType, resultTitle: title, resultUrl: url, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined });
|
||||
if (response && typeof response === 'object' && 'ok' in response && !response.ok) {
|
||||
alert(response.message || '计划未满足完成条件');
|
||||
return;
|
||||
}
|
||||
setShowComplete(false);
|
||||
};
|
||||
|
||||
@@ -138,9 +147,9 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
{plan.tasks.map((task) => (
|
||||
<div key={task.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-[var(--bg-subtle)]">
|
||||
<button
|
||||
disabled={!canInteract}
|
||||
disabled={!canToggle}
|
||||
onClick={() => handleToggleTask(task)}
|
||||
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canInteract ? 'opacity-40 cursor-not-allowed' : ''} ${task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
|
||||
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canToggle ? 'opacity-40 cursor-not-allowed' : ''} ${task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
|
||||
>
|
||||
{task.status === 'completed' && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
|
||||
</button>
|
||||
@@ -151,7 +160,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
)}
|
||||
|
||||
{/* Product/UI: Linked Requirements */}
|
||||
{!isResearch && linkedReqs.length > 0 && (
|
||||
{linkedReqs.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[11px] font-medium text-[var(--ink-muted)]">关联需求</div>
|
||||
{linkedReqs.map((req) => {
|
||||
@@ -159,9 +168,9 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
return (
|
||||
<div key={req.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-[var(--bg-subtle)]">
|
||||
<button
|
||||
disabled={!canInteract}
|
||||
disabled={!canEditCoverage}
|
||||
onClick={() => handleToggleReq(req.id)}
|
||||
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canInteract ? 'opacity-40 cursor-not-allowed' : ''} ${isDone ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
|
||||
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canEditCoverage ? 'opacity-40 cursor-not-allowed' : ''} ${isDone ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
|
||||
>
|
||||
{isDone && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
|
||||
</button>
|
||||
@@ -170,6 +179,9 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
|
||||
<p className="pt-1 text-[11px] text-[var(--ink-muted)]">还不能提交成果:{completionState.missingReasons.join('、')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -180,7 +192,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
<div className="flex items-center gap-1.5">
|
||||
{plan.resultType === 'link' ? <Link2 className="h-3 w-3 text-[var(--accent)]" /> : <FileUp className="h-3 w-3 text-[var(--accent)]" />}
|
||||
<a href={plan.resultUrl} target="_blank" rel="noopener noreferrer" className="text-[12px] text-[var(--accent)] hover:underline flex items-center gap-1">
|
||||
{plan.resultFileName || '查看成果'}<ExternalLink className="h-3 w-3" />
|
||||
{plan.resultTitle || plan.resultFileName || '查看成果'}<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -212,6 +224,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
{showComplete && (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 space-y-2">
|
||||
<div className="text-[11px] font-medium text-emerald-700">提交成果</div>
|
||||
<input value={resultTitle} onChange={(e) => setResultTitle(e.target.value)} placeholder="成果标题(必填,如 v1.0 产品方案)" className="h-8 w-full rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setResultType('link')} className={`h-7 px-2.5 rounded text-[11px] font-medium border ${resultType === 'link' ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}><Link2 className="h-3 w-3 inline mr-1" />链接</button>
|
||||
<button onClick={() => setResultType('file')} className={`h-7 px-2.5 rounded text-[11px] font-medium border ${resultType === 'file' ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}><FileUp className="h-3 w-3 inline mr-1" />文件</button>
|
||||
@@ -225,7 +238,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleSubmitResult} disabled={resultType === 'link' ? !resultUrl.trim() : !fileData} className="h-7 px-3 rounded text-[11px] font-medium bg-emerald-500 text-white disabled:opacity-50">确认提交</button>
|
||||
<button onClick={handleSubmitResult} disabled={!completionState.canSubmitResult || !resultTitle.trim() || (resultType === 'link' ? !resultUrl.trim() : !fileData)} className="h-7 px-3 rounded text-[11px] font-medium bg-emerald-500 text-white disabled:opacity-50">确认提交</button>
|
||||
<button onClick={() => setShowComplete(false)} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -234,21 +247,26 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
|
||||
{/* Footer Actions */}
|
||||
{plan.status !== 'completed' && (
|
||||
<div className="flex items-center gap-2 px-5 py-3 border-t border-[var(--line)] shrink-0">
|
||||
{plan.status === 'pending' && (
|
||||
<button onClick={() => updatePlan(plan.id, { status: 'in_progress' })} className="h-8 px-3 rounded-lg text-[12px] font-medium text-blue-600 border border-blue-200 hover:bg-blue-50 flex items-center gap-1">
|
||||
<Play className="h-3 w-3" />开始
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-5 py-3 border-t border-[var(--line)] shrink-0">
|
||||
{plan.status === 'pending' && (
|
||||
<button onClick={() => updatePlan(plan.id, { status: 'in_progress' })} className="h-8 px-3 rounded-lg text-[12px] font-medium text-blue-600 border border-blue-200 hover:bg-blue-50 flex items-center gap-1">
|
||||
<Play className="h-3 w-3" />开始
|
||||
</button>
|
||||
)}
|
||||
{plan.status === 'in_progress' && (
|
||||
<button onClick={() => setShowComplete(true)} disabled={!completionState.canSubmitResult} className="h-8 px-3 rounded-lg text-[12px] font-medium text-emerald-600 border border-emerald-200 hover:bg-emerald-50 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
提交完成
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setShowTransfer(true)} className="h-8 px-3 rounded-lg text-[12px] font-medium text-[var(--ink-soft)] border border-[var(--line)] hover:bg-[var(--bg-subtle)] flex items-center gap-1">
|
||||
<ArrowRightLeft className="h-3 w-3" />转交
|
||||
</button>
|
||||
</div>
|
||||
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
|
||||
<div className="px-5 pb-3 text-[11px] text-[var(--ink-muted)]">还不能提交成果:{completionState.missingReasons.join('、')}</div>
|
||||
)}
|
||||
{plan.status === 'in_progress' && (
|
||||
<button onClick={() => setShowComplete(true)} className="h-8 px-3 rounded-lg text-[12px] font-medium text-emerald-600 border border-emerald-200 hover:bg-emerald-50">
|
||||
提交完成
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setShowTransfer(true)} className="h-8 px-3 rounded-lg text-[12px] font-medium text-[var(--ink-soft)] border border-[var(--line)] hover:bg-[var(--bg-subtle)] flex items-center gap-1">
|
||||
<ArrowRightLeft className="h-3 w-3" />转交
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2, Play, ArrowRightLeft } from 'lucide-react';
|
||||
import type { VersionPlan, PlanTask } from '@/lib/version-plan';
|
||||
import { calcPlanDuration, formatDuration, calcTotalDuration, calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import { FieldError } from '@/components/FieldError';
|
||||
import { AiDecomposeButton } from './AiDecomposeButton';
|
||||
import type { VersionWithContext } from '@/lib/derive';
|
||||
import type { Requirement } from '@/lib/requirement';
|
||||
import { mergeSelectedRequirementOptions } from '@/lib/requirement-selector';
|
||||
import { canEditPlanRequirementCoverage, canTogglePlanChecklist, getPlanCompletionState } from '@/lib/version-plan-workflow';
|
||||
|
||||
interface Props {
|
||||
plans: VersionPlan[];
|
||||
versionId: string;
|
||||
version?: VersionWithContext;
|
||||
versionDeadline?: string;
|
||||
currentUserName: string;
|
||||
planType: 'research' | 'product' | 'ui';
|
||||
versionMembers: { role: string; name: string }[];
|
||||
linkedRequirements?: { id: string; title: string; code: string; productOwner?: string }[];
|
||||
linkedRequirements?: Requirement[];
|
||||
allRequirements?: Requirement[];
|
||||
onCreate: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => void;
|
||||
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
|
||||
onComplete: (id: string, result: { resultType: 'link' | 'file'; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void;
|
||||
onComplete: (id: string, result: { resultType: 'link' | 'file'; resultTitle: string; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => { ok: boolean; message?: string } | void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
@@ -29,7 +36,7 @@ const STATUS_STYLE = {
|
||||
};
|
||||
const STATUS_LABEL = { pending: '未开始', in_progress: '进行中', completed: '已完成' };
|
||||
|
||||
export function PlanTab({ plans, versionId, versionDeadline, currentUserName, planType, versionMembers, linkedRequirements, onCreate, onUpdate, onComplete, onDelete }: Props) {
|
||||
export function PlanTab({ plans, versionId, version, versionDeadline, currentUserName, planType, versionMembers, linkedRequirements, allRequirements, onCreate, onUpdate, onComplete, onDelete }: Props) {
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingPlan, setEditingPlan] = useState<VersionPlan | null>(null);
|
||||
const [completingPlan, setCompletingPlan] = useState<VersionPlan | null>(null);
|
||||
@@ -65,6 +72,10 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
|
||||
const autoStarted = plan.status === 'pending' && plan.startTime && new Date(plan.startTime) <= new Date();
|
||||
const effectiveStatus = autoStarted ? 'in_progress' : plan.status;
|
||||
const effectiveStartAt = plan.actualStartAt || (autoStarted ? plan.startTime : null);
|
||||
const completionState = getPlanCompletionState(plan);
|
||||
const canToggle = canTogglePlanChecklist(plan);
|
||||
const canEditCoverage = canEditPlanRequirementCoverage(plan);
|
||||
const requirementOptions = mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], plan.linkedRequirementIds ?? []);
|
||||
// 耗时用实际时间戳计算
|
||||
const dur = plan.status === 'completed' && plan.completedAt && plan.actualStartAt
|
||||
? calcPlanDuration(plan.actualStartAt, plan.completedAt)
|
||||
@@ -102,11 +113,14 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
|
||||
<div className="flex items-center gap-1.5 mt-2">
|
||||
{plan.resultType === 'link' ? <Link2 className="h-3 w-3 text-[var(--accent)]" /> : <FileUp className="h-3 w-3 text-[var(--accent)]" />}
|
||||
<a href={plan.resultUrl} target="_blank" rel="noopener noreferrer" className="text-[12px] text-[var(--accent)] hover:underline flex items-center gap-1">
|
||||
{plan.resultFileName || '查看成果'}<ExternalLink className="h-3 w-3" />
|
||||
{plan.resultTitle || plan.resultFileName || '查看成果'}<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
{planType === 'product' && version && (
|
||||
<AiDecomposeButton plan={plan} version={version} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 调研:任务进度 */}
|
||||
{/* 子任务 */}
|
||||
{plan.type === 'research' && plan.tasks && plan.tasks.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -119,14 +133,14 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
|
||||
{plan.tasks.map((task) => (
|
||||
<div key={task.id} className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={plan.status !== 'in_progress' && !autoStarted}
|
||||
disabled={!canToggle}
|
||||
onClick={() => {
|
||||
if (plan.status !== 'in_progress' && !autoStarted) return;
|
||||
if (!canToggle) return;
|
||||
const nextStatus = task.status === 'completed' ? 'pending' : 'completed';
|
||||
const updatedTasks = plan.tasks!.map((t) => t.id === task.id ? { ...t, status: nextStatus as PlanTask['status'] } : t);
|
||||
onUpdate(plan.id, { tasks: updatedTasks });
|
||||
}}
|
||||
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${plan.status !== 'in_progress' && !autoStarted ? 'opacity-40 cursor-not-allowed' : ''} ${task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
|
||||
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canToggle ? 'opacity-40 cursor-not-allowed' : ''} ${task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
|
||||
>
|
||||
{task.status === 'completed' && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
|
||||
</button>
|
||||
@@ -139,8 +153,8 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 产品方案/UI:关联需求进度 */}
|
||||
{(plan.type === 'product' || plan.type === 'ui') && plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0 && linkedRequirements && (
|
||||
{/* 关联需求 */}
|
||||
{plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0 && requirementOptions.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-[var(--bg-subtle)] overflow-hidden">
|
||||
@@ -150,19 +164,19 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{plan.linkedRequirementIds.map((rid) => {
|
||||
const req = linkedRequirements.find((r) => r.id === 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">
|
||||
<button
|
||||
disabled={plan.status !== 'in_progress' && !autoStarted}
|
||||
disabled={!canEditCoverage}
|
||||
onClick={() => {
|
||||
if (plan.status !== 'in_progress' && !autoStarted) return;
|
||||
if (!canEditCoverage) return;
|
||||
const current = plan.completedRequirementIds || [];
|
||||
const next = isDone ? current.filter((id) => id !== rid) : [...current, rid];
|
||||
onUpdate(plan.id, { completedRequirementIds: next });
|
||||
}}
|
||||
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${plan.status !== 'in_progress' && !autoStarted ? 'opacity-40 cursor-not-allowed' : ''} ${isDone ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
|
||||
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canEditCoverage ? 'opacity-40 cursor-not-allowed' : ''} ${isDone ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
|
||||
>
|
||||
{isDone && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
|
||||
</button>
|
||||
@@ -172,14 +186,11 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
{calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds) === 100 && plan.status !== 'completed' && (
|
||||
<div className="mt-2 rounded-lg bg-green-50 border border-green-200 px-3 py-2 flex items-center justify-between">
|
||||
<span className="text-[12px] text-green-700">所有需求已完成,请提交原型成果</span>
|
||||
<button onClick={() => setCompletingPlan(plan)} className="text-[11px] font-medium text-green-700 hover:text-green-900 underline">提交</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
|
||||
<p className="mt-2 text-[11px] text-[var(--ink-muted)]">还不能提交成果:{completionState.missingReasons.join('、')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-3">
|
||||
{plan.status === 'pending' && !autoStarted && (
|
||||
@@ -187,11 +198,6 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
|
||||
<Play className="h-3 w-3" />开始
|
||||
</button>
|
||||
)}
|
||||
{plan.status !== 'completed' && plan.status !== 'pending' && (
|
||||
<button onClick={() => setCompletingPlan(plan)} className="h-7 w-7 flex items-center justify-center rounded-md text-green-600 hover:bg-green-50" title="标记完成">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{plan.status !== 'completed' && (
|
||||
<>
|
||||
<button onClick={() => setTransferPlanId(plan.id)} className="h-7 w-7 flex items-center justify-center rounded-md text-blue-500 hover:bg-blue-50" title="转交">
|
||||
@@ -218,6 +224,12 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
|
||||
<button onClick={() => { setTransferPlanId(null); setTransferTo(''); }} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]">取消</button>
|
||||
</div>
|
||||
)}
|
||||
{plan.status !== 'completed' && completionState.canSubmitResult && (
|
||||
<div className="mt-3 rounded-lg bg-green-50 border border-green-200 px-3 py-2 flex items-center justify-between">
|
||||
<span className="text-[12px] text-green-700">已满足提交成果条件</span>
|
||||
<button onClick={() => setCompletingPlan(plan)} className="text-[11px] font-medium text-green-700 hover:text-green-900 underline">提交成果</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -232,6 +244,7 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
|
||||
versionDeadline={versionDeadline}
|
||||
currentUserName={currentUserName}
|
||||
linkedRequirements={linkedRequirements}
|
||||
allRequirements={allRequirements}
|
||||
onClose={() => { setShowCreateModal(false); setEditingPlan(null); }}
|
||||
onSubmit={(data) => {
|
||||
if (editingPlan) onUpdate(editingPlan.id, data);
|
||||
@@ -245,20 +258,28 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
|
||||
{completingPlan && (
|
||||
<CompleteModal
|
||||
onClose={() => setCompletingPlan(null)}
|
||||
onSubmit={(result) => { onComplete(completingPlan.id, result); setCompletingPlan(null); }}
|
||||
onSubmit={(result) => {
|
||||
const response = onComplete(completingPlan.id, result);
|
||||
if (response && typeof response === 'object' && 'ok' in response && !response.ok) {
|
||||
alert(response.message || '计划未满足完成条件');
|
||||
return;
|
||||
}
|
||||
setCompletingPlan(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanFormModal({ initial, planType, versionId, versionDeadline, currentUserName, linkedRequirements, onClose, onSubmit }: {
|
||||
function PlanFormModal({ initial, planType, versionId, versionDeadline, currentUserName, linkedRequirements, allRequirements, onClose, onSubmit }: {
|
||||
initial: VersionPlan | null;
|
||||
planType: 'research' | 'product' | 'ui';
|
||||
versionId: string;
|
||||
versionDeadline?: string;
|
||||
currentUserName: string;
|
||||
linkedRequirements?: { id: string; title: string; code: string }[];
|
||||
linkedRequirements?: Requirement[];
|
||||
allRequirements?: Requirement[];
|
||||
onClose: () => void;
|
||||
onSubmit: (data: any) => void;
|
||||
}) {
|
||||
@@ -273,7 +294,10 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
|
||||
const [overdueReason, setOverdueReason] = useState(initial?.overdueReason ?? '');
|
||||
const [selectedReqs, setSelectedReqs] = useState<Set<string>>(new Set(initial?.linkedRequirementIds ?? []));
|
||||
const [endTimeError, setEndTimeError] = useState('');
|
||||
const showReqSelect = planType === 'product' || planType === 'ui';
|
||||
const requirementOptions = useMemo(
|
||||
() => mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], Array.from(selectedReqs)),
|
||||
[linkedRequirements, allRequirements, selectedReqs],
|
||||
);
|
||||
const isOverdue = !!(versionDeadline && endTime && new Date(endTime) > new Date(versionDeadline));
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
@@ -294,8 +318,8 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
|
||||
startTime,
|
||||
endTime,
|
||||
status: initial?.status ?? 'pending',
|
||||
linkedRequirementIds: showReqSelect ? Array.from(selectedReqs) : undefined,
|
||||
tasks: tasks.length > 0 ? tasks : undefined,
|
||||
linkedRequirementIds: requirementOptions.length > 0 ? Array.from(selectedReqs) : undefined,
|
||||
tasks: planType === 'research' && tasks.length > 0 ? tasks : undefined,
|
||||
remark: remark.trim() || undefined,
|
||||
overdueReason: isOverdue ? overdueReason.trim() : undefined,
|
||||
addedBy: currentUserName,
|
||||
@@ -345,63 +369,83 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showReqSelect && linkedRequirements && linkedRequirements.length > 0 && (
|
||||
{requirementOptions.length > 0 && (
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">关联需求</label>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[11px] text-[var(--ink-muted)]">已选 {selectedReqs.size} / {requirementOptions.filter((req) => !req.isHistorical).length}</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedReqs(new Set(requirementOptions.map((req) => req.id)))}
|
||||
className="text-[11px] text-[var(--accent)] hover:underline"
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedReqs(new Set())}
|
||||
className="text-[11px] text-[var(--ink-muted)] hover:underline"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[120px] overflow-y-auto rounded-lg border border-[var(--line)] p-2 space-y-1">
|
||||
{linkedRequirements.map((req) => (
|
||||
{requirementOptions.map((req) => (
|
||||
<label key={req.id} className="flex items-center gap-2 rounded px-2 py-1 hover:bg-[var(--bg-subtle)] cursor-pointer text-[12px]">
|
||||
<input type="checkbox" checked={selectedReqs.has(req.id)} onChange={() => { const s = new Set(selectedReqs); if (s.has(req.id)) s.delete(req.id); else s.add(req.id); setSelectedReqs(s); }} className="h-3.5 w-3.5 rounded" />
|
||||
<span className="text-[var(--ink-muted)] font-mono">{req.code}</span>
|
||||
<span className="text-[var(--ink)] truncate">{req.title}</span>
|
||||
{req.isHistorical && <span className="text-[10px] text-orange-600 bg-orange-50 px-1.5 py-0.5 rounded">历史</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{planType === 'research' && (
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
|
||||
任务清单 <span className="text-red-500">*</span>
|
||||
<span className="text-[10px] text-[var(--ink-muted)] ml-1">至少添加一项</span>
|
||||
</label>
|
||||
{/* 预设选项 */}
|
||||
{tasks.length === 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{['竞品分析', '用户访谈', '数据调研', '技术可行性分析', '市场调研', '需求分析'].map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => setTasks([...tasks, { id: `task-${Date.now()}-${Math.random().toString(36).slice(2, 5)}`, title: preset, status: 'pending' }])}
|
||||
className="h-6 px-2.5 rounded-md text-[11px] border border-dashed border-[var(--line)] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
|
||||
>
|
||||
+ {preset}
|
||||
</button>
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
|
||||
调研方向 <span className="text-red-500">*</span>
|
||||
<span className="text-[10px] text-[var(--ink-muted)] ml-1">至少添加一项</span>
|
||||
</label>
|
||||
{/* 预设选项 */}
|
||||
{tasks.length === 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{['竞品分析', '用户访谈', '数据调研', '技术可行性分析', '市场调研', '需求分析'].map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => setTasks([...tasks, { id: `task-${Date.now()}-${Math.random().toString(36).slice(2, 5)}`, title: preset, status: 'pending' }])}
|
||||
className="h-6 px-2.5 rounded-md text-[11px] border border-dashed border-[var(--line)] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
|
||||
>
|
||||
+ {preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5 mb-2">
|
||||
{tasks.map((task, i) => (
|
||||
<div key={task.id} className="flex items-center gap-2 rounded-lg bg-[var(--bg-subtle)] px-3 py-1.5">
|
||||
<span className="flex-1 text-[12px] text-[var(--ink)]">{task.title}</span>
|
||||
<button type="button" onClick={() => setTasks(tasks.filter((_, idx) => idx !== i))} className="text-red-400 hover:text-red-600"><X className="h-3 w-3" /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5 mb-2">
|
||||
{tasks.map((task, i) => (
|
||||
<div key={task.id} className="flex items-center gap-2 rounded-lg bg-[var(--bg-subtle)] px-3 py-1.5">
|
||||
<span className="flex-1 text-[12px] text-[var(--ink)]">{task.title}</span>
|
||||
<button type="button" onClick={() => setTasks(tasks.filter((_, idx) => idx !== i))} className="text-red-400 hover:text-red-600"><X className="h-3 w-3" /></button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={newTaskTitle}
|
||||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); if (newTaskTitle.trim()) { setTasks([...tasks, { id: `task-${Date.now()}`, title: newTaskTitle.trim(), status: 'pending' }]); setNewTaskTitle(''); } } }}
|
||||
placeholder="自定义调研方向,回车添加"
|
||||
className="flex-1 h-8 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none"
|
||||
/>
|
||||
<button type="button" onClick={() => { if (newTaskTitle.trim()) { setTasks([...tasks, { id: `task-${Date.now()}`, title: newTaskTitle.trim(), status: 'pending' }]); setNewTaskTitle(''); } }} className="h-8 px-3 rounded-lg text-[12px] font-medium bg-[var(--bg-subtle)] text-[var(--ink-soft)] hover:bg-[var(--line)]">添加</button>
|
||||
</div>
|
||||
{tasks.length === 0 && (
|
||||
<div className="text-[11px] text-red-500 mt-1">请至少添加一项调研方向</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={newTaskTitle}
|
||||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); if (newTaskTitle.trim()) { setTasks([...tasks, { id: `task-${Date.now()}`, title: newTaskTitle.trim(), status: 'pending' }]); setNewTaskTitle(''); } } }}
|
||||
placeholder="自定义任务名称,回车添加"
|
||||
className="flex-1 h-8 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none"
|
||||
/>
|
||||
<button type="button" onClick={() => { if (newTaskTitle.trim()) { setTasks([...tasks, { id: `task-${Date.now()}`, title: newTaskTitle.trim(), status: 'pending' }]); setNewTaskTitle(''); } }} className="h-8 px-3 rounded-lg text-[12px] font-medium bg-[var(--bg-subtle)] text-[var(--ink-soft)] hover:bg-[var(--line)]">添加</button>
|
||||
</div>
|
||||
{tasks.length === 0 && (
|
||||
<div className="text-[11px] text-red-500 mt-1">请至少添加一项任务</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">备注</label>
|
||||
@@ -419,9 +463,10 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
|
||||
|
||||
function CompleteModal({ onClose, onSubmit }: {
|
||||
onClose: () => void;
|
||||
onSubmit: (result: { resultType: 'link' | 'file'; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void;
|
||||
onSubmit: (result: { resultType: 'link' | 'file'; resultTitle: string; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void;
|
||||
}) {
|
||||
const [resultType, setResultType] = useState<'link' | 'file'>('link');
|
||||
const [resultTitle, setResultTitle] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [fileData, setFileData] = useState('');
|
||||
@@ -435,7 +480,7 @@ function CompleteModal({ onClose, onSubmit }: {
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const canSubmit = resultType === 'link' ? url.trim().length > 0 : fileData.length > 0;
|
||||
const canSubmit = resultTitle.trim().length > 0 && (resultType === 'link' ? url.trim().length > 0 : fileData.length > 0);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
@@ -445,6 +490,10 @@ function CompleteModal({ onClose, onSubmit }: {
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">成果标题<span className="text-red-500 ml-0.5">*</span></label>
|
||||
<input value={resultTitle} onChange={(e) => setResultTitle(e.target.value)} placeholder="如 v1.0 产品方案" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => setResultType('link')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'link' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}>链接</button>
|
||||
<button type="button" onClick={() => setResultType('file')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'file' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}>文件</button>
|
||||
@@ -459,7 +508,7 @@ function CompleteModal({ onClose, onSubmit }: {
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">取消</button>
|
||||
<button onClick={() => onSubmit({ resultType, resultUrl: resultType === 'link' ? url.trim() : fileData, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined })} disabled={!canSubmit} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50">确认</button>
|
||||
<button onClick={() => onSubmit({ resultType, resultTitle: resultTitle.trim(), resultUrl: resultType === 'link' ? url.trim() : fileData, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined })} disabled={!canSubmit} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Requirement, ChangeReason } from '@/lib/requirement';
|
||||
import type { DevTask } from '@/lib/dev-task';
|
||||
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, CHANGE_REASON_LABEL } from '@/lib/requirement';
|
||||
import { deriveReqDevStatus, canEditRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine';
|
||||
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
|
||||
|
||||
interface Props {
|
||||
versionId: string;
|
||||
@@ -23,7 +24,7 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showChangeModal, setShowChangeModal] = useState(false);
|
||||
const linkedReqs = requirements.filter((r) => r.versionId === versionId);
|
||||
const availableReqs = requirements.filter((r) => r.projectId === projectId && !r.versionId && r.status === 'adopted');
|
||||
const availableReqs = getProjectAdoptedRequirementCandidates(requirements, projectId).filter((r) => !r.versionId);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -43,7 +44,7 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
|
||||
|
||||
{linkedReqs.length === 0 ? (
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 text-center text-[13px] text-[var(--ink-muted)]">
|
||||
暂无关联需求,从需求池中添加
|
||||
暂无关联需求,从当前项目已采纳需求中添加
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
|
||||
@@ -213,7 +214,7 @@ function AddRequirementModal({ available, onClose, onConfirm }: {
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
<div className="w-full max-w-lg rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] shadow-[var(--shadow-md)] flex flex-col max-h-[70vh]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--line)]">
|
||||
<h3 className="text-[13px] font-semibold text-[var(--ink)]">从需求池添加</h3>
|
||||
<h3 className="text-[13px] font-semibold text-[var(--ink)]">从项目已采纳需求添加</h3>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
<div className="px-5 py-3 border-b border-[var(--line)]">
|
||||
@@ -221,6 +222,15 @@ function AddRequirementModal({ available, onClose, onConfirm }: {
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
||||
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="搜索需求编号或标题" className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-9 pr-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
|
||||
</div>
|
||||
{filtered.length > 0 && (
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="text-[11px] text-[var(--ink-muted)]">当前筛选 {filtered.length} 条,已选 {selected.size} 条</span>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => setSelected(new Set(filtered.map((r) => r.id)))} className="text-[11px] text-[var(--accent)] hover:underline">全选</button>
|
||||
<button type="button" onClick={() => setSelected(new Set())} className="text-[11px] text-[var(--ink-muted)] hover:underline">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-5 py-3">
|
||||
{filtered.length === 0 ? (
|
||||
|
||||
Reference in New Issue
Block a user