feat(平台): 补齐服务端持久化和AI拆解契约
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user