From 0e27603f15966e98e8e3c96a5b1ff69acf2a27e5 Mon Sep 17 00:00:00 2001 From: Aparencia Date: Fri, 31 Jul 2026 22:07:35 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix(classroom):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=B2=BE=E7=BB=86=E9=87=87=E9=9B=86=E4=B8=89=E7=97=87=E7=8A=B6?= =?UTF-8?q?=E2=80=94=E2=80=94=E8=A7=86=E8=A7=89=E6=8A=93=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=85=83=E6=95=B0=E6=8D=AE=E3=80=81ASR=E9=9D=99=E9=9F=B3?= =?UTF-8?q?=E5=B9=BB=E8=A7=89=E3=80=81=E6=88=AA=E6=96=ADJSON=E6=B3=84?= =?UTF-8?q?=E6=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内测反馈课堂助手视频与音频识别异常,定位为三个独立缺陷: - 视觉 prompt 未排除浏览器 UI,模型忠实 OCR 了 B 站标题/播放量/URL → auto/full prompt 增加忽略清单,无教学内容时 text 返回空串 - Path A 音频链无 VAD(Path B 有),静音段直送 ASR 触发幻觉输出 重复语气词与脏话 → 新增 asrFilters:RMS 0.008 静音门控 + 输出端 幻觉过滤(纯标点/重复灌水/短句脏话,宁放过不误杀) - 模型输出被 max_tokens 截断后,_parse_response 兜底把原始 JSON 整段当 text 返回并渲染进时间线 → 改为正则抢救 text 字段, 抢救不到返回空文本 新增 vitest 10 例 + pytest 6 例,用例取自内测截图真实幻觉文本 --- client/src/lib/ai/asrWorker.ts | 12 ++++ client/src/lib/capture/asrFilters.test.ts | 65 +++++++++++++++++++ client/src/lib/capture/asrFilters.ts | 55 ++++++++++++++++ ...oom-capture-asr-hallucination-json-leak.md | 49 ++++++++++++++ .../ai-gateway/chains/vision_extract_chain.py | 36 +++++++++- server/ai-gateway/tests/test_vision_chain.py | 54 +++++++++++++++ 6 files changed, 268 insertions(+), 3 deletions(-) create mode 100644 client/src/lib/capture/asrFilters.test.ts create mode 100644 client/src/lib/capture/asrFilters.ts create mode 100644 docs/knowledge/bugs/2026-07-classroom-capture-asr-hallucination-json-leak.md create mode 100644 server/ai-gateway/tests/test_vision_chain.py diff --git a/client/src/lib/ai/asrWorker.ts b/client/src/lib/ai/asrWorker.ts index 8b8f88f1..8c2885f9 100644 --- a/client/src/lib/ai/asrWorker.ts +++ b/client/src/lib/ai/asrWorker.ts @@ -13,6 +13,7 @@ import type { ExtractionResult, AudioChunkData, } from '@/lib/capture/captureTypes'; +import { isSilentChunk, isLikelyHallucination } from '@/lib/capture/asrFilters'; import { aiClient } from '@/lib/http/apiClient'; // ================================================================ @@ -54,6 +55,11 @@ export class ASRWorker implements PipelineWorker { async process(message: PipelineMessage): Promise { const audioData = message.data as AudioChunkData; + // 静音门控:低能量块不送 ASR(静音段会诱发模型幻觉文本,且白白消耗配额) + if (isSilentChunk(audioData.audioBuffer)) { + return null; + } + // ArrayBuffer → base64 const base64 = arrayBufferToBase64(audioData.audioBuffer); @@ -79,6 +85,12 @@ export class ASRWorker implements PipelineWorker { return null; } + // 幻觉过滤:重复字符灌水/纯标点/短句脏话是静音段 ASR 的典型幻觉形态, + // 不进入时间线(宁放过不误杀,规则见 asrFilters.ts) + if (isLikelyHallucination(response.text)) { + return null; + } + // 转换为 ExtractionResult return { text: response.text, diff --git a/client/src/lib/capture/asrFilters.test.ts b/client/src/lib/capture/asrFilters.test.ts new file mode 100644 index 00000000..b539ba3d --- /dev/null +++ b/client/src/lib/capture/asrFilters.test.ts @@ -0,0 +1,65 @@ +/** + * @ai-context: asrFilters 单元测试——静音门控(RMS)与 ASR 幻觉文本过滤。 + * 用例源自内测真实故障:静音段幻觉输出"嗯嗯嗯""。""是是是"及短句脏话。 + */ +import { describe, it, expect } from 'vitest'; +import { + computeRms, + isSilentChunk, + isLikelyHallucination, + SILENCE_RMS_THRESHOLD, +} from './asrFilters'; + +/** 构造固定幅值的 Float32 PCM 块 */ +function pcm(amplitude: number, length = 4800): ArrayBuffer { + return new Float32Array(length).fill(amplitude).buffer; +} + +describe('computeRms / isSilentChunk', () => { + it('全零样本 RMS 为 0,判定为静音', () => { + expect(computeRms(pcm(0))).toBe(0); + expect(isSilentChunk(pcm(0))).toBe(true); + }); + + it('低于阈值的底噪判定为静音', () => { + expect(isSilentChunk(pcm(SILENCE_RMS_THRESHOLD / 2))).toBe(true); + }); + + it('正常语音能量不判定为静音', () => { + expect(isSilentChunk(pcm(0.1))).toBe(false); + }); + + it('空 buffer 判定为静音', () => { + expect(isSilentChunk(new ArrayBuffer(0))).toBe(true); + }); +}); + +describe('isLikelyHallucination', () => { + it('过滤纯标点与空白(内测症状:"。")', () => { + expect(isLikelyHallucination('。')).toBe(true); + expect(isLikelyHallucination(' ')).toBe(true); + expect(isLikelyHallucination(',。,。')).toBe(true); + }); + + it('过滤重复字符灌水(内测症状:"嗯嗯嗯…""是是是…")', () => { + expect(isLikelyHallucination('嗯嗯嗯嗯嗯嗯嗯嗯嗯嗯嗯嗯')).toBe(true); + expect(isLikelyHallucination('是是是是是是是是是是')).toBe(true); + expect(isLikelyHallucination('嗯嗯,嗯嗯嗯。')).toBe(true); + }); + + it('过滤静音段短句脏话幻觉', () => { + expect(isLikelyHallucination('我操你妈的。')).toBe(true); + }); + + it('保留正常教学语音转写', () => { + expect(isLikelyHallucination('打鼾的根本原因是气道在睡眠中变窄')).toBe(false); + expect(isLikelyHallucination('软腭和咽部肌肉松弛导致气流受阻')).toBe(false); + expect(isLikelyHallucination('嗯,这个知识点我们再讲一遍')).toBe(false); + }); + + it('长句即使包含敏感词也不误杀(可能是课程内容引述)', () => { + expect( + isLikelyHallucination('有些人骂人时会说畜生,这在语言学上属于詈语范畴,本节课我们分析其构词'), + ).toBe(false); + }); +}); diff --git a/client/src/lib/capture/asrFilters.ts b/client/src/lib/capture/asrFilters.ts new file mode 100644 index 00000000..14ffd592 --- /dev/null +++ b/client/src/lib/capture/asrFilters.ts @@ -0,0 +1,55 @@ +/** + * ASR 前置静音门控与幻觉过滤 + * + * @ai-context: Path A(精细采集)音频链无 VAD,固定切片直送 ASR 会让 + * 静音/背景噪声段触发 ASR 幻觉(重复语气词"嗯嗯嗯"、纯标点、短句脏话)。 + * 本模块提供两道防线:送 ASR 前的 RMS 静音门控 + ASR 返回后的幻觉文本过滤。 + * RMS 阈值与 vadMarker 的 loopback 预设阈值保持一致(0.008),修改需同步。 + */ + +/** RMS 静音阈值(Float32 PCM,与 vadMarker loopback 预设一致) */ +export const SILENCE_RMS_THRESHOLD = 0.008; + +/** 计算 Float32 PCM 块的 RMS 能量 */ +export function computeRms(buffer: ArrayBuffer): number { + const samples = new Float32Array(buffer); + if (samples.length === 0) return 0; + let sum = 0; + for (let i = 0; i < samples.length; i++) { + sum += samples[i] * samples[i]; + } + return Math.sqrt(sum / samples.length); +} + +/** 判断音频块是否为静音(低于阈值不值得送 ASR,直接跳过) */ +export function isSilentChunk( + buffer: ArrayBuffer, + threshold: number = SILENCE_RMS_THRESHOLD, +): boolean { + return computeRms(buffer) < threshold; +} + +/** 纯标点/空白检测 */ +const PUNCT_ONLY_RE = /^[\s。,、..,!??!…~~·\-—]*$/; + +/** 短句脏话模式:静音/噪声段 ASR 幻觉的高频形态,正常教学语音几乎不会独立出现 */ +const PROFANITY_RE = /操你|草泥马|傻逼|妈的|畜生/; + +/** + * 判断 ASR 输出是否为幻觉文本(保守规则,宁放过不误杀): + * 1. 纯标点/空白(如"。") + * 2. 重复字符灌水:去标点后 unique 字符 ≤ 2 且长度 ≥ 4("嗯嗯嗯嗯""是是是是") + * 3. 短句脏话(≤ 20 字符且命中模式) + */ +export function isLikelyHallucination(text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) return true; + if (PUNCT_ONLY_RE.test(trimmed)) return true; + + const compact = trimmed.replace(/[\s。,、..,!??!…~~]/g, ''); + if (compact.length >= 4 && new Set(compact).size <= 2) return true; + + if (trimmed.length <= 20 && PROFANITY_RE.test(trimmed)) return true; + + return false; +} diff --git a/docs/knowledge/bugs/2026-07-classroom-capture-asr-hallucination-json-leak.md b/docs/knowledge/bugs/2026-07-classroom-capture-asr-hallucination-json-leak.md new file mode 100644 index 00000000..2fc2faa2 --- /dev/null +++ b/docs/knowledge/bugs/2026-07-classroom-capture-asr-hallucination-json-leak.md @@ -0,0 +1,49 @@ +# 知识卡片 · 踩坑记录 + +## 基本信息 + +| 字段 | 内容 | +|------|------| +| 标题 | 课堂助手精细采集三症状:视觉抓取页面元数据、ASR 静音幻觉、截断 JSON 泄漏 UI | +| 日期 | 2026-07-31 | +| 类型 | 踩坑记录 | +| 标签 | #课堂助手 #多模态 #ASR幻觉 #VAD #prompt工程 #JSON解析 | + +--- + +## 症状 + +内测用户在课堂助手(回声定位)使用**精细采集**(Path A)看 B 站网课时,时间线出现三类异常: + +- **A 视觉轨**:反复输出同一段浏览器页面元数据(视频标题/播放量/日期/URL/导航栏文字),而非教学内容 +- **B 音频轨**:大量"嗯嗯嗯嗯""。""是是是"及短句脏话("我操你妈的。")——用户观感极差("我要哭了,还会骂人") +- **C**:一条视觉条目直接显示原始 JSON 片段(`"keyPoints": [...], "codeBlocks": []...}` ``` ) + +## 根因(三个独立缺陷,同场景集中暴露) + +| 症状 | 根因 | 位置 | +|---|---|---| +| A | vision prompt 只说"提取所有可见的学习内容",未指示忽略浏览器 chrome/页面元数据;整窗口截图里标题栏/侧栏被模型忠实 OCR | `server/ai-gateway/chains/vision_extract_chain.py` VISION_MODE_PROMPTS | +| B | Path A 音频链**无 VAD**:固定切片全部直送 ASR,静音/背景音乐段触发 ASR 模型典型幻觉(重复语气词、脏话短句是 Qwen/GLM-ASR 在静音段的高频幻觉形态);且输出端无幻觉过滤 | `client/src/lib/ai/asrWorker.ts`(对比:Path B smart 模式有 vadMarker,Path A 没有) | +| C | 模型输出被 max_tokens(2048)截断 → 残缺 JSON 三段解析全失败 → 兜底分支把**原始 content 整段**当 text 返回 → 前端原样渲染 | `vision_extract_chain.py` `_parse_response` 兜底分支 | + +## 解决方案 + +1. **A**:auto/full 两个 prompt 增加硬性指令——忽略浏览器/网站 UI 元数据,只提取教学画面内容,无教学内容时 text 返回空串 +2. **B**:新增 `client/src/lib/capture/asrFilters.ts` 双防线——送 ASR 前 RMS 静音门控(阈值 0.008 与 vadMarker loopback 预设一致)+ ASR 返回后幻觉过滤(纯标点/重复字符灌水/短句脏话,宁放过不误杀) +3. **C**:`_parse_response` 兜底分支区分形态——形似 JSON(含 `"text":`、fence、`{` 开头)则正则抢救 `text` 字段值(还原转义),抢救不到返回空文本;纯文本形态才原样透传 + +验证:新增 vitest 10 例 + pytest 6 例全过(用例直接取自内测截图的真实幻觉文本);client 455 tests / gateway 164 tests 全绿。 + +## 教训 + +- **管线成对能力要对齐**:Path B 有 VAD、Path A 没有——同一功能的并行实现路径,防护能力不一致时薄弱路径必然先炸。新增采集路径时应有"能力清单"对照(VAD/去重/降级/过滤)。 +- **ASR 静音幻觉是已知模型行为不是玄学**:静音/音乐段送 ASR,输出重复语气词甚至脏话是大模型 ASR 的公开特性,任何 ASR 集成都必须有静音门控 + 输出过滤两道防线。 +- **LLM 结构化输出的兜底分支同样要"结构化"**:`解析失败→返回原文`看似安全,实际把内部协议(JSON)泄漏给了用户;兜底必须考虑"截断的半个 JSON"这一最常见失败形态。 +- **prompt 的"提取所有内容"在真实屏幕上是错的**:用户屏幕永远比教学内容多(浏览器 UI、弹幕、推荐位),提取类 prompt 必须显式声明忽略清单。 + +## 参考 + +- 修复文件:`server/ai-gateway/chains/vision_extract_chain.py`、`client/src/lib/capture/asrFilters.ts`(新增)、`client/src/lib/ai/asrWorker.ts` +- 回归测试:`client/src/lib/capture/asrFilters.test.ts`、`server/ai-gateway/tests/test_vision_chain.py` +- 后续跟进(未在本次范围):smart 路径的流式 ASR 输出可复用 `isLikelyHallucination`;`fine` 路径可考虑接入窗口区域裁剪只截视频区 diff --git a/server/ai-gateway/chains/vision_extract_chain.py b/server/ai-gateway/chains/vision_extract_chain.py index 4052092c..034e828f 100644 --- a/server/ai-gateway/chains/vision_extract_chain.py +++ b/server/ai-gateway/chains/vision_extract_chain.py @@ -36,6 +36,8 @@ ' "concepts": ["概念1", "概念2"]\n' "}\n\n" "注意:\n" + "- 忽略一切软件界面元素:浏览器标签页/地址栏/导航栏、视频网站的标题/播放量/发布日期/作者信息/推荐列表/弹幕评论等页面元数据\n" + "- 只提取教学画面本身的内容(板书/PPT/字幕/讲解要点);若画面中没有教学内容,text 返回空字符串\n" "- 如果没有看到公式或图表,对应数组返回空 []\n" "- 数学公式使用 LaTeX 格式\n" "- 代码块保留原始格式\n" @@ -114,7 +116,9 @@ ' "concepts": ["识别到的所有概念和术语"]\n' "}\n\n" "注意:\n" - "- 提取所有文字内容,包括标题、正文、注释\n" + "- 忽略一切软件界面元素:浏览器标签页/地址栏/导航栏、视频网站的标题/播放量/发布日期/作者信息/推荐列表/弹幕评论等页面元数据\n" + "- 只提取教学画面本身的内容;若画面中没有教学内容,text 返回空字符串\n" + "- 提取所有教学文字内容,包括标题、正文、注释\n" "- 识别所有数学公式并使用 LaTeX 格式\n" "- 详细描述所有图表和可视化内容\n" "- 提取所有代码块并标注语言\n" @@ -189,8 +193,34 @@ def _parse_response(self, content: str) -> dict[str, Any]: except json.JSONDecodeError: pass - # 解析失败,返回默认结构 - logger.warning("视觉提取结果 JSON 解析失败,返回原始文本") + # 解析失败。区分两类情况: + # - 输出形似 JSON 但残缺(常见于 max_tokens 截断)→ 抢救 text 字段, + # 绝不把原始 JSON 片段泄漏到 UI 时间线 + # - 输出本就是纯文本(未按要求返回 JSON)→ 原样作为 text + looks_like_json = bool(re.search(r'"text"\s*:|^\s*```|^\s*\{', content)) + if looks_like_json: + salvaged = "" + text_match = re.search(r'"text"\s*:\s*"((?:[^"\\]|\\.)*)"', content, re.DOTALL) + if text_match: + try: + # 经 json.loads 还原转义字符(\n、\" 等) + salvaged = json.loads(f'"{text_match.group(1)}"') + except json.JSONDecodeError: + salvaged = text_match.group(1) + logger.warning( + "视觉提取 JSON 残缺(疑似截断),已抢救 text 字段(%d 字符)", len(salvaged), + ) + return { + "text": salvaged, + "formulas": [], + "diagrams": [], + "keyPoints": [], + "codeBlocks": [], + "concepts": [], + } + + # 非 JSON 形态:视为模型直接输出了纯文本内容 + logger.warning("视觉提取结果非 JSON 格式,返回原始文本") return { "text": content, "formulas": [], diff --git a/server/ai-gateway/tests/test_vision_chain.py b/server/ai-gateway/tests/test_vision_chain.py new file mode 100644 index 00000000..f32d4384 --- /dev/null +++ b/server/ai-gateway/tests/test_vision_chain.py @@ -0,0 +1,54 @@ +""" +VisionExtractChain._parse_response 单元测试 + +@ai-context: 用例源自内测真实故障——模型输出被 max_tokens 截断产生残缺 JSON, +旧逻辑将原始 JSON 片段直接返回为 text 并泄漏到 UI 时间线。 +""" + +import pytest + +from chains.vision_extract_chain import VisionExtractChain + + +@pytest.fixture +def chain() -> VisionExtractChain: + # _parse_response 不触碰 provider,传 None 即可 + return VisionExtractChain(provider=None) # type: ignore[arg-type] + + +class TestParseResponse: + def test_valid_json(self, chain): + content = '{"text": "牛顿第二定律", "formulas": ["$F=ma$"], "diagrams": [], "keyPoints": [], "codeBlocks": [], "concepts": []}' + result = chain._parse_response(content) + assert result["text"] == "牛顿第二定律" + assert result["formulas"] == ["$F=ma$"] + + def test_fenced_json(self, chain): + content = '```json\n{"text": "板书内容", "formulas": [], "diagrams": [], "keyPoints": [], "codeBlocks": [], "concepts": []}\n```' + result = chain._parse_response(content) + assert result["text"] == "板书内容" + + def test_truncated_json_salvages_text_field(self, chain): + """截断残缺 JSON:抢救 text 字段值,不泄漏 JSON 语法到 UI""" + content = '{"text": "打鼾的危害讲解", "formulas": [], "keyPoints": ["打鼾虽正常' + result = chain._parse_response(content) + assert result["text"] == "打鼾的危害讲解" + assert '"keyPoints"' not in result["text"] + + def test_truncated_json_without_text_field_returns_empty(self, chain): + """残缺 JSON 连 text 字段都不完整时返回空文本,而非泄漏原文""" + content = '```json\n{"keyPoints": ["要点1", "要' + result = chain._parse_response(content) + assert result["text"] == "" + + def test_plain_text_passthrough(self, chain): + """非 JSON 形态的纯文本输出原样保留""" + content = "这是一段普通的板书文字描述" + result = chain._parse_response(content) + assert result["text"] == content + + def test_salvaged_text_unescapes(self, chain): + """抢救的 text 字段应还原转义字符""" + content = '{"text": "第一行\\n第二行", "formulas": [' + result = chain._parse_response(content) + assert result["text"] == "第一行\n第二行" From 213969c68be22dffce8192ac0d5b805eef8d0532 Mon Sep 17 00:00:00 2001 From: Aparencia Date: Fri, 31 Jul 2026 22:10:57 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(auth):=20=E4=BF=AE=E5=A4=8D=E9=82=AE?= =?UTF-8?q?=E7=AE=B1=E9=AA=8C=E8=AF=81=E8=B7=B3=E8=BD=AC=E6=AD=BB=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E3=80=81=E9=87=8D=E7=BD=AE=E5=AF=86=E7=A0=81=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E5=A4=B1=E6=95=88=E4=B8=8E=E7=99=BB=E5=BD=95=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内测用户点击 QQ 邮箱验证链接后落到 localhost:3000 死页面,误以为 验证失败(实际已验证成功),进而反馈'不用验证也能登录'。 - signUp 补 emailRedirectTo 指向官网新增 /verified 落地页,该页兼顾 成功与链接过期(hash 含 error)两种结果,用 useSyncExternalStore 读取 hash 以规避静态导出的 hydration 不匹配 - ResetPassword 改为应用内验证码(OTP)流程:桌面端 file:// 协议无法 作为 redirectTo,链接回跳结构性不可用;顺带移除原 token_hash 分支 从未调用 verifyOtp 的死代码 - 修复登录循环:AuthGuard 与'跳过登录'的模式降级缺口 + session-expired 事件风暴 需配合 Supabase Dashboard:Site URL、Redirect URLs 白名单、Recovery 邮件模板加 Token 占位符(均已配置) --- client/src/hooks/useSessionExpiry.test.ts | 59 +++++++++ client/src/hooks/useSessionExpiry.ts | 14 ++ client/src/lib/auth/AuthContext.tsx | 9 +- client/src/pages/LoginPage.test.tsx | 39 ++++++ client/src/pages/LoginPage.tsx | 21 ++- client/src/pages/ResetPassword.tsx | 120 +++++++++--------- .../2026-07-login-loop-authguard-mode-gap.md | 53 ++++++++ website/app/verified/page.tsx | 99 +++++++++++++++ 8 files changed, 349 insertions(+), 65 deletions(-) create mode 100644 client/src/hooks/useSessionExpiry.test.ts create mode 100644 client/src/pages/LoginPage.test.tsx create mode 100644 docs/knowledge/bugs/2026-07-login-loop-authguard-mode-gap.md create mode 100644 website/app/verified/page.tsx diff --git a/client/src/hooks/useSessionExpiry.test.ts b/client/src/hooks/useSessionExpiry.test.ts new file mode 100644 index 00000000..561d35fb --- /dev/null +++ b/client/src/hooks/useSessionExpiry.test.ts @@ -0,0 +1,59 @@ +/** + * @ai-context: useSessionExpiry 回归测试——并发 401 与 SIGNED_OUT 会在短时间 + * 内派发多个 kb:session-expired 事件,冷却窗口内必须只弹一次 Toast。 + * @ai-context: Regression test — bursts of kb:session-expired events must be + * deduplicated within the cooldown window (single toast, single redirect). + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { useSessionExpiry } from './useSessionExpiry'; + +const toastSpy = vi.fn(); + +vi.mock('@/components/ui', () => ({ + useToast: () => ({ toast: toastSpy }), +})); + +function wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(MemoryRouter, null, children); +} + +describe('useSessionExpiry - 事件风暴去重', () => { + beforeEach(() => { + vi.useFakeTimers(); + toastSpy.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('冷却窗口内的多个 session-expired 事件只弹一次 Toast', () => { + renderHook(() => useSessionExpiry(), { wrapper }); + + act(() => { + // 模拟多个并发 401 请求 + SIGNED_OUT 各自派发事件 + window.dispatchEvent(new CustomEvent('kb:session-expired')); + window.dispatchEvent(new CustomEvent('kb:session-expired')); + window.dispatchEvent(new CustomEvent('kb:session-expired')); + }); + + expect(toastSpy).toHaveBeenCalledTimes(1); + }); + + it('冷却窗口过后允许再次提示', () => { + renderHook(() => useSessionExpiry(), { wrapper }); + + act(() => { + window.dispatchEvent(new CustomEvent('kb:session-expired')); + }); + act(() => { + vi.advanceTimersByTime(10000); // 越过冷却窗口 + window.dispatchEvent(new CustomEvent('kb:session-expired')); + }); + + expect(toastSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/client/src/hooks/useSessionExpiry.ts b/client/src/hooks/useSessionExpiry.ts index c6b79a00..95bb1ac6 100644 --- a/client/src/hooks/useSessionExpiry.ts +++ b/client/src/hooks/useSessionExpiry.ts @@ -7,6 +7,13 @@ import { useToast } from '@/components/ui'; const SESSION_EXPIRED_EVENT = 'kb:session-expired'; +/** + * 事件去重冷却窗口:并发 401 请求与 Supabase SIGNED_OUT 会在短时间内 + * 各自派发 session-expired,窗口内只处理第一个,避免重复弹 Toast + * 让用户感知为"持续要求登录"(内测反馈 bug) + */ +const SESSION_EXPIRED_COOLDOWN_MS = 5000; + /** * 监听 session 过期事件,弹出 Toast 提示并提供重新登录入口 * 需在 AppLayout 或其他全局组件中调用 @@ -23,9 +30,16 @@ export function useSessionExpiry() { // Bug #15: 防止重复设置 setTimeout const timeoutRef = useRef | null>(null); + // 冷却窗口去重:记录上次处理事件的时间戳 + const lastHandledAtRef = useRef(0); useEffect(() => { function handleSessionExpired() { + // 冷却窗口内的重复事件(并发 401 / SIGNED_OUT 风暴)直接忽略 + const now = Date.now(); + if (now - lastHandledAtRef.current < SESSION_EXPIRED_COOLDOWN_MS) return; + lastHandledAtRef.current = now; + toastRef.current({ type: 'warning', message: '登录已过期,请重新登录', diff --git a/client/src/lib/auth/AuthContext.tsx b/client/src/lib/auth/AuthContext.tsx index bf16e462..9442568a 100644 --- a/client/src/lib/auth/AuthContext.tsx +++ b/client/src/lib/auth/AuthContext.tsx @@ -100,7 +100,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (isPlaceholder) { return { error: { message: '云服务尚未配置,请先在 .env 中设置 VITE_SUPABASE_URL 和 VITE_SUPABASE_ANON_KEY' } as AuthError }; } - const { error } = await supabase.auth.signUp({ email, password }); + // emailRedirectTo:验证邮件链接在系统浏览器打开,桌面端无法回到应用, + // 故落地到官网验证成功页(需同步加入 Supabase Redirect URLs 白名单, + // 否则回退到 Site URL) + const { error } = await supabase.auth.signUp({ + email, + password, + options: { emailRedirectTo: 'https://entropydecrease.com/verified' }, + }); return { error }; }, []); diff --git a/client/src/pages/LoginPage.test.tsx b/client/src/pages/LoginPage.test.tsx new file mode 100644 index 00000000..9314414a --- /dev/null +++ b/client/src/pages/LoginPage.test.tsx @@ -0,0 +1,39 @@ +/** + * @ai-context: LoginPage 回归测试——"跳过登录"必须降级到 local 模式, + * 否则 AuthGuard 会在 hybrid/full 模式下把未登录用户弹回登录页(死循环)。 + * @ai-context: Regression test — "skip login" must downgrade to local mode, + * otherwise AuthGuard keeps redirecting unauthenticated users back to /login. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import LoginPage from './LoginPage'; +import { modeManager } from '@/lib/mode/ModeManager'; + +vi.mock('@/lib/auth/AuthContext', () => ({ + useAuth: () => ({ + signIn: vi.fn().mockResolvedValue({ error: null }), + }), +})); + +describe('LoginPage - 跳过登录', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('点击"跳过登录"应将模式降级为 local,避免 AuthGuard 循环重定向', () => { + // 模拟内测用户此前开启过云同步(hybrid 模式已持久化) + modeManager.setMode('hybrid'); + + render( + + + , + ); + + fireEvent.click(screen.getByText(/跳过登录/)); + + // 核心断言:模式必须降级,否则回到 / 会被 AuthGuard 再次踢回 /login + expect(modeManager.getMode()).toBe('local'); + }); +}); diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index b8b9ad63..6b17fc5c 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -7,6 +7,7 @@ import { Mail, Lock, AlertCircle } from 'lucide-react'; import { Button, Input, Card } from '@/components/ui'; import { cn } from '@/lib/utils'; import { useAuth } from '@/lib/auth/AuthContext'; +import { modeManager } from '@/lib/mode/ModeManager'; export default function LoginPage() { const navigate = useNavigate(); @@ -17,6 +18,16 @@ export default function LoginPage() { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + /** + * 跳过登录:必须先把模式降级为 local,再回首页。 + * 否则 hybrid/full 模式下 AuthGuard 会立即把未登录用户踢回登录页, + * 形成"持续要求登录"的死循环(内测反馈 bug)。 + */ + const handleSkipLogin = () => { + modeManager.setMode('local'); + navigate('/', { replace: true }); + }; + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); setError(null); @@ -136,11 +147,15 @@ export default function LoginPage() {

- {/* Skip link */} + {/* Skip link:降级到本地模式后再离开,避免 AuthGuard 循环重定向 */}

- +

