fix: 胶囊进度按子任务加权 + 调研任务清单必填+预设选项

1. 胶囊进度计算改为子任务加权:
   - 分子 = 所有plan中已完成的子任务数
   - 分母 = 所有plan的子任务总数
   - completed的plan视为其子任务全部完成
   - 没有子任务的plan按1项计算(不会导致分母为0)
   - 例:4个completed(共8子任务) + 1个pending(2子任务)
         = 8/(8+2) = 80%

2. 调研任务清单必须至少1项:
   - handleSubmit 加校验:tasks.length === 0 时 return
   - 红色提示:"请至少添加一项任务"

3. 预设可选任务选项(空列表时显示):
   - 竞品分析、用户访谈、数据调研
   - 技术可行性分析、市场调研、需求分析
   - 点击即添加,也可自定义输入

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Script Generator
2026-06-15 15:39:38 +08:00
parent 5bbcf02962
commit 2d3f56b00c
2 changed files with 45 additions and 17 deletions

View File

@@ -303,27 +303,33 @@ export default function VersionDetailPage() {
const calcGroupProgress = (group: typeof vPlans, type: 'research' | 'product' | 'ui') => {
if (group.length === 0) return 0;
// 每个 plan 按权重贡献进度completed=1, in_progress=子任务完成率, pending=0
let totalWeight = 0;
// 分子=已完成子任务数,分母=所有plan的子任务总数
// completed 的 plan其子任务全算已完成
let totalItems = 0;
let doneItems = 0;
for (const p of group) {
if (p.status === 'completed') {
totalWeight += 1;
} else if (p.status === 'in_progress') {
if (type === 'research') {
const tasks = p.tasks || [];
if (tasks.length > 0) {
totalWeight += tasks.filter((t) => t.status === 'completed').length / tasks.length;
}
if (type === 'research') {
const tasks = p.tasks || [];
const count = Math.max(tasks.length, 1);
totalItems += count;
if (p.status === 'completed') {
doneItems += count;
} else {
doneItems += tasks.filter((t) => t.status === 'completed').length;
}
} else {
const linked = p.linkedRequirementIds || [];
const count = Math.max(linked.length, 1);
totalItems += count;
if (p.status === 'completed') {
doneItems += count;
} else {
const linked = p.linkedRequirementIds || [];
const completed = p.completedRequirementIds || [];
if (linked.length > 0) {
totalWeight += completed.filter((id) => linked.includes(id)).length / linked.length;
}
doneItems += completed.filter((id) => linked.includes(id)).length;
}
}
}
return Math.round((totalWeight / group.length) * 100);
return totalItems > 0 ? Math.round((doneItems / totalItems) * 100) : 0;
};
const getPlanStatus = (group: typeof vPlans): 'idle' | 'active' | 'done' => {

View File

@@ -252,6 +252,7 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
e.preventDefault();
if (!title.trim() || !endTime) return;
if (isOverdue && !overdueReason.trim()) return;
if (planType === 'research' && tasks.length === 0) return;
onSubmit({
versionId,
type: planType,
@@ -326,7 +327,25 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
)}
{planType === 'research' && (
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block"></label>
<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">
@@ -340,11 +359,14 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
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="输入任务名称,回车添加"
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>