Skip to content

Commit aca2166

Browse files
committed
feat(classroom): 回声定位页面布局优化与窗口选择增强
- 双阶段左栏:配置态一屏不滚 + 运行态声呐仪表盘 - 右侧空态改为当前配置说明书,随路径/模式切换 - 智能路径置顶并设为默认采集路径 - 窗口选择两级过滤:推荐窗口 + 可展开全部窗口手动自选 - 拆分 ClassroomPage 为装配层,新增 7 组件 + constants - 新增 docs/Foresight 课程预设头脑风暴
1 parent 61d16e8 commit aca2166

11 files changed

Lines changed: 1007 additions & 462 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* CourseInfoCard — 课程信息卡片(右侧空态区)
3+
* Course info editable card rendered in the right-side idle guide area.
4+
*
5+
* @ai-context: 原左栏 CourseInfoSection 迁出至右侧空态底部,启动采集前顺手
6+
* 填写课程名称/学科/AI 识别开关;detectedBy 徽标标示信息来源(ai/窗口标题/手动)。
7+
* @ai-context: Moved out of the left rail to keep it within one screen height;
8+
* rendered as an editable card at the bottom of the idle guide panel.
9+
*/
10+
import { Sparkles } from 'lucide-react';
11+
import { cn } from '@/lib/utils';
12+
import type { CourseMeta } from '@/lib/capture';
13+
import { SUBJECT_OPTIONS } from '../constants';
14+
15+
interface CourseInfoCardProps {
16+
courseMeta: CourseMeta;
17+
onChange: (meta: CourseMeta) => void;
18+
aiDetectEnabled: boolean;
19+
onAiDetectToggle: (enabled: boolean) => void;
20+
}
21+
22+
export function CourseInfoCard({ courseMeta, onChange, aiDetectEnabled, onAiDetectToggle }: CourseInfoCardProps) {
23+
return (
24+
<div className="rounded-kb-lg border border-border/20 bg-bg-secondary/40 p-4 space-y-3">
25+
<div className="flex items-center gap-2 text-b3 text-text-tertiary">
26+
<Sparkles className="w-4 h-4" strokeWidth={1.5} />
27+
<span className="font-medium">课程信息</span>
28+
{courseMeta.detectedBy && (
29+
<span className="ml-auto text-[10px] px-1.5 py-0.5 rounded-kb-xs bg-brand-50 text-brand-500">
30+
{courseMeta.detectedBy === 'ai' ? 'AI 识别' : courseMeta.detectedBy === 'window_title' ? '自动提取' : '手动'}
31+
</span>
32+
)}
33+
</div>
34+
<div>
35+
<label className="text-b3 text-text-tertiary mb-1 block">课程名称</label>
36+
<input
37+
type="text"
38+
value={courseMeta.courseName ?? ''}
39+
onChange={(e) => onChange({ ...courseMeta, courseName: e.target.value || undefined, detectedBy: 'manual' })}
40+
placeholder="如:高等数学、数据结构..."
41+
className="w-full px-2.5 py-1.5 rounded-kb-sm text-b3 bg-bg-secondary border border-border/30 text-text-primary placeholder:text-text-tertiary/50 focus:outline-none focus:ring-1 focus:ring-brand-300"
42+
/>
43+
</div>
44+
<div>
45+
<label className="text-b3 text-text-tertiary mb-1 block">学科</label>
46+
<div className="flex gap-1 flex-wrap">
47+
{SUBJECT_OPTIONS.map(({ value, label }) => (
48+
<button key={value} onClick={() => onChange({ ...courseMeta, subject: value, detectedBy: 'manual' })}
49+
className={cn(
50+
'px-2 py-1 rounded-kb-sm text-[11px] font-medium transition-all',
51+
courseMeta.subject === value
52+
? 'bg-brand-50 text-brand-600 ring-1 ring-brand-200/50'
53+
: 'text-text-tertiary hover:bg-bg-tertiary hover:text-text-secondary',
54+
)}>
55+
{label}
56+
</button>
57+
))}
58+
</div>
59+
</div>
60+
{/* AI 识别开关 */}
61+
<label className="flex items-center gap-2 cursor-pointer">
62+
<input
63+
type="checkbox"
64+
checked={aiDetectEnabled}
65+
onChange={(e) => onAiDetectToggle(e.target.checked)}
66+
className="w-3.5 h-3.5 rounded accent-brand-600"
67+
/>
68+
<span className="text-b3 text-text-tertiary">采集开始时 AI 自动识别课程</span>
69+
</label>
70+
</div>
71+
);
72+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* IdleGuidePanel — 右侧空态"当前配置说明书"面板
3+
* Right-side idle guide: sonar visual + explanation cards of current selection.
4+
*
5+
* @ai-context: 采集未开始时的右侧空态。只展示"当前选中"的采集路径与模式各
6+
* 1 张说明卡(文案来自 constants.ts),左栏切换选项时通过 key 变化触发
7+
* page-fade-in 重播实现淡入切换;声呐波纹用 animate-ping 实现并遵循
8+
* prefers-reduced-motion(motion-reduce:animate-none)。
9+
* @ai-context: Idle-state panel showing ONLY the currently selected capture
10+
* path & mode cards; children slot hosts the CourseInfoCard at the bottom.
11+
*/
12+
import type { ReactNode } from 'react';
13+
import { Radar } from 'lucide-react';
14+
import type { CaptureMode, CapturePath } from '@/lib/capture';
15+
import { PATH_OPTIONS, MODE_OPTIONS } from '../constants';
16+
import type { CaptureOptionMeta } from '../constants';
17+
18+
interface IdleGuidePanelProps {
19+
capturePath: CapturePath;
20+
mode: CaptureMode;
21+
hasWindow: boolean;
22+
/** 底部插槽:课程信息卡等启动前配置 */
23+
children?: ReactNode;
24+
}
25+
26+
/** 单张说明卡:图标 + 名称 + 简述徽标 + 详细说明 + 适合场景 */
27+
function GuideCard({ meta, kind }: { meta: CaptureOptionMeta<string>; kind: string }) {
28+
const Icon = meta.icon;
29+
return (
30+
<div className="rounded-kb-lg border border-border/20 bg-bg-secondary/40 p-4">
31+
<div className="flex items-center gap-2">
32+
<Icon className="w-4 h-4 text-brand-500" strokeWidth={1.5} />
33+
<span className="text-b2 font-medium text-text-primary">{meta.label}</span>
34+
<span className="px-1.5 py-0.5 rounded-kb-xs bg-brand-50 text-brand-500 text-[10px] font-medium">{meta.brief}</span>
35+
<span className="ml-auto text-[10px] text-text-tertiary">{kind}</span>
36+
</div>
37+
<p className="mt-2 text-b3 leading-relaxed text-text-secondary">{meta.detail}</p>
38+
<p className="mt-1.5 text-[11px] text-text-tertiary">适合:{meta.scene}</p>
39+
</div>
40+
);
41+
}
42+
43+
export function IdleGuidePanel({ capturePath, mode, hasWindow, children }: IdleGuidePanelProps) {
44+
const pathMeta = PATH_OPTIONS.find((p) => p.value === capturePath) ?? PATH_OPTIONS[0];
45+
const modeMeta = MODE_OPTIONS.find((m) => m.value === mode) ?? MODE_OPTIONS[2];
46+
47+
return (
48+
<div className="flex flex-col items-center px-6 py-10">
49+
{/* 声呐待命视觉:双层波纹 + 中心雷达 */}
50+
<div className="relative flex items-center justify-center w-24 h-24">
51+
<span
52+
className="absolute inset-0 rounded-full bg-brand-500/10 animate-ping motion-reduce:animate-none"
53+
style={{ animationDuration: '2.4s' }}
54+
/>
55+
<span
56+
className="absolute inset-3 rounded-full bg-brand-500/10 animate-ping motion-reduce:animate-none"
57+
style={{ animationDuration: '2.4s', animationDelay: '0.6s' }}
58+
/>
59+
<span className="relative flex items-center justify-center w-12 h-12 rounded-full bg-brand-50 border border-brand-200/40">
60+
<Radar className="w-6 h-6 text-brand-500" strokeWidth={1.5} />
61+
</span>
62+
</div>
63+
<p className="mt-4 text-b2 font-medium text-text-secondary">声呐待命</p>
64+
<p className="mt-1 text-b3 text-text-tertiary">
65+
{hasWindow ? '配置就绪,点击左侧「开始回声定位」启动扫描' : '先在左侧锁定目标窗口'}
66+
</p>
67+
68+
{/* 当前配置说明卡:key 变化触发淡入切换 */}
69+
<div key={`${capturePath}-${mode}`} className="page-fade-in w-full max-w-md space-y-3 mt-8">
70+
<GuideCard meta={pathMeta} kind="采集路径" />
71+
<GuideCard meta={modeMeta} kind="采集模式" />
72+
</div>
73+
74+
{children && <div className="w-full max-w-md mt-3">{children}</div>}
75+
</div>
76+
);
77+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* PathModeSelector — 采集路径与采集模式选择器
3+
* Capture path (fine/smart/full_record) and mode (vision/audio/mixed) selector.
4+
*
5+
* @ai-context: 左栏配置态组件。为保证左栏一屏容纳,此处只保留选项按钮本身,
6+
* 详细说明文案统一移至右侧空态说明卡(IdleGuidePanel),文案源自 constants.ts。
7+
* @ai-context: Left-rail config-stage component. Option details are rendered by
8+
* the right-side IdleGuidePanel to keep the rail within one screen height.
9+
*/
10+
import { CheckCircle2 } from 'lucide-react';
11+
import { cn } from '@/lib/utils';
12+
import type { CaptureMode, CapturePath } from '@/lib/capture';
13+
import { PATH_OPTIONS, MODE_OPTIONS } from '../constants';
14+
15+
interface PathModeSelectorProps {
16+
capturePath: CapturePath;
17+
onPathChange: (path: CapturePath) => void;
18+
mode: CaptureMode;
19+
onModeChange: (mode: CaptureMode) => void;
20+
disabled?: boolean;
21+
}
22+
23+
export function PathModeSelector({ capturePath, onPathChange, mode, onModeChange, disabled }: PathModeSelectorProps) {
24+
return (
25+
<div className="space-y-4">
26+
{/* 采集路径 */}
27+
<div>
28+
<span className="text-b3 font-medium text-text-tertiary block mb-2">采集路径</span>
29+
<div className="flex flex-col gap-1.5">
30+
{PATH_OPTIONS.map(({ value, label, icon: PathIcon, brief }) => (
31+
<button key={value} onClick={() => onPathChange(value)} disabled={disabled}
32+
className={cn(
33+
'flex items-center gap-2.5 px-3 py-2 rounded-kb-md text-left transition-all border',
34+
capturePath === value
35+
? 'bg-brand-50 border-brand-200/60 shadow-kb-sm'
36+
: 'border-transparent hover:bg-bg-tertiary/50',
37+
disabled && 'opacity-50 cursor-not-allowed',
38+
)}>
39+
<PathIcon className={cn('w-4 h-4 flex-shrink-0', capturePath === value ? 'text-brand-600' : 'text-text-tertiary')} strokeWidth={1.5} />
40+
<span className={cn('text-b3 font-medium', capturePath === value ? 'text-brand-700' : 'text-text-secondary')}>
41+
{label}
42+
</span>
43+
<span className={cn('text-[11px]', capturePath === value ? 'text-brand-500' : 'text-text-tertiary')}>
44+
{brief}
45+
</span>
46+
{capturePath === value && (
47+
<CheckCircle2 className="ml-auto w-4 h-4 text-brand-500 flex-shrink-0" strokeWidth={1.5} />
48+
)}
49+
</button>
50+
))}
51+
</div>
52+
</div>
53+
54+
{/* 采集模式 */}
55+
<div>
56+
<span className="text-b3 font-medium text-text-tertiary block mb-2">采集模式</span>
57+
<div className="flex items-center gap-1">
58+
{MODE_OPTIONS.map(({ value, label, icon: ModeIcon }) => (
59+
<button key={value} onClick={() => onModeChange(value)} disabled={disabled}
60+
className={cn(
61+
'flex-1 inline-flex items-center justify-center gap-1.5 py-2 rounded-kb-sm text-b3 font-medium transition-all',
62+
mode === value
63+
? 'bg-brand-50 text-brand-600 ring-1 ring-brand-200/50'
64+
: 'text-text-tertiary hover:text-text-secondary hover:bg-bg-tertiary/50',
65+
disabled && 'opacity-50 cursor-not-allowed',
66+
)}>
67+
<ModeIcon className="w-3.5 h-3.5" strokeWidth={1.5} /> {label}
68+
</button>
69+
))}
70+
</div>
71+
</div>
72+
</div>
73+
);
74+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* SegmentList — 精细路径提取结果列表
3+
* Extracted segment list for the fine capture path.
4+
*
5+
* @ai-context: 右侧内容区组件(fine 路径)。从 ClassroomPage 平移拆出,
6+
* 支持多选/批量插入(当前落地为复制剪贴板),新片段到达时自动滚动到底部。
7+
* @ai-context: Right-pane component for the fine path, split out of
8+
* ClassroomPage; auto-scrolls to bottom when new segments arrive.
9+
*/
10+
import { useRef, useEffect } from 'react';
11+
import { Eye, Plus, ListPlus, CheckCircle2 } from 'lucide-react';
12+
import { cn } from '@/lib/utils';
13+
import type { ExtractedSegment } from '@/lib/capture';
14+
15+
interface SegmentListProps {
16+
segments: ExtractedSegment[];
17+
selectedIds: Set<string>;
18+
onToggleSelect: (id: string) => void;
19+
onInsertSelected: () => void;
20+
onInsertAll: () => void;
21+
}
22+
23+
export function SegmentList({ segments, selectedIds, onToggleSelect, onInsertSelected, onInsertAll }: SegmentListProps) {
24+
const scrollRef = useRef<HTMLDivElement>(null);
25+
useEffect(() => {
26+
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
27+
}, [segments.length]);
28+
29+
return (
30+
<div className="flex-1 flex flex-col min-h-0">
31+
<div className="flex items-center justify-between px-4 py-3 border-b border-border/20">
32+
<span className="text-b2 font-medium text-text-secondary">提取结果 ({segments.length})</span>
33+
<div className="flex items-center gap-2">
34+
<button onClick={onInsertSelected} disabled={selectedIds.size === 0}
35+
className={cn('inline-flex items-center gap-1 px-2.5 py-1.5 rounded-kb-sm text-b3 font-medium transition-all',
36+
selectedIds.size > 0 ? 'bg-brand-50 text-brand-600 hover:bg-brand-100' : 'text-text-tertiary cursor-not-allowed')}>
37+
<Plus className="w-3.5 h-3.5" strokeWidth={2} /> 插入选中
38+
</button>
39+
<button onClick={onInsertAll} disabled={segments.length === 0}
40+
className={cn('inline-flex items-center gap-1 px-2.5 py-1.5 rounded-kb-sm text-b3 font-medium transition-all',
41+
segments.length > 0 ? 'bg-bg-secondary text-text-secondary hover:bg-bg-tertiary' : 'text-text-tertiary cursor-not-allowed')}>
42+
<ListPlus className="w-3.5 h-3.5" strokeWidth={2} /> 全部插入
43+
</button>
44+
</div>
45+
</div>
46+
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-3 space-y-2">
47+
{segments.length === 0 && (
48+
<div className="flex flex-col items-center justify-center py-16 text-text-tertiary">
49+
<Eye className="w-12 h-12 mb-3 opacity-20" strokeWidth={1} />
50+
<p className="text-b2">采集进行中,提取结果将在此显示</p>
51+
</div>
52+
)}
53+
{segments.map((seg) => {
54+
const isSelected = selectedIds.has(seg.id);
55+
const sourceLabel = seg.source === 'vision' ? '视觉' : seg.source === 'audio' ? '音频' : 'UI';
56+
const sourceColor = seg.source === 'vision'
57+
? 'bg-accent-500/10 text-accent-600'
58+
: seg.source === 'audio' ? 'bg-emerald-500/10 text-emerald-600' : 'bg-amber-500/10 text-amber-600';
59+
return (
60+
<div key={seg.id} onClick={() => onToggleSelect(seg.id)}
61+
className={cn('group p-3 rounded-kb-md cursor-pointer transition-all border border-transparent',
62+
isSelected ? 'bg-brand-50/50 border-brand-200/50' : 'hover:bg-bg-tertiary/50')}>
63+
<div className="flex items-center gap-2 mb-1.5">
64+
<span className={cn('px-1.5 py-0.5 rounded-kb-xs text-[10px] font-medium', sourceColor)}>{sourceLabel}</span>
65+
<span className="text-[10px] text-text-tertiary">{new Date(seg.timestamp).toLocaleTimeString()}</span>
66+
{isSelected && <CheckCircle2 className="ml-auto w-4 h-4 text-brand-500" strokeWidth={1.5} />}
67+
</div>
68+
<p className="text-b2 text-text-primary leading-relaxed">{seg.text}</p>
69+
</div>
70+
);
71+
})}
72+
</div>
73+
</div>
74+
);
75+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* SettingsCollapse — 采集设置折叠区
3+
* Collapsible capture settings (screenshot interval + recognition language).
4+
*
5+
* @ai-context: 左栏配置态组件,默认收起以保证左栏一屏容纳;收起时标题行
6+
* 显示当前配置摘要(如 "5s · 中文"),展开后提供滑块与语言切换。
7+
* @ai-context: Left-rail config-stage component, collapsed by default to keep
8+
* the rail within one screen; header shows a summary of current values.
9+
*/
10+
import { useState } from 'react';
11+
import { Settings2, ChevronDown, ChevronRight } from 'lucide-react';
12+
import { cn } from '@/lib/utils';
13+
import type { CaptureSidebarConfig } from '@/lib/capture';
14+
import { LANGUAGE_OPTIONS } from '../constants';
15+
16+
interface SettingsCollapseProps {
17+
config: CaptureSidebarConfig;
18+
onChange: (patch: Partial<CaptureSidebarConfig>) => void;
19+
}
20+
21+
export function SettingsCollapse({ config, onChange }: SettingsCollapseProps) {
22+
const [open, setOpen] = useState(false);
23+
const intervalSec = config.screenshotInterval / 1000;
24+
const languageLabel = LANGUAGE_OPTIONS.find((l) => l.value === config.language)?.label ?? '';
25+
const Chevron = open ? ChevronDown : ChevronRight;
26+
27+
return (
28+
<div className="pt-3 border-t border-border/20">
29+
{/* 折叠标题行:收起时展示配置摘要 */}
30+
<button onClick={() => setOpen((prev) => !prev)}
31+
className="w-full flex items-center gap-2 text-b3 text-text-tertiary hover:text-text-secondary transition-colors">
32+
<Settings2 className="w-4 h-4" strokeWidth={1.5} />
33+
<span className="font-medium">采集设置</span>
34+
{!open && (
35+
<span className="ml-auto text-[11px] text-text-tertiary/80">{intervalSec}s · {languageLabel}</span>
36+
)}
37+
<Chevron className={cn('w-3.5 h-3.5', open && 'ml-auto')} strokeWidth={1.5} />
38+
</button>
39+
40+
{open && (
41+
<div className="mt-3 space-y-3">
42+
<div>
43+
<label className="text-b3 text-text-tertiary mb-1 block">截图间隔: {intervalSec}s</label>
44+
<input type="range" min={1} max={30} value={intervalSec}
45+
onChange={(e) => onChange({ screenshotInterval: Number(e.target.value) * 1000 })}
46+
className="w-full accent-brand-600" />
47+
</div>
48+
<div>
49+
<label className="text-b3 text-text-tertiary mb-1 block">识别语言</label>
50+
<div className="flex gap-1">
51+
{LANGUAGE_OPTIONS.map(({ value, label }) => (
52+
<button key={value} onClick={() => onChange({ language: value })}
53+
className={cn(
54+
'flex-1 py-1.5 rounded-kb-sm text-b3 font-medium transition-all',
55+
config.language === value
56+
? 'bg-brand-50 text-brand-600 ring-1 ring-brand-200/50'
57+
: 'text-text-tertiary hover:bg-bg-tertiary hover:text-text-secondary',
58+
)}>
59+
{label}
60+
</button>
61+
))}
62+
</div>
63+
</div>
64+
</div>
65+
)}
66+
</div>
67+
);
68+
}

0 commit comments

Comments
 (0)