diff --git a/client/src/pages/ResetPassword.tsx b/client/src/pages/ResetPassword.tsx index 93a1fe5f..8cb4f37d 100644 --- a/client/src/pages/ResetPassword.tsx +++ b/client/src/pages/ResetPassword.tsx @@ -1,9 +1,13 @@ /** - * @ai-context: 页面组件:ResetPassword。 + * @ai-context: 页面组件:ResetPassword。重置密码采用邮箱验证码(OTP)流程: + * 请求重置 → 邮件收 6 位验证码 → 应用内输入验证码+新密码。 + * Why: 桌面端邮件链接在系统浏览器打开、恢复会话无法回到应用(file:// 协议 + * 也无法作为 redirectTo),链接回跳模式结构性不可用,故全程留在应用内。 + * 依赖 Supabase Recovery 邮件模板包含 {{ .Token }} 验证码。 */ import { useState, useEffect, useRef, type FormEvent } from 'react'; -import { Link, useSearchParams, useNavigate } from 'react-router-dom'; -import { Mail, Lock, AlertCircle, CheckCircle2, ArrowLeft } from 'lucide-react'; +import { Link, useNavigate } from 'react-router-dom'; +import { Mail, Lock, KeyRound, AlertCircle, CheckCircle2, ArrowLeft } from 'lucide-react'; import { Button, Input, Card } from '@/components/ui'; import { cn } from '@/lib/utils'; import { supabase } from '@/lib/auth/supabaseClient'; @@ -11,15 +15,11 @@ import { supabase } from '@/lib/auth/supabaseClient'; type ViewMode = 'request' | 'reset'; export default function ResetPassword() { - const [searchParams] = useSearchParams(); const navigate = useNavigate(); - const tokenHash = searchParams.get('token_hash'); - const type = searchParams.get('type'); - const isResetMode = tokenHash && type === 'recovery'; - - const [viewMode, setViewMode] = useState(isResetMode ? 'reset' : 'request'); + const [viewMode, setViewMode] = useState('request'); const [email, setEmail] = useState(''); + const [otpCode, setOtpCode] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [error, setError] = useState(null); @@ -27,10 +27,6 @@ export default function ResetPassword() { const [loading, setLoading] = useState(false); const navigateTimerRef = useRef | null>(null); - useEffect(() => { - if (isResetMode) setViewMode('reset'); - }, [isResetMode]); - useEffect(() => { return () => { if (navigateTimerRef.current) clearTimeout(navigateTimerRef.current); @@ -48,12 +44,10 @@ export default function ResetPassword() { setLoading(true); try { - const redirectTo = `${window.location.origin}${window.location.pathname}#/reset-password`; - const { error: resetError } = await supabase.auth.resetPasswordForEmail(email.trim(), { - redirectTo, - }); + // 不传 redirectTo:验证走应用内 OTP 验证码,不依赖邮件链接跳转 + const { error: resetError } = await supabase.auth.resetPasswordForEmail(email.trim()); if (resetError) throw resetError; - setSuccess(true); + setViewMode('reset'); } catch (err: unknown) { const msg = err instanceof Error ? err.message : '发送失败,请稍后重试'; setError(msg); @@ -66,6 +60,10 @@ export default function ResetPassword() { e.preventDefault(); setError(null); + if (!otpCode.trim() || otpCode.trim().length < 6) { + setError('请输入邮件中的 6 位验证码'); + return; + } if (!password || password.length < 8) { setError('密码长度至少 8 位'); return; @@ -77,6 +75,13 @@ export default function ResetPassword() { setLoading(true); try { + // 先用验证码换取恢复会话,再更新密码 + const { error: otpError } = await supabase.auth.verifyOtp({ + email: email.trim(), + token: otpCode.trim(), + type: 'recovery', + }); + if (otpError) throw otpError; const { error: updateError } = await supabase.auth.updateUser({ password }); if (updateError) throw updateError; setSuccess(true); @@ -107,7 +112,9 @@ export default function ResetPassword() { {viewMode === 'request' ? '重置密码' : '设置新密码'}

- {viewMode === 'request' ? '输入邮箱以接收重置链接' : '输入你的新密码'} + {viewMode === 'request' + ? '输入邮箱以接收验证码' + : `验证码已发送至 ${email}`}

@@ -117,41 +124,25 @@ export default function ResetPassword() { {success ? (
-

- {viewMode === 'request' - ? '重置邮件已发送' - : '密码重置成功'} -

-

- {viewMode === 'request' - ? `如果 ${email} 已注册,你将收到一封重置密码的邮件` - : '即将跳转到登录页...'} -

- {viewMode === 'request' && ( - - 返回登录 - - )} +

密码重置成功

+

即将跳转到登录页...

) : ( <> - {viewMode === 'request' ? ( -
- {error && ( -
- -

{error}

-
+ {error && ( +
+ +

{error}

+
+ )} + {viewMode === 'request' ? ( +
) : (
- {error && ( -
- -

{error}

-
- )} + } + value={otpCode} + onChange={(e) => setOtpCode(e.target.value)} + /> 重置密码 + +
)} diff --git a/docs/knowledge/bugs/2026-07-login-loop-authguard-mode-gap.md b/docs/knowledge/bugs/2026-07-login-loop-authguard-mode-gap.md new file mode 100644 index 00000000..d19e5f42 --- /dev/null +++ b/docs/knowledge/bugs/2026-07-login-loop-authguard-mode-gap.md @@ -0,0 +1,53 @@ +# 知识卡片 · 踩坑记录 + +## 基本信息 + +| 字段 | 内容 | +|------|------| +| 标题 | 登录失败后持续要求登录:AuthGuard 与"跳过登录"的模式降级缺口 + session-expired 事件风暴 | +| 日期 | 2026-07-31 | +| 类型 | 踩坑记录 | +| 标签 | #认证 #AuthGuard #路由守卫 #模式管理 #事件去重 #死循环 | + +--- + +## 症状 + +内测反馈:**如果无法登录,会出现持续要求登录的情况**——用户被反复带回登录页,无法进入应用其他页面;session 过期时短时间弹出多个"登录已过期"提示。 + +## 环境 + +| 项目 | 版本/信息 | +|------|----------| +| 认证 | Supabase Auth(`AuthContext` + `AuthGuard` 软守卫) | +| 模式 | `ModeManager`(local/hybrid/full,持久化 `ed_app_mode`) | +| 相关文件 | `client/src/pages/LoginPage.tsx`、`client/src/lib/auth/AuthGuard.tsx`、`client/src/hooks/useSessionExpiry.ts`、`client/src/lib/http/apiClient.ts` | + +## 排查过程(按 debug-sop) + +1. **分类**:逻辑错误(必现,非并发/环境) +2. **隔离**:梳理 `kb:session-expired` 的全部派发点(apiClient 401 刷新失败 × N 个并发请求 + AuthContext SIGNED_OUT)与全部消费点(useSessionExpiry) +3. **关键发现**:`AuthGuard` 包裹**全部主路由(含设置页)**,拦截条件为"云凭证有效 + 模式为 hybrid/full + 未登录";而登录页的"跳过登录,继续使用本地功能"只是 ``,**不降级模式** +4. **循环推演**:曾开启云同步的用户(模式已持久化为 hybrid/full)一旦登不上 → 点"跳过登录"回首页 → AuthGuard 立即踢回 `/login` → 死循环;且切回本地模式的唯一入口(设置页)也被 AuthGuard 拦截,用户无法自救 + +## 根因 + +1. **模式降级缺口**(主因):"跳过登录"承诺了本地功能,却没有执行 `modeManager.setMode('local')`;模式持久化后不存在任何"未登录可达"的降级出口,与 AuthGuard 的拦截条件形成闭环 +2. **事件无去重**(放大因素):session 过期瞬间,多个并发 401 请求与 SIGNED_OUT 各自派发 `kb:session-expired`,`useSessionExpiry` 只防重复跳转、不防重复 Toast,用户感知为"持续要求登录" + +## 修复方案 + +1. `LoginPage.handleSkipLogin`:跳过登录改为先 `modeManager.setMode('local')` 再 `navigate('/', { replace: true })`,打断闭环 +2. `useSessionExpiry`:增加 5s 冷却窗口(`lastHandledAtRef`),窗口内的重复事件直接忽略 +3. 回归测试:`LoginPage.test.tsx`(跳过登录必须降级模式)、`useSessionExpiry.test.ts`(事件风暴只弹一次 Toast / 冷却后允许再提示) + +## 教训 + +- **守卫类逻辑必须验证"逃生通道"**:任何强制重定向(AuthGuard)都要保证存在一条未满足条件用户可达的退出路径,否则持久化状态会把用户锁死 +- **提供"跳过/降级"入口时,入口必须真正改变判定条件**,而不是仅做一次导航——否则守卫下一帧就会再次触发 +- **全局事件(如 session-expired)的消费端必须做时间窗去重**:派发端天然多源(N 个并发请求 + auth 状态机),不能假设只派发一次 +- 排查此类"循环弹窗/循环跳转"问题时,先画出**状态判定条件 × 状态修改入口**矩阵,检查是否存在"条件成立后所有修改入口都不可达"的死锁组合 + +## 相关提交 + +- fix: 登录失败/跳过登录死循环 + session-expired 事件风暴去重(待提交) diff --git a/website/app/verified/page.tsx b/website/app/verified/page.tsx new file mode 100644 index 00000000..5f53428d --- /dev/null +++ b/website/app/verified/page.tsx @@ -0,0 +1,99 @@ +// @ai-context +// 邮箱验证结果页:Supabase 注册确认邮件的跳转落地页。Email verification landing page. +// Why: 桌面客户端无法接收浏览器跳转,验证完成后需一个网页告知用户"回到客户端登录"; +// Supabase 验证失败时会在 URL hash 携带 error 参数,此页同时兜底展示过期/无效提示。 +"use client"; + +import { useSyncExternalStore } from "react"; +import { motion } from "framer-motion"; +import { GlowOrb } from "@/components/GlowOrb"; + +/** 订阅 hash 变化(验证结果仅体现在 hash 中) */ +function subscribeHash(onChange: () => void): () => void { + window.addEventListener("hashchange", onChange); + return () => window.removeEventListener("hashchange", onChange); +} + +/** 客户端快照:验证失败时 Supabase 跳转形如 /verified#error=access_denied&error_code=otp_expired */ +function getHashHasError(): boolean { + return window.location.hash.includes("error"); +} + +/** 预渲染快照:静态导出时无 window,先按成功态渲染,hydration 后自动校正 */ +function getServerSnapshot(): boolean { + return false; +} + +/** + * 邮箱验证结果页 + * 成功:引导用户回到熵减客户端登录; + * 失败(hash 含 error,如 otp_expired):引导重新注册以重发验证邮件 + * + * @ai-context: hash 属于外部可变数据源,用 useSyncExternalStore 而非 + * useEffect+setState——后者会触发 react-hooks/set-state-in-effect 且在 + * 静态导出下产生 hydration 不匹配。 + */ +export default function VerifiedPage() { + const isExpired = useSyncExternalStore(subscribeHash, getHashHasError, getServerSnapshot); + const isSuccess = !isExpired; + + return ( +
+ + +
+ + + Email Verification · 邮箱验证 + + +
+ +

+ {isSuccess ? "邮箱验证成功" : "验证链接已失效"} +

+

+ {isSuccess ? ( + <> + 你的账号已激活。 +
+ 请回到熵减客户端 + ,使用注册邮箱登录即可开始使用。 + + ) : ( + <> + 链接可能已过期或已被使用。 +
+ 若尚未完成验证,请回到熵减客户端重新注册, +
+ 系统会重新发送验证邮件。 + + )} +

+

此页面可以安全关闭

+
+
+
+
+ ); +} From b0f90e148d13991fabd9feb7326f6b6064be5075 Mon Sep 17 00:00:00 2001 From: Aparencia Date: Fri, 31 Jul 2026 22:11:25 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix(window):=20=E6=9C=80=E5=B0=8F=E5=8C=96?= =?UTF-8?q?=E6=97=B6=E4=BB=BB=E5=8A=A1=E6=A0=8F=E5=8F=B3=E9=94=AE=E5=85=B3?= =?UTF-8?q?=E9=97=AD=E6=97=A0=E5=93=8D=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit close 事件走确认对话框分支前未确保窗口可见,任务栏右键「关闭窗口」 时对话框弹在最小化窗口内、用户不可见,表现为命令无效。发送 window:closing 前先 restore/show/focus。 --- client/electron/windowManager.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/electron/windowManager.ts b/client/electron/windowManager.ts index 150333e1..e91b7f08 100644 --- a/client/electron/windowManager.ts +++ b/client/electron/windowManager.ts @@ -163,7 +163,12 @@ export function createMainWindow( } else if (savedChoice === 'minimize') { win.hide(); } else { - // 无记忆选择,通知前端弹出确认对话框 + // 无记忆选择,通知前端弹出确认对话框。 + // 窗口可能处于最小化/隐藏状态(如任务栏右键「关闭窗口」), + // 必须先恢复并聚焦,否则应用内对话框用户不可见,表现为“无法关闭” + if (win.isMinimized()) win.restore(); + if (!win.isVisible()) win.show(); + win.focus(); win.webContents.send('window:closing'); } } else if (!syncBeforeQuitCompleted) { From ed9e5d7779226b62a456d3497df9c9388bbfc3c5 Mon Sep 17 00:00:00 2001 From: Aparencia Date: Fri, 31 Jul 2026 22:11:30 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix(pomodoro):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=91=A8=E6=9C=9F=E8=AE=A1=E6=95=B0=E6=97=A0=E9=87=8D=E7=BD=AE?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E4=B8=8E=E5=89=AF=E4=BD=9C=E7=94=A8=E5=8F=8C?= =?UTF-8?q?=E9=87=8D=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 无重置路径的周期计数 + 跨模式状态残留 + store/hook 副作用双重执行 导致番茄钟计数统计异常。 --- .../pomodoro/hooks/usePomodoroEffects.ts | 135 ++---------------- .../pomodoro/store/usePomodoroStore.test.ts | 49 +++++++ .../pomodoro/store/usePomodoroStore.ts | 63 ++++++-- 3 files changed, 114 insertions(+), 133 deletions(-) diff --git a/client/src/features/pomodoro/hooks/usePomodoroEffects.ts b/client/src/features/pomodoro/hooks/usePomodoroEffects.ts index 2bf8296b..49895558 100644 --- a/client/src/features/pomodoro/hooks/usePomodoroEffects.ts +++ b/client/src/features/pomodoro/hooks/usePomodoroEffects.ts @@ -1,10 +1,13 @@ /** * 番茄钟副作用 hook - * @ai-context 监听 usePomodoroStore 的 lastAction 信号,触发音效、通知、成就检测、会话记录 + * @ai-context 监听 usePomodoroStore 的 lastAction 信号,触发视觉反馈(水墨涟漪)与通知权限请求 * * 与 usePomodoroStore 配合使用: - * - Store 仅负责纯状态变更(计时器 tick、阶段切换、设置更新) - * - 本 hook 负责所有副作用(音效播放、浏览器通知、成就检测、会话持久化) + * - Store 负责状态变更及音效播放、会话持久化、浏览器通知(tick/skip/start 内联执行) + * - 本 hook 仅负责 store 无法执行的 DOM 级副作用(涟漪动画、权限请求) + * + * 注意:不要在此重复播放音效或记录会话——store 已执行过, + * 重复执行会导致会话统计翻倍(BUG:番茄会话重复记录)。 * * 使用方式:在番茄钟页面顶层组件中调用一次即可 * usePomodoroEffects() @@ -12,10 +15,7 @@ import { useEffect, useRef } from 'react'; import { usePomodoroActionSignal } from '../store/usePomodoroStore'; -import { recordSession, playCompletionSound, sendNotification } from '../store/usePomodoroPersistence'; -import { soundPlayer } from '@/lib/audio/SoundPlayer'; import { triggerInkRipple } from '@/lib/animation/InkRipple'; -import type { PomodoroAction } from '../store/usePomodoroStore'; /** * 番茄钟副作用 hook @@ -44,125 +44,10 @@ export function usePomodoroEffects(): void { if (signal.lastActionCounter === prevCounterRef.current) return; prevCounterRef.current = signal.lastActionCounter; - const action: PomodoroAction | null = signal.lastAction; - if (!action) return; - - switch (action) { - case 'start': - soundPlayer.play('pomodoro_start'); - break; - - case 'pause': - soundPlayer.play('pomodoro_pause'); - break; - - case 'exit_immersive': - soundPlayer.play('pomodoro_pause'); - break; - - case 'tick_5min_warning': - // store 已确保 mode !== 'class' 时才发出此信号 - soundPlayer.play('pomodoro_5min_warning'); - break; - - case 'tick_final': - // store 已确保 mode !== 'class' 时才发出此信号 - soundPlayer.play('pomodoro_tick_final'); - break; - - case 'phase_complete': - handlePhaseComplete(signal); - break; + // 音效/会话记录/浏览器通知均由 store 内联执行,此处仅处理视觉反馈 + if (signal.lastAction === 'phase_complete') { + // 水墨涟漪反馈:每次阶段完成时触发 + triggerInkRipple(window.innerWidth / 2, window.innerHeight / 2); } }, [signal.lastActionCounter, signal.lastAction, signal]); } - -// ───────────────────────────────────────────────────────────── -// 内部:phase_complete 副作用处理 -// ───────────────────────────────────────────────────────────── - -interface PhaseCompletePayload { - lastCompletedPhase: 'work' | 'short_break' | 'long_break' | null; - isCycleComplete: boolean; - lastSessionActualDuration: number | null; - mode: 'class' | 'self_study'; - settings: { - soundEnabled: boolean; - notificationEnabled: boolean; - workDuration: number; - classDuration: number; - }; - currentGoal: string | null; -} - -function handlePhaseComplete(payload: PhaseCompletePayload): void { - const { - lastCompletedPhase, - isCycleComplete, - lastSessionActualDuration, - mode, - settings, - currentGoal, - } = payload; - - if (!lastCompletedPhase) return; - - // ── 水墨涟漪反馈:每次阶段完成时触发 ──────────────── - triggerInkRipple(window.innerWidth / 2, window.innerHeight / 2); - - // ── 记录会话(仅 work 阶段) ───────────────────────────── - if (lastCompletedPhase === 'work') { - const workMinutes = mode === 'class' ? settings.classDuration : settings.workDuration; - const actualDuration = lastSessionActualDuration ?? workMinutes * 60; - - recordSession({ - mode, - duration: workMinutes * 60, - actualDuration, - completedAt: new Date(), - interrupted: false, - goal: currentGoal ?? undefined, - }) - .then(() => { - // 触发成就检查(动态 import 避免循环依赖) - import('@/lib/achievements/evaluator') - .then(({ checkAchievements }) => { - checkAchievements({ type: 'pomodoro_completed' }) - .then((unlocked) => { - unlocked.forEach((a) => { - window.dispatchEvent( - new CustomEvent('achievement-unlocked', { detail: a }), - ); - }); - }) - .catch(() => {}); - }) - .catch(() => {}); - }) - .catch(() => {}); - } - - // ── 播放音效(上课模式静默) ────────────────────────────── - if (mode !== 'class') { - if (settings.soundEnabled) { - playCompletionSound(); - } - if (lastCompletedPhase === 'work') { - soundPlayer.play('pomodoro_work_complete'); - } else { - soundPlayer.play('pomodoro_break_end'); - if (isCycleComplete) { - soundPlayer.play('pomodoro_complete'); - } - } - } - - // ── 发送浏览器通知 ──────────────────────────────────────── - if (settings.notificationEnabled) { - if (lastCompletedPhase === 'work') { - sendNotification('又添了一段暖意', '继续深潜吧 ☕').catch(() => {}); - } else { - sendNotification('休息结束!', '开始下一个番茄 🍅').catch(() => {}); - } - } -} diff --git a/client/src/features/pomodoro/store/usePomodoroStore.test.ts b/client/src/features/pomodoro/store/usePomodoroStore.test.ts index b9d31165..8fa59832 100644 --- a/client/src/features/pomodoro/store/usePomodoroStore.test.ts +++ b/client/src/features/pomodoro/store/usePomodoroStore.test.ts @@ -465,5 +465,54 @@ describe('Pomodoro Store', () => { usePomodoroStore.getState().setMode('class'); expect(usePomodoroStore.getState().mode).toBe('class'); }); + + it('should reset completedCount when switching mode', () => { + // 上课模式累计了 7 个番茄后切到自习模式,计数应归零 + // (回归:旧计数带入新模式导致首轮长休要等到 8 个番茄) + usePomodoroStore.setState({ mode: 'class', completedCount: 7 }); + usePomodoroStore.getState().setMode('self_study'); + expect(usePomodoroStore.getState().completedCount).toBe(0); + }); + + it('should NOT reset completedCount when setting the same mode', () => { + usePomodoroStore.setState({ mode: 'self_study', completedCount: 2 }); + usePomodoroStore.getState().setMode('self_study'); + expect(usePomodoroStore.getState().completedCount).toBe(2); + }); + }); + + // ── class mode completedCount ──────────────────────────── + + describe('class mode completedCount', () => { + const completeWorkPhase = () => { + usePomodoroStore.setState({ phase: 'work', remainingSeconds: 1, isRunning: true }); + usePomodoroStore.getState().tick(); // work → short_break + usePomodoroStore.setState({ remainingSeconds: 1, isRunning: true }); + usePomodoroStore.getState().tick(); // short_break → work + }; + + it('should never enter long_break in class mode', () => { + usePomodoroStore.setState({ + mode: 'class', phase: 'work', completedCount: 3, + isRunning: true, remainingSeconds: 1, + }); + usePomodoroStore.getState().tick(); + expect(usePomodoroStore.getState().phase).toBe('short_break'); + }); + + it('should wrap completedCount within longBreakInterval in class mode (no unbounded growth)', () => { + // 回归:上课模式无长休导致计数永不归零、一直累加(实测 9/4) + usePomodoroStore.setState({ mode: 'class', completedCount: 0 }); + for (let i = 0; i < 9; i++) completeWorkPhase(); + const count = usePomodoroStore.getState().completedCount; + expect(count).toBeGreaterThanOrEqual(1); + expect(count).toBeLessThanOrEqual(DEFAULT_SETTINGS.longBreakInterval); + }); + + it('should wrap count via skip in class mode as well', () => { + usePomodoroStore.setState({ mode: 'class', phase: 'work', completedCount: 4 }); + usePomodoroStore.getState().skip(); + expect(usePomodoroStore.getState().completedCount).toBe(1); + }); }); }); diff --git a/client/src/features/pomodoro/store/usePomodoroStore.ts b/client/src/features/pomodoro/store/usePomodoroStore.ts index 67f1e966..d299383f 100644 --- a/client/src/features/pomodoro/store/usePomodoroStore.ts +++ b/client/src/features/pomodoro/store/usePomodoroStore.ts @@ -44,6 +44,8 @@ interface PomodoroState { sessionStartTime: number | null; /** 当前番茄目标文字 */ currentGoal: string | null; + /** 首潜迷你会话标记:3 分钟体验潜水,会话时长按实际记录而非 settings 时长 */ + isMiniDive: boolean; /** 是否处于沉浸专注模式 */ isImmersive: boolean; /** 退出沉浸后标记,用于 resume 时自动重入 */ @@ -64,6 +66,8 @@ interface PomodoroState { lastSessionActualDuration: number | null; start: () => void; + /** 开始首潜 3 分钟迷你体验(新手引导专用,不改动用户设置) */ + startMiniDive: () => void; pause: () => void; resume: () => void; reset: () => void; @@ -90,6 +94,9 @@ const getPhaseDuration = (phase: Phase, settings: PomodoroSettings, mode?: Mode) } }; +/** 首潜迷你体验时长(3 分钟),见新手引导系统 */ +export const MINI_DIVE_SECONDS = 180; + const getNextPhase = ( currentPhase: Phase, completedCount: number, @@ -106,6 +113,24 @@ const getNextPhase = ( return 'work'; }; +/** + * 计算阶段结束后的完成计数: + * - 长休结束 → 归零(一轮完成) + * - 工作结束 → +1;上课模式无长休,计数达到周期上限后回绕,避免无限累加 + * - 其他阶段 → 不变 + */ +const getNextCount = ( + phase: Phase, + completedCount: number, + longBreakInterval: number, + mode?: Mode, +): number => { + if (phase === 'long_break') return 0; + if (phase !== 'work') return completedCount; + if (mode === 'class') return (completedCount % longBreakInterval) + 1; + return completedCount + 1; +}; + export const usePomodoroStore = create((set, get) => { const defaultSettings: PomodoroSettings = { workDuration: 25, @@ -130,6 +155,7 @@ export const usePomodoroStore = create((set, get) => { settings: defaultSettings, sessionStartTime: null, currentGoal: null, + isMiniDive: false, isImmersive: false, wasImmersive: false, aiRecommendedDuration: undefined, @@ -168,6 +194,18 @@ export const usePomodoroStore = create((set, get) => { soundPlayer.play('pomodoro_start'); }, + startMiniDive: () => { + // 3 分钟真实专注:走完整 tick 链路(记会话/触发成就),duration 按 180s 如实记录 + set((s) => ({ + mode: 'self_study', phase: 'work', + remainingSeconds: MINI_DIVE_SECONDS, totalSeconds: MINI_DIVE_SECONDS, + isMiniDive: true, isRunning: true, isPaused: false, + sessionStartTime: Date.now(), currentGoal: '首潜 · 3 分钟体验', + lastAction: 'start' as PomodoroAction, lastActionCounter: s.lastActionCounter + 1, + })); + soundPlayer.play('pomodoro_start'); + }, + pause: () => { set((s) => ({ isRunning: false, isPaused: true, @@ -199,12 +237,13 @@ export const usePomodoroStore = create((set, get) => { isPaused: false, sessionStartTime: null, wasImmersive: false, + isMiniDive: false, }); }, skip: () => { const { phase, completedCount, settings, mode } = get(); - const newCount = phase === 'long_break' ? 0 : (phase === 'work' ? completedCount + 1 : completedCount); + const newCount = getNextCount(phase, completedCount, settings.longBreakInterval, mode); const nextPhase = getNextPhase(phase, completedCount, settings.longBreakInterval, mode); const duration = getPhaseDuration(nextPhase, settings, mode); set({ @@ -214,12 +253,15 @@ export const usePomodoroStore = create((set, get) => { completedCount: newCount, isRunning: false, isPaused: false, + isMiniDive: false, }); }, setMode: (mode) => { - const { settings, phase, isRunning, isPaused } = get(); - set({ mode }); + const { settings, phase, isRunning, isPaused, mode: prevMode } = get(); + if (mode === prevMode) return; + // 切换模式 = 开启新周期:计数归零,避免跨模式累计(自习首轮跳到 8 的根因) + set({ mode, completedCount: 0 }); // 切换模式后,若计时器未运行,重置当前阶段时长 if (!isRunning && !isPaused) { const duration = getPhaseDuration(phase, settings, mode); @@ -252,7 +294,7 @@ export const usePomodoroStore = create((set, get) => { if (remainingSeconds <= 1) { // Phase completed const wasRunning = isRunning; - const newCount = phase === 'long_break' ? 0 : (phase === 'work' ? completedCount + 1 : completedCount); + const newCount = getNextCount(phase, completedCount, settings.longBreakInterval, mode); const nextPhase = getNextPhase(phase, completedCount, settings.longBreakInterval, mode); const duration = getPhaseDuration(nextPhase, settings, mode); const isCycleComplete = phase === 'long_break'; @@ -272,14 +314,17 @@ export const usePomodoroStore = create((set, get) => { // 记录完成的番茄会话 let actualDuration: number | null = null; if (phase === 'work') { - const { sessionStartTime: sst } = get(); - const workMinutes = mode === 'class' ? settings.classDuration : settings.workDuration; + const { sessionStartTime: sst, isMiniDive } = get(); + // 迷你潜水按实际 180s 记录,避免污染效率统计(首潜决策:计入成就) + const plannedSeconds = isMiniDive + ? MINI_DIVE_SECONDS + : (mode === 'class' ? settings.classDuration : settings.workDuration) * 60; actualDuration = sst ? Math.round((Date.now() - sst) / 1000) - : workMinutes * 60; + : plannedSeconds; recordSession({ mode: get().mode, - duration: workMinutes * 60, + duration: plannedSeconds, actualDuration, completedAt: new Date(), interrupted: false, @@ -328,6 +373,8 @@ export const usePomodoroStore = create((set, get) => { completedCount: newCount, isRunning: shouldAutoStart, isPaused: !shouldAutoStart, + // 迷你潜水仅限一个工作阶段,阶段切换即恢复常规节律 + isMiniDive: false, // 切换到新阶段时清空计时,下一个 start/resume 会重新设置 sessionStartTime: null, // 发出 phase_complete 动作信号 From a3a4e1802c59fb06d031d6808a14d6217230a614 Mon Sep 17 00:00:00 2001 From: Aparencia Date: Fri, 31 Jul 2026 22:11:35 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E5=A4=8D=20Tailwind=20?= =?UTF-8?q?var()=20=E4=BB=A4=E7=89=8C=E8=89=B2=E9=80=8F=E6=98=8E=E5=BA=A6?= =?UTF-8?q?=E4=BF=AE=E9=A5=B0=E7=AC=A6=E9=9D=99=E9=BB=98=E5=A4=B1=E6=95=88?= =?UTF-8?q?=E8=87=B4=E5=BC=B9=E7=AA=97=E5=85=A8=E9=80=8F=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tailwind v3 对 var() 形式的令牌色应用 /透明度 修饰符时静默丢弃, 明亮主题下 ContextMenu 背景变为全透明。改用 color-mix 方案。 --- client/src/components/ui/ContextMenu.tsx | 25 ++++- client/tailwind.config.js | 126 +++++++++++++---------- 2 files changed, 91 insertions(+), 60 deletions(-) diff --git a/client/src/components/ui/ContextMenu.tsx b/client/src/components/ui/ContextMenu.tsx index 6202cddb..c88a4549 100644 --- a/client/src/components/ui/ContextMenu.tsx +++ b/client/src/components/ui/ContextMenu.tsx @@ -1,7 +1,10 @@ /** * @ai-context: UI 基础组件(shadcn/radix 封装):ContextMenu。 + * 定位机制:Radix ContextMenu 的菜单锚点取自触发器真实收到的 contextmenu 事件坐标, + * 因此挂载后向隐藏触发器派发携带 position 坐标的原生事件来打开菜单; + * 绝不可强制传 open(锚点未初始化会导致菜单固定在屏幕左上角)。 */ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import * as RadixContextMenu from '@radix-ui/react-context-menu'; import { motion } from 'framer-motion'; import { cn } from '@/lib/utils'; @@ -120,6 +123,8 @@ export const ContextMenu = ({ onSelect, onClose, }: ContextMenuProps) => { + const triggerRef = useRef(null); + const handleSelect = useCallback( (itemKey: string) => { onSelect(itemKey, context); @@ -128,10 +133,24 @@ export const ContextMenu = ({ [onSelect, context, onClose], ); + // 向隐藏触发器派发带坐标的 contextmenu 事件,让 Radix 记录锚点并打开菜单; + // position 变化时(菜单未关闭又右键另一目标)重新派发以重定位 + useEffect(() => { + triggerRef.current?.dispatchEvent(new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: position.x, + clientY: position.y, + })); + }, [position.x, position.y]); + return ( - { if (!v) onClose(); }}> + { if (!v) onClose(); }}> {/* Hidden trigger at target position */} e.stopPropagation()} style={{ position: 'fixed', left: position.x, @@ -147,7 +166,7 @@ export const ContextMenu = ({ e.preventDefault()} className={cn( 'z-[9999] bg-bg-elevated/90 backdrop-blur-2xl border border-border/50 rounded-kb-md shadow-xl py-1 min-w-[160px]', diff --git a/client/tailwind.config.js b/client/tailwind.config.js index 94429a63..b9bcd061 100644 --- a/client/tailwind.config.js +++ b/client/tailwind.config.js @@ -1,4 +1,16 @@ /** @type {import('tailwindcss').Config} */ + +/** + * 令牌色 + 透明度修饰符支持(如 bg-bg-elevated/90、border-border/40)。 + * Tailwind v3 无法对纯 var() 颜色应用 /alpha 修饰符(此前这些类静默丢失, + * 导致明亮主题下弹窗/面板背景全透明),用 color-mix 按透明度混入 transparent + * 实现(Chromium 111+ / Electron 35 支持)。 + */ +const tokenColor = (variable) => ({ opacityValue }) => + opacityValue === undefined + ? `var(${variable})` + : `color-mix(in srgb, var(${variable}) calc(${opacityValue} * 100%), transparent)`; + module.exports = { content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], darkMode: ['selector', '[data-theme="dark"]'], @@ -6,30 +18,30 @@ module.exports = { extend: { colors: { brand: { - 50: 'var(--kb-brand-50)', - 100: 'var(--kb-brand-100)', - 200: 'var(--kb-brand-200)', - 300: 'var(--kb-brand-300)', - 400: 'var(--kb-brand-400)', - 500: 'var(--kb-brand-500)', - 600: 'var(--kb-brand-600)', - 700: 'var(--kb-brand-700)', - 800: 'var(--kb-brand-800)', - 900: 'var(--kb-brand-900)', + 50: tokenColor('--kb-brand-50'), + 100: tokenColor('--kb-brand-100'), + 200: tokenColor('--kb-brand-200'), + 300: tokenColor('--kb-brand-300'), + 400: tokenColor('--kb-brand-400'), + 500: tokenColor('--kb-brand-500'), + 600: tokenColor('--kb-brand-600'), + 700: tokenColor('--kb-brand-700'), + 800: tokenColor('--kb-brand-800'), + 900: tokenColor('--kb-brand-900'), }, accent: { - DEFAULT: 'var(--accent)', - foreground: 'var(--accent-foreground)', - 50: 'var(--kb-accent-50)', - 100: 'var(--kb-accent-100)', - 200: 'var(--kb-accent-200)', - 300: 'var(--kb-accent-300)', - 400: 'var(--kb-accent-400)', - 500: 'var(--kb-accent-500)', - 600: 'var(--kb-accent-600)', - 700: 'var(--kb-accent-700)', - 800: 'var(--kb-accent-800)', - 900: 'var(--kb-accent-900)', + DEFAULT: tokenColor('--accent'), + foreground: tokenColor('--accent-foreground'), + 50: tokenColor('--kb-accent-50'), + 100: tokenColor('--kb-accent-100'), + 200: tokenColor('--kb-accent-200'), + 300: tokenColor('--kb-accent-300'), + 400: tokenColor('--kb-accent-400'), + 500: tokenColor('--kb-accent-500'), + 600: tokenColor('--kb-accent-600'), + 700: tokenColor('--kb-accent-700'), + 800: tokenColor('--kb-accent-800'), + 900: tokenColor('--kb-accent-900'), }, pomodoro: { DEFAULT: '#5B8A72', light: '#AAC9B5' }, note: { DEFAULT: '#6B9BD2', light: '#ADD6FF' }, @@ -37,62 +49,62 @@ module.exports = { feynman: { DEFAULT: '#C4956A', light: '#DEBB92' }, classroom: { DEFAULT: '#14B8A6', light: '#5EEAD4' }, /* 深海静谧功能色 */ - focus: { DEFAULT: 'var(--kb-focus-blue)' }, - amber: { DEFAULT: 'var(--kb-amber)' }, - moss: { DEFAULT: 'var(--kb-moss-green)' }, - cyber: { DEFAULT: 'var(--kb-cyber-cyan)' }, - 'stone-purple': 'var(--kb-stone-purple)', + focus: { DEFAULT: tokenColor('--kb-focus-blue') }, + amber: { DEFAULT: tokenColor('--kb-amber') }, + moss: { DEFAULT: tokenColor('--kb-moss-green') }, + cyber: { DEFAULT: tokenColor('--kb-cyber-cyan') }, + 'stone-purple': tokenColor('--kb-stone-purple'), bg: { - primary: 'var(--kb-bg-primary)', - secondary: 'var(--kb-bg-secondary)', - tertiary: 'var(--kb-bg-tertiary)', - elevated: 'var(--kb-bg-elevated)', + primary: tokenColor('--kb-bg-primary'), + secondary: tokenColor('--kb-bg-secondary'), + tertiary: tokenColor('--kb-bg-tertiary'), + elevated: tokenColor('--kb-bg-elevated'), }, text: { - primary: 'var(--kb-text-primary)', - secondary: 'var(--kb-text-secondary)', - tertiary: 'var(--kb-text-tertiary)', - inverse: 'var(--kb-text-inverse)', + primary: tokenColor('--kb-text-primary'), + secondary: tokenColor('--kb-text-secondary'), + tertiary: tokenColor('--kb-text-tertiary'), + inverse: tokenColor('--kb-text-inverse'), }, border: { - DEFAULT: 'var(--kb-border-default)', - strong: 'var(--kb-border-strong)', + DEFAULT: tokenColor('--kb-border-default'), + strong: tokenColor('--kb-border-strong'), }, semantic: { - success: 'var(--kb-color-success)', - warning: 'var(--kb-color-warning)', - error: 'var(--kb-color-error)', - info: 'var(--kb-color-info)', + success: tokenColor('--kb-color-success'), + warning: tokenColor('--kb-color-warning'), + error: tokenColor('--kb-color-error'), + info: tokenColor('--kb-color-info'), }, // shadcn/ui 兼容色 - background: 'var(--background)', - foreground: 'var(--foreground)', + background: tokenColor('--background'), + foreground: tokenColor('--foreground'), card: { - DEFAULT: 'var(--card)', - foreground: 'var(--card-foreground)', + DEFAULT: tokenColor('--card'), + foreground: tokenColor('--card-foreground'), }, popover: { - DEFAULT: 'var(--popover)', - foreground: 'var(--popover-foreground)', + DEFAULT: tokenColor('--popover'), + foreground: tokenColor('--popover-foreground'), }, primary: { - DEFAULT: 'var(--primary)', - foreground: 'var(--primary-foreground)', + DEFAULT: tokenColor('--primary'), + foreground: tokenColor('--primary-foreground'), }, secondary: { - DEFAULT: 'var(--secondary)', - foreground: 'var(--secondary-foreground)', + DEFAULT: tokenColor('--secondary'), + foreground: tokenColor('--secondary-foreground'), }, muted: { - DEFAULT: 'var(--muted)', - foreground: 'var(--muted-foreground)', + DEFAULT: tokenColor('--muted'), + foreground: tokenColor('--muted-foreground'), }, destructive: { - DEFAULT: 'var(--destructive)', - foreground: 'var(--destructive-foreground)', + DEFAULT: tokenColor('--destructive'), + foreground: tokenColor('--destructive-foreground'), }, - input: 'var(--input)', - ring: 'var(--ring)', + input: tokenColor('--input'), + ring: tokenColor('--ring'), }, fontFamily: { sans: ['var(--kb-font-sans)'], From 030ac80a361da01ce4a73cfd002d66f4af39c9f6 Mon Sep 17 00:00:00 2001 From: Aparencia Date: Fri, 31 Jul 2026 22:11:56 +0800 Subject: [PATCH 6/7] =?UTF-8?q?feat(onboarding):=20First=20Dive=20?= =?UTF-8?q?=E6=96=B0=E6=89=8B=E5=BC=95=E5=AF=BC=E7=B3=BB=E7=BB=9F=E4=B8=8E?= =?UTF-8?q?=E6=96=B0=E6=89=8B=E6=9C=9F=E5=8F=8C=E6=A0=87=E7=AD=BE=E5=AF=BC?= =?UTF-8?q?=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增新手引导流程(onboarding feature + OnboardingOverlay),并调整 侧边栏/布局与 3D 导航实体以适配新手期双标签导航策略。 --- client/src/components/layout/AppLayout.tsx | 2 + client/src/components/layout/Sidebar.tsx | 29 ++- .../onboarding/OnboardingOverlay.tsx | 4 + .../onboarding/firstDive/FirstDiveGate.tsx | 37 ++++ .../onboarding/firstDive/FirstDiveGuide.tsx | 163 +++++++++++++++++ .../onboarding/firstDive/LandingQuestion.tsx | 100 +++++++++++ .../onboarding/firstDive/MicroLight.tsx | 32 ++++ .../onboarding/firstDive/diveSteps.ts | 70 ++++++++ .../onboarding/firstDive/firstDive.test.ts | 165 +++++++++++++++++ .../onboarding/firstDive/firstDiveStorage.ts | 68 +++++++ .../onboarding/firstDive/handbookDeck.ts | 62 +++++++ .../onboarding/firstDive/moduleSubtitles.ts | 19 ++ .../onboarding/firstDive/seedHandbook.ts | 98 ++++++++++ .../features/onboarding/firstDive/types.ts | 51 ++++++ .../onboarding/firstDive/useFirstDiveStore.ts | 169 ++++++++++++++++++ client/src/lib/3d/navigation/OrbitalStore.ts | 2 +- .../src/lib/3d/objects/AuroraModuleEntity.tsx | 11 +- client/src/lib/3d/objects/ModuleEntity.tsx | 9 + client/src/lib/3d/scenes/MobileNavGrid.tsx | 7 + 19 files changed, 1087 insertions(+), 11 deletions(-) create mode 100644 client/src/features/onboarding/firstDive/FirstDiveGate.tsx create mode 100644 client/src/features/onboarding/firstDive/FirstDiveGuide.tsx create mode 100644 client/src/features/onboarding/firstDive/LandingQuestion.tsx create mode 100644 client/src/features/onboarding/firstDive/MicroLight.tsx create mode 100644 client/src/features/onboarding/firstDive/diveSteps.ts create mode 100644 client/src/features/onboarding/firstDive/firstDive.test.ts create mode 100644 client/src/features/onboarding/firstDive/firstDiveStorage.ts create mode 100644 client/src/features/onboarding/firstDive/handbookDeck.ts create mode 100644 client/src/features/onboarding/firstDive/moduleSubtitles.ts create mode 100644 client/src/features/onboarding/firstDive/seedHandbook.ts create mode 100644 client/src/features/onboarding/firstDive/types.ts create mode 100644 client/src/features/onboarding/firstDive/useFirstDiveStore.ts diff --git a/client/src/components/layout/AppLayout.tsx b/client/src/components/layout/AppLayout.tsx index e260895d..2dcf8f57 100644 --- a/client/src/components/layout/AppLayout.tsx +++ b/client/src/components/layout/AppLayout.tsx @@ -23,6 +23,7 @@ import { HelpCenter } from '@/components/onboarding/HelpCenter'; import { useOnboardingStore } from '@/components/onboarding/useOnboardingStore'; import { useRuntimeEnv } from '@/lib/env/useRuntimeEnv'; import { PWAInstallPrompt } from '@/components/PWAInstallPrompt'; +import { FirstDiveGate } from '@/features/onboarding/firstDive/FirstDiveGate'; export default function AppLayout() { const { pathname } = useLocation(); @@ -164,6 +165,7 @@ export default function AppLayout() { {/* 全局组件 */} + diff --git a/client/src/components/layout/Sidebar.tsx b/client/src/components/layout/Sidebar.tsx index 9445da3b..a9a32f0c 100644 --- a/client/src/components/layout/Sidebar.tsx +++ b/client/src/components/layout/Sidebar.tsx @@ -16,24 +16,26 @@ import { useAuth } from '@/lib/auth/AuthContext'; import { useTheme } from '@/hooks/useTheme'; import { SPRING, BEAT } from '@/lib/animation/springConfig'; import { soundPlayer } from '@/lib/audio/SoundPlayer'; +import { useIsNewbiePhase } from '@/features/onboarding/firstDive/useFirstDiveStore'; import FeedbackPanel from './FeedbackPanel'; /* ── 导航配置 ── */ +// subtitle:新手期双标签副标题(隐喻名无法自解释,首潜完成前附直白说明) const navSection1 = [ { to: '/', label: '首页', icon: Home, shortcut: '⌘ 1', exact: true }, ]; const navSection2 = [ - { to: '/pomodoro', label: '深潜', icon: Timer, shortcut: '⌘ 2', dotColor: 'bg-brand-500' }, - { to: '/notes', label: '结礁', icon: FileText, shortcut: '⌘ 3', dotColor: 'bg-note' }, - { to: '/flashcards', label: '反衰减呼吸', icon: Layers, shortcut: '⌘ 4', dotColor: 'bg-flashcard' }, - { to: '/feynman', label: '浮出水面', icon: Lightbulb, shortcut: '⌘ 5', dotColor: 'bg-feynman' }, + { to: '/pomodoro', label: '深潜', subtitle: '专注番茄钟', icon: Timer, shortcut: '⌘ 2', dotColor: 'bg-brand-500' }, + { to: '/notes', label: '结礁', subtitle: '学习笔记', icon: FileText, shortcut: '⌘ 3', dotColor: 'bg-note' }, + { to: '/flashcards', label: '反衰减呼吸', subtitle: '记忆闪卡', icon: Layers, shortcut: '⌘ 4', dotColor: 'bg-flashcard' }, + { to: '/feynman', label: '浮出水面', subtitle: '费曼讲解', icon: Lightbulb, shortcut: '⌘ 5', dotColor: 'bg-feynman' }, ]; const navSection3 = [ { to: '/analytics', label: '数据分析', icon: BarChart3, shortcut: '⌘ 6' }, - { to: '/inspiration', label: '萤火海沟', icon: Sparkles, shortcut: '⌘ 6' }, - { to: '/classroom', label: '回声定位', icon: Clapperboard, shortcut: '⌘ 7', dotColor: 'bg-classroom' }, + { to: '/inspiration', label: '萤火海沟', subtitle: '灵感收集', icon: Sparkles, shortcut: '⌘ 6' }, + { to: '/classroom', label: '回声定位', subtitle: '课堂采集', icon: Clapperboard, shortcut: '⌘ 7', dotColor: 'bg-classroom' }, ]; /* ── 蔡格尼克效应:待继续任务提示池 ── */ @@ -73,6 +75,8 @@ export default function Sidebar() { const setCaptureOpen = useCaptureStore((s) => s.setOpen); const { user, isAuthenticated } = useAuth(); const { theme, toggleTheme } = useTheme(); + // 新手期显示模块副标题(首潜完成后自动隐去) + const isNewbie = useIsNewbiePhase(); // TODO: 接入真实学习进度数据 const _progressItems: { subject: string; progress: number }[] = []; @@ -194,9 +198,10 @@ export default function Sidebar() { )} {collapsed &&
} - {navSection2.map(({ to, label, icon: Icon, shortcut, dotColor }, i) => ( + {navSection2.map(({ to, label, subtitle, icon: Icon, shortcut, dotColor }, i) => ( @@ -209,9 +214,10 @@ export default function Sidebar() {
)} {collapsed &&
} - {navSection3.map(({ to, label, icon: Icon, shortcut, dotColor }, i) => ( + {navSection3.map(({ to, label, subtitle, icon: Icon, shortcut, dotColor }, i) => ( @@ -316,6 +322,8 @@ export default function Sidebar() { interface SidebarItemProps { to: string; label: string; + /** 新手期双标签副标题(如"专注番茄钟"),传入即显示 */ + subtitle?: string; icon: React.ComponentType<{ className?: string; strokeWidth?: string | number }>; shortcut?: string; dotColor?: string; @@ -324,7 +332,7 @@ interface SidebarItemProps { index?: number; } -function SidebarItem({ to, label, icon: Icon, shortcut, dotColor, collapsed, end, index = 0 }: SidebarItemProps) { +function SidebarItem({ to, label, subtitle, icon: Icon, shortcut, dotColor, collapsed, end, index = 0 }: SidebarItemProps) { const location = useLocation(); // 切换到不同模块时播放导航音效(点击当前已激活项不响) const isCurrentActive = end ? location.pathname === to : location.pathname === to || location.pathname.startsWith(to + '/'); @@ -383,6 +391,9 @@ function SidebarItem({ to, label, icon: Icon, shortcut, dotColor, collapsed, end isActive ? 'opacity-100' : 'opacity-60 group-hover:opacity-100', )}> {label} + {subtitle && ( + · {subtitle} + )} )} {/* 快捷键提示 — hover 显示 */} diff --git a/client/src/components/onboarding/OnboardingOverlay.tsx b/client/src/components/onboarding/OnboardingOverlay.tsx index d0574934..d1e833c4 100644 --- a/client/src/components/onboarding/OnboardingOverlay.tsx +++ b/client/src/components/onboarding/OnboardingOverlay.tsx @@ -6,6 +6,7 @@ import { useEffect, useCallback } from 'react'; import { AnimatePresence } from 'framer-motion'; import { useOnboardingStore, isGuideDone } from './useOnboardingStore'; +import { loadFirstDiveState } from '@/features/onboarding/firstDive/firstDiveStorage'; import { Step1Welcome } from './steps/Step1Welcome'; import { Step2Navigate } from './steps/Step2Navigate'; import { Step3CameraFlight } from './steps/Step3CameraFlight'; @@ -19,7 +20,10 @@ export function OnboardingOverlay() { useOnboardingStore(); // 初始化:检查 localStorage,若未完成则 1.5s 后自动启动 + // 首潜(L0/L1)未结束时不自动启动,避免双引导叠加轰炸新用户 useEffect(() => { + const diveStage = loadFirstDiveState().stage; + if (diveStage === 'landing' || diveStage === 'diving') return; if (!isGuideDone()) { const timer = setTimeout(() => startGuide(), 1500); return () => clearTimeout(timer); diff --git a/client/src/features/onboarding/firstDive/FirstDiveGate.tsx b/client/src/features/onboarding/firstDive/FirstDiveGate.tsx new file mode 100644 index 00000000..e306e139 --- /dev/null +++ b/client/src/features/onboarding/firstDive/FirstDiveGate.tsx @@ -0,0 +1,37 @@ +/** + * 首潜编排 Gate — 按引导阶段挂载 L0/L1(AppLayout 全局挂载一次) + * + * @ai-context: bootstrap 负责旧标记迁移、老用户判定与手册种子(幂等); + * stage 为 done/skipped 时本组件渲染 null,对存量用户零打扰。 + * 最后一步的 praise 需要在 stage 变为 done 后仍短暂展示,故 diving 判断 + * 额外放行 justCompleted 存在的瞬间。 + */ +import { useEffect } from 'react'; +import { AnimatePresence } from 'framer-motion'; +import { LandingQuestion } from './LandingQuestion'; +import { FirstDiveGuide } from './FirstDiveGuide'; +import { useFirstDiveStore } from './useFirstDiveStore'; + +export function FirstDiveGate() { + const stage = useFirstDiveStore((s) => s.stage); + const isReady = useFirstDiveStore((s) => s.isReady); + const justCompleted = useFirstDiveStore((s) => s.justCompleted); + const bootstrap = useFirstDiveStore((s) => s.bootstrap); + + useEffect(() => { + bootstrap().catch(() => {}); + }, [bootstrap]); + + if (!isReady) return null; + + const showLanding = stage === 'landing'; + // done 后放行片刻,让最后一句 praise 说完 + const showGuide = stage === 'diving' || (stage === 'done' && justCompleted !== null); + + return ( + + {showLanding && } + {showGuide && } + + ); +} diff --git a/client/src/features/onboarding/firstDive/FirstDiveGuide.tsx b/client/src/features/onboarding/firstDive/FirstDiveGuide.tsx new file mode 100644 index 00000000..f7b8c2ff --- /dev/null +++ b/client/src/features/onboarding/firstDive/FirstDiveGuide.tsx @@ -0,0 +1,163 @@ +/** + * 首潜引导条(L1)— 底部常驻微光伴航,带做完整学习循环 + * + * @ai-context: 完成检测 = 轮询 checkProgress(数据基线差值),与各模块 + * UI 零耦合;轮询仅在 diving 阶段挂载(3s 间隔 + 窗口聚焦触发)。 + * 跳过用可见按钮而非 Esc——Esc 已被 AppLayout 用于退出模块,不抢占。 + */ +import { useEffect, useMemo, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { AnimatePresence, motion } from 'framer-motion'; +import { ArrowRight, Play } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { usePomodoroStore } from '@/features/pomodoro/store/usePomodoroStore'; +import { DIVE_STEPS, getDiveStep, orderStepsByProfile } from './diveSteps'; +import { getCurrentStep, useFirstDiveStore } from './useFirstDiveStore'; +import { MicroLight } from './MicroLight'; + +const PROGRESS_POLL_MS = 3000; +const PRAISE_VISIBLE_MS = 4200; + +export function FirstDiveGuide() { + const { profile, completedSteps, justCompleted, checkProgress, skipDive, clearJustCompleted } = + useFirstDiveStore(); + const startMiniDive = usePomodoroStore((s) => s.startMiniDive); + const pomodoroRunning = usePomodoroStore((s) => s.isRunning); + const navigate = useNavigate(); + const { pathname } = useLocation(); + const [showPraise, setShowPraise] = useState(false); + + const currentStepId = getCurrentStep(profile, completedSteps); + const currentStep = currentStepId ? getDiveStep(currentStepId) : null; + const orderedSteps = useMemo( + () => (profile ? orderStepsByProfile(profile) : DIVE_STEPS), + [profile], + ); + + // ── 进度轮询:仅 diving 阶段挂载 ── + useEffect(() => { + const timer = setInterval(() => { checkProgress().catch(() => {}); }, PROGRESS_POLL_MS); + const onFocus = () => { checkProgress().catch(() => {}); }; + window.addEventListener('focus', onFocus); + return () => { clearInterval(timer); window.removeEventListener('focus', onFocus); }; + }, [checkProgress]); + + // ── praise 展示:步骤刚完成时露出几秒 ── + useEffect(() => { + if (!justCompleted) return; + setShowPraise(true); + const timer = setTimeout(() => { + setShowPraise(false); + clearJustCompleted(); + }, PRAISE_VISIBLE_MS); + return () => clearTimeout(timer); + }, [justCompleted, clearJustCompleted]); + + const onPrimaryAction = () => { + if (!currentStep) return; + if (!pathname.startsWith(currentStep.route)) { + navigate(currentStep.route); + return; + } + // 已在深潜页且当前步骤是迷你潜水 → 直接替用户按下开始 + if (currentStep.id === 'pomodoro' && !pomodoroRunning) { + startMiniDive(); + } + }; + + const primaryLabel = (() => { + if (!currentStep) return ''; + if (!pathname.startsWith(currentStep.route)) return '带我去'; + if (currentStep.id === 'pomodoro') return pomodoroRunning ? '潜水中…' : '开始 3 分钟迷你深潜'; + return '就在这页,试试吧'; + })(); + + const praiseText = justCompleted ? getDiveStep(justCompleted).praise : ''; + + return ( + +
+ + {showPraise ? ( + /* 步骤完成的回应 */ + + +

{praiseText}

+
+ ) : currentStep ? ( + /* 当前步骤引导 */ + +
+ +

+ {currentStep.instruction} +

+
+
+ {/* 潜航进度点 */} +
+ {orderedSteps.map((s) => ( + + ))} +
+ + +
+
+ ) : null} +
+
+
+ ); +} diff --git a/client/src/features/onboarding/firstDive/LandingQuestion.tsx b/client/src/features/onboarding/firstDive/LandingQuestion.tsx new file mode 100644 index 00000000..5c5d766d --- /dev/null +++ b/client/src/features/onboarding/firstDive/LandingQuestion.tsx @@ -0,0 +1,100 @@ +/** + * 着陆之问(L0)— 首启唯一的一个问题,替代多步产品介绍 + * + * @ai-context: 全屏覆盖层(非路由页),由 FirstDiveGate 按 stage==='landing' + * 挂载。选择画像后进入首潜(diving)或自由探索(skipped)。 + * 画像仅写入本地 kb-onboarding-v2,不上云。 + */ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { cn } from '@/lib/utils'; +import type { OnboardingProfile } from './types'; +import { LANDING_OPTIONS, orderStepsByProfile } from './diveSteps'; +import { useFirstDiveStore } from './useFirstDiveStore'; +import { MicroLight } from './MicroLight'; + +export function LandingQuestion() { + const answerLanding = useFirstDiveStore((s) => s.answerLanding); + const navigate = useNavigate(); + const [submitting, setSubmitting] = useState(false); + + const handleSelect = async (profile: OnboardingProfile) => { + if (submitting) return; + setSubmitting(true); + await answerLanding(profile); + if (profile !== 'explore') { + // 直接落到首潜第一步的页面,让引导条接管 + navigate(orderStepsByProfile(profile)[0].route); + } + }; + + return ( + + {/* 微光自我介绍 */} + + + + 我是微光,这片海的守夜人。 + + + + + 此刻,最困扰你的是什么? + + + 选一个,我带你从那里潜下去。 + + +
+ {LANDING_OPTIONS.map((opt, i) => ( + handleSelect(opt.profile)} + disabled={submitting} + className={cn( + 'w-full text-left px-5 py-4 rounded-2xl transition-all duration-300 group', + 'bg-white/[0.04] border border-white/10 backdrop-blur-sm', + 'hover:bg-cyan-400/10 hover:border-cyan-400/30 hover:shadow-[0_0_24px_rgba(6,182,212,0.15)]', + submitting && 'opacity-50 pointer-events-none', + )} + initial={{ opacity: 0, y: 16 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: 1.0 + i * 0.12, duration: 0.5 }} + whileTap={{ scale: 0.98 }} + > + + {opt.label} + + + {opt.hint} + + + ))} +
+
+ ); +} diff --git a/client/src/features/onboarding/firstDive/MicroLight.tsx b/client/src/features/onboarding/firstDive/MicroLight.tsx new file mode 100644 index 00000000..1b2bbdae --- /dev/null +++ b/client/src/features/onboarding/firstDive/MicroLight.tsx @@ -0,0 +1,32 @@ +/** + * 微光 — 水母伴航生命体的 P1 雏形(纯 CSS 光点,无外部资产) + * + * @ai-context: 视觉资产决策(遗留问题①)——P1 用赛博青 CSS 光晕光点 + * 代替水母动画,零依赖零资产成本;P2 升级 SVG/Lottie 时仅替换此组件。 + * 纯展示组件,无副作用。 + */ +import { motion } from 'framer-motion'; +import { cn } from '@/lib/utils'; + +interface MicroLightProps { + /** 尺寸(px),默认 14 */ + size?: number; + className?: string; +} + +export function MicroLight({ size = 14, className }: MicroLightProps) { + return ( + + ); +} diff --git a/client/src/features/onboarding/firstDive/diveSteps.ts b/client/src/features/onboarding/firstDive/diveSteps.ts new file mode 100644 index 00000000..56fec4e6 --- /dev/null +++ b/client/src/features/onboarding/firstDive/diveSteps.ts @@ -0,0 +1,70 @@ +/** + * 首潜步骤定义 · 纯数据 + * + * @ai-context: 步骤完成检测采用"数据基线差值"机制——首潜开始时记录 + * 各表计数基线,某表计数超过基线即判定该步完成(见 useFirstDiveStore)。 + * 文案遵循品牌人格:微光水母语气,不催促、不评判。 + */ +import type { DiveStepDef, DiveStepId, OnboardingProfile } from './types'; + +/** 首潜步骤(完整学习循环:专注 → 记录 → 复习 → 复述) */ +export const DIVE_STEPS: DiveStepDef[] = [ + { + id: 'pomodoro', + route: '/pomodoro', + title: '完成一次迷你深潜', + instruction: '先试一次 3 分钟的迷你深潜吧——不用准备什么,感受一下专注沉下去的样子。', + praise: '你完成了第一次下潜。水面之下,比想象中安静。', + }, + { + id: 'note', + route: '/notes', + title: '记下一条笔记', + instruction: '随手记点什么——刚才想到的、今天学到的,一句话就够,它会沉淀成你的第一块暗礁。', + praise: '第一块暗礁已经形成。碎片落进海里,就不会再丢了。', + }, + { + id: 'review', + route: '/flashcards', + title: '呼吸一次(复习手册卡)', + instruction: '打开《潜航员手册》复习几张卡——它会教你怎么用这片海,而且过几天还会自己回来找你。', + praise: '第一次呼吸完成。这些卡片会在你快忘记时,准时浮现。', + }, + { + id: 'feynman', + route: '/feynman', + title: '浮出水面说一句', + instruction: '最后一步:用一句话讲讲你刚才学到的东西。讲得出来,才是真的懂。', + praise: '你浮出了水面。首潜完成——这片海,现在是你的了。', + }, +]; + +/** 按步骤 id 查找定义 */ +export const getDiveStep = (id: DiveStepId): DiveStepDef => + DIVE_STEPS.find((s) => s.id === id) ?? DIVE_STEPS[0]; + +/** + * 根据画像决定首潜起始步骤(着陆之问的分流规则): + * 走神 → 深潜起步;背了就忘 → 直接从复习手册开始;讲不出来 → 费曼起步。 + * 起始步骤之前的步骤不会被跳过,只是排到循环末尾。 + */ +export const orderStepsByProfile = (profile: OnboardingProfile): DiveStepDef[] => { + const startId: DiveStepId = + profile === 'memory' ? 'review' + : profile === 'expression' ? 'feynman' + : 'pomodoro'; + const startIndex = DIVE_STEPS.findIndex((s) => s.id === startId); + return [...DIVE_STEPS.slice(startIndex), ...DIVE_STEPS.slice(0, startIndex)]; +}; + +/** 着陆之问选项文案(L0) */ +export const LANDING_OPTIONS: Array<{ + profile: OnboardingProfile; + label: string; + hint: string; +}> = [ + { profile: 'focus', label: '一学习就走神', hint: '从一次 3 分钟的迷你深潜开始' }, + { profile: 'memory', label: '背了就忘', hint: '从一副会自己回来的卡组开始' }, + { profile: 'expression', label: '学了却讲不出来', hint: '从把一件事讲清楚开始' }, + { profile: 'explore', label: '我只想先随便看看', hint: '直接进入,自由探索' }, +]; diff --git a/client/src/features/onboarding/firstDive/firstDive.test.ts b/client/src/features/onboarding/firstDive/firstDive.test.ts new file mode 100644 index 00000000..e32c6dcb --- /dev/null +++ b/client/src/features/onboarding/firstDive/firstDive.test.ts @@ -0,0 +1,165 @@ +/** + * 首潜引导系统单元测试 + * + * @ai-context: 覆盖存储迁移、种子幂等/版本追加、步骤推进纯函数。 + * 存储层全部 Mock,禁止连接真实 IndexedDB。 + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock 存储层,阻断真实 Dexie 依赖链 +vi.mock('@/lib/storage', () => ({ + db: {}, + flashcardDeckStore: {}, + flashcardStore: {}, +})); + +import { + loadFirstDiveState, + saveFirstDiveState, + createInitialState, + FIRST_DIVE_STORAGE_KEY, +} from './firstDiveStorage'; +import { seedHandbookDeck } from './seedHandbook'; +import { getCurrentStep } from './useFirstDiveStore'; +import { DIVE_STEPS, orderStepsByProfile } from './diveSteps'; +import { HANDBOOK_CARDS, HANDBOOK_DECK_ID, HANDBOOK_VERSION_KEY } from './handbookDeck'; +import type { Flashcard, FlashcardDeck } from '@/types/models'; + +beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// firstDiveStorage +// --------------------------------------------------------------------------- + +describe('firstDiveStorage', () => { + it('should return initial landing state for fresh install', () => { + // Act + const state = loadFirstDiveState(); + // Assert + expect(state.stage).toBe('landing'); + expect(state.completedSteps).toEqual([]); + }); + + it('should migrate legacy onboarding mark to done', () => { + // Arrange:旧版引导完成标记存在 + localStorage.setItem('kb-onboarding-done', 'true'); + // Act + const state = loadFirstDiveState(); + // Assert:老用户直接视为完成,且已回写新 key + expect(state.stage).toBe('done'); + expect(localStorage.getItem(FIRST_DIVE_STORAGE_KEY)).toContain('"done"'); + }); + + it('should fall back to defaults when persisted JSON is corrupted', () => { + // Arrange + localStorage.setItem(FIRST_DIVE_STORAGE_KEY, '{not-json'); + // Act + const state = loadFirstDiveState(); + // Assert:解析失败降级为 done,不反复弹引导 + expect(state.stage).toBe('done'); + }); + + it('should round-trip save and load', () => { + // Arrange + const saved = { ...createInitialState(), stage: 'diving' as const, completedSteps: ['pomodoro' as const] }; + // Act + saveFirstDiveState(saved); + const loaded = loadFirstDiveState(); + // Assert + expect(loaded.stage).toBe('diving'); + expect(loaded.completedSteps).toEqual(['pomodoro']); + }); +}); + +// --------------------------------------------------------------------------- +// seedHandbookDeck +// --------------------------------------------------------------------------- + +function createMockStores(existingDeck?: FlashcardDeck, existingCards: Flashcard[] = []) { + return { + deckStore: { + getById: vi.fn().mockResolvedValue(existingDeck), + create: vi.fn().mockResolvedValue(HANDBOOK_DECK_ID), + }, + cardStore: { + where: vi.fn().mockResolvedValue(existingCards), + create: vi.fn().mockResolvedValue('card-id'), + }, + }; +} + +const fakeDeck: FlashcardDeck = { + id: HANDBOOK_DECK_ID, name: '潜航员手册', createdAt: new Date(), updatedAt: new Date(), order: 0, +}; + +describe('seedHandbookDeck', () => { + it('should create deck and all cards on fresh install', async () => { + // Arrange + const { deckStore, cardStore } = createMockStores(undefined); + // Act + const added = await seedHandbookDeck(deckStore, cardStore); + // Assert + expect(deckStore.create).toHaveBeenCalledTimes(1); + expect(cardStore.create).toHaveBeenCalledTimes(HANDBOOK_CARDS.length); + expect(added).toBe(HANDBOOK_CARDS.length); + }); + + it('should be idempotent when deck exists and version matches', async () => { + // Arrange:牌组已存在且版本一致 + localStorage.setItem(HANDBOOK_VERSION_KEY, '999'); + const { deckStore, cardStore } = createMockStores(fakeDeck); + // Act + const added = await seedHandbookDeck(deckStore, cardStore); + // Assert:快路径,零写入 + expect(deckStore.create).not.toHaveBeenCalled(); + expect(cardStore.create).not.toHaveBeenCalled(); + expect(added).toBe(0); + }); + + it('should only append missing cards on version upgrade (protect review progress)', async () => { + // Arrange:旧版本,已有前 3 张卡 + localStorage.setItem(HANDBOOK_VERSION_KEY, '0'); + const existing = HANDBOOK_CARDS.slice(0, 3).map((c, i) => ({ + id: `old-${i}`, deckId: HANDBOOK_DECK_ID, front: c.front, back: c.back, + type: 'basic', easeFactor: 2.5, interval: 3, repetitions: 2, lapses: 0, + dueDate: new Date(), createdAt: new Date(), updatedAt: new Date(), order: i, + })) as Flashcard[]; + const { deckStore, cardStore } = createMockStores(fakeDeck, existing); + // Act + const added = await seedHandbookDeck(deckStore, cardStore); + // Assert:只补缺失的卡,不重建已有卡 + expect(added).toBe(HANDBOOK_CARDS.length - 3); + expect(deckStore.create).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// 步骤推进纯函数 +// --------------------------------------------------------------------------- + +describe('dive step ordering', () => { + it('should start from review step for memory profile', () => { + // Act + const ordered = orderStepsByProfile('memory'); + // Assert:背了就忘 → 从手册复习起步,且不丢步骤 + expect(ordered[0].id).toBe('review'); + expect(ordered).toHaveLength(DIVE_STEPS.length); + }); + + it('should return first uncompleted step in profile order', () => { + // Act & Assert + expect(getCurrentStep('focus', [])).toBe('pomodoro'); + expect(getCurrentStep('focus', ['pomodoro'])).toBe('note'); + expect(getCurrentStep('memory', [])).toBe('review'); + }); + + it('should return null when all steps completed', () => { + // Arrange + const all = DIVE_STEPS.map((s) => s.id); + // Act & Assert + expect(getCurrentStep('focus', all)).toBeNull(); + }); +}); diff --git a/client/src/features/onboarding/firstDive/firstDiveStorage.ts b/client/src/features/onboarding/firstDive/firstDiveStorage.ts new file mode 100644 index 00000000..8b09a673 --- /dev/null +++ b/client/src/features/onboarding/firstDive/firstDiveStorage.ts @@ -0,0 +1,68 @@ +/** + * 首潜状态持久化(localStorage)+ 旧引导 key 迁移 + * + * @ai-context: 副作用仅限 localStorage 读写。旧 key(kb-onboarding-done / + * kb-3d-guide-done)只读不删——保留给尚未退役的 3D 引导(P2 统一清理)。 + * @ai-context: 老用户判定不在此处(需查 IndexedDB),见 useFirstDiveStore.bootstrap。 + */ +import type { FirstDiveStateV2 } from './types'; + +export const FIRST_DIVE_STORAGE_KEY = 'kb-onboarding-v2'; + +/** 旧版引导完成标记(任一存在即视为老用户,跳过 L0/L1) */ +const LEGACY_KEYS = ['kb-onboarding-done', 'kb-3d-guide-done'] as const; + +export const createInitialState = (): FirstDiveStateV2 => ({ + version: 1, + stage: 'landing', + profile: null, + completedSteps: [], + baselines: {}, +}); + +/** 是否存在旧版引导完成标记 */ +export function hasLegacyOnboardingMark(): boolean { + try { + return LEGACY_KEYS.some((k) => localStorage.getItem(k) === 'true'); + } catch { + return false; + } +} + +/** + * 读取首潜状态。 + * 无记录时:旧标记存在 → 直接视为 done(老用户不打扰);否则返回初始态。 + */ +export function loadFirstDiveState(): FirstDiveStateV2 { + try { + const raw = localStorage.getItem(FIRST_DIVE_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw) as Partial; + // 结构校验:字段缺失时回退默认值,保证向后兼容 + return { + version: 1, + stage: parsed.stage ?? 'landing', + profile: parsed.profile ?? null, + completedSteps: Array.isArray(parsed.completedSteps) ? parsed.completedSteps : [], + baselines: parsed.baselines ?? {}, + }; + } + if (hasLegacyOnboardingMark()) { + const migrated: FirstDiveStateV2 = { ...createInitialState(), stage: 'done' }; + saveFirstDiveState(migrated); + return migrated; + } + return createInitialState(); + } catch { + // localStorage 不可用(隐私模式等)时降级为已完成,避免反复弹引导 + return { ...createInitialState(), stage: 'done' }; + } +} + +export function saveFirstDiveState(state: FirstDiveStateV2): void { + try { + localStorage.setItem(FIRST_DIVE_STORAGE_KEY, JSON.stringify(state)); + } catch { + // 写入失败静默:引导状态丢失的代价仅是重新展示 + } +} diff --git a/client/src/features/onboarding/firstDive/handbookDeck.ts b/client/src/features/onboarding/firstDive/handbookDeck.ts new file mode 100644 index 00000000..f1a192e3 --- /dev/null +++ b/client/src/features/onboarding/firstDive/handbookDeck.ts @@ -0,0 +1,62 @@ +/** + * 《潜航员手册》自举卡组 · 纯数据 + * + * @ai-context: 手册卡组是"产品自己教自己"的载体——新用户复习这副卡 + * 即同时学会软件用法与间隔重复方法论。卡组 ID 固定,种子写入幂等。 + * @ai-context: 版本更新机制——HANDBOOK_VERSION 递增时,seedHandbook + * 只追加 front 不存在的新卡,绝不覆盖用户已有的复习进度。 + * 修改已发布卡片的 front 文本等于新增一张卡,需慎重。 + */ + +/** 固定牌组 ID(跨版本稳定,种子幂等判断依据) */ +export const HANDBOOK_DECK_ID = 'builtin-handbook-deck'; + +/** 手册内容版本(追加新卡时 +1),存于 localStorage kb-handbook-version */ +export const HANDBOOK_VERSION = 1; + +export const HANDBOOK_VERSION_KEY = 'kb-handbook-version'; + +export const HANDBOOK_DECK_NAME = '潜航员手册'; + +export const HANDBOOK_DECK_DESCRIPTION = + '沉船遗物 · 这副卡会教你如何使用熵减——当它隔几天再次浮现时,你也在亲历间隔重复的原理。'; + +export interface HandbookCard { + front: string; + back: string; +} + +export const HANDBOOK_CARDS: HandbookCard[] = [ + { + front: '「深潜」(专注番茄钟)帮你解决什么?', + back: '把学习切成一段段可坚持的专注(如 25 分钟),专注时白噪音隔绝干扰,结束后强制短休——对抗"一学习就走神"。', + }, + { + front: '为什么番茄钟的休息不是浪费时间?', + back: '大脑在休息时才完成记忆巩固。短休 5 分钟是专注循环的一部分,跳过休息反而让后续专注质量下降。', + }, + { + front: '「结礁」(笔记)和普通笔记软件有什么不同?', + back: '结礁的笔记是活的:AI 可以把一段笔记一键"结晶"成闪卡,进入复习循环——记下来只是开始,记得住才是目的。', + }, + { + front: '一张闪卡什么时候会再次出现?', + back: '由间隔重复算法决定:答得越熟,下次出现隔得越久(1 天 → 3 天 → 1 周…)。就像这张卡,它会在你快忘记时准时回来。', + }, + { + front: '「浮出水面」(费曼学习法)为什么要求你讲出来?', + back: '能用自己的话讲清楚,才是真的懂。讲不下去的地方就是知识盲区——AI 会扮演听众帮你找到它。', + }, + { + front: '复习时忘了很多,正常吗?', + back: '完全正常。遗忘是大脑的默认行为,间隔重复正是利用"快忘时复习"来加固记忆。没关系,暗流很正常。', + }, + { + front: '我的学习数据存在哪里?', + back: '全部在你自己的电脑里(本地优先)。不联网也能用;云同步是可选项,由你决定。', + }, + { + front: '卡住了、找不到功能怎么办?', + back: '按 Ctrl + / 随时打开帮助中心:快速上手、快捷键、模块详解、常见问题都在里面。', + }, +]; diff --git a/client/src/features/onboarding/firstDive/moduleSubtitles.ts b/client/src/features/onboarding/firstDive/moduleSubtitles.ts new file mode 100644 index 00000000..c3ce22d5 --- /dev/null +++ b/client/src/features/onboarding/firstDive/moduleSubtitles.ts @@ -0,0 +1,19 @@ +/** + * 模块副标题映射 — 新手期双标签的唯一数据源 + * + * @ai-context: 隐喻命名(深潜/结礁…)无法自解释,是新手墙之一; + * 新手期(首潜未完成)各导航面在模块名旁附直白副标题。 + * 所有导航面(3D 标签 / 移动端网格 / 侧边栏)统一从此处取值,禁止各自硬编码。 + */ +export const MODULE_SUBTITLES: Record = { + pomodoro: '专注番茄钟', + notes: '学习笔记', + flashcards: '记忆闪卡', + feynman: '费曼讲解', + inspiration: '灵感收集', + classroom: '课堂采集', +}; + +/** 取模块副标题;无副标题(如首页)返回 undefined */ +export const getModuleSubtitle = (moduleId: string): string | undefined => + MODULE_SUBTITLES[moduleId]; diff --git a/client/src/features/onboarding/firstDive/seedHandbook.ts b/client/src/features/onboarding/firstDive/seedHandbook.ts new file mode 100644 index 00000000..754ac690 --- /dev/null +++ b/client/src/features/onboarding/firstDive/seedHandbook.ts @@ -0,0 +1,98 @@ +/** + * 《潜航员手册》种子服务 — 幂等写入 + 版本追加 + * + * @ai-context: 副作用——写入 flashcardDecks/flashcards 表。幂等性靠固定 + * HANDBOOK_DECK_ID 与"front 去重追加"保证;绝不修改/删除已有卡片, + * 保护用户复习进度(FSRS/SM-2 调度字段)。 + * @ai-context: 依赖注入——stores 通过参数传入,测试时传 Mock, + * 禁止测试直连真实 IndexedDB。 + */ +import { + flashcardDeckStore as defaultDeckStore, + flashcardStore as defaultCardStore, +} from '@/lib/storage'; +import { getScheduler } from '@/lib/schedulingFactory'; +import type { Flashcard, FlashcardDeck } from '@/types/models'; +import { + HANDBOOK_DECK_ID, + HANDBOOK_DECK_NAME, + HANDBOOK_DECK_DESCRIPTION, + HANDBOOK_CARDS, + HANDBOOK_VERSION, + HANDBOOK_VERSION_KEY, +} from './handbookDeck'; + +interface DeckStoreLike { + getById(id: string): Promise; + create(item: FlashcardDeck): Promise; +} + +interface CardStoreLike { + where(index: string, value: string): Promise; + create(item: Flashcard): Promise; +} + +/** 构建一张手册闪卡(调度字段用当前算法的新卡初始态) */ +function buildHandbookCard(front: string, back: string): Flashcard { + const now = new Date(); + const init = getScheduler().createNew(); + return { + id: crypto.randomUUID(), + deckId: HANDBOOK_DECK_ID, + front, + back, + type: 'basic', + easeFactor: init.easeFactor, + interval: init.interval, + repetitions: init.repetitions, + lapses: init.lapses, + dueDate: init.dueDate, + stability: init.stability, + difficulty: init.difficulty, + createdAt: now, + updatedAt: now, + order: Date.now(), + }; +} + +/** + * 幂等种子:牌组不存在则创建;卡片按 front 去重追加。 + * 版本一致且牌组已存在时直接跳过(快路径,避免每次启动查卡片表)。 + * + * @returns 本次新写入的卡片数 + */ +export async function seedHandbookDeck( + deckStore: DeckStoreLike = defaultDeckStore, + cardStore: CardStoreLike = defaultCardStore, +): Promise { + const existingDeck = await deckStore.getById(HANDBOOK_DECK_ID); + const storedVersion = Number(localStorage.getItem(HANDBOOK_VERSION_KEY) ?? '0'); + + if (existingDeck && storedVersion >= HANDBOOK_VERSION) return 0; + + if (!existingDeck) { + const now = new Date(); + await deckStore.create({ + id: HANDBOOK_DECK_ID, + name: HANDBOOK_DECK_NAME, + description: HANDBOOK_DECK_DESCRIPTION, + color: '#06B6D4', // 赛博青 — 微光水母的颜色 + createdAt: now, + updatedAt: now, + order: 0, // 排在最前,新用户第一眼可见 + }); + } + + // 版本追加:只补 front 不存在的卡,保护已有复习进度 + const existingCards = await cardStore.where('deckId', HANDBOOK_DECK_ID); + const existingFronts = new Set(existingCards.map((c) => c.front)); + let added = 0; + for (const card of HANDBOOK_CARDS) { + if (existingFronts.has(card.front)) continue; + await cardStore.create(buildHandbookCard(card.front, card.back)); + added += 1; + } + + localStorage.setItem(HANDBOOK_VERSION_KEY, String(HANDBOOK_VERSION)); + return added; +} diff --git a/client/src/features/onboarding/firstDive/types.ts b/client/src/features/onboarding/firstDive/types.ts new file mode 100644 index 00000000..a57cca32 --- /dev/null +++ b/client/src/features/onboarding/firstDive/types.ts @@ -0,0 +1,51 @@ +/** + * 「首潜」新手引导系统 · 领域类型 + * + * @ai-context: kb-onboarding-v2 是统一的新手引导状态(替代散落的 + * kb-onboarding-done / kb-3d-guide-done 双 key),结构变更需保持向后兼容 + * (新增字段给默认值,禁止改名/删除已有字段)。 + * @ai-context: 画像(profile)仅存本地 localStorage,不上云(隐私决策, + * 见 docs/Foresight/first-dive-onboarding-brainstorm.md §6)。 + */ + +/** 首潜整体阶段 */ +export type FirstDiveStage = + | 'landing' // 待回答着陆之问(L0) + | 'diving' // 首潜进行中(L1) + | 'done' // 已完成 + | 'skipped'; // 已跳过 + +/** 着陆之问的用户画像(学习困扰类型) */ +export type OnboardingProfile = + | 'focus' // 一学习就走神 → 从深潜开始 + | 'memory' // 背了就忘 → 从闪卡开始 + | 'expression' // 学了讲不出来 → 从费曼开始 + | 'explore'; // 随便看看 → 跳过首潜 + +/** 首潜步骤标识(顺序由 DIVE_STEPS 决定) */ +export type DiveStepId = 'pomodoro' | 'note' | 'review' | 'feynman'; + +/** 持久化到 localStorage 的首潜状态(key: kb-onboarding-v2) */ +export interface FirstDiveStateV2 { + /** 状态结构版本,用于将来迁移 */ + version: 1; + stage: FirstDiveStage; + profile: OnboardingProfile | null; + /** 已完成的首潜步骤 */ + completedSteps: DiveStepId[]; + /** 首潜开始时各数据表的基线计数(用于检测"新产生的第一条数据") */ + baselines: Partial>; +} + +/** 单个首潜步骤的静态定义 */ +export interface DiveStepDef { + id: DiveStepId; + /** 需要引导用户前往的路由 */ + route: string; + /** 步骤短标题(潜航日志用) */ + title: string; + /** 微光的引导语(品牌人格:不催促、不评判) */ + instruction: string; + /** 完成时微光的回应 */ + praise: string; +} diff --git a/client/src/features/onboarding/firstDive/useFirstDiveStore.ts b/client/src/features/onboarding/firstDive/useFirstDiveStore.ts new file mode 100644 index 00000000..83e6a0fa --- /dev/null +++ b/client/src/features/onboarding/firstDive/useFirstDiveStore.ts @@ -0,0 +1,169 @@ +/** + * 首潜状态机 Store(Zustand) + * + * @ai-context: 步骤完成检测 = 数据基线差值——首潜开始时记录四张表的 + * 计数基线,轮询发现某表计数超过基线即判定对应步骤完成。 + * 好处:只认"真实产生的数据",与各模块 UI 零耦合。 + * @ai-context: 副作用——localStorage 持久化(firstDiveStorage)、 + * IndexedDB 计数(fetchStepCounts)、手册种子(seedHandbookDeck)。 + * 判定老用户(任一核心表非空)时跳过 L0/L1 且不种手册,不打扰存量用户。 + */ +import { create } from 'zustand'; +import { db } from '@/lib/storage'; +import type { DiveStepId, FirstDiveStage, FirstDiveStateV2, OnboardingProfile } from './types'; +import { DIVE_STEPS, orderStepsByProfile } from './diveSteps'; +import { loadFirstDiveState, saveFirstDiveState } from './firstDiveStorage'; +import { seedHandbookDeck } from './seedHandbook'; + +/** 各首潜步骤对应的数据表计数(基线差值检测的数据源) */ +export type StepCounts = Record; + +/** 读取四张表的当前计数(依赖注入 database 便于测试 Mock) */ +export async function fetchStepCounts(database: typeof db = db): Promise { + const [pomodoro, note, review, feynman] = await Promise.all([ + database.pomodoroSessions.count(), + database.notes.count(), + database.flashcardReviews.count(), + database.feynmanNotes.count(), + ]); + return { pomodoro, note, review, feynman }; +} + +interface FirstDiveStoreState { + stage: FirstDiveStage; + profile: OnboardingProfile | null; + completedSteps: DiveStepId[]; + baselines: Partial>; + /** bootstrap 是否已执行(防止重复初始化) */ + isReady: boolean; + /** 刚完成的步骤(供 UI 展示 praise 文案,展示后清空) */ + justCompleted: DiveStepId | null; + + /** 启动引导:迁移旧标记、判定老用户、种手册 */ + bootstrap: () => Promise; + /** 回答着陆之问(L0) */ + answerLanding: (profile: OnboardingProfile) => Promise; + /** 轮询检测步骤完成(L1) */ + checkProgress: () => Promise; + /** 跳过首潜 */ + skipDive: () => void; + /** 清除 praise 展示标记 */ + clearJustCompleted: () => void; +} + +/** 当前应执行的步骤(按画像排序后第一个未完成项) */ +export function getCurrentStep( + profile: OnboardingProfile | null, + completedSteps: DiveStepId[], +): DiveStepId | null { + const ordered = profile ? orderStepsByProfile(profile) : DIVE_STEPS; + const next = ordered.find((s) => !completedSteps.includes(s.id)); + return next?.id ?? null; +} + +const persist = (state: Pick) => + saveFirstDiveState({ version: 1, ...state }); + +export const useFirstDiveStore = create((set, get) => ({ + stage: 'done', // bootstrap 前默认不打扰,避免闪烁 + profile: null, + completedSteps: [], + baselines: {}, + isReady: false, + justCompleted: null, + + bootstrap: async () => { + if (get().isReady) return; + const persisted = loadFirstDiveState(); + + if (persisted.stage === 'landing') { + // 老用户判定:任一核心表已有数据 → 视为已完成,不种手册、不弹引导 + try { + const counts = await fetchStepCounts(); + const hasData = counts.pomodoro > 0 || counts.note > 0 || counts.feynman > 0 + || (await db.flashcardDecks.count()) > 0; + if (hasData) { + const done: FirstDiveStateV2 = { ...persisted, stage: 'done' }; + persist(done); + set({ ...done, isReady: true }); + return; + } + // 全新用户:种入《潜航员手册》(幂等) + await seedHandbookDeck(); + } catch { + // 数据层异常时不阻塞应用启动,按已完成处理 + set({ stage: 'done', isReady: true }); + return; + } + } + + set({ + stage: persisted.stage, + profile: persisted.profile, + completedSteps: persisted.completedSteps, + baselines: persisted.baselines, + isReady: true, + }); + }, + + answerLanding: async (profile) => { + if (profile === 'explore') { + const next = { stage: 'skipped' as const, profile, completedSteps: [], baselines: {} }; + persist(next); + set(next); + return; + } + // 记录基线:首潜只认"此后新产生"的数据 + let baselines: Partial> = {}; + try { + baselines = await fetchStepCounts(); + } catch { + // 计数失败时基线为空(0),首潜仍可进行 + } + const next = { stage: 'diving' as const, profile, completedSteps: [] as DiveStepId[], baselines }; + persist(next); + set(next); + }, + + checkProgress: async () => { + const { stage, profile, completedSteps, baselines } = get(); + if (stage !== 'diving') return; + + let counts: StepCounts; + try { + counts = await fetchStepCounts(); + } catch { + return; // 单次轮询失败静默,下次再试 + } + + const current = getCurrentStep(profile, completedSteps); + if (!current) return; + + // 仅推进"当前步骤":保证引导节奏线性,避免用户乱序操作导致跳步混乱 + if (counts[current] > (baselines[current] ?? 0)) { + const nextCompleted = [...completedSteps, current]; + const allDone = nextCompleted.length >= DIVE_STEPS.length; + const next = { + stage: (allDone ? 'done' : 'diving') as FirstDiveStage, + profile, + completedSteps: nextCompleted, + baselines, + }; + persist(next); + set({ ...next, justCompleted: current }); + } + }, + + skipDive: () => { + const { profile, completedSteps, baselines } = get(); + const next = { stage: 'skipped' as const, profile, completedSteps, baselines }; + persist(next); + set(next); + }, + + clearJustCompleted: () => set({ justCompleted: null }), +})); + +/** 新手期判定:双标签副标题等新手辅助 UI 的显隐依据 */ +export const useIsNewbiePhase = (): boolean => + useFirstDiveStore((s) => s.isReady && s.stage !== 'done'); diff --git a/client/src/lib/3d/navigation/OrbitalStore.ts b/client/src/lib/3d/navigation/OrbitalStore.ts index af82c5b7..e634b48e 100644 --- a/client/src/lib/3d/navigation/OrbitalStore.ts +++ b/client/src/lib/3d/navigation/OrbitalStore.ts @@ -32,7 +32,7 @@ export const MODULE_POSITIONS: ModulePosition[] = [ { id: 'pomodoro', position: [4, 2, -2], route: '/pomodoro', label: '深潜' }, { id: 'notes', position: [-4, 1, -1], route: '/notes', label: '结礁' }, { id: 'flashcards', position: [3, -2, -3], route: '/flashcards', label: '闪卡' }, - { id: 'feynman', position: [-3, -1, -4], route: '/feynman', label: '反衰减呼吸' }, + { id: 'feynman', position: [-3, -1, -4], route: '/feynman', label: '浮出水面' }, { id: 'inspiration', position: [0, 3, -5], route: '/inspiration', label: '萤火海沟' }, { id: 'classroom', position: [-2, -3, -2], route: '/classroom', label: '回声定位' }, ]; diff --git a/client/src/lib/3d/objects/AuroraModuleEntity.tsx b/client/src/lib/3d/objects/AuroraModuleEntity.tsx index 4afab0c2..6b0fe557 100644 --- a/client/src/lib/3d/objects/AuroraModuleEntity.tsx +++ b/client/src/lib/3d/objects/AuroraModuleEntity.tsx @@ -9,6 +9,8 @@ import { useFrame } from '@react-three/fiber'; import * as THREE from 'three'; import { Float, Html } from '@react-three/drei'; import type { ModuleId } from '../navigation/OrbitalStore'; +import { useIsNewbiePhase } from '@/features/onboarding/firstDive/useFirstDiveStore'; +import { getModuleSubtitle } from '@/features/onboarding/firstDive/moduleSubtitles'; export interface AuroraModuleEntityProps { id: ModuleId; @@ -33,7 +35,7 @@ const PLANET_CONFIGS: Record = { pomodoro: { radius: 0.7, color: '#F97316', emissive: '#EA580C', label: '深潜' }, notes: { radius: 0.7, color: '#60A5FA', emissive: '#3B82F6', label: '结礁' }, flashcards: { radius: 0.5, color: '#34D399', emissive: '#059669', label: '闪卡' }, - feynman: { radius: 0.6, color: '#A78BFA', emissive: '#7C3AED', label: '反衰减呼吸' }, + feynman: { radius: 0.6, color: '#A78BFA', emissive: '#7C3AED', label: '浮出水面' }, inspiration: { radius: 0.4, color: '#F472B6', emissive: '#EC4899', label: '萤火海沟' }, classroom: { radius: 0.55, color: '#14B8A6', emissive: '#0D9488', label: '回声定位' }, }; @@ -53,6 +55,9 @@ export function AuroraModuleEntity({ const ringRef = useRef(null); const [hovered, setHovered] = useState(false); const angleRef = useRef(initialAngle); + // 新手期双标签(首潜完成后自动隐去) + const isNewbie = useIsNewbiePhase(); + const subtitle = getModuleSubtitle(id); const config = PLANET_CONFIGS[id]; @@ -168,6 +173,10 @@ export function AuroraModuleEntity({ >
{config.label} + {/* 新手期双标签:隐喻名旁附直白副标题 */} + {isNewbie && subtitle && ( + · {subtitle} + )}
)} diff --git a/client/src/lib/3d/objects/ModuleEntity.tsx b/client/src/lib/3d/objects/ModuleEntity.tsx index 5b9b5ff3..ce230729 100644 --- a/client/src/lib/3d/objects/ModuleEntity.tsx +++ b/client/src/lib/3d/objects/ModuleEntity.tsx @@ -8,6 +8,8 @@ import { useRef } from 'react'; import { useFrame } from '@react-three/fiber'; import * as THREE from 'three'; import { Float, Html } from '@react-three/drei'; +import { useIsNewbiePhase } from '@/features/onboarding/firstDive/useFirstDiveStore'; +import { getModuleSubtitle } from '@/features/onboarding/firstDive/moduleSubtitles'; type GeometryType = 'dodecahedron' | 'torus' | 'box' | 'sphere' | 'octahedron' | 'icosahedron'; @@ -80,6 +82,9 @@ export function ModuleEntity({ }: ModuleEntityProps) { const meshRef = useRef(null); const materialRef = useRef(null); + // 新手期双标签(首潜完成后自动隐去) + const isNewbie = useIsNewbiePhase(); + const subtitle = getModuleSubtitle(id); // Each entity has a unique rotation axis and speed const rotationConfig = useRef({ @@ -155,6 +160,10 @@ export function ModuleEntity({ >
{label} + {/* 新手期双标签:隐喻名旁附直白副标题 */} + {isNewbie && subtitle && ( + · {subtitle} + )}
)} diff --git a/client/src/lib/3d/scenes/MobileNavGrid.tsx b/client/src/lib/3d/scenes/MobileNavGrid.tsx index 3d15846e..7953d3da 100644 --- a/client/src/lib/3d/scenes/MobileNavGrid.tsx +++ b/client/src/lib/3d/scenes/MobileNavGrid.tsx @@ -11,6 +11,8 @@ import { motion } from 'framer-motion'; import { Timer, FileText, Layers, Lightbulb, Sparkles, Clapperboard, BarChart3 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { soundPlayer } from '@/lib/audio/SoundPlayer'; +import { useIsNewbiePhase } from '@/features/onboarding/firstDive/useFirstDiveStore'; +import { getModuleSubtitle } from '@/features/onboarding/firstDive/moduleSubtitles'; const modules = [ { id: 'dashboard', label: '首页', route: '/', icon: BarChart3, color: 'from-indigo-500/20 to-indigo-600/10', iconColor: 'text-indigo-400' }, @@ -38,6 +40,8 @@ const itemVariants = { export function MobileNavGrid() { const navigate = useNavigate(); const { pathname } = useLocation(); + // 新手期双标签(首潜完成后自动隐去) + const isNewbie = useIsNewbiePhase(); return (
@@ -107,6 +111,9 @@ export function MobileNavGrid() {
{mod.label} + {isNewbie && getModuleSubtitle(mod.id) && ( + {getModuleSubtitle(mod.id)} + )} ); })} From b46be5db8d805cfd0c7f007995329807ef4cb949 Mon Sep 17 00:00:00 2001 From: Aparencia Date: Fri, 31 Jul 2026 22:12:18 +0800 Subject: [PATCH 7/7] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E7=9F=A5?= =?UTF-8?q?=E8=AF=86=E5=8D=A1=E7=89=87=E3=80=81=E5=86=85=E6=B5=8B=E8=BF=90?= =?UTF-8?q?=E8=90=A5=E6=96=87=E6=A1=A3=E4=B8=8E=E5=89=8D=E7=9E=BB=E6=80=A7?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E5=A4=B4=E8=84=91=E9=A3=8E=E6=9A=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 知识卡片:番茄钟计数、Tailwind var 透明度修饰符两则踩坑记录 + 索引更新 - 内测运营:协议、招募公告与playbook、分层管理、内测者简介 - Foresight:AI 时代竞争力策略、新手引导与番茄钟自定义头脑风暴、SOP 设计 --- .../ai-era-competitiveness-strategy.md | 225 ++++++++++ .../first-dive-onboarding-brainstorm.md | 192 ++++++++ .../pomodoro-customization-brainstorm.md | 179 ++++++++ docs/Foresight/sop-custom-design.md | 368 ++++++++++++++++ docs/Foresight/sop-module-brainstorm.md | 413 ++++++++++++++++++ docs/README.md | 5 + ...-count-reset-and-duplicate-side-effects.md | 61 +++ ...tailwind-var-alpha-modifier-silent-drop.md | 67 +++ docs/knowledge/index.md | 8 +- docs/product/beta-agreement.md | 72 +++ docs/product/beta-agreement_facetouser.md | 68 +++ ...eta-recruitment-announcement facetouese.md | 80 ++++ docs/product/beta-recruitment-announcement.md | 83 ++++ docs/product/beta-recruitment-playbook.md | 281 ++++++++++++ docs/product/beta-tester-intro.md | 34 ++ docs/product/beta-tier-management.md | 151 +++++++ ...344\273\213\351\207\215\345\210\2662.docx" | Bin 0 -> 16273 bytes 17 files changed, 2285 insertions(+), 2 deletions(-) create mode 100644 docs/Foresight/ai-era-competitiveness-strategy.md create mode 100644 docs/Foresight/first-dive-onboarding-brainstorm.md create mode 100644 docs/Foresight/pomodoro-customization-brainstorm.md create mode 100644 docs/Foresight/sop-custom-design.md create mode 100644 docs/Foresight/sop-module-brainstorm.md create mode 100644 docs/knowledge/bugs/2026-07-pomodoro-count-reset-and-duplicate-side-effects.md create mode 100644 docs/knowledge/bugs/2026-07-tailwind-var-alpha-modifier-silent-drop.md create mode 100644 docs/product/beta-agreement.md create mode 100644 docs/product/beta-agreement_facetouser.md create mode 100644 docs/product/beta-recruitment-announcement facetouese.md create mode 100644 docs/product/beta-recruitment-announcement.md create mode 100644 docs/product/beta-recruitment-playbook.md create mode 100644 docs/product/beta-tester-intro.md create mode 100644 docs/product/beta-tier-management.md create mode 100644 "docs/product/\350\275\257\344\273\266\347\256\200\344\273\213\351\207\215\345\210\2662.docx" diff --git a/docs/Foresight/ai-era-competitiveness-strategy.md b/docs/Foresight/ai-era-competitiveness-strategy.md new file mode 100644 index 00000000..0d8db741 --- /dev/null +++ b/docs/Foresight/ai-era-competitiveness-strategy.md @@ -0,0 +1,225 @@ +# AI 时代竞争力战略分析:熵减如何避免被通用 AI 淘汰 + +> **状态**: 前瞻战略(Foresight,滚动更新) +> **日期**: 2026-07-30 +> **范围**: 全产品线(客户端 AI 集成层 / AI 网关 / 数据层 / 六大功能模块) +> **关联代码**: `client/src/lib/ai/`(AIPluginLoader / offlineAIQueue / hooks)、`client/electron/ai/`(15 个 IPC handler + Ollama)、`server/ai-gateway/`(23 Chains / 4 Providers / fallback 链) +> **关联文档**: `docs/product/pain-points.md`(47 痛点)、`docs/product/requirements-pool.md`(FEAT-001~052) + +--- + +## 核心判断 + +**通用 AI(ChatGPT/Claude/Gemini 类产品)会吞掉"问答",但吞不掉"学习过程"。** + +熵减的生存空间不在"AI 能回答什么",而在三件通用 AI 结构性做不到的事: + +1. **过程数据闭环** — 通用 AI 不知道你上周专注了几个番茄钟、哪张闪卡是"高自信错误"、费曼解释卡在哪一步。熵减的 SQLite 本地库(`pomodoro_sessions` / `flashcard_reviews` / `feynman_weak_points` / `predictions`)持续积累这些数据,AI 功能全部消费它们。 +2. **反效率的教学法立场** — 通用 AI 的产品目标是"最快给答案",这恰恰摧毁学习(能力错觉 P18、被动接收 P4)。熵减的 AI 被刻意设计为"不给答案":苏格拉底追问、费曼反问、三级救援递进提示。这是产品哲学层面的护城河,通用 AI 不会自我阉割去做。 +3. **本地优先 + 永不失败的降级** — 5 层降级体系(缓存 → 本地 Ollama → 云端多 Provider 链 → FallbackProvider → 本地规则引擎)保证学习流程不因 AI 服务波动中断。学习是每日习惯,可靠性比智能上限更重要。 + +--- + +## 一、AI 功能的差异化优势分析 + +### 1.1 现有 22 个 AI 功能的差异化分级 + +以"通用 AI 是否能 5 分钟内复制该体验"为标尺,将现有功能分为三档: + +| 档位 | 功能 | 差异化来源 | 代码证据 | +|---|---|---|---| +| **A 档:结构性差异(护城河)** | 番茄钟推荐、学习预测、卡壳救援、高自信错误追踪、仪式回顾 | 消费**本地行为数据**(专注历史、复习记录、卡壳时长),通用 AI 无此数据 | `recommendDuration` 消费 `pomodoro_sessions`;`predictions` 表记录预测-验证闭环;`FallbackProvider.recommend_duration_fallback` 甚至离线也能基于历史推荐 | +| **B 档:流程性差异(半护城河)** | 苏格拉底四模式(brainstorm/question/evaluate/deepening)、费曼反问+评估、生成式复习、灵感分拣 | AI 被嵌入**固定教学法流程**中,价值在流程编排而非单次生成 | `useAISocratic` 的四状态机编排;`socratic_evaluate` 四维度评分;FEAT-022"深潜探索"四步隐喻 | +| **C 档:功能性同质(易被替代)** | 摘要、闪卡生成、内容打标、闪卡优化、视觉提取、语音转写 | 任何通用 AI 都能做,且模型越强做得越好 | `summarize_chain` / `card_gen_chain` 等本质是 prompt 包装 | + +**结论**:C 档功能不是护城河但是入口——它们把用户内容"喂进"数据层,转化为 A 档功能的燃料。战略上应把 C 档定位为"数据采集器",把资源倾斜到 A/B 档。 + +### 1.2 与通用 AI 工具的本质区别 + +以苏格拉底模块为例(`useAISocratic.ts`): + +- **通用 AI**:用户问 → AI 答 → 用户"觉得懂了"(制造能力错觉 P18) +- **熵减**:用户选主题 → AI 头脑风暴激活先验 → AI 追问逼用户输出 → 四维度评分暴露盲区 → 深化角度引导下一轮。**用户是输出方,AI 是提问方**——角色反转是与 ChatGPT 的根本区别。 + +同理,费曼模块的 `feynman_weak_points` 表持久化"讲不清楚的点",卡壳救援(`rescue_chain`)实现"简化解释 → 拆解子概念 → 推荐前置知识"三级递进而非直接给答案(最近发展区理论)。这些都是**把认知科学编译成产品流程**,而流程无法被单条 prompt 复制。 + +### 1.3 需要警惕的侵蚀方向 + +- 通用 AI 正在补齐"记忆"能力(长期 memory、项目上下文)。若 ChatGPT 记住了用户的学习历史,A 档优势会被稀释。**对策**:本地数据的粒度(每次复习的 ease_factor 变化、每个番茄钟的中断记录)远细于对话记忆能捕捉的,应加速把细粒度数据转化为 AI 上下文(见第五节路线图)。 +- Anki + AI 插件、Notion AI、飞书知识问答等垂直组合正在逼近 B 档。**对策**:熵减的组合优势是六模块联动(番茄钟-笔记-闪卡-费曼-灵感-课堂在同一数据层),单点工具无法复制跨模块数据流。 + +--- + +## 二、技术架构适应性评估 + +### 2.1 现有架构的"AI 变革免疫力"——已做对的部分 + +| 架构设计 | 应对的 AI 变革风险 | 实现位置 | +|---|---|---| +| **Provider 抽象 + 路由表** | 模型迭代快、价格战、单厂商停服 | `AI_PROVIDERS` + `MODEL_ROUTING` 表驱动,换模型改配置不改代码;`_resolve_model_name` 支持槽位逐级回退 | +| **Fallback 链 + 超时预算** | 云端服务不稳定 | `call_with_fallback` 每功能独立降级链,整链共享 `TIMEOUT_CONFIG*1.5` 预算 | +| **双轨执行(插件模式)** | 本地模型能力爆发(Qwen3/Llama 系小模型已可用) | `aiPluginProvider` 按环境选 `ElectronAIPlugin`(IPC→Ollama 优先)或 `RemoteAIPlugin`(直连网关) | +| **离线队列** | 网络不可靠场景(学生宿舍/移动网络) | `offlineAIQueue` 独立 Dexie 实例,FIFO + 指数退避重放 | +| **用户自带 Key** | 成本转嫁与高级用户自主权 | `get_provider_for_request` 动态实例化,用户 Key 失败自动降级服务端链 | +| **Chain 模式** | 功能快速增删 | 23 个 Chain 各自封装 prompt/解析/容错,新增功能不影响存量 | + +这套架构在"模型是易变依赖"这一点上判断正确,**多 Provider 抽象层就是 AI 时代的保险单**。 + +### 2.2 架构短板与演进方向 + +**短板 1:Chain 是"单次调用"范式,缺少 Agent 范式支撑。** +23 条 Chain 全部是"一问一答"结构(prompt in → JSON out)。AI 技术趋势正从单次调用转向多步 Agent(规划-执行-反思循环)。例如"AI 学习规划师"(FEAT-046:诊断水平→生成路径→预填框架→每日计划)需要跨多次 LLM 调用、读写本地数据的编排能力,现有 Chain 抽象承载不了。 +→ **建议**:在网关层引入轻量编排层(不必上重框架,可基于现有 Chain 组合出 `Workflow` 概念:Chain 序列 + 条件分支 + 本地数据读写钩子),先用 FEAT-046 和"课程归档"(P15)两个场景验证。 + +**短板 2:离线队列只适配"可延迟"请求,与交互式 AI 冲突。** +`offlineAIQueue` 的注释写明适用 feature 是 `summarize/generate-cards/evaluate/recommend`——这些结果可以晚点到。但苏格拉底追问、卡壳救援是强交互场景,排队重放没有意义(用户已离开对话)。 +→ **建议**:明确两类 AI 请求的架构分野:**可延迟类**走离线队列(现状保持);**交互类**的离线出路是本地 Ollama——将 `ElectronAIPlugin` 的"Ollama 优先"策略升级为"断网时交互类功能自动切本地小模型 + UI 明示降级",把 BIZ-012(离线体验完整性)落到 AI 功能上。 + +**短板 3:本地数据未向量化,RAG 能力缺位。** +`search_index` 表是分词检索,FEAT-044(RAG 认知卸载网格)、FEAT-045(活知识星图)、FEAT-037(跨源知识缝合)全部依赖本地向量检索,目前无 embedding 基础设施。 +→ **建议**:这是**下一个必须补的架构层**。方案:Electron 主进程内嵌轻量向量方案(sqlite-vec 与现有 better-sqlite3 同栈,或 Ollama embedding 模型本地生成向量),保持本地优先原则——用户知识库向量永不出本地。 + +**短板 4:缓存策略过于保守。** +`CACHE_TTL_5MIN`(300 秒)对"相同笔记生成闪卡"这类幂等请求太短,白白消耗 token 成本。 +→ **建议**:按功能分层 TTL——内容哈希不变的生成类请求(摘要/闪卡/锚点)可缓存 24h+ 并落 IndexedDB 持久化;交互类维持现状。这直接降低网关成本,支撑免费额度策略(BIZ-002)。 + +**短板 5:`fallback_provider.py` 的场景判断依赖关键词嗅探。** +`generate()` 用 `if "摘要" in combined` 判断场景,脆弱且已有 22 个 feature。网关的 `_FEATURE_CONTEXT` 上下文变量已存在,应把 feature 标识直接传入 FallbackProvider,用查表替代关键词匹配——顺带为每个 feature 定制更有教学价值的降级内容(降级文案本身就是"离线教练",见 3.4)。 + +--- + +## 三、用户体验创新:AI 增强而非替代 + +### 3.1 指导原则:"AI 隐入流程,用户保持主体" + +现有实现已体现该原则的雏形(`useAISocratic` 中 AI 只在流程节点出现;降级文案引导用户"自问为什么"而非道歉了事)。应把它升格为明文设计律: + +> **任何 AI 功能上线前必答:这个功能让用户思考得更多还是更少?答案是"更少"的,重新设计。** + +### 3.2 专注力管理:从"推荐时长"到"节律感知" + +现状:`recommendDuration` 基于历史平均值推荐时长(云端 AI + 本地规则引擎双轨)。 +演进(对应 FEAT-029 自适应番茄钟 / FEAT-043 FlowSense / P39 节律错配): + +- **短期**:推荐时把"中断记录、时段、科目"喂给 AI(数据已在 `pomodoro_sessions.interrupted/subject/completed_at`),从"平均时长"升级为"周三晚上学数学你通常 35 分钟进入状态,建议 40 分钟深潜档"。 +- **中期**:FlowSense 行为信号(打字节奏/切换频率)全部本地计算,仅将聚合特征交给 AI 判断心流/超载状态——隐私敏感的原始行为流永不上传,这既是伦理立场也是与云端产品的差异卖点。 + +### 3.3 知识整理:从"帮你写"到"帮你连" + +通用 AI 最强的是"帮你写",熵减不应在此竞争。差异化方向是"帮你连"和"帮你减": + +- **跨模块缝合**(FEAT-037):学新笔记时 AI 检索本地旧笔记/闪卡/费曼记录,提示"这与你 3 周前学的 X 冲突/呼应"——依赖 2.2 的向量层,是通用 AI 无法做的(数据在用户本地)。 +- **策略性遗忘**(FEAT-026 / P47):AI 提取 3-5 个"必记"核心,其余标"参考"。与所有"帮你记更多"的工具反向而行,认知科学立场即差异化。 +- **笔记健康度**(FEAT-025 / P22):AI 检测逐字率、提示"缺少你自己的思考"——AI 当教练不当代笔。 + +### 3.4 深度思考:把降级做成"离线教练" + +`FallbackProvider` 目前的降级文案已经在教方法("思考如何讲给小孩听")。把这个思路推到底:**AI 不可用时,产品退化为一本"结构化的学习方法论"而非报错页**。每个 feature 的降级内容由教学法专家式的静态内容库支撑(本地打包,零网络依赖),配合 2.2 短板 5 的查表重构一起落地。这让"离线降级"从工程兜底变成品牌体验——"熵减断网也在教你学习"。 + +### 3.5 多模态课堂(回声定位):最陡的增长曲线 + +`multimodal_analyze`(Qwen-VL 多图联合)+ `video_analyze`(Gemini 原生视频)+ ASR 转写已构成完整采集-理解链路。多模态模型能力正在陡峭爬升,该模块的体验上限每季度都在被模型侧免费抬高——是"搭 AI 趋势便车"最直接的模块。配合课程预设头脑风暴(`course-preset-brainstorm.md`)中的"动态换挡"方案,方向正确,应保持投入优先级。 + +--- + +## 四、竞争壁垒构建 + +### 4.1 数据壁垒(最深的护城河) + +**壁垒公式:细粒度学习过程数据 × 时间积累 × 本地私有 = 不可迁移的个人学习模型。** + +现有数据资产盘点(`client/electron/db/schema.ts`): + +| 数据 | 表 | AI 消费现状 | 未开发价值 | +|---|---|---|---| +| 复习轨迹(含 FSRS 参数、自信度、黄金错误) | `flashcards` / `flashcard_reviews` | 仅调度算法用 | 个人遗忘曲线建模 → 个性化复习策略 | +| 专注会话(时长/中断/科目/时段) | `pomodoro_sessions` | recommendDuration 部分消费 | 精力周期画像(P10)、黄金时段推荐 | +| 费曼薄弱点 | `feynman_weak_points` | 未被 AI 消费 | 跨概念盲区图谱、复习优先级 | +| 预测-验证记录 | `predictions` | 记录后未回流 | 校准度追踪(用户"自以为懂"的系统性偏差) | +| 灵感流 | `inspirations` | 分拣时消费 | 兴趣图谱、跨模块联想素材 | + +**关键动作**:建立"**学习者画像层**"(Learner Profile)——一个由本地数据定期聚合出的结构化画像(掌握度分布/遗忘参数/精力节律/盲区清单),作为所有 AI 调用的标准上下文注入。数据越久画像越准,AI 输出越个性化,用户迁移成本越高。这是把零散数据资产变成系统性壁垒的杠杆点,建议作为最高优先级的架构投资(先于任何新 AI 功能)。 + +### 4.2 技术壁垒 + +- **多级降级工程**:5 层降级 + 超时预算 + 离线队列的组合可靠性,创业竞品短期难以复制(这是数月工程积累,133 个测试基线是证据)。 +- **本地推理集成深度**:Ollama 集成 + IPC 流式桥 + 本地规则引擎的"混合推理"栈,随本地小模型能力增强而增值。 +- **提示词资产**(IMPROVE-006):23 条 Chain 的 prompt 经过真实学习场景打磨,且与四维度评分等结构化输出协议绑定。建议为核心 Chain 建立评估基准集(golden set),让 prompt 迭代可回归——把隐性资产显性化。 + +### 4.3 用户习惯壁垒 + +- **仪式设计**:学习启动仪式(FEAT-017)、仪式回顾(`ritual_recall`)、时间胶囊(FEAT-018)把产品嵌入每日学习的开始与结束——习惯性入口是对抗"用户直接打开 ChatGPT"的第一道防线。 +- **成长可视化**:streak、成就(`achievements` 表)、微进展引擎(P46)让积累可见,积累即沉没成本。 +- **学习化身**(FEAT-050,远期):情感依恋是最强的习惯锁定。 + +### 4.4 认知/品牌壁垒 + +"熵减 = 用 AI 逼你思考,而不是替你思考"——这个反直觉定位应贯穿所有对外叙事。当市场充斥"AI 帮你 10 秒读完一本书"时,反向立场自带辨识度,且有 47 个痛点 × 科学理论依据的内容库(`pain-points.md`)可持续输出为品牌内容。 + +### 4.5 刻意不做的事(负壁垒清单) + +- **不做通用 Chat 界面**——那是把用户推向与巨头正面对比的擂台。 +- **不做"AI 代写作业/论文"**——短期流量长期毒药,摧毁品牌立场。 +- **不把用户学习数据上云训练**——本地优先是承诺不是权宜(keban 数据标识的永久豁免制度已体现此文化)。 + +--- + +## 五、发展路径规划 + +### 短期(1-2 个版本,~6 个月):夯实数据飞轮起点 + +| 事项 | 对应 | 性质 | +|---|---|---| +| 学习者画像层 v1:聚合 `pomodoro_sessions`/`flashcard_reviews`/`feynman_weak_points` 为结构化画像,注入 recommend/predict/rescue 三个 Chain | 4.1 | 架构 | +| FallbackProvider 查表化重构 + 各 feature 定制"离线教练"文案 | 2.2-短板5 / 3.4 | 工程+内容 | +| 缓存分层:生成类请求内容哈希缓存 24h+,落地持久化 | 2.2-短板4 | 工程 | +| 断网时交互类 AI 自动切本地 Ollama + UI 降级明示 | 2.2-短板2 | 工程 | +| 核心 Chain 建立 prompt 评估基准集(先覆盖苏格拉底/费曼/闪卡三族) | IMPROVE-006 | 质量 | +| 回声定位"动态换挡"落地(智能路径自动调频) | course-preset ② | 功能 | + +**验收标尺**:至少 3 个 AI 功能的输出内容因"用户历史数据不同"而显著不同(个性化可感知)。 + +### 中期(3-5 个版本,~1 年):从工具到学习系统 + +| 事项 | 对应 | 性质 | +|---|---|---| +| 本地向量层(sqlite-vec / Ollama embedding),支撑 RAG 检索 | 2.2-短板3 | 架构 | +| 跨源知识缝合助手(FEAT-037)+ RAG 认知卸载网格(FEAT-044) | 3.3 | 功能 | +| 网关 Workflow 编排层(Chain 组合 + 条件分支),落地 AI 学习规划师(FEAT-046)第一版 | 2.2-短板1 | 架构+功能 | +| 个人遗忘曲线建模:用 `flashcard_reviews` 历史拟合个体 FSRS 参数 | 4.1 | 算法 | +| FlowSense 行为信号本地采集 + 自适应番茄钟(FEAT-029/043) | 3.2 | 功能 | +| 商业化:免费额度(GLM 免费模型承载)+ 订阅解锁高级模型/画像功能 + 用户自带 Key 三轨并行 | BIZ-002 | 商业 | + +**验收标尺**:用户能提出"我上个月学的 X 和这周的 Y 有什么关系"并得到基于本地知识库的回答。 + +### 长期(1-3 年):个人学习操作系统 + +- **活知识星图**(FEAT-045):向量层 + 画像层成熟后,知识网络可视化成为产品的"第二主界面"。 +- **本地优先的学习 Agent**:随端侧模型能力增长,将画像 + 向量库 + Workflow 编排下沉到本地,实现"断网可用的私人学习教练"——彼时云端仅做重型多模态,本地优先从降级策略升格为主路径。 +- **学习化身/数字生命体**(FEAT-050):画像层的情感化外显。 +- **开放性对冲**:若出现颠覆性交互范式(如 OS 级 AI 助手接管所有应用),熵减的退路是数据层——只要细粒度学习数据和画像在用户本地且格式可导出,产品可以重铸交互层而不失去积累。**数据模式的长期稳定性 > 任何一版 UI。** + +### 各阶段不变的两条红线 + +1. **本地优先不动摇**:任何新 AI 能力必须回答"离线降级是什么"才能上线(现有 AGENTS.md 约定的延续)。 +2. **教学法立场不动摇**:AI 增加用户思考量的功能优先,减少思考量的功能仅作为"数据采集入口"控制投入。 + +--- + +## 附:本文引用的关键代码位置速查 + +| 主题 | 位置 | +|---|---| +| AI 插件门面/双轨路由 | `client/src/lib/AIPluginLoader.ts`、`client/src/lib/ai/aiPluginProvider.ts` | +| 苏格拉底 Hook(缓存/去重/降级三件套) | `client/src/lib/ai/hooks/useAISocratic.ts` | +| 离线 AI 队列 | `client/src/lib/ai/offlineAIQueue.ts` | +| 本地规则引擎降级 | `client/src/lib/ai/LocalFallback.ts`、`server/ai-gateway/providers/fallback_provider.py` | +| Provider 路由与降级链 | `server/ai-gateway/config/providers.py`、`server/ai-gateway/config/fallback.py` | +| 本地数据资产 | `client/electron/db/schema.ts` | +| Electron AI 双轨执行 | `client/electron/ai/index.ts`(15 handlers + Ollama) | + +## 下一步 + +- [ ] 待决策:是否将"学习者画像层 v1"立项为下一版本的架构主线(本文短期路线图第一项) +- [ ] 待决策:FallbackProvider 查表化重构是否合入近期技术债清理 +- [ ] 本文观点随模型市场变化每季度复审一次(重点关注:通用 AI memory 能力进展、端侧小模型能力、多模态价格曲线) diff --git a/docs/Foresight/first-dive-onboarding-brainstorm.md b/docs/Foresight/first-dive-onboarding-brainstorm.md new file mode 100644 index 00000000..552cf1ec --- /dev/null +++ b/docs/Foresight/first-dive-onboarding-brainstorm.md @@ -0,0 +1,192 @@ +# 「首潜」新手引导系统 · 头脑风暴与系统设计 + +> **状态**: P1 已实现(2026-07-31,L0+L1+L4+双标签落地,遗留问题已裁决,见 §6);P2/P3 待排期 +> **日期**: 2026-07-31 +> **模块**: 跨模块系统(onboarding 重构 + 全局伴航层) +> **来源**: 内测反馈"对新手不友好,不知道怎么使用软件" +> **关联**: brand-story.md(水母人格/深海世界观)、sop-module-brainstorm.md(编排层) + +--- + +## 0. 诊断:为什么"有引导"却"不会用" + +现状盘点确认项目**并不缺引导设施**:3D 空间 7 步引导、OnboardingPage 6 步、帮助中心(Ctrl+/,4 Tab)、模块首次进入 toast、FAQ×10。但内测用户仍说不会用,根因有五: + +| # | 根因 | 说明 | +|---|---|---| +| 1 | **教操作不教目的** | 引导讲 3D 导航/相机/快捷键,用户学会转镜头,仍不知道"第一步干什么" | +| 2 | **双引导系统割裂** | OnboardingPage 与 OnboardingOverlay 两套流程、两个 localStorage key,疲劳且困惑 | +| 3 | **隐喻命名是新手墙** | "深潜/结礁/反衰减呼吸/浮出水面"品牌感强但无法自解释,翻译藏在帮助中心里 | +| 4 | **空白应用没有第一步** | 无示例数据,空状态有诗意文案但无 CTA | +| 5 | **方法论门槛被忽略** | 用户可能根本不懂费曼学习法/间隔重复,"我为什么需要这个模块"无人回答 | + +**结论**:修补式引导(再加几步教程)无解。需要一套以"带做"替代"讲解"、以"世界观叙事"替代"功能罗列"的完整系统。 + +--- + +## 1. 核心创意:三个非常规设计 + +### 创意一:引导即首潜(Onboarding as First Dive) + +不做"教程覆盖层",把首次使用设计成一次**真实的、有剧情的首次下潜**:用户以新潜航员身份,在 10 分钟里完成一个**完整且真实的学习循环**—— + +``` +定目标 → 3 分钟迷你深潜(真番茄钟)→ 结礁(记 1 条真笔记) +→ AI 结晶(笔记生成 2 张真闪卡)→ 呼吸一次(复习刚生成的卡) +→ 浮出水面(用一句话复述学到的东西) +``` + +结束时用户**不是"看完了介绍",而是"已经拥有自己的第一份数据"**,且身体记住了完整链路。所有步骤可跳过,跳过即直接进入自由模式。 + +### 创意二:产品自己教自己(Self-Referential Onboarding) + +首潜的学习素材**就是"如何使用熵减"本身**: + +- 预置的第一个牌组是 **《潜航员手册》卡组**(8~10 张卡:每张卡正面"深潜是什么?"背面一句话答案) +- 首潜里"呼吸一次"复习的就是这副卡;**7 天后间隔重复算法自然把手册卡再推给用户**——新手引导不是一次性事件,而是被产品自己的记忆引擎调度的持续过程 +- 用户在学习"怎么用软件"的同时,亲身体验了间隔重复的原理——**方法论教育与功能教育合二为一** +- 手册卡组带"沉船遗物"角标,全部掌握后触发成就「出师」,可一键归档 + +### 创意三:水母伴航(Companion, not Tutorial) + +把品牌人格(发光水母守夜人)具象为常驻引导生命体 **「微光」**: + +- 一只小水母悬浮在界面角落,赛博青光晕一明一灭;新手期跟随任务进度游动指路 +- 点击微光 = 情境帮助:它知道你在哪个页面、卡在哪一步,给出**当下该做的一件事**(替代"用户自己想起 Ctrl+/") +- 说话风格遵循品牌人格:不催促、不评判——"我在这里,慢慢来" +- 新手期结束后不消失,退为安静的帮助锚点(可在设置隐藏);关键时刻苏醒:连续 7 天未复习时轻声提醒,而非红点轰炸 + +--- + +## 2. 系统分层设计 + +``` +L0 着陆之问 首启一个问题,场景分流(30 秒) +L1 首潜叙事 带做完整学习循环(10 分钟,可跳过) +L2 海域点亮 首周渐进探索六模块(7 天) +L3 水母伴航 常驻情境帮助生命体(永久) +L4 自举卡组 《潜航员手册》被记忆引擎持续调度(约 21 天自然完成) +``` + +### L0 · 着陆之问 + +替代现有 OnboardingPage 的 6 步介绍。深海背景,只有一个问题: + +> **"此刻,最困扰你的是什么?"** +> A. 一学习就走神 → 首潜从深潜模块开始 +> B. 背了就忘 → 首潜从闪卡模块开始(手册卡组直接开背) +> C. 学了却讲不出来 → 首潜从费曼模块开始 +> D. 我只想随便看看 → 跳到 L2 自由探索 + +答案同时写入用户画像,供 AI 时长推荐冷启动使用。 + +### L1 · 首潜叙事 + +- 全屏沉浸(复用 ImmersiveTimer 的潮汐穹顶视觉),微光水母作为叙事者 +- 每一步都在**真实功能界面**上操作(非模拟截图),完成即产生真实数据 +- 3 分钟迷你番茄是特殊时长(不入统计或标记 `isOnboarding`),避免污染效率报表 +- 全程可 Esc 跳过;跳过后微光记住进度,下次可续潜 + +### L2 · 海域点亮 + +- 六模块在导航/3D 空间中初始为**微暗的"未探明海域"**(可点击,非锁死——尊重自由) +- 每完成一个模块的**首次真实使用**(非"看过介绍"),该海域亮起 + 水墨涟漪 + 一行海域铭文 +- 新手任务清单「潜航日志」:可收起的 checklist(首个番茄/首条笔记/首张闪卡/首次复习/首次费曼/首个灵感),逐项接入现有 `checkAchievements` 钩子 +- 全部点亮 → 成就「深海全图」+ 3D 空间彩蛋(认知星图第一颗星亮起) + +### L3 · 水母伴航 + +- 技术上是一个全局悬浮组件 + 情境规则表(当前路由 × 用户数据状态 → 建议动作) +- 例:闪卡页 & 无牌组 → "要不要把《潜航员手册》拿出来呼吸一下?";笔记页 & 有笔记无闪卡 → "这条笔记可以结晶成卡片,试试?" +- 帮助中心 4 Tab 保留,作为微光的"深层记忆"(点微光 → "查看完整手册" → 打开帮助中心对应 Tab) + +### L4 · 自举卡组 + +- 《潜航员手册》内容草案(每卡一问一答,双标签命名同步教学): + 1. 深潜(番茄钟)帮你解决什么? + 2. 为什么休息不是浪费时间? + 3. 结礁(笔记)和普通笔记 App 有什么不同? + 4. 一张闪卡什么时候会再次出现?(间隔重复原理) + 5. 浮出水面(费曼)为什么要求你"讲出来"? + 6. 忘了很多,正常吗?(遗忘曲线 + 品牌安抚语) + 7. 数据存在哪里?(本地优先,隐私) + 8. 卡住了找谁?(微光/帮助中心/Ctrl+/) + +--- + +## 3. 配套的常规补丁(系统的地基) + +创新系统之下,四个常规缺口仍需补上: + +| 补丁 | 内容 | +|---|---| +| **双标签命名** | 新手期全部隐喻名附直白副标题:"深潜 · 专注番茄钟";「潜航日志」全部完成后副标题淡出(设置可重开) | +| **空状态 CTA** | 每个模块空状态 = 海域文案 + 一个大按钮("开始第一次深潜")+ 微光在旁 | +| **合并旧双引导** | OnboardingPage 与 OnboardingOverlay 退役,3D 空间教学并入 L2(首次进入 3D 视图时微光教一次拖拽/缩放) | +| **全局 tooltip** | 所有 icon-only 按钮补 hover 提示(现完全缺失) | + +--- + +## 4. 评估与分期 + +评分:可行性/影响力/创新性 1-5,总分 = 可行×0.4 + 影响×0.4 + 创新×0.2 + +| 子系统 | 可行 | 影响 | 创新 | 总分 | 分期 | +|---|---|---|---|---|---| +| L0 着陆之问 | 5 | 4 | 3 | 4.2 | P1 | +| L1 首潜叙事 | 3 | 5 | 5 | 4.2 | P1(核心,工程量最大) | +| L4 自举卡组 | 5 | 4 | 5 | 4.6 | P1(纯内容+种子数据,成本极低) | +| 空状态 CTA + 双标签 | 5 | 4 | 2 | 4.0 | P1 | +| L2 海域点亮 + 潜航日志 | 4 | 4 | 4 | 4.0 | P2 | +| L3 水母伴航(基础版) | 3 | 4 | 5 | 3.8 | P2(先做静态锚点+情境规则,动画后置) | +| 旧双引导退役合并 | 4 | 3 | 1 | 3.0 | P2 | +| 全局 tooltip | 4 | 2 | 1 | 2.6 | P3 | +| 微光高级动画/AI 对话化 | 2 | 3 | 5 | 3.0 | P3+ | + +**P1(第一个迭代)= L0 + L1 + L4 + 空状态 CTA + 双标签**:新用户从"一个问题"进入"一次真实首潜",结束时拥有手册卡组和自己的第一份数据。 +**P2 = L2 + L3 基础版 + 旧引导退役**。 + +--- + +## 5. 技术落点预估(P1) + +| 改动 | 位置 | +|---|---| +| L0 着陆之问页 | 重构 `OnboardingPage`(复用其路由与完成标记) | +| L1 首潜编排器 | 新增 `features/onboarding/firstDive/`:状态机(步骤×真实页面跳转)+ 叙事层组件;复用 usePomodoroStore(特殊 3min 会话标记)、笔记/闪卡现有创建 API | +| L4 手册卡组种子 | 首启写入 `flashcardDecks`/`flashcards`(带 `builtin: 'handbook'` 标记);内容为纯数据文件 | +| 双标签 | `onboardingConstants.ts` 模块信息表加 `subtitle` 字段,导航组件按新手期状态渲染 | +| 空状态 CTA | 各模块空状态组件统一接 `EmptyState`(已存在但未普用)+ CTA 路由 | +| 新手期判定 | 统一为单一 `kb-onboarding-v2` 状态对象(阶段/进度/画像),替代散落的多个 localStorage key | + +风险与对策: +- **首潜打断成本**:全程 Esc 可跳过 + 断点续潜;首潜时长控制在 10 分钟内(迷你番茄仅 3 分钟) +- **老用户兼容**:检测到既有数据(任一表非空)则跳过 L0/L1,只提示"新增了微光伴航" +- **示例数据污染**:手册卡组与首潜番茄均打标记,统计页默认过滤 + +--- + +## 6. 遗留问题(已全部裁决,2026-07-31) + +| 问题 | 裁决与落地 | +|---|---| +| 微光水母视觉资产 | ✅ **已决策+落地**:P1 用纯 CSS 光晕光点(`MicroLight.tsx`,零依赖零资产);P2 升级 SVG/Lottie 时仅替换该组件 | +| 手册卡组版本更新机制 | ✅ **已实现**:`HANDBOOK_VERSION` + localStorage `kb-handbook-version`,升版时按 front 去重只追加新卡,绝不覆盖复习进度(测试覆盖);文案润色后递增版本号即可 | +| AI 离线降级 | ✅ **已决策+落地**:P1 首潜流程不依赖 AI——"结晶"步骤改为复习《潜航员手册》,完全离线可用;AI 结晶作为 P2 可选增强 | +| L0 画像是否上云 | ✅ **已决策**:仅存本地 `kb-onboarding-v2`,不上云,无需隐私声明变更;未来若需上云单独过隐私评审 | +| 3 分钟迷你番茄计入成就 | ✅ **已决策+落地**:计入(降低挫败)——`startMiniDive` 走完整 tick→recordSession→checkAchievements 链路,duration 如实记 180s 不污染效率统计 | + +### P1 实施补充决策 + +- **跳过交互**:用可见"先跳过"按钮而非 Esc——Esc 已被 AppLayout 用于退出模块,不抢占 +- **双引导冲突**:首潜(landing/diving)期间抑制旧 3D 引导自动启动(OnboardingOverlay 加判断);旧引导完整退役仍属 P2 +- **步骤完成检测**:数据基线差值(四表计数 vs 首潜开始时基线),与各模块 UI 零耦合 +- **空状态 CTA**:盘点确认闪卡/笔记/费曼三处均已有引导按钮,无需改动 + +--- + +## 7. 回顾 + +- 核心命题转换:从"补引导"到"重设计首次体验"——教程解释产品,首潜让用户直接活在产品里 +- 三个非常规抓手:引导即首潜(带做)、产品自己教自己(自举卡组)、水母伴航(世界观人格化的情境帮助) +- 最高性价比单点:L4 自举卡组(纯内容成本,同时解决功能教育+方法论教育+间隔重复体验三件事) diff --git a/docs/Foresight/pomodoro-customization-brainstorm.md b/docs/Foresight/pomodoro-customization-brainstorm.md new file mode 100644 index 00000000..86877d07 --- /dev/null +++ b/docs/Foresight/pomodoro-customization-brainstorm.md @@ -0,0 +1,179 @@ +# 番茄钟(深潜)自定义功能头脑风暴 + +> **状态**: 前瞻构想(Foresight,方向已选定,待细化排期) +> **日期**: 2026-07-31 +> **模块**: 深潜(Pomodoro) +> **来源**: 内测反馈驱动 —— "番茄钟优化,新添自定义功能" +> **关联**: SOP 编排层(sop-module-brainstorm.md)、课程预设(course-preset-brainstorm.md) + +--- + +## 0. 前置分析:现状与缺口 + +### 现有能力盘点 + +| 能力 | 现状 | 位置 | +|---|---|---| +| 双模式 | 上课 45min / 自习 25min,**硬编码两个胶囊** | `PomodoroPage.tsx` | +| 时长设置 | 专注/短休/长休/周期数/课堂时长可调,**全局唯一一套** | `PomodoroSettingsPage.tsx` | +| AI 时长推荐 | 基于历史会话推荐专注时长 | `useAIDuration` | +| 番茄目标 | 一行文字 + 常用目标记忆(`pomodoroGoals` 表) | `GoalInput.tsx` | +| 白噪音 | 多音轨 + 音量,随专注自动播放 | `useAudioPlayer` | +| 提示音 | 5 分钟预警 + 最后 10 秒滴答 + 完成音,**时点与音色硬编码** | `usePomodoroStore.tick()` | +| 上课静默 | 上课模式跳过全部音效(BUG-005 fix) | `usePomodoroStore` | + +### 核心缺口 + +**一句话**:所有节律参数是"全局一套",但学生的真实场景是多套并存的——刷题 50 分钟、背单词 15 分钟、晚自习 90 分钟、第 3 节课 40 分钟。每次切场景都要进设置页改数字,等于没有自定义。 + +### 约束条件 + +- 技术:本地优先,全部数据存 IndexedDB/SQLite,不依赖云端;单文件 ≤300 行 +- 兼容:`pomodoroSettings`/`pomodoroSessions` 既有数据不可破坏(跨版本兼容) +- 体验:主界面不可复杂化——"深潜"的核心体验是极简与沉浸 + +### 成功标准 + +内测用户无需进设置页,即可在自己常用的 2~4 个学习场景之间一键切换节律。 + +--- + +## 1. 发散阶段:四方向想法池 + +### 方向 A:自定义模式预设(本次主体) + +| # | 想法 | 备注 | +|---|---|---| +| A1 | 用户可创建模式预设:名称 + 图标 + 专注时长 + 短休/长休 + 周期数 + 静默开关 | 核心 | +| A2 | 内置模板库:刷题 50/10、背单词 15/3、晚自习 90/20、考研冲刺 60/10 | 降低冷启动成本 | +| A3 | 主界面胶囊从"2 个固定"变为"预设列表 + 管理入口",上限 6 个防止膨胀 | UI 改造点 | +| A4 | 现有"上课/自习"降级为两个不可删除的内置预设,数据结构统一 | 兼容策略 | +| A5 | 每个预设独立统计(会话记录带 presetId),报表可按预设分组 | 打通统计页 | +| A6 | AI 时长推荐升级为"按预设推荐"——刷题和背单词的最优时长本就不同 | 与既有 AI 能力协同 | +| A7 | 预设支持"温度"个性化:绑定专属白噪音音轨与主题色 | 锦上添花,可后置 | + +### 方向 B:目标与任务整合 + +| # | 想法 | 备注 | +|---|---|---| +| B1 | 番茄目标可关联闪卡组:专注结束后一键进入"反衰减呼吸"复习 | 费曼闭环第一步 | +| B2 | 目标可关联费曼主题:番茄结束提示"用 3 句话复述刚才学的" | 专注→输出 | +| B3 | 目标升级为任务卡:预估番茄数 vs 实际消耗,训练估算能力 | 学习元认知 | +| B4 | 常用目标 → 目标模板:绑定默认预设("背单词"目标自动切 15/3 预设) | A×B 联动 | +| B5 | 未完成目标自动进入次日"待深潜"清单 | 依赖任务体系,偏重 | + +### 方向 C:节律个性化 + +| # | 想法 | 备注 | +|---|---|---| +| C1 | 预警时点自定义:5 分钟预警可改为 3/10 分钟或关闭 | 现硬编码 `300` | +| C2 | 最后倒计时滴答可开关(有用户觉得紧张) | 现硬编码 10 秒 | +| C3 | 提示音音色可选:从现有 46 个音效资源中选完成音/预警音 | 资源已就绪 | +| C4 | 休息活动清单:拉伸/喝水/远眺卡片,休息时轮播展示 | 内容可内置 | +| C5 | 每日番茄目标数 + 连续打卡,联动成就系统 | `checkAchievements` 已有钩子 | +| C6 | 呼吸引导:休息开始时 30 秒呼吸动画(复用 BreathingProvider) | 组件已存在 | + +### 方向 D:课表联动(上课模式特化) + +| # | 想法 | 备注 | +|---|---|---| +| D1 | 手动录入周课表:节次 + 起止时间 + 科目 | 数据基础 | +| D2 | 上课模式按课表自动排程:到点自动开始,下课铃即短休 | 自动化核心 | +| D3 | 课表与回声定位(classroom)联动:上课番茄自动触发采集 | 跨模块,价值高 | +| D4 | 学校作息模板:一次配置"第 1 节 8:00-8:45…"全周复用 | 降低录入成本 | +| D5 | 课表导入(Excel/教务系统截图 OCR) | 技术成本高,远期 | + +### 灵感触发检查 + +- **反过来做**:不是"用户配置节律",而是 AI 观察一周后主动建议"你周三晚上的专注平均只有 19 分钟,要不要建一个 20/5 的预设?"(A6 延伸,远期) +- **竞品对照**:Forest 的"标签"、潮汐的"场景"、番茄 ToDo 的"自习室"——共性是**场景化预设**,验证方向 A 是行业共识 +- **用户真正要的**:不是更多设置项,而是"打开就是我要的节奏"——预设 + 记住上次选择即可满足 80% + +--- + +## 2. 收敛阶段:评估矩阵 + +评分:可行性/影响力/创新性 1-5,总分 = 可行×0.4 + 影响×0.4 + 创新×0.2 + +| 想法簇 | 可行性 | 影响力 | 创新性 | 总分 | 排名 | +|---|---|---|---|---|---| +| A1-A4 预设核心 | 5 | 5 | 2 | 4.4 | 1 | +| C1-C3 提示音自定义 | 5 | 3 | 1 | 3.4 | 3 | +| A5-A6 预设统计+AI | 4 | 4 | 3 | 3.8 | 2 | +| B1-B2 复习闭环 | 3 | 4 | 4 | 3.6 | 4(依赖闪卡组选择器) | +| B3-B4 任务卡 | 3 | 3 | 3 | 3.0 | 5 | +| C4-C6 休息体验 | 4 | 2 | 2 | 2.8 | 6 | +| D1-D2/D4 课表排程 | 2 | 4 | 4 | 3.2 | 7(工程量大,独立迭代) | +| D3/D5 跨模块与导入 | 1 | 4 | 4 | — | 暂时搁置 | + +### 分类结论 + +- **立即执行(v0.28 本次迭代)**:A1-A4 预设核心 + C1-C3 提示音自定义(搭车,改动集中在同一 store/设置页) +- **重点规划(下个迭代)**:A5-A6 预设统计与 AI 按预设推荐;B1-B2 复习闭环 +- **快速尝试**:C5 每日目标打卡(成就钩子已有) +- **暂时搁置**:D 课表联动整体独立成迭代(建议与 SOP 编排层构想合并评估——课表排程本质是 SOP 的时间触发特例);B5、C4、C6、D5 + +--- + +## 3. 立即执行项:技术草案 + +### 3.1 数据结构(新增,不破坏既有) + +```ts +/** 番茄模式预设(新增 pomodoroPresets 表) */ +interface PomodoroPreset { + id: string; + name: string; // "刷题" / "背单词" + icon: string; // lucide 图标名 + workDuration: number; // 分钟 + shortBreakDuration: number; + longBreakDuration: number; + longBreakInterval: number; // 0 = 无长休(即当前上课模式行为) + silent: boolean; // 静默(继承 BUG-005 语义) + builtin: boolean; // 内置预设不可删("上课""自习"迁移为内置) + sortOrder: number; + createdAt: Date; +} + +/** settings 扩展(向后兼容,缺省走默认值) */ +interface PomodoroSettingsV2 extends PomodoroSettings { + activePresetId?: string; // 记住上次选择 + warningMinutes?: number; // 预警时点,0 = 关闭(替代硬编码 300s) + tickFinalEnabled?: boolean; // 最后 10 秒滴答开关 + completionSoundId?: string; // 完成音音色 +} +``` + +### 3.2 迁移与兼容 + +- `mode: 'class' | 'self_study'` 保留为内置预设的别名,`pomodoroSessions.mode` 字段追加可选 `presetId`,旧数据无损 +- 首次启动 v0.28 时把用户当前 `classDuration`/`workDuration` 写入两个内置预设,体验无缝 +- `longBreakInterval: 0` 统一表达"无长休",消除 `mode === 'class'` 的特判分支(顺带简化本次计数 bug 修复后的逻辑) + +### 3.3 改动面预估 + +| 文件 | 改动 | +|---|---| +| `usePomodoroStore.ts` | mode 逻辑泛化为 preset;`getPhaseDuration`/`getNextPhase` 参数改预设 | +| `PomodoroPage.tsx` | 胶囊切换 → 预设列表(横向滚动,末尾"+"管理入口) | +| `PomodoroSettingsPage.tsx` | 新增预设管理区块 + 提示音区块 | +| `lib/storage`(Dexie + SQLite schema/migration) | 新表 `pomodoro_presets`,settings 加列 | +| 新组件 `PresetEditor.tsx` | 预设创建/编辑弹窗 | + +--- + +## 4. 遗留问题 + +- 预设数量上限定 6 还是 8?需观察内测用户实际创建数 +- "静默"是否拆成"静默音效"与"隐藏通知"两个开关? +- `longBreakInterval: 0` 的 UI 表述("不设长休")需要文案确认 +- 课表联动(方向 D)与 SOP 编排层的边界划分,待 SOP 立项时一并裁决 +- AI 按预设推荐(A6)需要会话表按 presetId 分组后有足够样本量,冷启动策略待定 + +--- + +## 5. 回顾 + +- 产出想法数量:23(A×7 B×5 C×6 D×5) +- 收敛结论:预设核心 + 提示音自定义进入本迭代;课表联动独立评估 +- 决策依据:内测用户为学生群体,多场景节律切换是最高频痛点;行业竞品验证了场景化预设方向 diff --git a/docs/Foresight/sop-custom-design.md b/docs/Foresight/sop-custom-design.md new file mode 100644 index 00000000..de471e2b --- /dev/null +++ b/docs/Foresight/sop-custom-design.md @@ -0,0 +1,368 @@ +# 用户自定义 SOP 设计考虑 + +> **状态**: 前瞻构想(Foresight,未排期) +> **日期**: 2026-07-30 +> **模块**: SOP(洋流图)— 用户自定义子系统 +> **前置**: [sop-module-brainstorm.md](./sop-module-brainstorm.md)(整体架构与定位) +> **定位**: 本文聚焦"用户如何创建、管理、使用、分享自己的 SOP",是整体头脑风暴中**用户侧自由度**的专项深化 + +--- + +## 设计原则 + +在展开 7 个维度之前,先锚定三条设计原则,所有决策以此为判据: + +| 原则 | 含义 | 反模式 | +|---|---|---| +| **30 秒可启动** | 从"想学"到"第一步开始"不超过 30 秒 | 需要填 5 个表单才能开始 | +| **渐进式复杂度** | 新手用内置模板,进阶用户自定义,专家用 AI 生成 | 一上来就暴露全部配置项 | +| **学习科学护栏** | 自由度有边界,系统温和提示不合理的编排 | 用户建了一个"连续 4 小时不休息"的 SOP 却无任何反馈 | + +--- + +## 1. 创建界面与操作流程 + +### 1.1 三种创建入口(渐进式) + +``` +入口 A:从空白创建(专家路径) + SOP 列表页 → FAB "+" → 空白编辑器 + +入口 B:从内置模板派生(推荐路径) + 内置模板卡片 → "以此为基础" → 预填充编辑器 → 修改 → 保存为我的 + +入口 C:AI 生成(零门槛路径) + SOP 列表页 → "AI 帮我规划" → 输入目标/场景描述 → AI 输出步骤 → 预览编辑 → 保存 +``` + +### 1.2 编辑器交互设计 + +**布局**:三栏式(桌面端)/ 抽屉式(窄屏) + +``` +┌──────────────────────────────────────────────────────┐ +│ SOP 名称: [________________] 标签: [数学] [课前] [+] │ +├──────────┬───────────────────────┬───────────────────┤ +│ 步骤面板 │ 步骤画布 │ 步骤配置面板 │ +│ │ │ │ +│ 🍅 深潜 │ ┌─────────────────┐ │ 类型: 深潜 │ +│ 📹 采集 │ │ 1. 🍅 深潜 25min │ │ 时长: [25] 分钟 │ +│ 📝 笔记 │ └─────────────────┘ │ 自动启动: ☑ │ +│ 🃏 闪卡 │ ┌─────────────────┐ │ │ +│ 🧠 费曼 │ │ 2. 📝 笔记整理 │ │ ── 或 ── │ +│ 💡 灵感 │ └─────────────────┘ │ │ +│ ☑ 检查单 │ ┌─────────────────┐ │ 检查子项: │ +│ ⏸ 休息 │ │ 3. 🃏 闪卡复习 │ │ 1. [________] │ +│ ✏ 自定义 │ └─────────────────┘ │ 2. [________] │ +│ │ │ [+ 添加子项] │ +│ 拖拽添加 →│ [+ 添加步骤] │ │ +├──────────┴───────────────────────┴───────────────────┤ +│ 预估总时长: 65 分钟 [保存] [另存为模板] [删除] │ +└──────────────────────────────────────────────────────┘ +``` + +**关键交互**: +- 左侧面板拖拽到画布添加步骤(或点击"+"按钮追加) +- 画布内步骤支持拖拽排序、长按删除 +- 点击步骤卡片 → 右侧弹出配置面板 +- 每步可标记"可跳过"(optional) +- 底部实时计算预估总时长 + +### 1.3 快速创建(极简路径) + +对于不想进编辑器的用户,提供"快速 SOP": + +``` +弹窗: + "给这个 SOP 起个名字" + [期末复习冲刺____________] + + "包含哪些活动?(点选)" + ☑ 深潜 ☑ 闪卡 ☐ 采集 ☑ 费曼 ☐ 笔记 + + "每个活动多久?" + 深潜 [25] min → 闪卡 [15] min → 费曼 [10] min + + [创建] +``` + +3 次点击即可生成一个可用 SOP。后续可随时进编辑器细化。 + +--- + +## 2. 存储与管理机制 + +### 2.1 数据模型(补充 brainstorm 中的基础模型) + +```typescript +interface UserSopTemplate extends SopTemplate { + // 用户自定义扩展字段 + color?: string; // 卡片主题色(品牌色板内选择) + triggerHint?: string; // 使用场景提示("适合课前 30 分钟") + lastRunAt?: number; // 上次执行时间 + runCount: number; // 累计执行次数 + avgDuration?: number; // 平均执行时长(秒) + skipStats?: Record; // 各步骤被跳过的次数 + parentId?: string; // 若从内置模板派生,记录来源 + shared: boolean; // 是否已导出/分享 +} +``` + +### 2.2 本地存储策略 + +| 数据 | 存储位置 | 说明 | +|---|---|---| +| 模板定义 | SQLite `sop_templates` 表 | 本地优先,离线可用 | +| 执行记录 | SQLite `sop_runs` 表 | 含每步产出快照 | +| 步骤产出关联 | `step_results_json` 字段 | 笔记 ID / 闪卡 deck ID / 专注 session ID | +| 用户偏好 | Zustand persist → localStorage | 上次使用的筛选标签、排序方式 | + +### 2.3 管理界面 + +**SOP 列表页的组织方式**: + +``` +┌─ 场景标签筛选 ──────────────────────────────────────┐ +│ [全部] [课前] [课中] [课后] [考试] [实验] [自定义] │ +└─────────────────────────────────────────────────────┘ + +┌─ 内置模板(只读,可派生)────────────── 横滑 ────────┐ +│ 📋 网课全流程 📋 实验操作 📋 期末复习周 │ +└─────────────────────────────────────────────────────┘ + +┌─ 我的 SOP ──────────────────────── 网格/列表切换 ───┐ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ 🌊 高数课前│ │ 🌊 编程日常│ │ 🌊 考研冲刺│ │ +│ │ 4步 · 50min│ │ 3步 · 40min│ │ 6步 · 120min│ │ +│ │ 上次: 昨天 │ │ 上次: 3天前│ │ 执行 12 次 │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +│ │ +│ 排序: [最近使用 ▾] / [执行次数] / [名称] │ +└─────────────────────────────────────────────────────┘ +``` + +**管理操作**: +- 长按/右键卡片 → 编辑 / 复制 / 导出 / 删除 +- 批量操作:多选 → 导出 / 删除 +- 搜索:按名称 + 标签模糊匹配 + +--- + +## 3. 场景化选择与使用 + +### 3.1 场景触发机制 + +| 触发方式 | 描述 | 优先级 | +|---|---|---| +| **手动选择** | 用户主动进入 SOP 列表,选一个执行 | 一期 | +| **时间提示** | 用户设定的学习时段到达时,通知栏提示"该执行《高数课前》了" | 二期 | +| **上下文推荐** | 打开回声定位时,若有关联 SOP 则提示"检测到你通常采集后会整理笔记,启动后续流程?" | 二期 | +| **AI 推荐** | 仪表盘根据近期学习数据建议"你已经 3 天没复习闪卡了,要执行《每日复习》SOP 吗?" | 三期 | + +### 3.2 执行器中的场景适配 + +执行器根据 SOP 的 `tags` 调整氛围: + +| 场景标签 | 执行器氛围 | +|---|---| +| 课前 | 穹顶模式(light),轻快节奏,进度条用苔藓绿 | +| 考试 | 深海模式(dark),沉浸专注,进度条用品牌靛蓝 | +| 实验 | 检查单为主界面,大字体,逐项打勾有触感反馈 | +| 自定义 | 跟随系统主题,中性配色 | + +### 3.3 快捷启动 + +- 桌面端:系统托盘右键菜单 → "快速启动 SOP" → 最近 3 个 +- 应用内:`Ctrl+Shift+S` 打开 SOP 快速选择浮层(类似命令面板) +- 仪表盘:首页"今日推荐"卡片中嵌入最常执行的 SOP 一键启动按钮 + +--- + +## 4. 内置 SOP 与用户 SOP 的区分和组织 + +### 4.1 分类体系 + +``` +SOP 模板 +├── 内置模板(category: 'builtin') +│ ├── 按场景分:课前预习 / 课中采集 / 课后巩固 / 考试冲刺 / 实验操作 +│ ├── 只读,不可编辑/删除 +│ ├── 可"复制为我的"后自定义 +│ └── 应用更新时可追加新模板(按 id 去重,不覆盖用户修改) +│ +├── 用户模板(category: 'user') +│ ├── 完全可编辑/删除/导出 +│ ├── 可标记场景标签 +│ └── 支持从内置模板派生(parentId 溯源) +│ +└── AI 生成(category: 'ai-generated') + ├── AI 根据用户输入生成 + ├── 生成后可编辑,编辑后自动转为 'user' 类别 + └── 标注"AI 生成"徽标,区别于用户手写 +``` + +### 4.2 视觉区分 + +| 类别 | 卡片样式 | 徽标 | +|---|---|---| +| 内置 | 品牌色边框 + 官方图标 | "官方" 小徽标 | +| 用户 | 用户自选颜色 + 自定义图标 | 无 | +| AI 生成 | 渐变边框(品牌色→辅助色) | "AI" 小徽标 | + +### 4.3 冲突处理 + +- 内置模板更新时,若用户已从该模板派生过自定义版本,**不自动同步**(避免覆盖用户修改),但在派生版本上提示"源模板已更新,查看差异?" +- 用户删除派生模板不影响内置原件 + +--- + +## 5. 分享与复用 + +### 5.1 一期:本地导入/导出 + +``` +导出:SOP 卡片 → 更多 → 导出 → 生成 .sop.json 文件 +导入:SOP 列表页 → 更多 → 导入 → 选择 .sop.json → 预览 → 确认 + +文件格式: +{ + "format": "entro-sop/v1", + "name": "高数课前预习", + "description": "适合高等数学课前 30 分钟", + "tags": ["课前", "数学"], + "steps": [...], + "exportedAt": 1722300000000, + "exportedBy": "熵减 v0.27.0" +} +``` + +- 导入时自动标记 `category: 'user'`,不保留原始 id(防冲突) +- 导入前预览步骤列表,用户确认后写入 + +### 5.2 二期:模板库(云端,可选) + +| 特性 | 设计 | +|---|---| +| 发布 | 用户选择"发布到模板库" → 填写简介 → 上传(经 sync-service) | +| 浏览 | 应用内"模板广场"页面,按场景/学科/热度筛选 | +| 安装 | 一键"使用此模板" → 复制到本地 → 可自定义 | +| 评分 | 执行完成后可打分(1-5 星)+ 短评 | +| 审核 | 初期人工审核,后期引入社区举报机制 | + +### 5.3 隐私与安全 + +- 导出/发布时**剥离所有执行记录和个人数据**,仅保留模板定义 +- 导入时校验 JSON schema,拒绝非法步骤类型 +- 模板库发布需登录(复用现有 auth 体系) + +--- + +## 6. 版本管理与编辑 + +### 6.1 版本策略(轻量) + +| 场景 | 处理 | +|---|---| +| 编辑保存 | `version + 1`,`updated_at` 更新,不保留历史版本 | +| 执行中编辑 | 执行器锁定当前步骤快照,编辑不影响进行中的 run | +| 执行记录 | 每条 run 保存执行时的步骤 JSON 快照(`steps_snapshot_json`),模板后续修改不影响历史 | +| 派生模板 | 记录 `parentId` + `parentVersion`,源模板更新时可选"查看差异" | + +### 6.2 编辑功能清单 + +| 操作 | 说明 | +|---|---| +| 重命名 / 改描述 | 随时可改 | +| 增删步骤 | 拖拽添加 / 滑动删除 | +| 调整顺序 | 拖拽排序 | +| 修改步骤配置 | 点击步骤 → 配置面板 | +| 切换步骤类型 | 配置面板中更换类型(保留标题,清空类型特定配置) | +| 复制步骤 | 长按 → 复制(插入到当前步骤后方) | +| 标记可跳过 | 步骤配置中的 optional 开关 | +| 另存为 | 保存为新模板(不影响原模板) | +| 恢复内置 | 派生模板可"重置为内置版本"(丢弃自定义修改) | + +### 6.3 编辑保护 + +- 删除模板前二次确认("已执行 N 次,确定删除?") +- 执行记录不随模板删除(保留历史,`template_id` 标记为已删除) +- 自动保存:编辑器每 30 秒或切换焦点时自动保存草稿 + +--- + +## 7. 学习科学护栏 + +### 7.1 设计哲学 + +**不阻止,但提醒**。用户有完全自由创建任何 SOP,系统在检测到可能影响学习效率的编排时,给出温和提示(可忽略)。 + +### 7.2 护栏规则引擎 + +```typescript +interface SopLintRule { + id: string; + severity: 'info' | 'warning'; // 不做 'error'——永远不阻止保存 + check: (steps: SopStep[]) => string | null; // 返回提示文案或 null +} +``` + +**内置规则**: + +| 规则 ID | 检测条件 | 提示文案 | 严重度 | +|---|---|---|---| +| `no-break-long` | 连续专注步骤总时长 > 90 分钟且无休息步骤 | "超过 90 分钟无休息,注意力可能显著下降,建议插入 5 分钟休息" | warning | +| `no-review` | 有"采集"或"笔记"步骤但无"闪卡"或"费曼"步骤 | "只有输入没有输出,记忆留存率可能较低,考虑加一步复习?" | info | +| `too-many-steps` | 步骤数 > 10 | "步骤较多(N 步),执行完成率可能下降,考虑拆分为多个 SOP?" | info | +| `no-output` | 全部步骤都是被动型(采集/休息),无主动型(笔记/闪卡/费曼) | "当前流程以被动接收为主,加入主动输出环节可显著提升学习效果" | warning | +| `short-total` | 预估总时长 < 10 分钟 | "流程较短,可能不足以形成有效学习闭环" | info | + +### 7.3 提示时机与方式 + +| 时机 | 方式 | +|---|---| +| 编辑器保存时 | 底部 toast 提示(可关闭,不阻止保存) | +| 执行器启动前 | 若存在 warning 级提示,显示"小贴士"卡片("开始"按钮始终可用) | +| 执行完成后 | 在总结页展示"本次学习科学提示"(如"你跳过了复习步骤,下次试试?") | + +### 7.4 正向激励 + +| 行为 | 反馈 | +|---|---| +| 连续 7 天执行同一 SOP | "洋流已稳定:《高数课前》已成为你的学习节律" | +| 从未跳过某步骤 | "全勤步骤:你的「闪卡复习」从未缺席" | +| 执行后成绩提升(若用户手动记录) | "这个 SOP 似乎对你有效,继续保持" | +| 首次创建 SOP | "第一条洋流已绘制,学习从此有章法" | + +### 7.5 数据驱动优化建议(三期) + +基于执行记录中的 `skipStats`(各步骤被跳过次数),AI 定期生成优化建议: + +``` +"你执行《课后巩固》时,第 4 步「费曼复述」被跳过了 8/10 次。 + 可能原因:时间安排太紧 / 这一步对你帮助不大。 + 建议:将它改为可选步骤,或缩短为 5 分钟快速复述。" +``` + +--- + +## 8. 与整体 SOP 架构的衔接 + +本文档所述的用户自定义子系统,在 [sop-module-brainstorm.md](./sop-module-brainstorm.md) 的整体架构中对应: + +| 整体架构层 | 本文档覆盖 | +|---|---| +| 数据模型(§2.2) | §2 存储与管理(扩展字段) | +| 编辑器页面(§3.1 ②) | §1 创建界面(详细交互) | +| 执行器页面(§3.1 ③) | §3 场景化使用(氛围适配) | +| 内置模板管理(§5.3) | §4 分类体系(详细组织) | +| 扩展性(§7.1 导入导出) | §5 分享与复用(完整方案) | +| 技术难点(§4.3) | §7 学习科学护栏(规则引擎) | + +--- + +## 9. 下一步 + +- [ ] 待决策:一期是否包含"快速创建"极简路径(§1.3),还是只做完整编辑器 +- [ ] 待决策:学习科学护栏规则集(§7.2)是否需要用户可配置开关 +- [ ] 待决策:分享功能一期仅做本地 JSON 导入导出,还是直接做模板广场 +- [ ] 若立项:与 sop-module-brainstorm.md 合并为完整 spec,走实现计划流程 diff --git a/docs/Foresight/sop-module-brainstorm.md b/docs/Foresight/sop-module-brainstorm.md new file mode 100644 index 00000000..8346d337 --- /dev/null +++ b/docs/Foresight/sop-module-brainstorm.md @@ -0,0 +1,413 @@ +# SOP(标准作业程序)功能模块头脑风暴 + +> **状态**: 前瞻构想(Foresight,未排期) +> **日期**: 2026-07-30 +> **模块**: 新功能模块(暂定代号,待品牌命名) +> **来源**: 用户主动发起的多维度头脑风暴 +> **关联**: 回声定位(classroom)、费曼/苏格拉底(feynman)、课程预设(course-preset-brainstorm.md) + +--- + +## 0. 前置分析:SOP 在熵减中的定位缺口 + +现有六大模块覆盖了学习的**单点能力**: + +| 模块 | 能力 | 隐喻 | +|---|---|---| +| 深潜(Pomodoro) | 专注 | 锁定当前时空 | +| 结礁(Notes) | 记录 | 碎片沉淀为暗礁 | +| 回声定位(Classroom) | 采集 | 声呐捕获暗物质 | +| 反衰减呼吸(Flashcards) | 记忆 | 对抗遗忘衰减 | +| 浮出水面(Feynman) | 理解 | 消除不确定性 | +| 萤火海沟(Inspiration) | 灵感 | 微光等待引爆 | + +**缺口**:没有模块回答 **"以什么顺序、在什么条件下、做哪些事"**。用户知道怎么专注、怎么记笔记、怎么复习,但缺少一个**编排层**把单点能力串联为可复用的学习流程。SOP 正是这个编排层。 + +**核心隐喻候选**(深海主题): + +| 候选名 | 隐喻 | 文案 | +|---|---|---| +| **洋流图** | 洋流是深海中可预测的流动路径,SOP 是学习活动的可复用路径 | "绘制你的认知洋流,让学习不再随波逐流。" | +| **深潜航路** | 每次学习是一次深潜,SOP 是预设的潜水航路 | "规划下潜路线,每一潜都有章法。" | +| **潮汐节律** | 潮汐是自然界的 SOP,学习也应有节律 | "建立你的认知潮汐,让秩序成为本能。" | + +> 推荐 **洋流图**:与"回声定位"的声呐隐喻同属海洋导航体系,且"图"暗示可视化编排。 + +--- + +## 1. 功能定位 + +### 1.1 核心价值 + +**一句话**:把"我今天该怎么学"从每次临时决策变成一键启动的固化流程。 + +三层价值递进: + +| 层次 | 描述 | 示例 | +|---|---|---| +| **L1 流程固化** | 将重复的学习活动序列保存为模板,一键启动 | "网课采集 → 整理笔记 → 生成闪卡 → 费曼复述" | +| **L2 程序性知识** | 从课程中捕获操作步骤类知识(实验/编程/解题),变成可交互练习清单 | "有机化学实验:滴定操作 7 步检查单" | +| **L3 AI 编排** | AI 根据学习目标 + 已有材料,自动生成个性化 SOP | "期末复习周:AI 根据薄弱科目自动编排 5 天计划" | + +### 1.2 目标用户 + +- **主力**:大学生(课程多、实验多、考试周期固定) +- **延伸**:终身学习者(考证、编程自学、语言学习) +- **共性痛点**:知道"该做什么"但每次都要重新规划,执行力衰减 + +### 1.3 与现有模块的关系 + +``` + ┌─────────────┐ + │ SOP 编排层 │ ← 新模块(洋流图) + │ "做什么序" │ + └──────┬──────┘ + ┌───────┬───────┼───────┬───────┐ + ▼ ▼ ▼ ▼ ▼ + 深潜 结礁 回声定位 反衰减 浮出水面 + (专注) (记录) (采集) (记忆) (理解) +``` + +**SOP 不替代任何模块,而是调度它们**。一个 SOP 步骤可以是"启动 25 分钟深潜"、"打开回声定位采集当前窗口"、"对今天的笔记做费曼复述"。 + +与课程预设的关系:课程预设(course-preset-brainstorm.md)是 SOP 的**特化子集**——它只编排"采集"环节的参数。SOP 是更通用的编排层,课程预设可以作为 SOP 模板库中的内置模板存在。 + +--- + +## 2. 功能架构 + +### 2.1 模块划分 + +``` +features/sop/ +├── pages/ +│ ├── SopListPage.tsx # SOP 模板列表(我的 + 内置) +│ ├── SopEditorPage.tsx # SOP 编辑器(拖拽步骤) +│ └── SopRunPage.tsx # SOP 执行器(逐步引导) +├── components/ +│ ├── StepCard.tsx # 单个步骤卡片 +│ ├── StepPalette.tsx # 步骤类型面板(拖拽源) +│ ├── RunProgress.tsx # 执行进度条 +│ ├── ChecklistItem.tsx # 检查单项(程序性知识用) +│ └── SopTemplateCard.tsx # 模板卡片 +├── hooks/ +│ ├── useSopTemplates.ts # 模板 CRUD +│ ├── useSopRunner.ts # 执行状态机 +│ └── useSopAI.ts # AI 生成/推荐 +├── store/ +│ └── useSopStore.ts # Zustand 状态 +├── types.ts # 类型定义 +└── constants.ts # 步骤类型注册表 +``` + +### 2.2 核心数据模型 + +```typescript +/** SOP 模板 */ +interface SopTemplate { + id: string; + name: string; + description?: string; + icon?: string; // lucide icon name + category: 'builtin' | 'user' | 'ai-generated'; + steps: SopStep[]; + tags: string[]; // 关联学科/场景 + createdAt: number; + updatedAt: number; + version: number; // 乐观锁版本号 +} + +/** SOP 步骤 */ +interface SopStep { + id: string; + type: SopStepType; + title: string; + description?: string; + config: Record; // 步骤类型特定配置 + duration?: number; // 预估时长(分钟) + checklist?: string[]; // 程序性知识:检查子项 + optional: boolean; // 是否可跳过 +} + +type SopStepType = + | 'focus' // 深潜(番茄钟) + | 'capture' // 回声定位(网课采集) + | 'note' // 结礁(笔记整理) + | 'review' // 反衰减呼吸(闪卡复习) + | 'feynman' // 浮出水面(费曼复述) + | 'inspiration' // 萤火海沟(灵感记录) + | 'break' // 休息 + | 'checklist' // 纯检查单(程序性知识) + | 'custom'; // 自定义文本步骤 + +/** SOP 执行实例 */ +interface SopRun { + id: string; + templateId: string; + startedAt: number; + completedAt?: number; + currentStepIndex: number; + stepResults: StepResult[]; // 每步产出(笔记ID/闪卡数/专注时长等) + status: 'running' | 'paused' | 'completed' | 'abandoned'; +} +``` + +### 2.3 数据流向 + +``` +模板库 ──选择──▶ 执行器 ──逐步调度──▶ 各功能模块 + │ │ + │◀───── 产出回收 ───────┘ + │ (笔记ID/闪卡数/专注时长) + ▼ + 执行记录 ──▶ 仪表盘统计 + │ + ▼ + AI 分析 ──▶ 优化建议 / 自动生成新 SOP +``` + +--- + +## 3. 用户界面 + +### 3.1 三个核心页面 + +**① SOP 列表页(SopListPage)** +- 顶部:场景标签筛选(全部 / 课前 / 课中 / 课后 / 考试 / 实验 / 自定义) +- 内置模板区:横滑卡片("网课全流程"、"实验操作练习"、"期末复习周"等) +- 我的 SOP 区:网格卡片,每张显示名称 + 步骤数 + 上次执行时间 + 执行次数 +- 右下角 FAB:"新建 SOP" / "AI 生成" + +**② SOP 编辑器(SopEditorPage)** +- 左侧:步骤类型面板(StepPalette),按类别分组,可拖拽 +- 中间:步骤画布,纵向排列 StepCard,支持拖拽排序 +- 右侧(或底部抽屉):选中步骤的配置面板 +- 顶部:SOP 名称 / 描述 / 标签编辑 +- 设计风格:沿用毛玻璃面板 + 品牌色,StepCard 用 `rounded-kb-lg` + `bg-bg-secondary/40` + +**③ SOP 执行器(SopRunPage)——最关键的页面** +- **全屏沉浸模式**(类似深潜的专注界面) +- 顶部:SOP 名称 + 进度环(已完成/总步骤) +- 中央:当前步骤大卡片(图标 + 标题 + 描述 + 操作按钮) + - 若步骤是"深潜":内嵌倒计时 + - 若步骤是"检查单":逐项打勾 + - 若步骤是"采集":显示"跳转到回声定位"按钮(deep link) +- 底部:上一步 / 跳过 / 完成当前步骤 +- 步骤切换动画:沿用 `page-fade-in`(250ms ease-out) +- 完成时:熵减反馈——"本次学习熵值 -X%,流程已固化" + +### 3.2 与现有设计语言的统一 + +| 维度 | 规范 | +|---|---| +| 圆角 | `rounded-kb-sm/md/lg`(8/12/16px) | +| 动效 | Framer Motion 弹簧(stiffness 300, damping 28) | +| 图标 | Lucide, strokeWidth 1.5 | +| 色彩 | 品牌色 `brand-*`,功能色 `semantic-*`,模块专属色待定 | +| 双主题 | 深海(dark)/ 穹顶(light)双世界适配 | +| 字号 | 标题 `text-h2`,正文 `text-b2`,辅助 `text-b3` | + +--- + +## 4. 技术实现 + +### 4.1 技术栈(复用现有) + +| 层 | 选型 | 说明 | +|---|---|---| +| 状态管理 | Zustand(`useSopStore`) | 与 feynman/flashcards 一致 | +| 持久化 | better-sqlite3(Electron 主进程) | 与 notes/flashcards 一致,本地优先 | +| AI 集成 | `useAIFeature` hook + ai-gateway | 复用现有降级链(remote → local → fallback) | +| 路由 | react-router lazy import | `/sop`、`/sop/:id/edit`、`/sop/:id/run` | +| 跨模块调度 | 路由跳转 + URL 参数 | 如 `/pomodoro?duration=25&returnTo=/sop/run/xxx` | +| 拖拽 | @dnd-kit/core(轻量)或原生 HTML5 DnD | 编辑器步骤排序 | + +### 4.2 跨模块调度方案 + +SOP 执行器需要"启动"其他模块的功能。两种方案: + +| 方案 | 机制 | 优劣 | +|---|---|---| +| **A. 路由跳转 + 回调参数** | 执行器跳转到目标模块页面,URL 带 `returnTo` 参数,目标模块完成后跳回 | 简单,零耦合;但用户可能迷路 | +| **B. 嵌入式面板** | 执行器内嵌目标模块的核心组件(如番茄钟计时器、闪卡复习组件) | 体验连贯;但耦合度高,组件需支持嵌入模式 | + +**推荐**:一期用 A(路由跳转),二期对高频步骤(深潜、闪卡)做 B(嵌入)。 + +### 4.3 技术难点 + +| 难点 | 风险 | 缓解 | +|---|---|---| +| 跨模块产出回收(如"采集完成后自动进入笔记整理") | 模块间无直接通信 | 用 Zustand 全局 `sopRunStore` 暂存上下文,目标模块读取 | +| AI 生成 SOP 的质量 | LLM 可能生成不切实际的步骤 | 约束输出为 `SopStepType` 枚举 + 后置校验 | +| 执行中断恢复 | 用户中途关闭应用 | `SopRun` 持久化到 SQLite,启动时检测未完成 run 并提示恢复 | +| 步骤计时与深潜模块的计时器冲突 | 两个计时器同时运行 | SOP 内嵌深潜时复用深潜的计时器,不另起 | + +--- + +## 5. 数据管理 + +### 5.1 存储方案 + +```sql +-- 模板表 +CREATE TABLE sop_templates ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + icon TEXT, + category TEXT DEFAULT 'user', -- builtin / user / ai-generated + steps_json TEXT NOT NULL, -- JSON: SopStep[] + tags_json TEXT DEFAULT '[]', + version INTEGER DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +-- 执行记录表 +CREATE TABLE sop_runs ( + id TEXT PRIMARY KEY, + template_id TEXT NOT NULL REFERENCES sop_templates(id), + started_at INTEGER NOT NULL, + completed_at INTEGER, + current_step_index INTEGER DEFAULT 0, + step_results_json TEXT DEFAULT '[]', + status TEXT DEFAULT 'running', -- running/paused/completed/abandoned + duration_seconds INTEGER -- 实际总时长 +); +``` + +### 5.2 版本控制 + +- 模板每次编辑 `version + 1`,`updated_at` 更新 +- 执行记录快照 `template_id` + 执行时的步骤 JSON(防止模板修改后历史记录失真) +- 不做完整版本链(YAGNI),只保留最新版 + 执行时快照 + +### 5.3 内置模板管理 + +- 内置模板以 JSON 文件形式存放在 `client/src/features/sop/builtin-templates/` 目录 +- 首次启动时 seed 到 SQLite(`category = 'builtin'`) +- 用户可"复制为我的"后自定义,内置模板不可编辑/删除 +- 应用更新时可追加新内置模板(按 id 去重) + +--- + +## 6. 应用场景 + +### 6.1 场景一:网课全流程(L1 流程固化) + +``` +模板名:网课学习全流程 +步骤: + 1. [采集] 打开回声定位,选择目标窗口,智能路径 + 混合模式(25 分钟) + 2. [笔记] 整理采集到的笔记,补充自己的理解(15 分钟) + 3. [闪卡] 从笔记生成闪卡,完成首轮复习(10 分钟) + 4. [费曼] 选一个核心概念做苏格拉底式追问(10 分钟) + 5. [休息] 起身活动,远眺放松(5 分钟) +``` + +### 6.2 场景二:实验操作练习(L2 程序性知识) + +``` +模板名:有机化学 · 滴定操作 +步骤: + 1. [检查单] 实验前准备 + ☐ 穿戴实验服和护目镜 + ☐ 检查滴定管是否漏液 + ☐ 用待装液润洗滴定管 2-3 次 + ☐ 装液至零刻度以上,排气泡 + 2. [检查单] 滴定操作 + ☐ 左手控制活塞,右手摇瓶 + ☐ 逐滴加入,接近终点时半滴操作 + ☐ 观察指示剂变色,30s 不褪色即为终点 + 3. [检查单] 数据记录 + ☐ 记录初读数和终读数 + ☐ 平行实验至少 3 次 + ☐ 相对偏差 ≤ 0.2% + 4. [费曼] 用自己的话解释:为什么接近终点时要半滴操作? +``` + +### 6.3 场景三:期末复习周(L3 AI 编排) + +``` +用户输入:"下周考高等数学和数据结构,高数比较薄弱" +AI 生成: + Day 1-2: [采集] 回看高数网课录像 → [笔记] 整理错题 + Day 3: [闪卡] 高数公式闪卡强化 → [费曼] 对 3 个薄弱定理做苏格拉底追问 + Day 4: [采集] 回看数据结构网课 → [笔记] 整理算法复杂度对比表 + Day 5: [闪卡] 数据结构 + 高数混合复习 → [深潜] 模拟考试 2 小时 +``` + +### 6.4 场景四:编程学习日常(L1 + L2 混合) + +``` +模板名:LeetCode 日常训练 +步骤: + 1. [深潜] 25 分钟专注审题 + 编码 + 2. [检查单] 代码自检 + ☐ 边界条件处理了吗? + ☐ 时间/空间复杂度分析了吗? + ☐ 能用更优解法吗? + 3. [笔记] 记录解题思路到笔记 + 4. [闪卡] 把关键算法模式生成闪卡 +``` + +--- + +## 7. 扩展性考虑 + +### 7.1 近期扩展(一期范围内) + +| 方向 | 描述 | +|---|---| +| **模板导入/导出** | JSON 格式导入导出,支持同学间分享 | +| **执行统计** | 仪表盘新增"SOP 执行"卡片:本周完成 N 个流程、累计时长、最常跳过的步骤 | +| **步骤备注** | 执行时可为每步添加临时备注,完成后可选存入笔记 | + +### 7.2 中期扩展(二期) + +| 方向 | 描述 | +|---|---| +| **AI 自适应调整** | 执行中发现某步超时,AI 建议"跳过下一步休息,直接进闪卡?" | +| **条件分支** | 步骤支持"如果上一步产出 > N 条笔记,则跳过整理直接复习" | +| **回声定位联动** | 采集结束后自动触发 SOP 的下一步(如"采集完成 → 自动跳转笔记整理") | +| **苏格拉底式 SOP 教练** | 执行完一个 SOP 后,AI 用苏格拉底追问引导反思:"你觉得哪一步最有效?为什么?" | + +### 7.3 远期愿景 + +| 方向 | 描述 | +|---|---| +| **SOP 市场** | 用户发布/订阅他人的学习流程模板(类似 Notion 模板库) | +| **课表驱动** | 与课程预设联动:周一 8:00 自动提示"该执行《高数》的课前预习 SOP 了" | +| **多设备同步** | 通过 sync-service 同步 SOP 模板和执行记录 | +| **SOP 链** | 多个 SOP 串联为"学习项目"(如"考研 90 天计划" = 12 个周 SOP 循环) | + +### 7.4 与 AI 能力的结合矩阵 + +| 现有 AI 能力 | SOP 结合点 | +|---|---| +| 苏格拉底追问(`useAISocratic`) | SOP 完成后的反思引导;检查单步骤的"为什么"追问 | +| 内容分析(`sessionAnalyzer`) | 根据采集内容自动推荐后续 SOP 步骤 | +| 闪卡生成(`useAIFlashcards`) | SOP 中"生成闪卡"步骤的 AI 自动化 | +| 课程检测(`courseDetector`) | 根据窗口标题自动匹配关联的 SOP 模板 | +| 离线队列(`offlineAIQueue`) | AI 生成 SOP 在离线时排队,恢复后自动执行 | + +--- + +## 8. 风险与约束 + +| 风险 | 影响 | 缓解 | +|---|---|---| +| 功能过重,变成"项目管理工具" | 偏离学习工具定位 | 严格限制步骤类型为学习模块枚举,不做通用任务管理 | +| 跨模块调度体验割裂 | 用户跳转后迷失 | 一期用路由跳转 + 顶部"SOP 执行中"悬浮条;二期嵌入 | +| AI 生成质量不稳定 | 用户信任下降 | 生成后可编辑;内置模板兜底;标注"AI 生成"来源 | +| 与课程预设功能重叠 | 用户困惑 | 明确定位:课程预设 = 采集参数预设;SOP = 全流程编排。课程预设作为 SOP 步骤的 config 子集 | +| 单文件 ≤300 行约束 | 编辑器/执行器复杂度高 | 按组件拆分(StepCard/StepPalette/RunProgress 等),hooks 分离逻辑 | + +--- + +## 9. 下一步 + +- [ ] 待决策:确认品牌命名(洋流图 / 深潜航路 / 潮汐节律 / 其他) +- [ ] 待决策:一期范围——建议 L1(流程固化)+ L2(检查单)先落地,L3(AI 编排)二期 +- [ ] 待决策:跨模块调度方案(路由跳转 vs 嵌入式) +- [ ] 若立项:走 brainstorming → spec → 实现计划流程,产出物落 `docs/versions/` 对应版本 diff --git a/docs/README.md b/docs/README.md index 6d02b545..d66b58cd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,6 +41,11 @@ | [pain-points.md](./product/pain-points.md) | 网课学习全链路痛点图谱 | | [requirements-pool.md](./product/requirements-pool.md) | 需求池(含立项规划精要附录) | | [migration-spec.md](./product/migration-spec.md) | 项目迁移与重构规范(含各阶段收尾结论与豁免清单) | +| [beta-tester-intro.md](./product/beta-tester-intro.md) | 内测简介(面向内测用户) | +| [beta-recruitment-playbook.md](./product/beta-recruitment-playbook.md) | 内测招募与运营手册(招募→筛选→增长→运营→过渡全链路) | +| [beta-recruitment-announcement.md](./product/beta-recruitment-announcement.md) | 内测用户招募公告(含一分钟短版 + 完整版) | +| [beta-agreement.md](./product/beta-agreement.md) | 内测协议简版(双方权责、数据处理、风险告知、退出机制) | +| [beta-tier-management.md](./product/beta-tier-management.md) | 内测用户分层管理方案(三层标准、升降级、各层运营动作) | ## 🗓 versions/ — 版本规划(扁平化) diff --git a/docs/knowledge/bugs/2026-07-pomodoro-count-reset-and-duplicate-side-effects.md b/docs/knowledge/bugs/2026-07-pomodoro-count-reset-and-duplicate-side-effects.md new file mode 100644 index 00000000..8da4cf52 --- /dev/null +++ b/docs/knowledge/bugs/2026-07-pomodoro-count-reset-and-duplicate-side-effects.md @@ -0,0 +1,61 @@ +# 知识卡片 · 踩坑记录 + +## 基本信息 + +| 字段 | 内容 | +|------|------| +| 标题 | 番茄钟计数异常:无重置路径的周期计数 + 跨模式状态残留 + store/hook 副作用双重执行 | +| 日期 | 2026-07-31 | +| 类型 | 踩坑记录 | +| 标签 | #Zustand #状态管理 #番茄钟 #副作用 #信号模式 #数据统计 | + +--- + +## 症状 + +内测实测反馈两个表象: + +1. **上课模式**:番茄完成计数一直增加(截图出现 `9/4`),永不归零 +2. **自习模式**:第一遍计数会"跳到 8" 才进长休,后续周期恢复正常(每 4 个进长休) + +排查中额外发现第三个隐藏 bug:**每个完成的番茄会话被重复记录 2 次、提示音双重播放**——用户未感知,但统计数据一直虚高。 + +## 环境 + +| 项目 | 版本/信息 | +|------|----------| +| 状态管理 | Zustand(`usePomodoroStore`)+ 动作信号模式(`lastAction`/`lastActionCounter`) | +| 相关文件 | `client/src/features/pomodoro/store/usePomodoroStore.ts`、`hooks/usePomodoroEffects.ts` | + +## 排查过程 + +1. 通读 `completedCount` 的**全部写入点**(`tick` 完成分支、`skip`、初始化)→ 发现归零只发生在"长休结束"这一条路径 +2. 对照 `getNextPhase`:上课模式被设计为永不进长休(`if (mode === 'class') return 'short_break'`)→ **计数无重置路径,必然无限累加**,症状 1 解释 +3. 关键推理:症状 2 的"8"不是随机数——用户先用上课模式累积了计数(如 4),切到自习模式时 `setMode` **不清计数**,长休判定 `(count + 1) % 4 === 0` 要等 count 到 7(即第 8 个)才触发;长休结束归零后恢复正常。**两个症状同根**:跨模式共享且从不重置的 `completedCount` +4. 排查计数是否被重复递增时(怀疑 interval 重复),顺藤摸瓜发现:`tick()` 在 store 内直接执行 `recordSession`/音效/通知,同时又发出 `phase_complete` 信号;而 `usePomodoroEffects` 收到信号后**把同一批副作用又执行了一遍**——重构时把副作用搬进了 hook,却没有删掉 store 里的旧实现 + +## 根因 + +1. **计数生命周期设计缺陷**:为"上课模式无长休"开了特例,却没有为该特例补充计数重置机制;周期计数必须保证每条路径都有归零出口 +2. **模式切换未重置周期状态**:`setMode` 只改 `mode`,跨模式携带旧计数 +3. **副作用迁移不彻底**:store 内联副作用 + 信号驱动 hook 两套机制并存,同一事件双重执行 + +## 解决方案 + +- 抽出 `getNextCount(phase, count, interval, mode)` 统一 `tick`/`skip` 的计数规则:长休结束归零;上课模式无长休则**按周期回绕**(`(count % interval) + 1`) +- `setMode` 切换模式即计数归零(切模式 = 开启新周期),相同模式提前返回 +- `usePomodoroEffects` 收敛为只做 store 无法执行的 DOM 级副作用(水墨涟漪、通知权限请求),会话记录/音效/通知保留在 store 单一执行 +- 补 5 个回归测试:上课模式计数回绕上界、切模式归零、同模式不归零、上课永不进长休、skip 同样回绕 + +## 教训 + +- **周期性计数器要审计"归零出口"**:每新增一个跳过归零路径的特例(如"X 模式无长休"),必须同步回答"这个特例下计数何时归零"。用穷举写入点的方式验证:`grep` 该字段所有 `set` 调用,画出状态机 +- **用户报出的"魔法数字"是根因线索**:8 = 4(周期)× 2,立刻提示"旧计数被带入 + 判定阈值翻倍",而不是随机抖动——先解释数字再改代码 +- **副作用重构必须删旧留新**:把副作用从 store 迁到信号驱动 hook 时,迁移 PR 的验收标准应包含"旧调用点已删除";否则双重执行类 bug 用户无感知、只污染统计数据,可潜伏很久 +- **对"只在第一次出现"的 bug,优先怀疑跨场景状态残留**(切换模式/页面/账号时未重置的字段),而非偶发时序 +- 重复执行类问题可用**测试断言调用次数**兜底:`expect(recordSession).toHaveBeenCalledTimes(1)`(本仓已有此测试,但它只覆盖 store 层,未挂载 hook,故未拦截住——集成层面的重复执行需要组件级测试覆盖) + +## 参考 + +- 修复提交涉及文件:`usePomodoroStore.ts`、`usePomodoroEffects.ts`、`usePomodoroStore.test.ts` +- 关联构想:预设化改造后用 `longBreakInterval: 0` 表达"无长休",可彻底消除 `mode === 'class'` 特判(见 `docs/Foresight/pomodoro-customization-brainstorm.md` §3.2) diff --git a/docs/knowledge/bugs/2026-07-tailwind-var-alpha-modifier-silent-drop.md b/docs/knowledge/bugs/2026-07-tailwind-var-alpha-modifier-silent-drop.md new file mode 100644 index 00000000..8b47ee93 --- /dev/null +++ b/docs/knowledge/bugs/2026-07-tailwind-var-alpha-modifier-silent-drop.md @@ -0,0 +1,67 @@ +# 知识卡片 · 踩坑记录 + +## 基本信息 + +| 字段 | 内容 | +|------|------| +| 标题 | Tailwind v3 对 `var()` 令牌色的 `/透明度` 修饰符静默失效,明亮主题弹窗背景全透明 | +| 日期 | 2026-07-31 | +| 类型 | 踩坑记录 | +| 标签 | #Tailwind #CSS #主题 #DesignTokens #color-mix #明亮主题 | + +--- + +## 症状 + +内测反馈"明亮主题下弹窗小字看不清":设定番茄目标弹窗在明亮主题下**面板背景完全透明**,描述文字、复选框标签直接叠在明亮的模糊壁纸上,几乎不可读;暗色主题下因遮罩为黑色而"侥幸"可读。 + +无任何构建报错、无 lint 警告、无运行时错误——样式类写在 JSX 里看起来完全正常。 + +## 环境 + +| 项目 | 版本/信息 | +|------|----------| +| Tailwind CSS | 3.4.x | +| 主题方案 | CSS 变量令牌(`--kb-*`)+ `data-theme` 切换,tailwind.config 中 `colors: { bg: { elevated: 'var(--kb-bg-elevated)' } }` | +| 相关文件 | `client/tailwind.config.js`、`client/src/components/ui/Modal.tsx` | + +## 排查过程 + +1. 初判以为是弹窗组件硬编码了暗色文字色 → 检查 `GoalInput.tsx`/`Modal.tsx`,发现全部正确使用了 `text-text-secondary` 等令牌类,**排除组件问题** +2. 对照截图细节:明亮主题下弹窗**整个面板不见了**(只剩遮罩的 backdrop-blur),不只是文字问题 → 怀疑面板背景类 `bg-bg-elevated/90` 未生效 +3. 关键一步:**直接在构建产物 `dist/assets/index-*.css` 里 grep 该类名** → `.bg-bg-elevated\/90` 根本不存在,而无修饰符的 `.bg-bg-elevated` 存在,`bg-black/40`(字面色)也存在 +4. 全仓正则扫描 `(bg|text|border|...)-(令牌)/\d+` → 约 **800 处**使用点全部失效,涉及弹窗、卡片、边框、辅助文字 + +## 根因 + +Tailwind v3 的透明度修饰符依赖将颜色拆成 RGB 通道再拼 `rgb(r g b / alpha)`。当颜色定义为不透明的字符串 `'var(--kb-bg-elevated)'` 时,Tailwind 无法注入 alpha,于是**静默跳过整条 utility 的生成**——不报错、不警告,类名留在 HTML 里但没有对应 CSS。 + +暗色主题未暴露此问题,是因为失效后露出的深色遮罩/背景恰好与设计效果接近,属于"两个错误互相掩盖"。 + +## 解决方案 + +在 `tailwind.config.js` 将令牌色改为**函数式颜色定义**,用 `color-mix()` 实现透明度(Chromium 111+ / Electron 35 支持),一处修复全部恢复: + +```js +const tokenColor = (variable) => ({ opacityValue }) => + opacityValue === undefined + ? `var(${variable})` + : `color-mix(in srgb, var(${variable}) calc(${opacityValue} * 100%), transparent)`; + +// colors: { bg: { elevated: tokenColor('--kb-bg-elevated') }, ... } +``` + +验证:构建产物中生成 `.bg-bg-elevated\/90 { background-color: color-mix(in srgb, var(--kb-bg-elevated) calc(.9 * 100%), transparent) }`;全量测试 433 通过。 + +## 教训 + +- **CSS 变量做 Tailwind 颜色时,必须验证 `/alpha` 修饰符**:要么用函数式 `tokenColor` + `color-mix`,要么把变量定义成裸通道(`--kb-bg: 250 248 245` + `rgb(var(--kb-bg) / )`)。纯 `var()` 字符串 + `/修饰符` 是静默陷阱。 +- **样式"看起来写了但没效果"时,第一时间 grep 构建产物 CSS**,确认类是否真的被生成——比在 DevTools 里逐个元素排查快得多。 +- **双主题项目必须两个主题都过验收**:一个主题下"能看"不代表样式正确,可能是失效样式与该主题底色巧合兼容。 +- 修复此类全局样式基建问题后,发版前应对主要页面做一轮**明亮主题视觉回归**——恢复的是设计原意,但与用户已习惯的"错误效果"存在肉眼差异。 + +## 参考 + +- [Tailwind v3 — Using CSS variables with opacity](https://v3.tailwindcss.com/docs/customizing-colors#using-css-variables) +- [MDN — color-mix()](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color-mix) +- 修复提交涉及文件:`client/tailwind.config.js` diff --git a/docs/knowledge/index.md b/docs/knowledge/index.md index c164b12d..1ca556d4 100644 --- a/docs/knowledge/index.md +++ b/docs/knowledge/index.md @@ -6,6 +6,10 @@ | 日期 | 标题 | 标签 | |------|------|------| +| 2026-07-31 | [课堂助手精细采集三症状:视觉抓页面元数据、ASR 静音幻觉、截断 JSON 泄漏 UI](./bugs/2026-07-classroom-capture-asr-hallucination-json-leak.md) | #课堂助手 #多模态 #ASR幻觉 #prompt工程 | +| 2026-07-31 | [登录失败后持续要求登录:AuthGuard 与“跳过登录”的模式降级缺口 + session-expired 事件风暴](./bugs/2026-07-login-loop-authguard-mode-gap.md) | #认证 #AuthGuard #路由守卫 #模式管理 #事件去重 #死循环 | +| 2026-07-31 | [Tailwind v3 对 `var()` 令牌色的 `/透明度` 修饰符静默失效,明亮主题弹窗背景全透明](./bugs/2026-07-tailwind-var-alpha-modifier-silent-drop.md) | #Tailwind #CSS #主题 #DesignTokens #color-mix | +| 2026-07-31 | [番茄钟计数异常:无重置路径的周期计数 + 跨模式状态残留 + store/hook 副作用双重执行](./bugs/2026-07-pomodoro-count-reset-and-duplicate-side-effects.md) | #Zustand #状态管理 #副作用 #番茄钟 #数据统计 | | 2026-07-31 | [`.env.production` 被 gitignore 致 CI 安装包云服务地址全为空(「云服务尚未配置」)](./bugs/2026-07-ci-env-production-gitignore-supabase-placeholder.md) | #CI #环境变量 #Vite #Supabase #GitHubActions #发布 | | 2026-07-31 | [下载慢诊断中的三次误判:对照实验缺失与观测行为污染测量](./bugs/2026-07-download-slow-misdiagnosis.md) | #性能诊断 #CDN #测量方法 #对照实验 #复盘 | | 2026-07-30 | [Git LFS 图标未在 CI 拉取致 electron-builder 打包报 `ERR_ELECTRON_BUILDER_CANNOT_EXECUTE`](./bugs/2026-07-git-lfs-icon-electron-builder-ci-failure.md) | #CI #GitLFS #electron-builder #GitHubActions #发布 | @@ -25,6 +29,6 @@ _(暂无)_ ## 标签速查 -- **技术**:#CSS #a11y #React #Vite #Tailwind #CI #GitLFS #electron-builder #GitHubActions #CDN #阿里云 #环境变量 #Supabase +- **技术**:#CSS #a11y #React #Vite #Tailwind #Zustand #CI #GitLFS #electron-builder #GitHubActions #CDN #阿里云 #环境变量 #Supabase #DesignTokens #color-mix #认证 #AuthGuard - **类型**:#bug #方案 #学习 #复盘 -- **模块**:#启动仪式 #reduced-motion #animation #发布 #安装包 #性能诊断 #测量方法 +- **模块**:#启动仪式 #reduced-motion #animation #发布 #安装包 #性能诊断 #测量方法 #番茄钟 #主题 #状态管理 #副作用 #数据统计 #路由守卫 #模式管理 #事件去重 diff --git a/docs/product/beta-agreement.md b/docs/product/beta-agreement.md new file mode 100644 index 00000000..051b7865 --- /dev/null +++ b/docs/product/beta-agreement.md @@ -0,0 +1,72 @@ +# 熵减内测协议(简版) + +> 配套文档:[内测招募与运营手册](./beta-recruitment-playbook.md) §3.2/§3.3/§6.3 · [招募公告](./beta-recruitment-announcement.md) +> 使用说明:随内测包发给已通过筛选的用户,请对方回复"已阅读并同意"即视为生效;发布前替换 `[待填]` 占位符。 +> ⚠️ 本文为面向个人开发者内测场景的简明约定,不构成法律意见;如后续商业化或用户规模扩大,建议咨询专业律师完善为正式协议。 + +--- + +欢迎加入熵减内测!这份协议不是冷冰冰的法律文书,而是把"咱们各自承担什么、你的数据怎么处理、怎么退出"提前说清楚——**信任从透明开始**。全文约 3 分钟读完。 + +**协议双方** + +- 开发者:熵减(Entropydecrease)独立开发者 `[待填:署名/昵称]`(下称"我") +- 内测用户:参与本轮内测的你(下称"你") + +**内测周期**:`[待填:起止日期]`,约 `[待填:X]` 周。 + +--- + +## 一、你会得到什么(我的承诺) + +1. **内测软件使用权**:内测期内免费使用熵减内测版全部功能。 +2. **反馈必有回音**:你提交的每条反馈,我会在 **48 小时内**回应处理状态(采纳 / 规划中 / 暂不做 + 理由)。 +3. **贡献被看见**:被采纳的建议将在更新日志中署名感谢(可选择匿名)。 +4. **内测权益**:创始内测体验官身份、新功能优先体验权、路线图投票权,正式版发布后权益保留,具体以发布时公告为准。 +5. **内测酬劳**: + - **基础酬劳** `[待填:金额]`:完成内测期全部基本要求(第二条第 1 款)后,于内测期结束后 7 日内一次性发放; + - **质量奖励**:提交有效 Bug(可复现且未被他人先报)、深度反馈(含场景描述与改进建议)或接受一次深度访谈,按 `[待填:每条/每次金额]` 另行发放,是否有效由我认定并附理由; + - 发放方式:`[待填:微信/支付宝转账]`,备注"内测酬劳"; + - **中途退出的结算**:已获得的质量奖励照常发放,不因退出取消;基础酬劳按实际完成周数比例折算(不足一周按一周计)。 + +## 二、希望你做到什么(你的承诺) + +1. **基本使用**:内测期内每周至少使用 3 次,每周至少提交 1 条有效反馈(吐槽也算)。 +2. **保密**:内测版本、未公开功能截图与安装包**不对外传播**,直到我公开发布或明确同意。 +3. **善意使用**:不对软件进行破解、反编译或恶意攻击测试(如发现安全漏洞,欢迎私下告知我,这算高质量反馈)。 +4. 无法继续参与时告知我一声即可,无需理由(见第五条退出机制)。 + +## 三、你的数据怎么处理(最重要的一条) + +1. **本地优先**:你的笔记、闪卡、学习记录等核心数据**始终存放在你自己的电脑上**,我无法访问。敏感数据加密存储。云端同步为完全可选功能,不开启则无任何数据上传。 +2. **我会收集什么**(最小必要原则): + - 联系方式(微信/QQ):仅用于内测沟通; + - 崩溃日志与匿名使用统计(如功能使用频次):仅用于定位 Bug 和改进产品,**不含你的笔记内容**; + - 你主动提交的反馈内容。 +3. **我不会做什么**:不出售、不共享你的任何信息给第三方;不在未经你同意的情况下公开你的反馈原文(脱敏摘要除外);不收集与产品改进无关的信息。 +4. **你的权利**:可随时要求我删除所持有的你的联系方式与反馈记录;可在软件设置中关闭匿名统计上报;退出内测时本地数据完整保留在你的电脑上,可自行导出或删除。 +5. 使用 AI 功能时,相关内容会经由 AI 服务处理(详见软件内说明);不使用 AI 功能则无此项。 + +## 四、风险告知(说在前面) + +1. **稳定性风险**:这是内测版本,可能出现 Bug、崩溃、功能异常,极端情况下可能需要重装。**建议对重要笔记定期使用导出功能备份。** +2. **功能变动风险**:内测期功能可能调整、重做甚至下线,你投入使用习惯的功能不保证保留到正式版。 +3. **免责与兜底**:内测软件按"现状"提供;若因软件缺陷导致你的数据损坏,我会尽全力协助恢复,但无法承诺完全恢复——这也是第 1 点建议备份的原因。 + +## 五、退出机制(你的"后悔权") + +1. **随时、无条件退出**:一句话告知即可,无需任何理由,我不会追问或挽留。 +2. 退出后:本地数据完整归你;酬劳按第一条第 5 款的中途退出规则结算;我将在 7 天内删除你的联系方式与个人相关记录(脱敏后的反馈内容可能继续用于产品改进);保密义务(第二条第 2 款)在软件公开发布前仍然有效。 +3. 我也可能终止内测(如项目方向调整),会提前 7 天通知,并保证你的本地数据不受影响;此情况下基础酬劳**全额发放**,不按比例折算。 + +## 六、其他 + +1. 本协议自你回复"已阅读并同意"起生效,至内测期结束或任一方退出时终止。 +2. 协议如有调整,我会提前告知,你可选择接受或退出。 +3. 有任何疑问,直接问我:`[待填:联系方式]`。 + +--- + +再次感谢你愿意把时间交给一个一人开发的软件。每一条反馈都是帮熵减点亮的一盏灯。🪼 + +`[待填:署名]` · `[待填:日期]` diff --git a/docs/product/beta-agreement_facetouser.md b/docs/product/beta-agreement_facetouser.md new file mode 100644 index 00000000..b72fd634 --- /dev/null +++ b/docs/product/beta-agreement_facetouser.md @@ -0,0 +1,68 @@ +# 熵减内测协议(简版) + +--- + +欢迎加入熵减内测!这份协议不是冷冰冰的法律文书,而是把"咱们各自承担什么、你的数据怎么处理、怎么退出"提前说清楚——**信任从透明开始**。全文约 3 分钟读完。 + +**协议双方** + +- 开发者:熵减(Entropydecrease)独立开发者 `刘子相`(下称"我") +- 内测用户:参与本轮内测的你(下称"你") + +**内测周期**:`答应日起至公测`,约 `2` 周。 + +--- + +## 一、你会得到什么(我的承诺) + +1. **内测软件使用权**:内测期内免费使用熵减内测版全部功能。 +2. **反馈必有回音**:你提交的每条反馈,我会在 **48 小时内**回应处理状态(采纳 / 规划中 / 暂不做 + 理由)。 +3. **贡献被看见**:被采纳的建议将在更新日志中署名感谢(可选择匿名)。 +4. **内测权益**:创始内测体验官身份、新功能优先体验权、路线图投票权,正式版发布后权益保留,具体以发布时公告为准。 +5. **内测酬劳**: + - **基础酬劳** `30`:完成内测期全部基本要求(第二条第 1 款)后,于内测期结束后 7 日内一次性发放; + - **质量奖励**:提交有效 Bug(可复现且未被他人先报)、深度反馈(含场景描述与改进建议)或接受一次深度访谈,按 `每条/5元`,如遇重大bug反馈,可视情况提升酬劳 另行发放,是否有效由我认定并附理由; + - 发放方式:`[微信/支付宝转账]`,备注"内测酬劳"; + - **中途退出的结算**:已获得的质量奖励照常发放,不因退出取消;基础酬劳按实际完成周数比例折算(不足一周按一周计)。 + +## 二、希望你做到什么(你的承诺) + +1. **基本使用**:内测期内每周至少使用 3 次,每周至少提交 1 条有效反馈(吐槽也算)。 +2. **保密**:内测版本、未公开功能截图与安装包**不对外传播**,直到我公开发布或明确同意。 +3. **善意使用**:不对软件进行破解、反编译或恶意攻击测试(如发现安全漏洞,欢迎私下告知我,这算高质量反馈)。 +4. 无法继续参与时告知我一声即可,无需理由(见第五条退出机制)。 + +## 三、你的数据怎么处理(最重要的一条) + +1. **本地优先**:你的笔记、闪卡、学习记录等核心数据**始终存放在你自己的电脑上**,我无法访问。敏感数据加密存储。云端同步为完全可选功能,不开启则无任何数据上传。 +2. **我会收集什么**(最小必要原则): + - 联系方式(微信/QQ):仅用于内测沟通; + - 崩溃日志与匿名使用统计(如功能使用频次):仅用于定位 Bug 和改进产品,**不含你的笔记内容**; + - 你主动提交的反馈内容。 +3. **我不会做什么**:不出售、不共享你的任何信息给第三方;不在未经你同意的情况下公开你的反馈原文(脱敏摘要除外);不收集与产品改进无关的信息。 +4. **你的权利**:可随时要求我删除所持有的你的联系方式与反馈记录;可在软件设置中关闭匿名统计上报;退出内测时本地数据完整保留在你的电脑上,可自行导出或删除。 +5. 使用 AI 功能时,相关内容会经由 AI 服务处理(详见软件内说明);不使用 AI 功能则无此项。 + +## 四、风险告知(说在前面) + +1. **稳定性风险**:这是内测版本,可能出现 Bug、崩溃、功能异常,极端情况下可能需要重装。**建议对重要笔记定期使用导出功能备份。** +2. **功能变动风险**:内测期功能可能调整、重做甚至下线,你投入使用习惯的功能不保证保留到正式版。 +3. **免责与兜底**:内测软件按"现状"提供;若因软件缺陷导致你的数据损坏,我会尽全力协助恢复,但无法承诺完全恢复——这也是第 1 点建议备份的原因。 + +## 五、退出机制(你的"后悔权") + +1. **随时、无条件退出**:一句话告知即可,无需任何理由,我不会追问或挽留。 +2. 退出后:本地数据完整归你;酬劳按第一条第 5 款的中途退出规则结算;我将在 7 天内删除你的联系方式与个人相关记录(脱敏后的反馈内容可能继续用于产品改进);保密义务(第二条第 2 款)在软件公开发布前仍然有效。 +3. 我也可能终止内测(如项目方向调整),会提前 7 天通知,并保证你的本地数据不受影响;此情况下基础酬劳**全额发放**,不按比例折算。 + +## 六、其他 + +1. 本协议自你回复"已阅读并同意"起生效,至内测期结束或任一方退出时终止。 +2. 协议如有调整,我会提前告知,你可选择接受或退出。 +3. 有任何疑问,直接问我。 + +--- + +再次感谢你愿意把时间交给一个一人开发的软件。每一条反馈都是帮熵减点亮的一盏灯。🪼 + +`刘子相` · `2026.7.31` diff --git a/docs/product/beta-recruitment-announcement facetouese.md b/docs/product/beta-recruitment-announcement facetouese.md new file mode 100644 index 00000000..0e64f7e4 --- /dev/null +++ b/docs/product/beta-recruitment-announcement facetouese.md @@ -0,0 +1,80 @@ +# 熵减内测用户招募公告 + +--- + +## 一分钟版(QQ 群 / 评论区 / 朋友圈转发用) + +> 我一个人做了一年的 AI 学习软件「熵减」,现在找 **10 位内测体验官**。 +> 它能干嘛:看网课/教程视频时自动截关键画面 + 转写语音,AI 一键生成结构化笔记;还有番茄钟、闪卡间隔复习、费曼学习法 AI 追问。 +> 特点:**断网也能用,数据全在你自己电脑上**(本地优先 + AI 增强可选)。 +> 要求:Windows 电脑,每周用 3 次以上,愿意说真话(吐槽越狠越好)。 +> 权益:终身免费的内测身份、你的建议直接进产品、更新日志署名感谢;完成内测还有一份酬劳心意(基础酬劳 + 优质反馈另有奖励)。 +> 随时可退,数据可完整导出。感兴趣加我:`[待填:联系方式]` + +--- + +## 完整版公告 + +### 熵减(Entropydecrease)内测体验官招募 + +大家好,我是熵减的开发者——是的,整个"团队"就我一个人。 + +**为什么做这个软件** + +我自己被网课折磨过:看视频不停暂停、截图、记笔记,一节 40 分钟的课能看出两小时;记完的笔记堆在文件夹里再也没打开过;复习全凭感觉,考前才发现"以为懂了"的地方全是坑。 + +熵减就是为解决这些事做的——一款 **AI 智能知识管理桌面应用**,践行费曼学习法与间隔重复。核心理念是**本地优先 + AI 增强可选**:断网也能完整使用,你的笔记、闪卡、学习数据始终存放在你自己的电脑上,云端同步完全可选。 + +**它现在能做什么** + +- 📹 **课堂助手**(重点体验):任何你想学的视频——网课、B 站教程、技术分享、纪录片——自动捕捉关键画面 + 转写语音,AI 生成结构化笔记 +- 🍅 **番茄钟**:三段式计时自动轮转,全屏沉浸专注 +- 📝 **智能笔记**:富文本编辑、模板、文件夹 + 标签 + 全文搜索 +- 🃏 **闪卡**:间隔重复算法按遗忘曲线安排复习 +- 🧠 **费曼学习法**:用自己的话讲出来,AI 苏格拉底式追问,暴露理解盲区 +- 💡 **灵感空间**:随手记灵感,AI 自动分拣 + +**找什么样的你(本批招募 [5-10] 人)** + +满足任一即可: + +- 正在跟网课/视频学习:考研、考证、技术自学、兴趣钻研 +- 被"记笔记低效、复习无序、学了就忘"困扰过 +- 喜欢折腾效率工具(Notion / Obsidian / Anki 用户尤其欢迎) + +硬性条件: + +- 有 Windows 电脑(macOS 版本在路上) +- 内测期(约 [待填:X 周])每周至少使用 3 次 +- 每周至少提交 1 条有效反馈(一句"这里好难用"也算) + +**你能得到什么** + +- 🎖 **创始内测体验官**身份:正式版永久保留标识与专属权益 +- 🗳 你的建议直接影响产品:被采纳的反馈会在更新日志**署名感谢** +- 🚀 永远第一批体验新功能,可参与路线图投票 +- 💬 与开发者直接对话——没有客服转接,你说的每句话我都看 +- 🧧 **内测酬劳**:完成内测期基本要求(每周 3 次使用 + 1 条反馈),期末发放基础酬劳 `[30]`;提交有效 Bug、深度反馈或接受访谈,另按条发放质量奖励 。金额不大,是心意——你的时间值得被尊重(结算细则见《内测协议》) + +**你需要知道的风险(说在前面)** + +- 这是内测版本,**会有 Bug**,可能遇到功能异常或需要重装的情况 +- 部分 AI 功能依赖网络,断网时降级为基础模式 +- 课堂助手目前仅捕获系统音频,暂不支持麦克风 + +对应的保障:你的数据本地存储、敏感数据加密;加入前会收到一份简明的[《内测协议》](./beta-agreement.md),写清数据怎么用、双方各自承担什么;**随时可以无条件退出**,退出时数据可完整导出带走。 + +**如何报名** + +1. 添加联系方式:`17359586872` +2. 填写报名表(1 分钟):`[待填:表单链接]` + - 表单最后一题是"为什么想参加内测"——认真写,这是我筛选的主要依据 +3. 通过后会收到内测包 + 《内测协议》+ 快速上手指引 + +名额有限([待填:X] 人),按报名表质量而非先后顺序筛选。 + +--- + +感谢看到这里。每一条反馈都是帮熵减点亮的一盏灯。🪼 + +`[待填:署名 / 发布日期]` diff --git a/docs/product/beta-recruitment-announcement.md b/docs/product/beta-recruitment-announcement.md new file mode 100644 index 00000000..0e436e5c --- /dev/null +++ b/docs/product/beta-recruitment-announcement.md @@ -0,0 +1,83 @@ +# 熵减内测用户招募公告 + +> 配套文档:[内测招募与运营手册](./beta-recruitment-playbook.md) · [内测简介](./beta-tester-intro.md) · [内测协议](./beta-agreement.md) +> 使用说明:发布前替换所有 `[待填]` 占位符;短渠道(QQ 群 / 评论区)可只用"一分钟版"。 + +--- + +## 一分钟版(QQ 群 / 评论区 / 朋友圈转发用) + +> 我一个人做了一年的 AI 学习软件「熵减」,现在找 **10 位内测体验官**。 +> 它能干嘛:看网课/教程视频时自动截关键画面 + 转写语音,AI 一键生成结构化笔记;还有番茄钟、闪卡间隔复习、费曼学习法 AI 追问。 +> 特点:**断网也能用,数据全在你自己电脑上**(本地优先 + AI 增强可选)。 +> 要求:Windows 电脑,每周用 3 次以上,愿意说真话(吐槽越狠越好)。 +> 权益:终身免费的内测身份、你的建议直接进产品、更新日志署名感谢;完成内测还有一份酬劳心意(基础酬劳 + 优质反馈另有奖励)。 +> 随时可退,数据可完整导出。感兴趣加我:`[待填:联系方式]` + +--- + +## 完整版公告 + +### 熵减(Entropydecrease)内测体验官招募 + +大家好,我是熵减的开发者——是的,整个"团队"就我一个人。 + +**为什么做这个软件** + +我自己被网课折磨过:看视频不停暂停、截图、记笔记,一节 40 分钟的课能看出两小时;记完的笔记堆在文件夹里再也没打开过;复习全凭感觉,考前才发现"以为懂了"的地方全是坑。 + +熵减就是为解决这些事做的——一款 **AI 智能知识管理桌面应用**,践行费曼学习法与间隔重复。核心理念是**本地优先 + AI 增强可选**:断网也能完整使用,你的笔记、闪卡、学习数据始终存放在你自己的电脑上,云端同步完全可选。 + +**它现在能做什么** + +- 📹 **课堂助手**(重点体验):任何你想学的视频——网课、B 站教程、技术分享、纪录片——自动捕捉关键画面 + 转写语音,AI 生成结构化笔记 +- 🍅 **番茄钟**:三段式计时自动轮转,全屏沉浸专注 +- 📝 **智能笔记**:富文本编辑、模板、文件夹 + 标签 + 全文搜索 +- 🃏 **闪卡**:间隔重复算法按遗忘曲线安排复习 +- 🧠 **费曼学习法**:用自己的话讲出来,AI 苏格拉底式追问,暴露理解盲区 +- 💡 **灵感空间**:随手记灵感,AI 自动分拣 + +**找什么样的你(本批招募 [待填:X] 人)** + +满足任一即可: + +- 正在跟网课/视频学习:考研、考证、技术自学、兴趣钻研 +- 被"记笔记低效、复习无序、学了就忘"困扰过 +- 喜欢折腾效率工具(Notion / Obsidian / Anki 用户尤其欢迎) + +硬性条件: + +- 有 Windows 电脑(macOS 版本在路上) +- 内测期(约 [待填:X 周])每周至少使用 3 次 +- 每周至少提交 1 条有效反馈(一句"这里好难用"也算) + +**你能得到什么** + +- 🎖 **创始内测体验官**身份:正式版永久保留标识与专属权益 +- 🗳 你的建议直接影响产品:被采纳的反馈会在更新日志**署名感谢** +- 🚀 永远第一批体验新功能,可参与路线图投票 +- 💬 与开发者直接对话——没有客服转接,你说的每句话我都看 +- 🧧 **内测酬劳**:完成内测期基本要求(每周 3 次使用 + 1 条反馈),期末发放基础酬劳 `[待填:金额]`;提交有效 Bug、深度反馈或接受访谈,另按条发放质量奖励 `[待填:标准]`。金额不大,是心意——你的时间值得被尊重(结算细则见《内测协议》) + +**你需要知道的风险(说在前面)** + +- 这是内测版本,**会有 Bug**,可能遇到功能异常或需要重装的情况 +- 部分 AI 功能依赖网络,断网时降级为基础模式 +- 课堂助手目前仅捕获系统音频,暂不支持麦克风 + +对应的保障:你的数据本地存储、敏感数据加密;加入前会收到一份简明的[《内测协议》](./beta-agreement.md),写清数据怎么用、双方各自承担什么;**随时可以无条件退出**,退出时数据可完整导出带走。 + +**如何报名** + +1. 添加联系方式:`[待填:联系方式]` +2. 填写报名表(1 分钟):`[待填:表单链接]` + - 表单最后一题是"为什么想参加内测"——认真写,这是我筛选的主要依据 +3. 通过后会收到内测包 + 《内测协议》+ 快速上手指引 + +名额有限([待填:X] 人),按报名表质量而非先后顺序筛选。 + +--- + +感谢看到这里。每一条反馈都是帮熵减点亮的一盏灯。🪼 + +`[待填:署名 / 发布日期]` diff --git a/docs/product/beta-recruitment-playbook.md b/docs/product/beta-recruitment-playbook.md new file mode 100644 index 00000000..ad15384a --- /dev/null +++ b/docs/product/beta-recruitment-playbook.md @@ -0,0 +1,281 @@ +# 熵减内测招募与运营手册(Beta Recruitment Playbook) + +> 定位:将"内测人员招募"从一次性动作变为可复制的运营体系。 +> 核心逻辑链:**明确目标 → 招募(找到人)→ 筛选(找对人)→ 增长(人带人)→ 运营(留住人)→ 支撑机制(反馈/版本/激励/数据)→ 过渡与毕业**。 +> 底层原则:所有策略的核心是**真诚**和**价值交换**——技术、心理、运营技巧都是辅助,真正的增长引擎是对用户需求的深刻理解和产品的持续迭代。 + +关联文档:[内测简介](./beta-tester-intro.md) · [用户反馈与支持规范](../standards/user-feedback-support.md) · [痛点图谱](./pain-points.md) + +--- + +## 0. 总览 + +```mermaid +flowchart LR + G[目标: 方向而非结果] --> A[招募
人数流/冷启动] + A --> B[筛选
客户流/启动门槛] + B --> C[增长
链式反应] + C --> D[运营
分级管理/氛围/人设] + D --> E[支撑机制
反馈·版本·激励·数据·合规] + E --> F[过渡
公测/毕业仪式] + E -.反哺.-> A +``` + +--- + +## 1. 目标:方向而非结果 + +- 目标是**方向**("获得真实反馈以打磨产品"),不是执念数字("必须招到 100 人")。 +- 设定**过程目标**而非仅结果目标:如"每周与 3 位内测用户深度对话"、"每两周发一个内测版本"。 +- **科学依据**:目标设置理论——明确、可接受、有反馈的目标才能提升绩效;把方向拆成可执行的过程目标并定期回顾。 +- ⚠️ **样本量校正(Nielsen 定律)**:5 个用户即可发现约 85% 的可用性问题。内测早期 **5-10 人的深度沟通 > 100 人潜水**。人数焦虑是伪命题,深度才是稀缺品。 + +**熵减的内测北极星(建议)**: + +| 阶段 | 北极星指标 | 辅助指标 | +|------|-----------|---------| +| 首批(5-10 人) | 每人 ≥1 次深度访谈 | 课堂助手笔记质量评分 | +| 二批(20-50 人) | 次周留存率 | 有效反馈条数/周 | +| 公测前 | Sean Ellis PMF 测试 ≥40% | 激活率(首次完成一次完整学习闭环) | + +> Sean Ellis PMF 测试:问"如果不能再使用熵减,你的感受?","非常失望"占比 ≥40% 即接近产品市场匹配。 + +--- + +## 2. 招募:人数流与冷启动 + +### 2.1 先分清阶段 + +- **种子用户**:产品理念形成期的共创者,认同愿景,容忍粗糙。 +- **内测用户**:功能验证期的测试者,关注可用性。 +- 熵减当前处于两者之间:首批按种子用户标准找(认同"本地优先 + AI 增强"理念的学习者),二批起按内测用户标准扩。 + +### 2.2 冷启动渠道(结合熵减实际) + +目标用户是**学生与终身学习者**,渠道优先级: + +| 优先级 | 渠道 | 理由 | +|--------|------|------| +| ⭐⭐⭐ | 身边关系链(同学/学习搭子/社群熟人) | 信任成本最低,首批 5-10 人来源 | +| ⭐⭐⭐ | B 站学习区 / 小红书学习博主评论区 | 目标用户密度最高 | +| ⭐⭐ | 考研群、考证群、QQ 学习群 | 学生群体主阵地在 QQ 而非企微,触达渠道要跟着用户走 | +| ⭐⭐ | 少数派、V2EX、Obsidian/Notion 中文社区 | 工具型早期采用者聚集地,反馈质量高 | +| ⭐ | 知乎、公众号长文 | 内容吸引,慢热但可持续 | + +- 招募贴清晰说明:项目愿景、对内测者的价值、预计投入时间、**退出机制**(明示可随时退出,反而降低加入的风险感知)。 +- **科学依据**:社会认同理论(人们参照他人行为决策)、社会促进效应(可感知的活跃社区激发参与)。 + +### 2.3 内容吸引:Build in Public(公开开发日记) + +比"营造繁荣感"更安全、更可持续的路线:定期公开真实的开发进度、踩坑记录、设计取舍。真实过程本身就是内容,天然积累信任与关注(详见 §6.3 红线)。 + +### 2.4 加上微信后的第一次对话(破冰 SOP) + +加上好友 ≠ 完成招募。前 3 次对话决定对方是成为共创者还是躺在通讯录里。核心心法:**先聊"他",再聊"产品"——把自己定位成"同为学习者的开发者",而不是"来推销软件的人"。** + +**第一步:开场(加上后 5 分钟内发,趁场景热度)** + +- 结构 = 自报家门 + 说明来源 + 一句真诚的钩子,一条消息说完,不刷屏: + > "你好呀,我是熵减的开发者(就我一个人在做😂)。看到你在 xx 群里说网课记笔记很痛苦,我做这个软件就是因为自己被这事折磨过——想先听听你平时是怎么学的?" +- 三个要点:**坦白单人开发**(真实感即信任感)、**引用对方说过的话**(证明你看见了他,不是群发)、**结尾是关于他的开放问题**(不是"要不要试试我的软件")。 +- 🚫 不要:加上就甩下载链接/长篇产品介绍/连发多条——这是推销行为,瞬间触发心理防御。 + +**第二步:破冰话题清单(聊什么)** + +按"他的痛点 → 他的现状 → 你的故事"推进,产品放最后: + +| 话题 | 示例问法 | 目的 | +|------|---------|------| +| 学习场景 | "你最近主要在学什么?考研还是技术?" | 建立画像,判断价值匹配(§3.1) | +| 痛点挖掘 | "看网课的时候,暂停记笔记这事你怎么处理的?" | 对照[痛点图谱](./pain-points.md)验证需求,他吐槽越多参与意愿越强 | +| 现有方案 | "现在用什么工具?Notion?纸笔?" | 了解竞品心智,找切入点 | +| 自我表露 | "我当年复习就是笔记记了一堆从来不看,所以才做了闪卡功能" | 用自己的失败经历换取对方的真实吐槽 | +| 产品(最后才聊) | "你刚说的这个问题,我软件里正好有个功能在解决,想不想帮我看看它做得对不对?" | 把"下载试用"重构为"请你来评判",给对方专家位置 | + +**第三步:首周跟进节奏** + +- D0 破冰 → D1-2 发内测包 + 一句"卡在哪随时喊我" → D3-4 主动问一次首次体验(只问一个具体问题,如"课堂助手生成的笔记你觉得能用吗")→ D7 视活跃度决定拉群或轻量维持。 +- 每次对话记录关键信息(学什么、痛点、用什么工具),存入用户标签(§5.1)——下次开口能接上上次的话,信任是"被记住"堆出来的。 + +**科学依据**: + +- **自我表露互惠**:先适度暴露自己的弱点(单人开发、自己的学习失败史),对方会回以同等深度的真实信息。 +- **相似性吸引**:"我也被网课折磨过"的同类身份,比"我的产品很强"的权威身份更快建立好感。 +- **互惠原理**:先给价值再提请求——破冰期可以先分享一个学习方法/资源,而不是先要对方花时间测试。 +- **富兰克林效应**:请对方"帮个小忙"(帮我看看这功能做得对不对)反而比"给对方好处"更能拉近关系——人会为自己的付出行为寻找合理化("我帮他是因为我认可他")。 + +⚠️ **红线**:破冰话术可以有模板,但每条消息必须含有"只属于这个人"的信息(他说过的话、他的学习场景)。纯模板群发一旦被识破,§3.2 建立的所有信任清零。 + +--- + +## 3. 筛选:客户流与启动门槛 + +### 3.1 基本条件:价值匹配 + +内测者要么有强烈需求痛点(网课低效、复习无序——见[痛点图谱](./pain-points.md)),要么能从产品获得直接收益(效率提升)。不匹配的人进来只会稀释信噪比。 + +### 3.2 安全性说明:建立信任 + +- 定期、透明同步项目进展、开发者背景、数据使用政策。 +- 发布简版《内测协议》:数据安全、反馈时限、退出机制、双方权责。 +- 熵减的天然优势要讲透:**本地优先存储、敏感数据加密、云端同步完全可选**——这本身就是最强的信任背书。 +- **科学依据**:信任的认知三维度(能力、正直、仁慈)——公开开发过程展示能力与正直,数据保护承诺体现仁慈。 + +### 3.3 风险告知与承担 + +- 明示风险类型:时间风险(投入但产品可能调整)、效能风险(内测版不稳定)、隐私风险(及对应保护措施)。 +- 提供"后悔权":随时无条件退出,数据可完整导出。 +- 建立反馈响应机制与奖励计划,把用户从"风险承担者"转变为"共创者"。 +- **科学依据**:风险感知理论——风险被明确告知并给出应对方案时,感知风险显著降低。 + +### 3.4 最低启动门槛 + +- 定最低要求:如"每周至少使用 3 次"、"提交至少 1 条有效反馈"。既筛认真用户,又给出行动框架。 +- **申请表即承诺装置**:让申请者写一段"为什么想参加内测"——公开承诺提升后续留存(承诺一致性原理)。 +- **科学依据**:登门槛效应——先接受小要求,后续接受更大要求(深度测试、访谈)的概率更高。 + +--- + +## 4. 增长:链式反应 + +- **邀请制**:首批用户发放邀请码,邀请者成为二批,形成链式扩散。 +- 推荐奖励与产品核心价值绑定(高级功能、专属通道、更新日志署名),而非纯物质激励——吸引真正有需求的人。 +- **内测等级**:首批为"资深内测员",享更早体验新功能等权益,满足独特感与身份认同。 +- **科学依据**:六度分隔理论(关系链是最低成本增长路径)、创新扩散理论(首批用户属"创新者/早期采用者",其人际网络聚集同类)。 +- ⚠️ **幸存者偏差警示**:邀请链会让用户画像趋同(重度工具爱好者扎堆)。二批起主动补充"轻度用户"样本,否则产品会被带向小众。 + +--- + +## 5. 运营:分级管理、氛围与人设 + +### 5.1 分级化管理 + +按**投入度 × 能力**分层: + +| 层级 | 特征 | 运营方式 | +|------|------|---------| +| 核心反馈者 | 高频使用 + 高质量反馈 | 小群深度互动,可影响路线图,直接对话开发者 | +| 普通用户 | 正常使用,偶尔反馈 | 大群公告 + 周报触达 | +| 潜水观察者 | 低频使用 | 定期一对一轻量回访,判断是流失还是节奏问题 | + +- 用标签体系标记用户(高频用户 / 反馈达人 / 活跃推广者),个性化运营。 +- **科学依据**:自我决定理论——自主感(我选择参与)、胜任感(我的建议有效)、归属感(我是核心成员)三者满足时内在动机最强。 +- 📘 完整的分层标准、升降级机制、各层运营动作与落地工具,见[分层管理方案](./beta-tier-management.md)。 + +### 5.2 氛围营造(替代"伪造性"的正确姿势) + +- 分享真实的产品改进截图、用户反馈摘要(脱敏)、讨论片段。 +- 定期周报:"本周新增内测员 X 人、有效反馈 X 条、因用户 A 建议改进了 X 功能"——制造增长与参与感势头。 +- **科学依据**:社会认同原理、羊群效应——初期展示的活跃度可引发真实的跟随行为。 +- 🚫 **红线见 §6.3**:势头可以放大呈现,事实不可虚构。 + +### 5.3 人设与触达节奏 + +- 打造"开发者本人"人设:分享开发日常、技术攻坚、偶尔生活化内容,拉近距离。 +- 固定节奏输出(每周 2-3 条动态):产品进度(带图)、反馈案例、技术科普、行业思考。 +- 分时段覆盖:工作日发进度干货,晚间/周末发轻松话题与用户故事。 +- 每月一次线上分享会,邀核心用户分享使用心得,强化社区感。 +- **科学依据**:拟人化理论(有人设的账号更易建立亲近感)、时间一致性偏好(固定时间固定风格降低认知成本、养成关注习惯)。 +- ⚠️ **可持续性优先(单人开发者)**:所有承诺按"最差状态也能兑现"的标准定。"48 小时内回应"优于承诺不了的"24 小时";违诺的伤害远大于低承诺。运营节奏宁可低频稳定,不要高频崩断。 + +### 5.4 学生群体节奏适配 + +- 考试周、寒暑假会造成活跃度**周期性波动**,勿误判为流失;重要动作(新版本、访谈)避开考试周。 +- 免费敏感度高:激励以权益、身份、署名感谢为主,物质激励为辅。 +- 峰终定律应用:设计"峰值时刻"(建议被采纳并在更新日志署名感谢)与"终值"(内测毕业仪式),用户对整段经历的记忆由峰值与结尾决定。 + +--- + +## 6. 支撑机制 + +### 6.1 反馈收集与处理 + +- 渠道:群内即时反馈 + 结构化表单 + 每月深度访谈。 +- 闭环原则:**每条反馈必有回音**(采纳/规划中/暂不做 + 理由),并公开展示"因你而改"清单。 +- 用 **Kano 模型**给反馈分类(必备 / 期望 / 兴奋 / 无差异),防止被声音最大的需求牵着走。 +- **科学依据**:期望确认理论——满意度取决于期望与实际感知的比较,及时反馈能管理期望。 + +### 6.2 内测版本管理(桌面应用特有) + +- 分发渠道:内测更新通道(electron-updater 独立 channel)为主,安装包直发为备份。 +- 每个内测版附简短更新说明:改了什么、希望重点测什么。 +- 保留回滚路径:新版严重问题时可指引用户退回上一版本,数据向前兼容。 +- 遥测配合:崩溃日志自动收集(需在协议中明示)负责"发现问题",用户主动反馈负责"理解问题",二者互补而非替代。 +- **科学依据**:控制感理论——让用户可选测试功能/退出遥测,提升参与度与信任。 + +### 6.3 合规与伦理红线 🚫 + +- **虚构用户数据 = 法律风险**:编造用户数、伪造反馈截图可能构成虚假宣传(《反不正当竞争法》),而不仅是道德问题。 +- 收集用户信息(微信号、使用数据、崩溃日志)受《个人信息保护法》约束:明示收集范围与用途、最小必要、可撤回。 +- 可以做的:放大真实亮点、精选真实反馈、呈现真实增长势头。不可以做的:无中生有的数据、功能、用户。 +- 熵减的"本地优先、数据在用户自己电脑"是合规与信任的双重优势,运营话术反复强化。 + +### 6.4 数据埋点与度量 + +- 埋点原则:**可行动性反馈**——每个埋点都要能回答一个产品决策问题,不为展示而埋。 +- 关键漏斗(AARRR 简化版):安装 → 激活(完成一次完整学习闭环,如"录一节课 → 生成笔记 → 复习一张闪卡")→ 次周留存 → 推荐(发出邀请码)。 +- ⚠️ **霍桑效应警示**:内测用户知道自己被观察,行为偏积极;数据结论要打折扣,公测前需小范围"静默验证"。 + +### 6.5 激励与认可 + +- 精神激励优先:更新日志署名感谢、内测荣誉身份、优先体验权、路线图投票权。 +- 公开展示贡献:"本月反馈之星"、贡献榜。 +- **科学依据**:自我肯定理论——公开认可满足维持积极自我形象的需要。 + +**有偿内测设计规范**: + +- 报酬结构 = **基础酬劳**(完成内测期全部基本要求后一次性发放)+ **质量奖励**(有效 Bug / 深度反馈 / 接受访谈按条计酬)。**按质量计酬,不按时长计酬**——按时长会换来挂机,按条数不限质量会换来灌水。 +- 基础酬劳期末一次性发放而非按周发:既是留存钩子,也避免把参与感变成"打工打卡"。 +- 金额定位是**"心意"而非"工资"**:金额不必高,但仪式感要足(附手写致谢/署名证书一起发);预算有限时优先把钱花在质量奖励上。 +- ⚠️ **过度理由效应(德西效应)警示**:外在金钱奖励可能挤出内在动机——本来"因为喜欢才参与"的用户,拿钱后会重新归因为"为钱参与",钱停则人走。所以:报酬只作为"感谢"而非"交易"来叙事,精神激励仍是主体;招募时报酬不作为第一卖点,避免吸引"赏金猎人"型申请者(与 §3.1 价值匹配冲突)。 +- 合规提醒:报酬发放需留存记录(转账备注"内测酬劳");结算规则、中途退出怎么算,必须在《内测协议》中事先写清,避免事后扯皮。 + +--- + +## 7. 过渡:从内测到公测 + +- 提前告知内测用户后续计划与专属过渡福利(如永久内测徽章、正式版权益)。 +- 设计"毕业仪式":公开致谢、内测成果总结(收到 X 条反馈、改进 X 项)、颁发身份认证。 +- 引导内测用户成为公测期的推广节点与新人答疑者(身份升级:测试者 → 布道者)。 +- **科学依据**:过渡仪式理论——通过毕业/升级仪式强化身份转变与忠诚度。 + +--- + +## 8. 执行清单(6 周计划) + +### 准备阶段(第 0-1 周) +- [ ] 明确内测方向与北极星指标(§1) +- [ ] 完成《内测招募公告》与简版《内测协议》(含数据说明、退出机制) +- [ ] 确定触达渠道组合(企微/QQ 群按目标人群定,§2.2)与人设、内容模板 +- [ ] 搭好反馈渠道(群 + 表单)与内测分发通道(§6.2) + +### 启动招募(第 1 周) +- [ ] 关系链邀请首批种子用户 5-10 人(配合[内测简介](./beta-tester-intro.md)) +- [ ] 按破冰 SOP 完成每位新好友的首次对话与 D7 跟进(§2.4) +- [ ] 申请表加入"为什么想参加"(承诺装置,§3.4) +- [ ] 开始固定节奏发布开发日记 / 进度动态 + +### 运营与扩张(第 2-4 周) +- [ ] 每周与 3+ 核心用户深度对话,快速迭代 +- [ ] 发放邀请码启动链式反应,二批注意补充轻度用户样本(§4) +- [ ] 建立分级群组与用户标签 +- [ ] 每周发布真实数据周报,公示"因你而改"清单 + +### 评估与过渡(第 5-6 周) +- [ ] 跑一轮 PMF 测试与留存复盘(§1、§6.4) +- [ ] 公开致谢核心用户,展示改进成果 +- [ ] 公布公测/正式版计划与内测用户过渡福利 + +--- + +## 附:常见认知陷阱速查 + +| 陷阱 | 表现 | 对策 | +|------|------|------| +| 人数焦虑 | 追求招募数字 | Nielsen 定律:早期 5-10 人深聊即可(§1) | +| 幸存者偏差 | 只听活跃用户的 | 主动回访潜水者,补轻度用户样本(§4) | +| 霍桑效应 | 内测数据过于乐观 | 数据打折,公测前静默验证(§6.4) | +| 过度承诺 | 24h 响应、周更两版 | 按最差状态定承诺(§5.3) | +| 伪造性越界 | 虚构数据/反馈 | 只放大真实,不无中生有(§6.3) | +| 需求噪音 | 被声音大的需求牵走 | Kano 模型分类 + 路线图定力(§6.1) | +| 误判流失 | 考试周活跃下跌 | 识别学生节奏的周期波动(§5.4) | diff --git a/docs/product/beta-tester-intro.md b/docs/product/beta-tester-intro.md new file mode 100644 index 00000000..eadc0a40 --- /dev/null +++ b/docs/product/beta-tester-intro.md @@ -0,0 +1,34 @@ +# 熵减(Entropydecrease)内测简介 + +欢迎加入熵减内测! + +**熵减是一款 AI 智能知识管理桌面应用。** 它不只服务于"学习"——上课、考证、职场充电、研究行业动态、钻研兴趣爱好……只要你想获取并留住知识,它都适用。核心理念是**本地优先 + AI 增强可选**:断网也能完整使用,数据始终存放在你自己的电脑上。 + +## 核心功能 + +- **📹 课堂助手(回声定位)⭐ 重点体验**:不局限于网课!任何你想从中获取知识的视频——B 站/YouTube 教程、技术分享、发布会、纪录片、会议录屏——它都能自动捕捉关键画面 + 转写语音,AI 一键生成结构化笔记。支持智能(默认)、精细、录制三种模式。 +- **🍅 番茄钟(深潜)**:三段式计时自动轮转,全屏沉浸专注,后台不中断。 +- **📝 智能笔记(结礁)**:富文本编辑,多种模板,文件夹 + 标签 + 全文搜索。 +- **🃏 闪卡(反衰减呼吸)**:间隔重复算法按遗忘曲线安排复习,对抗遗忘、减少无效复习。 +- **🧠 费曼学习法(浮出水面)**:用自己的话讲出来,AI 苏格拉底式追问,暴露"以为懂了"的盲区。 +- **💡 灵感空间(萤火海沟)**:随手记录灵感,AI 自动分拣,3D 球面呈现。 +- **⚙️ 启动仪式 & 📊 仪表盘**:呼吸引导帮你快速进入状态,数据与成就让进步可见。 + +## 数据与隐私 + +本地优先存储、敏感数据加密、云端同步完全可选;AI 服务不可用时自动降级,不阻断任何功能。 + +## 已知限制 + +- 课堂助手目前仅捕获电脑播放的声音(系统音频),暂不支持麦克风 +- 断网时部分 AI 功能降级为基础模式,效果弱于云端 + +## 希望你重点反馈 + +1. 用**各种类型的视频**(教程、演讲、纪录片、会议录屏等)测试课堂助手的笔记质量 +2. 断网状态下核心功能是否完整可用 +3. 长时间运行的稳定性,以及任何让你困惑的瞬间 + +--- + +感谢参与,每一条反馈都是帮熵减点亮的一盏灯。🪼 diff --git a/docs/product/beta-tier-management.md b/docs/product/beta-tier-management.md new file mode 100644 index 00000000..08b91a89 --- /dev/null +++ b/docs/product/beta-tier-management.md @@ -0,0 +1,151 @@ +# 熵减内测用户分层管理方案 + +> 配套文档:[内测招募与运营手册](./beta-recruitment-playbook.md) §5.1 · [内测协议](./beta-agreement.md) · [招募公告](./beta-recruitment-announcement.md) +> 定位:把手册 §5.1 的分层概念落成可执行的制度——**谁在哪一层、怎么升降、每层给什么、我对每层花多少精力**。 +> 理论基础:自我决定理论(自主感/胜任感/归属感)、峰终定律、二八法则(80% 的高质量反馈来自 20% 的用户)。 + +--- + +## 0. 何时启用分层 + +| 阶段 | 人数 | 策略 | +|------|------|------| +| 阶段一 | ≤10 人 | **不分层**。人人都是核心,全部一对一深聊,分层此时是过度设计 | +| 阶段二 | 10-50 人 | 启用本方案三层结构 | +| 阶段三 | 50+ 人 | 增设"荣誉层"(毕业内测员/布道者),进入公测过渡(手册 §7) | + +> ⚠️ 单人开发者的铁律:**分层的目的是省精力,不是加工作量**。任何一层的运营动作如果让你连续两周做不完,砍动作,不砍层。 + +--- + +## 1. 三层结构总览 + +```mermaid +flowchart TB + subgraph L1[🌟 核心共创层 · 目标占比 ~20%] + A1[高频使用 + 高质量反馈
可影响产品路线] + end + subgraph L2[🌊 活跃反馈层 · 目标占比 ~50%] + A2[正常使用 + 偶尔反馈
内测主体] + end + subgraph L3[🫧 观察维持层 · 目标占比 ~30%] + A3[低频使用或沉默
轻量维持 + 定期回访] + end + L2 -- 晋升 --> L1 + L3 -- 激活 --> L2 + L1 -. 降级(自然滑落,不通知) .-> L2 + L2 -. 滑落 .-> L3 + L3 -- 连续无响应 --> OUT[礼貌退出流程] +``` + +## 2. 分层标准(可量化) + +以**滚动两周**为观察窗口,满足任意判定条件即归入该层: + +| 层级 | 使用频次 | 反馈质量 | 附加信号 | +|------|---------|---------|---------| +| 🌟 核心共创 | ≥3 次/周 | 累计 ≥2 条深度反馈(含场景+建议)或 ≥1 个有效 Bug | 主动提问、接受过访谈、发出过邀请码 | +| 🌊 活跃反馈 | ≥1 次/周 | 有反馈记录(吐槽也算) | 群内有互动 | +| 🫧 观察维持 | <1 次/周 | 两周无反馈 | 消息已读不回 ≠ 流失,先按 §5.4 排除考试周因素 | + +**判定说明**: + +- 数据来源:使用频次看匿名统计(协议第三条已授权);反馈质量人工判定,与[有偿内测质量奖励](./beta-agreement.md)的认定共用一套标准,不重复劳动。 +- **宁升勿降**:边界情况一律往高层放——错把核心用户当潜水者的代价(被重视感崩塌)远大于反过来。 + +## 3. 各层权益与运营动作 + +### 🌟 核心共创层(预计 5-10 人) + +**权益**(满足自我决定理论三要素): + +- 路线图共创权:新功能做不做、先做哪个,小群内直接讨论(自主感) +- 新版本提前 3-5 天体验(尝鲜通道) +- 更新日志署名感谢 + 期末"核心共创者"证书(胜任感) +- 进入核心小群,与开发者即时对话(归属感) + +**我的运营动作**(每周预算:约 2 小时): + +- 每周至少 1 次一对一深聊(轮流,不必每人每周) +- 新版本发布前先发小群征求意见 +- 他们的反馈 24 小时内回应(对外承诺仍是 48h,内部对这层提速——超额兑现制造峰值时刻) + +### 🌊 活跃反馈层(内测主体) + +**权益**: + +- 内测标准权益(协议第一条全部内容) +- 反馈被采纳同样署名感谢 +- 月度"反馈之星"评选资格 + +**我的运营动作**(每周预算:约 1 小时): + +- 大群周报(复用手册 §5.2 的真实数据周报,一次编写全员触达) +- 反馈 48 小时内回应(按协议兑现即可) +- 每两周从中挑 1-2 人尝试深聊——**这是晋升核心层的主要通道** + +### 🫧 观察维持层 + +**权益**:不剥夺任何协议权益(基础酬劳仍按协议结算),只降低触达频率。 + +**我的运营动作**(每周预算:约 20 分钟): + +- 不主动打扰,仅接收周报 +- 每两周一次轻量回访,**只问一个具体问题**:"是太忙了,还是软件哪里让你用不下去了?"——后者是金子级反馈(流失原因比功能建议更值钱) +- 回访话术保持 §2.4 破冰原则:引用他上次说过的话,不发模板 + +## 4. 升降级机制 + +### 晋升(要有仪式感) + +- 触发:达到上一层标准,且我主观判断匹配(人少,人工判定完全够用)。 +- 动作:**私聊正式邀请**,说清为什么是他("你上次关于课堂助手笔记结构的建议我改了,想拉你进核心群一起定后面的方向")——引用具体贡献,这就是峰终定律的"峰值时刻"。 +- 🚫 不要群发"恭喜晋升"名单——分层制度本身**对用户不完全公开**(见 §6 红线)。 + +### 降级(静默处理,绝不通知) + +- 核心层用户连续 3 周无互动 → 我停止对他的高频动作,自然回到活跃层节奏;**群不踢、权益不收、不发任何"你被降级了"的信号**。 +- 理由:降级通知只会制造羞辱感和流失;静默滑落成本为零,且他随时活跃回来就随时恢复。 + +### 退出流程(观察层 → 出局) + +- 观察层连续 2 次回访无响应(排除考试周/假期后)→ 发一条**留面子的收尾消息**: + > "看你最近比较忙,内测这边你随时想回来都在。如果不方便继续,也跟我说一声,酬劳按协议给你结算~" +- 对方明确退出或仍无响应 → 按协议第五条结算酬劳、7 天内删除个人信息。**善终也是运营**:今天体面退出的人,是明天正式版的潜在用户。 + +## 5. 落地工具(单人够用版) + +**用户档案表**(一张表管所有人,Excel/Notion 均可): + +| 字段 | 示例 | 来源 | +|------|------|------| +| 昵称/联系方式 | — | 报名表 | +| 当前层级 | 🌊 活跃 | 每两周滚动更新 | +| 学习场景 | 考研数学 | §2.4 破冰记录 | +| 核心痛点 | 网课笔记低效 | §2.4 破冰记录 | +| 现用工具 | Notion + 纸笔 | §2.4 破冰记录 | +| 反馈记录 | 3 条(1 深度) | 反馈台账,与酬劳结算共用 | +| 上次互动 | 07-28 深聊 | 手动记 | +| 备注 | 8 月考试,暂勿打扰 | — | + +**群组架构**: + +- 「熵减内测群」:全员大群,发周报、版本通知(活跃层 + 观察层的主触达面) +- 「熵减核心群」:核心层小群,≤10 人,讨论路线图 +- 不再多建群——两个群是单人运营的上限 + +**每周固定节奏(合计 ≤4 小时)**: + +| 时间 | 动作 | 覆盖层 | +|------|------|--------| +| 周一 | 更新用户档案表层级 + 处理积压反馈 | 全部 | +| 周三 | 1-2 次一对一深聊 | 核心层为主 | +| 周五 | 大群周报 + 核心群下周计划 | 全部 | +| 隔周五 | 观察层轻量回访 | 观察层 | + +## 6. 红线与提醒 🚫 + +- **分层对用户半公开**:核心群的存在可以知道(制造向往感),但完整分层标准、谁在哪层、降级机制**不公开**——公开的等级制度会把共创氛围变成 KPI 竞赛,还会羞辱低层用户。 +- **层级 ≠ 人的价值**:观察层的一条流失原因反馈,可能比核心层十条功能建议更重要(幸存者偏差对冲,手册 §4)。 +- **酬劳与层级脱钩**:内测酬劳按[协议](./beta-agreement.md)统一结算,不因层级打折——分层是运营精力的分配方案,不是薪酬等级。 +- 档案表含用户个人信息,本地保存、不上云共享文档,退出用户 7 天内从表中删除(对齐协议第五条)。 diff --git "a/docs/product/\350\275\257\344\273\266\347\256\200\344\273\213\351\207\215\345\210\2662.docx" "b/docs/product/\350\275\257\344\273\266\347\256\200\344\273\213\351\207\215\345\210\2662.docx" new file mode 100644 index 0000000000000000000000000000000000000000..1ca98c075218b2255305015392c5c767a306fa66 GIT binary patch literal 16273 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