|
| 1 | +/** |
| 2 | + * StepFlowView — 步骤化笔记视图(P2-7/P2-8) |
| 3 | + * |
| 4 | + * @ai-context: 技能场景(软件/手法技巧)的步骤卡片流:每步 = 截图 + 操作 |
| 5 | + * 说明 + 时间戳;支持「练习模式」(隐藏说明看截图回忆操作,翻卡对照, |
| 6 | + * 费曼式主动回忆)、「跟着做」checklist 勾选、步骤→闪卡问答导出。 |
| 7 | + * 数据源为 smart 会话数据经 extractSteps 提取(内存态,会话内可用)。 |
| 8 | + * @ai-context EN: Step-card flow for skill videos: screenshot + instruction |
| 9 | + * + timestamp per step. Practice mode hides instructions for active recall; |
| 10 | + * checklist tracks progress; cards export as Q&A flashcards. |
| 11 | + */ |
| 12 | +import { useMemo, useState } from 'react'; |
| 13 | +import { Eye, EyeOff, CheckSquare, Copy, Layers } from 'lucide-react'; |
| 14 | +import { cn } from '@/lib/utils'; |
| 15 | +import { |
| 16 | + extractSteps, |
| 17 | + stepsToChecklist, |
| 18 | + stepsToFlashcards, |
| 19 | +} from '@/lib/capture/stepExtractor'; |
| 20 | +import type { SessionBundle } from '@/lib/capture'; |
| 21 | + |
| 22 | +interface StepFlowViewProps { |
| 23 | + bundle: Partial<SessionBundle>; |
| 24 | + /** 是否显示操作说明(练习模式=false 隐藏,点击单卡对照) */ |
| 25 | + initiallyPractice?: boolean; |
| 26 | +} |
| 27 | + |
| 28 | +export function StepFlowView({ bundle, initiallyPractice = false }: StepFlowViewProps) { |
| 29 | + const [practice, setPractice] = useState(initiallyPractice); |
| 30 | + /** 练习模式下被翻开的卡片 id 集合(对照查看) */ |
| 31 | + const [revealed, setRevealed] = useState<Set<string>>(new Set()); |
| 32 | + const [checked, setChecked] = useState<Set<string>>(new Set()); |
| 33 | + const [copied, setCopied] = useState(false); |
| 34 | + |
| 35 | + const { steps, boundaryCount, commandCueCount } = useMemo( |
| 36 | + () => extractSteps(bundle.keyframes ?? [], bundle.audioSegments ?? []), |
| 37 | + [bundle.keyframes, bundle.audioSegments], |
| 38 | + ); |
| 39 | + const checklist = useMemo(() => stepsToChecklist(steps), [steps]); |
| 40 | + |
| 41 | + const toggleReveal = (id: string) => { |
| 42 | + setRevealed((prev) => { |
| 43 | + const next = new Set(prev); |
| 44 | + if (next.has(id)) next.delete(id); |
| 45 | + else next.add(id); |
| 46 | + return next; |
| 47 | + }); |
| 48 | + }; |
| 49 | + |
| 50 | + const toggleCheck = (id: string) => { |
| 51 | + setChecked((prev) => { |
| 52 | + const next = new Set(prev); |
| 53 | + if (next.has(id)) next.delete(id); |
| 54 | + else next.add(id); |
| 55 | + return next; |
| 56 | + }); |
| 57 | + }; |
| 58 | + |
| 59 | + /** 导出步骤闪卡问答(复制到剪贴板,供闪卡模块导入) */ |
| 60 | + const exportFlashcards = async () => { |
| 61 | + const cards = stepsToFlashcards(steps); |
| 62 | + const text = cards.map((c) => `${c.front}\n答:${c.back}`).join('\n\n---\n\n'); |
| 63 | + await navigator.clipboard.writeText(text); |
| 64 | + setCopied(true); |
| 65 | + window.setTimeout(() => setCopied(false), 2000); |
| 66 | + }; |
| 67 | + |
| 68 | + if (steps.length === 0) { |
| 69 | + return ( |
| 70 | + <div className="flex-1 flex flex-col items-center justify-center py-10 text-text-tertiary"> |
| 71 | + <Layers className="w-8 h-8 mb-2 opacity-30" strokeWidth={1} /> |
| 72 | + <p className="text-b3">技能类内容(界面切换 + 操作指令)将自动生成步骤卡片</p> |
| 73 | + </div> |
| 74 | + ); |
| 75 | + } |
| 76 | + |
| 77 | + return ( |
| 78 | + <div className="flex-1 min-h-0 flex flex-col"> |
| 79 | + {/* 工具栏:练习模式 / 统计 / 闪卡导出 */} |
| 80 | + <div className="flex items-center gap-2 px-3 py-2 border-b border-border/20 flex-shrink-0"> |
| 81 | + <button |
| 82 | + onClick={() => setPractice((p) => !p)} |
| 83 | + className={cn( |
| 84 | + 'flex items-center gap-1 px-2 py-1 rounded-kb-sm text-[11px] font-medium transition-all', |
| 85 | + practice |
| 86 | + ? 'bg-amber-50 text-amber-600 ring-1 ring-amber-200/50' |
| 87 | + : 'text-text-tertiary hover:bg-bg-tertiary', |
| 88 | + )} |
| 89 | + > |
| 90 | + {practice ? <EyeOff className="w-3 h-3" /> : <Eye className="w-3 h-3" />} |
| 91 | + {practice ? '练习模式(点击卡片对照)' : '查看模式'} |
| 92 | + </button> |
| 93 | + <span className="text-[10px] text-text-tertiary"> |
| 94 | + {steps.length} 步 · {boundaryCount} 次界面切换 · {commandCueCount} 条操作指令 |
| 95 | + </span> |
| 96 | + <button |
| 97 | + onClick={exportFlashcards} |
| 98 | + className="ml-auto flex items-center gap-1 px-2 py-1 rounded-kb-sm text-[11px] text-text-tertiary hover:text-cyber hover:bg-cyber/5 transition-all" |
| 99 | + > |
| 100 | + <Copy className="w-3 h-3" /> |
| 101 | + {copied ? '已复制闪卡问答' : '导出步骤闪卡'} |
| 102 | + </button> |
| 103 | + </div> |
| 104 | + |
| 105 | + {/* 步骤卡片流 */} |
| 106 | + <div className="flex-1 overflow-y-auto p-3 space-y-2"> |
| 107 | + {steps.map((step, idx) => { |
| 108 | + const isRevealed = !practice || revealed.has(step.id); |
| 109 | + return ( |
| 110 | + <div |
| 111 | + key={step.id} |
| 112 | + className={cn( |
| 113 | + 'rounded-kb-md border border-border/20 bg-bg-elevated/40 p-3', |
| 114 | + practice && 'cursor-pointer transition-colors hover:bg-bg-elevated', |
| 115 | + )} |
| 116 | + onClick={practice ? () => toggleReveal(step.id) : undefined} |
| 117 | + > |
| 118 | + <div className="flex items-start gap-3"> |
| 119 | + {/* 步骤截图 */} |
| 120 | + {step.imageBase64 && ( |
| 121 | + <img |
| 122 | + src={`data:image/jpeg;base64,${step.imageBase64}`} |
| 123 | + alt={`步骤 ${idx + 1} 截图`} |
| 124 | + className="w-36 rounded-kb-sm object-cover border border-border/30 flex-shrink-0" |
| 125 | + /> |
| 126 | + )} |
| 127 | + <div className="flex-1 min-w-0"> |
| 128 | + <div className="flex items-center gap-2"> |
| 129 | + <span className="text-[10px] text-text-tertiary font-mono"> |
| 130 | + {new Date(step.timestamp).toLocaleTimeString()} |
| 131 | + </span> |
| 132 | + <span className="text-b3 font-medium text-text-primary">{step.title}</span> |
| 133 | + </div> |
| 134 | + <p className={cn( |
| 135 | + 'mt-1 text-[12px] leading-relaxed text-text-secondary', |
| 136 | + practice && !isRevealed && 'opacity-0 select-none', |
| 137 | + practice && !isRevealed && 'h-4', // 占位防跳动 |
| 138 | + )}> |
| 139 | + {isRevealed ? (step.instruction || '(本步无口述说明,观察截图)') : '••• 回忆一下这一步做了什么 •••'} |
| 140 | + </p> |
| 141 | + </div> |
| 142 | + </div> |
| 143 | + </div> |
| 144 | + ); |
| 145 | + })} |
| 146 | + </div> |
| 147 | + |
| 148 | + {/* 跟着做 checklist */} |
| 149 | + <div className="border-t border-border/20 px-3 py-2 flex-shrink-0"> |
| 150 | + <div className="flex items-center gap-1.5 mb-1.5"> |
| 151 | + <CheckSquare className="w-3.5 h-3.5 text-emerald-500" strokeWidth={1.5} /> |
| 152 | + <span className="text-[11px] font-medium text-text-tertiary"> |
| 153 | + 跟着做({checked.size}/{checklist.length}) |
| 154 | + </span> |
| 155 | + </div> |
| 156 | + <div className="flex flex-wrap gap-1.5 max-h-20 overflow-y-auto"> |
| 157 | + {checklist.map((item) => ( |
| 158 | + <button |
| 159 | + key={item.id} |
| 160 | + onClick={() => toggleCheck(item.id)} |
| 161 | + className={cn( |
| 162 | + 'px-2 py-0.5 rounded-kb-sm text-[11px] border transition-all', |
| 163 | + checked.has(item.id) |
| 164 | + ? 'border-emerald-300/50 bg-emerald-50 text-emerald-600 line-through' |
| 165 | + : 'border-border/30 text-text-secondary hover:border-emerald-300/50', |
| 166 | + )} |
| 167 | + > |
| 168 | + {item.label} |
| 169 | + </button> |
| 170 | + ))} |
| 171 | + </div> |
| 172 | + </div> |
| 173 | + </div> |
| 174 | + ); |
| 175 | +} |
| 176 | + |
| 177 | +export default StepFlowView; |
0 commit comments