去掉"到了计划日期自动变进行中"的逻辑,改为用户手动点击: - pending 状态显示蓝色"开始"按钮 - 点击后记录 actualStartAt(精确时间戳) - 进行中才显示"完成"按钮 跟开发/测试逻辑一致:状态由用户主动触发,时间戳准确。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
410 lines
26 KiB
TypeScript
410 lines
26 KiB
TypeScript
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2, Play } from 'lucide-react';
|
||
import type { VersionPlan, PlanTask } from '@/lib/version-plan';
|
||
import { calcPlanDuration, formatDuration, calcTotalDuration, calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
|
||
|
||
interface Props {
|
||
plans: VersionPlan[];
|
||
versionId: string;
|
||
versionDeadline?: string;
|
||
currentUserName: string;
|
||
planType: 'research' | 'product' | 'ui';
|
||
linkedRequirements?: { id: string; title: string; code: string; productOwner?: string }[];
|
||
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;
|
||
onDelete: (id: string) => void;
|
||
}
|
||
|
||
const TYPE_LABEL = { research: '调研', product: '产品方案', ui: 'UI设计' };
|
||
const STATUS_STYLE = {
|
||
pending: 'bg-zinc-100 text-zinc-600',
|
||
in_progress: 'bg-blue-50 text-blue-600 border-blue-200',
|
||
completed: 'bg-green-50 text-green-700 border-green-200',
|
||
};
|
||
const STATUS_LABEL = { pending: '未开始', in_progress: '进行中', completed: '已完成' };
|
||
|
||
export function PlanTab({ plans, versionId, versionDeadline, currentUserName, planType, linkedRequirements, onCreate, onUpdate, onComplete, onDelete }: Props) {
|
||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||
const [editingPlan, setEditingPlan] = useState<VersionPlan | null>(null);
|
||
const [completingPlan, setCompletingPlan] = useState<VersionPlan | null>(null);
|
||
|
||
const typePlans = plans.filter((p) => p.versionId === versionId && p.type === planType);
|
||
const totalDuration = calcTotalDuration(typePlans);
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-[12px] text-[var(--ink-muted)]">{typePlans.length} 个{TYPE_LABEL[planType]}计划</span>
|
||
<span className="text-[12px] text-[var(--ink-soft)]">已耗时:<span className="font-medium text-[var(--ink)]">{totalDuration}</span></span>
|
||
{versionDeadline && <span className="text-[12px] text-[var(--ink-muted)]">版本截止:<span className="font-medium text-red-500">{versionDeadline}</span></span>}
|
||
</div>
|
||
<button onClick={() => setShowCreateModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
|
||
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
|
||
新建计划
|
||
</button>
|
||
</div>
|
||
|
||
{typePlans.length === 0 ? (
|
||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 text-center text-[13px] text-[var(--ink-muted)]">
|
||
暂无{TYPE_LABEL[planType]}计划
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{typePlans.map((plan) => {
|
||
const now = new Date().toISOString();
|
||
// 耗时用实际时间戳计算
|
||
const dur = plan.status === 'completed' && plan.completedAt && plan.actualStartAt
|
||
? calcPlanDuration(plan.actualStartAt, plan.completedAt)
|
||
: plan.status === 'in_progress' && plan.actualStartAt
|
||
? calcPlanDuration(plan.actualStartAt, now)
|
||
: { days: 0, hours: 0 };
|
||
const durText = dur.days > 0 || dur.hours > 0 ? formatDuration(dur.days, dur.hours) : '-';
|
||
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[plan.status]}`}>
|
||
{STATUS_LABEL[plan.status]}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-4 text-[12px] text-[var(--ink-soft)]">
|
||
<span>计划:{plan.startTime.slice(0, 10)} → {plan.endTime.slice(0, 10)}</span>
|
||
{plan.actualStartAt && <span className="text-blue-600">实际开始:{plan.actualStartAt.slice(0, 16).replace('T', ' ')}</span>}
|
||
{plan.completedAt && <span className="text-green-600">完成:{plan.completedAt.slice(0, 16).replace('T', ' ')}</span>}
|
||
<span className="font-medium text-[var(--ink)]">已耗时 {durText}</span>
|
||
</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.status === 'completed' && plan.resultUrl && (
|
||
<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" />
|
||
</a>
|
||
</div>
|
||
)}
|
||
{/* 调研:任务进度 */}
|
||
{plan.type === 'research' && plan.tasks && plan.tasks.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">
|
||
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${calcPlanProgress(plan.tasks)}%` }} />
|
||
</div>
|
||
<span className="text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{calcPlanProgress(plan.tasks)}%</span>
|
||
</div>
|
||
<div className="space-y-1">
|
||
{plan.tasks.map((task) => (
|
||
<div key={task.id} className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => {
|
||
const nextStatus = task.status === 'pending' ? 'in_progress' : task.status === 'in_progress' ? 'completed' : 'pending';
|
||
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 ${task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' : task.status === 'in_progress' ? 'border-blue-400 bg-blue-50' : 'border-[var(--line)]'}`}
|
||
>
|
||
{task.status === 'completed' && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
|
||
{task.status === 'in_progress' && <div className="h-1.5 w-1.5 rounded-full bg-blue-500" />}
|
||
</button>
|
||
<span className={`text-[12px] ${task.status === 'completed' ? 'line-through text-[var(--ink-muted)]' : 'text-[var(--ink)]'}`}>{task.title}</span>
|
||
<span className={`text-[10px] ${task.status === 'completed' ? 'text-green-600' : task.status === 'in_progress' ? 'text-blue-600' : 'text-[var(--ink-muted)]'}`}>
|
||
{task.status === 'completed' ? '已完成' : task.status === 'in_progress' ? '进行中' : '未开始'}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* 产品方案/UI:关联需求进度 */}
|
||
{(plan.type === 'product' || plan.type === 'ui') && plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0 && linkedRequirements && (
|
||
<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">
|
||
<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">
|
||
{plan.linkedRequirementIds.map((rid) => {
|
||
const req = linkedRequirements.find((r) => r.id === rid);
|
||
const isDone = (plan.completedRequirementIds || []).includes(rid);
|
||
return req ? (
|
||
<div key={rid} className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => {
|
||
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 ${isDone ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
|
||
>
|
||
{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>
|
||
</div>
|
||
) : 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>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-1 ml-3">
|
||
{plan.status === 'pending' && (
|
||
<button onClick={() => onUpdate(plan.id, { status: 'in_progress' })} className="h-7 px-2 flex items-center gap-1 rounded-md text-[11px] font-medium text-blue-600 hover:bg-blue-50 border border-blue-200" title="开始">
|
||
<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={() => setEditingPlan(plan)} className="h-7 w-7 flex items-center justify-center rounded-md text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]" title="编辑">
|
||
<Pencil className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button onClick={() => onDelete(plan.id)} className="h-7 w-7 flex items-center justify-center rounded-md text-red-500 hover:bg-red-50" title="删除">
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{(showCreateModal || editingPlan) && (
|
||
<PlanFormModal
|
||
initial={editingPlan}
|
||
planType={planType}
|
||
versionId={versionId}
|
||
versionDeadline={versionDeadline}
|
||
currentUserName={currentUserName}
|
||
linkedRequirements={linkedRequirements}
|
||
onClose={() => { setShowCreateModal(false); setEditingPlan(null); }}
|
||
onSubmit={(data) => {
|
||
if (editingPlan) onUpdate(editingPlan.id, data);
|
||
else onCreate(data as any);
|
||
setShowCreateModal(false);
|
||
setEditingPlan(null);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{completingPlan && (
|
||
<CompleteModal
|
||
onClose={() => setCompletingPlan(null)}
|
||
onSubmit={(result) => { onComplete(completingPlan.id, result); setCompletingPlan(null); }}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PlanFormModal({ initial, planType, versionId, versionDeadline, currentUserName, linkedRequirements, onClose, onSubmit }: {
|
||
initial: VersionPlan | null;
|
||
planType: 'research' | 'product' | 'ui';
|
||
versionId: string;
|
||
versionDeadline?: string;
|
||
currentUserName: string;
|
||
linkedRequirements?: { id: string; title: string; code: string }[];
|
||
onClose: () => void;
|
||
onSubmit: (data: any) => void;
|
||
}) {
|
||
const now = new Date().toISOString().slice(0, 10);
|
||
const [title, setTitle] = useState(initial?.title ?? '');
|
||
const [owner] = useState(initial?.owner ?? currentUserName);
|
||
const [startTime, setStartTime] = useState(initial?.startTime?.slice(0, 10) ?? now);
|
||
const [endTime, setEndTime] = useState(initial?.endTime?.slice(0, 10) ?? '');
|
||
const [remark, setRemark] = useState(initial?.remark ?? '');
|
||
const [tasks, setTasks] = useState<PlanTask[]>(initial?.tasks ?? []);
|
||
const [newTaskTitle, setNewTaskTitle] = useState('');
|
||
const [overdueReason, setOverdueReason] = useState(initial?.overdueReason ?? '');
|
||
const [selectedReqs, setSelectedReqs] = useState<Set<string>>(new Set(initial?.linkedRequirementIds ?? []));
|
||
const showReqSelect = planType === 'product' || planType === 'ui';
|
||
const isOverdue = !!(versionDeadline && endTime && new Date(endTime) > new Date(versionDeadline));
|
||
|
||
const handleSubmit = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!title.trim() || !endTime) return;
|
||
if (isOverdue && !overdueReason.trim()) return;
|
||
onSubmit({
|
||
versionId,
|
||
type: planType,
|
||
title: title.trim(),
|
||
owner: owner.trim() || currentUserName,
|
||
startTime,
|
||
endTime,
|
||
status: initial?.status ?? 'pending',
|
||
linkedRequirementIds: showReqSelect ? Array.from(selectedReqs) : undefined,
|
||
tasks: tasks.length > 0 ? tasks : undefined,
|
||
remark: remark.trim() || undefined,
|
||
overdueReason: isOverdue ? overdueReason.trim() : undefined,
|
||
addedBy: currentUserName,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||
<div className="w-full max-w-md rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-[13px] font-semibold text-[var(--ink)]">{initial ? '编辑' : '新建'}{TYPE_LABEL[planType]}计划</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>
|
||
<form onSubmit={handleSubmit} className="space-y-3">
|
||
<div>
|
||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">标题</label>
|
||
<input value={title} onChange={(e) => setTitle(e.target.value)} required 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>
|
||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">负责人</label>
|
||
<input value={owner} readOnly className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-3 text-[13px] text-[var(--ink-muted)] cursor-not-allowed" />
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">开始日期</label>
|
||
<input type="date" value={startTime} onChange={(e) => setStartTime(e.target.value)} 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>
|
||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">结束日期</label>
|
||
<input type="date" value={endTime} onChange={(e) => setEndTime(e.target.value)} required 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>
|
||
{versionDeadline && (
|
||
<div className="text-[11px] text-[var(--ink-muted)] -mt-1">版本截止日期:<span className="text-red-500 font-medium">{versionDeadline}</span></div>
|
||
)}
|
||
{isOverdue && (
|
||
<div className="rounded-lg bg-red-50 border border-red-200 p-3 space-y-2">
|
||
<div className="text-[12px] text-red-700 font-medium">⚠ 结束日期超出版本截止日期,请说明原因</div>
|
||
<textarea
|
||
value={overdueReason}
|
||
onChange={(e) => setOverdueReason(e.target.value)}
|
||
rows={2}
|
||
placeholder="例如:技术难点超预期、上游交付延误..."
|
||
required
|
||
className="w-full rounded-lg border border-red-200 bg-white px-3 py-2 text-[13px] focus:border-red-400 focus:outline-none resize-none"
|
||
/>
|
||
</div>
|
||
)}
|
||
{showReqSelect && linkedRequirements && linkedRequirements.length > 0 && (
|
||
<div>
|
||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">关联需求</label>
|
||
<div className="max-h-[120px] overflow-y-auto rounded-lg border border-[var(--line)] p-2 space-y-1">
|
||
{linkedRequirements.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>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{planType === 'research' && (
|
||
<div>
|
||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">任务清单</label>
|
||
<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="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>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">备注</label>
|
||
<textarea value={remark} onChange={(e) => setRemark(e.target.value)} rows={2} className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 py-2 text-[13px] focus:border-[var(--accent)] focus:outline-none resize-none" />
|
||
</div>
|
||
<div className="flex justify-end gap-2 pt-2">
|
||
<button type="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 type="submit" className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">{initial ? '保存' : '创建'}</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CompleteModal({ onClose, onSubmit }: {
|
||
onClose: () => void;
|
||
onSubmit: (result: { resultType: 'link' | 'file'; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void;
|
||
}) {
|
||
const [resultType, setResultType] = useState<'link' | 'file'>('link');
|
||
const [url, setUrl] = useState('');
|
||
const [fileName, setFileName] = useState('');
|
||
const [fileData, setFileData] = useState('');
|
||
|
||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
setFileName(file.name);
|
||
const reader = new FileReader();
|
||
reader.onload = () => setFileData(reader.result as string);
|
||
reader.readAsDataURL(file);
|
||
};
|
||
|
||
const canSubmit = 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}>
|
||
<div className="w-full max-w-sm rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<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="space-y-3">
|
||
<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>
|
||
</div>
|
||
{resultType === 'link' ? (
|
||
<input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://..." 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>
|
||
<input type="file" onChange={handleFile} className="text-[12px] text-[var(--ink-soft)]" />
|
||
{fileName && <p className="text-[11px] text-[var(--ink-muted)] mt-1">已选:{fileName}</p>}
|
||
</div>
|
||
)}
|
||
<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>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|