From 794e27e0982402e3186bc5dcbd2d6c91cffba3f1 Mon Sep 17 00:00:00 2001 From: CyC2018 Date: Sat, 31 Oct 2026 14:31:33 +0800 Subject: [PATCH 01/10] Add learn/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增学习站:按学习路线自评定级、生成每日计划、间隔复习,记录保存在浏览器中,可导出导入。 --- learn/.gitignore | 2 + learn/README.md | 93 + learn/build.py | 335 +++ learn/content.lock.json | 3647 ++++++++++++++++++++++++++++++ learn/content.py | 211 ++ learn/maintain.py | 151 ++ learn/make_images.py | 69 + learn/routes.json | 9 + learn/serve.py | 18 + learn/src/app.css | 686 ++++++ learn/src/app.js | 1499 ++++++++++++ learn/src/core.js | 583 +++++ learn/src/icons/favicon-32.png | Bin 0 -> 1113 bytes learn/src/icons/icon-180.png | Bin 0 -> 6006 bytes learn/src/icons/icon-192.png | Bin 0 -> 6508 bytes learn/src/icons/icon-512.png | Bin 0 -> 17977 bytes learn/src/icons/maskable-512.png | Bin 0 -> 8182 bytes learn/src/index.html | 53 + learn/src/manifest.webmanifest | 16 + learn/src/og.png | Bin 0 -> 73063 bytes learn/src/qr.js | 177 ++ learn/src/sw.js | 22 + learn/tests/core.test.cjs | 480 ++++ learn/tests/e2e.py | 414 ++++ learn/tests/maintenance.py | 339 +++ learn/tests/personas.py | 300 +++ learn/tests/replay_history.py | 78 + learn/tests/test_content.py | 205 ++ 28 files changed, 9387 insertions(+) create mode 100644 learn/.gitignore create mode 100644 learn/README.md create mode 100644 learn/build.py create mode 100644 learn/content.lock.json create mode 100644 learn/content.py create mode 100644 learn/maintain.py create mode 100644 learn/make_images.py create mode 100644 learn/routes.json create mode 100644 learn/serve.py create mode 100644 learn/src/app.css create mode 100644 learn/src/app.js create mode 100644 learn/src/core.js create mode 100644 learn/src/icons/favicon-32.png create mode 100644 learn/src/icons/icon-180.png create mode 100644 learn/src/icons/icon-192.png create mode 100644 learn/src/icons/icon-512.png create mode 100644 learn/src/icons/maskable-512.png create mode 100644 learn/src/index.html create mode 100644 learn/src/manifest.webmanifest create mode 100644 learn/src/og.png create mode 100644 learn/src/qr.js create mode 100644 learn/src/sw.js create mode 100644 learn/tests/core.test.cjs create mode 100644 learn/tests/e2e.py create mode 100644 learn/tests/maintenance.py create mode 100644 learn/tests/personas.py create mode 100644 learn/tests/replay_history.py create mode 100644 learn/tests/test_content.py diff --git a/learn/.gitignore b/learn/.gitignore new file mode 100644 index 000000000..2ead01d05 --- /dev/null +++ b/learn/.gitignore @@ -0,0 +1,2 @@ +dist/ +__pycache__/ diff --git a/learn/README.md b/learn/README.md new file mode 100644 index 000000000..b166a002e --- /dev/null +++ b/learn/README.md @@ -0,0 +1,93 @@ +# CS-Notes 学习站 + +从三条学习路线(Java 后端、大模型应用开发、面试冲刺)生成的静态站点,与笔记网站一起部署在 `/learn/`。读者先用路线中的自测题自评定级,按水平、每天的时间和面试日期排出计划,自评过的题按 1、3、7、15、30、60 天的间隔复习。不需要账号,不连接后端或 AI 接口,记录只保存在浏览器的 localStorage 中,可以导出和导入。 + +## 功能 + +- 今天:只给一个“下一步”(继续定级、先复习一批、学今天的内容),下面是连续学习天数、最近 7 天和预计学完的日期;中断几天后回来时提示,并把到期的题分批复习; +- 复习份额:每天的复习按设定的时间分批,做完份额先学当天的内容;积压超过两批时份额加倍;时间紧时可以“只有 5 分钟”,复习 5 道保住连续天数; +- 连续学习:每学满 7 天得到 1 次保护(最多 2 次),中断一天自动使用,不清零;连续天数到 3、7、14、30 等数字时在完成页提示; +- 定级:每个阶段两三道题,看参考要点后自评,“模糊”算半个“会”; +- 计划:按路线顺序装进每天的时间,先留出复习的时间;今天完成的天仍算在今天;可以导出 .ics 加到日历; +- 每天:导读、原文链接、自测题卡、动手和算法;完成后显示进度和下一步,学完一个阶段单独提示; +- 复习与模拟面试:键盘 空格、1、2、3 操作;模拟面试按热度和不牢的程度抽 8 道题,结束时给出得分和需要再看的题; +- 题库:按路线、热度、掌握情况和关键词找题,每道题有单独的链接(`#/q/`),可以分享; +- 进度:16 周热力图、各阶段掌握、常忘的题,以及在本地用 canvas 生成的 1080×1440 分享图(带指向本站的二维码,不含学习记录); +- 回答对照:写了回答再看要点时,列出要点中的术语哪些提到了、哪些没提到(只作提示,不打分); +- 反馈:题卡上的“要点有误”和页脚的“反馈建议”打开 GitHub 新建议题页,预填题号、题目和要点,读者自己提交;维护者按题号在路线文件中修改; +- 题卡上的“复制给 AI 追问”把题目、回答、参考要点和原文链接整理成提示词; +- 学习记录:导出成文件或复制成文字,在另一台设备上选择文件、拖入或粘贴导入;导入前显示两边的概况,可以合并(同一道题取最近一次自评)或替换;进度页在记录较多又久未导出时轻提示备份; +- 数据与隐私(页脚链接,`#/privacy`):不收集个人信息、不用 Cookie 和第三方脚本、记录只在本机、访问日志由托管服务商按其规则记录、怎样转移和删除;内容有变化时更新页面上的日期; +- 外观:清爽(默认)、纸面、活泼三套配色,在学习记录或设置页切换,只存在本机; +- 手机上导航在底部;可以安装到桌面,载入一次后离线可用;深色模式跟随系统。 + +## 构建与预览 + +```bash +python3 learn/build.py --notes-base 'http://127.0.0.1:8769/#/notes/' # 单独构建到 learn/dist,链接指向本机的笔记网站 +python3 learn/serve.py # http://127.0.0.1:8770/ +``` + +笔记网站的 `website/builder/build.py` 会调用 `learn/build.py`,把学习站生成到 `website/dist/learn/`,题目链接改为站内的 `/#/notes/…`,并在首页、侧边栏和学习路线的文章中加入口;随网站一起发布,不需要单独部署。单独构建时严格检查所有指向笔记的链接,锚点不存在时报错退出;和网站一起构建时用 --lenient,只给警告(见下文“维护”)。地址后加 `?today=2026-11-02` 可以模拟日期。 + +图标和链接预览图由 `python3 learn/make_images.py` 生成到 `src/icons/` 和 `src/og.png`(需要 Pillow,先构建一次以读取题数)。 + +## 维护 + +学习站的内容全部来自三条学习路线和它们链接到的笔记,笔记更新后随网站一起重新构建,不需要单独发布。读者的记录按题目和天的编号保存,编号由 content.py 根据提交在仓库中的 content.lock.json 分配,保证笔记更新后不变: + +- 改了题目的问法或参考要点:按文字和要点的相似度找回原编号,复习记录不受影响; +- 新增题、新增天:拿新编号;新天的编号接着这条路线用过的最大序号,插在中间也不会让后面的天换编号; +- 学过的天改了导读或阅读范围(lock 中记录每天的指纹):完成状态不变,首页列出这些天,读者可以再看一眼; +- 删掉的题和天:标记为下线,读者的记录移到归档中保留,不再安排复习;重新加回来时恢复原编号和记录; +- 路线下线(从 routes.json 删掉):在学这条路线的读者看到换路线的提示,路线中相同的题接着按原来的间隔复习;新增路线在 routes.json 中加一项。 + +改了学习路线或笔记标题后,在本机运行: + +```bash +python3 learn/maintain.py # 列出新增、修改、下线的题和天,以及链接到的小节不存在等问题 +python3 learn/maintain.py --write # 确认无误后写入 lock,这次变化记入更新记录,读者在首页看到和自己有关的部分 +``` + +自动判断错了时(两道不同的题被当成同一道,或改写后没认出来),用 `--separate 新题临时编号` 或 `--map 新题临时编号=旧编号` 指定,临时编号在 maintain.py 的输出中。没有运行 maintain.py 就提交也能正常发布:CI 中宽松构建,读者的记录照样保留,只是更新记录没有日期;构建日志和 dist-report.json 的 learn 一项会提示 lock 不一致,链接到的小节改名时改为链接到文章开头并给出警告,不挡住笔记的发布。 + +首页的“内容更新”只列和读者自己有关的部分:学过的天新增的题(可以一键练习)、当前路线新增的天、练过的题改了什么(带链接)、学过的天改了导读、练过的题下线。页面只带最近 20 条更新,落下更多的读者只看到一句概括。还没发布过时不需要更新记录:第一次发布前运行 `python3 learn/maintain.py --baseline`,写入现在的内容并清空更新记录。 + +页面一侧:本地记录有版本号(现在是 2),旧版本的记录读入时自动转换;另一个标签页已经用更新版本保存过时,旧页面只读并提示刷新。部署新版本后,打开着的页面在切回前台时检查更新,提示刷新;离线缓存按构建号整体替换。 + +## 上线步骤 + +1. 第一次发布前运行 `python3 learn/maintain.py --baseline`,按现在的内容写入 lock 并清空更新记录;之后每次改路线用 `maintain.py` 和 `--write`; +2. 提交 learn/(含 content.lock.json、routes.json、src/icons、src/og.png)、website/ 中学习站相关的改动和 .github/workflows/website.yml; +3. CI 构建网站时生成 /learn/,跑核心逻辑、编号和网站的测试,发布后校验 /learn/ 的入口、数据和离线缓存脚本; +4. 上线后在手机和电脑上各打开一次 https://cyc2018.xyz/learn/:能定级、能学一天、刷新后记录还在、断网后能打开。 + +读者反馈的题目问题,用 `python3 learn/maintain.py --find 题号` 找到所在的天和路线文件。 + +## 上线与安全 + +- 入口页带内容安全策略(CSP):只允许本站的脚本、样式、图片和请求,head 中的内联脚本由 build.py 计算哈希写入;以后加内联脚本无需改动,但不能加 onclick 这类内联事件。e2e 测试把 CSP 拦截记为错误; +- 页面渲染出错时显示“页面出错了”,提供刷新和直接导出记录,不会白屏;题目数据载入失败时可以重试; +- 发布后的线上校验(website/deploy/deploy.mjs)同时核对 learn/index.html、data.json、app.js、sw.js,以及目录地址 /learn/ 返回的是学习站入口页; +- 旧浏览器(不支持 color-mix 等)有颜色回退,顶部和底部导航不会透明。 + +## 测试 + +```bash +node learn/tests/core.test.cjs # 调度、定级、计划、日历、导入校验;dist 存在时附带真实数据的检查 +python3 learn/tests/e2e.py --notes http://127.0.0.1:8769/ # 无头 Edge 走完整流程,需要 websocket-client +python3 learn/tests/personas.py # 访客、连续 20 天学习、中断后回来、模拟面试、离线等场景,约 10 分钟 +python3 learn/tests/test_content.py # 编号对应:改问法、改要点、插入一天、删了再加回、路线增删、手工指定 +python3 learn/tests/maintenance.py # 读者学了 3 天后笔记陆续更新、部署:记录保留、更新提示、刷新提示、路线下线,约 3 分钟 +python3 learn/tests/replay_history.py # 按 Git 历史逐个提交重放路线的修改,检查编号是否稳定 +``` + +e2e.py 和 personas.py 默认测试 8770 上的单独构建,加 `--base http://127.0.0.1:8769/learn/` 测试笔记网站中的版本。 + +## 文件 + +- build.py:解析 routes.json 中的路线,生成 dist/data.json,复制 src,给静态文件加版本号,写入离线缓存的文件列表; +- content.py、content.lock.json:题目和天的稳定编号与更新记录;maintain.py:查看变化并写入 lock;routes.json:放进学习站的路线; +- src/core.js:不依赖页面的逻辑(日期、调度、定级、计划、模拟面试抽题、日历、提示词、导入校验),浏览器和 Node 共用; +- src/qr.js:二维码生成(字节模式、纠错等级 M、版本 1~10); +- src/app.js、app.css、index.html:页面;sw.js、manifest.webmanifest:离线缓存和安装。 diff --git a/learn/build.py b/learn/build.py new file mode 100644 index 000000000..72af9e6c2 --- /dev/null +++ b/learn/build.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +"""从学习路线生成学习站的数据,并把 src/ 复制到 dist/。 + +学习路线(notes/学习路线 - *.md)每天有导读、阅读范围、自测题和折叠的参考要点。这里把它们拆成: +- 路线:阶段(二级标题)和每天的安排; +- 题目:自测题的原问法、参考要点、原文链接和热度(链接到的小节带 🔥 的数量)。 +不同路线中相同的问题共用一张题卡。题目和天的编号由 content.py 按 content.lock.json 分配,笔记更新后保持不变, +读者的复习记录和完成情况不会因为改了措辞、插入一天或调整顺序而丢失。 + +用法:python3 learn/build.py [--notes-base URL] [--output DIR] [--lenient] +--lenient 用于和笔记网站一起构建:链接到的小节不存在时改为链接到文章开头、某一天的题目和要点对不上时跳过这一天的题, +只给出警告,不让学习站的问题挡住笔记的发布。单独构建和 maintain.py 默认严格检查。 +""" +from __future__ import annotations + +import argparse +import base64 +import hashlib +import html +import json +import re +import shutil +import sys +import urllib.parse +from collections import Counter +from pathlib import Path + +import content + +HERE = Path(__file__).resolve().parent +REPO = HERE.parent +sys.path.insert(0, str(REPO / 'website' / 'builder')) +# 笔记网站的构建脚本也叫 build.py,按路径加载,避免和本文件同名冲突 +import importlib.util # noqa: E402 +_spec = importlib.util.spec_from_file_location('site_build', REPO / 'website' / 'builder' / 'build.py') +_site = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_site) +# 代码块的正则在新旧版本的网站脚本中位置不同,统一从 build.py 取 +github_slug, strip_hot, VERBATIM = _site.github_slug, _site.strip_hot, _site.VERBATIM +from site_assets import heading_slug # noqa: E402 + +CONFIG = HERE / 'routes.json' +LOCK = HERE / 'content.lock.json' +# 页面中显示的更新记录条数 +HISTORY_SHOWN = 20 +# 路线中这些二级标题是说明,不是阶段 +INTRO = re.compile(r'^[一二三四]、(说明|开始之前|每天的安排)$') +DAY = re.compile(r'^### 第 (\d+) 天:(.+)$') +LINK = re.compile(r'\[([^\]]+)\]\(([^)\s]+)\)') +HOT = re.compile(r' (🔥{1,2})[ \t]*$') + + +def headings(path: Path): + """一篇笔记中每个 GitHub 锚点对应的 Docsify id 和热度(0、1、2)。""" + result, github, docsify, fence = {}, Counter(), Counter(), None + for line in re.sub(r'', '', path.read_text()).splitlines(): + marker = re.match(r'^\s*(`{3,}|~{3,})', line) + if marker: + fence = marker[1][0] if fence is None else (None if marker[1][0] == fence else fence) + continue + if fence is not None: + continue + match = re.match(r'^(#{1,6})\s+(.+?)(?:\s+#+)?\s*$', line) + if not match: + continue + heat = HOT.search(match[2]) + text = strip_hot(match[0]).split(None, 1)[1].strip() + slug = github_slug(text) + count = github[slug] + github[slug] += 1 + key = slug + (f'-{count}' if count else '') + result.setdefault(key, (heading_slug(text, docsify), len(heat[1]) if heat else 0, text)) + return result + + +class Notes: + """把路线中的相对链接换成笔记网站上的地址,并记录无法解析的链接。""" + + def __init__(self, root: Path, base: str, lenient: bool = False): + self.root, self.base, self.cache, self.broken, self.warnings, self.lenient = root, base, {}, [], [], lenient + + def resolve(self, href: str, where: str): + if re.match(r'^https?://', href): + return {'href': href, 'external': True} + target, _, fragment = href.partition('#') + name = urllib.parse.unquote(target) + if not name.endswith('.md'): + self.broken.append((where, href, '不是笔记链接')) + return None + path = self.root / 'notes' / name + if not path.is_file(): + (self.warnings if self.lenient else self.broken).append((where, href, '笔记不存在' + (',已去掉这个链接' if self.lenient else ''))) + return None + if name not in self.cache: + self.cache[name] = headings(path) + note = name[:-3] + url = self.base + urllib.parse.quote(note) + heat = 0 + if fragment: + anchor = self.cache[name].get(urllib.parse.unquote(fragment)) + if not anchor and self.lenient: + # 小节改了名:先链接到文章开头,读者仍能找到内容 + self.warnings.append((where, href, '小节不存在,已改为链接到文章开头')) + return {'href': url, 'note': note, 'heat': 0} + if not anchor: + self.broken.append((where, href, '小节不存在')) + return None + url += '?id=' + urllib.parse.quote(anchor[0]) + heat = anchor[1] + return {'href': url, 'note': note, 'heat': heat} + + +def links(text: str, notes: Notes, where: str): + found = [] + for label, href in LINK.findall(text): + target = notes.resolve(href, where) + if target: + found.append({'text': label, **target}) + return found + + +CJK_LATIN = re.compile(r'(?<=[\u4e00-\u9fff])(?=[A-Za-z0-9])|(?<=[A-Za-z0-9])(?=[\u4e00-\u9fff])') + + +def plain(text: str): + """去掉 Markdown 链接语法,只留文字;链接文字与相邻的汉字之间补上空格(题目编号去掉空白后计算,不受影响)。""" + return CJK_LATIN.sub(' ', re.sub(r'\s+', ' ', LINK.sub(lambda m: m[1], text)).strip()) + + +def question_id(text: str): + """临时题号:文字的哈希,content.resolve 再换成稳定编号。""" + return content.text_id(plain(text)) + + +def parse_route(key, file, title, summary, notes: Notes, questions: dict): + text = VERBATIM.sub('', (notes.root / 'notes' / (file + '.md')).read_text()) + stages, days, stage, day, lines = [], {}, None, None, [] + + def finish(): + if day is None: + return + days[day['id']] = parse_day(day, lines, key, notes, questions) + + for line in text.splitlines(): + if line.startswith('## '): + finish() + day, lines = None, [] + name = line[3:].strip() + stage = None if INTRO.match(name) else {'title': re.sub(r'^[一二三四五六七八九十]+、', '', name), 'days': []} + if stage: + stages.append(stage) + continue + match = DAY.match(line) + if match and stage is not None: + finish() + # 临时编号,content.resolve 再换成稳定编号;n 是显示用的第几天,随路线变化 + day = {'id': f'{key}~{int(match[1])}', 'n': int(match[1]), 'title': match[2].strip(), 'stage': len(stages) - 1} + stage['days'].append(day['id']) + lines = [] + continue + if day is not None: + lines.append(line) + finish() + return {'id': key, 'title': title, 'summary': summary, 'source': file, 'stages': stages}, days + + +def parse_day(day, lines, route, notes: Notes, questions: dict): + where = f'{route} 第 {day["n"]} 天' + body = '\n'.join(lines) + guide, reading, practice, expect, algos, asks = [], [], '', '', [], [] + block = re.search(r'
\s*参考要点([\s\S]*?)
', body) + points = [re.sub(r'\s+', ' ', p).strip() for p in re.findall(r'^\d+\.\s+(.+)$', block[1], re.M)] if block else [] + section, folded = None, False + for raw in lines: + line = raw.strip() + # 折叠块中是参考要点,同样带编号,不能当作自测题 + if line.startswith('\n', + f' \n' + f' \n' + ' \n \n') + # 内容安全策略:只允许本站的脚本(加上 head 中那段应用配色的内联脚本的哈希)、样式、图片和请求, + # 页面即使被注入内容也无法加载第三方脚本或把数据发到别处;也和“数据与隐私”中不加载第三方脚本的说明一致 + inline = re.findall(r'', entry, re.S) + hashes = ' '.join("'sha256-" + base64.b64encode(hashlib.sha256(code.encode()).digest()).decode() + "'" for code in inline) + csp = ("default-src 'self'; script-src 'self' " + hashes + "; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; " + "connect-src 'self'; font-src 'self'; manifest-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'self'; form-action 'none'") + entry = entry.replace('', f'\n ', 1) + (output / 'index.html').write_text(entry) + # 离线缓存的文件列表和构建号 + files = ['./'] + [f'{n}?v={hashlib.sha256((output / n).read_bytes()).hexdigest()[:10]}' for n in ('app.css', 'core.js', 'qr.js', 'app.js', 'data.json')] + files += sorted(str(p.relative_to(output)) for p in (output / 'icons').glob('*.png')) + ['manifest.webmanifest'] + build_id = hashlib.sha256(entry.encode() + json.dumps(files).encode()).hexdigest()[:10] + sw = (output / 'sw.js').read_text().replace('__BUILD__', build_id).replace('__FILES__', json.dumps(files)) + (output / 'sw.js').write_text(sw) + return {'routes': {r['id']: sum(len(s['days']) for s in r['stages']) for r in routes}, + 'questions': len(questions), 'sharedQuestions': sum(1 for q in questions.values() if len(q['days']) > 1), + 'hot': Counter(q['heat'] for q in questions.values()), + 'pending': {group: {k: len(v) for k, v in items.items()} for group, items in changes.items()} if content.has_changes(changes) else None, + 'warnings': [f'{w}:{h}({why})' for w, h, why in notes.warnings]} + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__.split('\n')[0]) + parser.add_argument('--notes-base', default='https://cyc2018.xyz/#/notes/', help='笔记网站中文章地址的前缀') + parser.add_argument('--source', type=Path, default=REPO) + parser.add_argument('--output', type=Path, default=HERE / 'dist') + parser.add_argument('--site-url', default='', help='学习站部署后的地址,用于链接预览图(og:image)') + parser.add_argument('--lenient', action='store_true', help='链接和题目的问题只给警告,用于和笔记网站一起构建') + parser.add_argument('--lock', type=Path, default=LOCK, help='编号对照文件,测试时可以指定别的文件') + parser.add_argument('--routes', type=Path, default=CONFIG, help='路线配置,测试时可以指定别的文件') + args = parser.parse_args() + print(json.dumps(build(args.source.resolve(), args.output.resolve(), args.notes_base, args.site_url, args.lenient, args.lock.resolve(), + args.routes.resolve()), ensure_ascii=False)) diff --git a/learn/content.lock.json b/learn/content.lock.json new file mode 100644 index 000000000..617c46bd4 --- /dev/null +++ b/learn/content.lock.json @@ -0,0 +1,3647 @@ +{ + "days": { + "java-01": { + "questions": [ + "380a188f36", + "8c543dbbcb", + "7649a66405" + ], + "route": "java", + "sig": "fbb7a6f2f4", + "stage": "Java 基础", + "status": "active", + "title": "数据类型与 String" + }, + "java-02": { + "questions": [ + "d85d93a06e", + "2af89296be" + ], + "route": "java", + "sig": "999e7b369a", + "stage": "Java 基础", + "status": "active", + "title": "常量池与运算" + }, + "java-03": { + "questions": [ + "ed40759232", + "1535c683ad", + "a47f889523" + ], + "route": "java", + "sig": "d42f2e52e7", + "stage": "Java 基础", + "status": "active", + "title": "关键字与 Object 的方法" + }, + "java-04": { + "questions": [ + "518704a7fc", + "cf1562e438", + "731153a537" + ], + "route": "java", + "sig": "9fb87602fb", + "stage": "Java 基础", + "status": "active", + "title": "继承与多态" + }, + "java-05": { + "questions": [ + "a7e52d0151", + "15b9ac4352", + "61d62633f1" + ], + "route": "java", + "sig": "2bc7a709af", + "stage": "Java 基础", + "status": "active", + "title": "反射、异常、泛型与新特性" + }, + "java-06": { + "questions": [ + "875d608421", + "a85a26685e", + "3cde3d237b" + ], + "route": "java", + "sig": "b6c4723d21", + "stage": "Java 容器", + "status": "active", + "title": "集合与 List" + }, + "java-07": { + "questions": [ + "54392ab875", + "393777a7d3", + "2de7600ab9" + ], + "route": "java", + "sig": "127e5d6fef", + "stage": "Java 容器", + "status": "active", + "title": "HashMap" + }, + "java-08": { + "questions": [ + "0679b458cf", + "8e70e9576a", + "7e5379ca96" + ], + "route": "java", + "sig": "a038b6f320", + "stage": "Java 容器", + "status": "active", + "title": "ConcurrentHashMap 与 LinkedHashMap" + }, + "java-09": { + "questions": [ + "bcb8868d08", + "ef0a1e3f79", + "154ba456f2" + ], + "route": "java", + "sig": "6abe05001b", + "stage": "Java 并发", + "status": "active", + "title": "线程与线程池" + }, + "java-10": { + "questions": [ + "1c0bbdffef", + "79b04fb5b0", + "f3cbce1b55" + ], + "route": "java", + "sig": "f29a28c96d", + "stage": "Java 并发", + "status": "active", + "title": "中断与互斥同步" + }, + "java-11": { + "questions": [ + "bcd73345b9", + "b50334ebd5", + "fb5644c7bf" + ], + "route": "java", + "sig": "69d86009ce", + "stage": "Java 并发", + "status": "active", + "title": "线程协作与线程状态" + }, + "java-12": { + "questions": [ + "bb799ba354", + "5efd34dc3a", + "0a5b56bc23" + ], + "route": "java", + "sig": "7208abc17a", + "stage": "Java 并发", + "status": "active", + "title": "J.U.C 的常用组件" + }, + "java-13": { + "questions": [ + "454c7daf16", + "bde7a7dfbe", + "e2b985d29c" + ], + "route": "java", + "sig": "0664d8544b", + "stage": "Java 并发", + "status": "active", + "title": "Java 内存模型与 volatile" + }, + "java-14": { + "questions": [ + "4383694e3e", + "36367df7c4", + "e326fb1c48" + ], + "route": "java", + "sig": "4faac9d338", + "stage": "Java 并发", + "status": "active", + "title": "线程安全与 CAS" + }, + "java-15": { + "questions": [ + "97c02eae0a", + "d5ef30f656", + "3896059e83" + ], + "route": "java", + "sig": "64c6db5ed9", + "stage": "Java 并发", + "status": "active", + "title": "ThreadLocal 与锁优化" + }, + "java-16": { + "questions": [ + "d175c9b0a1", + "d30a0abfb5", + "a806a94773" + ], + "route": "java", + "sig": "8179b551b2", + "stage": "Java 虚拟机", + "status": "active", + "title": "运行时数据区域" + }, + "java-17": { + "questions": [ + "152f56d6c4", + "d4c6da97dc", + "71ab53cab0" + ], + "route": "java", + "sig": "71c6e96394", + "stage": "Java 虚拟机", + "status": "active", + "title": "对象存活与垃圾收集算法" + }, + "java-18": { + "questions": [ + "7796810063", + "b432826e95", + "347baa0bbd" + ], + "route": "java", + "sig": "51793d06cc", + "stage": "Java 虚拟机", + "status": "active", + "title": "垃圾收集器" + }, + "java-19": { + "questions": [ + "1df5ce34bc", + "f9317cc094", + "01d822171f" + ], + "route": "java", + "sig": "a688f7aba8", + "stage": "Java 虚拟机", + "status": "active", + "title": "三色标记与内存分配" + }, + "java-20": { + "questions": [ + "3011568278", + "15d74500a9", + "6159fb310f" + ], + "route": "java", + "sig": "9a5a5c658b", + "stage": "Java 虚拟机", + "status": "active", + "title": "类加载机制" + }, + "java-21": { + "questions": [ + "75d8147fc6", + "f806988135", + "d82af70a7a" + ], + "route": "java", + "sig": "96984118f8", + "stage": "Java 虚拟机", + "status": "active", + "title": "线上问题排查" + }, + "java-22": { + "questions": [ + "68acba4c85", + "e93ce780bc", + "fc65cb524a" + ], + "route": "java", + "sig": "9f4404f0d9", + "stage": "MySQL", + "status": "active", + "title": "事务、并发一致性与锁" + }, + "java-23": { + "questions": [ + "48101acfd2", + "a1c4bbe5c6", + "47712a7a5f" + ], + "route": "java", + "sig": "b840538d9f", + "stage": "MySQL", + "status": "active", + "title": "隔离级别与 MVCC" + }, + "java-24": { + "questions": [ + "59a27f0b68", + "76eb545f8c", + "52c9969ca3" + ], + "route": "java", + "sig": "b335016f18", + "stage": "MySQL", + "status": "active", + "title": "Next-Key Lock 与死锁" + }, + "java-25": { + "questions": [ + "4391e4bcee", + "91de874283", + "f311870155" + ], + "route": "java", + "sig": "2638e86add", + "stage": "MySQL", + "status": "active", + "title": "B+ 树与索引的类型" + }, + "java-26": { + "questions": [ + "56455baece", + "16787de94b", + "80d3fed1b5" + ], + "route": "java", + "sig": "aaa9a44152", + "stage": "MySQL", + "status": "active", + "title": "索引的使用" + }, + "java-27": { + "questions": [ + "b9fdebae51", + "c0ce068130", + "fddac344d2" + ], + "route": "java", + "sig": "6af43022ca", + "stage": "MySQL", + "status": "active", + "title": "查询性能优化" + }, + "java-28": { + "questions": [ + "f399525472", + "394e2ef8d2", + "fd1182bd45" + ], + "route": "java", + "sig": "a310b8d5cd", + "stage": "MySQL", + "status": "active", + "title": "存储引擎、切分与复制" + }, + "java-29": { + "questions": [ + "a79dbaa169", + "1528bcdda2", + "21184e47ef" + ], + "route": "java", + "sig": "7a40e52189", + "stage": "MySQL", + "status": "active", + "title": "日志" + }, + "java-30": { + "questions": [ + "0aad3661f4", + "d977db9dde", + "535132ab86" + ], + "route": "java", + "sig": "5afc6efca0", + "stage": "Redis 与缓存", + "status": "active", + "title": "Redis 的数据类型" + }, + "java-31": { + "questions": [ + "babcc095aa", + "2081fb3147", + "a96d51b500" + ], + "route": "java", + "sig": "02311b9d62", + "stage": "Redis 与缓存", + "status": "active", + "title": "使用场景与底层结构" + }, + "java-32": { + "questions": [ + "af9254f7f2", + "fb32f47b96", + "4489ec0668" + ], + "route": "java", + "sig": "b369769ba9", + "stage": "Redis 与缓存", + "status": "active", + "title": "过期、淘汰、持久化与事务" + }, + "java-33": { + "questions": [ + "be8cf2c3e0", + "ab0c96ab4a", + "9de3a8a2c2" + ], + "route": "java", + "sig": "2acf2d18a4", + "stage": "Redis 与缓存", + "status": "active", + "title": "线程模型、复制与集群" + }, + "java-34": { + "questions": [ + "c79e20122e", + "4978768999", + "57ed7e67f6" + ], + "route": "java", + "sig": "d9e1a3616b", + "stage": "Redis 与缓存", + "status": "active", + "title": "缓存的位置与三种问题" + }, + "java-35": { + "questions": [ + "218297315a", + "6ee29f4ce5", + "082e65c58c" + ], + "route": "java", + "sig": "d47c240757", + "stage": "Redis 与缓存", + "status": "active", + "title": "缓存一致性与一致性哈希" + }, + "java-36": { + "questions": [ + "1d67fbd177", + "87aaeeaa89", + "b21b2cd3d2" + ], + "route": "java", + "sig": "77c2164ad9", + "stage": "计算机网络", + "status": "active", + "title": "TCP 的连接管理" + }, + "java-37": { + "questions": [ + "0214655d6c", + "221ffa2522", + "d45f6f5a22" + ], + "route": "java", + "sig": "ca41cb00a8", + "stage": "计算机网络", + "status": "active", + "title": "可靠传输与拥塞控制" + }, + "java-38": { + "questions": [ + "f79e6d406d", + "699cec5d70", + "fd090d3343" + ], + "route": "java", + "sig": "63ce9fe4cd", + "stage": "计算机网络", + "status": "active", + "title": "HTTP 基础" + }, + "java-39": { + "questions": [ + "72afd7353e", + "3b0c37d0ee", + "f9d6fbbf46" + ], + "route": "java", + "sig": "4ef74f7447", + "stage": "计算机网络", + "status": "active", + "title": "连接管理与 Cookie" + }, + "java-40": { + "questions": [ + "f308884a22", + "4e79ae834e", + "710b3551d1" + ], + "route": "java", + "sig": "39dc79c3c1", + "stage": "计算机网络", + "status": "active", + "title": "缓存、WebSocket 与跨域" + }, + "java-41": { + "questions": [ + "a3c215dfb9", + "29246511a6", + "6dbf8f2ba2" + ], + "route": "java", + "sig": "e9b030d100", + "stage": "计算机网络", + "status": "active", + "title": "HTTPS" + }, + "java-42": { + "questions": [ + "eb8b8fb51f", + "6236dc8524", + "271db04da7" + ], + "route": "java", + "sig": "1a7871d767", + "stage": "计算机网络", + "status": "active", + "title": "HTTP 的版本、GET 与 POST、DNS" + }, + "java-43": { + "questions": [ + "dd70642b39", + "a4e729949e", + "7ce5cde1cd" + ], + "route": "java", + "sig": "6563f58ac8", + "stage": "计算机网络", + "status": "active", + "title": "从输入 URL 到显示页面" + }, + "java-44": { + "questions": [ + "ea588f5661", + "7ec003a5dd", + "0ac347cf06" + ], + "route": "java", + "sig": "0c596cf628", + "stage": "操作系统", + "status": "active", + "title": "进程与线程" + }, + "java-45": { + "questions": [ + "5cfaefbdbb", + "28fb95c127", + "94fc7c8234" + ], + "route": "java", + "sig": "ae085852e5", + "stage": "操作系统", + "status": "active", + "title": "系统调用、进程同步与通信" + }, + "java-46": { + "questions": [ + "64f1daf686", + "b129be802f", + "8d674478d1" + ], + "route": "java", + "sig": "1b9213d938", + "stage": "操作系统", + "status": "active", + "title": "死锁" + }, + "java-47": { + "questions": [ + "62d04c530a", + "752067dae4", + "80afd5ae0b" + ], + "route": "java", + "sig": "8c96a15e75", + "stage": "操作系统", + "status": "active", + "title": "内存管理" + }, + "java-48": { + "questions": [ + "a9201474fc", + "7ef8c8b5a2", + "20c2e1e5be" + ], + "route": "java", + "sig": "00d1f75e0b", + "stage": "操作系统", + "status": "active", + "title": "I/O 多路复用" + }, + "java-49": { + "questions": [ + "3f287e23ea", + "e4be6236bb", + "25bf570498" + ], + "route": "java", + "sig": "59da29c1f9", + "stage": "Spring 与设计模式", + "status": "active", + "title": "IoC 与 Bean" + }, + "java-50": { + "questions": [ + "2d35e2a6e9", + "7d1a93e400", + "eab615f7b3" + ], + "route": "java", + "sig": "c8d70664df", + "stage": "Spring 与设计模式", + "status": "active", + "title": "AOP 与事务" + }, + "java-51": { + "questions": [ + "30520434c8", + "da147d6f3a", + "a61b813949" + ], + "route": "java", + "sig": "440c855487", + "stage": "Spring 与设计模式", + "status": "active", + "title": "Spring Boot、Spring MVC 与 MyBatis" + }, + "java-52": { + "questions": [ + "1a5bcb758d", + "100d141107", + "18a112ceaf" + ], + "route": "java", + "sig": "6eb9107734", + "stage": "Spring 与设计模式", + "status": "active", + "title": "设计模式" + }, + "java-53": { + "questions": [ + "874987f0de", + "283fe326d9", + "f76f14f53c" + ], + "route": "java", + "sig": "71334cf903", + "stage": "分布式与消息队列", + "status": "active", + "title": "微服务" + }, + "java-54": { + "questions": [ + "4c736dfa3f", + "7040328bec", + "86472abfac" + ], + "route": "java", + "sig": "dcd58ad57b", + "stage": "分布式与消息队列", + "status": "active", + "title": "分布式锁" + }, + "java-55": { + "questions": [ + "d707267199", + "3eda6c30d3", + "e2b9c83f1c" + ], + "route": "java", + "sig": "de34216b56", + "stage": "分布式与消息队列", + "status": "active", + "title": "分布式事务与 CAP" + }, + "java-56": { + "questions": [ + "d97306ed39", + "57e6bf391b", + "a9fdb0e73d" + ], + "route": "java", + "sig": "9c0c92eda6", + "stage": "分布式与消息队列", + "status": "active", + "title": "RPC、分布式 ID 与幂等" + }, + "java-57": { + "questions": [ + "a21036a9de", + "b6e89f7079", + "3362091f47" + ], + "route": "java", + "sig": "330dcd9b4f", + "stage": "分布式与消息队列", + "status": "active", + "title": "消息队列的作用与可靠性" + }, + "java-58": { + "questions": [ + "0e366e4baa", + "d3574a433d", + "2309010a02" + ], + "route": "java", + "sig": "066b4100b7", + "stage": "分布式与消息队列", + "status": "active", + "title": "顺序、积压与延迟消息" + }, + "java-59": { + "questions": [ + "17a016b327", + "e554283074", + "b06acac358" + ], + "route": "java", + "sig": "eeedb47fdc", + "stage": "分布式与消息队列", + "status": "active", + "title": "Kafka" + }, + "java-60": { + "questions": [ + "302564cde9", + "fa62f2e19c", + "d47bb94d7b" + ], + "route": "java", + "sig": "6319016bb7", + "stage": "系统设计", + "status": "active", + "title": "性能与可用性" + }, + "java-61": { + "questions": [ + "4baf13900d", + "7032e684b1", + "f5bc9de8b8" + ], + "route": "java", + "sig": "b3c5607e14", + "stage": "系统设计", + "status": "active", + "title": "秒杀与抢红包" + }, + "java-62": { + "questions": [ + "488c66ff9c", + "85d1c43400", + "857dd1fbdb" + ], + "route": "java", + "sig": "b8fd27960b", + "stage": "系统设计", + "status": "active", + "title": "海量数据、短链与 Feed 流" + }, + "java-63": { + "questions": [ + "6d49cb17be", + "b1361d5a9a", + "bf51732677" + ], + "route": "java", + "sig": "decc93632e", + "stage": "系统设计", + "status": "active", + "title": "高并发系统的设计" + }, + "java-64": { + "questions": [ + "f38768f09b", + "b3dc5cbe84", + "89a386e6ca" + ], + "route": "java", + "sig": "7fdda12d7a", + "stage": "系统设计", + "status": "active", + "title": "Docker" + }, + "java-65": { + "questions": [ + "9dde8d6033", + "5f403638bf", + "c8485a2dab" + ], + "route": "java", + "sig": "c5f573017a", + "stage": "系统设计", + "status": "active", + "title": "Kubernetes" + }, + "java-66": { + "questions": [ + "cd627f049d", + "b813e96d80", + "34f42af76f" + ], + "route": "java", + "sig": "bfe3bf196a", + "stage": "系统设计", + "status": "active", + "title": "系统设计题的思路与即时通讯" + }, + "java-67": { + "questions": [ + "c93bb3c65b", + "e1c4dd69ef", + "2c37283a5c" + ], + "route": "java", + "sig": "6ed51dc735", + "stage": "系统设计", + "status": "active", + "title": "订单与支付" + }, + "java-68": { + "questions": [ + "16e0b97125", + "ec06220d69", + "2c02de7f11" + ], + "route": "java", + "sig": "49bc06a543", + "stage": "大模型应用", + "status": "active", + "title": "RAG 与 Agent" + }, + "java-69": { + "questions": [ + "efe411f83c", + "c5494b65a3", + "904bc79056" + ], + "route": "java", + "sig": "3d4ec02919", + "stage": "大模型应用", + "status": "active", + "title": "MCP、Skills 与 AI 编程助手" + }, + "llm-01": { + "questions": [ + "cff796edde", + "98a89cafcd", + "aeb37c713b" + ], + "route": "llm", + "sig": "81ace0c3e4", + "stage": "大模型基础", + "status": "active", + "title": "Token 与 Transformer" + }, + "llm-02": { + "questions": [ + "128dcc61a9", + "0b76f3a00d", + "479c864f58" + ], + "route": "llm", + "sig": "bb9e8fd086", + "stage": "大模型基础", + "status": "active", + "title": "注意力机制" + }, + "llm-03": { + "questions": [ + "e4952a889a", + "493fe133cc", + "beeadc9d28" + ], + "route": "llm", + "sig": "1f17c0dd24", + "stage": "大模型基础", + "status": "active", + "title": "生成、KV Cache 与采样" + }, + "llm-04": { + "questions": [ + "e2bec394b4", + "f319983c2c", + "e628ba7bc1" + ], + "route": "llm", + "sig": "19969e06c1", + "stage": "大模型基础", + "status": "active", + "title": "训练过程" + }, + "llm-05": { + "questions": [ + "c4e5f8bc1a", + "f2ed00f34c", + "fa683c8ea1" + ], + "route": "llm", + "sig": "0650f10589", + "stage": "大模型基础", + "status": "active", + "title": "微调与量化" + }, + "llm-06": { + "questions": [ + "e90dd83642", + "8a7213cfde", + "3d4ae89337" + ], + "route": "llm", + "sig": "355a1a9ef7", + "stage": "大模型基础", + "status": "active", + "title": "幻觉与推理模型" + }, + "llm-07": { + "questions": [ + "8c4d592987", + "888d45b3b3", + "92dc236853" + ], + "route": "llm", + "sig": "cf9fcea4a9", + "stage": "提示词与上下文", + "status": "active", + "title": "提示词与结构化输出" + }, + "llm-08": { + "questions": [ + "63f07a4386", + "0a3efb066b", + "23a4be74d7" + ], + "route": "llm", + "sig": "77a4054a33", + "stage": "提示词与上下文", + "status": "active", + "title": "上下文工程" + }, + "llm-09": { + "questions": [ + "03016da606", + "45bf4a5b4d", + "4ff0d2d0d0" + ], + "route": "llm", + "sig": "697f70f997", + "stage": "检索增强生成", + "status": "active", + "title": "RAG 的流程、文档解析与切片" + }, + "llm-10": { + "questions": [ + "b9bc59a83b", + "f2d13da1ee", + "da52b096ec" + ], + "route": "llm", + "sig": "2d7f462d49", + "stage": "检索增强生成", + "status": "active", + "title": "Embedding 与向量索引" + }, + "llm-11": { + "questions": [ + "e9083e85a7", + "91d75f1bb2", + "c82b36ba2a" + ], + "route": "llm", + "sig": "aa9d7e0cf5", + "stage": "检索增强生成", + "status": "active", + "title": "向量数据库、混合检索与重排" + }, + "llm-12": { + "questions": [ + "1cb0108be7", + "6d00dbcd74", + "80e07271f2" + ], + "route": "llm", + "sig": "f092e64977", + "stage": "检索增强生成", + "status": "active", + "title": "Top-K 与查询改写" + }, + "llm-13": { + "questions": [ + "a53acc431d", + "a7f4a61121", + "b5c9ebc9ee" + ], + "route": "llm", + "sig": "7e7af62853", + "stage": "检索增强生成", + "status": "active", + "title": "RAG 的评测与扩展" + }, + "llm-14": { + "questions": [ + "513b0767e9", + "a2d1d91ef7", + "c226c6df57" + ], + "route": "llm", + "sig": "0a16a4cfab", + "stage": "Agent", + "status": "active", + "title": "Agent 与工具调用" + }, + "llm-15": { + "questions": [ + "c9434cba34", + "a645db12eb", + "1cd94a5fd7" + ], + "route": "llm", + "sig": "14048899f3", + "stage": "Agent", + "status": "active", + "title": "意图识别与规划" + }, + "llm-16": { + "questions": [ + "bc38dbf743", + "5b6919dab0", + "4bf4ba1fb0" + ], + "route": "llm", + "sig": "de47d8b380", + "stage": "Agent", + "status": "active", + "title": "记忆与多轮对话" + }, + "llm-17": { + "questions": [ + "d0cadbf383", + "676b456ee1" + ], + "route": "llm", + "sig": "4b80a829b6", + "stage": "Agent", + "status": "active", + "title": "MCP 与 Skills" + }, + "llm-18": { + "questions": [ + "634ab6d850", + "72f9842f42", + "4ec5716b53" + ], + "route": "llm", + "sig": "99f0739a24", + "stage": "Agent", + "status": "active", + "title": "多 Agent 与 Harness" + }, + "llm-19": { + "questions": [ + "bdc1af9732", + "bfcd308f00", + "bbc2863617" + ], + "route": "llm", + "sig": "6f37433fcb", + "stage": "Agent", + "status": "active", + "title": "开发框架、可靠性与评测" + }, + "llm-20": { + "questions": [ + "e36c204678", + "e8de3674b2", + "100f1b6f0b" + ], + "route": "llm", + "sig": "346ac5093e", + "stage": "工程实践", + "status": "active", + "title": "流式输出、成本与部署" + }, + "llm-21": { + "questions": [ + "52aa24db2a", + "1313e1319a", + "9f8d77ff45" + ], + "route": "llm", + "sig": "1ef0a754bf", + "stage": "工程实践", + "status": "active", + "title": "稳定性与安全" + }, + "sprint-01": { + "questions": [ + "56d0a4ee0e", + "0679b458cf", + "9c0c516330" + ], + "route": "sprint", + "sig": "7c00e65e35", + "stage": "Java", + "status": "active", + "title": "集合" + }, + "sprint-02": { + "questions": [ + "e4941b7494", + "f3cbce1b55", + "14a8b66241" + ], + "route": "sprint", + "sig": "437c9782af", + "stage": "Java", + "status": "active", + "title": "线程池、锁与 volatile" + }, + "sprint-03": { + "questions": [ + "36367df7c4", + "efa6604d75", + "97c02eae0a" + ], + "route": "sprint", + "sig": "0208298a44", + "stage": "Java", + "status": "active", + "title": "CAS、锁升级与 ThreadLocal" + }, + "sprint-04": { + "questions": [ + "b5eb99096c", + "b432826e95", + "75d8147fc6" + ], + "route": "sprint", + "sig": "14e300ab57", + "stage": "Java", + "status": "active", + "title": "JVM" + }, + "sprint-05": { + "questions": [ + "bc787d309a", + "76647ed961", + "d560ae1052" + ], + "route": "sprint", + "sig": "59d9d1a94e", + "stage": "MySQL", + "status": "active", + "title": "索引" + }, + "sprint-06": { + "questions": [ + "9a4ab4e114", + "955ea8cac8", + "59a27f0b68" + ], + "route": "sprint", + "sig": "5bf91b50c1", + "stage": "MySQL", + "status": "active", + "title": "事务、MVCC 与锁" + }, + "sprint-07": { + "questions": [ + "b9fdebae51", + "a5d2df43ec", + "394e2ef8d2" + ], + "route": "sprint", + "sig": "147d845c87", + "stage": "MySQL", + "status": "active", + "title": "SQL 优化、日志与分库分表" + }, + "sprint-08": { + "questions": [ + "4f74d11814", + "fb32f47b96", + "a0f17ec6e9" + ], + "route": "sprint", + "sig": "b10a65d9e0", + "stage": "Redis 与缓存", + "status": "active", + "title": "Redis" + }, + "sprint-09": { + "questions": [ + "c79e20122e", + "218297315a", + "c7974bfd54" + ], + "route": "sprint", + "sig": "42c1273538", + "stage": "Redis 与缓存", + "status": "active", + "title": "缓存问题与 Redis 集群" + }, + "sprint-10": { + "questions": [ + "f87401d466", + "2eacd733a9", + "a3c215dfb9" + ], + "route": "sprint", + "sig": "05ef6623f8", + "stage": "网络与操作系统", + "status": "active", + "title": "TCP 与 HTTPS" + }, + "sprint-11": { + "questions": [ + "1c3ffb95a4", + "d2c8cce9c3", + "a9201474fc" + ], + "route": "sprint", + "sig": "dcec70f902", + "stage": "网络与操作系统", + "status": "active", + "title": "进程、死锁与 I/O 多路复用" + }, + "sprint-12": { + "questions": [ + "2d35e2a6e9", + "e6469eab7d", + "a79be5580a" + ], + "route": "sprint", + "sig": "d6b2df2bd0", + "stage": "框架、分布式与大模型", + "status": "active", + "title": "Spring 与设计模式" + }, + "sprint-13": { + "questions": [ + "8325776328", + "747ba7232d", + "4fad9621d5" + ], + "route": "sprint", + "sig": "ead905739d", + "stage": "框架、分布式与大模型", + "status": "active", + "title": "分布式锁、消息队列与秒杀" + }, + "sprint-14": { + "questions": [ + "a03e2ca327", + "f62bbcd15d", + "485504eb0f" + ], + "route": "sprint", + "sig": "bef1d7ba3e", + "stage": "框架、分布式与大模型", + "status": "active", + "title": "大模型应用" + } + }, + "history": [], + "manual": { + "map": {}, + "separate": [] + }, + "questions": { + "01d822171f": { + "days": [ + "java-19" + ], + "points": "调用 System.gc(),规范上只是建议,HotSpot 默认会执行一次 Full GC,-XX:+DisableExplicitGC 可以禁用;老年代空间不足,例如大对象、长期存活的对象不断进入,或者空间分配担保失败,G1 中是混合回收来不及释放、复制时找不到空闲 Region,或 Humongous 对象找不到连续的 Region;JDK 7 及以前永久代不足,JDK 8 起元空间达到阈值(日志原因为 Metadata GC Threshold)时 Serial 和 ", + "status": "active", + "text": "Full GC 在什么情况下触发?" + }, + "0214655d6c": { + "days": [ + "java-37" + ], + "points": "检验和:出错的报文段直接丢弃;序号和累积确认:去重、排序,SACK 告诉发送方收到了哪些区间;重传:超时重传(RTO 按 RTT 动态计算、超时后翻倍),或收到 3 个重复确认时快重传;滑动窗口、流量控制和拥塞控制让发送速率不超过接收方和网络的能力。", + "status": "active", + "text": "TCP 怎样保证可靠传输?" + }, + "03016da606": { + "days": [ + "llm-09" + ], + "points": "离线:解析文档、切片,用 Embedding 模型转为向量存入向量数据库,同时建立关键词索引;在线:改写问题,向量和关键词两路召回,合并后重排,把最相关的几段和问题放进提示词,生成带引用的回答;提示词中给切片编号,要求每个结论标注来源,资料中没有答案时如实回答没找到。", + "status": "active", + "text": "RAG 的完整流程是怎样的?" + }, + "0679b458cf": { + "days": [ + "java-08", + "sprint-01" + ], + "points": "JDK 1.7 用分段锁,Segment 继承 ReentrantLock,默认 16 个,最多 16 个线程同时写,并发度创建后不能改;JDK 1.8 去掉 Segment,用 Node 数组加链表或红黑树,空桶用 CAS 插入,非空桶用 synchronized 锁住头节点,锁粒度缩小到桶;get 不加锁,靠 volatile 读到最新值。", + "status": "active", + "text": "ConcurrentHashMap 怎样保证线程安全?JDK 1.7 和 1.8 有什么区别?" + }, + "082e65c58c": { + "days": [ + "java-35" + ], + "points": "取模分布在节点数变化时几乎所有键都要迁移,一致性哈希把节点和键放在哈希环上,键存到顺时针第一个节点,增删节点只影响相邻节点,4 个节点扩到 5 个约 18% 的键迁移;节点少时分布不均(最多 44.7%、最少 9.0%),每个节点放 150 个虚拟节点后占比在 22.8%~27.0%。", + "status": "active", + "text": "一致性哈希解决了什么问题?虚拟节点有什么用?" + }, + "0a3efb066b": { + "days": [ + "llm-08" + ], + "points": "方法有截断(只留最近几轮,丢失早期内容)、摘要(多一次调用,可能漏掉或改写细节)、清理旧的工具结果(风险最小)和外置到文件,工具调用和结果要成对删除;常组合成“系统提示词 + 摘要 + 最近几轮原文 + 当前问题”;时机按 Token 数而不是轮数,约 70% 时在后台生成摘要,到上限时必须先压缩。", + "status": "active", + "text": "对话历史太长时怎样压缩?什么时候压缩?" + }, + "0a5b56bc23": { + "days": [ + "java-12" + ], + "points": "用有界的 ArrayBlockingQueue 作为共享缓冲区;生产者调用 put(),队列满时阻塞;消费者调用 take(),队列空时阻塞,不需要自己写 wait()/notify()。", + "status": "active", + "text": "怎样用 BlockingQueue 实现生产者消费者?" + }, + "0aad3661f4": { + "days": [ + "java-30" + ], + "points": "STRING 缓存对象、计数器、分布式锁,LIST 按插入顺序两端读写,例如最新 N 条消息、简单的消息队列;SET 去重和求交集、并集,例如标签、共同好友,HASH 保存对象的多个字段,例如用户信息、购物车;ZSET 按分值排序和范围查找,例如排行榜、按时间排序的 Feed;BITMAP、HyperLogLog 用于签到和 UV 统计,GEO 用于附近的门店,Stream 用于带消费者组的消息。", + "status": "active", + "text": "Redis 有哪些数据类型?分别用在什么场景?" + }, + "0ac347cf06": { + "days": [ + "java-44" + ], + "points": "协程在用户态由运行时调度,Go 和 Java 虚拟线程把大量协程映射到少量线程上(M:N),Python asyncio 则在一个线程的事件循环中执行,切换不陷入内核、内存占用小,可以大量创建,但同一线程上的协程不能并行;虚拟线程是 JVM 实现的有栈协程,由 ForkJoinPool 的平台线程(默认等于 CPU 核数)执行,阻塞 I/O 时卸载,栈帧以栈块对象存入堆,就绪后再挂载到某个平台线程继续执行;JDK 21 中在 synchronized 内阻塞无法卸载,JDK ", + "status": "active", + "text": "协程和线程有什么区别?Java 的虚拟线程是怎样实现的?" + }, + "0b76f3a00d": { + "days": [ + "llm-02" + ], + "points": "单头注意力中每个 Token 只有一组权重,只能按一种标准挑选信息;多头把 Q、K、V 切成 h 份分别计算,拼接后乘 WO,不同的头可以关注指代、相邻词等不同关系;每头维度为 d / h,计算量与单头基本相同。", + "status": "active", + "text": "多头注意力有什么作用?" + }, + "0e366e4baa": { + "days": [ + "java-58" + ], + "points": "消息队列只保证单个队列(Kafka 的分区)内有序;发送端按业务 Key(如订单号)把同一业务的消息发到同一个队列;接收端一个队列只由一个线程顺序消费,Kafka 中一个分区只分给组内一个消费者,RabbitMQ 用单个消费者或单活跃消费者;生产者重试可能让后发的消息先写入,Kafka 默认开启的幂等生产者用序号避免了这种乱序;分区数改变后同一 key 可能落到别的分区,要预先规划分区数。", + "status": "active", + "text": "怎样保证消息按顺序消费?" + }, + "100d141107": { + "days": [ + "java-52" + ], + "points": "例如下单校验用责任链:参数、限购、黑名单、库存各写一个 OrderValidator 实现类,用 @Order 排序,OrderService 注入 List 依次调用,不通过就抛异常;新增校验只需再写一个实现类,OrderService 不用改;类似地,运费和支付渠道用策略加工厂,支付成功后的通知用观察者。", + "status": "active", + "text": "项目中用过哪些设计模式?举一个例子。" + }, + "100f1b6f0b": { + "days": [ + "llm-20" + ], + "points": "早期系统按最大长度为每个请求预留连续的显存,真正存放 KV Cache 的只有 20.4%~38.2%;PagedAttention 借鉴操作系统的分页,把 KV Cache 切成固定大小的块(vLLM 默认 16 个 Token)按需分配,用块表记录位置,只浪费最后一块的空余;同样 7 GB 显存按 8K 预留只能服务 16 个请求,请求平均只用到 2K Token 时按块分配可服务 64 个;延迟相近时吞吐是 FasterTransformer、Orca 的 2~4 倍。", + "status": "active", + "text": "PagedAttention 解决了什么问题?" + }, + "128dcc61a9": { + "days": [ + "llm-02" + ], + "points": "公式为 softmax(QKT / √d) V:Q 与各 K 点积得到分数,softmax 成权重后对 V 加权求和;分量独立、均值 0、方差 1 时点积的方差为 d,维度大时 softmax 几乎集中到一个 Token 上,梯度接近 0;除以 √d 使方差恢复为 1。", + "status": "active", + "text": "注意力的计算公式是什么?为什么要除以 √d?" + }, + "1313e1319a": { + "days": [ + "llm-21" + ], + "points": "还没输出任何内容时直接重试,用户感知不到;已输出部分文字时,把已输出的部分作为 assistant 消息的开头请求续写(如 DeepSeek 的对话前缀续写),不支持就丢弃重新生成并通知前端清空;工具调用的参数只输出了一半时不能执行,丢弃后重新请求。", + "status": "active", + "text": "流式输出中途断开怎么办?" + }, + "14a8b66241": { + "days": [ + "sprint-02" + ], + "points": "保证可见性:写入后其它线程读取时立即可见;保证有序性:volatile 写之前、volatile 读之后的读写不会被重排序越过它,可用来发布数据(如双重校验锁单例);不保证 count++ 这类复合操作的原子性,要用锁或原子类。", + "status": "active", + "text": "volatile 能保证什么,不能保证什么?" + }, + "1528bcdda2": { + "days": [ + "java-29" + ], + "points": "事务要同时写 redo log 和 binlog,只写成功一个就崩溃会让主从不一致,例如 redo log 写了而 binlog 没写,主库保留修改,从库却收不到;两阶段提交:先写 redo log 并刷盘进入 prepare 状态,再写 binlog 并刷盘(带 Xid),最后在 redo log 中标记提交;崩溃恢复时 prepare 状态的事务在 binlog 中找到 Xid 就提交,否则回滚。", + "status": "active", + "text": "为什么需要两阶段提交?" + }, + "152f56d6c4": { + "days": [ + "java-17" + ], + "points": "引用计数无法处理循环引用,JVM 使用可达性分析:从 GC Roots 出发搜索,不可达的对象可以被回收;GC Roots 包括虚拟机栈中的参数和局部变量、JNI 引用的对象、类静态属性和常量引用的对象、被 synchronized 持有的锁对象和还没有结束的线程。", + "status": "active", + "text": "怎样判断一个对象可以被回收?哪些对象可以作为 GC Roots?" + }, + "1535c683ad": { + "days": [ + "java-03" + ], + "points": "约定是等价的两个对象哈希值必须相同;HashMap、HashSet 先用 hashCode() 确定桶并比较哈希值,相等才调用 equals();只重写 equals() 时两个等价对象的哈希值通常不同,会被当成两个键存入。", + "status": "active", + "text": "重写 equals() 时为什么必须重写 hashCode()?" + }, + "154ba456f2": { + "days": [ + "java-09" + ], + "points": "AbortPolicy(默认)抛出 RejectedExecutionException,CallerRunsPolicy 由提交任务的线程自己执行,DiscardPolicy 直接丢弃,DiscardOldestPolicy 丢弃队列中最早的任务再提交;不能丢且提交方能处理失败时用 Abort,能接受变慢时用 CallerRuns,Netty 的 I/O 线程这类不能阻塞的线程除外;允许丢失的统计上报用 Discard,只关心最新数据时用 DiscardOldest;线程池", + "status": "active", + "text": "线程池有哪些拒绝策略?怎样选择?" + }, + "15b9ac4352": { + "days": [ + "java-05" + ], + "points": "会执行,finally 在离开 try 语句之前执行;返回值在执行 finally 之前已经保存,返回基本类型时 finally 修改变量不影响返回值,返回引用时能看到对对象的修改;finally 中的 return 会覆盖返回值并吞掉异常;调用 System.exit()、kill -9 或 JVM 崩溃时 finally 不执行。", + "status": "active", + "text": "try 和 catch 中有 return 时,finally 还会执行吗?返回值是什么?" + }, + "15d74500a9": { + "days": [ + "java-20" + ], + "points": "类加载器先把加载请求交给父加载器,父加载器无法完成时才自己加载;好处是基础类统一,例如自己写的 java.lang.Object 不会替换启动类加载器加载的那个,程序中所有的 Object 都是同一个类;父加载器已经加载过的类不会重复加载,避免同一个类出现两个互不相等的 Class 对象。", + "status": "active", + "text": "什么是双亲委派模型?为什么需要它?" + }, + "16787de94b": { + "days": [ + "java-26" + ], + "points": "没有从联合索引的最左列开始,或者依赖联合索引中范围条件之后的列缩小扫描范围(范围条件带等号时例外);对索引列使用函数(例如 DATE(create_time)),或者发生隐式类型转换(例如字符串列与数字比较);LIKE 以 % 开头,OR 的一侧没有索引;匹配的行太多,优化器估算全表扫描更快,常见于 !=、NOT IN 和大范围的条件;这些是普通索引上的常见情况,只查索引中的列时可能改为扫描整个索引,缺少最左列时 8.0.13 起可能用跳跃扫描,对表达式建函数索引后函数条件也", + "status": "active", + "text": "哪些情况下索引会失效?" + }, + "16e0b97125": { + "days": [ + "java-68" + ], + "points": "离线阶段解析文档、切成小段,用 Embedding 模型把每段转为向量存入向量数据库,同时建立关键词索引;在线阶段先改写问题,向量检索和关键词检索同时召回候选,合并后重排,把最相关的几段资料和问题一起放进提示词,生成带引用的回答;能减少幻觉,是因为模型从凭记忆回答变为根据给定的资料回答,每个结论都能按编号核对;但资料没找到或找错时模型仍会编造,所以要用重排分数和相似度阈值判断有没有答案,没有时改写问题再检索、向用户追问,或者如实回答没找到。", + "status": "active", + "text": "RAG 的完整流程是什么?它为什么能减少幻觉?" + }, + "17a016b327": { + "days": [ + "java-59" + ], + "points": "顺序写和页缓存:消息只追加到日志末尾,写入页缓存即可,不要求每条消息刷盘,确认条件仍由 acks 决定,可靠性靠多副本;批量:同一分区的消息攒成批次传输、写入和复制,关闭批量后吞吐从每秒约 6 万条降到约 6600 条;零拷贝:broker 用 sendfile 把日志直接发到 Socket;分区并行:多个分区分布在不同 broker 上。", + "status": "active", + "text": "Kafka 为什么吞吐量高?" + }, + "18a112ceaf": { + "days": [ + "java-52" + ], + "points": "工厂(BeanFactory、FactoryBean)和单例(默认的 singleton 作用域);代理:AOP 的 JdkDynamicAopProxy、CglibAopProxy;模板方法:JdbcTemplate、TransactionTemplate;还有观察者(ApplicationEvent、@EventListener)、适配器(HandlerAdapter)、责任链(拦截器链)和策略(InstantiationStrategy)。", + "status": "active", + "text": "Spring 中用到了哪些设计模式?" + }, + "1a5bcb758d": { + "days": [ + "java-52" + ], + "points": "写法有懒汉式(线程不安全)、饿汉式、方法加 synchronized、双重校验锁、静态内部类、枚举(还能防反射和反序列化);new Singleton() 分为分配内存、初始化、赋值给引用三步,可能被重排序为先赋值再初始化,另一个线程在外层判空时就会拿到未初始化的对象,volatile 禁止这种重排序。", + "status": "active", + "text": "单例模式有哪几种写法?双重检查锁为什么要加 volatile?" + }, + "1c0bbdffef": { + "days": [ + "java-10" + ], + "points": "靠协作:其它线程设置 volatile 停止标记或调用 interrupt(),由线程自己检查后在合适的位置结束;线程在 sleep()、wait()、join() 中时 interrupt() 使其抛出 InterruptedException,捕获后是否结束由代码决定,只设停止标记唤醒不了它;循环中没有这类调用时,用 interrupted() 检查中断标记后退出;interrupt() 不能中断等待 synchronized 锁的阻塞,平台线程阻塞在传统 I/O 上时通", + "status": "active", + "text": "怎样停止一个正在运行的线程?" + }, + "1c3ffb95a4": { + "days": [ + "sprint-11" + ], + "points": "进程是资源分配的基本单位,线程是调度的基本单位,线程共享进程的资源,创建和切换开销小,能直接读写共享数据,进程间要用 IPC;进程互相隔离,而一个线程访问非法内存触发段错误时,整个进程都会被终止;协程是在用户态由运行时调度的轻量级线程,切换不陷入内核、内存占用小,大量协程映射到少量线程上,适合 I/O 密集任务,同一线程上的协程不能并行。", + "status": "active", + "text": "进程、线程和协程有什么区别?" + }, + "1cb0108be7": { + "days": [ + "llm-12" + ], + "points": "召回的 K 是每路交给重排的候选数(如 50),在评测集上取召回率不再明显上升的位置,有重排时可以取大些;放进提示词的 K 是重排后保留的数量(如 3~5),太大增加成本、混入无关或旧版本资料,太小答不全,按端到端正确率和上下文预算确定。", + "status": "active", + "text": "召回的 Top-K 和放进提示词的 Top-K 怎么定?" + }, + "1cd94a5fd7": { + "days": [ + "llm-15" + ], + "points": "类别定义互斥并写明边界,输出限制为枚举值,不在列表中的按“其它”处理;分层:规则只处理“转人工”、带订单号这类精确率高的情况,其余交给模型;置信度低时反问澄清或转人工,阈值在验证集上扫一遍再定,分错代价高的类别(如退款)单独设更高的阈值;多轮对话结合最近几轮;用评测集按类别看精确率、召回率和混淆矩阵,修改后回归。", + "status": "active", + "text": "怎样保证意图识别和路由准确?" + }, + "1d67fbd177": { + "days": [ + "java-36" + ], + "points": "客户端发 SYN(序号 x),服务器回 SYN+ACK(序号 y、确认号 x+1),客户端回 ACK(确认号 y+1),双方进入 ESTABLISHED;双方的初始序号都要得到对方确认,两次握手时服务器的序号得不到确认;第三次握手还能让客户端对滞留的旧连接请求回 RST,避免服务器多打开一个连接。", + "status": "active", + "text": "三次握手的过程是怎样的?为什么不是两次?" + }, + "1df5ce34bc": { + "days": [ + "java-19" + ], + "points": "并发标记时,用户线程让已经是黑色的对象引用了白色对象,同时删除了从灰色对象到它的所有引用,这个白色对象不会再被访问,标记结束后被当成垃圾回收;CMS 用增量更新,写入引用时写屏障把卡标记为脏,重新标记时重新扫描;G1 用原始快照(SATB),删除或覆盖引用前写屏障记录旧值并当作存活。", + "status": "active", + "text": "三色标记中的漏标是怎样发生的?CMS 和 G1 分别怎样解决?" + }, + "2081fb3147": { + "days": [ + "java-31" + ], + "points": "跳表在有序链表上加多层索引,每个节点以 1/4 的概率升一层,查找、插入、删除的期望复杂度都是 O(logN);与平衡树相比,插入删除只改前驱指针,不需要旋转,范围查询找到起点后沿底层链表遍历,实现也更简单;数据在内存中,不需要 B+ 树那样减少磁盘 I/O;每个指针记录跨度,ZRANK 也是 O(logN)。", + "status": "active", + "text": "ZSET 的底层为什么用跳表?" + }, + "20c2e1e5be": { + "days": [ + "java-48" + ], + "points": "BIO 对应阻塞式 I/O,线程阻塞在 read 上,每个连接要一个线程;NIO 对应 I/O 复用,Channel 设为非阻塞,一个线程在 Selector.select() 上等待多个连接中任意一个就绪(Linux 上用 epoll 实现);AIO 对应异步 I/O 的编程方式,数据复制到应用的缓冲区后才通知应用,但 Linux 上的 JDK 实际用 epoll 加线程池实现;BIO、NIO 在复制数据时仍会阻塞,属于同步 I/O。", + "status": "active", + "text": "BIO、NIO 和 AIO 有什么区别?" + }, + "21184e47ef": { + "days": [ + "java-29" + ], + "points": "以 UPDATE orders SET status = 3 WHERE id = 1024 为例:按主键找到这一行,页不在缓冲池就先读入,并加 X 锁;旧值写入 undo log,修改缓冲池中的数据页并生成 redo log 放入 redo log buffer,执行器把修改写入 binlog cache;提交时按两阶段提交把 redo log 和 binlog 刷盘,释放锁并返回;脏页之后由后台线程写回磁盘。", + "status": "active", + "text": "一条 UPDATE 语句执行时,三种日志是怎样写的?" + }, + "218297315a": { + "days": [ + "java-35", + "sprint-09" + ], + "points": "用 Cache Aside:读未命中时查数据库并写回缓存,写时先更新数据库(事务提交之后)再删除缓存;删除而不是更新缓存,避免并发写到达缓存的顺序与数据库不同;删除失败或慢读写回旧值时仍会不一致,用过期时间兜底,加上延迟双删、消息队列重试或订阅 binlog 删除;只能最终一致,余额、库存直接读写数据库。", + "status": "active", + "text": "怎样保证缓存与数据库的一致性?" + }, + "221ffa2522": { + "days": [ + "java-37" + ], + "points": "流量控制保护接收方:接收方在确认报文的窗口字段中通告缓冲区剩余空间,为 0 时发送方停止发送;拥塞控制保护整个网络:发送方维护拥塞窗口 cwnd,用慢开始、拥塞避免、快重传、快恢复调节;实际发送窗口取 cwnd 和接收窗口中的较小值。", + "status": "active", + "text": "拥塞控制和流量控制有什么区别?" + }, + "2309010a02": { + "days": [ + "java-58" + ], + "points": "下单时发一条 30 分钟后投递的延迟消息,到期后消费者用条件更新取消订单(WHERE status = 'UNPAID'),重复或晚到的消息不会取消已支付的订单,要求高的业务再用低频扫描兜底;用户已付款、回调还没到时本地仍是待支付,所以取消前要先向支付平台关单,平台返回已支付就按支付成功处理;RocketMQ 4.x 有 30m 延迟级别,5.x 可指定任意时间,RabbitMQ 用 TTL 加死信队列;也可以用 Redis 有序集合,分值为到期时间,后台线程取到期成员,多实", + "status": "active", + "text": "怎样实现订单 30 分钟未支付自动取消?" + }, + "23a4be74d7": { + "days": [ + "llm-08" + ], + "points": "订单号、金额、地址等由代码从工具参数和结果中提取单独保存,承诺通过工具写进记录;摘要分项输出、编号和金额照原文抄写,“已办理”只写有工具结果确认的操作,新摘要在旧摘要的基础上合并;用关键事实覆盖和探针问题检查,实测 3B 的结构化摘要保留 9.7 条事实,普通摘要只有 4.3 条(共 14 条)。", + "status": "active", + "text": "压缩后怎样保证关键事实不丢失?" + }, + "25bf570498": { + "days": [ + "java-49" + ], + "points": "不一定:Spring 不做同步,是否安全取决于有没有可变的成员变量;只持有其它 Bean 引用的 Controller、Service 是安全的,把请求数据存进成员变量就会互相覆盖;数据放在局部变量中,必须共享时用 AtomicInteger 等线程安全的类,每个线程各自的数据放在 ThreadLocal 中。", + "status": "active", + "text": "单例 Bean 是线程安全的吗?" + }, + "271db04da7": { + "days": [ + "java-42" + ], + "points": "先查浏览器缓存、系统缓存和 hosts,未命中则向本地 DNS 服务器递归查询;本地 DNS 服务器依次迭代询问根服务器、顶级域服务器,它们只返回下一级服务器的地址,最后由权威服务器返回 IP;各级结果按 TTL 缓存;一般用 UDP 53 端口,响应超过 UDP 报文的长度上限(不用 EDNS 时为 512 字节)被截断,或做全量区域传送(AXFR)时用 TCP。", + "status": "active", + "text": "DNS 的解析过程是怎样的?" + }, + "283fe326d9": { + "days": [ + "java-53" + ], + "points": "已经运行的服务还能调用,实例列表缓存在本地;新启动的服务默认注册失败直接退出,关闭 fail-fast 能启动,但没有实例列表,调用报 No servers available;所以不会立刻影响线上调用,但无法扩容和发布,生产环境要集群部署。", + "status": "active", + "text": "注册中心宕机后,服务之间还能调用吗?" + }, + "28fb95c127": { + "days": [ + "java-45" + ], + "points": "管道:单向,一般用于父子或兄弟这类有共同祖先的进程,FIFO(命名管道)去掉了这个限制;消息队列:独立于读写进程存在,可按类型接收;共享内存:同一物理页映射到多个进程,不用复制,最快,要配合信号量同步;套接字:可用于不同机器之间。", + "status": "active", + "text": "进程间通信有哪些方式?" + }, + "29246511a6": { + "days": [ + "java-41" + ], + "points": "对称加密快,但无法安全地把密钥交给对方;非对称加密的公钥可以公开传输(是否属于对方要靠证书验证),但运算慢(RSA-2048 私钥解密不到 0.4 MB/s,AES-128-GCM 加密约 6 GB/s);所以用非对称方式协商或传输会话密钥,再用对称加密传输数据;现在大多用 ECDHE 协商会话密钥,证书的密钥只用于签名,TLS 1.3 已删除 RSA 密钥交换。", + "status": "active", + "text": "HTTPS 为什么同时使用对称加密和非对称加密?" + }, + "2af89296be": { + "days": [ + "java-02" + ], + "points": "值传递;传入对象时复制的是对象的地址,方法中修改对象的字段会影响原对象;方法中让形参指向新对象,方法外的引用不变。", + "status": "active", + "text": "Java 是值传递还是引用传递?" + }, + "2c02de7f11": { + "days": [ + "java-68" + ], + "points": "工作流的步骤和顺序由代码事先写好,可预测、成本可控,适合步骤固定的业务;Agent 的下一步由模型临时决定,适合事先确定不了步骤的开放任务,代价是成本更高、错误会逐步累积,每步成功率 95% 时连续 10 步都成功只有约 60%;判断标准是步骤和步骤之间的转移规则能否事先写清;实际系统常把两者组合:先用意图路由分流,退款这类涉及资金、步骤固定的流程写成工作流,开放的咨询交给 Agent。", + "status": "active", + "text": "什么时候用工作流,什么时候用 Agent?" + }, + "2c37283a5c": { + "days": [ + "java-67" + ], + "points": "按账户 ID 从小到大加锁,两个方向的转账都先锁 id 小的行,就不会互相等待(实验中每 480 笔的死锁从约 70~100 次降为 0),只有涉及同一账户的转账在这一行的锁上排队,不是串行化;余额判断写在条件中:UPDATE account SET balance = balance - 100 WHERE id = 1 AND balance >= 100,影响 0 行说明余额不足、回滚,先读出余额在应用中算好再写回会互相覆盖;客户端生成的请求号作主键,死锁回滚或超时重试", + "status": "active", + "text": "A 给 B 转账的同时 B 给 A 转账,怎样避免死锁,又不让余额扣成负数?" + }, + "2d35e2a6e9": { + "days": [ + "java-50", + "sprint-12" + ], + "points": "Spring AOP 基于动态代理:容器中保存代理对象,调用时先执行切面逻辑,再调用原始对象的方法;JDK 动态代理要求目标类实现接口,代理类继承 Proxy 并实现同样的接口,只能代理接口方法;CGLIB 生成目标类的子类、重写方法,无法代理 final 类,以及 final、static 和 private 方法;Spring Boot 默认用 CGLIB。", + "status": "active", + "text": "AOP 的原理是什么?JDK 动态代理和 CGLIB 有什么区别?" + }, + "2de7600ab9": { + "days": [ + "java-07" + ], + "points": "两个线程同时向同一个空桶插入,后写的覆盖先写的,size++ 也不是原子操作,8 个线程各插 10 万个键 size 远少于 80 万;JDK 1.7 并发扩容时头插法可能让链表成环,get 死循环;JDK 1.8 改为尾插法,但仍会丢数据,转红黑树时还可能破坏树结构;多线程用 ConcurrentHashMap。", + "status": "active", + "text": "HashMap 为什么线程不安全?" + }, + "2eacd733a9": { + "days": [ + "sprint-10" + ], + "points": "UDP 无连接、尽最大努力交付、面向报文、没有拥塞控制、首部 8 字节,延迟低,支持一对多;TCP 面向连接、可靠交付、面向字节流、有流量控制和拥塞控制,只能点对点;TCP 靠检验和、序号和累积确认(去重、排序)、超时重传和快重传(3 个重复确认,配合 SACK 只重传缺失的段),以及滑动窗口、流量控制、拥塞控制保证可靠。", + "status": "active", + "text": "TCP 和 UDP 有什么区别?TCP 怎样保证可靠传输?" + }, + "3011568278": { + "days": [ + "java-20" + ], + "points": "加载:按全限定名取得字节流,转换为方法区的运行时结构,生成 Class 对象;验证字节流符合规范、不危害虚拟机;准备:为类变量分配内存并设为零值(基本类型或 String、用常量表达式初始化的 static final 常量不经 \\() 直接取 ConstantValue 的值,《深入理解 Java 虚拟机》把这一步归在准备阶段,JVMS SE 21 写在初始化过程中);解析:把常量池中的符号引用替换为直接引用;初始化:执行 \\(),即类变量赋", + "status": "active", + "text": "类加载的过程是怎样的?" + }, + "302564cde9": { + "days": [ + "java-60" + ], + "points": "固定窗口:每个窗口一个计数器,最简单,但窗口边界处最多放行 2 倍;滑动窗口:统计往前一个窗口内的请求,精确,但每个请求存一条记录;漏桶:排队后按固定速率处理,最平滑,突发要等待,适合保护处理能力固定的下游;令牌桶:按固定速率放令牌,允许不超过桶容量的突发,Guava RateLimiter 按这种方式实现,它还让当前请求先通过、把欠下的等待算到下一个请求上。", + "status": "active", + "text": "常用的限流算法有哪些?分别有什么特点?" + }, + "30520434c8": { + "days": [ + "java-51" + ], + "points": "@SpringBootApplication 包含 @EnableAutoConfiguration;启动时读取各 jar 中 META-INF/spring/...AutoConfiguration.imports(2.7 之前为 spring.factories)列出的自动配置类;大多数自动配置类带 @ConditionalOnClass、@ConditionalOnMissingBean 等条件注解,条件满足才生效;自动配置在用户的 Bean 注册之后处理,用户定义的 ", + "status": "active", + "text": "Spring Boot 的自动配置是怎样实现的?" + }, + "3362091f47": { + "days": [ + "java-57" + ], + "points": "处理完才确认,崩溃后会重复投递,所以消费逻辑要幂等:用业务唯一标识(订单号加事件类型,不用消息 ID)作去重键,在同一个本地事务中先写去重表再执行业务,主键冲突就直接确认;有状态的业务用条件更新(WHERE status = 'UNPAID');Kafka 的恰好一次不能代替业务幂等。", + "status": "active", + "text": "怎样避免重复消费?" + }, + "347baa0bbd": { + "days": [ + "java-18" + ], + "points": "把堆分成大小相等的 Region,每个 Region 可以单独回收;记录每个 Region 的回收耗时和能回收的空间,维护优先列表,优先回收价值最大的 Region;每次混合回收挑选的老年代 Region 数量受 MaxGCPauseMillis(默认 200 ms)限制;靠调整新生代大小满足目标,所以不要用 -Xmn 固定新生代。", + "status": "active", + "text": "G1 怎样控制停顿时间?" + }, + "34f42af76f": { + "days": [ + "java-66" + ], + "points": "TCP 连接由四元组区分,服务端所有连接共用一个监听端口,65535 不是服务端的上限,端口数只限制发起连接的一方(同一个源 IP 到同一个目的地址约 2.8 万个);服务端的瓶颈是文件描述符(调大 ulimit -n)、内存(实测每个空闲连接约 5 KB,10 万个约 500 MB)以及消息量带来的 CPU 和带宽;用 epoll 一个线程就能管理 10 万个连接,再配合应用层心跳及时清理已经断开的连接。", + "status": "active", + "text": "一台服务器能支撑多少个长连接?瓶颈在哪里?" + }, + "36367df7c4": { + "days": [ + "java-14", + "sprint-03" + ], + "points": "CAS 有内存地址 V、预期值 A、新值 B 三个操作数,V 等于 A 时才更新为 B,由处理器指令(x86 上带 lock 前缀的 cmpxchg)保证原子性,失败时不阻塞线程、只返回 false,由调用方决定重试还是放弃,incrementAndGet() 等方法在内部循环重试;ABA 指值从 A 改为 B 又改回 A,CAS 误以为没变,会让无锁栈这类结构出错;用 AtomicStampedReference 给引用加版本号,每次修改加 1,持有旧版本号的 CAS 会失", + "status": "active", + "text": "CAS 的原理是什么?ABA 问题怎么解决?" + }, + "380a188f36": { + "days": [ + "java-01" + ], + "points": "可变性:String 不可变,StringBuffer 和 StringBuilder 可变;线程安全:String 因不可变而线程安全,StringBuffer 的方法用 synchronized 同步,StringBuilder 不加锁,单线程拼接时用它;循环中拼接字符串应在循环外建一个 StringBuilder 调用 append(),用 + 每次都会创建新对象并复制内容。", + "status": "active", + "text": "String、StringBuffer 和 StringBuilder 有什么区别?" + }, + "3896059e83": { + "days": [ + "java-15" + ], + "points": "JDK 21 中依次为无锁(Mark Word 末尾 01)、轻量级锁(CAS 换成 Lock Record 指针,末尾 00)、重量级锁(其它线程持有锁时 CAS 失败,膨胀为 ObjectMonitor,末尾 10,先自适应自旋,抢不到再阻塞);偏向锁 JDK 15 起默认禁用、之后被移除;空闲的 ObjectMonitor 会被回收,对象头恢复为无锁,所以“只能升级不能降级”不准确。", + "status": "active", + "text": "说一下 synchronized 的锁升级过程。" + }, + "393777a7d3": { + "days": [ + "java-07" + ], + "points": "插入后 size 大于 threshold(capacity × 0.75)时容量翻倍,默认 16 时放入第 13 个键后变为 32;扩容时按 hash & oldCap 是否为 0 把节点分到 j 和 j + oldCap 两个位置,保持相对顺序;容量是 2 的幂时,hash & (capacity - 1) 只保留 hash 的低位,hash 非负时与 hash % capacity 相同(hash 为负时 % 可能得到负数,与运算仍是合法下标),位运算比取模快,扩容也只", + "status": "active", + "text": "HashMap 是怎样扩容的?为什么容量是 2 的幂?" + }, + "394e2ef8d2": { + "days": [ + "java-28", + "sprint-07" + ], + "points": "跨分片的事务要用分布式事务,跨分片的连接要拆成单表查询在应用中连接;不能再用自增主键,改用雪花算法等分布式 ID;不带分片键的查询要查所有分片,可以用基因法、映射表或按另一个维度再存一份,跨分片的深分页改用游标分页;增加分片要迁移数据,常用双写迁移。", + "status": "active", + "text": "分库分表之后会遇到哪些问题?" + }, + "3b0c37d0ee": { + "days": [ + "java-39" + ], + "points": "Cookie 是保存在浏览器、每次请求自动携带的小块数据(约 4 KB);Session 把状态存在服务器(如 Redis),Cookie 只带 Session ID,删除 Session 就能让用户下线,集群要共享;Token(如 JWT)把状态放在带签名的令牌中,服务无需保存会话就能验证,但过期前无法主动失效,常用短期访问令牌加刷新令牌,需要立即失效时把令牌 ID 放入 Redis 黑名单,这又回到了在服务器保存状态。", + "status": "active", + "text": "Cookie、Session 和 Token 有什么区别?" + }, + "3cde3d237b": { + "days": [ + "java-06" + ], + "points": "线程安全的 List 有 Vector、Collections.synchronizedList() 和 CopyOnWriteArrayList;CopyOnWriteArrayList 写操作加锁并在复制的新数组上进行,写完再替换原数组,读不加锁;适合读多写少的场景,缺点是写时内存约翻倍,写入期间读到的可能是旧数组,迭代器也看不到创建后的修改。", + "status": "active", + "text": "线程安全的 List 有哪些?CopyOnWriteArrayList 适合什么场景?" + }, + "3d4ae89337": { + "days": [ + "llm-06" + ], + "points": "编造参数,例如用户没给订单号就自己编一个去查;编造或误读工具结果,例如接口超时仍说“正在运输中”、把 updated_at 当成送达时间;声称做了没做的事,例如没调用退款工具却说已退款;应对要靠工具和代码:结果字段写清楚、校验参数来源、用代码核对关键信息。", + "status": "active", + "text": "Agent 调用工具时会出现哪些幻觉?" + }, + "3eda6c30d3": { + "days": [ + "java-55" + ], + "points": "TCC 由业务实现 Try(预留资源,如冻结库存)、Confirm、Cancel,不长期持有数据库锁,适合一致性要求较高、能改造出三个接口的资源操作,如库存、资金;本地消息表把消息和业务数据写在同一个本地事务中,再投递给对方幂等执行,失败只能重试,适合能接受最终一致的后续操作,如下单后发券、加积分。", + "status": "active", + "text": "TCC 和本地消息表分别适合什么场景?" + }, + "3f287e23ea": { + "days": [ + "java-49" + ], + "points": "实例化(反射调用构造函数)→ 属性填充 → Aware 回调 → BeanPostProcessor 的 before 方法 → 初始化(@PostConstruct、afterPropertiesSet()、init-method)→ BeanPostProcessor 的 after 方法,AOP 在这里替换为代理;容器关闭时依次调用 @PreDestroy、destroy()、destroy-method。", + "status": "active", + "text": "Bean 的生命周期是怎样的?" + }, + "4383694e3e": { + "days": [ + "java-14" + ], + "points": "多个线程不管以何种方式访问一个类,调用方不需要额外同步都能表现正确;根源是多个线程读写共享的可变数据;做法:不可变对象,互斥同步(synchronized、ReentrantLock),非阻塞同步(CAS、原子类),无同步方案(栈封闭、ThreadLocal)。", + "status": "active", + "text": "什么是线程安全?怎样保证线程安全?" + }, + "4391e4bcee": { + "days": [ + "java-25" + ], + "points": "数据在磁盘上,要让读磁盘的次数最少;红黑树是二叉树,200 万行约 21 层,B+ 树的一个节点是 16 KB 的页,有几百上千个子节点,200 万行只有 3 层;B 树每个节点都存数据,B+ 树只有叶子存数据,内部节点能放下更多 key,叶子用指针相连,便于范围查询,每次查找都走到叶子,I/O 次数稳定;哈希索引只支持等值查找,不能用于排序和范围查询。", + "status": "active", + "text": "MySQL 为什么用 B+ 树做索引,而不用 B 树、红黑树或哈希表?" + }, + "4489ec0668": { + "days": [ + "java-32" + ], + "points": "Redis 用一个线程执行命令,执行脚本期间不处理其它客户端的命令,整个脚本就像一条命令,可以根据前一条命令的结果决定后续操作,例如先 GET 再判断是否 INCR;这里的原子性是不被打断,不是出错回滚,中途出错时已执行的写命令不会回滚。", + "status": "active", + "text": "Lua 脚本为什么能保证原子性?" + }, + "454c7daf16": { + "days": [ + "java-13" + ], + "points": "volatile 保证可见性(写入对之后读取的线程立即可见)和有序性(volatile 写之前的读写不会被重排序到写之后,volatile 读之后的读写不会被重排序到读之前);单次读写是原子的,long、double 也不会被拆成两次,但不保证 i++ 这类复合操作的原子性:i++ 是读取、加 1、写回三步,两个线程可能读到同一个旧值,各自加 1 后写回,只增加了 1;读-改-写操作要用锁或原子类。", + "status": "active", + "text": "volatile 能保证什么?为什么 volatile 修饰的 i++ 仍然不安全?" + }, + "45bf4a5b4d": { + "days": [ + "llm-09" + ], + "points": "按标题、段落等结构切,表格和代码块保持完整,相邻切片保留重叠,切片带上标题路径;一节超过上限时递归切分,按空行、换行、句号、逗号由粗到细尝试,LangChain 默认的分隔符没有中文标点,要补上;没有标题的长文本可以语义切分,在相邻两句相似度明显下降处切开;也可以用小切片检索、返回所在的完整段落(父子切片);大小没有通用最优值,以 Embedding 模型的最大长度为上限,事实类问题用小切片,最后在评测集上比较召回率。", + "status": "active", + "text": "有哪些切片策略?切片大小怎么确定?" + }, + "47712a7a5f": { + "days": [ + "java-23" + ], + "points": "快照读是普通 SELECT,读 MVCC 快照,不加锁;当前读是 INSERT、UPDATE、DELETE 和 SELECT ... FOR SHARE / FOR UPDATE,读最新版本并加锁;可重复读下同一事务混用时,快照读看不到快照建立后其它事务提交的新行,当前读却能看到并修改它们,修改后本事务的快照读也能看到这些行。", + "status": "active", + "text": "快照读和当前读有什么区别?" + }, + "479c864f58": { + "days": [ + "llm-02" + ], + "points": "标准注意力的计算量为 O(n2d),n × n 的分数矩阵占 O(n2) 显存,长上下文时显存读写成为瓶颈;FlashAttention 分块在 GPU 片上缓存中计算,分数矩阵不写回显存,用在线 softmax 逐块累积;结果在数学上与标准注意力相同,不是近似,省的是显存读写而不是计算量。", + "status": "active", + "text": "FlashAttention 解决了什么问题?" + }, + "48101acfd2": { + "days": [ + "java-23" + ], + "points": "未提交读三种问题都可能出现;提交读避免脏读;可重复读再避免不可重复读,InnoDB 用 MVCC 和 Next-Key Lock 在很大程度上也避免了幻读,是 MySQL 的默认级别;可串行化三种都能避免,InnoDB 在显式事务中把普通 SELECT 变成 FOR SHARE,读写互相阻塞,并发度最低。", + "status": "active", + "text": "四种隔离级别分别解决了哪些问题?" + }, + "485504eb0f": { + "days": [ + "sprint-14" + ], + "points": "Function Calling 是模型侧的能力,模型根据工具描述给出函数名和参数;MCP 是应用与外部工具、数据之间的协议,统一工具的发现(tools/list)和调用(tools/call),工具实现一次服务端就能被所有支持 MCP 的应用使用;两者配合:主机把 MCP 工具转成工具定义交给模型,再通过 MCP 执行模型给出的调用。", + "status": "active", + "text": "MCP 和 Function Calling 有什么区别?" + }, + "488c66ff9c": { + "days": [ + "java-62" + ], + "points": "按 hash(url) % 100 切成 100 个约 1 GB 的小文件,相同的 URL 一定在同一个文件中;逐个文件用 HashMap 统计次数,用小顶堆取出前 10 个;再用小顶堆从 100 × 10 个候选中选出全局前 10 个;某个文件中不同的 URL 仍然太多、HashMap 放不下时,换哈希函数对它再切分。", + "status": "active", + "text": "100 GB 的日志中每行是一个 URL,内存只有 4 GB,怎样找出出现次数最多的 10 个?" + }, + "493fe133cc": { + "days": [ + "llm-03" + ], + "points": "MHA 中每个查询头都有自己的 K、V;GQA 让多个查询头共享一组 K、V,Qwen2.5-7B 的 28 个查询头共享 4 组,每个 Token 缓存 56 KB,MHA 要 392 KB;MQA 中所有查询头共享一组,缓存最小,但效果下降更明显;GQA 减少的是缓存和解码每步读取的数据量,计算量基本不变,每个查询头仍有自己的 Q,所以效果接近 MHA。", + "status": "active", + "text": "MHA、MQA 和 GQA 有什么区别?" + }, + "4978768999": { + "days": [ + "java-34" + ], + "points": "先查本地缓存(Caffeine),未命中查 Redis,再未命中查数据库并逐级回填;本地读取约 21 纳秒、Redis 约 68 微秒,热点放本地还能分散单个 Redis 节点的压力;更新后删 Redis,并广播消息删除各实例的本地缓存,本地再设约 30 秒过期兜底;只放读多写少、能接受短暂不一致的数据。", + "status": "active", + "text": "本地缓存和 Redis 怎样配合使用?" + }, + "4baf13900d": { + "days": [ + "java-61" + ], + "points": "让“判断库存”和“扣减库存”成为一个整体;悲观锁 FOR UPDATE 正确,但锁持有到事务提交,其它请求排队;乐观锁同时读到同一版本的请求只有一个成功,有库存也会失败(150 人抢 100 件,重试 3 次也只卖出 48~57 件),适合冲突少的场景;条件更新 WHERE stock > 0 不需重试,但同一行更新仍串行,所以先用 Redis 挡住大部分请求,条件更新作为最后防线。", + "status": "active", + "text": "秒杀系统怎样防止超卖?悲观锁、乐观锁和条件更新各有什么问题?" + }, + "4bf4ba1fb0": { + "days": [ + "llm-16" + ], + "points": "完整历史在上下文中时模型一般能理解指代,问题出在检索、压缩和多个候选;检索前结合最近几轮和任务状态改写成完整的问题,例如“它什么时候发货”改为“订单 1026 什么时候发货”;任务状态记录当前和提到过的订单,有多个候选时追问而不是猜。", + "status": "active", + "text": "多轮对话中的指代和省略怎样处理?" + }, + "4c736dfa3f": { + "days": [ + "java-54" + ], + "points": "用 SET lock_key unique_value NX PX 30000 加锁,键不存在才成功,并同时设置过期时间;释放时用 Lua 脚本先比较 value 再删除,避免误删别人的锁;分成 SETNX 和 EXPIRE 两条命令时,执行完 SETNX 后进程崩溃,锁就永远不会过期。", + "status": "active", + "text": "用 Redis 怎样实现分布式锁?为什么加锁和设置过期时间要用一条命令?" + }, + "4e79ae834e": { + "days": [ + "java-40" + ], + "points": "HTTP 是请求-响应模式,服务器不能主动发消息;WebSocket 由一次 HTTP 升级(101)而来,之后在同一个 TCP 连接上全双工传输帧;SSE 是 text/event-stream 的普通 HTTP 响应,只能服务器推送,浏览器的 EventSource 断线后自动重连(它只能发 GET,用 fetch 读取响应流时要自己重连);只需服务器推送(如大模型逐字输出)用 SSE,双方都要随时发消息(聊天)用 WebSocket。", + "status": "active", + "text": "WebSocket 和 HTTP 有什么区别?什么时候用 SSE?" + }, + "4ec5716b53": { + "days": [ + "llm-18" + ], + "points": "第一次会话只做准备:把需求拆成带测试步骤和 passes 字段的 JSON 功能清单,写好启动脚本并提交到 git;之后每次会话先读进度文件和 git log、做冒烟测试,只实现一个未通过的功能,端到端测试通过才把 passes 改为 true,再提交并更新进度;只允许修改 passes,防止 Agent 删掉测试来“完成”任务。", + "status": "active", + "text": "长任务怎样在多次会话之间交接?" + }, + "4f74d11814": { + "days": [ + "sprint-08" + ], + "points": "STRING、LIST、SET、HASH、ZSET 五种,另有 BITMAP、HyperLogLog、GEO、Stream;ZSET 成员多时用跳表,在有序链表上加多层索引,以 1/4 的概率升层,查找和插入删除期望 O(logN);相比平衡树,插入删除只改前驱指针、不需要旋转,范围查询沿底层链表遍历,实现简单;数据都在内存中,不需要 B+ 树那样减少磁盘 I/O。", + "status": "active", + "text": "Redis 有哪些数据类型?ZSET 的底层为什么用跳表?" + }, + "4fad9621d5": { + "days": [ + "sprint-13" + ], + "points": "超卖源于判断库存和扣减库存不是原子操作;数据库中可用悲观锁 SELECT ... FOR UPDATE、乐观锁版本号或条件更新 UPDATE ... SET stock = stock - 1 WHERE stock > 0;实测 1000 个用户抢 100 件,先查再扣多卖了 34~62 件,悲观锁和条件更新都恰好卖出 100 件;乐观锁不会超卖,但冲突多时会少卖,150 个用户抢、重试 3 次也只卖出 48~57 件;条件更新不需要重试、最常用;秒杀时先用 Redis 的", + "status": "active", + "text": "秒杀系统怎样防止超卖?" + }, + "4ff0d2d0d0": { + "days": [ + "llm-09" + ], + "points": "PDF 只记录“在某坐标画某字”,没有段落和表格,直接读文本层会混入页眉页脚、句子在折行处断开;扫描件要先 OCR,表格可能被逐列输出;用版面分析识别标题、正文、表格和阅读顺序,表格转为 Markdown,按行切分时每行带上表头;跨页的表格在列数、列宽一致且中间没有新标题时拼成一张,去掉重复的表头。", + "status": "active", + "text": "PDF 为什么难解析?表格怎么处理?" + }, + "513b0767e9": { + "days": [ + "llm-14" + ], + "points": "模型负责理解任务、决定下一步;工具用于获取信息和执行操作,记忆保存对话历史和用户偏好,规划把复杂任务拆成步骤;控制循环是应用代码中反复调用模型、执行工具的循环,并负责限制步数、校验参数和权限;模型只输出文本,执行都由应用完成。", + "status": "active", + "text": "Agent 由哪些部分组成?" + }, + "518704a7fc": { + "days": [ + "java-04" + ], + "points": "设计上抽象类是 IS-A 关系,接口是 LIKE-A 关系,只提供方法契约;一个类可以实现多个接口,只能继承一个抽象类;接口的字段只能是 static final,成员默认 public,抽象类没有这些限制;相关类共享代码、需要非 public 成员或非常量字段时用抽象类,让不相关的类具备同一行为(例如 Comparable)时用接口,多数情况接口优先。", + "status": "active", + "text": "抽象类和接口有什么区别?分别用在什么场景?" + }, + "52aa24db2a": { + "days": [ + "llm-21" + ], + "points": "非流式请求的总超时要大于 TTFT + 最大输出 Token 数 × TPOT,例如 1000 个 Token、30 毫秒时要 30 多秒;流式请求分别设首 Token 超时、间隔超时和总时长上限,心跳不算内容;429、5xx、超时可以重试,400、401 直接失败,按指数退避加随机抖动、遵守 Retry-After,次数设上限且只在一层重试。", + "status": "active", + "text": "调用模型接口时怎样设置超时和重试?" + }, + "52c9969ca3": { + "days": [ + "java-24" + ], + "points": "常见原因:两个事务按相反的顺序更新同一组行,或者“先查有没有,没有再插入”时拿到了同一个间隙锁,插入意向锁与对方的间隙锁冲突;InnoDB 检测到死锁后回滚其中一个事务,应用要重试;排查:看 SHOW ENGINE INNODB STATUS 的 LATEST DETECTED DEADLOCK,打开 innodb_print_all_deadlocks 记录每次死锁,锁等待时查 performance_schema.data_locks 和 data_lock_waits;", + "status": "active", + "text": "遇到过 MySQL 死锁吗?怎样排查?" + }, + "535132ab86": { + "days": [ + "java-30" + ], + "points": "用位图:每天一个键 active:{日期},offset 为用户的连续序号,签到时 SETBIT 为 1,1 亿用户每天 12.5 MB、一年约 4.6 GB;BITCOUNT 统计当天的人数,BITOP 求多天的交集和并集;也可以每人每月一个键 sign:{用户 ID}:{年月},便于查一个人的签到,但大量小键的开销大,每人每年一个键时 1 亿用户约 15 GB;用户 ID 稀疏时先转换为连续的序号。", + "status": "active", + "text": "一亿用户每天的签到状态怎样存储?" + }, + "54392ab875": { + "days": [ + "java-07" + ], + "points": "用 hash() 计算哈希值,table 为空时先 resize() 创建数组;用 (n - 1) & hash 求桶下标,桶为空直接放入;头节点是 TreeNode 就在红黑树中插入,否则遍历链表,键相同则替换旧值,否则接到链表尾部,已有 8 个节点时调用 treeifyBin();size 大于 threshold 时扩容。", + "status": "active", + "text": "HashMap 的 put() 过程是怎样的?" + }, + "56455baece": { + "days": [ + "java-26" + ], + "points": "联合索引先按第一列排序,第一列相同再按第二列,所以条件要从最左列开始连续使用才能用索引定位,与 WHERE 中的书写顺序无关;(a, b, c) 只用到 a 和 c 时能走索引,但只用 a 定位,c 在定位到的范围内逐条判断,借助索引下推可以在索引上过滤,减少回表。", + "status": "active", + "text": "什么是最左前缀原则?联合索引 (a, b, c) 只用到 a 和 c 时能走索引吗?" + }, + "56d0a4ee0e": { + "days": [ + "sprint-01" + ], + "points": "JDK 1.8:table 为空先 resize(),用 (n - 1) & hash 定位桶,空桶直接放入;否则头节点是 TreeNode 就在红黑树中查找或插入,是链表就逐个比较,键相同就替换旧值,找不到就接在链表尾部;插入后 size 大于 threshold(容量 × 0.75,默认 12)时扩容为 2 倍;接上前链表已有 8 个节点时,数组长度小于 64 只扩容,否则转成红黑树。", + "status": "active", + "text": "HashMap 的 put() 过程是怎样的?什么时候扩容、什么时候转成红黑树?" + }, + "57e6bf391b": { + "days": [ + "java-56" + ], + "points": "64 位依次为 1 位符号位、41 位毫秒时间戳、10 位机器 ID、12 位序号,同一毫秒内序号递增,4096 个用完就等到下一毫秒;时钟回拨几毫秒时等时钟追上(Leaf 在 5 ms 以内等待两倍的差值),较大时报错并摘除实例;lastTime 只在内存中,重启后发现不了回拨,要把时间持久化,例如 Leaf 每 3 秒把本机时间写入 ZooKeeper,启动时本机时间比记录的还早就不启动;机器 ID 在启动时从 ZooKeeper、数据库等领取。", + "status": "active", + "text": "雪花算法是怎样生成 ID 的?时钟回拨怎么办?" + }, + "57ed7e67f6": { + "days": [ + "java-34" + ], + "points": "用 k 个哈希函数把 Key 映射到位数组的 k 个位置并置 1,查询时有一位为 0 就一定不存在,全为 1 则可能存在;误判是因为这些位可能都被其它 Key 置为 1;每个 Key 占 10 位、k = 7 时误判率约 0.82%,插入数超过预期后迅速上升,且不能删除元素。", + "status": "active", + "text": "布隆过滤器的原理是什么?为什么会误判?" + }, + "59a27f0b68": { + "days": [ + "java-24", + "sprint-06" + ], + "points": "快照读靠 MVCC:可重复读复用同一个 ReadView,看不到其它事务新插入的行;当前读靠 Next-Key Lock:锁住扫描到的记录和记录之间的间隙,其它事务无法在范围内插入;同一事务中快照读和当前读混用时仍会出现幻读。", + "status": "active", + "text": "InnoDB 在可重复读级别下怎样避免幻读?" + }, + "5b6919dab0": { + "days": [ + "llm-16" + ], + "points": "写入前检索相似的旧记忆,由模型选择 ADD、UPDATE、DELETE 或 NOOP(Mem0 论文的做法,开源版后来改为只新增),用户 ID、时间等字段由代码填写;冲突时用户本次说的优先,亲口说的优先于推断,同一来源以新的为准,只对这一次有效的说法不覆盖,仍无法判断就问用户;旧记忆标记失效而不物理删除,并按时间衰减排序、定期清理。", + "status": "active", + "text": "长期记忆中的信息过时或冲突时怎样处理?" + }, + "5cfaefbdbb": { + "days": [ + "java-45" + ], + "points": "内核运行在特权级 0(内核态),应用运行在 3 级(用户态),特权指令只能在内核态执行,用户态也只能访问本进程的用户空间,以保护内核和其它进程;系统调用(如读写文件、创建进程)、异常(如缺页)和外中断会从用户态进入内核态;一次系统调用约 165 ns,是普通函数调用的近 180 倍,但不一定发生上下文切换。", + "status": "active", + "text": "什么是用户态和内核态?什么时候会切换?" + }, + "5efd34dc3a": { + "days": [ + "java-12" + ], + "points": "Future 只能用 get() 阻塞等待结果,多个任务有依赖时要自己写等待逻辑;CompletableFuture 可以注册回调,用 thenApply、thenCompose 串联,用 thenCombine 合并,用 exceptionally、handle 处理异常;带 Async 的方法不传线程池时在 ForkJoinPool.commonPool() 中执行(并行度小于 2 时每个任务新建一个线程),阻塞的 I/O 任务应传入自己的线程池。", + "status": "active", + "text": "CompletableFuture 解决了 Future 的什么问题?" + }, + "5f403638bf": { + "days": [ + "java-65" + ], + "points": "Service 工作在传输层,用标签选出一组 Pod,提供固定的虚拟 IP 和域名,由 kube-proxy 写成 iptables 或 IPVS 规则转发,有 ClusterIP、NodePort、LoadBalancer 等类型;Ingress 是 HTTP 路由规则,按域名和路径把请求分给不同的 Service,由 Ingress 控制器执行,多个 HTTP 服务共用一个入口。", + "status": "active", + "text": "Service 和 Ingress 有什么区别?" + }, + "6159fb310f": { + "days": [ + "java-20" + ], + "points": "重写 loadClass(),先自己加载、找不到再交给父加载器;例子:JDBC 的 DriverManager 由平台类加载器加载,通过 ServiceLoader 用线程上下文类加载器加载 classpath 上的驱动;Tomcat 每个应用的 WebappClassLoader 先在本应用中查找,不同应用可以用同一个库的不同版本;热部署用新的类加载器加载修改后的类。", + "status": "active", + "text": "怎样打破双亲委派?有哪些实际的例子?" + }, + "61d62633f1": { + "days": [ + "java-05" + ], + "points": "泛型只在编译时检查类型,编译后类型参数替换为上界(没有上界时为 Object),取出元素处由编译器插入强制类型转换;运行时 List\\ 和 List\\ 是同一个类,所以类型参数不能是基本类型,不能 new T() 或用 instanceof List\\;类、字段、方法声明上的泛型信息保存在 Signature 属性中,可以通过反射读取。", + "status": "active", + "text": "什么是泛型擦除?" + }, + "6236dc8524": { + "days": [ + "java-42" + ], + "points": "语义:GET 获取资源,POST 传输实体主体;GET 安全且幂等,POST 会改变服务器状态,不幂等;参数放在哪里只是惯例,GET 参数通常在 URL 中(要编码,长度受浏览器和服务器的限制,例如 Nginx 默认请求行不超过 8 KB),POST 在主体中,但抓包同样能看到,并不更安全;GET 响应可缓存,POST 多数不可缓存。", + "status": "active", + "text": "GET 和 POST 有什么区别?" + }, + "62d04c530a": { + "days": [ + "java-47" + ], + "points": "虚拟内存让每个进程看到独立、连续的地址空间,程序使用虚拟地址,由硬件和操作系统按页翻译成物理地址;它让程序可以比物理内存大(只把用到的页放在内存中),隔离各进程并按页设置权限,简化加载(物理内存不必连续),还能让多个进程共享同一物理页。", + "status": "active", + "text": "什么是虚拟内存?为什么需要它?" + }, + "634ab6d850": { + "days": [ + "llm-18" + ], + "points": "子任务互相独立、需要读大量资料或反复试错时值得拆,需要隔离权限时也可以拆,好处是隔离上下文,子 Agent 只返回精简的结论;代价是 Token 消耗成倍增加(Anthropic 的数据中多 Agent 系统约为普通对话的 15 倍),只传摘要会丢失细节;依赖紧密的任务和对延迟、成本敏感的在线请求用单个 Agent;协作方式有主管分派(子 Agent 包装成工具、可并行)、流水线(固定顺序)、讨论(互看答案后投票或裁决)和转交(把控制权和上下文交给另一个 Agent,由它直接", + "status": "active", + "text": "什么时候需要多个 Agent?有哪些协作方式?" + }, + "63f07a4386": { + "days": [ + "llm-08" + ], + "points": "提示词工程关注怎样写好指令,尤其是系统提示词;上下文工程决定每次调用前把哪些内容、按什么顺序放进窗口,包括规则、工具定义、检索资料、对话历史和记忆;Agent 每一步都有新的工具结果,每次调用前都要重新挑选和压缩。", + "status": "active", + "text": "上下文工程和提示词工程有什么区别?" + }, + "64f1daf686": { + "days": [ + "java-46" + ], + "points": "互斥:资源要么已分配给一个进程,要么可用;占有和等待:已持有资源的进程还能请求新资源;不可抢占:资源只能由持有者显式释放;环路等待:多个进程形成环,每个都在等下一个持有的资源。", + "status": "active", + "text": "死锁的四个必要条件是什么?" + }, + "676b456ee1": { + "days": [ + "llm-17" + ], + "points": "Tool 是可调用的函数,提供查询订单这类原子能力;MCP 是连接工具和数据的协议,解决工具怎么接入;Skill 是包含 SKILL.md、脚本和资料的文件夹,提供完成某类任务的步骤和规范,执行时仍要调用工具,平时只加载名称和描述(约 100 个 Token),需要时才读取正文。", + "status": "active", + "text": "Skill 和 MCP、Tool 有什么区别?" + }, + "68acba4c85": { + "days": [ + "java-22" + ], + "points": "原子性:要么全部成功,要么全部回滚,InnoDB 用 undo log 记录修改前的值来回滚;一致性:事务前后满足约束和业务规则,由另外三者和主键、唯一索引等约束共同保证,业务规则还要应用自己维护;隔离性:未提交的修改对其它事务不可见,写操作加锁,普通读用 MVCC 读快照;持久性:提交后不丢失,先写 redo log,提交时刷盘,崩溃后重放。", + "status": "active", + "text": "ACID 分别是什么?InnoDB 怎样保证它们?" + }, + "699cec5d70": { + "days": [ + "java-38" + ], + "points": "301 永久重定向,没有缓存指令时默认可被浏览器缓存,适合域名迁移;302 临时重定向,默认不缓存;304 表示条件请求(If-None-Match、If-Modified-Since)的资源没有变化,不带响应体;502 是网关从上游收到无效响应(后端出错或不可达),504 是网关等待上游超时(后端太慢)。", + "status": "active", + "text": "301、302、304、502、504 分别表示什么?" + }, + "6d00dbcd74": { + "days": [ + "llm-12" + ], + "points": "向量相似度的绝对值很难定阈值:实测阈值 0.50 时无答案的问题放行 40%,0.60 时有答案的问题误拒 10%;所以只用较低的阈值过滤明显无关的结果,有没有答案靠重排分数和“资料不足时回答不知道”的提示词判断;阈值在评测集上确定,换模型后重新确定。", + "status": "active", + "text": "相似度阈值怎么设置?" + }, + "6d49cb17be": { + "days": [ + "java-63" + ], + "points": "先把日流量换算成高峰时的 QPS:日 PV 6 亿平均约 6944 QPS,高峰一小时占 6% 时这一小时平均约 1 万 QPS,大促按 10 倍设计为 10 万,小时内的秒级突发还要留余量;机器数 = 峰值 QPS ÷(单实例拐点 QPS × 目标利用率),再按可用区取整,一个可用区故障时其余实例仍要低于拐点;存储按每天的增量乘以保存时间估算,例如日下单 1200 万、一年约 44 亿行,分库分表主要由数据量决定。", + "status": "active", + "text": "设计一个系统前,怎样估算需要的机器和存储?" + }, + "6dbf8f2ba2": { + "days": [ + "java-41" + ], + "points": "CA 用私钥对证书内容(域名、公钥、有效期等)的哈希值签名,客户端用上一级证书的公钥验证,内容改一个字节就会失败;沿证书链逐级验证,顶端必须是预装在系统或浏览器中的根证书,根证书自签名,是信任的起点;还要检查有效期、吊销状态和域名是否在证书的 Subject Alternative Name 中。", + "status": "active", + "text": "证书是怎样验证的?根证书有什么用?" + }, + "6ee29f4ce5": { + "days": [ + "java-35" + ], + "points": "大 Key 是值很大的键(几 MB 的字符串、几十万元素的集合),会阻塞单线程、占带宽、造成数据倾斜,用 --bigkeys 发现,拆成小键,删除用 UNLINK、分批用 HSCAN 读;热 Key 是访问量远高于其它的键,请求集中到一个节点,淘汰策略为 LFU 时用 --hotkeys 发现,否则在客户端或代理中统计,用本地缓存或复制成多个键分散读取。", + "status": "active", + "text": "什么是大 Key 和热 Key?怎样处理?" + }, + "7032e684b1": { + "days": [ + "java-61" + ], + "points": "以数据库的库存和订单为准,Redis 库存只是闸门,允许暂时少卖,数据库条件更新始终保留;发送消息明确失败时执行反向脚本回补,发送超时(消息可能已送达)或应用宕机交给对账,也可以用事务消息;订单因业务原因失败时回补 Redis,异常失败靠消息重试,唯一索引防止重复建单;回补先 SREM 已购集合、返回 1 才加库存,允许再次抢购时还要核对请求 ID;Redis 主从切换丢失扣减时由数据库兜底,因数据库库存不足失败的请求,确认这个用户没有订单后只移出已购集合、不加回库存;活动后", + "status": "active", + "text": "Redis 预扣库存成功、订单却创建失败,怎样保证两边一致?" + }, + "7040328bec": { + "days": [ + "java-54" + ], + "points": "过期时间取正常耗时的几倍,难以估计时由后台线程续期,例如 Redisson 看门狗:lock() 不指定过期时间时先设 30 秒,每 10 秒检查一次,锁仍是自己的就重置为 30 秒,进程崩溃后最多 30 秒释放;锁内操作都要设超时,否则卡住的线程会被一直续期;GC 暂停时仍可能两个客户端同时持锁,要用递增的栅栏令牌拒绝旧写入。", + "status": "active", + "text": "锁过期了业务还没执行完怎么办?" + }, + "710b3551d1": { + "days": [ + "java-40" + ], + "points": "同源指协议、域名、端口都相同,浏览器禁止脚本读取其它源的响应(简单请求仍会发出并在服务器上执行);常用 CORS:服务器返回 Access-Control-Allow-Origin,非简单请求先发 OPTIONS 预检,带 Cookie 时不能用 *;也可以用同源的反向代理转发;JSONP 只支持 GET,已被 CORS 取代。", + "status": "active", + "text": "什么是跨域?怎样解决?" + }, + "71ab53cab0": { + "days": [ + "java-17" + ], + "points": "可达性分析要遍历对象之间的引用,用户线程同时修改引用可能漏掉存活的对象;复制和整理算法还要移动对象、修改指向它的引用;用户线程运行到安全点才暂停,CMS、G1 把耗时最长的标记改为并发执行来缩短停顿。", + "status": "active", + "text": "垃圾收集为什么要 Stop The World?" + }, + "72afd7353e": { + "days": [ + "java-39" + ], + "points": "长连接指一个 TCP 连接上进行多次 HTTP 通信,省去每次建连的开销;HTTP/1.1 默认长连接,要断开时发送 Connection: close,之前的版本要用 Connection: Keep-Alive;服务器在连接空闲一段时间后关闭它(Nginx 默认 75 秒),这和 TCP 的保活探测不是一回事;同一连接上的请求按顺序收发,流水线因队头阻塞未被浏览器启用,HTTP/2 用多路复用解决。", + "status": "active", + "text": "什么是长连接?HTTP/1.1 怎样复用连接?" + }, + "72f9842f42": { + "days": [ + "llm-18" + ], + "points": "Harness 指模型之外的所有代码、配置和执行逻辑;组成包括 Agent 循环与停止条件、上下文组装与压缩、工具与权限、记忆与状态交接、子 Agent、钩子与校验、评测与可观测,MCP 和 Skill 也属于它;核心是动手前的引导(规范、文档、Skill)和动手后的反馈(编译、测试、lint、模型审查)。", + "status": "active", + "text": "什么是 Agent Harness?由哪些部分组成?" + }, + "731153a537": { + "days": [ + "java-04" + ], + "points": "运行时多态需要继承或实现接口、重写和向上转型;通过类类型的引用调用编译成 invokevirtual 加符号引用,通过接口类型的引用调用用 invokeinterface,运行时按对象的实际类型选择方法,称为动态分派;HotSpot 为每个类维护虚方法表(vtable),按固定槽位取方法入口,接口调用用 itable;重载在编译时按静态类型确定,称为静态分派。", + "status": "active", + "text": "多态是怎么实现的?" + }, + "747ba7232d": { + "days": [ + "sprint-13" + ], + "points": "不丢:发送端收到确认才算成功(如 Kafka acks=all 加重试),可用与业务同事务的本地消息表;存储端持久化加多副本,消费端处理完再提交偏移量或 ack;不重复:处理完才确认意味着可能重复投递,消费要幂等,用订单号加事件类型这类业务键写去重表并与业务同事务,或用状态做条件更新;消息队列的恰好一次代替不了业务幂等。", + "status": "active", + "text": "怎样保证消息不丢失、不重复消费?" + }, + "752067dae4": { + "days": [ + "java-47" + ], + "points": "OPT 换出最长时间不再访问的页,缺页最少但无法实现,只作对照;LRU 换出最近最久未使用的页,维护链表代价高;FIFO 换出最先进入的页,可能换出常用页,第二次机会和时钟算法用访问位 R 改进它,NRU 按 R、M 位分类换出;同一序列 3 个物理块时 OPT、LRU、FIFO 分别缺页 9、12、15 次。", + "status": "active", + "text": "有哪些页面置换算法?" + }, + "75d8147fc6": { + "days": [ + "java-21", + "sprint-04" + ], + "points": "先看错误信息判断是哪块内存:Java heap space、GC overhead limit exceeded、Metaspace、direct buffer memory、unable to create native thread;元空间、直接内存、线程创建失败分别检查类的生成与卸载、堆外内存的释放、线程数和容器的限制,不能靠增大堆解决;Java heap space 时,启动时加 -XX:+HeapDumpOnOutOfMemoryError 保存堆转储,或用 jcm", + "status": "active", + "text": "线上出现 OOM 怎样排查?" + }, + "7649a66405": { + "days": [ + "java-01" + ], + "points": "127 时为 true,128 时为 false;自动装箱调用 Integer.valueOf(),值在缓存池范围 -128~127 内时返回 IntegerCache 中的同一个对象,超出范围时 new 一个新对象;启动参数 -XX:AutoBoxCacheMax 可以调大上界。", + "status": "active", + "text": "Integer a = 127, b = 127 时 a == b 的结果是什么?换成 128 呢?" + }, + "76647ed961": { + "days": [ + "sprint-05" + ], + "points": "聚簇索引:InnoDB 主键索引的叶子节点保存整行数据,一张表只有一个;二级索引的叶子保存索引列和主键值,查其它列时先拿到主键,再到聚簇索引查出整行,这就是回表;覆盖索引指索引中已包含查询需要的全部列(含主键),不需要回表,Extra 为 Using index。", + "status": "active", + "text": "什么是聚簇索引、回表和覆盖索引?" + }, + "76eb545f8c": { + "days": [ + "java-24" + ], + "points": "间隙锁锁住索引记录之间的开区间,不包含记录本身,只阻止插入,间隙锁之间互不冲突;Next-Key Lock 是记录锁加前面的间隙,为前开后闭区间,例如 c 有 10、11、13、20 时为 (10, 11]、(11, 13] 等;c 为普通索引时 c = 11 FOR UPDATE 锁住 (10, 11] 和 (11, 13),唯一索引并且记录存在时只锁这一条记录。", + "status": "active", + "text": "间隙锁锁住的是什么范围?" + }, + "7796810063": { + "days": [ + "java-18" + ], + "points": "Serial 单线程、简单高效,适合单核和小内存,只有 1 个处理器时 JDK 21 仍默认用它;Parallel Scavenge 加 Parallel Old 以吞吐量为目标,适合后台计算;CMS 并发收集老年代,停顿短,但有碎片和浮动垃圾,JDK 14 已移除;G1 是 JDK 9 起的默认收集器,按 Region 回收、停顿可预测,适合大内存的在线服务,ZGC 的停顿通常不超过 1 毫秒,但要给堆留出余量。", + "status": "active", + "text": "有哪些垃圾收集器?各自适合什么场景?" + }, + "79b04fb5b0": { + "days": [ + "java-10" + ], + "points": "shutdown() 后不再接受新任务,已提交的任务继续执行完,本身不阻塞,需要等待时调用 awaitTermination();shutdownNow() 相当于对每个线程调用 interrupt(),并返回队列中还没有执行的任务。", + "status": "active", + "text": "shutdown() 和 shutdownNow() 有什么区别?" + }, + "7ce5cde1cd": { + "days": [ + "java-43" + ], + "points": "使用参数化查询(PreparedStatement、MyBatis 的 #{}),SQL 结构先确定,参数只当作数据;表名、列名、ORDER BY 字段不能用占位符,用白名单校验;数据库账号只授予最小权限。", + "status": "active", + "text": "怎样防止 SQL 注入?" + }, + "7d1a93e400": { + "days": [ + "java-50" + ], + "points": "带 @Transactional 的 Bean 被代理,调用时 TransactionInterceptor 读取事务属性;事务管理器从连接池取连接、setAutoCommit(false),绑定到当前线程的 ThreadLocal,JdbcTemplate、MyBatis 优先使用这个连接,所以方法中的 SQL 都在同一个事务里;方法正常返回则提交,抛出符合规则的异常则回滚,最后恢复连接设置并归还。", + "status": "active", + "text": "Spring 事务的原理是什么?为什么加了 @Transactional 就有事务了?" + }, + "7e5379ca96": { + "days": [ + "java-08" + ], + "points": "继承 LinkedHashMap,构造时把 accessOrder 设为 true,每次访问把节点移到链表尾部,链表头部就是最久未使用的节点;重写 removeEldestEntry(),size() 超过容量时返回 true,插入后移除头部节点。", + "status": "active", + "text": "怎样用 LinkedHashMap 实现 LRU 缓存?" + }, + "7ec003a5dd": { + "days": [ + "java-44" + ], + "points": "保存当前线程的寄存器、程序计数器、栈指针,恢复另一个线程的值;切换到另一个进程还要切换页表,TLB 和 CPU 缓存中原来的映射和数据大多用不上、要重新加载(有 PCID 时 TLB 不必全部清空),这部分间接开销通常更大,工作集很小时两者几乎没有差别;实测一次切换约 1.1 微秒(其中还包含一次 write 和一次 read)。", + "status": "active", + "text": "上下文切换有哪些开销?" + }, + "7ef8c8b5a2": { + "days": [ + "java-48" + ], + "points": "LT(默认)只要描述符仍可读,每次 epoll_wait 都会报告;ET 只在有新数据到达时报告一次,必须用非阻塞描述符并循环读到 EAGAIN,否则剩下的数据不会再触发事件,连接停住;ET 减少了重复通知,但要防止一个连接长时间占住线程。", + "status": "active", + "text": "epoll 的水平触发和边缘触发有什么区别?" + }, + "80afd5ae0b": { + "days": [ + "java-47" + ], + "points": "分页对程序员透明,分段要显式划分;分页是一维地址空间,分段是二维的;页大小固定,段大小可变;分页为了实现虚拟内存,分段为了按逻辑划分程序和数据、便于共享和保护;分页没有外部碎片,但每个段或区域的最后一页可能用不满,形成内部碎片,分段会留下大小不一的空闲块,形成外部碎片(x86-64 的 Linux 只用分页)。", + "status": "active", + "text": "分页和分段有什么区别?" + }, + "80d3fed1b5": { + "days": [ + "java-26" + ], + "points": "给经常出现在 WHERE、JOIN、ORDER BY、GROUP BY 中并且区分度高的列建索引,状态这类区分度低的列单独建索引作用不大,但总是查询占比很少的值时索引仍然有效;多个条件常一起出现时建联合索引,等值列在前、排序列其次、范围列最后;尽量让高频查询用上覆盖索引;索引不是越多越好,有 (a, b) 时 (a) 是冗余的,主键用较短的自增整数。", + "status": "active", + "text": "建索引有哪些原则?" + }, + "80e07271f2": { + "days": [ + "llm-12" + ], + "points": "原始问题常不适合直接检索:有指代(“那 ConcurrentHashMap 呢”)时结合历史补全;问题太短时用 HyDE 先生成假设的回答再检索;多个子问题拆开分别检索再合并,RAG-Fusion 改写成多个查询后用 RRF 合并;代价是多一次模型调用。", + "status": "active", + "text": "为什么要改写用户的查询?有哪些方法?" + }, + "8325776328": { + "days": [ + "sprint-13" + ], + "points": "用 SET lock_key unique_value NX PX 30000 一条命令加锁并设置过期时间;释放时用 Lua 脚本比较 value 是自己的才删除,避免误删别人的锁;业务超时锁会提前释放,需要续期,Redisson 的看门狗每 10 秒把过期时间重置为 30 秒,并用哈希计数支持可重入;主从是异步复制,切换时锁可能丢失;这种情况和续期失败都会让两个客户端同时持锁,正确性要求高时用栅栏令牌或数据库的唯一约束、条件更新兜底。", + "status": "active", + "text": "用 Redis 怎样实现分布式锁?有哪些问题?" + }, + "857dd1fbdb": { + "days": [ + "java-62" + ], + "points": "拉模式(读扩散)发帖只写一次,读时合并所有关注者的发件箱,关注越多读越慢;推模式(写扩散)发帖写入每个粉丝的收件箱,读很快,但粉丝越多写越多(1000 万粉丝单节点约 50 秒);实际推拉结合:普通用户推,大 V 由粉丝读时拉取合并,只推给活跃粉丝,收件箱只保留最近若干条。", + "status": "active", + "text": "Feed 流用推模式还是拉模式?" + }, + "85d1c43400": { + "days": [ + "java-62" + ], + "points": "301 永久跳转,没有缓存指令时也可能被浏览器缓存,之后不再请求短链服务,已缓存的跳转无法撤回;302 临时跳转,默认不缓存,统计点击时再加 Cache-Control: private, no-store;需要统计点击、修改目标地址或设置有效期时用 302,只想减轻服务端压力时用 301。", + "status": "active", + "text": "短链跳转用 301 还是 302?" + }, + "86472abfac": { + "days": [ + "java-54" + ], + "points": "数据库:实现简单,性能低;Redis:性能最高,适合高并发,但主从切换、进程暂停时可能两个客户端同时持锁;ZooKeeper:写入要同步到多数节点,性能较低但更可靠,还能提供栅栏令牌,适合一致性要求高、并发不大的场景。", + "status": "active", + "text": "数据库、Redis、ZooKeeper 三种实现怎样选择?" + }, + "874987f0de": { + "days": [ + "java-53" + ], + "points": "注册与配置:Nacos,或 Eureka 加 Spring Cloud Config;负载均衡和调用:Spring Cloud LoadBalancer、OpenFeign;网关和熔断:Spring Cloud Gateway、Resilience4j 或 Sentinel;分布式事务和链路追踪:Seata、Micrometer Tracing。", + "status": "active", + "text": "Spring Cloud 有哪些常用组件?" + }, + "875d608421": { + "days": [ + "java-06" + ], + "points": "ArrayList 基于动态数组,支持随机访问,插入删除要移动元素;LinkedList 基于双向链表,插入删除只改指针,但要先遍历到插入位置;10 万个元素中间插入 1 万次,ArrayList 约 50 ms,LinkedList 约 0.8~1 秒,只有头部插入 LinkedList 明显更快;多数场景用 ArrayList,头尾频繁增删用 ArrayDeque 或 LinkedList。", + "status": "active", + "text": "ArrayList 和 LinkedList 有什么区别?分别用在什么场景?" + }, + "87aaeeaa89": { + "days": [ + "java-36" + ], + "points": "保证最后一个 ACK 能到达:ACK 丢失时对方会重传 FIN,2MSL 正好覆盖 ACK 的 1 MSL 加重传 FIN 的 1 MSL;让本连接的旧报文都从网络中消失,不干扰同一四元组的新连接;Linux 中固定为 60 秒。", + "status": "active", + "text": "为什么 TIME_WAIT 要等待 2MSL?" + }, + "888d45b3b3": { + "days": [ + "llm-07" + ], + "points": "优先用约束解码:每生成一个 Token 前按 Schema 屏蔽会让输出不合法的 Token;JSON 模式只保证是合法 JSON,不保证字段;不支持时先修复(去掉代码块标记、截取首个 { 到末个 }),再用 JSON Schema 校验,失败时带着具体错误重试 1~2 次,仍失败就返回默认值或转人工。", + "status": "active", + "text": "怎样让模型稳定地输出合法的 JSON?" + }, + "89a386e6ca": { + "days": [ + "java-64" + ], + "points": "容器跟随 1 号进程,主进程结束容器就退出;用 docker ps -a 看退出码,docker logs 看报错(如 MySQL 没设 MYSQL_ROOT_PASSWORD),docker inspect 看是否 OOMKilled;常见原因还有启动命令把服务放到后台(nginx 没加 daemon off)、缺少依赖库或 CPU 架构不一致。", + "status": "active", + "text": "容器启动后马上退出,怎样排查?" + }, + "8a7213cfde": { + "days": [ + "llm-06" + ], + "points": "检测:有资料时把回答拆成结论逐条检查依据,引文和数字用代码核对;RAG 答错时先看正确的资料是否在提示词中,区分检索和生成的问题;没有资料时多次采样比较,编造的内容往往每次不同;缓解:用 RAG 提供资料并允许回答不知道,降低温度、要求引用,用程序或另一个模型校验,展示来源、高风险结果人工确认。", + "status": "active", + "text": "怎样检测和缓解幻觉?" + }, + "8c4d592987": { + "days": [ + "llm-07" + ], + "points": "身份、规则和输出格式放在系统消息中,写清任务、约束、输出格式和示例;要求具体,例如“不超过 100 字”而不是“简洁一些”;说明信息不足时怎么处理,否则模型倾向编造;用少样本示例说明格式,多步推理让模型先写步骤(CoT);改动在评测集上比较,每条运行多次,从失败的样例入手补规则或示例。", + "status": "active", + "text": "怎样写好一个提示词?" + }, + "8c543dbbcb": { + "days": [ + "java-01" + ], + "points": "哈希值不变,只需计算一次,适合做 HashMap 的键;String Pool 中多个引用共享同一个对象,只有不可变才能这样共享;作为网络连接等参数时不会被中途改掉;不可变的对象天然线程安全。", + "status": "active", + "text": "String 为什么设计成不可变的?" + }, + "8d674478d1": { + "days": [ + "java-46" + ], + "points": "用阻塞式的锁时,死锁线程几乎不耗 CPU(例如等待 synchronized 的线程处于 BLOCKED),现象是 CPU 低而请求全部卡住;用 jstack 或 jcmd \\ Thread.print 打印线程栈,末尾会报告 Found one Java-level deadlock,列出各线程等待的锁和持有者;程序中也可以定时调用 ThreadMXBean.findDeadlockedThreads() 检测并告警,但它检测不到包含虚拟线程的死锁环和 Semap", + "status": "active", + "text": "线上的 Java 程序发生死锁,怎样定位?" + }, + "8e70e9576a": { + "days": [ + "java-08" + ], + "points": "元素个数达到 sizeCtl(容量的 0.75 倍)时创建 2 倍的新数组,多个线程用 CAS 修改 transferIndex 领取至少 16 个桶从后往前迁移,迁移完的桶放入 ForwardingNode,put 遇到它会先尝试帮忙迁移,再到新数组中插入;元素个数分散在 baseCount 和多个 CounterCell 中,size() 不加锁求和,期间其它线程可能在增减,所以只是估计值。", + "status": "active", + "text": "ConcurrentHashMap 是怎样扩容的?size() 为什么不精确?" + }, + "904bc79056": { + "days": [ + "java-69" + ], + "points": "给出可以验证的目标,例如让某个测试通过,修 bug 时先写一个能复现问题的测试;先让它阅读代码、给出方案,确认后再修改;任务拆小,一次只改一个功能,便于审查;生成的代码同样要做代码审查和测试,测试用例按需求写,由人检查边界;反复纠正的要求写进 CLAUDE.md、AGENTS.md 这类项目约定文件;AI 生成的代码占多大比例不是关键,开发者仍要负责需求、方案、审查和上线的结果,涉及资金和数据安全的修改每一处都要审查。", + "status": "active", + "text": "你平时怎样用 AI 编程工具?怎样保证生成的代码质量?" + }, + "91d75f1bb2": { + "days": [ + "llm-11" + ], + "points": "Embedding 模型分别编码问题和文档,文档向量能提前算好,速度快,但细节的对应关系在压成一个向量时丢失;重排模型(交叉编码器)把问题和文档拼在一起编码,每个词之间都能算注意力,更准,但每一对都要重算;所以先快速召回几十条,再重排选出几条;混入 12.5 万条干扰文本时,重排 RRF 的前 50 个候选使 Recall@1 从 0.275 升到 0.700。", + "status": "active", + "text": "向量检索已经计算了相似度,为什么还要重排?" + }, + "91de874283": { + "days": [ + "java-25" + ], + "points": "InnoDB 的聚簇索引就是主键索引(没有主键时用第一个非空唯一索引,再没有就用隐藏的行 ID),叶子节点保存整行数据,一张表只有一个;二级索引的叶子节点保存索引列和主键值;通过二级索引查其它列时,先查到主键,再到聚簇索引中查出整行,称为回表;查询的列都在二级索引中时不需要回表(覆盖索引)。", + "status": "active", + "text": "聚簇索引和二级索引有什么区别?什么是回表?" + }, + "92dc236853": { + "days": [ + "llm-07" + ], + "points": "不一定,约束解码和 Schema 校验只保证格式;订单是否属于当前用户、退款是否超过实付金额这类业务规则要在代码中校验;还要检查 finish_reason 是否为 length,以及字段类型是否与代码一致。", + "status": "active", + "text": "输出格式正确,内容就一定正确吗?" + }, + "94fc7c8234": { + "days": [ + "java-45" + ], + "points": "信号量是整型变量,down(P)在大于 0 时减 1、等于 0 时睡眠,up(V)加 1 并唤醒等待者,两者是原语;取值只有 0、1 的二值信号量可以当作互斥量,进入临界区前 down、离开时 up(但信号量可以由别的进程 up,互斥锁只能由加锁的线程释放);同步用计数信号量,例如生产者-消费者用 empty、full 计数,且要先 down(empty) 再 down(mutex),反过来会死锁。", + "status": "active", + "text": "信号量是怎样实现同步与互斥的?" + }, + "955ea8cac8": { + "days": [ + "sprint-06" + ], + "points": "修改时旧版本写入 Undo Log,经回滚指针连成版本链;ReadView 记录创建时未提交的事务列表、其中最小的 TRX_ID_MIN 和下一个要分配的 TRX_ID_MAX;版本的 TRX_ID 是自己的或小于 MIN 时可见,大于等于 MAX 或仍在列表中时不可见,就沿版本链找上一个版本;提交读每次快照读都新建 ReadView,可重复读在第一次快照读时创建、整个事务复用。", + "status": "active", + "text": "MVCC 的原理是什么?" + }, + "97c02eae0a": { + "days": [ + "java-15", + "sprint-03" + ], + "points": "ThreadLocalMap 的 Entry 对 key(ThreadLocal 对象)是弱引用,对 value 是强引用;ThreadLocal 没有其它强引用时 key 在 GC 后变为 null,value 仍被线程的 Map 引用;这样的陈旧条目要等这个线程之后调用 set()、remove() 或 get() 未命中时才可能被清理,不保证及时,static final 的 ThreadLocal 一直强可达,弱引用不起作用;复用线程还会让后一个请求读到前一个请求的数", + "status": "active", + "text": "ThreadLocal 为什么可能导致内存泄漏?" + }, + "98a89cafcd": { + "days": [ + "llm-01" + ], + "points": "几乎都在矩阵中:每层的注意力和前馈网络,以及词嵌入和输出层(Qwen2.5-7B 中各约 7%);Qwen2.5-7B 的前馈网络三个矩阵(3584 → 18944 → 3584)占全部参数的 75%;GQA 使 k_proj、v_proj 只有 q_proj 的七分之一,总计约 76 亿个参数。", + "status": "active", + "text": "大模型的参数主要分布在哪里?" + }, + "9a4ab4e114": { + "days": [ + "sprint-06" + ], + "points": "未提交读:三种都可能发生;提交读:解决脏读;可重复读:再解决不可重复读,按 SQL 标准仍可能幻读,InnoDB 用 MVCC 和 Next-Key Locks 在很大程度上避免;可串行化:三种都解决,并发度最低。", + "status": "active", + "text": "四种隔离级别分别解决了脏读、不可重复读、幻读中的哪些?" + }, + "9c0c516330": { + "days": [ + "sprint-01" + ], + "points": "ArrayList 基于动态数组,随机访问快,中间插入删除要移动元素,但一次复制一整块连续内存;LinkedList 基于双向链表,插入删除只改指针,但要先 O(N) 遍历到位置;实测 10 万个元素中间插入 1 万次,ArrayList 约 50 ms、LinkedList 约 0.8~1 秒,多数场景用 ArrayList。", + "status": "active", + "text": "ArrayList 和 LinkedList 有什么区别?" + }, + "9dde8d6033": { + "days": [ + "java-65" + ], + "points": "各组件都通过 API Server 协作:kubectl 提交 Deployment,API Server 校验后写入 etcd;Deployment 和 ReplicaSet 控制器依次创建 ReplicaSet 和未指定节点的 Pod,调度器过滤、打分后把节点写入 nodeName;该节点的 kubelet 通过 CRI 让 containerd 创建沙箱(pause 容器持有网络命名空间,CNI 分配 IP),拉取镜像并启动容器;就绪探针通过后 Pod 变为 Ready", + "status": "active", + "text": "创建一个 Pod 时,Kubernetes 内部经过了哪些步骤?" + }, + "9de3a8a2c2": { + "days": [ + "java-33" + ], + "points": "键空间分为 16384 个哈希槽,CRC16(key) % 16384 计算键所属的槽,每个主节点负责一部分槽;访问了不负责这个槽的节点会收到 MOVED 重定向,客户端缓存槽映射;多键命令要求键在同一个槽,可以用 {} 哈希标签;扩容时按键迁移槽:源节点标记 MIGRATING、目标节点标记 IMPORTING,用 MIGRATE 分批搬键,已迁走的键返回 ASK,客户端先发 ASKING 再访问目标节点,全部迁完后用 CLUSTER SETSLOT 把槽分给新节点。", + "status": "active", + "text": "Redis Cluster 怎样分配数据?扩容时怎样迁移?" + }, + "9f8d77ff45": { + "days": [ + "llm-21" + ], + "points": "提示词注入指通过输入的内容改变模型原本的指令,指令也可能藏在网页、文档或工具结果中(间接注入);指令和资料最终拼成同一个 Token 序列,模型无法可靠区分,提示词只能降低风险,重点是限制后果;外部内容标注为不可信,工具按最小权限提供、在代码中按当前用户校验,高风险操作人工确认;读过不可信内容的任务不给对外发送的工具,前端不自动加载输出中的外部图片,防止数据外泄;输出转义、查询参数化,提示词中不放密钥,记录审计日志。", + "status": "active", + "text": "怎样防御提示词注入?" + }, + "a03e2ca327": { + "days": [ + "sprint-14" + ], + "points": "离线:解析文档、切片,用 Embedding 模型转为向量存入向量数据库,同时建立关键词索引;在线:改写问题,向量和关键词两路召回,合并后重排,把最相关的几段和问题放进提示词,生成带引用的回答;每一环都影响最终效果,解析错了或资料没被召回,后面无法弥补。", + "status": "active", + "text": "RAG 的流程是怎样的?" + }, + "a0f17ec6e9": { + "days": [ + "sprint-08" + ], + "points": "数据在内存中,一次 GET 只查一次哈希表;单线程执行命令,不加锁、没有线程切换;I/O 多路复用让一个线程处理大量连接,数据少时用 listpack 等紧凑编码;执行命令本身很少成为瓶颈,CPU 时间大部分花在网络读写的系统调用上,实测每条命令的用户态时间约 1 微秒,流水线减少系统调用后吞吐量约为原来的 10 倍。", + "status": "active", + "text": "Redis 为什么快?" + }, + "a1c4bbe5c6": { + "days": [ + "java-23" + ], + "points": "每行有隐藏列 DB_TRX_ID(最后修改它的事务 ID)和 DB_ROLL_PTR,旧版本通过回滚指针串在 undo log 中,读不加锁;ReadView 记录创建时未提交的事务列表、其中的最小 ID 和下一个要分配的 ID:版本的 TRX_ID 是自己或小于最小 ID 时可见,大于等于下一个 ID 时不可见,在两者之间则不在列表中才可见;不可见就沿回滚指针找上一个版本;提交读每次快照读都创建新的 ReadView,可重复读整个事务复用第一次快照读时创建的那个。", + "status": "active", + "text": "MVCC 的原理是什么?ReadView 怎样判断可见性?" + }, + "a21036a9de": { + "days": [ + "java-57" + ], + "points": "作用:异步处理(注册后发验证邮件)、流量削峰(消费者按处理能力拉取)、应用解耦(支付成功发一条消息,积分、物流、短信各自订阅);下一步依赖调用结果、被调用方不可用时调用方也应失败,用 RPC,如支付前扣库存;结果可以稍后产生或有多个接收方,用消息队列,如支付后加积分。", + "status": "active", + "text": "消息队列有什么作用?什么时候用 RPC,什么时候用消息队列?" + }, + "a2d1d91ef7": { + "days": [ + "llm-14" + ], + "points": "应用在请求中附上工具的名称、用途和参数 JSON Schema,推理服务按对话模板拼进提示词;模型需要时生成特定格式的文本(如 Qwen2.5 包在 tool_call 标签里的 JSON),服务解析成 tool_calls 返回;应用校验后执行,用 tool 消息和 tool_call_id 发回结果;本质仍是生成文本,模型可能调用不存在的工具、写错参数或编造订单号,严格模式只保证参数符合 Schema,所以必须先校验再执行。", + "status": "active", + "text": "Function Calling 的原理是什么?" + }, + "a3c215dfb9": { + "days": [ + "java-41", + "sprint-10" + ], + "points": "以 TLS 1.2 的 ECDHE 为例:ClientHello 带客户端随机数、密码套件列表和 SNI;服务器回 ServerHello(选定套件、服务器随机数)、证书链和 ServerKeyExchange(ECDHE 公钥及证书私钥的签名);客户端验证证书和签名后发出自己的 ECDHE 公钥,双方算出预主密钥,结合两个随机数派生会话密钥,再互发 Finished 校验握手未被篡改;TLS 1.2 要 2 个 RTT,TLS 1.3 在 ClientHello 中直接带公", + "status": "active", + "text": "HTTPS 的握手过程是怎样的?" + }, + "a47f889523": { + "days": [ + "java-03" + ], + "points": "浅拷贝的引用类型字段与原对象指向同一个对象;深拷贝要把对象图中的可变对象逐层复制,String 这类不可变对象可以共享,例如订单的商品列表要复制列表本身和其中的每个商品;用 clone() 实现时先调用 super.clone(),再为可变的引用字段新建对象并复制内容;clone() 需要实现 Cloneable、处理异常和类型转换,更推荐拷贝构造函数或拷贝工厂。", + "status": "active", + "text": "浅拷贝和深拷贝有什么区别?怎样实现深拷贝?" + }, + "a4e729949e": { + "days": [ + "java-43" + ], + "points": "XSS 把恶意脚本注入网页,利用用户对网站的信任,可窃取 Cookie、冒用身份,防范:输出时按上下文转义、富文本白名单过滤、Cookie 设 HttpOnly、CSP 禁止内联脚本;CSRF 诱导浏览器带着登录状态访问目标网站执行操作,利用网站对浏览器的信任,防范:校验随机 Token、SameSite Cookie、检查 Referer 和 Origin、敏感操作要求重新输入密码或短信验证码。", + "status": "active", + "text": "XSS 和 CSRF 有什么区别?分别怎样防范?" + }, + "a53acc431d": { + "days": [ + "llm-13" + ], + "points": "检索看 Recall@K(相关切片有多少在前 K 个中)、Precision@K(前 K 个中相关的比例)、MRR(第一个相关结果排名倒数的平均)和 NDCG(分级相关、按排名打折),K 增大时召回率不降、精确率往往下降,放进提示词的切片还要看精确率和端到端正确率;生成看忠实度(结论能否在资料中找到依据)和答案相关性(是否回应了问题),通常由模型裁判拆成小步骤打分,两者要一起看。", + "status": "active", + "text": "怎样评测一个 RAG 系统?" + }, + "a5d2df43ec": { + "days": [ + "sprint-07" + ], + "points": "undo log 由 InnoDB 写,记录旧版本,用于回滚和 MVCC;redo log 由 InnoDB 写,记录对数据页的修改,循环写,用于崩溃恢复;binlog 由 Server 层写,记录行修改或语句,追加写,用于主从复制和按时间点恢复;两阶段提交先写 redo log 进入 prepare,再写 binlog,最后 commit,崩溃后 prepare 的事务在 binlog 中有 Xid 就提交、否则回滚,保证主库数据与 binlog 一致,从库才不会不一致。", + "status": "active", + "text": "redo log、undo log 和 binlog 有什么区别?为什么需要两阶段提交?" + }, + "a61b813949": { + "days": [ + "java-51" + ], + "points": "#{} 被替换为占位符 ?,参数通过 PreparedStatement 单独传给数据库,不会改变 SQL 结构;${} 把参数直接拼进 SQL,传入 1024 OR 1=1 就让条件恒为真,造成 SQL 注入;${} 只用于表名、列名、ORDER BY 字段,并用白名单校验。", + "status": "active", + "text": "MyBatis 中 #{} 和 ${} 有什么区别?" + }, + "a645db12eb": { + "days": [ + "llm-15" + ], + "points": "先规划后执行先一次生成带依赖的步骤列表,按依赖执行、无依赖的步骤并行,失败或结果不符时重规划(次数设上限),进度容易展示和恢复,但计划可能与实际不符;ReAct 每步根据中间结果调整,但每步都要调用一次模型,长任务容易偏离;实际常结合使用,每个步骤内部用 ReAct。", + "status": "active", + "text": "先规划后执行(Plan-and-Execute)和 ReAct 有什么区别?" + }, + "a79be5580a": { + "days": [ + "sprint-12" + ], + "points": "单例:线程不安全的懒汉式、饿汉式、方法加锁的懒汉式、双重校验锁(变量要加 volatile)、静态内部类、枚举(可防反射和反序列化);项目中常见:下单校验用责任链,运费和支付渠道用策略加简单工厂,物流回调用模板方法,支付成功后的通知用观察者,事务和分布式锁用代理(Spring AOP)。", + "status": "active", + "text": "单例模式有哪几种写法?项目中还用过哪些设计模式?" + }, + "a79dbaa169": { + "days": [ + "java-29" + ], + "points": "undo log 由 InnoDB 写,记录修改前的旧版本,用于回滚和 MVCC;redo log 由 InnoDB 写,记录对数据页的修改,空间固定、循环写,用于崩溃恢复;binlog 由 Server 层写,记录行的修改或 SQL 语句,追加写,用于主从复制和恢复到指定时间点。", + "status": "active", + "text": "redo log、undo log 和 binlog 有什么区别?" + }, + "a7e52d0151": { + "days": [ + "java-05" + ], + "points": "类加载时 JVM 根据 .class 文件创建 Class 对象,反射通过它在运行时取得类的信息,类名可以到运行时才确定;用 Field 读写字段,Method 的 invoke() 调用方法,Constructor 的 newInstance() 创建对象;应用:Spring 创建 Bean 并注入、MyBatis 写入结果字段、JUnit 调用 @Test 方法、JDK 动态代理;缺点是有性能开销(框架会缓存 Method 对象),类名和方法名写成字符串、失去编译期检查,", + "status": "active", + "text": "反射的原理是什么?有哪些应用场景?" + }, + "a7f4a61121": { + "days": [ + "llm-13" + ], + "points": "按数据流动的顺序从前往后查:解析、切片、检索、重排、生成,前面的错误后面无法弥补;每步分别看解析结果与原文是否一致、答案是否完整在一个切片中、相关切片在两路检索和重排前后的排名、直接给正确资料能否答对;也可以先做最后一步,区分问题在检索还是生成。", + "status": "active", + "text": "RAG 回答得不好,按什么顺序排查?" + }, + "a806a94773": { + "days": [ + "java-16" + ], + "points": "类加载检查,类没有加载或初始化就先完成类加载过程;分配内存:内存规整时用指针碰撞,有碎片时用空闲列表,每个线程先在 Eden 中的 TLAB 里分配以避免加锁;把实例字段初始化为零值,再设置对象头(Mark Word 和类型指针);执行构造方法。", + "status": "active", + "text": "对象的创建过程是怎样的?" + }, + "a85a26685e": { + "days": [ + "java-06" + ], + "points": "添加元素时容量不够就调用 grow(),新容量为 oldCapacity + (oldCapacity >> 1),约 1.5 倍;无参构造的容量依次为 0、10、15、22、33;扩容用 Arrays.copyOf() 复制整个数组,能预估大小时创建时就指定容量。", + "status": "active", + "text": "ArrayList 是怎样扩容的?" + }, + "a9201474fc": { + "days": [ + "java-48", + "sprint-11" + ], + "points": "select 每次调用都要把描述符集合复制到内核并逐个检查,开销与描述符数成正比,只能监听编号小于 1024 的描述符;poll 用 pollfd 数组,没有 1024 限制,但同样每次复制和遍历;epoll 用 epoll_ctl 把描述符注册到内核的红黑树中只复制一次,就绪时由回调加入就绪链表,epoll_wait 只返回就绪的事件;监听 1 万个描述符、只有 1 个就绪时,poll 每次约 1.6 ms,epoll_wait 约 0.21 µs,但 epoll 只能在 ", + "status": "active", + "text": "select、poll 和 epoll 有什么区别?" + }, + "a96d51b500": { + "days": [ + "java-31" + ], + "points": "字典是拉链法的哈希表,桶数为 2 的幂;键数达到桶数时扩容(有子进程执行 BGSAVE 或 AOF 重写时要达到 4 倍),新表大小为不小于键数加 1 的 2 的幂;采用渐进式 rehash:同时保留新旧两张表,之后每次增删改查顺便迁移一个桶,serverCron 也定期迁移,期间新键只写入新表,查找和删除时两张表都可能要查,迁移完后新表替换旧表。", + "status": "active", + "text": "Redis 的字典是怎样扩容的?" + }, + "a9fdb0e73d": { + "days": [ + "java-56" + ], + "points": "先确定幂等键:客户端在确认页生成 request_id,重试都带着它,或用业务唯一标识(如支付交易号);判断和处理要原子:唯一索引(重复插入报 1062,返回第一次的结果)、状态机条件更新、版本号乐观锁(重试沿用第一次读到的版本号)、Token(DEL 返回 1 才处理,只能挡住重复点击,最终去重仍靠业务唯一约束);先查再插、分布式锁都不能代替幂等。", + "status": "active", + "text": "怎样保证接口的幂等?" + }, + "ab0c96ab4a": { + "days": [ + "java-33" + ], + "points": "主观下线:某个哨兵在 down-after-milliseconds 内没有收到主节点的有效回复;客观下线:认为它下线的哨兵数达到 quorum,再由哨兵投票选出领头哨兵负责故障转移;选新主:排除断线太久的从节点,依次比较优先级(replica-priority 越小越优先)、复制偏移量(越大数据越新)和运行 ID;对选中的从节点执行 REPLICAOF NO ONE,其它从节点改为复制它,旧主节点恢复后成为从节点。", + "status": "active", + "text": "哨兵怎样发现主节点下线并选出新的主节点?" + }, + "aeb37c713b": { + "days": [ + "llm-01" + ], + "points": "Token 是分词器切出的文本片段,模型只处理它在词表中的编号,常用字节级 BPE 反复合并最高频的相邻片段;上下文窗口和费用都按 Token 计算;中英文不同,DeepSeek 估算 1 个中文字符约 0.6 个 Token、1 个英文字符约 0.3 个,准确数量以 usage 为准。", + "status": "active", + "text": "什么是 Token?同样的内容,中文和英文的 Token 数一样吗?" + }, + "af9254f7f2": { + "days": [ + "java-32" + ], + "points": "惰性删除:访问键时检查,过期就删除并当作不存在;定期删除:每秒 10 次,每次抽 20 个设置了过期时间的键,过期的超过 10% 就继续下一批,每次最多占 25% 的 CPU;内存达到 maxmemory 后按淘汰策略处理,有 volatile 和 allkeys 两个范围的 lru、lfu、random,以及 volatile-ttl 和默认的 noeviction(SET 这类可能增加内存的命令返回 OOM 错误,GET、DEL 照常执行),Redis 4.0 到 7.x", + "status": "active", + "text": "过期的键怎样删除?内存满了有哪些淘汰策略?" + }, + "b06acac358": { + "days": [ + "java-59" + ], + "points": "组内每个分区只分配给一个消费者,多出来的消费者分不到分区,只能空闲,例如 3 个分区启动 4 个消费者,其中一个分到 0 个分区;分区数决定了一个组的最大消费并行度;Kafka 4.2 起的共享组没有这个限制,但不保证顺序。", + "status": "active", + "text": "消费者组中的消费者比分区多时会怎样?" + }, + "b129be802f": { + "days": [ + "java-46" + ], + "points": "设计时破坏任一必要条件:一次申请全部资源(破坏占有和等待,利用率低)、申请失败就释放已有资源(破坏不可抢占,如 tryLock 超时)、资源编号后按顺序加锁(破坏环路等待);最常用的是按固定顺序加锁,代价最小,例如转账时总是先锁 id 小的账户。", + "status": "active", + "text": "怎样避免死锁?实际开发中最常用哪种方法?" + }, + "b1361d5a9a": { + "days": [ + "java-63" + ], + "points": "先确认流量性质:看来源 IP、User-Agent 和接口分布,区分真实增长、爬虫和重试风暴;先保护再扩容:网关限流、关闭非核心功能、熔断慢依赖;逐层查看 QPS、CPU、连接池、慢 SQL、Redis 延迟,找出瓶颈;扩容无状态的应用实例,事后复盘更新容量模型。", + "status": "active", + "text": "QPS 突然增长到原来的 10 倍,应该先做什么?" + }, + "b21b2cd3d2": { + "days": [ + "java-36" + ], + "points": "TIME_WAIT 在主动关闭方,多由短连接引起;发起连接的一方每个 TIME_WAIT 占用本地端口 60 秒,连接同一目标时每秒新建约 470 个连接就会耗尽默认的 28232 个端口(服务器一侧的 TIME_WAIT 共用监听端口,不消耗本地端口),用长连接或连接池、把 tcp_tw_reuse 设为 1(默认值 2 只对回环地址生效)、扩大端口范围解决;CLOSE_WAIT 在被动关闭方,是应用没有调用 close(),多为异常分支没关连接或连接池借出未还,要改代码。", + "status": "active", + "text": "服务器上出现大量 TIME_WAIT 或 CLOSE_WAIT 是什么原因?" + }, + "b3dc5cbe84": { + "days": [ + "java-64" + ], + "points": "多阶段构建:编译阶段用 Maven 和 JDK,最终阶段只把 jar 复制到 JRE 镜像,镜像从约 680 MB 降到约 236 MB;用 -alpine、-slim 等小的基础镜像,下载、安装和清理写在同一条 RUN 中;变化少的步骤放前面,先复制 pom.xml 下载依赖再复制源码,改源码后重新构建只需约 6 秒;用 .dockerignore 排除 target、.git。", + "status": "active", + "text": "怎样写一个体积小、构建快的 Dockerfile?" + }, + "b432826e95": { + "days": [ + "java-18", + "sprint-04" + ], + "points": "回收范围:CMS 只回收老年代,要与 ParNew 配合,G1 用 Region 统一管理新生代和老年代;碎片:CMS 用标记 - 清除,碎片多时只能 Full GC,G1 把存活对象复制到空闲的 Region,普通对象不产生碎片(Humongous 对象需要连续的 Region,可能因找不到连续空间而提前 Full GC);停顿:CMS 的停顿无法控制,G1 按停顿时间目标挑选要回收的 Region;并发标记:CMS 用增量更新,G1 用原始快照(SATB)。", + "status": "active", + "text": "CMS 和 G1 有什么区别?" + }, + "b50334ebd5": { + "days": [ + "java-11" + ], + "points": "NEW 是创建后还没有启动,start() 后进入 RUNNABLE,可能正在运行,也可能在等 CPU;等待进入 synchronized 时为 BLOCKED,拿到锁后回到 RUNNABLE;无参的 wait()、join() 和 park() 进入 WAITING,等其它线程唤醒(被 notify() 唤醒后要先重新竞争锁,这时为 BLOCKED),sleep() 和带超时的 wait()、join() 进入 TIMED_WAITING,时间到自动唤醒;执行完或因异常结束", + "status": "active", + "text": "线程有哪些状态?状态之间怎样转换?" + }, + "b5c9ebc9ee": { + "days": [ + "llm-13" + ], + "points": "优先从真实用户问题中抽样,不够时用模型生成再人工改写措辞,避免照抄原文高估向量检索;包含没有答案、需要多个切片和新旧版本冲突的问题,没有答案的问题回答不知道算对、编出答案算错;标注标准答案和所需的原文片段而不是切片 ID,线上的错误回答持续补充进来。", + "status": "active", + "text": "评测集怎样构建?" + }, + "b5eb99096c": { + "days": [ + "sprint-04" + ], + "points": "线程私有:程序计数器、Java 虚拟机栈(每个方法一个栈帧,-Xss 设置大小)、本地方法栈;线程共享:堆(几乎所有对象,分新生代和老年代,-Xms、-Xmx 设置)和方法区(类的元信息和运行时常量池,JDK 8 起由本地内存中的元空间实现;字符串常量池和静态变量 JDK 7 起已移到堆中,JIT 编译的代码在单独的代码缓存中);NIO 还会使用堆外的直接内存;除程序计数器外都可能 OOM,两种栈还可能 StackOverflowError。", + "status": "active", + "text": "JVM 的内存区域有哪些?" + }, + "b6e89f7079": { + "days": [ + "java-57" + ], + "points": "发送端:等待确认并重试(Kafka 用 acks=all,检查回调,退出前 flush),要与业务一致时用本地消息表或事务消息;存储端:持久化加多副本(min.insync.replicas,不允许落后副本当选 Leader);消费端:处理完再提交偏移量或 ack;关键业务再定期对账补发。", + "status": "active", + "text": "怎样保证消息不丢失?" + }, + "b813e96d80": { + "days": [ + "java-66" + ], + "points": "不丢、不重:发送方为每条消息生成 msg_id,服务端存储后回复 ACK,超时没收到就用同一个 msg_id 重发,服务端用 (sender_id, msg_id) 唯一索引去重;不乱序:服务端为每个会话分配递增的 seq,接收方按 seq 排序,不按各机器的时间;离线:推送只负责通知,接收方记住连续收齐的最大 seq(例如 58),重连时拉取它之后的消息,收到 61 而本地只到 58 时补拉中间的 59、60;用 INCR 分配 seq 时写入失败会留下补不到的空号,后分配", + "status": "active", + "text": "聊天消息怎样做到不丢失、不重复、不乱序?离线期间的消息怎样补上?" + }, + "b9bc59a83b": { + "days": [ + "llm-10" + ], + "points": "分词后输入 Transformer 编码器,每个 Token 得到一个向量,再池化(取 [CLS] 或平均值,与训练时一致)成固定长度的向量并归一化;对比学习使意思相近的文本向量靠近;余弦相似度看方向是否接近,归一化后等于点积、计算更快,排序与欧氏距离等价。", + "status": "active", + "text": "文本是怎样变成向量的?为什么用余弦相似度?" + }, + "b9fdebae51": { + "days": [ + "java-27", + "sprint-07" + ], + "points": "找出:开启慢查询日志,long_query_time 线上调到 1 秒或更低,对比 Rows_examined 和 Rows_sent,用 mysqldumpslow 或 sys.statement_analysis 按语句归类;分析:用 EXPLAIN 和 EXPLAIN ANALYZE 看执行计划,常见原因有索引失效、回表太多、filesort 和临时表、连接列没有索引、等锁(Lock_time 大)、统计信息不准;修改:建合适的索引或改写语句,再对比执行计划和耗时,例如", + "status": "active", + "text": "线上出现慢 SQL,怎样排查和优化?" + }, + "babcc095aa": { + "days": [ + "java-31" + ], + "points": "用 ZSET,成员是用户或文章 ID,分值是分数或点赞数;ZINCRBY 加分,ZREVRANGE key 0 9 WITHSCORES 取前 10 名,ZREVRANK 查名次(从 0 开始),都是 O(logN);分值相同时按成员名的字典序排,要先达到的排前面可以把时间编进分值;前 10 名是热点键时,可以在本地缓存中保存 1 秒。", + "status": "active", + "text": "怎样用 Redis 实现排行榜?" + }, + "bb799ba354": { + "days": [ + "java-12" + ], + "points": "CountDownLatch 让一个或多个线程等待其它线程:countDown() 使计数减 1,减到 0 时唤醒 await() 的线程,计数不能重置;CyclicBarrier 让多个线程互相等待,全部调用 await() 到达后一起继续,计数自动恢复,可以用于下一轮,还可以指定全部到达时执行的 barrierAction。", + "status": "active", + "text": "CountDownLatch 和 CyclicBarrier 有什么区别?" + }, + "bbc2863617": { + "days": [ + "llm-19" + ], + "points": "主要检查结果(例如数据库中是否真有一条退款记录),轨迹只检查必须满足的约束,优先用代码检查,开放内容才用模型裁判;指标有任务成功率、工具调用正确率和效率,用 pass^k 衡量稳定性;裁判有位置偏差、长度偏差和自我偏好,缓解靠交换顺序评两次、给出参考、拆成逐项判断的问题、与人工标注核对一致率。", + "status": "active", + "text": "怎样评测一个 Agent?用模型做裁判有哪些偏差?" + }, + "bc38dbf743": { + "days": [ + "llm-16" + ], + "points": "短期记忆是当前会话的原始消息(含工具调用和结果)、摘要和任务状态,常以会话 ID 为键存在 Redis,请求时放入“摘要 + 最近几轮 + 任务状态”;长期记忆由后台提取或模型调用 save_memory 写入,只写用户明确说过、以后有用、业务系统查不到的事实;使用时按用户 ID 过滤,按相似度和时间检索最相关的几条。", + "status": "active", + "text": "Agent 的短期记忆和长期记忆分别怎样实现?" + }, + "bc787d309a": { + "days": [ + "sprint-05" + ], + "points": "B+ 树的一个节点就是一个 16 KB 的页,有几百上千个子节点,树很矮(200 万行的主键索引 3 层,二叉树约 21 层),一次 I/O 读一个节点,读盘次数少;内部节点只存 key 和指针,比 B 树每页放的 key 更多,每次查找都走到叶子、I/O 次数稳定;叶子按顺序用指针相连,范围查询顺序扫描即可。", + "status": "active", + "text": "MySQL 为什么用 B+ 树做索引?" + }, + "bcb8868d08": { + "days": [ + "java-09" + ], + "points": "corePoolSize 核心线程数,maximumPoolSize 最大线程数;keepAliveTime 和 unit:超出核心线程数的线程空闲多久后回收;workQueue 保存等待任务的阻塞队列,threadFactory 创建线程,常用来起名字;handler 拒绝策略,线程数达到最大且队列已满时处理新提交的任务。", + "status": "active", + "text": "线程池的核心参数有哪些?" + }, + "bcd73345b9": { + "days": [ + "java-11" + ], + "points": "wait() 是 Object 的方法,sleep() 是 Thread 的静态方法;wait() 会释放锁,sleep() 不会;wait() 只能在同步方法或同步块中调用,否则抛出 IllegalMonitorStateException;不带超时的 wait() 要等 notify()、notifyAll() 唤醒(也可能虚假唤醒),sleep() 到时间自动返回,两者都可以被 interrupt() 打断。", + "status": "active", + "text": "wait() 和 sleep() 有什么区别?" + }, + "bdc1af9732": { + "days": [ + "llm-19" + ], + "points": "LangChain 提供模型、工具、检索器等组件和现成的 Agent,早期核心是按固定顺序串起来的链;LangGraph 是更底层的编排运行时,用共享状态、节点和条件边组成状态图,LangChain 1.0 的 create_agent 就运行在它之上;Agent 和业务流程需要循环、分支、暂停等人工确认和从中间恢复,用链表达很别扭,图加 checkpoint 可以直接表达。", + "status": "active", + "text": "LangChain 和 LangGraph 有什么区别?为什么用图来编排?" + }, + "bde7a7dfbe": { + "days": [ + "java-13" + ], + "points": "volatile 只修饰变量,保证可见性和有序性,不保证复合操作的原子性,线程不会阻塞;synchronized 修饰方法和代码块,同一时刻只有一个线程执行,同时保证原子性、可见性和有序性,竞争时线程阻塞;状态标志、发布数据用 volatile,单个变量的读-改-写可以用原子类,多个变量一起修改通常用锁。", + "status": "active", + "text": "volatile 和 synchronized 有什么区别?" + }, + "be8cf2c3e0": { + "days": [ + "java-33" + ], + "points": "执行命令是单线程的,6.0 起可以开启 I/O 多线程处理网络读写,关闭文件、AOF 刷盘、UNLINK 释放大键由后台线程完成;快的原因:数据在内存中,单线程不用加锁、没有线程切换,I/O 多路复用让一个线程处理大量连接,小数据用紧凑的编码;瓶颈通常在网络,50 个连接逐条 GET 约 16 万次/秒,流水线约 160 万次/秒。", + "status": "active", + "text": "Redis 是单线程的吗?为什么单线程也这么快?" + }, + "beeadc9d28": { + "days": [ + "llm-03" + ], + "points": "temperature 把分数除以 T 再 softmax,T < 1 时分布更尖、T > 1 时更平,例如最高概率 0.50 在 T = 0.5 时升到 0.75;top-p 只在累计概率刚达到 p 的高概率 Token 中采样,候选数随分布变化;抽取字段、调用工具用低温度,写作可调高。", + "status": "active", + "text": "temperature 和 top-p 分别控制什么?" + }, + "bf51732677": { + "days": [ + "java-63" + ], + "points": "逐步增加并发,记录吞吐量、P99、错误率和 CPU,找到吞吐不再增长、延迟陡升的拐点,重复多次取中位数;环境、数据量和请求参数的分布要接近生产,读写先分开压再混合,大促前做带标记、写影子库的全链路压测;结论要说明哪一层先到拐点、原因是什么,饱和的资源不一定是根因,例如连接池占满可能是 SQL 在等行锁;实例数 = 峰值 QPS ÷(单实例拐点 × 目标利用率),再用 CPU 使用率校准。", + "status": "active", + "text": "怎样做压测和容量规划?" + }, + "bfcd308f00": { + "days": [ + "llm-19" + ], + "points": "执行前先按 Schema 校验参数,再做业务校验;超时、限流、5xx 由代码按指数退避重试 2~3 次,不交给模型(实测把超时回传给 7B 模型,10 次都没有重新调用),接口持续失败时熔断;参数错误把错在哪、正确格式是什么回传给模型修正;仍失败就如实回传,由模型换办法或告知用户、转人工,不能编造结果;同时限制步数和预算、检测有没有进展(比较工具名、参数和结果,而不只数调用次数),有副作用的操作要幂等,只对涉及资金、无法撤销或对外可见的操作人工确认(确认太频繁,人会不看内容就", + "status": "active", + "text": "工具调用失败时怎样容错?" + }, + "c0ce068130": { + "days": [ + "java-27" + ], + "points": "type(const、eq_ref、ref、range、index、ALL,从好到差)、key 和 possible_keys、key_len(用到了联合索引的前几列)、rows(估算扫描行数)、Extra;Using filesort 表示无法利用索引的顺序,需要额外排序;Using temporary 表示用了临时表,常见于 GROUP BY 的列无法利用索引的顺序,或者 GROUP BY 和 ORDER BY 的列不一致。", + "status": "active", + "text": "EXPLAIN 主要看哪些字段?Using filesort 和 Using temporary 表示什么?" + }, + "c226c6df57": { + "days": [ + "llm-14" + ], + "points": "工作流的步骤和顺序由代码事先写好,模型只做其中某些步骤,可预测、成本可控,适合步骤固定的业务;Agent 由模型决定下一步,适合无法事先确定步骤的开放任务,但成本高、错误会累积(每步 95% 时连续 10 步约 60%),所以先尝试单次调用或工作流;判断看步骤和步骤之间的转移规则能否事先写清,写不全才交给 Agent;实际常把两者组合,退款这类固定流程走工作流,商品咨询交给 Agent。", + "status": "active", + "text": "Agent 和工作流有什么区别?分别适合什么场景?" + }, + "c4e5f8bc1a": { + "days": [ + "llm-05" + ], + "points": "LoRA 冻结原矩阵 W,只训练小矩阵 B、A,h = Wx + (α / r)·BAx,B 初始化为 0,训练开始时与原模型相同;可训练参数很少(Qwen2.5-7B 全部线性层 r = 16 时占 0.53%),训练后合并进 W,推理无额外开销;QLoRA 再把冻结的权重量化为 4 位 NF4 存储、计算时还原为 BF16,7B 权重从 15.2 GB 降到约 5.5 GB(嵌入层和输出层仍为 BF16)。", + "status": "active", + "text": "LoRA 的原理是什么?LoRA 和 QLoRA 有什么区别?" + }, + "c5494b65a3": { + "days": [ + "java-69" + ], + "points": "Skill 是一个包含 SKILL.md、脚本和参考资料的文件夹,教 Agent 完成某一类任务;核心是渐进式加载:平时只把名称和描述放进上下文(每个约 100 Token),模型判断与当前任务相关时才读入正文,正文引用的文档和脚本需要时才读取或执行;工具是可调用的函数,提供原子能力,MCP 解决工具怎样接入,Skill 提供做事的步骤和规范,执行时仍要调用工具;需要接入外部系统、要求权限控制的能力做成 MCP 服务端,组合已有工具的流程和规范写成 Skill。", + "status": "active", + "text": "Skill 是什么?它和工具、MCP 有什么区别?" + }, + "c7974bfd54": { + "days": [ + "sprint-09" + ], + "points": "哨兵在主从复制的基础上自动故障转移:认为主节点下线的哨兵数达到 quorum 后确认客观下线,再选出得票过半且不少于 quorum 的领头哨兵,按优先级和复制偏移量选新主并切换,但只有一个主节点写入,容量受单机内存限制;Redis Cluster 把 16384 个槽分到多个主节点,每个主节点有从节点,同时解决容量和高可用;两者都是异步复制,切换时可能丢失少量写入。", + "status": "active", + "text": "哨兵和 Redis Cluster 分别解决什么问题?" + }, + "c79e20122e": { + "days": [ + "java-34", + "sprint-09" + ], + "points": "穿透:查询缓存和数据库都不存在的数据,用参数校验、布隆过滤器拦截,空值缓存兜底;击穿:单个热点键过期,用互斥锁只让一个请求查库,或逻辑过期返回旧值、后台重建;雪崩:大量键同时过期或缓存宕机,用随机过期时间、主从加哨兵或集群、本地缓存、限流熔断。", + "status": "active", + "text": "缓存穿透、击穿和雪崩有什么区别?分别怎样解决?" + }, + "c82b36ba2a": { + "days": [ + "llm-11" + ], + "points": "HNSW 删除一般只做标记、检索时跳过,之后统一清理,更新等于删除加插入;Milvus 的删除在位图中标记,Compaction 时才真正去掉,所以更新要批量进行;文档修改后按文档 ID 和内容哈希找出变化的文档,删除它的全部旧切片再写新切片,只改权限或版本状态时只更新元数据;按切片 ID 幂等写入,并与关系数据库定期对账;更换 Embedding 模型或切片方式时,在新集合中重建索引并评测,再把别名切过去,查询用的模型同时切换。", + "status": "active", + "text": "向量数据库中的数据怎样更新和删除?" + }, + "c8485a2dab": { + "days": [ + "java-65" + ], + "points": "容器启动后反复退出,重启等待按 10 秒、20 秒、40 秒翻倍,最长 5 分钟;kubectl describe pod 看事件和退出码,kubectl logs --previous 看上一次退出前的日志(如配置错误);退出码 137、原因 OOMKilled 时检查内存 limits 与 JVM 堆和堆外内存。", + "status": "active", + "text": "Pod 一直处于 CrashLoopBackOff 状态,怎样排查?" + }, + "c93bb3c65b": { + "days": [ + "java-67" + ], + "points": "关单以支付平台为准,分三步:先用条件更新把订单从待支付改为关单中,影响 0 行就按当前状态处理;再调用平台的关单接口,成功或订单已关闭时改为已关闭,在同一个事务中回补库存;关单失败时查单,已支付就按支付成功处理(与回调走同一段代码),订单不存在可以关闭,结果不明保持关单中,由扫描任务稍后重做;只改本地状态的话,用户打开旧的支付页面仍能付款;下单时把同一个截止时间作为 time_expire 传给平台,否则平台默认 7 天内都能支付;回调在关单中到达时仍然有效。", + "status": "active", + "text": "订单超时未支付要自动关闭,关单时用户恰好付了款,怎样保证两边一致?" + }, + "c9434cba34": { + "days": [ + "llm-15" + ], + "points": "ReAct 让推理和行动交替:先思考,再调用工具,把真实结果作为观察放回上下文再思考;与思维链相比,事实来自外部而不是模型的记忆,HotpotQA 的分析中思维链失败案例 56% 是编造事实,ReAct 为 0%;适合下一步取决于上一步结果、步数不多的任务,缺点是只能串行、长任务容易偏离目标。", + "status": "active", + "text": "ReAct 是什么?为什么用它?" + }, + "cd627f049d": { + "days": [ + "java-66" + ], + "points": "先明确需求:要支持的功能、规模和哪些数据不能出错,例如点赞不能重复计、点赞数允许延迟几秒;再估算高峰时的读写量和存储:日活 2000 万、每人每天点赞 5 次时,高峰小时写约 1700 次/秒、读约 3.3 万次/秒,点赞关系每年约 4 TB;然后定义接口和表,(user_id, video_id) 作主键让重复点赞插入失败;先给出单库事务的简单方案,再按估算找瓶颈逐个改进:热门视频的计数行锁改为 Redis 计数加合并写回,读多加缓存,数据量大按 user_id 分片;最后", + "status": "active", + "text": "面试中遇到“设计一个点赞系统”这类开放题,应该按什么顺序回答?" + }, + "cf1562e438": { + "days": [ + "java-04" + ], + "points": "重写发生在子类与父类之间,方法声明相同,访问权限不能更小,返回类型与父类相同或为其子类型(基本类型必须相同),抛出的受检异常只能是父类方法声明的异常或其子类型;重载指一个类中(包括从父类继承来的方法)方法名相同,参数类型、个数、顺序至少一个不同,只有返回值不同不算重载;重载在编译时按参数的静态类型选定,重写在运行时按对象的实际类型选定。", + "status": "active", + "text": "重载和重写有什么区别?" + }, + "cff796edde": { + "days": [ + "llm-01" + ], + "points": "目前的大模型基本是只保留解码器的 Decoder-only 结构;Token 经词嵌入变成向量(Qwen2.5-7B 为 3584 维),依次经过几十个相同的层(28 层),每层先用注意力从前文收集信息,再用前馈网络逐位置加工;子层都带残差连接和归一化(多为 Pre-Norm 的 RMSNorm);最后一个位置经线性层得到 logits,softmax 后得到下一个 Token 的概率。", + "status": "active", + "text": "Transformer 的结构是怎样的?" + }, + "d0cadbf383": { + "days": [ + "llm-17" + ], + "points": "MCP 是 AI 应用与外部工具、数据之间的统一协议,工具实现一次服务端就能被所有支持 MCP 的应用使用,消息用 JSON-RPC 2.0,经 stdio 或 Streamable HTTP 传输,tools/list 发现工具、tools/call 调用;Function Calling 是模型侧的能力,模型按工具描述给出调用;两者配合:主机把 MCP 工具转成工具定义交给模型,再通过 MCP 执行模型给出的调用。", + "status": "active", + "text": "MCP 是什么?MCP 和 Function Calling 有什么区别?" + }, + "d175c9b0a1": { + "days": [ + "java-16" + ], + "points": "程序计数器、Java 虚拟机栈和本地方法栈是线程私有的,随线程创建和销毁;堆和方法区被所有线程共享,JDK 8 起方法区由本地内存中的元空间实现;另有 NIO 使用的直接内存;除程序计数器外都可能抛出 OutOfMemoryError,两种栈还可能抛出 StackOverflowError。", + "status": "active", + "text": "JVM 的内存区域有哪些?哪些是线程私有的?" + }, + "d2c8cce9c3": { + "days": [ + "sprint-11" + ], + "points": "四个条件:互斥、占有和等待、不可抢占、环路等待,同时成立才会死锁;预防就是破坏其中一个,例如一次申请全部资源、申请不到就释放已占有的资源或给锁设过期时间;最常用的是给资源编号、按顺序申请来破坏环路等待,例如转账总是先锁 id 小的账户。", + "status": "active", + "text": "死锁的必要条件是什么?怎样避免?" + }, + "d30a0abfb5": { + "days": [ + "java-16" + ], + "points": "栈中是方法调用的栈帧(局部变量、操作数、返回地址),堆中是对象和数组;每个线程有自己的栈,堆被所有线程共享,只有堆中的对象有线程安全问题;栈帧在方法返回时销毁,堆中的对象由垃圾收集器回收;栈大小由 -Xss 设置,太深抛出 StackOverflowError,堆由 -Xms、-Xmx 设置,放不下抛出 OutOfMemoryError。", + "status": "active", + "text": "堆和栈有什么区别?" + }, + "d3574a433d": { + "days": [ + "java-58" + ], + "points": "先区分生产突增(峰值后可自然消化)还是消费变慢(下游变慢、反复重试、频繁重平衡),先修复消费变慢的原因,失败消息转入死信队列;扩容消费者(Kafka 普通消费者组、RocketMQ 4.x 中超过分区或队列数的消费者会空闲),或批量写库、线程池并行(同一 key 交给同一线程);分区不够时转发到分区更多的新主题,不重要的消息降级跳过;积压接近保留时间(Kafka 默认 7 天)前先转存。", + "status": "active", + "text": "消息大量积压时怎样处理?" + }, + "d45f6f5a22": { + "days": [ + "java-37" + ], + "points": "TCP 面向字节流,不保留应用写入的边界,一次读取可能读到两条消息拼在一起(粘包)或半条消息(拆包);原因有按缓冲区现有数据读取、Nagle 合并小数据、超过 MSS 被分段;在应用层约定边界:固定长度、分隔符或长度字段(长度要限制上限);UDP 面向报文,没有粘包。", + "status": "active", + "text": "什么是粘包?怎样解决?" + }, + "d47bb94d7b": { + "days": [ + "java-60" + ], + "points": "QPS 是每秒处理的请求数,衡量吞吐量;TPS 是每秒处理的事务数,一个事务可能包含多次请求或多条 SQL;TP99 是响应时间的 99 分位,即 99% 的请求不超过这个时间,比平均值更能反映慢请求。", + "status": "active", + "text": "QPS、TPS 和 TP99 分别是什么意思?" + }, + "d4c6da97dc": { + "days": [ + "java-17" + ], + "points": "从 GC Roots 出发沿强引用可达的对象不会被回收,只是互相强引用、从 GC Roots 不可达的仍会被回收;只被软引用(SoftReference)关联的对象在即将内存溢出前一定被回收,其它时候 HotSpot 按多久没被访问决定是否清除;只被弱引用(WeakReference)关联的对象在下一次回收它所在区域的 GC 时一定被回收,例如 ThreadLocalMap 的 key;虚引用(PhantomReference)不影响对象的存活,get() 总是返回 null", + "status": "active", + "text": "强引用、软引用、弱引用、虚引用有什么区别?" + }, + "d560ae1052": { + "days": [ + "sprint-05" + ], + "points": "联合索引先按第一列排序、再按第二列,所以条件要从最左列开始连续使用才能定位,与 WHERE 中的书写顺序无关,范围条件之后的列一般不能再缩小扫描范围(带等号时例外);常见的无法用索引定位的情况:缺少最左列(8.0.13 起少数情况可跳跃扫描)、对索引列用函数(可建函数索引)、字符串列与数字比较、LIKE 以 % 开头、OR 一侧没有索引;匹配的行太多时,优化器按成本选择全表扫描。", + "status": "active", + "text": "联合索引的最左前缀是什么?哪些情况下索引会失效?" + }, + "d5ef30f656": { + "days": [ + "java-15" + ], + "points": "同步块编译为 monitorenter 和 monitorexit,另有异常处理器保证抛出异常时也释放锁,同步方法用 ACC_SYNCHRONIZED 标志;没有竞争时是轻量级锁,用 CAS 把 Mark Word 换成指向栈中 Lock Record 的指针;有竞争时膨胀为 ObjectMonitor,_owner 指向持有锁的线程,_recursions 记录重入次数,抢不到锁的线程进入 _cxq、_EntryList 阻塞,调用 wait() 的线程进入 _WaitSe", + "status": "active", + "text": "synchronized 的底层是怎样实现的?" + }, + "d707267199": { + "days": [ + "java-55" + ], + "points": "同步阻塞:参与者准备后一直持有锁,等待协调者的决定;单点问题:协调者在提交阶段故障时,已同意的参与者既不能提交也不能回滚,只能等待;数据不一致:协调者只发出部分 Commit 就崩溃;太过保守:准备阶段任一参与者失败、回复中止或超时,整个事务都要回滚;用数据库的 XA 实现时每个参与者都要支持 XA,Redis、Kafka、RocketMQ 和一般的 HTTP 接口无法直接参与。", + "status": "active", + "text": "2PC 有什么问题?" + }, + "d82af70a7a": { + "days": [ + "java-21" + ], + "points": "先明确目标(在线服务看最长停顿,批处理看吞吐量),默认用 G1 并开启 GC 日志观察;-Xms 与 -Xmx 设为相同,-Xmx 要给元空间、线程栈、直接内存等堆外内存留出余量,不能等于容器的内存,按需设置 MaxGCPauseMillis、MaxMetaspaceSize、HeapDumpOnOutOfMemoryError;频繁 Full GC 时用 jstat -gcutil 看老年代使用率,降不下来查泄漏,能降下来就增大堆或调整新生代;GC 问题多数是代码造成的,只", + "status": "active", + "text": "做过哪些 JVM 调优?" + }, + "d85d93a06e": { + "days": [ + "java-02" + ], + "points": "String Pool 中还没有 \"abc\" 时创建两个:字面量 \"abc\" 在 String Pool 中(HotSpot 通常在第一次执行对应的 ldc 时创建),另一个是 new 在堆中创建的对象;Pool 中已有 \"abc\" 时只新建堆中的一个;两个对象共享同一个 value 数组。", + "status": "active", + "text": "new String(\"abc\") 会创建几个对象?" + }, + "d97306ed39": { + "days": [ + "java-56" + ], + "points": "HTTP 是协议,RPC 是调用方式,RPC 可以基于自定义 TCP 协议(Dubbo)或 HTTP(gRPC 基于 HTTP/2);内部服务多用 RPC 框架:序列化更紧凑(同样的参数 Protobuf 5 字节、JSON 25 字节)、长连接复用、内置服务治理、按接口生成代码;HTTP + JSON 通用、可读,适合对外接口。", + "status": "active", + "text": "RPC 和 HTTP 有什么区别?" + }, + "d977db9dde": { + "days": [ + "java-30" + ], + "points": "数据位置:Redis 全部在内存中,容量受内存限制,MySQL 在磁盘上,缓冲池只缓存热点页;查询:Redis 只能按键访问数据结构,MySQL 用 SQL 按任意条件查询、联表和聚合;事务和持久性:MySQL 满足 ACID,提交后不丢数据,Redis 事务中的命令出错不回滚,开启 AOF everysec 也可能丢失最近 1 秒的写入;通常 MySQL 存业务数据,Redis 作缓存。", + "status": "active", + "text": "Redis 和 MySQL 有什么区别?" + }, + "da147d6f3a": { + "days": [ + "java-51" + ], + "points": "DispatcherServlet 统一接收请求,HandlerMapping 找到处理方法和拦截器;依次执行拦截器的 preHandle,返回 false 就停止;HandlerAdapter 转换参数、调用 Controller,再把返回值转换为响应(如 JSON);倒序执行 postHandle(Controller 抛出异常时跳过);返回视图名时由 ViewResolver 渲染;最后倒序执行 preHandle 返回 true 的拦截器的 afterCompleti", + "status": "active", + "text": "Spring MVC 处理一个请求的过程是怎样的?" + }, + "da52b096ec": { + "days": [ + "llm-10" + ], + "points": "逐个比较时,100 万个 1024 维向量每次查询约 10 亿次乘加,数据量和并发大时太慢;ANN 索引用少量精度换速度,可能漏掉个别真正最近的向量。", + "status": "active", + "text": "为什么需要近似最近邻检索?" + }, + "dd70642b39": { + "days": [ + "java-43" + ], + "points": "解析 URL,有未过期的缓存直接使用;DNS 解析得到 IP;TCP 三次握手,HTTPS 再做 TLS 握手;发送 HTTP 请求,经负载均衡或反向代理转给应用服务器,响应是重定向时对新地址重走一遍;浏览器解析 HTML 构建 DOM 树、解析 CSS 构建 CSSOM,合成渲染树后布局、绘制,遇到 CSS、JS、图片另发请求,普通脚本会暂停 HTML 的解析;连接保留一段时间供复用。", + "status": "active", + "text": "从输入 URL 到页面显示经历了哪些过程?" + }, + "e1c4dd69ef": { + "days": [ + "java-67" + ], + "points": "先验签,核对商户号、订单号、金额与本地订单一致,并确认交易状态是支付成功,再把三件事放在同一个事务中:以交易号为主键插入去重表,重复的通知报 1062 后回滚;用 UPDATE ... WHERE status IN ('UNPAID', 'CLOSING') 改为已支付,影响 1 行才继续;写入发货消息;重复的通知也要应答成功,否则平台会继续重发;先查状态再更新的写法,同一笔通知并发到达 10 次时会发 10 次货。", + "status": "active", + "text": "支付回调重复到达或并发到达时,怎样保证只发一次货?" + }, + "e2b985d29c": { + "days": [ + "java-13" + ], + "points": "Java 内存模型用先行发生关系定义可见性:A 先行发生于 B,A 的结果对 B 可见,不要求 A 在时间上先执行;规则有单一线程、管程锁定(unlock 先于之后对同一锁的 lock)、volatile 变量(写先于之后的读)、线程启动、线程加入、线程中断、对象终结和传递性;例如写 data 后再写 volatile 的 ready,读到 ready 的线程一定能看到 data。", + "status": "active", + "text": "什么是先行发生(happens-before)原则?" + }, + "e2b9c83f1c": { + "days": [ + "java-55" + ], + "points": "C 指线性一致性,A 指发给未故障节点的请求都能在有限时间内得到非错误响应,P 指出现网络分区时系统仍能运行;网络不可靠,分区无法避免,所以 P 必须满足,CAP 实际是分区发生时在 C 和 A 之间选择:拒绝请求(CP,如 ZooKeeper)或返回可能过期的数据(AP,如 Eureka);没有分区时 C 和 A 可以兼得。", + "status": "active", + "text": "什么是 CAP?为什么分区容忍性必须满足?" + }, + "e2bec394b4": { + "days": [ + "llm-04" + ], + "points": "预训练:在海量文本上预测下一个 Token,获得语言能力和知识,但只会续写;监督微调:用指令和人工编写的回答训练,学会按对话格式回答、遵循指令;偏好对齐:用“两个回答哪个更好”的数据让回答更有帮助、更安全,这类目标难写标准答案,但比较容易。", + "status": "active", + "text": "大模型的训练分为哪几个阶段?" + }, + "e326fb1c48": { + "days": [ + "java-14" + ], + "points": "悲观锁访问前先加锁,例如 synchronized、ReentrantLock、SELECT ... FOR UPDATE;乐观锁不加锁直接操作,提交时检查是否被改过,改过就重试,例如 CAS 和数据库版本号(UPDATE ... WHERE version = 1 影响 0 行时重试);读多写少、冲突少时用乐观锁,写多、冲突频繁时用悲观锁。", + "status": "active", + "text": "乐观锁和悲观锁有什么区别?" + }, + "e36c204678": { + "days": [ + "llm-20" + ], + "points": "接口用 SSE:响应类型为 text/event-stream,连接保持打开,每生成一段内容写一条事件;事件由若干行“字段: 值”组成、以空行结束,data 是内容、id 是编号、event 是类型,冒号开头的行是注释(常作心跳);OpenAI 兼容接口以 data: [DONE] 结束,断线后 EventSource 带着 Last-Event-ID 重连。", + "status": "active", + "text": "流式输出是怎样实现的?SSE 的格式是怎样的?" + }, + "e4941b7494": { + "days": [ + "sprint-02" + ], + "points": "参数有核心线程数、最大线程数、空闲线程的存活时间、任务队列、线程工厂和拒绝策略;提交时线程数小于核心数就新建线程,否则放入队列,队列满且小于最大线程数时新建线程执行当前任务,再不行就执行拒绝策略;例如核心 2、最大 4、队列 2 时第 7 个任务被拒绝;不用 Executors 的 newFixedThreadPool()、newSingleThreadExecutor()、newCachedThreadPool(),它们的队列或线程数没有上限,要直接创建 ThreadPoo", + "status": "active", + "text": "线程池的核心参数有哪些?提交任务后是怎样执行的?" + }, + "e4952a889a": { + "days": [ + "llm-03" + ], + "points": "因果掩码下前文 Token 的 K、V 不再变化,缓存起来复用就是 KV Cache;预填充一次算好输入的 K、V,解码每步只为新 Token 计算 Q、K、V,再与缓存做注意力,不再重算前文;代价是显存,Qwen2.5-7B 每个 Token 56 KB,32K Token 约 1.75 GB。", + "status": "active", + "text": "KV Cache 是什么?为什么能加快生成?" + }, + "e4be6236bb": { + "days": [ + "java-49" + ], + "points": "只能解决字段或 Setter 注入的单例:A 实例化后把生成提前引用的工厂放入三级缓存,注入 B 时 B 通过工厂拿到 A 的提前引用(放入二级缓存),A 初始化完成后进入一级缓存;需要第三级是因为 A 可能要被 AOP 代理,工厂被调用时才提前创建代理,保证 B 拿到的与容器中最终的对象相同;构造器注入和 prototype 的循环依赖无法解决,Spring Boot 2.6 起默认禁止。", + "status": "active", + "text": "Spring 怎样解决循环依赖?为什么需要三级缓存?" + }, + "e554283074": { + "days": [ + "java-59" + ], + "points": "每个分区有一个 Leader 和多个 Follower,与 Leader 保持同步的副本组成 ISR,Leader 故障时从 ISR 中选新 Leader;生产者设 acks=all,ISR 中的副本都写入才确认;配合 min.insync.replicas=2,ISR 不足时拒绝写入,常用 3 副本加这两项配置;幂等生产者用生产者 ID 和序号去掉重试造成的重复。", + "status": "active", + "text": "Kafka 的副本机制怎样保证可靠性?" + }, + "e628ba7bc1": { + "days": [ + "llm-04" + ], + "points": "标注员把同一提示词的 4~9 个回答按好坏排序,两两组成偏好对(9 个回答 36 对);奖励模型输入提示词和回答、输出一个分数,损失为 -log σ(rw - rl),使较好回答得分更高,例如分数 1.2 和 0.3 时损失约 0.34。", + "status": "active", + "text": "RLHF 中的奖励模型是怎样训练的?" + }, + "e6469eab7d": { + "days": [ + "sprint-12" + ], + "points": "针对字段或 Setter 注入的单例 Bean,用三级缓存提前暴露未初始化完的对象:一级存初始化完成的 Bean,二级存提前暴露的 Bean,三级存生成提前引用的工厂;A 实例化后放入工厂,注入 B 时 B 从工厂拿到 A 的提前引用,A 需要 AOP 代理时工厂提前创建代理,使 B 拿到的就是最终对象;构造器注入和原型 Bean 无法解决,Spring Boot 2.6 起默认禁止循环依赖。", + "status": "active", + "text": "Spring 怎样解决循环依赖?" + }, + "e8de3674b2": { + "days": [ + "llm-20" + ], + "points": "前缀缓存降低的是单价:不变的系统提示词和工具定义放最前、工具顺序固定、历史原样追加;减少 Token 的数量:工具先返回摘要或片段,需要时再读全文;设置 max_tokens,输出单价是未命中缓存输入的 4 倍;简单问题交给小模型;历史过长时压缩,但压缩会改变前缀,不宜过于频繁;避免模型反复调用同一个工具。", + "status": "active", + "text": "怎样降低一次 Agent 任务的 Token 消耗?" + }, + "e9083e85a7": { + "days": [ + "llm-11" + ], + "points": "向量检索擅长匹配措辞不同的近义内容,但精确匹配不准,E1024 和 E1025 文档的相似度 0.762 与 0.743 几乎分不开;BM25 按词频、逆文档频率和文档长度打分,罕见词 E1024 权重高,两篇得分 1.088 与 0.130;两路同时召回,分数尺度不同,用 RRF 按排名融合;混合不一定比单路好,口语化问题上等权 RRF 的 Recall@3(0.950)低于单独的向量检索(0.975),BM25 的权重要在评测集上确定。", + "status": "active", + "text": "为什么要把向量检索和关键词检索结合起来?" + }, + "e90dd83642": { + "days": [ + "llm-06" + ], + "points": "幻觉指生成看似合理、但与事实或给定资料不符的内容,分事实性幻觉和忠实性幻觉;根本原因是训练目标是生成通顺的下一个 Token 而不是核对事实,只按答对比例打分的评测又鼓励猜测;此外还有知识缺失、采样出错后顺着错误生成、上下文过长或资料有噪声。", + "status": "active", + "text": "什么是大模型的幻觉?为什么会产生?" + }, + "e93ce780bc": { + "days": [ + "java-22" + ], + "points": "脏读是读到了其它事务还没有提交的数据,对方回滚后读到的就是不存在的值;不可重复读是同一事务两次读同一行结果不同,因为中间有事务修改并提交了这一行;幻读是两次读同一范围时行数变了,因为中间有事务插入了新行。", + "status": "active", + "text": "脏读、不可重复读和幻读有什么区别?" + }, + "ea588f5661": { + "days": [ + "java-44" + ], + "points": "进程是资源分配的基本单位,线程是调度的基本单位,同一进程的线程共享地址空间和文件描述符;创建进程的开销远大于线程,切换进程还要切换地址空间,开销更大;线程间直接读写共享数据,进程间要借助 IPC;进程相互隔离,一个线程段错误会终止整个进程。", + "status": "active", + "text": "进程和线程有什么区别?" + }, + "eab615f7b3": { + "days": [ + "java-50" + ], + "points": "不经过代理:自调用、对象不是 Spring 管理的;无法代理:CGLIB 无法重写 final、static、private 方法,JDK 动态代理只拦截接口中声明的方法;异常没有传到代理:被捕获后未重新抛出;不符合回滚规则:抛出受检异常而没配 rollbackFor;不在事务连接上:新线程中操作数据库、引擎不支持事务(MyISAM)。", + "status": "active", + "text": "哪些情况下 @Transactional 会失效?" + }, + "eb8b8fb51f": { + "days": [ + "java-42" + ], + "points": "二进制分帧:报文拆成 HEADERS 帧和 DATA 帧,一个 TCP 连接承载多个流,帧交错发送,不再需要多个连接;首部压缩:双方维护首部字段表并用 Huffman 编码;服务端推送(Chrome 106 起默认禁用);TCP 层的队头阻塞仍在,HTTP/3 改用基于 UDP 的 QUIC 解决。", + "status": "active", + "text": "HTTP/2 相比 HTTP/1.1 有哪些改进?" + }, + "ec06220d69": { + "days": [ + "java-68" + ], + "points": "普通调用是一问一答,模型根据输入生成文本,不能自行查询数据库或执行操作,外部资料要由应用放进输入;Agent 由模型决定下一步调用哪个工具,应用代码执行后把结果交还模型,如此循环直到任务完成;工具调用的过程:请求中附上工具的名称、用途和参数的 JSON Schema,推理服务按对话模板把它们拼进提示词,模型生成一段特定格式的文本,推理服务解析成 tool_calls 返回,应用执行后用 tool 角色的消息把结果发回;模型始终只输出文本,可能写错参数或编造订单号,所以执行前要", + "status": "active", + "text": "Agent 和普通的大模型调用有什么区别?模型是怎样调用工具的?" + }, + "ed40759232": { + "days": [ + "java-03" + ], + "points": "基本类型用 == 比较值,没有 equals();引用类型的 == 判断两个变量是否引用同一个对象,equals() 判断两个对象是否等价,例如两个 new Integer(1) 用 == 为 false、equals() 为 true。", + "status": "active", + "text": "== 和 equals() 有什么区别?" + }, + "ef0a1e3f79": { + "days": [ + "java-09" + ], + "points": "线程数小于核心线程数时新建线程执行,否则放入队列,队列满且线程数小于最大线程数时新建线程执行当前任务,都不满足就执行拒绝策略;先排队是因为线程比排队的任务昂贵:核心线程承担平时的负载,队列缓冲短时高峰;队列也满了说明负载持续超出处理能力,才临时增加线程,高峰过后空闲的线程被回收。", + "status": "active", + "text": "提交一个任务后,线程池是怎样处理的?为什么先放进队列,队列满了才创建新线程?" + }, + "efa6604d75": { + "days": [ + "sprint-03" + ], + "points": "无锁(Mark Word 末尾 01);轻量级锁(00):线程用 CAS 把 Mark Word 换成指向栈中 Lock Record 的指针,重入只多放一个 Lock Record;重量级锁(10):其它线程 CAS 失败时膨胀为 ObjectMonitor,先自适应自旋,仍抢不到再阻塞;JDK 15 起默认禁用偏向锁,空闲的 ObjectMonitor 会被回收,对象恢复为无锁。", + "status": "active", + "text": "synchronized 的锁升级过程是怎样的?" + }, + "efe411f83c": { + "days": [ + "java-69" + ], + "points": "同一个工具要接入多个 AI 应用时,原本要在每个应用里分别适配;MCP 定义了 AI 应用与外部工具、数据之间的统一接口,工具实现一次服务端就能被所有支持 MCP 的应用使用;三种角色是主机、客户端(一个客户端只连接一个服务端)和服务端,服务端提供工具、资源和提示词模板,本地用 stdio 通信,远程用 Streamable HTTP;Function Calling 是模型一侧的能力,模型根据工具描述给出调用,MCP 是应用与工具之间的协议,解决工具怎样被发现和调用,两者配", + "status": "active", + "text": "MCP 解决了什么问题?它和 Function Calling 有什么区别?" + }, + "f2d13da1ee": { + "days": [ + "llm-10" + ], + "points": "把向量组织成多层图,每个向量连接附近的若干向量,层越高向量越少、连线跨度越大,最下层包含全部向量;查询从最上层的入口开始,上面各层只保留一个最近节点,贪心地走向查询,走不动就下降一层,最下层保留 efSearch 个候选扩展邻居,从中取前 K 个,思路类似跳跃表;上层的长连线让查询几步就到目标附近,12.4 万个向量上每次只算约 1300 次距离(暴力检索的 1.1%);过滤掉大部分向量、大量删除和内存放不下时会变差;M、efConstruction、efSearch 在速度", + "status": "active", + "text": "HNSW 的原理是什么?" + }, + "f2ed00f34c": { + "days": [ + "llm-05" + ], + "points": "需要私有资料或经常更新的知识、要给出依据时用 RAG;要固定风格和格式、学会某种专业任务,提示词仍常出错时用微调;只是补少量规则和示例时用提示词;一般先提示词、再 RAG、最后微调,写进参数的知识难更新、无法给出来源。", + "status": "active", + "text": "什么时候用微调,什么时候用 RAG?" + }, + "f308884a22": { + "days": [ + "java-40" + ], + "points": "强缓存:在 max-age(或 Expires)规定的有效期内直接使用本地缓存,不发请求;协商缓存:缓存过期或响应带 no-cache 时,带上 If-None-Match 或 If-Modified-Since 向服务器确认,没变化返回 304、只传首部;常见做法是 HTML 用 no-cache,带内容哈希的 JS、CSS 设很长的 max-age。", + "status": "active", + "text": "强缓存和协商缓存有什么区别?" + }, + "f311870155": { + "days": [ + "java-25" + ], + "points": "主键索引一张表只有一个,列不能为 NULL,在 InnoDB 中就是聚簇索引;唯一索引的值不能重复,但可以有多个 NULL,普通索引没有唯一约束,两者在 InnoDB 中通常是二级索引;查询速度差别很小,唯一索引等值查询找到一条就停止,普通索引要多读一条;写入时普通索引可以用 change buffer,唯一索引要先读出页检查是否重复。", + "status": "active", + "text": "主键索引、唯一索引和普通索引有什么区别?" + }, + "f319983c2c": { + "days": [ + "llm-04" + ], + "points": "PPO 在线生成回答,奖励模型打分、价值模型给出预期,加载 4 个模型、训练 2 个;DPO 不训练奖励模型、不在训练中生成回答,直接用离线偏好对训练,只需策略模型和参考模型,简单稳定;GRPO 去掉价值模型,每题生成一组回答、以组内平均分为预期,适合能用规则判对错的推理训练;PPO 和 GRPO 能探索出训练数据之外的更好回答。", + "status": "active", + "text": "PPO、DPO 和 GRPO 有什么区别?" + }, + "f38768f09b": { + "days": [ + "java-64" + ], + "points": "虚拟机模拟硬件并运行完整的操作系统,启动慢,占用大量资源,一台机器只能运行几十个;容器是宿主机上用命名空间隔离、用 cgroup 限制资源的普通进程,启动快,一台机器可运行成千上万个;容器共享宿主机内核,更轻量,但隔离性更弱。", + "status": "active", + "text": "Docker 和虚拟机有什么区别?" + }, + "f399525472": { + "days": [ + "java-28" + ], + "points": "InnoDB 支持事务和外键,MyISAM 不支持;MyISAM 只有表锁,InnoDB 还支持行锁;InnoDB 支持在线热备份,MyISAM 崩溃后更容易损坏,恢复也更慢;InnoDB 的主键索引是聚簇索引,MyISAM 的索引都是非聚簇索引,叶子节点保存行的地址。", + "status": "active", + "text": "InnoDB 和 MyISAM 有什么区别?" + }, + "f3cbce1b55": { + "days": [ + "java-10", + "sprint-02" + ], + "points": "synchronized 由 JVM 实现,ReentrantLock 由 JDK 基于 AQS 实现,性能大致相同;ReentrantLock 支持等待可中断、公平锁和绑定多个 Condition,synchronized 都不支持;ReentrantLock 必须在 finally 中 unlock(),synchronized 由 JVM 保证释放;不需要这些功能时优先用 synchronized,但 JDK 21 的虚拟线程在 synchronized 中阻塞会固定载", + "status": "active", + "text": "synchronized 和 ReentrantLock 有什么区别?" + }, + "f5bc9de8b8": { + "days": [ + "java-61" + ], + "points": "金额用整数分表示;二倍均值法:剩余 R 分、还有 k 份时,本份在 1 到 2R ÷ k − 1 分之间均匀随机,最后一份拿走剩余全部,每份至少 1 分;每个位置的期望相同,越靠后波动越大;可以发红包时预先拆好放入 Redis 列表,抢时 LPOP。", + "status": "active", + "text": "抢红包时金额怎样拆分?" + }, + "f62bbcd15d": { + "days": [ + "sprint-14" + ], + "points": "组成:模型决定下一步,工具、记忆、规划提供能力,控制循环执行工具并限制步数、校验参数和权限;Function Calling:请求附上工具的名称、用途和参数 JSON Schema,推理服务拼进提示词,模型需要时生成特定格式的文本,服务解析为 tool_calls 返回;应用校验后执行,以 tool 消息和 tool_call_id 发回结果,本质仍是生成文本,必须先校验。", + "status": "active", + "text": "Agent 由哪些部分组成?Function Calling 的原理是什么?" + }, + "f76f14f53c": { + "days": [ + "java-53" + ], + "points": "Spring Cloud LoadBalancer 默认轮询,也可以改为随机,两者都不考虑机器差异;启用 NacosLoadBalancer 后优先选同集群的实例,再按权重随机(权重 3:1 时 400 次约为 303:97);还可以实现 ReactorServiceInstanceLoadBalancer 自定义规则。", + "status": "active", + "text": "有哪些负载均衡策略?" + }, + "f79e6d406d": { + "days": [ + "java-38" + ], + "points": "请求报文:请求行(方法、URL、协议版本)、请求首部、空行、请求主体;响应报文:状态行(协议版本、状态码、原因短语)、响应首部、空行、响应主体。", + "status": "active", + "text": "HTTP 请求报文和响应报文由哪几部分组成?" + }, + "f806988135": { + "days": [ + "java-21" + ], + "points": "top 找到 CPU 占用高的 Java 进程;top -H -p \\ 找到占用最高的线程 ID;jstack \\ 导出线程栈,按 nid 找到这个线程(JDK 18 前 nid 是十六进制,要先用 printf \"%x\" 转换);查看它正在执行的代码,常见是死循环或频繁 GC。", + "status": "active", + "text": "CPU 占用过高怎样定位到具体的代码?" + }, + "f87401d466": { + "days": [ + "sprint-10" + ], + "points": "握手:A 发 SYN(序号 x),B 回 SYN + ACK(确认号 x + 1、序号 y),A 回 ACK(确认号 y + 1),双方进入 ESTABLISHED,三次是为了让双方的初始序号都得到确认,并防止滞留的旧请求建立连接;挥手:A 发 FIN,B 确认后进入 CLOSE_WAIT 发完数据再发 FIN,A 确认后进入 TIME_WAIT;等 2MSL 一是确保最后的 ACK 能到达(丢失时 B 会重传 FIN),二是让本连接的旧报文从网络中消失。", + "status": "active", + "text": "三次握手和四次挥手的过程是怎样的?为什么 TIME_WAIT 要等 2MSL?" + }, + "f9317cc094": { + "days": [ + "java-19" + ], + "points": "经过多次 Minor GC 后年龄达到 MaxTenuringThreshold;动态年龄判定(以 Serial 为例,G1 和 Parallel 的计算方式不同):Minor GC 结束时从小到大累加 Survivor 中各年龄对象的大小,超过 Survivor 一半时,取这个年龄和 MaxTenuringThreshold 中较小的值作为新阈值,下一次 Minor GC 时大于或等于它的对象晋升;Minor GC 时 Survivor 放不下的存活对象直接进入老年代;大对", + "status": "active", + "text": "对象在什么情况下进入老年代?" + }, + "f9d6fbbf46": { + "days": [ + "java-39" + ], + "points": "HttpOnly 禁止 JavaScript 读取,减少 XSS 窃取;Secure 只通过 HTTPS 发送;SameSite 限制跨站请求携带(Strict 都不带,Lax 只允许顶级导航的 GET,None 都带但必须同时设置 Secure),防范 CSRF;Domain、Path 限定发送范围,Expires、Max-Age 控制有效期。", + "status": "active", + "text": "Cookie 有哪些和安全相关的属性?" + }, + "fa62f2e19c": { + "days": [ + "java-60" + ], + "points": "限流保护自己,拒绝超出处理能力的请求;熔断针对下游,持续失败时暂停调用,避免线程被拖住、故障扩散;降级决定这时返回什么,如缓存、默认值或关闭非核心功能;熔断器失败率达到阈值时打开,打开一段时间后的下一次调用使其进入半开,放行少量试探请求,失败率低于阈值则关闭,否则重新打开。", + "status": "active", + "text": "限流、熔断和降级有什么区别?熔断器怎样从打开恢复到关闭?" + }, + "fa683c8ea1": { + "days": [ + "llm-05" + ], + "points": "模型在新数据上训练后原有能力明显下降,例如只用客服对话微调后写代码变差,1B~7B 模型的实验中模型越大越严重;缓解:混入通用指令数据,用 LoRA、较小的学习率和较少的轮数并早停,训练前后都在通用评测集上评测。", + "status": "active", + "text": "什么是灾难性遗忘?怎样缓解?" + }, + "fb32f47b96": { + "days": [ + "java-32", + "sprint-08" + ], + "points": "RDB 由 fork 出的子进程把某一时刻的全部数据写入紧凑的二进制文件,适合备份、恢复快,但会丢失最后一次快照之后的数据,可能有几分钟;AOF 把写命令追加到文件,everysec 时约丢 1 秒,文件更大,需要定期重写;开启 AOF 后,Redis 5.0 起默认使用混合持久化,重写生成的基础文件用 RDB 格式;数据重要时两种都开启,重启时用 AOF 恢复。", + "status": "active", + "text": "RDB 和 AOF 有什么区别?" + }, + "fb5644c7bf": { + "days": [ + "java-11" + ], + "points": "用共享变量记录轮到哪个线程,没轮到的线程 wait(),轮到的线程打印后改变轮次并 notifyAll();等待条件用 while 而不是 if,被唤醒时要重新检查是否轮到自己;也可以用两个 Semaphore 传递许可,第一个初始为 1、第二个为 0,打印后释放对方的许可。", + "status": "active", + "text": "两个线程交替打印 1~100 怎样实现?" + }, + "fc65cb524a": { + "days": [ + "java-22" + ], + "points": "全局读锁(FLUSH TABLES WITH READ LOCK);表级的表锁、元数据锁(MDL)、意向锁和自增锁;行级的记录锁、间隙锁、Next-Key Lock 和插入意向锁,加在索引上;意向锁 IS、IX 是表锁,加行锁前先加,要锁整张表时只需检查表上有没有意向锁,不必逐行检查。", + "status": "active", + "text": "MySQL 中有哪些锁?意向锁有什么用?" + }, + "fd090d3343": { + "days": [ + "java-38" + ], + "points": "PUT 创建或完全替换目标资源;PATCH 只对资源做部分修改。", + "status": "active", + "text": "PUT 和 PATCH 有什么区别?" + }, + "fd1182bd45": { + "days": [ + "java-28" + ], + "points": "主库把修改写入 binlog,由 binlog dump 线程发给从库;从库的 I/O 线程写入中继日志,SQL 线程读取并重放,8.0.27 起默认 4 个工作线程并行重放;读写分离的问题是默认异步复制带来的复制延迟,刚写完就读从库可能读到旧数据;写后的读和实时性要求高的读走主库,或者写入后记下 GTID,读从库前用 WAIT_FOR_EXECUTED_GTID_SET 等从库执行完,再在新开始的事务或快照中读取,超时就改读主库;监控延迟,延迟过大的从库暂停分配读请求;半同", + "status": "active", + "text": "主从复制的过程是怎样的?读写分离有什么问题?" + }, + "fddac344d2": { + "days": [ + "java-27" + ], + "points": "LIMIT 1000000, 10 要先找出前 1000010 行再丢弃前 100 万行,走二级索引要回表上百万次,优化器改为全表扫描加排序,200 万行约 1.5 秒;延迟关联先在覆盖索引上查出这一页的主键,只回表 10 次,约 81 ms;游标分页记住上一页最后一行的排序值和主键,从它之后取 10 行,约 0.2 ms,但不能跳页;也可以限制翻页深度。", + "status": "active", + "text": "深分页为什么慢?怎样优化?" + } + }, + "routes": { + "java": { + "status": "active", + "title": "Java 后端" + }, + "llm": { + "status": "active", + "title": "大模型应用开发" + }, + "sprint": { + "status": "active", + "title": "面试冲刺" + } + }, + "version": 1 +} diff --git a/learn/content.py b/learn/content.py new file mode 100644 index 000000000..be54df9af --- /dev/null +++ b/learn/content.py @@ -0,0 +1,211 @@ +"""题目和天的稳定编号。 + +读者的复习记录按题目编号保存、完成情况按天的编号保存,所以笔记更新后编号要尽量不变: +- 文字没变的题沿用原编号;改了措辞的题按文字和参考要点的相似度找回原编号(记为修改); +- 新增的题用文字的哈希作编号,与 learn/build.py 早期的规则一致; +- 从路线中删掉的题和天标记为下线,编号不再分配给别的内容,重新加回来时恢复原编号; +- 天先按标题对应,标题改了再按题目的重合程度对应;在路线中间插入一天不会让后面的天换编号。 + +对应关系保存在 content.lock.json 中并提交到仓库,构建只读取它;改了路线后在本机运行 learn/maintain.py 查看变化并写入。 +""" +from __future__ import annotations + +import difflib +import hashlib +import json +import re +from pathlib import Path + +LOCK_VERSION = 1 +PUNCT = re.compile(r'[\s,。、;:?!“”‘’()()《》,.;:?!"\'`·\-—…]+') +# 相似度阈值:题目文字 0.55 以上,或参考要点 0.7 以上,才认为是同一道题改了写法 +TEXT_SIMILAR, POINTS_SIMILAR = 0.55, 0.7 + + +def normalize(text: str) -> str: + return PUNCT.sub('', text).lower() + + +def similarity(a: str, b: str) -> float: + a, b = normalize(a), normalize(b) + if not a or not b: + return 0.0 + return difflib.SequenceMatcher(None, a, b, autojunk=False).ratio() + + +def text_id(text: str, taken=()) -> str: + base = re.sub(r'\s+', '', text) + qid, salt = hashlib.sha1(base.encode()).hexdigest()[:10], 0 + while qid in taken: + salt += 1 + qid = hashlib.sha1(f'{base}#{salt}'.encode()).hexdigest()[:10] + return qid + + +def empty_lock(): + return {'version': LOCK_VERSION, 'questions': {}, 'days': {}, 'routes': {}, 'history': [], 'manual': {'map': {}, 'separate': []}} + + +def load_lock(path: Path): + if not path.is_file(): + return empty_lock() + lock = json.loads(path.read_text()) + if lock.get('version') != LOCK_VERSION: + raise SystemExit(f'{path} 的版本是 {lock.get("version")},当前只支持 {LOCK_VERSION}') + for key, value in empty_lock().items(): + lock.setdefault(key, value) + return lock + + +def resolve(routes, days, questions, lock): + """把解析得到的临时编号换成稳定编号。 + + routes:[{id, stages: [{title, days: [临时天编号]}]}],按路线顺序; + days:{临时天编号: {route, title, questions: [临时题号]}}; + questions:{临时题号: {text, points}},临时题号就是文字的哈希。 + 返回 (题号对照, 天编号对照, 变化, 新的 lock);变化中的编号都是稳定编号。 + """ + old_q, old_d = lock['questions'], lock['days'] + manual = lock.get('manual', {}) + qmap, dmap, claimed_q, claimed_d = {}, {}, set(), set() + edited_q, edited_d = [], [] + + # 1. 题目:文字相同的直接对应;手工指定的对应优先 + by_text = {} + for qid, entry in old_q.items(): + by_text.setdefault(normalize(entry['text']), qid) + for tmp, q in questions.items(): + forced = manual.get('map', {}).get(tmp) + if forced in old_q and forced not in claimed_q: + qmap[tmp] = forced + elif tmp not in manual.get('separate', []): + hit = by_text.get(normalize(q['text'])) + if hit and hit not in claimed_q: + qmap[tmp] = hit + if tmp in qmap: + claimed_q.add(qmap[tmp]) + old = old_q[qmap[tmp]] + # 问法或参考要点有改动都算修改,读者可以在更新说明里看到 + if normalize(old['text']) != normalize(q['text']) or normalize(old.get('points', '')) != normalize(q.get('points', '')[:240]): + edited_q.append(qmap[tmp]) + + # 2. 天:同一路线中标题相同的对应,否则按已经对应上的题目重合度对应 + for route in routes: + rid = route['id'] + pending = [] + candidates = {d: e for d, e in old_d.items() if e['route'] == rid} + for stage in route['stages']: + for tmp in stage['days']: + title = days[tmp]['title'] + hit = next((d for d, e in candidates.items() if d not in claimed_d and e['title'] == title), None) + if hit: + dmap[tmp] = hit + claimed_d.add(hit) + else: + pending.append(tmp) + for tmp in pending: + mine = {qmap[q] for q in days[tmp]['questions'] if q in qmap} + best, best_score = None, 0.0 + for d, e in candidates.items(): + if d in claimed_d: + continue + theirs = set(e.get('questions', [])) + score = len(mine & theirs) / len(mine | theirs) if mine | theirs else 0 + if score > best_score: + best, best_score = d, score + if best and best_score >= 0.5: + dmap[tmp] = best + claimed_d.add(best) + edited_d.append(best) + # 新的天:编号接着这条路线用过的最大序号,下线的编号不复用 + used = [int(m[1]) for d in [*old_d, *dmap.values()] if (m := re.match(rf'^{re.escape(rid)}-(\d+)$', d))] + next_number = max(used, default=0) + 1 + for stage in route['stages']: + for tmp in stage['days']: + if tmp not in dmap: + dmap[tmp] = f'{rid}-{next_number:02d}' + next_number += 1 + + # 3. 题目:剩下的按文字和要点的相似度对应,同一天里的题加分;从最相似的一对开始 + pairs = [] + for tmp, q in questions.items(): + if tmp in qmap or tmp in manual.get('separate', []): + continue + here = {dmap[d] for d in q.get('days', [])} + for qid, entry in old_q.items(): + if qid in claimed_q: + continue + ts, ps = similarity(q['text'], entry['text']), similarity(q.get('points', ''), entry.get('points', '')) + if ts < TEXT_SIMILAR and ps < POINTS_SIMILAR: + continue + bonus = 0.1 if here & set(entry.get('days', [])) else 0 + pairs.append((max(ts, ps * 0.9) + bonus, tmp, qid)) + for _, tmp, qid in sorted(pairs, reverse=True): + if tmp in qmap or qid in claimed_q: + continue + qmap[tmp] = qid + claimed_q.add(qid) + edited_q.append(qid) + + # 4. 新题 + taken = set(old_q) | set(qmap.values()) + added_q = [] + for tmp, q in questions.items(): + if tmp not in qmap: + qmap[tmp] = text_id(q['text'], taken) + taken.add(qmap[tmp]) + added_q.append(qmap[tmp]) + + # 新的 lock:现在的内容都是 active,没对应上的旧条目下线 + new = {'version': LOCK_VERSION, 'questions': {}, 'days': {}, 'routes': {}, 'history': lock.get('history', []), 'manual': manual} + for tmp, q in questions.items(): + qid = qmap[tmp] + new['questions'][qid] = {'text': q['text'], 'points': q.get('points', '')[:240], + 'days': sorted({dmap[d] for d in q.get('days', [])}), 'status': 'active'} + reactivated_q = [qid for qid in new['questions'] if old_q.get(qid, {}).get('status') == 'retired' and qid not in edited_q] + retired_q = [qid for qid, e in old_q.items() if e.get('status') == 'active' and qid not in new['questions']] + for qid in retired_q + [q for q, e in old_q.items() if e.get('status') == 'retired' and q not in new['questions']]: + new['questions'][qid] = {**old_q[qid], 'status': 'retired'} + for route in routes: + for stage in route['stages']: + for tmp in stage['days']: + did = dmap[tmp] + new['days'][did] = {'route': route['id'], 'title': days[tmp]['title'], 'stage': stage['title'], + 'questions': [qmap[q] for q in days[tmp]['questions']], 'status': 'active'} + # 导读和阅读范围的指纹:变了说明这一天要读的内容有调整,学过的读者会看到提示;旧的 lock 中没有指纹时只记下,不算修改 + if days[tmp].get('sig'): + new['days'][did]['sig'] = days[tmp]['sig'] + old_sig = old_d.get(did, {}).get('sig') + if old_sig and old_sig != days[tmp]['sig'] and old_d[did].get('status') == 'active': + edited_d.append(did) + added_d = [d for d in new['days'] if d not in old_d or old_d[d].get('status') == 'retired'] + edited_d = [d for d in edited_d if d in new['days'] and d not in added_d] + retired_d = [d for d, e in old_d.items() if e.get('status') == 'active' and d not in new['days']] + for did, entry in old_d.items(): + if did not in new['days']: + new['days'][did] = {**entry, 'status': 'retired'} + for route in routes: + new['routes'][route['id']] = {'title': route['title'], 'status': 'active'} + for rid, entry in lock.get('routes', {}).items(): + if rid not in new['routes']: + new['routes'][rid] = {**entry, 'status': 'retired'} + current = {r['id'] for r in routes} + retired_r = [r for r, e in lock.get('routes', {}).items() if e.get('status') == 'active' and r not in current] + added_r = [r['id'] for r in routes if lock.get('routes', {}).get(r['id'], {}).get('status') != 'active'] + changes = { + 'added': {'questions': added_q + reactivated_q, 'days': added_d, 'routes': added_r}, + 'edited': {'questions': sorted(set(edited_q)), 'days': sorted(set(edited_d))}, + 'retired': {'questions': retired_q, 'days': retired_d, 'routes': retired_r}, + } + return qmap, dmap, changes, new + + +def has_changes(changes) -> bool: + return any(v for group in changes.values() for v in group.values()) + + +def record(lock, changes, date: str): + """把一次变化记入历史(maintain.py 写入 lock 时调用)。""" + if has_changes(changes): + lock['history'].append({'date': date, **changes}) + return lock diff --git a/learn/maintain.py b/learn/maintain.py new file mode 100644 index 000000000..4c2f9299c --- /dev/null +++ b/learn/maintain.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""学习站的日常维护:改了学习路线或笔记后,查看学习站的内容有哪些变化,确认后写入 content.lock.json。 + +用法(在仓库根目录): + python3 learn/maintain.py 查看变化:新增、修改、下线的题和天,链接问题,没有放进学习站的路线文件 + python3 learn/maintain.py --write 确认无误后写入 lock,并把这次变化记入更新记录(读者在首页看到) + python3 learn/maintain.py --map 新=旧 自动判断错了:把新题(临时编号)当作旧题的修改,沿用旧编号和复习记录 + python3 learn/maintain.py --separate 新 自动判断错了:新题和相似的旧题是两道题,旧题下线 + python3 learn/maintain.py --init 第一次使用:按现在的内容建立 lock,不记更新 + python3 learn/maintain.py --find 题号 处理读者反馈:按题号找到题目、所在的天和路线文件 + python3 learn/maintain.py --baseline 还没发布过时:写入现在的内容并清空更新记录,第一次发布时读者不会看到“内容更新”;编号照常沿用 +--map 和 --separate 写入 lock 的 manual 中,之后的构建都按它处理;可以和 --write 一起用。 +发现问题时退出码为 1(严格检查失败,或有链接、题目问题),可以放在提交前的检查中。 +""" +from __future__ import annotations + +import argparse +import datetime +import json +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) +import build # noqa: E402 +import content # noqa: E402 + + +def describe(changes, old_lock, new_lock, questions, tmp_of): + lines = [] + q_text = lambda lock, q: lock['questions'].get(q, {}).get('text', q) # noqa: E731 + d_text = lambda lock, d: f"{d}({lock['days'].get(d, {}).get('title', '')})" # noqa: E731 + for q in changes['added']['questions']: + state = '恢复' if old_lock['questions'].get(q, {}).get('status') == 'retired' else '新增' + lines.append(f' {state}题目 {q}:{q_text(new_lock, q)}') + for q in changes['edited']['questions']: + old, new = old_lock['questions'][q], new_lock['questions'][q] + same = content.normalize(old['text']) == content.normalize(new['text']) + if same: + lines.append(f' 修改要点 {q}:{new["text"]}') + else: + score = content.similarity(old['text'], new['text']) + lines.append(f' 修改题目 {q}(文字相似度 {score:.2f},新题临时编号 {tmp_of.get(q, "?")}):\n 原:{old["text"]}\n 新:{new["text"]}') + for q in changes['retired']['questions']: + lines.append(f' 下线题目 {q}:{q_text(old_lock, q)}') + for d in changes['added']['days']: + lines.append(f' 新增天 {d_text(new_lock, d)}') + for d in changes['edited']['days']: + old_t, new_t = old_lock['days'][d]['title'], new_lock['days'][d]['title'] + lines.append(f" 修改天 {d}:{old_t} → {new_t}" if old_t != new_t else f" 修改天 {d}({new_t}):导读或阅读范围有调整,学过这一天的读者会看到提示") + for d in changes['retired']['days']: + lines.append(f' 下线天 {d_text(old_lock, d)}') + for r in changes['added']['routes']: + lines.append(f' 新增路线 {r}') + for r in changes['retired']['routes']: + lines.append(f' 下线路线 {r}(读者会看到“这条路线已下线”,记录保留)') + return lines + + +def main(): + parser = argparse.ArgumentParser(description='查看并写入学习站的内容变化') + parser.add_argument('--source', type=Path, default=build.REPO) + parser.add_argument('--lock', type=Path, default=build.LOCK) + parser.add_argument('--routes', type=Path, default=build.CONFIG) + parser.add_argument('--write', action='store_true') + parser.add_argument('--init', action='store_true') + parser.add_argument('--baseline', action='store_true') + parser.add_argument('--find', metavar='题号') + parser.add_argument('--date', default=datetime.date.today().isoformat()) + parser.add_argument('--map', action='append', default=[], metavar='新=旧') + parser.add_argument('--separate', action='append', default=[], metavar='新') + args = parser.parse_args() + + lock = content.load_lock(args.lock) + if args.find: + entry = lock['questions'].get(args.find) + if not entry: + print(f'lock 中没有题号 {args.find}') + return 1 + config = json.loads(args.routes.read_text()) + files = {r['id']: r['file'] for r in config['routes']} + print(f"{args.find}({'在用' if entry['status'] == 'active' else '已下线'}):{entry['text']}") + for did in entry.get('days', []): + d = lock['days'].get(did, {}) + print(f" {did}:{d.get('title', '')},在 notes/{files.get(d.get('route'), '?')}.md 中") + return 0 + for pair in args.map: + new, _, old = pair.partition('=') + lock['manual']['map'][new.strip()] = old.strip() + for tmp in args.separate: + if tmp not in lock['manual']['separate']: + lock['manual']['separate'].append(tmp) + + # 严格检查一遍,再宽松解析一遍收集全部警告 + problems = [] + try: + routes, days, questions, notes = build.parse(args.source, '/#/notes/', config_path=args.routes) + except SystemExit as error: + print(error) + routes, days, questions, notes = build.parse(args.source, '/#/notes/', lenient=True, config_path=args.routes) + problems = notes.warnings + tmp_ids = {q: e for q, e in questions.items()} + old_lock = json.loads(json.dumps(lock)) + routes, days, questions, changes, new_lock = build.stabilize(routes, days, questions, lock) + tmp_of = {} + for tmp, entry in tmp_ids.items(): + stable = next((q for q, e in questions.items() if e['text'] == entry['text']), None) + if stable: + tmp_of[stable] = tmp + + config = json.loads(args.routes.read_text()) + listed = {r['file'] for r in config['routes']} | set(config.get('ignore', [])) + unlisted = sorted(p.stem for p in (args.source / 'notes').glob('学习路线 - *.md') if p.stem not in listed) + + total = len(questions) + print(f'学习站:{len(routes)} 条路线、{len(days)} 天、{total} 道题。') + if args.init: + if old_lock['questions']: + raise SystemExit('lock 中已有内容,不能重新初始化;要重建请先删除 ' + str(args.lock)) + args.lock.write_text(json.dumps(new_lock, ensure_ascii=False, indent=1, sort_keys=True) + '\n') + print(f'已建立 {args.lock.name}。') + return 0 + lines = describe(changes, old_lock, new_lock, questions, tmp_of) + print('与 lock 相比的变化:' if lines else '与 lock 相比没有变化。') + print('\n'.join(lines)) + for name in unlisted: + print(f' 提示:{name}.md 没有放进学习站,需要的话在 learn/routes.json 中加上,不需要的加到 ignore。') + for where, href, why in problems: + print(f' 问题:{where}:{href}({why})') + if args.baseline: + if problems: + print('有问题没有解决,没有写入。') + return 1 + new_lock['history'] = [] + args.lock.write_text(json.dumps(new_lock, ensure_ascii=False, indent=1, sort_keys=True) + '\n') + print(f'已写入 {args.lock.name},更新记录已清空(只在第一次发布前使用)。') + return 0 + if args.write: + if problems: + print('有问题没有解决,没有写入。') + return 1 + content.record(new_lock, changes, args.date) + args.lock.write_text(json.dumps(new_lock, ensure_ascii=False, indent=1, sort_keys=True) + '\n') + print(f'已写入 {args.lock.name}' + (f',更新记录第 {len(new_lock["history"])} 条({args.date})。' if content.has_changes(changes) else '。')) + elif lines: + print('确认无误后运行 python3 learn/maintain.py --write。') + return 1 if problems else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/learn/make_images.py b/learn/make_images.py new file mode 100644 index 000000000..907db4131 --- /dev/null +++ b/learn/make_images.py @@ -0,0 +1,69 @@ +"""生成学习站的图标和链接预览图(src/icons/、src/og.png),改了配色或文字后重新运行:python3 learn/make_images.py""" +from pathlib import Path +from PIL import Image, ImageDraw, ImageFont + +HERE = Path(__file__).resolve().parent +THEME, BG, INK, MUTED, SOFT = (91, 91, 214), (246, 246, 248), (21, 21, 28), (94, 94, 106), (238, 238, 253) +CJK = '/System/Library/Fonts/Hiragino Sans GB.ttc' +LATIN = '/System/Library/Fonts/Helvetica.ttc' + + +def icon(size, maskable=False): + # 绿色底上一张白色题卡,卡上三行“要点”和一个对勾;maskable 版本留出安全区 + scale = 4 + s = size * scale + img = Image.new('RGB', (s, s), THEME) + d = ImageDraw.Draw(img) + pad = s * (0.2 if maskable else 0.14) + if not maskable: + img = Image.new('RGBA', (s, s), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + d.rounded_rectangle([0, 0, s - 1, s - 1], radius=s * 0.22, fill=THEME) + x0, y0, x1, y1 = pad, pad * 1.1, s - pad, s - pad * 1.1 + d.rounded_rectangle([x0, y0, x1, y1], radius=s * 0.07, fill=(255, 255, 255)) + w = x1 - x0 + for i, frac in enumerate((0.62, 0.48, 0.38)): + y = y0 + (y1 - y0) * (0.26 + i * 0.2) + d.rounded_rectangle([x0 + w * 0.14, y, x0 + w * (0.14 + frac), y + s * 0.045], radius=s * 0.0225, fill=SOFT if i else (201, 201, 245)) + cx, cy = x0 + w * 0.72, y0 + (y1 - y0) * 0.7 + d.line([(cx - s * 0.07, cy), (cx - s * 0.02, cy + s * 0.05), (cx + s * 0.08, cy - s * 0.06)], fill=THEME, width=int(s * 0.04), joint='curve') + return img.resize((size, size), Image.LANCZOS) + + +def og(count): + w, h = 1200, 630 + img = Image.new('RGB', (w, h), BG) + d = ImageDraw.Draw(img) + d.rounded_rectangle([40, 40, w - 40, h - 40], radius=28, fill=(255, 255, 255), outline=(228, 228, 234), width=2) + img.paste(icon(96), (96, 96), icon(96)) + d.text((216, 112), 'CS-Notes 学习', font=ImageFont.truetype(CJK, 40), fill=THEME) + d.text((96, 236), '先测水平,再按天学,', font=ImageFont.truetype(CJK, 64), fill=INK) + d.text((96, 322), '记到面试那天', font=ImageFont.truetype(CJK, 64), fill=INK) + d.text((96, 432), f'{count} 道面试题 · 自评定级 · 每日计划 · 间隔复习', font=ImageFont.truetype(CJK, 30), fill=MUTED) + d.text((96, 490), '免费、无需注册,学习记录只保存在自己的浏览器中', font=ImageFont.truetype(CJK, 30), fill=MUTED) + # 右侧一张示意题卡 + x, y = 820, 150 + d.rounded_rectangle([x, y, x + 300, y + 330], radius=20, fill=SOFT) + for i, frac in enumerate((0.8, 0.62, 0.7, 0.45)): + d.rounded_rectangle([x + 32, y + 48 + i * 46, x + 32 + 236 * frac, y + 68 + i * 46], radius=10, fill=(201, 201, 245) if i == 0 else (255, 255, 255)) + for i, (label, color) in enumerate((('不会', (180, 42, 51)), ('模糊', (138, 80, 0)), ('会', THEME))): + bx = x + 24 + i * 88 + d.rounded_rectangle([bx, y + 250, bx + 76, y + 300], radius=12, fill=(255, 255, 255)) + f = ImageFont.truetype(CJK, 24) + tw = d.textlength(label, font=f) + d.text((bx + 38 - tw / 2, y + 260), label, font=f, fill=color) + return img + + +if __name__ == '__main__': + out = HERE / 'src' / 'icons' + out.mkdir(exist_ok=True) + for size in (180, 192, 512): + icon(size).save(out / f'icon-{size}.png', optimize=True) + icon(512, maskable=True).save(out / 'maskable-512.png', optimize=True) + icon(32).save(out / 'favicon-32.png', optimize=True) + # 题数取自构建结果,先运行 build.py + import json + count = len(json.loads((HERE / 'dist' / 'data.json').read_text())['questions']) + og(count).save(HERE / 'src' / 'og.png', optimize=True) + print('done') diff --git a/learn/routes.json b/learn/routes.json new file mode 100644 index 000000000..4db443525 --- /dev/null +++ b/learn/routes.json @@ -0,0 +1,9 @@ +{ + "comment": "学习站使用的路线:file 是 notes/ 下的文件名(不含 .md)。新增路线在这里加一项;下线时删掉对应的项,读者的记录会保留并提示换路线。ignore 中的路线文件不是按天的格式,不放进学习站。", + "routes": [ + {"id": "java", "file": "学习路线 - Java 后端", "title": "Java 后端", "summary": "从 Java 基础到分布式和系统设计,按天读笔记、做自测"}, + {"id": "llm", "file": "学习路线 - 大模型应用开发", "title": "大模型应用开发", "summary": "从模型原理到 RAG、Agent 和工程实践"}, + {"id": "sprint", "file": "学习路线 - 面试冲刺", "title": "面试冲刺", "summary": "两周过一遍后端面试最常问的内容"} + ], + "ignore": ["学习路线 - 零基础"] +} diff --git a/learn/serve.py b/learn/serve.py new file mode 100644 index 000000000..7deb2a843 --- /dev/null +++ b/learn/serve.py @@ -0,0 +1,18 @@ +"""只在本机提供 learn/dist 的预览:python3 learn/serve.py [--port 8770]""" +import argparse +import functools +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +parser = argparse.ArgumentParser() +parser.add_argument('--port', type=int, default=8770) +args = parser.parse_args() +handler = functools.partial(SimpleHTTPRequestHandler, directory=str(Path(__file__).resolve().parent / 'dist')) +server = ThreadingHTTPServer(('127.0.0.1', args.port), handler) +print(f'学习站预览:http://127.0.0.1:{args.port}/', flush=True) +try: + server.serve_forever() +except KeyboardInterrupt: + pass +finally: + server.server_close() diff --git a/learn/src/app.css b/learn/src/app.css new file mode 100644 index 000000000..2c71d51f7 --- /dev/null +++ b/learn/src/app.css @@ -0,0 +1,686 @@ +/* 学习站的样式。颜色、圆角、阴影都是变量,三套配色方案(data-skin)只改变量,深色模式跟随系统 */ +:root { + --bg: #f6f6f8; --surface: #fff; --surface-2: #f1f1f5; --border: #e4e4ea; --border-strong: #cfcfd8; + --text: #3b3b46; --heading: #15151c; --muted: #5e5e6a; --faint: #9a9aa6; --hover: rgba(21, 21, 28, .045); + --accent: #5b5bd6; --accent-hover: #4c4cc4; --accent-soft: #eeeefd; --accent-text: #4747b8; --on-accent: #fff; + --known: #187a42; --known-soft: #e5f5ec; --fuzzy: #8a5000; --fuzzy-soft: #fdf1df; --weak: #b42a33; --weak-soft: #fdebec; + --flame: #f2681c; --flame-soft: #fff0e6; --flame-text: #c2410c; --badge: #cf3a3f; + --radius-sm: 8px; --radius: 12px; --radius-lg: 18px; + --shadow-sm: 0 1px 2px rgba(20, 20, 40, .05); + --shadow: 0 1px 2px rgba(20, 20, 40, .04), 0 4px 14px rgba(20, 20, 40, .05); + --shadow-lg: 0 2px 6px rgba(20, 20, 40, .05), 0 16px 40px rgba(20, 20, 40, .09); + --hero-bg: linear-gradient(135deg, #f3f2ff 0%, #fff 55%); + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + --font-head: var(--font); + --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + --depth: 0px; --depth-color: transparent; --head-weight: 680; + color-scheme: light; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #111114; --surface: #1a1a1f; --surface-2: #22222a; --border: #2c2c35; --border-strong: #3d3d48; + --text: #c9c9d3; --heading: #f1f1f5; --muted: #9393a1; --faint: #6c6c79; --hover: rgba(255, 255, 255, .05); + --accent: #8b8bf0; --accent-hover: #9d9df4; --accent-soft: #23233f; --accent-text: #a9a9f6; --on-accent: #0f0f24; + --known: #4cc584; --known-soft: #16291f; --fuzzy: #e3a64a; --fuzzy-soft: #2c2214; --weak: #f07a80; --weak-soft: #331a1d; + --flame: #ff8a43; --flame-soft: #33201a; --flame-text: #ff9d5c; + --shadow-sm: none; --shadow: 0 1px 0 rgba(255, 255, 255, .03); --shadow-lg: 0 16px 40px rgba(0, 0, 0, .45); + --hero-bg: linear-gradient(135deg, #1e1e36 0%, #1a1a1f 60%); + color-scheme: dark; + } +} +/* 方案二:暖色纸面,衬线标题,像读一本书 */ +[data-skin="paper"] { + --bg: #f5f1ea; --surface: #fffdf8; --surface-2: #efe9df; --border: #e4dccf; --border-strong: #cfc3b1; + --text: #3e3a33; --heading: #1f1b16; --muted: #6a6155; --faint: #a39886; --hover: rgba(60, 45, 20, .05); + --accent: #0f766e; --accent-hover: #0c625b; --accent-soft: #e2f0ec; --accent-text: #0c625b; --on-accent: #fff; + --flame: #e0601a; --flame-soft: #fcebdc; --flame-text: #b23a0a; + --radius-sm: 10px; --radius: 14px; --radius-lg: 22px; + --shadow: 0 1px 2px rgba(80, 60, 30, .05), 0 6px 18px rgba(80, 60, 30, .06); + --shadow-lg: 0 2px 6px rgba(80, 60, 30, .05), 0 18px 44px rgba(80, 60, 30, .1); + --hero-bg: linear-gradient(135deg, #e6f2ee 0%, #fffdf8 60%); + --font-head: "Songti SC", "Noto Serif SC", "Source Han Serif SC", STSong, serif; --head-weight: 700; +} +@media (prefers-color-scheme: dark) { + [data-skin="paper"] { + --bg: #17150f; --surface: #1f1c16; --surface-2: #29251d; --border: #353026; --border-strong: #4a4335; + --text: #d3cbbd; --heading: #f3ede2; --muted: #a39a8a; --faint: #756d5f; --hover: rgba(255, 245, 225, .05); + --accent: #3fb7a9; --accent-hover: #55c4b7; --accent-soft: #15302c; --accent-text: #6fd0c4; --on-accent: #062a26; + --hero-bg: linear-gradient(135deg, #15302c 0%, #1f1c16 60%); + --flame: #ff8a43; --flame-soft: #33201a; --flame-text: #ff9d5c; + --shadow: 0 1px 0 rgba(255, 255, 255, .03); --shadow-lg: 0 16px 40px rgba(0, 0, 0, .45); + } +} +/* 方案三:活泼,厚按钮、大圆角、亮绿色,接近游戏化的学习应用 */ +[data-skin="play"] { + --bg: #f7f9fb; --surface: #fff; --surface-2: #eef3f6; --border: #e1e8ee; --border-strong: #c9d4dd; + --text: #3c4650; --heading: #17222c; --muted: #5d6974; + --accent: #18874a; --accent-hover: #147a42; --accent-soft: #e3f6ea; --accent-text: #146f3a; --on-accent: #fff; + --radius-sm: 12px; --radius: 16px; --radius-lg: 22px; + --shadow: none; --shadow-sm: none; --shadow-lg: 0 10px 30px rgba(20, 50, 80, .08); + --hero-bg: linear-gradient(135deg, #e3f6ea 0%, #fff 65%); + --depth: 4px; --depth-color: #0f5f31; --head-weight: 800; +} +@media (prefers-color-scheme: dark) { + [data-skin="play"] { + --bg: #0f171d; --surface: #16212a; --surface-2: #1d2a35; --border: #263642; --border-strong: #354958; + --text: #c6d2dc; --heading: #f0f5f9; --muted: #8b9ba8; + --accent: #3ccb74; --accent-hover: #52d585; --accent-soft: #143024; --accent-text: #5fdb8f; --on-accent: #052414; + --hero-bg: linear-gradient(135deg, #143024 0%, #16212a 65%); --depth-color: #1f8a4a; + } +} + +/* ---------- 基础 ---------- */ +* { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; scroll-padding-top: 80px; } +body { margin: 0; background: var(--bg); color: var(--text); font: 16px/1.75 var(--font); -webkit-font-smoothing: antialiased; } +a { color: var(--accent-text); text-decoration: none; } +a:hover { text-decoration: underline; text-underline-offset: 3px; } +h1, h2, h3 { color: var(--heading); font-family: var(--font-head); font-weight: var(--head-weight); line-height: 1.3; letter-spacing: -.01em; text-wrap: balance; } +h1 { font-size: 1.9rem; margin: 0 0 .5em; } +h2 { font-size: 1.2rem; margin: 1.6em 0 .6em; } +h3 { font-size: 1.05rem; margin: 1.2em 0 .4em; } +p { margin: .5em 0; } +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; } +[hidden] { display: none !important; } +.i { width: 1.2em; height: 1.2em; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; vertical-align: -.22em; } +.i.sm { width: 1em; height: 1em; vertical-align: -.14em; } +.i .fill { fill: currentColor; stroke: none; } +.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } +.loading, .muted { color: var(--muted); } +.small { font-size: 14px; } +.center { text-align: center; } +.skip { position: fixed; left: 12px; top: -48px; z-index: 50; padding: 8px 14px; border-radius: 8px; background: var(--accent); color: var(--on-accent); } +.skip:focus { top: 12px; } + +/* ---------- 顶栏 ---------- */ +.top { position: sticky; top: 0; z-index: 10; background: var(--bg); background: color-mix(in srgb, var(--bg) 82%, transparent); -webkit-backdrop-filter: saturate(1.6) blur(14px); backdrop-filter: saturate(1.6) blur(14px); border-bottom: 1px solid var(--border); } +.top-inner { display: flex; align-items: center; gap: 20px; max-width: 1120px; margin: 0 auto; padding: 10px 24px; } +.brand { display: inline-flex; align-items: center; gap: 9px; color: var(--heading); font-weight: 700; letter-spacing: -.01em; white-space: nowrap; } +.brand:hover { text-decoration: none; } +.brand .logo { color: var(--accent); flex: none; } +.brand em { font-style: normal; color: var(--accent-text); } +.top nav { display: flex; gap: 2px; margin: 0 auto; padding: 3px; border-radius: 12px; } +.top nav a { position: relative; display: inline-flex; align-items: center; gap: 7px; padding: 7px 13px; border-radius: 9px; color: var(--muted); font-size: 15px; font-weight: 500; } +.top nav a .i { width: 18px; height: 18px; } +.top nav a:hover { background: var(--hover); color: var(--heading); text-decoration: none; } +.top nav a[aria-current="page"] { background: var(--surface); color: var(--heading); box-shadow: var(--shadow-sm), 0 0 0 1px var(--border); } +.top nav a[aria-current="page"] .i { color: var(--accent); } +.badge { display: inline-block; min-width: 19px; padding: 0 6px; border-radius: 10px; background: var(--badge); color: #fff; font-size: 11.5px; line-height: 19px; text-align: center; font-weight: 700; font-variant-numeric: tabular-nums; } +.top-end { display: flex; align-items: center; gap: 6px; } +.streak-chip { display: inline-flex; align-items: center; gap: 4px; padding: 5px 11px 5px 9px; border-radius: 20px; background: var(--surface-2); color: var(--faint); font-weight: 700; font-variant-numeric: tabular-nums; } +.streak-chip:hover { text-decoration: none; background: var(--hover); } +.streak-chip.lit { background: var(--flame-soft); color: var(--flame); } +.streak-chip b { color: var(--heading); } +.notes-link { display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; border-radius: 8px; color: var(--muted); font-size: 15px; } +.notes-link:hover { color: var(--heading); background: var(--hover); text-decoration: none; } + +main { max-width: 1120px; margin: 0 auto; padding: 32px 24px 72px; outline: none; } +/* 换页时把焦点移到 main 上供读屏软件朗读,不显示焦点框 */ +main:focus, main:focus-visible { outline: none; } +main[data-view="plan"], main[data-view="bank"], main[data-view="data"], main[data-view="placement"] { max-width: 860px; } +main[data-view="q"], main[data-view="review"], main[data-view="mock"], main[data-view="new"] { max-width: 760px; } +main > * { animation: enter .28s cubic-bezier(.2, .7, .3, 1) both; } +main > :nth-child(2) { animation-delay: .03s; } +main > :nth-child(3) { animation-delay: .06s; } +@keyframes enter { from { opacity: 0; transform: translateY(6px); } } +.foot { max-width: 1120px; margin: 0 auto; padding: 24px 24px 48px; border-top: 1px solid var(--border); color: var(--muted); font-size: 13px; } +.foot p { margin: 4px 0; } +.foot-brand { color: var(--heading); font-weight: 650; } +.foot a { color: inherit; text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--border-strong); } + +/* ---------- 按钮 ---------- */ +.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px; padding: 8px 18px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); + color: var(--heading); font: inherit; font-size: 15px; font-weight: 600; line-height: 1.3; cursor: pointer; text-decoration: none; box-shadow: var(--shadow-sm), 0 var(--depth) 0 var(--border-strong); + transition: background .15s, border-color .15s, transform .08s, box-shadow .15s; -webkit-tap-highlight-color: transparent; } +.btn:hover { text-decoration: none; background: var(--surface-2); } +.btn:active { transform: translateY(min(var(--depth), 1px)); box-shadow: 0 0 0 var(--border-strong); } +.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); box-shadow: 0 1px 2px rgba(0, 0, 0, .08), 0 var(--depth) 0 var(--depth-color); } +.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); } +.btn.quiet { border-color: transparent; background: none; color: var(--muted); box-shadow: none; font-weight: 500; } +.btn.quiet:hover { color: var(--heading); background: var(--hover); } +.btn.big { min-height: 50px; padding: 10px 24px; font-size: 16px; border-radius: calc(var(--radius-sm) + 2px); } +.btn.sm, .small-btn { min-height: 34px; padding: 4px 12px; font-size: 14px; } +.small-btn { margin-left: 6px; } +.btn .i { width: 18px; height: 18px; } +.btn.primary .i:last-child { transition: transform .15s; } +.btn.primary:hover .i:last-child { transform: translateX(2px); } +.btn:disabled { opacity: .5; cursor: not-allowed; } +.icon-btn { display: inline-grid; place-items: center; width: 40px; height: 40px; border-radius: 10px; color: var(--muted); } +.icon-btn:hover { background: var(--hover); color: var(--heading); } +.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 18px 0 0; } +.actions.inline { margin: 10px 0 18px; } + +/* ---------- 通用组件 ---------- */ +.page-head { margin-bottom: 22px; } +.page-head h1 { margin-bottom: .15em; } +.page-head h1 .small { font-family: var(--font); font-weight: 500; margin-left: 6px; letter-spacing: 0; } +.eyebrow { display: flex; align-items: center; gap: 6px; margin: 0 0 6px; color: var(--accent-text); font-size: 13px; font-weight: 650; letter-spacing: .02em; } +.lead { font-size: 1.1rem; color: var(--text); line-height: 1.7; } +.panel { margin: 16px 0; padding: 20px 22px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); } +.panel > :first-child { margin-top: 0; } +.panel > :last-child { margin-bottom: 0; } +.panel.notice { background: var(--fuzzy-soft); border-color: color-mix(in srgb, var(--fuzzy) 25%, transparent); box-shadow: none; } +.notice-line { padding: 10px 14px; border-radius: var(--radius-sm); background: var(--known-soft); color: var(--text); } +.block { margin: 28px 0; } +.block-head > a.small { padding: 6px 8px; margin: -6px -8px; border-radius: 7px; } +.block-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; } +.block-head h2 { margin: 0; } +details.panel summary { display: flex; align-items: center; gap: 8px; padding: 4px 0; cursor: pointer; color: var(--heading); font-weight: 600; } +details.panel[open] summary { margin-bottom: 8px; } +.tag { display: inline-flex; align-items: center; gap: 3px; padding: 0 8px; border-radius: 6px; font-size: 12px; font-weight: 600; line-height: 22px; white-space: nowrap; background: var(--surface-2); color: var(--muted); } +.tag.learn { background: var(--accent-soft); color: var(--accent-text); } +.tag.mastered { background: var(--known-soft); color: var(--known); } +.tag.partial { background: var(--fuzzy-soft); color: var(--fuzzy); } +.tag.weak, .tag.unknown { background: var(--weak-soft); color: var(--weak); } +.tag.unknown { background: var(--surface-2); color: var(--muted); } +.tag.hot { background: var(--flame-soft); color: var(--flame-text); } +.chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 0; } +.chip-meta { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 20px; background: var(--surface-2); color: var(--muted); font-size: 13px; } +a.chip-meta:hover { color: var(--heading); text-decoration: none; background: var(--hover); } +.bar { height: 6px; border-radius: 6px; background: var(--surface-2); overflow: hidden; } +.bar i { display: block; height: 100%; background: var(--accent); border-radius: 6px; transition: width .4s cubic-bezier(.2, .7, .3, 1); } +.ring { position: relative; width: var(--size); height: var(--size); flex: none; } +.ring svg { width: 100%; height: 100%; transform: rotate(-90deg); } +.ring circle { fill: none; stroke-width: 9; } +.ring .track { stroke: var(--surface-2); } +.ring .value { stroke: var(--accent); stroke-linecap: round; transition: stroke-dasharray .6s cubic-bezier(.2, .7, .3, 1); } +.ring > span { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; line-height: 1.2; } +.ring b { color: var(--heading); font-size: calc(var(--size) * .22); font-weight: 750; font-variant-numeric: tabular-nums; letter-spacing: -.02em; } +.ring small { max-width: 80%; margin-top: 3px; color: var(--muted); font-size: 11.5px; } +.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 16px 0; } +.stat { position: relative; padding: 16px 18px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); } +.stat b { display: block; color: var(--heading); font-size: 1.65rem; font-weight: 750; line-height: 1.2; font-variant-numeric: tabular-nums; letter-spacing: -.02em; } +.stat b small { font-size: .55em; font-weight: 550; color: var(--muted); letter-spacing: 0; } +.stat > span:not(.stat-icon) { color: var(--muted); font-size: 13px; } +.stat-icon { display: inline-grid; place-items: center; width: 32px; height: 32px; margin-bottom: 10px; border-radius: 9px; background: var(--accent-soft); color: var(--accent-text); } +.stat-icon .i { width: 18px; height: 18px; } +.stat.streak .stat-icon { background: var(--surface-2); color: var(--faint); } +.stat.streak.lit .stat-icon { background: var(--flame-soft); color: var(--flame); } +.empty { padding: 48px 20px; text-align: center; } +.empty .actions { justify-content: center; } +.empty-icon { display: inline-grid; place-items: center; width: 64px; height: 64px; margin-bottom: 14px; border-radius: 50%; background: var(--known-soft); color: var(--known); } +.empty-icon .i { width: 30px; height: 30px; stroke-width: 2.2; } +.small-empty { padding: 28px 12px; } +.small-empty .empty-icon { width: 48px; height: 48px; background: var(--surface-2); color: var(--muted); } +.small-empty .empty-icon .i { width: 22px; height: 22px; } +.crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 0; margin-bottom: 14px; color: var(--muted); font-size: 14px; } +.crumbs a { display: inline-flex; align-items: center; gap: 4px; margin-right: 6px; padding: 5px 10px 5px 6px; border-radius: 7px; color: var(--muted); } +.crumbs a:hover { background: var(--hover); color: var(--heading); text-decoration: none; } +.crumbs span + span::before { content: "/"; margin: 0 8px; color: var(--border-strong); } + +/* ---------- 首页 ---------- */ +.home { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 28px; align-items: start; } +.home-side { position: sticky; top: 84px; } +.home-side .stats { grid-template-columns: 1fr 1fr; margin-top: 0; } +.home-side .stat.streak { grid-column: 1 / -1; display: grid; grid-template-columns: auto 1fr; grid-template-rows: auto auto auto; column-gap: 12px; align-items: center; } +.home-side .stat.streak .stat-icon { grid-row: 1 / 3; margin: 0; width: 44px; height: 44px; border-radius: 12px; } +.home-side .stat.streak .stat-icon .i { width: 24px; height: 24px; } +.home-side .stat.streak .week { grid-column: 1 / -1; margin-top: 14px; } +.home-side .stat.streak .streak-note { grid-column: 1 / -1; } +.forecast { display: flex; gap: 8px; margin: 4px 2px 16px; line-height: 1.6; } +.forecast .i { margin-top: 3px; } +.hero { position: relative; display: flex; align-items: center; gap: 24px; margin: 0 0 8px; padding: 28px 30px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--hero-bg); box-shadow: var(--shadow-lg); overflow: hidden; } +.hero-body { flex: 1; min-width: 0; } +.hero h2 { margin: 2px 0 8px; font-size: 1.55rem; } +.hero p { margin: 4px 0; } +.hero .actions { margin-top: 20px; } +.hero-side { flex: none; } +.hero.done { --hero-bg: linear-gradient(135deg, var(--known-soft) 0%, var(--surface) 60%); } +.hero.done .eyebrow { color: var(--known); } +.hero.done .ring .value { stroke: var(--known); } +.hero.celebrate { margin-top: 18px; animation: pop .45s cubic-bezier(.2, 1.4, .4, 1) both; } +@keyframes pop { from { opacity: 0; transform: scale(.96) translateY(6px); } } +.tasks { display: grid; gap: 8px; } +.task { display: flex; align-items: center; gap: 14px; padding: 14px 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); box-shadow: var(--shadow-sm); transition: border-color .15s, transform .15s, box-shadow .15s; } +.task:hover { text-decoration: none; border-color: var(--border-strong); box-shadow: var(--shadow); transform: translateY(-1px); } +.task-check { display: grid; place-items: center; width: 32px; height: 32px; flex: none; border: 2px solid var(--border-strong); border-radius: 50%; color: var(--muted); font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; } +.task.done .task-check { border-color: var(--known); background: var(--known); color: #fff; } +.task.done .task-check .i { stroke-width: 2.6; width: 16px; height: 16px; } +.task-body { display: flex; flex-direction: column; flex: 1; min-width: 0; gap: 3px; } +.task-title { color: var(--heading); font-weight: 600; line-height: 1.45; } +.task.done .task-title { color: var(--muted); text-decoration: line-through; text-decoration-color: var(--border-strong); } +.task-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; color: var(--muted); font-size: 13px; } +.task-meta > span:not(.tag) { display: inline-flex; align-items: center; gap: 4px; } +.task-chev { color: var(--faint); width: 18px; height: 18px; transition: transform .15s; } +.task:hover .task-chev { transform: translateX(2px); color: var(--muted); } +.path { margin: 0; padding: 4px 0; list-style: none; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); } +.path-stage { position: relative; display: flex; gap: 14px; padding: 12px 18px; } +.path-stage + .path-stage { border-top: 1px solid var(--border); } +.path-dot { display: grid; place-items: center; width: 28px; height: 28px; flex: none; margin-top: 1px; border-radius: 50%; background: var(--surface-2); color: var(--muted); font-size: 12.5px; font-weight: 700; } +.path-stage.complete .path-dot { background: var(--known); color: #fff; } +.path-stage.complete .path-dot .i { width: 15px; height: 15px; stroke-width: 2.6; } +.path-stage.current .path-dot { background: var(--accent); color: var(--on-accent); box-shadow: 0 0 0 4px var(--accent-soft); } +.path-more { padding: 6px 18px 6px 60px; border-top: 1px solid var(--border); font-size: 14px; } +.path-more a { display: inline-block; padding: 6px 0; color: var(--muted); } +.path-body { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 6px 14px; flex: 1; min-width: 0; } +.path-title { display: flex; align-items: center; gap: 8px; color: var(--heading); font-weight: 600; font-size: 15px; } +.path-stage:not(.current):not(.complete):not(.started) .path-title { color: var(--muted); font-weight: 500; } +.path-meta { color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; grid-row: 1; grid-column: 2; } +.path-body .bar { grid-column: 1 / -1; height: 5px; } +.path-stage.complete .bar i { background: var(--known); } +.week { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; margin: 0; padding: 0; list-style: none; text-align: center; font-size: 12px; color: var(--muted); } +.week li { display: flex; flex-direction: column; align-items: center; gap: 4px; } +.week i { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; background: var(--surface-2); color: #fff; } +.week i .i { width: 14px; height: 14px; stroke-width: 2.8; } +.week .on i { background: var(--flame); } +.week .now span { color: var(--heading); font-weight: 700; } +.week .now i { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--border-strong); } +.week .now.on i { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px color-mix(in srgb, var(--flame) 45%, transparent); } +.quick { display: grid; margin: 0; padding: 4px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); } +.quick a { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: calc(var(--radius) - 4px); color: var(--heading); font-weight: 600; font-size: 15px; } +.quick a:hover { background: var(--hover); text-decoration: none; } +.quick a > .i:first-child { width: 20px; height: 20px; color: var(--accent-text); } +.quick a > span { display: flex; flex-direction: column; flex: 1; line-height: 1.35; } +.quick small { color: var(--muted); font-weight: 400; font-size: 12.5px; } +.update { border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); background: linear-gradient(135deg, var(--accent-soft), var(--surface) 70%); } +.update h2 { display: flex; align-items: center; gap: 8px; margin: 0 0 4px; font-size: 1.05rem; } +.update h2 .i { color: var(--accent-text); } +.update h2 .small { font-weight: 400; } +.update ul { margin: 6px 0 0; padding-left: 1.2em; } +.update li { margin: 6px 0; } + +/* ---------- 题卡 ---------- */ +.card { margin: 16px 0; padding: 24px 26px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow); transition: border-color .2s, box-shadow .2s; } +.card .meta { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center; margin-bottom: 12px; } +.meta-text { display: inline-flex; align-items: center; gap: 4px; color: var(--muted); font-size: 13px; } +.card .q { margin: 0 0 16px; color: var(--heading); font-size: 1.2rem; font-weight: 650; line-height: 1.55; letter-spacing: -.005em; } +.answer-label { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; margin-bottom: 6px; color: var(--heading); font-size: 14px; font-weight: 600; } +.answer-label span { color: var(--muted); font-weight: 400; font-size: 12.5px; } +textarea { display: block; width: 100%; min-height: 72px; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-2); color: var(--heading); font: inherit; line-height: 1.7; resize: vertical; transition: border-color .15s, background .15s, box-shadow .15s; } +textarea::placeholder { color: var(--muted); opacity: .8; } +textarea:focus { outline: none; border-color: var(--accent); background: var(--surface); box-shadow: 0 0 0 3px var(--accent-soft); } +.card .actions { margin-top: 14px; } +.card .answer { margin-top: 18px; padding: 18px 20px; border-radius: var(--radius); background: var(--surface-2); outline: none; animation: reveal .3s cubic-bezier(.2, .7, .3, 1) both; } +@keyframes reveal { from { opacity: 0; transform: translateY(-4px); } } +.answer-title { display: flex; align-items: center; gap: 6px; margin: 0 0 6px; color: var(--accent-text); font-size: 13px; font-weight: 700; letter-spacing: .02em; } +.card .points { margin: 0; white-space: pre-line; color: var(--text); } +.card .refs { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: 12px 0 0; padding: 0; list-style: none; font-size: 14px; } +.card .refs a { display: inline-flex; align-items: center; gap: 4px; } +.card .refs .i { color: var(--faint); } +.rate-prompt { margin: 18px 0 8px; color: var(--heading); font-size: 14px; font-weight: 600; } +.rate { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; } +.rate button { position: relative; display: flex; align-items: center; gap: 10px; min-height: 58px; padding: 8px 14px; border: 1.5px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--heading); + font: inherit; font-weight: 650; text-align: left; cursor: pointer; box-shadow: 0 var(--depth) 0 var(--border); transition: border-color .15s, background .15s, transform .08s; -webkit-tap-highlight-color: transparent; } +.rate button:active { transform: translateY(min(var(--depth), 1px)) scale(.98); } +.rate-icon { display: grid; place-items: center; width: 30px; height: 30px; flex: none; border-radius: 50%; } +.rate-icon .i { width: 17px; height: 17px; stroke-width: 2.4; } +.rate-text { display: flex; flex-direction: column; line-height: 1.3; } +.rate button small { font-weight: 400; font-size: 12px; color: var(--muted); } +.rate-key { position: absolute; top: 8px; right: 10px; } +.rate .r0 .rate-icon { background: var(--weak-soft); color: var(--weak); } +.rate .r1 .rate-icon { background: var(--fuzzy-soft); color: var(--fuzzy); } +.rate .r2 .rate-icon { background: var(--known-soft); color: var(--known); } +.rate .r0:hover { border-color: var(--weak); background: var(--weak-soft); } +.rate .r1:hover { border-color: var(--fuzzy); background: var(--fuzzy-soft); } +.rate .r2:hover { border-color: var(--known); background: var(--known-soft); } +.card-tools { display: flex; flex-wrap: wrap; gap: 4px; margin: 14px -8px -6px; } +.card-tools .btn { color: var(--muted); } +.card.is-rated { padding-top: 18px; padding-bottom: 18px; } +.card.is-rated .q { margin-bottom: 6px; font-size: 1.05rem; } +.card.rated-2 { border-color: color-mix(in srgb, var(--known) 40%, var(--border)); } +.card.rated-1 { border-color: color-mix(in srgb, var(--fuzzy) 40%, var(--border)); } +.card.rated-0 { border-color: color-mix(in srgb, var(--weak) 40%, var(--border)); } +.rated { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 0; color: var(--muted); font-size: 14px; animation: reveal .3s both; } +.rated .btn { margin-left: auto; } +.rated-icon { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; } +.rated-icon .i { width: 14px; height: 14px; stroke-width: 2.8; } +.rated-2 .rated-icon { background: var(--known); color: #fff; } +.rated-1 .rated-icon { background: var(--fuzzy); color: #fff; } +.rated-0 .rated-icon { background: var(--weak); color: #fff; } +.keys { color: var(--muted); font-size: 13px; text-align: center; } +@media (pointer: coarse) { .keys, .rate-key { display: none; } } +kbd { display: inline-block; min-width: 20px; padding: 2px 6px; border: 1px solid var(--border-strong); border-bottom-width: 2px; border-radius: 5px; background: var(--surface); color: var(--muted); font: 600 11.5px/1.2 var(--mono); text-align: center; } +.progress-line { display: flex; align-items: center; gap: 14px; color: var(--muted); font-size: 14px; font-variant-numeric: tabular-nums; } +.progress-line .bar { flex: 1; } + +/* ---------- 复习与模拟面试 ---------- */ +.session-top { display: flex; align-items: center; gap: 12px; margin: -8px 0 20px; } +.session-top .progress-line { flex: 1; } +.bar.segs { display: flex; gap: 4px; height: 8px; background: none; overflow: visible; } +.bar.segs i { flex: 1; height: 100%; border-radius: 4px; background: var(--surface-2); } +.bar.segs i.done { background: var(--accent); } +.bar.segs i.now { background: var(--accent-soft); background: color-mix(in srgb, var(--accent) 35%, var(--surface-2)); } +.bar.segs.dense { gap: 1px; } +.session-tally { display: flex; gap: 10px; font-size: 14px; font-weight: 700; font-variant-numeric: tabular-nums; } +.session-tally span { display: inline-flex; align-items: center; gap: 3px; } +.session-tally .r2 { color: var(--known); } +.session-tally .r0 { color: var(--weak); } +.session-title { font-size: 1.1rem; color: var(--muted); font-family: var(--font); font-weight: 600; margin: 0 0 4px; } +.session .card { margin-top: 10px; } +.session-summary { display: flex; align-items: center; gap: 28px; margin: 8px 0 18px; padding: 26px 28px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--hero-bg); box-shadow: var(--shadow-lg); animation: pop .45s cubic-bezier(.2, 1.4, .4, 1) both; } +.session-summary h1 { margin: 0 0 12px; font-size: 1.6rem; } +.score { display: flex; flex-wrap: wrap; gap: 8px; } +.score-item { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 10px; background: var(--surface); border: 1px solid var(--border); font-size: 14px; color: var(--muted); } +.score-item b { color: var(--heading); font-size: 1.15rem; font-variant-numeric: tabular-nums; } +.score-item.r2 .i { color: var(--known); } .score-item.r1 .i { color: var(--fuzzy); } .score-item.r0 .i { color: var(--weak); } +.missed { margin: 0; padding: 0; list-style: none; } +.missed a { padding: 4px 0; } +.missed li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 12px; padding: 10px 0; border-bottom: 1px solid var(--border); } +.missed li:last-child { border-bottom: 0; } +.missed li > :first-child { flex: 1 1 260px; color: var(--heading); } +.missed a { display: inline-flex; align-items: center; gap: 4px; } + +/* ---------- 计划 ---------- */ +.plan-stats { margin-top: 0; } +.plan-stats .stat b { font-size: 1.4rem; } +.timeline { position: relative; margin-top: 22px; } +.timeline-stage { display: flex; align-items: center; gap: 8px; margin: 26px 0 8px 112px; color: var(--accent-text); font-size: 13px; font-weight: 700; letter-spacing: .02em; } +.timeline-stage:first-child { margin-top: 0; } +.plan-day { position: relative; display: grid; grid-template-columns: 96px 1fr; gap: 16px; padding: 6px 0; } +.plan-day::before { content: ""; position: absolute; left: 104px; top: 0; bottom: 0; width: 2px; background: var(--border); } +.plan-day::after { content: ""; position: absolute; left: 99px; top: 26px; width: 12px; height: 12px; border-radius: 50%; background: var(--surface); border: 2px solid var(--border-strong); } +.plan-day.today::after { background: var(--accent); border-color: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); } +.plan-day .tasks { padding-left: 16px; } +.plan-date { display: flex; flex-direction: column; align-items: flex-end; padding: 14px 14px 0 0; line-height: 1.3; text-align: right; } +.plan-date time { color: var(--heading); font-weight: 650; font-size: 15px; font-variant-numeric: tabular-nums; } +.plan-week { color: var(--muted); font-size: 12.5px; } +.plan-day.today time { color: var(--accent-text); } + +/* ---------- 每天的内容 ---------- */ +.day-head h1 { font-size: 2rem; } +.guide { max-width: 720px; padding: 16px 20px; border-left: 3px solid var(--accent); border-radius: 0 var(--radius-sm) var(--radius-sm) 0; background: var(--accent-soft); color: var(--text); } +.day-layout { display: grid; grid-template-columns: minmax(0, 1fr) 220px; gap: 40px; align-items: start; margin-top: 8px; } +.day-main { max-width: 760px; } +.step { margin: 32px 0; } +.step-head { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; } +.step-head h2 { margin: 0; flex: 1; } +.step-num { display: grid; place-items: center; width: 36px; height: 36px; flex: none; border-radius: 10px; background: var(--accent-soft); color: var(--accent-text); } +.step-num .i { width: 19px; height: 19px; } +.step-count { padding: 2px 10px; border-radius: 20px; background: var(--surface-2); color: var(--muted); font-size: 13px; font-weight: 650; font-variant-numeric: tabular-nums; } +.sources { display: grid; gap: 8px; margin: 12px 0 0; padding: 0; list-style: none; } +.source { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--heading); font-weight: 600; box-shadow: var(--shadow-sm); transition: border-color .15s, transform .15s; } +.source:hover { text-decoration: none; border-color: var(--accent); transform: translateY(-1px); } +.source > .i:first-child { width: 22px; height: 22px; color: var(--accent-text); } +.source > span:not(.tag) { display: flex; flex-direction: column; flex: 1; min-width: 0; line-height: 1.4; } +.source small { color: var(--muted); font-weight: 400; font-size: 12.5px; } +.source > .i:last-child { color: var(--faint); } +.finish { margin: 36px 0 0; padding-top: 24px; border-top: 1px solid var(--border); } +.finish .actions { margin: 0; } +.pager { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 28px; } +.pager a { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border: 1px solid var(--border); border-radius: var(--radius); color: var(--heading); font-weight: 600; font-size: 14.5px; } +.pager a:hover { text-decoration: none; border-color: var(--border-strong); background: var(--surface); } +.pager a span { display: flex; flex-direction: column; min-width: 0; line-height: 1.4; } +.pager small { color: var(--muted); font-weight: 400; font-size: 12.5px; } +.pager .next { grid-column: 2; justify-content: flex-end; text-align: right; } +.day-side { position: sticky; top: 92px; padding-left: 20px; border-left: 1px solid var(--border); } +.side-title { margin: 0 0 6px; color: var(--muted); font-size: 12.5px; font-weight: 700; letter-spacing: .04em; } +.toc { margin: 0 0 14px; padding: 0; list-style: none; } +.toc button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 8px; margin-left: -8px; border: 0; border-radius: 7px; background: none; color: var(--text); font: inherit; font-size: 14.5px; text-align: left; cursor: pointer; } +.toc button:hover { background: var(--hover); color: var(--heading); } +.toc .i { color: var(--muted); } +.toc-count { margin-left: auto; color: var(--muted); font-size: 12.5px; font-variant-numeric: tabular-nums; } +.day-side .bar { height: 5px; } +.day-side p { margin-top: 8px; } + +/* ---------- 介绍页与设置 ---------- */ +.landing { margin-bottom: 28px; } +.landing-hero { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, .9fr); gap: 48px; align-items: center; padding: 28px 0 36px; } +.landing h1 { font-size: clamp(2rem, 4.2vw, 3.1rem); line-height: 1.15; letter-spacing: -.025em; margin: 6px 0 18px; } +.landing .lead { max-width: 560px; } +.landing .actions { margin-top: 26px; } +.proof { display: flex; gap: 32px; margin: 30px 0 0; padding: 0; list-style: none; color: var(--muted); font-size: 14px; } +.proof b { display: block; color: var(--heading); font-size: 1.6rem; font-weight: 750; line-height: 1.2; font-variant-numeric: tabular-nums; letter-spacing: -.02em; } +.demo { position: relative; padding: 44px 30px 40px 0; } +.demo-card { padding: 22px 24px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-lg); transform: rotate(-1.2deg); } +.demo-meta { display: flex; gap: 10px; align-items: center; margin: 0 0 10px; } +.demo-q { margin: 0 0 14px; color: var(--heading); font-weight: 650; font-size: 1.05rem; line-height: 1.5; } +.demo-lines i { display: block; height: 8px; margin: 8px 0; border-radius: 4px; background: var(--surface-2); } +.demo-lines .short { width: 60%; } +.demo-rate { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 16px; } +.demo-rate span { display: flex; align-items: center; justify-content: center; gap: 4px; flex-wrap: wrap; min-height: 42px; padding: 4px; border: 1.5px solid var(--border); border-radius: 10px; color: var(--heading); font-size: 14px; font-weight: 650; } +.demo-rate small { width: 100%; text-align: center; color: var(--known); font-weight: 500; font-size: 11px; line-height: 1; } +.demo-rate .r0 .i { color: var(--weak); } .demo-rate .r1 .i { color: var(--fuzzy); } .demo-rate .r2 .i { color: var(--known); } +.demo-rate .on { border-color: var(--known); background: var(--known-soft); } +.demo-streak { position: absolute; left: -22px; bottom: 6px; display: flex; align-items: center; gap: 8px; padding: 10px 16px 10px 12px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow-lg); color: var(--flame); font-size: 14px; } +.demo-streak .i { width: 26px; height: 26px; } +.demo-streak span { color: var(--muted); } +.demo-streak b { color: var(--heading); font-size: 1.1rem; } +.demo-ring { position: absolute; right: -6px; top: -36px; padding: 10px; border: 1px solid var(--border); border-radius: 50%; background: var(--surface); box-shadow: var(--shadow-lg); } +.demo-ring .ring small { font-size: 9.5px; } +.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin: 8px 0 18px; padding: 0; list-style: none; } +.steps li { padding: 20px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); } +.step-icon { display: inline-grid; place-items: center; width: 40px; height: 40px; margin-bottom: 12px; border-radius: 11px; background: var(--accent-soft); color: var(--accent-text); } +.steps b { display: block; color: var(--heading); font-size: 1.05rem; } +.steps li > span:last-child { display: block; color: var(--muted); font-size: 14px; line-height: 1.65; } +.landing .center .i { margin-right: 4px; } +.setup-form.panel { padding: 28px 30px; } +.form-title { margin: 0 0 4px; } +fieldset { border: 0; padding: 0; margin: 24px 0; } +legend { display: flex; align-items: center; gap: 10px; padding: 0; margin-bottom: 12px; color: var(--heading); font-weight: 650; } +legend .num { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: var(--accent); color: var(--on-accent); font-size: 12.5px; font-weight: 700; } +.routes { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; } +.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; } +.grid.minutes { grid-template-columns: repeat(4, 1fr); gap: 8px; } +.choice { position: relative; display: block; padding: 16px 18px; border: 1.5px solid var(--border); border-radius: var(--radius); background: var(--surface); cursor: pointer; transition: border-color .15s, background .15s, box-shadow .15s; } +.choice:hover { border-color: var(--border-strong); } +.choice input { position: absolute; inset: 0; z-index: 1; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; } +.choice:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; } +.choice:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); box-shadow: 0 0 0 1px var(--accent); } +.choice strong { display: block; color: var(--heading); } +.choice > span { display: block; color: var(--muted); font-size: 14px; line-height: 1.6; } +.route-choice { padding: 18px 18px 16px; } +.route-choice::after { content: ""; position: absolute; top: 16px; right: 16px; width: 20px; height: 20px; border: 2px solid var(--border-strong); border-radius: 50%; background: var(--surface); } +.route-choice:has(input:checked)::after { border: 6px solid var(--accent); } +.route-icon { display: grid !important; place-items: center; width: 40px; height: 40px; margin-bottom: 12px; border-radius: 11px; background: var(--surface-2); color: var(--accent-text) !important; } +.route-choice:has(input:checked) .route-icon { background: var(--surface); } +.route-icon .i { width: 22px; height: 22px; } +.route-choice strong { font-size: 1.05rem; margin-bottom: 2px; } +.route-meta { margin-top: 10px; color: var(--heading) !important; font-size: 13px !important; font-weight: 600; } +.choice.seg { padding: 12px 8px; text-align: center; } +.row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; } +input[type="date"] { min-height: 42px; padding: 8px 12px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); color: var(--heading); font: inherit; } + +/* 外观选择:色块用各方案自己的变量画,选项本身套上对应方案 */ +.skins { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; } +.skin-choice { padding: 14px 16px; background: var(--surface); } +.swatch { display: flex !important; gap: 4px; margin-bottom: 10px; } +.swatch i { width: 22px; height: 22px; border-radius: 50%; border: 1px solid var(--border); } +.skin-picker { margin: 0; } +@media (max-width: 640px) { .skins { grid-template-columns: 1fr; } } + +/* ---------- 定级 ---------- */ +.stage-dots { display: flex; gap: 6px; margin: 10px 0 0; } +.stage-dots i { width: 8px; height: 8px; border-radius: 50%; background: var(--border-strong); } +.stage-dots i.done { background: var(--accent); } +.stage-dots i.now { width: 22px; border-radius: 4px; background: var(--accent); } +.stage-title { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.stage-title .small { font-family: var(--font); font-weight: 500; } +.result { display: grid; grid-template-columns: 1fr auto; gap: 8px 12px; align-items: center; padding: 12px 0; border-bottom: 1px solid var(--border); } +.result:last-child { border-bottom: 0; } +.result b { color: var(--heading); font-weight: 600; } + +/* ---------- 进度 ---------- */ +.progress-top { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr); gap: 16px; align-items: stretch; } +.progress-top .stats { margin: 0; } +.progress-ring { display: flex; align-items: center; gap: 22px; margin: 0; } +.big-num { color: var(--heading); font-size: 1.8rem; font-weight: 750; letter-spacing: -.02em; } +.big-num small { font-size: .55em; color: var(--muted); font-weight: 550; } +.heat { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 1fr); grid-template-columns: repeat(16, 1fr); gap: 4px; max-width: 520px; margin: 8px 0 4px; } +.heat i, .heat-legend i { display: block; aspect-ratio: 1; border-radius: 4px; background: var(--surface-2); } +.heat i.future { visibility: hidden; } +.heat-legend span { display: inline-flex; align-items: center; gap: 3px; margin-left: 10px; } +.heat-legend i { width: 11px; } +.l1 { background: var(--accent-soft); background: color-mix(in srgb, var(--accent) 28%, var(--surface-2)) !important; } +.l2 { background: var(--accent); background: color-mix(in srgb, var(--accent) 52%, var(--surface-2)) !important; } +.l3 { background: var(--accent); background: color-mix(in srgb, var(--accent) 76%, var(--surface-2)) !important; } +.l4 { background: var(--accent) !important; } +.mastery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr)); column-gap: 32px; } +.mastery { padding: 10px 0; } +.heat-wrap { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 20px 40px; } +.heat-wrap > :first-child { flex: 1 1 360px; max-width: 520px; } +.heat-wrap .heat-legend { flex-basis: 100%; } +.heat-summary { display: grid; gap: 14px; margin: 8px 0 0; flex: 0 0 160px; } +.heat-summary dt { color: var(--muted); font-size: 13px; } +.heat-summary dd { margin: 0; color: var(--heading); font-size: 1.5rem; font-weight: 750; line-height: 1.2; font-variant-numeric: tabular-nums; } +.heat-summary small { font-size: .55em; color: var(--muted); font-weight: 550; } +.mastery-head { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 6px; color: var(--heading); font-weight: 550; } +.stack { display: flex; height: 8px; border-radius: 5px; background: var(--surface-2); overflow: hidden; } +.stack i, .legend i { display: block; height: 100%; } +.stack .solid, .legend .solid { background: var(--accent); } +.stack .learning, .legend .learning { background: var(--accent-soft); background: color-mix(in srgb, var(--accent) 35%, var(--surface-2)); } +.legend i { display: inline-block; width: 10px; height: 10px; margin: 0 4px 0 12px; border-radius: 3px; background: var(--surface-2); vertical-align: -1px; } +.legend i:first-child { margin-left: 0; } +.share { display: flex; flex-wrap: wrap; gap: 28px; align-items: center; } +.share img { width: 240px; height: 320px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-2); box-shadow: var(--shadow-lg); transform: rotate(-1.5deg); } +.share-body { flex: 1 1 280px; } +.share-body h2 { display: flex; align-items: center; gap: 8px; margin: 0 0 6px; } + +/* ---------- 题库 ---------- */ +.tabs { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0; } +.chip { min-height: 36px; padding: 4px 15px; border: 1px solid var(--border); border-radius: 20px; background: var(--surface); color: var(--text); font: inherit; font-size: 14px; font-weight: 500; cursor: pointer; transition: background .15s, border-color .15s; } +.chip:hover { border-color: var(--border-strong); } +.chip[aria-pressed="true"] { background: var(--heading); border-color: var(--heading); color: var(--bg); } +.bank-tools { margin: 14px 0 4px; } +.search { position: relative; } +.search .i { position: absolute; left: 15px; top: 50%; transform: translateY(-50%); color: var(--faint); pointer-events: none; } +.search input { width: 100%; min-height: 48px; padding: 10px 16px 10px 44px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--heading); font: inherit; box-shadow: var(--shadow-sm); } +.search input:focus { border-color: var(--accent); outline: none; box-shadow: 0 0 0 3px var(--accent-soft); } +.bank-stage { font-size: 1rem; margin: 26px 0 8px; } +.bank { margin: 0; padding: 4px; list-style: none; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow-sm); } +.bank li { position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; padding: 9px 12px; border-radius: calc(var(--radius) - 4px); } +.bank li:hover { background: var(--hover); } +.bank li a { flex: 1 1 260px; color: var(--heading); } +.bank li a::after { content: ""; position: absolute; inset: 0; } +.bank li a:hover { text-decoration: none; } +.bank li + li::before { content: ""; position: absolute; top: 0; left: 12px; right: 12px; border-top: 1px solid var(--border); } +.bank li:hover::before, .bank li:hover + li::before { border-color: transparent; } + +/* ---------- 浮层 ---------- */ +.update-bar { position: fixed; left: 50%; bottom: 20px; z-index: 45; display: flex; align-items: center; gap: 14px; transform: translateX(-50%); width: max-content; max-width: calc(100vw - 24px); + padding: 10px 12px 10px 18px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow-lg); font-size: 14px; animation: rise .3s both; } +.toast { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 40; max-width: calc(100vw - 32px); padding: 10px 18px; border-radius: 12px; background: var(--heading); color: var(--bg); font-size: 14px; box-shadow: var(--shadow-lg); animation: rise .25s both; } +@keyframes rise { from { opacity: 0; transform: translate(-50%, 8px); } } +.noscript { padding: 24px; text-align: center; } + +/* ---------- 方案三的厚边框 ---------- */ +[data-skin="play"] .card, [data-skin="play"] .panel, [data-skin="play"] .stat, [data-skin="play"] .task, [data-skin="play"] .path, [data-skin="play"] .quick, +[data-skin="play"] .source, [data-skin="play"] .steps li, [data-skin="play"] .bank, [data-skin="play"] .hero, [data-skin="play"] .session-summary { border-width: 2px; border-bottom-width: 4px; } +[data-skin="play"] .btn:not(.quiet) { border-width: 2px; text-transform: none; } +[data-skin="play"] .btn.primary:active, [data-skin="play"] .rate button:active { transform: translateY(3px); box-shadow: 0 1px 0 var(--depth-color); } +[data-skin="play"] .rate button { border-width: 2px; } +[data-skin="play"] .rate .r0 { box-shadow: 0 4px 0 color-mix(in srgb, var(--weak) 35%, var(--border)); } +[data-skin="play"] .rate .r1 { box-shadow: 0 4px 0 color-mix(in srgb, var(--fuzzy) 35%, var(--border)); } +[data-skin="play"] .rate .r2 { box-shadow: 0 4px 0 color-mix(in srgb, var(--known) 35%, var(--border)); } +[data-skin="play"] .ring circle { stroke-width: 11; } +[data-skin="play"] .top nav a[aria-current="page"] { background: var(--accent-soft); color: var(--accent-text); box-shadow: none; } + +/* ---------- 窄屏 ---------- */ +@media (max-width: 1000px) { + .home { grid-template-columns: 1fr; gap: 8px; } + .home-side { position: static; } + .home-side .stats { grid-template-columns: repeat(3, 1fr); } + .home-side .stat.streak { grid-column: auto; display: block; } + .home-side .stat.streak .stat-icon { width: 32px; height: 32px; margin-bottom: 10px; } + .home-side .stat.streak .week, .home-side .stat.streak .streak-note { display: none; } + .quick { grid-template-columns: 1fr 1fr; } + .day-layout { grid-template-columns: 1fr; } + .day-side { display: none; } + .progress-top { grid-template-columns: 1fr; } +} +@media (max-width: 860px) { + .landing-hero { grid-template-columns: 1fr; gap: 20px; padding-top: 8px; } + .demo { max-width: 460px; margin: 0 auto; padding: 18px 26px 40px; } + .demo-streak { left: 0; } + .demo-ring { right: 0; } +} +@media (max-width: 640px) { + body { padding-bottom: calc(64px + env(safe-area-inset-bottom)); font-size: 15.5px; } + /* backdrop-filter 会让 fixed 的子元素相对顶栏定位,手机上去掉,底部标签栏才能贴到屏幕底部 */ + .top { background: var(--bg); -webkit-backdrop-filter: none; backdrop-filter: none; } + .top-inner { padding: 8px 14px; gap: 8px; justify-content: space-between; } + .brand .logo { width: 22px; height: 22px; } + /* 手机上导航改为底部标签栏,单手可以够到 */ + .top nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; display: grid; grid-template-columns: repeat(5, 1fr); gap: 0; margin: 0; padding: 4px 6px calc(4px + env(safe-area-inset-bottom)); border-radius: 0; + background: var(--surface); background: color-mix(in srgb, var(--surface) 92%, transparent); -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); border-top: 1px solid var(--border); } + .top nav a { flex-direction: column; justify-content: center; gap: 2px; min-height: 52px; padding: 4px 0; font-size: 11.5px; border-radius: 10px; } + .top nav a .i { width: 22px; height: 22px; } + .top nav a[aria-current="page"] { background: none; box-shadow: none; color: var(--accent-text); } + .top nav a[aria-current="page"] .i { stroke-width: 2.2; } + .badge { position: absolute; top: 3px; left: calc(50% + 5px); } + .notes-link { padding: 6px 8px; font-size: 14px; } + main { padding: 20px 16px 56px; } + h1 { font-size: 1.5rem; } + .page-head { margin-bottom: 16px; } + .page-head h1 .small { display: block; margin: 4px 0 0; font-size: 14px; } + .hero { flex-direction: column-reverse; align-items: stretch; gap: 0; padding: 22px 20px; } + .hero-side { position: absolute; top: 18px; right: 16px; } + .hero-side .ring { --size: 64px !important; } + .hero-side .ring small { display: none; } + .hero-body { padding-right: 0; } + .hero .eyebrow, .hero h2 { padding-right: 72px; } + .hero h2 { font-size: 1.3rem; } + .hero .btn.big { width: 100%; } + .stats { gap: 8px; } + .stat { padding: 12px; } + .stat b { font-size: 1.3rem; } + .stat-icon { display: none !important; } + .quick { grid-template-columns: 1fr; } + .card { padding: 18px 16px; border-radius: var(--radius); } + .card .q { font-size: 1.1rem; } + .card .answer { padding: 14px; margin-left: -4px; margin-right: -4px; } + .rate { gap: 8px; } + .rate button { flex-direction: column; justify-content: center; gap: 4px; min-height: 76px; padding: 8px 4px; text-align: center; } + .rate-text { align-items: center; } + .session-summary { flex-direction: column; align-items: flex-start; gap: 16px; padding: 20px; } + .timeline-stage { margin-left: 0; } + .plan-day { grid-template-columns: 58px 1fr; gap: 10px; } + .plan-day::before { left: 66px; } + .plan-day::after { left: 61px; } + .plan-day .tasks { padding-left: 12px; } + .plan-date time { font-size: 14px; } + .plan-stats .stat b { font-size: 1.15rem; } + .d-long { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } + .d-short { display: inline !important; } + .task { padding: 12px; gap: 10px; } + .task-check { width: 28px; height: 28px; } + .landing h1 { font-size: 2rem; } + .proof { gap: 22px; } + .steps { grid-template-columns: 1fr; gap: 8px; } + .steps li { display: grid; grid-template-columns: auto 1fr; column-gap: 14px; padding: 14px 16px; } + .steps .step-icon { grid-row: 1 / 3; margin: 0; } + .setup-form.panel { padding: 20px 16px; margin-left: -4px; margin-right: -4px; } + .grid.minutes { grid-template-columns: repeat(2, 1fr); } + .pager { grid-template-columns: 1fr; } + .pager .next { grid-column: 1; } + .share { justify-content: center; } + .share img { width: 100%; height: auto; max-width: 280px; aspect-ratio: 3 / 4; } + .update-bar { bottom: calc(76px + env(safe-area-inset-bottom)); } + .toast { bottom: calc(80px + env(safe-area-inset-bottom)); } + .demo-ring { display: none; } +} +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; animation: none !important; } } +@media print { .top, .foot, .actions, .rate, .skip, .day-side { display: none !important; } } + +/* 学习记录:导入确认和拖放 */ +.drop-zone { transition: border-color .15s, background .15s; } +.drop-zone.dragging { border-color: var(--accent); border-style: dashed; background: var(--accent-soft); } +.compare { display: grid; gap: 10px; margin: 8px 0 12px; } +.compare dt { color: var(--muted); font-size: 13px; } +.compare dd { margin: 2px 0 0; color: var(--heading); text-wrap: pretty; } +#import-title:focus { outline: none; } +.field-label { display: block; margin: 12px 0 6px; color: var(--heading); font-size: 14px; font-weight: 600; } +#paste-text { font: 13px/1.5 var(--mono); } +.backup-note { margin-top: 18px; } +.backup-note .i { margin-right: 4px; vertical-align: -2px; } +.prose h2 { font-size: 1.05rem; margin: 1.6em 0 .5em; } +.prose ul { padding-left: 1.2em; } +.prose li { margin: 6px 0; } + +/* 连续学习的保护和里程碑 */ +.week .ice i { background: var(--accent-soft); color: var(--accent); } +.week .ice i .i { stroke-width: 2.2; } +.streak-note { margin: 10px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; } +.streak-note .i { margin-right: 4px; vertical-align: -2px; color: var(--accent); } +.streak-note.ice { color: var(--accent); } +.milestone { display: inline-flex; align-items: center; gap: 6px; margin: 10px 0 0; padding: 4px 12px; border-radius: 999px; background: var(--flame-soft, rgba(234, 88, 12, .1)); color: var(--flame-text); font-weight: 700; animation: pop .5s cubic-bezier(.2, 1.4, .4, 1) both; } +.milestone .i { color: var(--flame); } +@keyframes pop { from { transform: scale(.6); opacity: 0; } to { transform: none; opacity: 1; } } + +/* 回答对照 */ +.term-check { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0 0 12px; font-size: 13px; } +.term-check .muted { flex-basis: 100%; } +.term { display: inline-flex; align-items: center; gap: 3px; padding: 1px 9px; border-radius: 999px; font: 12.5px/1.8 var(--mono); } +.term.hit { background: var(--known-soft); color: var(--known); } +.term.miss { background: var(--surface); color: var(--muted); border: 1px dashed var(--border-strong); } +.term .i { width: 12px; height: 12px; } +.d-short { display: none; } diff --git a/learn/src/app.js b/learn/src/app.js new file mode 100644 index 000000000..fdb2f973f --- /dev/null +++ b/learn/src/app.js @@ -0,0 +1,1499 @@ +// 学习站的页面:今天、设置、定级、计划、每天的内容、复习和数据。 +// 所有记录只写入 localStorage,不发出除 data.json 之外的任何请求。 +(function () { + 'use strict'; + var C = window.LearnCore; + var KEY = 'cs-learn-v1'; + var main = document.getElementById('main'); + var data = null, state = null, storageOk = true; + // 当前页面的点击处理:main 上只注册一次监听,换页时替换处理函数 + var onClick = null; + + // ---------- 工具 ---------- + function esc(value) { + return String(value == null ? '' : value).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + // 测试时可以用 ?today=YYYY-MM-DD 模拟日期 + function now() { + var forced = new URLSearchParams(location.search).get('today'); + return C.isDate(forced) ? forced : C.today(); + } + var WEEK = '日一二三四五六'; + function dateLabel(d, withWeek) { + var p = d.split('-').map(Number), date = new Date(p[0], p[1] - 1, p[2]); + return p[1] + ' 月 ' + p[2] + ' 日' + (withWeek ? ' 周' + WEEK[date.getDay()] : ''); + } + function relative(d) { + var n = C.daysBetween(now(), d); + return n <= 0 ? '今天' : n === 1 ? '明天' : n + ' 天后'; + } + // 计划的时间线上用:宽屏写“11 月 2 日”,手机上写“11/2”,读屏软件读完整日期 + function shortDate(d) { + var p = d.split('-').map(Number); + return '' + dateLabel(d) + ''; + } + function ago(d) { + var n = C.daysBetween(d, now()); + return n <= 0 ? '今天' : n === 1 ? '昨天' : n + ' 天前'; + } + var toastTimer; + function toast(text) { + var box = document.getElementById('toast'); + box.textContent = text; + box.hidden = false; + clearTimeout(toastTimer); + toastTimer = setTimeout(function () { box.hidden = true; }, 2600); + } + function route(id) { return data.routes.filter(function (r) { return r.id === id; })[0]; } + function day(id) { return data.days[id]; } + function stageOf(d) { return route(d.route).stages[d.stage]; } + function totalDays(r) { return r.stages.reduce(function (n, s) { return n + s.days.length; }, 0); } + function doneCount(r) { return r.stages.reduce(function (n, s) { return n + s.days.filter(function (d) { return state.done[d]; }).length; }, 0); } + + // ---------- 图标与小部件 ---------- + // 线条图标,24×24,颜色跟随文字;以 < 开头的是完整的元素,其余是 path 的 d + var ICONS = { + home: 'M4 11.5 12 5l8 6.5V20h-5v-5h-6v5H4z', + plan: '', + review: 'M19 12a7 7 0 1 1-2.05-4.95M19 4.5V9h-4.5', + bank: '', + progress: 'M5 19v-6M10 19V9M15 19v-4M20 19V5', + flag: '', + shield: '', + flame: '', + check: 'M5 12.5l4.5 4.5L19 7.5', + x: 'M6.5 6.5l11 11M17.5 6.5l-11 11', + wave: 'M4 13.5c2-3.5 4-3.5 6 0s4 3.5 6 0 3-2.5 4-1.5', + arrow: 'M5 12h14M13 6l6 6-6 6', + back: 'M19 12H5M11 6l-6 6 6 6', + clock: '', + book: 'M12 6.5C10.5 5 8 4.5 4 4.5v13c4 0 6.5.5 8 2 1.5-1.5 4-2 8-2v-13c-4 0-6.5.5-8 2zM12 6.5v13', + quiz: '', + code: 'M8.5 8 4.5 12l4 4M15.5 8l4 4-4 4M13.5 5.5l-3 13', + algo: '', + share: 'M12 4v11M7.5 8.5 12 4l4.5 4.5M5 13v5.5A1.5 1.5 0 0 0 6.5 20h11a1.5 1.5 0 0 0 1.5-1.5V13', + copy: '', + sparkle: 'M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9L12 17.5l-1.9-5.1L5 10.5l5.1-1.9zM18.5 16l.7 1.8 1.8.7-1.8.7-.7 1.8-.7-1.8-1.8-.7 1.8-.7z', + mic: '', + trophy: 'M8 4.5h8v4.5a4 4 0 0 1-8 0zM8 6.5H5.5a2.5 2.5 0 0 0 2.6 3.4M16 6.5h2.5a2.5 2.5 0 0 1-2.6 3.4M12 13v3.5M8.5 20h7M9.5 16.5h5V20h-5z', + search: '', + chev: 'M9.5 6l6 6-6 6', + sliders: '', + download: 'M12 4v11M7.5 10.5 12 15l4.5-4.5M5 19.5h14', + external: 'M14 4.5h5.5V10M19.5 4.5 11 13M17.5 14v4.5a1.5 1.5 0 0 1-1.5 1.5H6a1.5 1.5 0 0 1-1.5-1.5v-10A1.5 1.5 0 0 1 6 7h4.5', + bell: 'M6.5 16.5V11a5.5 5.5 0 0 1 11 0v5.5l1.5 1.5H5zM10 20.5h4', + layers: 'M12 4 3.5 8.5 12 13l8.5-4.5zM3.5 12.5 12 17l8.5-4.5', + target: '', + lock: '', + notes: 'M7 4.5h7.5L18 8v11.5H7zM14 4.5V8.5h4M10 12.5h5M10 16h5' + }; + function icon(name, cls) { + var body = ICONS[name] || ''; + return ''; + } + // 环形进度:value / max,中间写 label + function ring(value, max, label, sub, size) { + size = size || 112; + var r = 44, c = 2 * Math.PI * r, pct = max ? Math.min(1, value / max) : 0; + return ''; + } + // ---------- 配色方案 ---------- + // 方案只存在本机的 localStorage(cs-learn-skin),不进入学习记录;页面加载前由 index.html 中的脚本先应用,避免闪烁 + var SKINS = [['', '清爽', '靛蓝主色、白底细边框,默认', ['#5b5bd6', '#eeeefd', '#f6f6f8']], ['paper', '纸面', '暖白纸色、墨绿主色、衬线标题', ['#0f766e', '#e2f0ec', '#f5f1ea']], + ['play', '活泼', '亮绿主色、厚边框和立体按钮', ['#18874a', '#e3f6ea', '#f7f9fb']]]; + var SKIN_THEME = { '': ['#f6f6f8', '#111114'], paper: ['#f5f1ea', '#17150f'], play: ['#f7f9fb', '#0f171d'] }; + function skin() { var k = document.documentElement.dataset.skin || ''; return SKIN_THEME[k] ? k : ''; } + function setSkin(k) { + if (!SKIN_THEME[k]) k = ''; + document.documentElement.dataset.skin = k; + try { if (k) localStorage.setItem('cs-learn-skin', k); else localStorage.removeItem('cs-learn-skin'); } catch (e) { /* 不能保存时只对当前页面生效 */ } + var metas = document.querySelectorAll('meta[name="theme-color"]'); + if (metas[0]) metas[0].content = SKIN_THEME[k][0]; + if (metas[1]) metas[1].content = SKIN_THEME[k][1]; + } + function skinPicker() { + var current = skin(); + return '
外观
' + SKINS.map(function (x) { + return ''; + }).join('') + '

只影响这个浏览器中的显示,立即生效;深色模式跟随系统。

'; + } + function greeting() { + var h = new Date().getHours(); + return h < 5 ? '夜深了' : h < 11 ? '早上好' : h < 13 ? '中午好' : h < 18 ? '下午好' : '晚上好'; + } + + // ---------- 存储 ---------- + // 本页的代码比记录旧(另一个标签页已经用新版本保存过)时只读,不保存,避免用旧格式覆盖新记录 + var readOnly = false; + function load() { + var raw = null; + try { raw = localStorage.getItem(KEY); } catch (error) { storageOk = false; } + if (!raw) return C.emptyState(); + var parsed = null; + try { parsed = JSON.parse(raw); } catch (error) { parsed = null; } + if (parsed && typeof parsed.version === 'number' && parsed.version > C.STORE_VERSION) { + readOnly = true; + showUpdateBar('学习记录已被新版本的学习站更新,刷新页面后继续使用。'); + return C.emptyState(); + } + try { + var state = C.sanitize(parsed, data); + // 旧版本的记录或下线内容的整理结果立即写回,之后的读取都是新格式 + if (JSON.stringify(state) !== raw) { try { localStorage.setItem(KEY, JSON.stringify(state)); } catch (e) { /* 保存失败时下次再试 */ } } + return state; + } catch (error) { + // 记录损坏时保留原文,避免下一次保存覆盖掉 + try { localStorage.setItem(KEY + '-broken-' + Date.now(), raw); } catch (e) { /* 忽略 */ } + return C.emptyState(); + } + } + function save() { + if (readOnly) { updateBadge(); return; } + try { localStorage.setItem(KEY, JSON.stringify(state)); storageOk = true; askPersist(); } catch (error) { + if (storageOk) toast('浏览器不允许保存记录(可能是隐私模式),关闭页面后记录会丢失'); + storageOk = false; + } + updateBadge(); + } + + // ---------- 计划与复习 ---------- + function currentPlan() { + if (!state.profile) return null; + return C.buildPlan({ route: state.profile.route, minutes: state.profile.minutes, deadline: state.profile.deadline, + start: now(), placement: state.placement, done: state.done }, data); + } + function modes() { + return state.profile ? C.dayModes(route(state.profile.route), data, state.placement) : {}; + } + function due() { return C.dueList(state.cards, data.questions, now()); } + function updateBadge() { + var chip = document.getElementById('streak-chip'); + if (chip) { + var streakDays = state && state.profile ? C.streak(state.log, now(), state.done) : 0; + chip.hidden = !state || !state.profile; + chip.className = 'streak-chip' + (state && C.activeDays(state.log, state.done)[now()] ? ' lit' : ''); + chip.innerHTML = icon('flame') + '' + streakDays + '天连续学习'; + chip.title = '连续学习 ' + streakDays + ' 天'; + } + var badge = document.getElementById('due-badge'), n = state ? due().length : 0; + badge.hidden = !n; + badge.textContent = n > 99 ? '99+' : n; + badge.setAttribute('aria-label', n + ' 道题到期'); + } + function rate(qid, rating) { + var today = now(); + state.cards[qid] = C.schedule(state.cards[qid] || C.newCard(), rating, today); + state.log[today] = (state.log[today] || 0) + 1; + save(); + return state.cards[qid]; + } + + // ---------- 题卡 ---------- + var RATINGS = [['不会', C.FORGOT, 'x'], ['模糊', C.FUZZY, 'wave'], ['会', C.KNOWN, 'check']]; + function cardHtml(q, options) { + options = options || {}; + var card = state.cards[q.id]; + var meta = []; + if (q.heat) meta.push('' + icon('flame', 'sm') + (q.heat > 1 ? '面试最常问' : '面试常问') + ''); + if (options.source) meta.push('' + esc(options.source) + ''); + if (card && card.due && !options.hideDue) meta.push('' + icon('clock', 'sm') + (C.isDue(card, now()) ? '今天复习' : '下次复习:' + relative(card.due)) + ''); + var refs = q.links.map(function (l) { + return '
  • ' + esc(l.text) + icon('external', 'sm') + '
  • '; + }).join(''); + var buttons = RATINGS.map(function (r) { + var next = C.schedule(card || C.newCard(), r[1], now()); + return ''; + }).join(''); + return '
    ' + + (meta.length ? '
    ' + meta.join('') + '
    ' : '') + + '

    ' + esc(q.text) + '

    ' + + '' + + '' + + '
    ' + + '
    '; + } + // 反馈题目:打开 GitHub 的新建议题页,预填题号、题目和要点;只有读者自己提交时才会发出,不含学习记录 + var ISSUES = 'https://github.com/CyC2018/CS-Notes/issues/new'; + function feedbackUrl(q) { + var body = '题号:' + q.id + '\n题目:' + q.text + '\n\n参考要点:\n' + q.points.slice(0, 600) + '\n\n问题(哪里不对、应该怎样):\n'; + return ISSUES + '?title=' + encodeURIComponent('学习站题目反馈:' + q.text.slice(0, 40)) + '&body=' + encodeURIComponent(body); + } + function reveal(cardEl) { + var answer = cardEl.querySelector('.answer'), button = cardEl.querySelector('[data-action="reveal"]'); + termCheck(cardEl); + answer.hidden = false; + button.setAttribute('aria-expanded', 'true'); + button.hidden = true; + // 焦点放在要点上而不是自评按钮上:按空格展开时,松开空格会点到获得焦点的按钮 + answer.focus({ preventScroll: true }); + } + // 写了回答再看要点时,列出要点中的术语哪些提到了、哪些没提到,帮助自评;没写回答时不显示 + function termCheck(cardEl) { + var box = cardEl.querySelector('.term-check'), q = data.questions[cardEl.dataset.q], input = cardEl.querySelector('[data-answer]'); + if (box) box.remove(); + if (!q || !input || input.value.trim().length < 4) return; + var terms = C.keyTerms(q.text, q.points); + if (terms.length < 2) return; + var r = C.compareAnswer(input.value, terms); + // 只对照英文术语和带引号的说法,中文表述说对了同样算,所以没提到时不给分数,只提示留意 + var head = r.hit.length ? '要点里的术语,你的回答提到了 ' + r.hit.length + ' 个:' : '对照要点时留意这些术语(用中文说对了同样算):'; + var html = '

    ' + head + '' + + r.hit.map(function (t) { return '' + icon('check', 'sm') + esc(t) + ''; }).join('') + + r.miss.map(function (t) { return '' + esc(t) + ''; }).join('') + '

    '; + cardEl.querySelector('.answer').insertAdjacentHTML('afterbegin', html); + } + function copyText(text) { + if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text); + return new Promise(function (resolve, reject) { + var area = document.createElement('textarea'); + area.value = text; + area.setAttribute('readonly', ''); + area.style.cssText = 'position:fixed;top:-200px;opacity:0'; + document.body.appendChild(area); + area.select(); + var ok = false; + try { ok = document.execCommand('copy'); } catch (error) { ok = false; } + area.remove(); + (ok ? resolve : reject)(); + }); + } + function copyForAi(cardEl) { + var q = data.questions[cardEl.dataset.q]; + copyText(C.aiPrompt(q, cardEl.querySelector('[data-answer]').value)).then(function () { + toast('已复制,粘贴到你常用的 AI 应用中追问'); + }, function () { toast('复制失败,请检查浏览器的剪贴板权限'); }); + } + // 自评后把题卡换成一行结果;onDone 用于复习页切换到下一题 + function rateCard(cardEl, rating, onDone) { + // 记下自评前的卡片,“重新作答”时撤销这一次 + cardEl._undo = { card: state.cards[cardEl.dataset.q] || null, day: now() }; + var card = rate(cardEl.dataset.q, rating); + cardEl.querySelector('.answer').hidden = true; + cardEl.querySelector('textarea').hidden = true; + cardEl.querySelector('.answer-label').hidden = true; + var note = cardEl.querySelector('.rated'); + note.hidden = false; + cardEl.classList.add('is-rated', 'rated-' + rating); + var actions = cardEl.querySelector('.actions'); + if (actions) actions.hidden = true; + note.innerHTML = '' + icon(RATINGS[rating][2]) + '已记录:' + RATINGS[rating][0] + ',' + esc(relative(card.due)) + '再复习。' + + ''; + if (onDone) onDone(card); + } + function resetCard(cardEl) { + var undo = cardEl._undo, qid = cardEl.dataset.q; + if (undo) { + if (undo.card) state.cards[qid] = undo.card; else delete state.cards[qid]; + if (state.log[undo.day] > 1) state.log[undo.day] -= 1; else delete state.log[undo.day]; + save(); + } + var holder = document.createElement('div'); + holder.innerHTML = cardHtml(data.questions[cardEl.dataset.q], { source: cardEl.dataset.source }); + cardEl.replaceWith(holder.firstChild); + } + // 题卡上的通用操作,各页面共用 + function handleCard(event, onRated) { + var button = event.target.closest('[data-action]'); + var cardEl = button && button.closest('.card'); + if (!cardEl) return false; + var action = button.dataset.action; + if (action === 'reveal') reveal(cardEl); + else if (action === 'copy-ai') copyForAi(cardEl); + else if (action === 'share-q') shareLink('面试题:' + data.questions[cardEl.dataset.q].text, '这道面试题你能答上来吗?' + data.questions[cardEl.dataset.q].text, questionUrl(cardEl.dataset.q)); + else if (action === 'rate') { + var rating = Number(button.dataset.rating); + rateCard(cardEl, rating, onRated && function (card) { onRated(cardEl, card, rating); }); + } + else if (action === 'again') resetCard(cardEl); + else return false; + return true; + } + + // ---------- 页面:设置 ---------- + var MINUTES = [[60, '1 小时'], [90, '1.5 小时'], [120, '2 小时'], [180, '3 小时']]; + var ROUTE_ICON = { java: 'code', llm: 'sparkle', sprint: 'target' }; + function viewSetup() { + var p = state.profile || { route: presetRoute, minutes: 90, deadline: null }; + var routes = data.routes.map(function (r) { + var qn = {}; + r.stages.forEach(function (st) { st.days.forEach(function (id) { day(id).questions.forEach(function (q) { qn[q] = 1; }); }); }); + return ''; + }).join(''); + var minutes = MINUTES.map(function (m) { + return ''; + }).join(''); + var changing = !!state.profile; + var retiredRoute = !changing && state.archive.profile ? (data.retired.routes[state.archive.profile.route] || { title: state.archive.profile.route }) : null; + var sample = data.questions[data.days['sprint-01'].questions[0]]; + var qCount = Object.keys(data.questions).length, dCount = data.routes.reduce(function (n, r) { return n + totalDays(r); }, 0); + var demo = ''; + var intro = '

    ' + icon('sparkle', 'sm') + 'CS-Notes 学习 · 免费 · 无需注册

    先测水平,再按天学,
    记到面试那天

    ' + + '

    ' + qCount + ' 道面试中的真实问题,分为 ' + data.routes.map(function (r) { return r.title; }).join('、') + '三条路线。已经会的跳过,不会的按天学,学过的按间隔复习。

    ' + + '
    先做一道示例题
    ' + + '
    • ' + qCount + '道题
    • ' + data.routes.length + '条路线
    • ' + dCount + '天的内容
    ' + demo + '
    ' + + '
    1. ' + icon('target') + '定级每个阶段两三道题,看参考要点后自评,约 10 分钟
    2. ' + + '
    3. ' + icon('plan') + '按天学已经掌握的内容只做自测,其余按路线顺序排进每天
    4. ' + + '
    5. ' + icon('review') + '间隔复习答“会”的题间隔拉长,答“不会”的明天再来
    ' + + '

    ' + icon('lock', 'sm') + '记录只保存在你的浏览器中(数据说明)。想先看看:一道示例题、面试冲刺第 1 天。

    '; + if (retiredRoute) { + intro = '

    选一条新的路线

    你之前学的“' + esc(retiredRoute.title) + '”路线已经下线。已经练过的题和复习安排都保留着,换一条路线后,路线中相同的题会接着按原来的间隔复习。

    '; + } + return (changing ? '

    ' + icon('sliders', 'sm') + '设置

    调整学习设置

    已完成的天和复习记录保留;更换路线后需要重新定级。

    ' : intro) + + '
    ' + (changing || retiredRoute ? '' : '

    开始之前,选三项

    ') + + '
    1学习路线
    ' + routes + '
    ' + + '
    2每天能用的时间
    ' + minutes + '
    ' + + '
    3面试日期(可选)
    ' + + '
    ' + + '

    设置后,时间不够时先去掉只需自测的天,再去掉面试中问得最少的内容。

    ' + + '
    ' + + (changing ? '取消' : + '' + + '') + + '
    ' + (changing ? '
    ' + skinPicker() + '
    ' : ''); + } + function bindSetup() { + var form = document.getElementById('setup'); + form.addEventListener('click', function (event) { + if (event.target.closest('[data-action="clear-date"]')) form.deadline.value = ''; + }); + form.addEventListener('submit', function (event) { + event.preventDefault(); + var go = event.submitter ? event.submitter.value : 'save'; + var routeId = (form.querySelector('input[name="route"]:checked') || {}).value; + if (!routeId) { toast('先选一条学习路线'); return; } + var deadline = form.deadline.value; + if (deadline && (!C.isDate(deadline) || deadline <= now())) { toast('面试日期要晚于今天'); form.deadline.focus(); return; } + var minutes = Number((form.querySelector('input[name="minutes"]:checked') || {}).value) || 90; + var changedRoute = state.profile && state.profile.route !== routeId; + if (!state.profile && !state.archive.profile && !Object.keys(state.cards).length) state.seenUpdate = latestUpdate(); + state.profile = { route: routeId, minutes: minutes, deadline: deadline || null, start: (state.profile && state.profile.start) || now() }; + state.archive.profile = null; + if (changedRoute || go === 'place') state.placement = C.createPlacement(route(routeId), data); + if (go === 'skip') state.placement = null; + save(); + location.hash = go === 'place' || (changedRoute && go === 'save') ? '#/placement' : '#/plan'; + }); + } + + // ---------- 页面:定级 ---------- + var RESULT = { mastered: ['掌握', '只做自测'], partial: ['部分掌握', '答“会”的天只做自测'], weak: ['薄弱', '按路线学习'], unknown: ['未学', '按路线学习'] }; + function viewPlacement() { + if (!state.profile) return viewSetup(); + if (!state.placement) state.placement = C.createPlacement(route(state.profile.route), data); + var r = route(state.profile.route), next = C.nextPlacementQuestion(state.placement); + if (!next) return viewPlacementResult(); + var progress = C.placementProgress(state.placement), stage = r.stages[next.stage], st = state.placement.stages[next.stage]; + var q = data.questions[next.id]; + var dots = r.stages.map(function (x, i) { var res = C.stageResult(state.placement.stages[i]); return ''; }).join(''); + return '

    ' + icon('target', 'sm') + '定级 · 约 10 分钟

    定级:' + esc(r.title) + '

    ' + + '
    第 ' + (progress.done + 1) + ' / ' + progress.total + ' 个阶段' + + '
    ' + + '' + + '

    ' + icon('layers', 'sm') + esc(stage.title) + '本阶段第 ' + (st.asked.length + 1) + ' 题

    ' + + '

    按真实情况自评:能说出要点中的主要内容算“会”,只记得一部分算“模糊”。

    ' + + cardHtml(q, { hideDue: true }) + + '
    ' + + '
    '; + } + function bindPlacement() { + onClick = function (event) { + var button = event.target.closest('[data-action]'); + if (!button) return; + var action = button.dataset.action; + if (action === 'rate') { + var cardEl = button.closest('.card'), next = C.nextPlacementQuestion(state.placement); + if (!next || next.id !== cardEl.dataset.q) return; + C.answerPlacement(state.placement, next.stage, next.id, Number(button.dataset.rating)); + // 答“会”“模糊”的题加入复习,过几天确认是不是真的记得;答“不会”的还没学,学到那一天时再练 + if (Number(button.dataset.rating) !== C.FORGOT) rate(next.id, Number(button.dataset.rating)); + else { state.log[now()] = (state.log[now()] || 0) + 1; save(); } // 仍算作今天的学习量 + render(); + } else if (action === 'skip-stage') { + C.skipStage(state.placement, Number(button.dataset.stage)); + save(); + render(); + } else if (action === 'finish-placement') { + state.placement.stages.forEach(function (s) { if (!C.stageResult(s)) s.skipped = true; }); + save(); + render(); + } else if (action === 'redo-placement') { + state.placement = C.createPlacement(route(state.profile.route), data); + save(); + render(); + } else { + handleCard(event); + } + }; + } + function viewPlacementResult() { + var r = route(state.profile.route), plan = currentPlan(); + var rows = r.stages.map(function (stage, i) { + var result = C.stageResult(state.placement.stages[i]) || 'unknown'; + return '
    ' + esc(stage.title) + ' ' + stage.days.length + ' 天 · ' + RESULT[result][1] + '' + + '' + RESULT[result][0] + '
    '; + }).join(''); + return '

    ' + icon('check', 'sm') + '定级完成

    定级结果

    ' + + '

    ' + esc(r.title) + '共 ' + totalDays(r) + ' 天:按路线学习 ' + plan.counts.learn + ' 天,已掌握、只做自测 ' + plan.counts.check + ' 天。' + + (plan.dropped.length ? '到面试日期为止时间不够,有 ' + plan.dropped.length + ' 天没有排进计划。' : '') + '

    ' + + '
    ' + rows + '
    ' + + (plan.counts.learn === 0 ? '

    定级显示所有阶段都已掌握,计划中只有自测。自测时答不上的题,记得打开原文读一遍;也可以先做一轮模拟面试,确认自评没有偏乐观。

    ' : '') + + '

    定级中答“会”和“模糊”的题已经加入复习,过几天会再出现;答“不会”的题学到那一天时再练。

    ' + + ''; + } + + // ---------- 页面:今天 ---------- + var MODE = { learn: ['学习', 'learn'], check: ['只做自测', 'mastered'] }; + function planItemHtml(item, d) { + var done = state.done[item.day]; + return '' + + '' + + '第 ' + d.n + ' 天:' + esc(d.title) + (done ? '(已完成)' : '') + '' + + '' + MODE[item.mode][0] + '' + + '' + esc(stageOf(d).title) + '' + icon('clock', 'sm') + '约 ' + item.minutes + ' 分钟' + + icon('chev', 'task-chev') + ''; + } + // 最近 7 天的学习情况,今天在最右边 + // 连续学习下面的一行说明:保护的次数、刚用掉保护、离下一次保护还差几天 + function streakNote(today) { + var info = C.streakInfo(state.log, today, state.done), last = info.used[info.used.length - 1]; + if (!info.days) return '

    学满 ' + C.FREEZE_EVERY + ' 天得到 1 次保护,偶尔中断一天不会清零。

    '; + if (last && C.daysBetween(last, today) <= 2) return '

    ' + icon('shield', 'sm') + dateLabel(last) + '没有学习,用掉 1 次保护,连续天数保住了。

    '; + return '

    ' + icon('shield', 'sm') + (info.freezes ? '有 ' + info.freezes + ' 次保护,中断一天会自动使用' : '再学 ' + (C.FREEZE_EVERY - (info.days % C.FREEZE_EVERY || 0)) + ' 天得到 1 次保护') + '

    '; + } + // 连续天数到了值得纪念的数字时,完成页上多一句 + function milestoneLine() { + var n = C.streak(state.log, now(), state.done); + return C.activeDays(state.log, state.done)[now()] && C.isMilestone(n) ? '

    ' + icon('flame', 'sm') + '连续学习 ' + n + ' 天' + (n >= 30 ? ',已经是习惯了' : n >= 7 ? ',坚持住了一周以上' : '') + '。

    ' : ''; + } + function weekStrip(today) { + var active = C.activeDays(state.log, state.done), frozen = C.streakInfo(state.log, today, state.done).used, cells = ''; + for (var i = 6; i >= 0; i--) { + var d = C.addDays(today, -i), on = !!active[d], ice = frozen.indexOf(d) !== -1, p = d.split('-').map(Number); + var wd = WEEK[new Date(p[0], p[1] - 1, p[2]).getDay()]; + cells += '
  • ' + + '' + dateLabel(d) + (on ? '学习了' : ice ? '没有学习,用了一次保护' : '没有学习') + '
  • '; + } + return '
      ' + cells + '
    '; + } + function latestUpdate() { + var list = data.updates || []; + return list.length ? list[list.length - 1].seq : 0; + } + // 内容更新后,按读者自己的记录说明对他有什么影响;和他无关的更新直接记为已读 + function updatePanel(r) { + var u = C.updateSummary(state, data); + if (!u) return ''; + if (!u.relevant) { state.seenUpdate = u.latest; save(); return ''; } + var items = []; + if (u.fresh.length) items.push('你学过的天里新增了 ' + u.fresh.length + ' 道题 练习新题'); + if (u.newDays.length) { + items.push(r.title + '新增了 ' + u.newDays.length + ' 天:' + u.newDays.slice(0, 3).map(function (id) { var d = day(id); return '第 ' + d.n + ' 天:' + esc(d.title) + ''; }).join('、') + + (u.newDays.length > 3 ? ' 等' : '') + ',已经排进计划'); + } + var qLinks = function (ids) { return ids.slice(0, 3).map(function (id) { return '' + esc(data.questions[id].text) + ''; }).join('、') + (ids.length > 3 ? ' 等' : ''); }; + if (u.edited.length) items.push('你练过的题中有 ' + u.edited.length + ' 道改了问法或参考要点:' + qLinks(u.edited) + '。复习记录不变,下次复习时以新内容为准'); + if (u.changedDays.length) items.push('你学过的 ' + u.changedDays.length + ' 天调整了导读或阅读范围:' + u.changedDays.slice(0, 3).map(function (id) { var d = day(id); return '第 ' + d.n + ' 天:' + esc(d.title) + ''; }).join('、') + + (u.changedDays.length > 3 ? ' 等' : '') + ',有空可以再看一眼,不影响完成状态'); + if (u.missedOlder) items.push('距离你上次打开,学习站更新过多次,更早的调整不再逐条列出;记录都已经按新内容对应好'); + if (u.retired.length) items.push(u.retired.length + ' 道练过的题已下线,复习记录保留,不再安排复习'); + return '

    ' + icon('bell') + '内容更新' + (u.date ? ' ' + dateLabel(u.date) + '' : '') + '

      ' + + items.map(function (t) { return '
    • ' + t + '
    • '; }).join('') + '
    '; + } + // 距离上一次学习过了几天,今天学过或从没学过时为 0 + function lastActive(today) { + var active = C.activeDays(state.log, state.done), days = Object.keys(active).filter(function (d) { return d < today; }).sort(); + return !days.length || active[today] ? 0 : C.daysBetween(days[days.length - 1], today); + } + // 首页最上面只给一个下一步:先定级,再复习,再学今天的内容 + function nextStep(plan, today, dueIds, todays) { + var placing = state.placement && C.nextPlacementQuestion(state.placement); + if (placing) { + var pr = C.placementProgress(state.placement); + return { icon: 'target', title: '继续定级', text: '还剩 ' + (pr.total - pr.done) + ' 个阶段,完成后计划会跳过已经掌握的内容。', href: '#/placement', label: '继续定级' }; + } + // 每天的复习有份额(按每天的时间算):做完份额后先学当天的内容,到期的题积压时不会一直挡住新内容 + var base = C.reviewLimit(state.profile.minutes), limit = base, reviewed = state.log[today] || 0; + // 积压超过两批时,今天的份额加倍(复习两批),先把积压降下来再学新内容,避免越积越多 + if (dueIds.length > base * 2) limit = base * 2; + var next = todays.filter(function (item) { return !state.done[item.day]; })[0]; + if (dueIds.length && (reviewed < limit || !next)) { + var gap = lastActive(today), back = gap >= 3 ? '欢迎回来,上次学习是 ' + gap + ' 天前。' : ''; + var batch = Math.min(dueIds.length, base, reviewed < limit ? Math.max(5, limit - reviewed) : base); + var todayTotal = Math.max(batch, Math.min(dueIds.length, limit - reviewed)), rest = dueIds.length - todayTotal; + var plan = todayTotal > batch ? '积压较多,今天复习 ' + todayTotal + ' 道(分两批),' : ''; + return { icon: 'review', title: '先复习 ' + batch + ' 道题', text: back + '约 ' + Math.max(1, Math.round(batch * 1.5)) + ' 分钟。' + + (rest > 0 ? (next ? plan + '另外 ' + rest + ' 道留到明天,复习完再学今天的内容。' : '共 ' + dueIds.length + ' 道到期,其余的可以接着复习或留到明天。') : plan ? plan.replace(/,$/, '。') : '趁快要忘的时候回忆一次,记得最牢。'), + href: '#/review', label: '开始复习', + // 已经复习完一批、积压还没清时,给出先学今天内容的选择,不让新内容一直被挡住 + // 已经复习完一批、积压还没清时,给出先学今天内容的选择;时间紧的日子可以只复习 5 道,保住连续天数 + alt: next && reviewed >= base ? { href: '#/day/' + next.day, label: '先学第 ' + day(next.day).n + ' 天' } : + batch > QUICK && !reviewed ? { href: '#/review/quick', label: '只有 5 分钟' } : null }; + } + if (next) { + var d = day(next.day); + var backlog = dueIds.length ? '今天的复习份额做完了,还有 ' + dueIds.length + ' 道到期,学完有空可以接着复习,没空就留到明天。' : ''; + return { icon: next.mode === 'check' ? 'quiz' : 'book', minutes: next.minutes, stage: stageOf(d).title, title: '第 ' + d.n + ' 天:' + d.title, + text: (next.mode === 'check' ? '定级显示已经掌握,只做自测,约 ' : '阅读、自测和动手,约 ') + next.minutes + ' 分钟。' + backlog, href: '#/day/' + next.day, label: next.mode === 'check' ? '开始自测' : '开始学习' }; + } + return null; + } + function heroHtml(cls, eyebrow, title, text, actions, side) { + return '

    ' + eyebrow + '

    ' + + '

    ' + title + '

    ' + (text ? '

    ' + text + '

    ' : '') + '
    ' + actions + '
    ' + + (side ? '
    ' + side + '
    ' : '') + '
    '; + } + // 路线的各个阶段:完成的天数和当前所在的阶段 + function stagePath(r, plan) { + var nextDay = plan && plan.days.length ? plan.days[0].items.filter(function (i) { return !state.done[i.day]; })[0] : null; + var current = nextDay ? day(nextDay.day).stage : -1; + // 首页只列到当前阶段之后的两个,其余的在计划页 + var upto = Math.max(current, 0) + 2, rest = r.stages.length - 1 - upto; + return '
      ' + r.stages.map(function (stage, i) { + if (i > upto && rest > 1) return ''; + var n = stage.days.filter(function (id) { return state.done[id]; }).length, all = stage.days.length; + var status = n === all ? 'complete' : i === current ? 'current' : n ? 'started' : ''; + return '
    1. ' + + '' + esc(stage.title) + (status === 'current' ? '进行中' : '') + '' + + '' + + '' + n + ' / ' + all + ' 天
    2. '; + }).join('') + (rest > 1 ? '
    3. 之后还有 ' + rest + ' 个阶段:' + r.stages.slice(upto + 1).map(function (st) { return esc(st.title); }).join('、') + '
    4. ' : '') + '
    '; + } + function viewHome() { + if (!state.profile) return viewSetup(); + var r = route(state.profile.route), plan = currentPlan(), today = now(), dueIds = due(); + var todays = plan.days.length && plan.days[0].date === today ? plan.days[0].items : []; + var finished = doneCount(r), total = totalDays(r), streakDays = C.streak(state.log, today, state.done); + var m = C.mastery(r, data, state.cards); + var solid = m.reduce(function (n, x) { return n + x.solid; }, 0), qTotal = m.reduce(function (n, x) { return n + x.total; }, 0); + var step = nextStep(plan, today, dueIds, todays); + var pct = total ? Math.round(finished / total * 100) : 0, progressRing = ring(finished, total, pct + '%', '已完成 ' + finished + ' / ' + total + ' 天'); + var html = '

    ' + greeting() + ' · ' + esc(r.title) + '

    今天 ' + dateLabel(today, true) + '

    ' + updatePanel(r); + var hero; + if (step) { + // 时长已经写在说明里,标签只写所在的阶段 + var chips = step.stage ? '' + icon('layers', 'sm') + esc(step.stage) + '' : ''; + hero = heroHtml('', icon(step.icon, 'sm') + '下一步', esc(step.title), esc(step.text) + (chips ? '

    ' + chips : ''), + '' + step.label + icon('arrow') + '' + (step.alt ? '' + esc(step.alt.label) + '' : ''), progressRing); + } else if (finished === total) { + hero = heroHtml('done', icon('trophy', 'sm') + '全部完成', esc(r.title) + '的 ' + total + ' 天都学完了', '接下来靠复习把记住的内容保持住,也可以做一轮模拟面试检验一下。', + '模拟面试' + icon('arrow') + '查看进度', progressRing); + } else if (state.profile.deadline && state.profile.deadline <= today) { + hero = heroHtml('', icon('mic', 'sm') + '已到面试日期', '祝面试顺利', '可以做一轮模拟面试热身;面试推迟了的话,在设置中修改日期,计划会重新排。', + '模拟面试' + icon('arrow') + '修改日期', progressRing); + } else { + var tomorrow = plan.days.filter(function (entry) { return entry.date > today; })[0]; + hero = heroHtml('done', icon('check', 'sm') + '今天的任务完成了', streakDays > 1 ? '已经连续学习 ' + streakDays + ' 天' : '明天继续', + tomorrow ? '明天:' + tomorrow.items.map(function (i) { var d = day(i.day); return '第 ' + d.n + ' 天 ' + esc(d.title); }).join('、') + '。想多学一点可以现在开始。' : '', + tomorrow ? '提前学明天的内容模拟面试' : '', progressRing); + } + var mainCol = hero; + // 今天只有一项、而且就是上面的下一步时不再重复列出 + if (todays.length > 1 || (todays.length === 1 && !(step && step.href === '#/day/' + todays[0].day))) { + var left = todays.filter(function (i) { return !state.done[i.day]; }).length; + mainCol += '

    今天的计划

    ' + (left ? '还剩 ' + left + ' 项' : '都完成了') + '
    ' + + '
    ' + todays.map(function (item) { return planItemHtml(item, day(item.day)); }).join('') + '
    '; + } + mainCol += '

    学习路径

    完整计划
    ' + stagePath(r, plan) + '
    '; + var last = plan.days[plan.days.length - 1]; + var side = '
    ' + + '
    ' + icon('flame') + '' + streakDays + '天连续学习' + weekStrip(today) + streakNote(today) + '
    ' + + '
    ' + icon('plan') + '' + finished + ' / ' + total + '天已完成
    ' + + '
    ' + icon('target') + '' + solid + ' / ' + qTotal + '道题已掌握
    '; + if (last && finished < total) { + side += '

    ' + icon('clock', 'sm') + '按现在的计划,' + (plan.dropped.length ? '面试前能学完 ' + (total - plan.dropped.length) + ' / ' + total + ' 天,' : '') + '预计 ' + dateLabel(last.date) + '(' + relative(last.date) + ')学完。没按计划完成时,剩下的内容自动顺延。

    '; + } + side += ''; + return html + '
    ' + mainCol + '
    '; + } + + // ---------- 页面:计划 ---------- + function viewPlan() { + if (!state.profile) return viewSetup(); + var r = route(state.profile.route), plan = currentPlan(), today = now(); + var minutes = MINUTES.filter(function (m) { return m[0] === state.profile.minutes; })[0]; + var last = plan.days[plan.days.length - 1]; + var html = '

    ' + icon('plan', 'sm') + esc(r.title) + '

    学习计划

    ' + + '

    ' + icon('clock', 'sm') + '每天 ' + (minutes ? minutes[1] : state.profile.minutes + ' 分钟') + '' + + (state.profile.deadline ? '' + icon('mic', 'sm') + '面试 ' + dateLabel(state.profile.deadline) + '' : '') + + '' + icon('sliders', 'sm') + '调整

    ' + + '
    ' + (plan.counts.learn + plan.counts.check) + ' 天待学' + (plan.counts.check ? ',其中 ' + plan.counts.check + ' 天只做自测' : '') + '
    ' + + '
    ' + plan.days.length + ' 天排完需要
    ' + + '
    ' + (last ? shortDate(last.date) : '—') + '预计学完' + (last ? '(' + relative(last.date) + ')' : '') + '
    ' + + '

    没按计划完成时,剩下的内容会从今天起顺延。

    ' + + (plan.days.length ? '
    导出 .ics 文件,在手机或电脑的日历中打开,每个计划日一个全天提醒
    ' : ''); + if (plan.dropped.length) { + html += '

    到面试日期为止时间不够,以下 ' + plan.dropped.length + ' 天没有排进计划(先去掉只需自测的天,再去掉面试中问得最少的内容),有空时可以直接打开:

    ' + + plan.dropped.map(function (id) { var d = day(id); return '第 ' + d.n + ' 天:' + esc(d.title) + ''; }).join('、') + '

    '; + } + var lastStage = null; + html += '
    ' + plan.days.map(function (entry) { + var first = day(entry.items[0].day), label = ''; + if (first.stage !== lastStage) { lastStage = first.stage; label = '

    ' + icon('layers', 'sm') + esc(stageOf(first).title) + '

    '; } + var p = entry.date.split('-').map(Number), wd = WEEK[new Date(p[0], p[1] - 1, p[2]).getDay()]; + return label + '
    ' + + entry.items.map(function (item) { return planItemHtml(item, day(item.day)); }).join('') + '
    '; + }).join('') + '
    '; + var done = Object.keys(state.done).filter(function (id) { return day(id) && day(id).route === r.id; }) + .sort(function (a, b) { return day(a).n - day(b).n; }); + if (done.length) { + html += '
    ' + icon('check', 'sm') + '已完成 ' + done.length + ' 天

    ' + + done.map(function (id) { var d = day(id); return '第 ' + d.n + ' 天:' + esc(d.title) + ''; }).join('、') + '

    '; + } + return html; + } + + // ---------- 页面:每天的内容 ---------- + function viewDay(id) { + var d = day(id); + if (!d) { + var gone = data.retired && data.retired.days[id]; + return gone ? '

    ' + esc(gone.title) + '

    路线调整后,这一天的内容已经合并到别的天或下线。完成记录和复习记录都保留着。

    ' + : '

    没有找到这一天

    返回计划

    '; + } + var r = route(d.route), mode = state.profile && state.profile.route === d.route ? modes()[id] : null; + var siblings = [].concat.apply([], r.stages.map(function (s) { return s.days; })), at = siblings.indexOf(id); + var reading = d.reading.map(function (l) { + return '
  • ' + icon('notes') + + '' + esc(l.note || '笔记') + '' + esc(l.text) + '' + (l.heat ? '' + icon('flame', 'sm') + '常问' : '') + icon('external', 'sm') + '
  • '; + }).join(''); + var rated = d.questions.filter(function (q) { return state.cards[q]; }).length; + var steps = [['s-read', 'book', '阅读']].concat([['s-quiz', 'quiz', '自测']], d.practice ? [['s-practice', 'code', '动手']] : [], d.algos.length ? [['s-algo', 'algo', '算法']] : []); + function head(i, sid, name, extra) { + return '

    ' + name + '

    ' + (extra || '') + '
    '; + } + var html = '' + + '

    第 ' + d.n + ' / ' + siblings.length + ' 天

    第 ' + d.n + ' 天:' + esc(d.title) + '

    ' + + '

    ' + (mode === 'check' ? '只做自测' : '') + + '' + icon('book', 'sm') + d.reading.length + ' 个小节' + icon('quiz', 'sm') + d.questions.length + ' 道自测' + + (d.practice ? '' + icon('code', 'sm') + '1 项动手' : '') + (d.algos.length ? '' + icon('algo', 'sm') + d.algos.length + ' 道算法' : '') + '

    ' + + (mode === 'check' ? '

    定级显示这部分已经掌握,直接做下面的自测;有答不上的再读原文。

    ' : '') + + (d.guide ? '

    ' + esc(d.guide) + '

    ' : ''); + var body = '
    ' + head(0, 's-read', '阅读') + '

    ' + esc(d.readingText) + '

      ' + reading + '
    ' + + '
    ' + head(1, 's-quiz', '自测', '' + rated + ' / ' + d.questions.length + '') + + '

    先自己回答,再看参考要点并自评;自评后加入复习。

    ' + + d.questions.map(function (qid) { return cardHtml(data.questions[qid]); }).join('') + '
    '; + var i = 2; + if (d.practice) { + body += '
    ' + head(i++, 's-practice', '动手') + '

    ' + esc(d.practice) + '

    ' + (d.expect ? '
    预期结果

    ' + esc(d.expect) + '

    ' : '') + '
    '; + } + if (d.algos.length) { + body += '
    ' + head(i++, 's-algo', '算法') + '
    '; + } + body += '
    ' + + ''; + var toc = ''; + return html + '
    ' + body + '
    ' + toc + '
    '; + } + function bindDay(id) { + var d = day(id); + function counts() { + var n = d.questions.filter(function (q) { return state.cards[q]; }).length, all = d.questions.length; + var a = document.getElementById('quiz-count'), b = document.getElementById('toc-quiz'), bar = document.getElementById('toc-bar'), st = document.getElementById('toc-state'); + if (a) a.textContent = n + ' / ' + all; + if (b) b.textContent = n + '/' + all; + if (bar) bar.style.width = (n / all * 100) + '%'; + if (st) st.textContent = state.done[id] ? '已完成' : n === all ? '自测都做完了,可以标记完成' : '自评完再标记完成'; + } + onClick = function (event) { + if (handleCard(event)) { + var current = main.querySelector('[data-action="toggle-done"]'); + if (current && !state.done[id]) doneButton(current, id); + counts(); + return; + } + var button = event.target.closest('[data-action="toggle-done"]'); + if (!button) return; + if (state.done[id]) delete state.done[id]; + else state.done[id] = now(); + save(); + // 只更新按钮,不重绘页面,题卡上写了一半的回答和展开的要点都保留 + doneButton(button, id, !!state.done[id]); + if (!state.done[id]) toast('已撤销完成标记'); + counts(); + }; + var button = main.querySelector('[data-action="toggle-done"]'); + if (button) doneButton(button, id); + } + function doneButton(button, id, justDone) { + var done = state.done[id], d = day(id); + button.className = 'btn' + (done ? ' quiet' : ' primary big'); + button.setAttribute('aria-pressed', String(!!done)); + button.innerHTML = done ? icon('check') + '已完成(' + dateLabel(done) + '),撤销' : icon('check') + '完成这一天'; + var note = document.getElementById('done-note'); + if (!note) return; + var unrated = d.questions.filter(function (q) { return !state.cards[q]; }).length; + if (!done) { + note.innerHTML = unrated ? '

    还有 ' + unrated + ' 道自测没有自评,自评后才会加入复习。

    ' : ''; + return; + } + if (!justDone) { note.innerHTML = ''; return; } + // 刚完成时给出进度和下一步,学完一个阶段时单独说明 + var r = route(d.route), stage = stageOf(d), plan = state.profile && state.profile.route === d.route ? currentPlan() : null; + var stageDone = stage.days.every(function (x) { return state.done[x]; }); + var next = null, today = false; + (plan ? plan.days : []).some(function (entry) { + next = entry.items.filter(function (i) { return !state.done[i.day]; })[0]; + today = entry.date === now(); + return !!next; + }); + note.innerHTML = '

    ' + icon(stageDone ? 'trophy' : 'check', 'sm') + (stageDone ? '阶段完成' : '第 ' + d.n + ' 天完成') + '

    ' + + '

    ' + (stageDone ? esc(stage.title) + '的 ' + stage.days.length + ' 天都学完了' : '已完成 ' + doneCount(r) + ' / ' + totalDays(r) + ' 天') + '

    ' + + '

    ' + (unrated ? '还有 ' + unrated + ' 道自测没有自评,自评后才会按间隔复习。' : '今天自评的题会在 1~3 天后出现在复习中。') + + (C.streak(state.log, now(), state.done) > 1 ? '已经连续学习 ' + C.streak(state.log, now(), state.done) + ' 天。' : '') + '

    ' + + milestoneLine() + '
    ' + + '
    ' + ring(doneCount(r), totalDays(r), Math.round(doneCount(r) / totalDays(r) * 100) + '%', '路线进度') + '
    '; + } + + // ---------- 页面:复习与模拟面试 ---------- + // 两者共用一套逐题作答的流程。复习进入时取一次到期列表,按每天的时间分批,答“不会”的题明天再出现; + // 模拟面试按热度和不牢的程度抽 8 道,自评同样计入复习安排 + var sessions = { review: null, mock: null, 'new': null }; + var MOCK_SIZE = 8, QUICK = 5, quickNext = false; + function startSession(kind) { + var queue; + if (kind === 'review') queue = due().slice(0, quickNext ? QUICK : C.reviewLimit(state.profile.minutes)); + else if (kind === 'new') queue = C.unpracticed(route(state.profile.route), data, state).slice(0, C.reviewLimit(state.profile.minutes)); + else queue = C.mockSet(route(state.profile.route), data, state.cards, MOCK_SIZE); + sessions[kind] = { date: now(), queue: queue, index: 0, counts: [0, 0, 0], missed: [] }; + return sessions[kind]; + } + function sessionSummary(kind, session) { + var total = session.queue.length, c = session.counts; + var rest = kind === 'review' ? due().length : kind === 'new' ? C.unpracticed(route(state.profile.route), data, state).length : 0; + var missed = session.missed.map(function (id) { + var q = data.questions[id], l = q.links[0]; + return '
  • ' + esc(q.text) + '' + (l ? ' 读原文' + icon('external', 'sm') + '' : '') + '
  • '; + }).join(''); + var score = Math.round((c[2] + c[1] * 0.5) / total * 100); + var html = '
    ' + ring(c[2] + c[1] * 0.5, total, kind === 'mock' ? String(score) : c[2] + '/' + total, kind === 'mock' ? '分' : '答“会”', 132) + + '

    ' + icon(kind === 'mock' ? 'mic' : 'review', 'sm') + { review: '复习', mock: '模拟面试', 'new': '练习新题' }[kind] + ' · ' + total + ' 道题

    ' + + '

    ' + { review: '这一轮复习完成了', mock: '模拟面试结束', 'new': '新题练完了' }[kind] + '

    ' + + '
    ' + icon('check', 'sm') + '' + c[2] + '会' + icon('wave', 'sm') + '' + c[1] + '模糊' + icon('x', 'sm') + '' + c[0] + '不会
    ' + + (kind === 'mock' ? '

    按“会”计 1 分、“模糊”计半分,这一轮得 ' + score + ' 分。' + (score >= 80 ? '高频题基本都能答上来。' : score >= 50 ? '还有一些题需要补,答不上的题明天会出现在复习中。' : '先把答不上的题对应的原文读一遍,明天在复习中再试。') + '

    ' : + '

    ' + (c[0] ? '答“不会”的题明天再出现,' : '') + '答“会”的题间隔会拉长。' + (kind === 'new' ? (rest ? '还有 ' + rest + ' 道新题。' : '自评过的题已经加入复习。') : rest ? '还有 ' + rest + ' 道到期的题。' : '今天到期的题都复习完了。') + '

    '); + if (missed) html += '

    需要再看的题

      ' + missed + '
    '; + html += milestoneLine(); + html += '
    ' + + (rest ? '先到这里' : + kind === 'mock' ? '返回今天' : + '返回今天') + '
    '; + return html; + } + function viewSession(kind) { + if (!state.profile) return viewSetup(); + var session = sessions[kind]; + // 上一轮已经做完、又从别的页面进来时,开始新的一轮 + if (!session || session.date !== now() || quickNext || (entering && session.index >= session.queue.length)) session = startSession(kind); + quickNext = false; + var total = session.queue.length; + if (!total && kind === 'new') { + return '
    ' + icon('check') + '

    练习新题

    学过的天里没有还没练过的题。

    '; + } + if (!total) { + var upcoming = Object.keys(state.cards).map(function (id) { return state.cards[id].due; }).filter(Boolean).sort(); + return '
    ' + icon('check') + '

    复习

    今天没有到期的题。' + + (upcoming.length ? '下一批在 ' + dateLabel(upcoming[0]) + '(' + relative(upcoming[0]) + '),共 ' + upcoming.filter(function (d) { return d === upcoming[0]; }).length + ' 道。' : + '还没有加入复习的题:定级和每天的自测中自评过的题会自动加入。') + '

    ' + + '

    复习的间隔是 1、3、7、15、30、60 天:答“会”就拉长一级,“模糊”保持并减半,“不会”从 1 天重新开始。

    ' + + '
    '; + } + if (session.index >= total) return sessionSummary(kind, session); + var q = data.questions[session.queue[session.index]]; + var source = q.days.map(function (id) { var d = day(id); return route(d.route).title + ' 第 ' + d.n + ' 天'; })[0]; + var segs = ''; + for (var k = 0; k < total; k++) segs += ''; + return '
    ' + icon('x') + '' + + '
    ' + (session.index + 1) + ' / ' + total + '' + + '
    ' + segs + '
    ' + + '' + icon('check', 'sm') + session.counts[2] + '' + icon('x', 'sm') + session.counts[0] + '
    ' + + '

    ' + { review: '复习', mock: '模拟面试', 'new': '练习新题' }[kind] + '

    ' + + (kind === 'new' && session.index === 0 ? '

    这些题是笔记更新后加进你学过的天里的,自评后和其它题一样按间隔复习。

    ' : '') + + (kind === 'mock' && session.index === 0 ? '

    像在面试中一样,先用一两分钟把答案说出来或写下来,再看参考要点自评。题目按面试中被问到的频率和你还不牢的程度抽取。

    ' : '') + + cardHtml(q, { source: source, hideDue: true }) + + '

    快捷键:空格 看参考要点,1 不会,2 模糊,3 会

    '; + } + function bindSession(kind) { + onClick = function (event) { + if (event.target.closest('[data-action="more"]')) { startSession(kind); render(); main.focus({ preventScroll: true }); window.scrollTo(0, 0); return; } + handleCard(event, function (cardEl, card, rating) { + var session = sessions[kind]; + session.counts[rating] += 1; + if (rating !== C.KNOWN) session.missed.push(cardEl.dataset.q); + session.index += 1; + setTimeout(function () { render(); }, 250); + }); + }; + } + document.addEventListener('keydown', function (event) { + if (['review/', 'mock/', 'new/'].indexOf(currentView) === -1 || event.metaKey || event.ctrlKey || event.altKey || event.isComposing) return; + if (event.target.closest && event.target.closest('textarea, input')) return; + var cardEl = main.querySelector('.card'); + if (!cardEl) return; + var answer = cardEl.querySelector('.answer'); + if ((event.key === ' ' || event.key === 'Enter') && answer.hidden && !cardEl.querySelector('.rated:not([hidden])')) { + event.preventDefault(); + reveal(cardEl); + } else if (!answer.hidden && /^[123]$/.test(event.key)) { + event.preventDefault(); + cardEl.querySelector('[data-rating="' + (Number(event.key) - 1) + '"]').click(); + } + }); + + // ---------- 分享 ---------- + function siteUrl() { return location.origin + location.pathname.replace(/index\.html$/, ''); } + function questionUrl(id) { return siteUrl() + '#/q/' + id; } + // 手机上用系统分享面板(可以直接发到微信等应用),电脑上复制链接更顺手 + function canShareNative() { return !!navigator.share && window.matchMedia('(pointer: coarse)').matches; } + function shareLink(title, text, url) { + if (canShareNative()) { + navigator.share({ title: title, text: text, url: url }).catch(function (error) { + if (error && error.name !== 'AbortError') copyText(text + ' ' + url).then(function () { toast('已复制链接'); }); + }); + return; + } + copyText(text + '\n' + url).then(function () { toast('已复制链接,可以粘贴给朋友'); }, function () { toast('复制失败:' + url); }); + } + + // 分享图片:1080×1440 竖图,适合朋友圈和聊天;全部在本地用 canvas 绘制 + var CARD = { w: 1080, h: 1440 }; + function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); } + function wrapText(g, text, maxWidth) { + var lines = [], line = ''; + Array.from(text).forEach(function (ch) { + if (g.measureText(line + ch).width > maxWidth && line) { lines.push(line); line = ch; } else line += ch; + }); + if (line) lines.push(line); + return lines; + } + // 版面自上而下:标题 120~210、完成天数 250~430、三个数字 470~560、各阶段 610 起,最多到底部文字区上方 40 像素; + // 阶段多时按可用高度缩小行高和字号,保证不会压到底部的说明和二维码 + // 分享图的配色,与 app.css 中各方案的浅色变量一致 + var SKIN_CARD = { + '': { theme: '#5b5bd6', soft: '#e3e3fb', ink: '#15151c', muted: '#5e5e6a', line: '#e4e4ea', bg: '#f6f6f8' }, + paper: { theme: '#0f766e', soft: '#d6ebe6', ink: '#1f1b16', muted: '#6a6155', line: '#e4dccf', bg: '#f5f1ea' }, + play: { theme: '#18874a', soft: '#d5f0de', ink: '#17222c', muted: '#5d6974', line: '#e1e8ee', bg: '#f7f9fb' } + }; + function drawShareCard(canvas) { + var r = route(state.profile.route), today = now(), m = C.mastery(r, data, state.cards); + var finished = doneCount(r), total = totalDays(r), streakDays = C.streak(state.log, today, state.done); + var solid = m.reduce(function (n, x) { return n + x.solid; }, 0), seen = Object.keys(state.cards).length; + canvas.width = CARD.w; canvas.height = CARD.h; + var g = canvas.getContext('2d'), font = '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif'; + // 固定使用当前配色方案的浅色,不随页面的深色模式变化 + var pal = SKIN_CARD[skin()] || SKIN_CARD['']; + var theme = pal.theme, soft = pal.soft, ink = pal.ink, muted = pal.muted, line = pal.line, left = 120, right = CARD.w - 120; + g.textBaseline = 'alphabetic'; + g.fillStyle = pal.bg; g.fillRect(0, 0, CARD.w, CARD.h); + g.fillStyle = '#fff'; roundRect(g, 60, 60, CARD.w - 120, CARD.h - 120, 36); g.fill(); + g.fillStyle = theme; g.font = '600 34px ' + font; g.fillText('CS-Notes 学习', left, 150); + g.fillStyle = muted; g.font = '30px ' + font; g.fillText(dateLabel(today) + ' · ' + r.title, left, 198); + g.fillStyle = ink; g.font = '700 132px ' + font; g.fillText(String(finished), left, 360); + var w = g.measureText(String(finished)).width; + g.fillStyle = muted; g.font = '500 48px ' + font; g.fillText('/ ' + total + ' 天', left + w + 18, 360); + g.font = '30px ' + font; g.fillText('已完成的学习天数', left, 408); + g.fillStyle = line; roundRect(g, left, 432, right - left, 16, 8); g.fill(); + g.fillStyle = theme; roundRect(g, left, 432, Math.max(16, (right - left) * finished / total), 16, 8); g.fill(); + [[streakDays, '天连续学习'], [solid, '道题已掌握'], [seen, '道题练过']].forEach(function (item, i) { + var x = left + i * 290; + g.fillStyle = ink; g.font = '700 64px ' + font; g.fillText(String(item[0]), x, 540); + g.fillStyle = muted; g.font = '28px ' + font; g.fillText(item[1], x, 582); + }); + var qrSize = 220, qrX = right - qrSize, footTop = CARD.h - 120 - qrSize - 30; + g.fillStyle = ink; g.font = '600 34px ' + font; g.fillText('各阶段掌握', left, 650); + var top = 676, bottom = footTop - 40; + if (m.length <= 6) { + // 阶段少:一行一个,名称在左、进度条在中间、数字在右 + var rowH = Math.min(58, (bottom - top) / m.length), size = 28, barH = 20; + g.font = size + 'px ' + font; + var labelW = Math.min(360, Math.max.apply(null, m.map(function (st) { return g.measureText(st.title).width; }))); + var barX = left + labelW + 28, barW = right - barX - 110; + m.forEach(function (st, i) { + var mid = top + i * rowH + rowH / 2; + g.textBaseline = 'middle'; + g.fillStyle = ink; g.font = size + 'px ' + font; g.fillText(fitText(g, st.title, labelW), left, mid); + stageBar(g, barX, mid - barH / 2, barW, barH, st, soft, theme); + g.fillStyle = muted; g.font = '26px ' + font; g.fillText(st.solid + '/' + st.total, barX + barW + 18, mid); + }); + } else { + // 阶段多:分两列,每格上面是名称和数字、下面是进度条,字号不小于 24 像素,手机上看分享图时仍然认得出 + var colW = (right - left - 48) / 2, perCol = Math.ceil(m.length / 2), cellH = Math.min(64, (bottom - top) / perCol); + m.forEach(function (st, i) { + var x = left + Math.floor(i / perCol) * (colW + 48), y = top + (i % perCol) * cellH; + g.textBaseline = 'alphabetic'; + g.fillStyle = muted; g.font = '22px ' + font; + var count = st.solid + '/' + st.total, countW = g.measureText(count).width; + g.fillText(count, x + colW - countW, y + 26); + g.fillStyle = ink; g.font = '24px ' + font; g.fillText(fitText(g, st.title, colW - countW - 16), x, y + 26); + stageBar(g, x, y + 36, colW, 10, st, soft, theme); + }); + } + g.textBaseline = 'alphabetic'; + // 底部:一句介绍、说明和二维码 + g.strokeStyle = line; g.lineWidth = 2; g.beginPath(); g.moveTo(left, footTop); g.lineTo(right, footTop); g.stroke(); + var qrY = footTop + 30; + g.fillStyle = ink; g.font = '600 36px ' + font; + g.fillText('先定级,再按天学,', left, qrY + 52); + g.fillText('用间隔复习记牢面试题', left, qrY + 102); + g.fillStyle = muted; g.font = '28px ' + font; + wrapText(g, '免费、无需注册,记录只保存在自己的浏览器中。扫码开始。', qrX - left - 40).forEach(function (t, i) { g.fillText(t, left, qrY + 156 + i * 40); }); + drawQr(g, siteUrl(), qrX, qrY, qrSize); + g.strokeStyle = line; g.lineWidth = 2; roundRect(g, 60, 60, CARD.w - 120, CARD.h - 120, 36); g.stroke(); + } + // 超出宽度时截断并加省略号 + function fitText(g, text, width) { + if (g.measureText(text).width <= width) return text; + while (g.measureText(text + '…').width > width && text.length > 2) text = text.slice(0, -1); + return text + '…'; + } + function stageBar(g, x, y, w, h, st, soft, theme) { + g.fillStyle = soft; roundRect(g, x, y, w, h, h / 2); g.fill(); + if (st.solid) { g.fillStyle = theme; roundRect(g, x, y, Math.max(h, w * st.solid / st.total), h, h / 2); g.fill(); } + } + function roundRect(g, x, y, w, h, r) { + g.beginPath(); + g.moveTo(x + r, y); g.arcTo(x + w, y, x + w, y + h, r); g.arcTo(x + w, y + h, x, y + h, r); + g.arcTo(x, y + h, x, y, r); g.arcTo(x, y, x + w, y, r); g.closePath(); + } + function drawQr(g, text, x, y, size) { + var q = window.LearnQR.encode(text), cell = Math.floor(size / (q.size + 2)), offset = (size - cell * q.size) / 2; + g.fillStyle = '#fff'; g.fillRect(x, y, size, size); + g.fillStyle = '#1f2d33'; + q.modules.forEach(function (row, yy) { row.forEach(function (dark, xx) { if (dark) g.fillRect(x + offset + xx * cell, y + offset + yy * cell, cell, cell); }); }); + } + function shareCardBlob(callback) { + var canvas = document.createElement('canvas'); + drawShareCard(canvas); + canvas.toBlob(callback, 'image/png'); + } + + // ---------- 页面:进度 ---------- + function heatmap(today) { + // 最近 16 周,每列一周,从周一到周日;颜色按当天自评题数和完成天数分 4 级 + var weeks = 16, counts = {}, cells = ''; + Object.keys(state.log).forEach(function (d) { counts[d] = (counts[d] || 0) + state.log[d]; }); + Object.keys(state.done).forEach(function (id) { var d = state.done[id]; counts[d] = (counts[d] || 0) + 5; }); + var p = today.split('-').map(Number), weekday = (new Date(p[0], p[1] - 1, p[2]).getDay() + 6) % 7; + var first = C.addDays(today, -weekday - (weeks - 1) * 7), active = 0; + for (var i = 0; i < weeks * 7; i++) { + var d = C.addDays(first, i), n = counts[d] || 0, future = d > today; + if (n) active++; + var level = !n ? 0 : n < 5 ? 1 : n < 10 ? 2 : n < 20 ? 3 : 4; + cells += ''; + } + return '
    ' + + '

    最近 16 周有 ' + active + ' 天在学习

    '; + } + // 热力图旁边的摘要:本周、最长连续、累计自评 + function weekSummary(today) { + var active = C.activeDays(state.log, state.done), week = 0, best = 0, run = 0; + for (var i = 0; i < 7; i++) if (active[C.addDays(today, -i)]) week++; + for (var k = 111; k >= 0; k--) { if (active[C.addDays(today, -k)]) { run++; best = Math.max(best, run); } else run = 0; } + var reviews = Object.keys(state.log).reduce(function (n, d) { return n + state.log[d]; }, 0); + return '
    最近 7 天
    ' + week + ' 天
    最长连续
    ' + best + ' 天
    ' + + '
    累计自评
    ' + reviews + ' 次
    '; + } + function viewProgress() { + if (!state.profile) return viewSetup(); + var r = route(state.profile.route), today = now(), m = C.mastery(r, data, state.cards); + var solid = m.reduce(function (n, x) { return n + x.solid; }, 0), qTotal = m.reduce(function (n, x) { return n + x.total; }, 0); + var streakDays = C.streak(state.log, today, state.done); + var html = '

    ' + icon('progress', 'sm') + esc(r.title) + ' · 从 ' + dateLabel(state.profile.start || today) + ' 开始

    进度

    ' + + '
    ' + ring(doneCount(r), totalDays(r), Math.round(doneCount(r) / totalDays(r) * 100) + '%', '路线完成', 120) + + '
    ' + doneCount(r) + ' / ' + totalDays(r) + ' 天

    ' + r.stages.filter(function (st) { return st.days.every(function (x) { return state.done[x]; }); }).length + ' / ' + r.stages.length + ' 个阶段学完

    ' + + '
    ' + + '
    ' + icon('flame') + '' + streakDays + '天连续学习
    ' + + '
    ' + icon('target') + '' + solid + ' / ' + qTotal + '道题已掌握
    ' + + '
    ' + icon('review') + '' + Object.keys(state.cards).length + '道题练过
    ' + + '

    学习记录

    最近 16 周
    ' + heatmap(today) + weekSummary(today) + '
    ' + + '

    各阶段掌握

    连续答“会”、复习间隔到 7 天以上的题算掌握;练过但还没到的算复习中。

    ' + + m.map(function (st, i) { + var days = r.stages[i].days, doneDays = days.filter(function (d) { return state.done[d]; }).length; + return '
    ' + esc(st.title) + '' + doneDays + '/' + days.length + ' 天 · 掌握 ' + st.solid + '/' + st.total + ' 题
    ' + + '
    '; + }).join('') + '

    掌握 复习中 没练过

    '; + // 答错过的题:遗忘次数多的在前 + var shaky = Object.keys(state.cards).filter(function (id) { var c = state.cards[id]; return c.lapses > 0 || (c.stage === 0 && c.reps > 0); }) + .sort(function (a, b) { return state.cards[b].lapses - state.cards[a].lapses || (data.questions[b].heat - data.questions[a].heat); }).slice(0, 10); + if (shaky.length) { + html += '

    常忘的题

    在题库中查看
      ' + shaky.map(function (id) { + var q = data.questions[id], c = state.cards[id]; + return '
    • ' + esc(q.text) + ' ' + (c.lapses ? '忘过 ' + c.lapses + ' 次' : '上次答“不会”') + '
    • '; + }).join('') + '
    '; + } + html += '' + + ''; + // 练过的题多了、又很久没备份时,在页面底部轻提示一次导出 + var exported = lastExport(), n = Object.keys(state.cards).length; + if (n >= 20 && (!exported || C.daysBetween(exported, now()) >= 14)) { + html += '

    ' + icon('lock', 'sm') + n + ' 道题的复习记录只在这个浏览器中,' + (exported ? '上次导出是 ' + ago(exported) : '还没有导出过') + ',换设备或清理浏览器前记得导出。

    '; + } + return html; + } + function bindProgress() { + var preview = document.getElementById('share-preview'), url = null; + shareCardBlob(function (blob) { if (blob && preview) { url = URL.createObjectURL(blob); preview.src = url; } }); + onClick = function (event) { + var button = event.target.closest('[data-action]'); + if (!button) return; + var action = button.dataset.action, name = 'cs-notes-learn-' + now() + '.png'; + if (action === 'save-image') { + shareCardBlob(function (blob) { downloadBlob(blob, name); toast('图片已保存'); }); + } else if (action === 'share-image') { + shareCardBlob(function (blob) { + var file = new File([blob], name, { type: 'image/png' }); + if (navigator.canShare && navigator.canShare({ files: [file] })) { + navigator.share({ files: [file], title: 'CS-Notes 学习进度' }).catch(function () { /* 取消分享时不提示 */ }); + } else { downloadBlob(blob, name); toast('当前浏览器不能直接分享图片,已保存到本地'); } + }); + } else if (action === 'share-site') { + shareLink('CS-Notes 学习', '我在用 CS-Notes 学习准备面试:先定级,再按天学,用间隔复习记牢面试题。免费、无需注册。', siteUrl()); + } + }; + } + function downloadBlob(blob, name) { + var link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = name; + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(function () { URL.revokeObjectURL(link.href); }, 1000); + } + + // ---------- 页面:题库 ---------- + // 全部自测题按路线和阶段排列,可以搜索和筛选;每道题链接到单独的页面 + var bankQuery = { text: '', filter: 'all', route: null }; + var FILTERS = [['all', '全部'], ['hot', '面试常问'], ['new', '没练过'], ['shaky', '常忘'], ['solid', '已掌握']]; + function bankMatch(q) { + var c = state.cards[q.id], f = bankQuery.filter; + if (f === 'hot' && !q.heat) return false; + if (f === 'new' && c) return false; + if (f === 'shaky' && !(c && (c.lapses > 0 || (c.stage === 0 && c.reps > 0)))) return false; + if (f === 'solid' && !(c && c.stage >= 2)) return false; + if (bankQuery.text) { + var hay = (q.text + ' ' + q.points).toLowerCase(); + return bankQuery.text.toLowerCase().split(/\s+/).filter(Boolean).every(function (t) { return hay.indexOf(t) !== -1; }); + } + return true; + } + function bankList() { + var r = route(bankQuery.route), seen = {}, shown = 0, html = ''; + r.stages.forEach(function (stage) { + var rows = ''; + stage.days.forEach(function (id) { + day(id).questions.forEach(function (qid) { + if (seen[qid]) return; + seen[qid] = true; + var q = data.questions[qid], c = state.cards[qid]; + if (!bankMatch(q)) return; + shown++; + var status = !c ? '' : c.stage >= 2 ? '已掌握' : (c.lapses > 0 || c.stage === 0) ? '常忘' : '复习中'; + rows += '
  • ' + esc(q.text) + '' + (q.heat ? '' + icon('flame', 'sm') + (q.heat > 1 ? '最常问' : '常问') + '' : '') + status + icon('chev', 'task-chev') + '
  • '; + }); + }); + if (rows) html += '

    ' + esc(stage.title) + '

      ' + rows + '
    '; + }); + return { html: html || '
    ' + icon('search') + '

    没有符合条件的题,换个关键词或筛选试试。

    ', count: shown }; + } + function viewBank() { + if (!bankQuery.route) bankQuery.route = state.profile ? state.profile.route : data.routes[0].id; + var list = bankList(); + return '

    ' + icon('bank', 'sm') + Object.keys(data.questions).length + ' 道面试题

    题库

    ' + + '
    ' + data.routes.map(function (r) { + return ''; + }).join('') + '
    ' + + '
    ' + + '
    ' + FILTERS.map(function (f) { + return ''; + }).join('') + '
    ' + + '

    ' + list.count + ' 道题

    ' + list.html + '
    '; + } + function bindBank() { + var input = document.getElementById('bank-search'), timer; + function refresh() { + var list = bankList(); + document.getElementById('bank-list').innerHTML = list.html; + document.getElementById('bank-count').textContent = list.count + ' 道题'; + } + input.addEventListener('input', function () { + clearTimeout(timer); + timer = setTimeout(function () { bankQuery.text = input.value.trim(); refresh(); }, 120); + }); + onClick = function (event) { + var chip = event.target.closest('.chip'); + if (!chip) return; + if (chip.dataset.route) bankQuery.route = chip.dataset.route; + if (chip.dataset.filter) bankQuery.filter = chip.dataset.filter; + chip.parentNode.querySelectorAll('.chip').forEach(function (c) { c.setAttribute('aria-pressed', String(c === chip)); }); + refresh(); + }; + } + + // ---------- 页面:单道题(分享出去的链接) ---------- + function viewQuestion(id) { + var q = data.questions[id]; + if (!q) { + var gone = data.retired && data.retired.questions[id]; + return gone ? '

    这道题已下线

    ' + esc(gone.text) + '

    笔记更新后这道题不再出现在学习路线中,复习记录保留,不会再安排复习。

    ' + : '

    没有找到这道题

    链接可能不完整。去题库看看

    '; + } + var d = day(q.days[0]), r = route(d.route); + var html = '' + + '

    ' + esc(q.text) + '

    ' + cardHtml(q, { source: '', hideDue: false }); + if (!state.profile) { + html += heroHtml('', icon('sparkle', 'sm') + 'CS-Notes 学习', '这样的面试题,按计划一道道掌握', + '先用 ' + Object.keys(data.questions).length + ' 道真实面试题自评定级,按你的水平和面试日期排出每天的计划,再用间隔复习记牢。免费、无需注册,记录只保存在你的浏览器中。', + '开始定级' + icon('arrow') + '看这道题所在的一天'); + } else { + html += ''; + } + return html; + } + + // ---------- 页面:数据 ---------- + // ---------- 学习记录:导出、导入、数据说明 ---------- + var EXPORTED = 'cs-learn-exported', pendingImport = null, persisted = null; + function lastExport() { try { return localStorage.getItem(EXPORTED); } catch (e) { return null; } } + function markExported() { try { localStorage.setItem(EXPORTED, now()); } catch (e) { /* 忽略 */ } } + // 导出的文件就是本地记录本身,另加导出日期和来源,导入时忽略这两项 + function exportText(pretty) { + return JSON.stringify(Object.assign({ app: 'cs-notes-learn', exportedAt: now() }, state), null, pretty ? 2 : 0); + } + // 请浏览器把本站数据当作需要长期保存的数据(不保证同意);只在开始学习后请求一次 + function askPersist() { + if (persisted !== null || !navigator.storage || !navigator.storage.persist || !state.profile) return; + persisted = false; + navigator.storage.persisted().then(function (yes) { return yes || navigator.storage.persist(); }).then(function (yes) { persisted = !!yes; }, function () {}); + } + function exportNote() { + var d = lastExport(), n = Object.keys(state.cards).length; + if (d) return '上次导出:' + dateLabel(d) + '(' + ago(d) + ')。'; + return n ? '还没有导出过。' : ''; + } + function viewData() { + var cards = Object.keys(state.cards).length, days = Object.keys(state.done).length, reviews = Object.keys(state.log).reduce(function (n, d) { return n + state.log[d]; }, 0); + var html = '

    ' + icon('lock', 'sm') + '只保存在这个浏览器中

    学习记录

    ' + + '

    学习记录只保存在当前浏览器中,不上传。清除浏览器数据、换浏览器或换设备时记录不会跟着走,可以导出成文件或复制成文字,在另一处导入。数据与隐私说明

    ' + + (storageOk ? '' : '

    当前浏览器不允许保存记录(可能是隐私模式),关闭页面后记录会丢失,请及时导出。

    ') + + '
    ' + cards + '道题在复习中
    ' + days + '天已完成
    ' + reviews + '次自评
    ' + + (Object.keys(state.archive.cards).length || Object.keys(state.archive.done).length ? '

    另有 ' + Object.keys(state.archive.cards).length + ' 道题、' + Object.keys(state.archive.done).length + + ' 天的记录属于已经下线的内容,保留在记录中、随导出一起保存,内容重新上线时自动恢复。

    ' : ''); + if (pendingImport) return html + importPreview(); + return html + + '

    导出

    ' + exportNote() + '文件和文字的内容一样,都可以在另一处导入。

    ' + + '
    ' + + '
    ' + + '

    在手机和电脑之间转移时,复制成文字后发给自己(例如文件传输助手),在另一台设备上粘贴导入。

    ' + + '

    导入

    选择导出的文件、把文件拖到这里,或粘贴导出的文字。导入前会显示文件中的内容,可以选择合并或替换。

    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + skinPicker() + '
    ' + + '

    ' + + '

    关于题目和参考要点

    题目来自 CS-Notes 学习路线中的自测题,是面试中的常见问法;参考要点由作者编写并对照原文核对。热度标记来自笔记中的高频小节标记。“复制给 AI 追问”只把题目、你的回答和参考要点复制到剪贴板,由你粘贴到自己使用的 AI 应用中,本站不调用任何 AI 服务。

    '; + } + function importPreview() { + var inc = C.stateSummary(pendingImport.state, data), cur = C.stateSummary(state, data); + var line = function (x) { return (x.route ? esc(x.route) + ',' : '没有选路线,') + x.cards + ' 道题、' + x.done + ' 天' + (x.last ? ',最后学习 ' + dateLabel(x.last) : '') + (x.archived ? ',另有 ' + x.archived + ' 条下线内容的记录' : ''); }; + var empty = !state.profile && !cur.cards && !cur.done; + return '

    导入前确认

    ' + + '
    要导入的记录' + (pendingImport.exportedAt ? '(' + dateLabel(pendingImport.exportedAt) + ' 导出)' : '') + '
    ' + line(inc) + '
    ' + + (empty ? '' : '
    这个浏览器中现有的记录
    ' + line(cur) + '
    ') + '
    ' + + (empty ? '' : '

    合并:两边的记录都保留,同一道题取最近一次自评的那份,路线和每天的时间用这个浏览器的设置。替换:只留下导入的记录,现有的会被覆盖。

    ') + + '
    ' + (empty ? '' : + '') + + '
    '; + } + function readImport(text) { + var raw; + try { raw = JSON.parse(String(text).trim()); } catch (e) { throw new Error('内容不是本站导出的记录(无法解析)'); } + var imported = C.sanitize(raw, data); + var any = function (o) { return Object.keys(o).length > 0; }; + if (!imported.profile && !imported.archive.profile && !any(imported.cards) && !any(imported.done) && !any(imported.archive.cards)) throw new Error('里面没有可用的学习记录'); + pendingImport = { state: imported, exportedAt: C.isDate(raw.exportedAt) ? raw.exportedAt : null }; + render(); + // 焦点移到确认框的标题上,读屏软件会读出要导入的内容 + var title = document.getElementById('import-title'); + if (title) { title.setAttribute('tabindex', '-1'); title.focus({ preventScroll: true }); title.closest('section').scrollIntoView({ block: 'center' }); } + } + function importFile(file) { + if (!file) return; + if (file.size > 5 * 1024 * 1024) { toast('文件过大,不是本站导出的记录'); return; } + file.text().then(readImport).catch(function (error) { toast('导入失败:' + error.message); }); + } + function bindData() { + onClick = function (event) { + var button = event.target.closest('[data-action]'); + if (!button) return; + var action = button.dataset.action; + if (action === 'export') { + downloadBlob(new Blob([exportText(true)], { type: 'application/json' }), 'cs-notes-learn-' + now() + '.json'); + markExported(); toast('已导出'); render(); + } else if (action === 'copy-export') { + copyText(exportText(false)).then(function () { markExported(); toast('已复制,可以粘贴到另一台设备上导入'); render(); }, function () { toast('复制失败,请改用导出成文件'); }); + } else if (action === 'import') { + document.getElementById('import-file').click(); + } else if (action === 'paste-open') { + var box = document.getElementById('paste-box'); box.hidden = false; document.getElementById('paste-text').focus(); + } else if (action === 'paste-import') { + try { readImport(document.getElementById('paste-text').value); } catch (error) { toast('导入失败:' + error.message); } + } else if (action === 'import-merge' || action === 'import-replace') { + state = action === 'import-merge' ? C.mergeState(state, pendingImport.state) : pendingImport.state; + pendingImport = null; + sessions = { review: null, mock: null, 'new': null }; + save(); + render(); + toast((action === 'import-merge' ? '已合并:' : '已导入:') + Object.keys(state.cards).length + ' 道题、' + Object.keys(state.done).length + ' 天'); + } else if (action === 'import-cancel') { + pendingImport = null; render(); + } else if (action === 'reset') { + if (!window.confirm('清空后无法恢复,建议先导出。确定清空全部学习记录吗?')) return; + state = C.emptyState(); + sessions = { review: null, mock: null, 'new': null }; + save(); + location.hash = '#/'; + toast('已清空'); + } + }; + var input = document.getElementById('import-file'); + if (input) input.addEventListener('change', function () { importFile(input.files[0]); input.value = ''; }); + var zone = document.getElementById('drop-zone'); + if (zone) { + ['dragenter', 'dragover'].forEach(function (type) { zone.addEventListener(type, function (event) { event.preventDefault(); zone.classList.add('dragging'); }); }); + ['dragleave', 'drop'].forEach(function (type) { zone.addEventListener(type, function () { zone.classList.remove('dragging'); }); }); + zone.addEventListener('drop', function (event) { event.preventDefault(); importFile(event.dataTransfer.files[0]); }); + } + } + + // 数据与隐私:放在页脚和学习记录页中,不在主流程里出现 + function viewPrivacy() { + var keep = persisted ? '浏览器已同意长期保存本站的数据。' : ''; + return '

    ' + icon('lock', 'sm') + '最后更新:2026 年 10 月 31 日

    数据与隐私

    ' + + '
    ' + + '

    CS-Notes 学习是一个纯静态的网站:没有账号系统,没有后端服务器,也没有数据库。下面说明使用过程中会产生哪些数据、放在哪里。

    ' + + '

    不收集个人信息

      ' + + '
    • 不需要注册或登录,不要求填写姓名、手机号、邮箱等任何个人信息;
    • ' + + '
    • 不使用 Cookie,不加载统计、广告或其它第三方脚本,页面只请求本站自己的文件;
    • ' + + '
    • 不调用任何 AI 服务。“复制给 AI 追问”只是把内容放进剪贴板,是否粘贴、粘贴到哪里由你决定。
    ' + + '

    学习记录保存在你的浏览器中

      ' + + '
    • 路线设置、定级结果、完成的天、每道题的复习安排和每天的学习量,保存在当前浏览器的 localStorage 中(cs-learn-v1),不上传、不同步;
    • ' + + '
    • 外观选择和上次导出的日期也只保存在本机(cs-learn-skin、cs-learn-exported);
    • ' + + '
    • 题卡中输入的回答只留在当前页面,离开或刷新后就没有了;
    • ' + + '
    • 为了离线使用,浏览器会缓存本站的页面、脚本和题目文件,其中不含学习记录;
    • ' + + '
    • 分享图和二维码在浏览器中本地生成,只有你保存或分享时才会离开这台设备。
    ' + + '

    访问日志

    和所有网站一样,提供网站托管和加速的服务商在传送页面时,会按其规则记录访问日志(例如 IP 地址、访问时间、浏览器类型),用于运行和安全防护。本站不另外收集这些信息,也不把它们和学习记录关联。

    ' + + '

    保存、转移和删除

      ' + + '
    • 记录只在这一个浏览器中。清除浏览器数据、使用隐私模式,或在 Safari 中较长时间没有访问本站时,浏览器可能删除这些记录;建议定期在学习记录页导出,添加到主屏幕后使用也更不容易被清理。' + keep + '
    • ' + + '
    • 导出的文件或文字由你自己保管,导入时只在浏览器中读取,不经过服务器;
    • ' + + '
    • 在学习记录页可以随时清空,也可以在浏览器设置中清除本站数据。
    ' + + '

    外部链接和反馈

    页面中指向 GitHub 等其它网站的链接,打开后适用那些网站自己的规则。“要点有误”和“反馈建议”会打开 GitHub 的新建议题页并预填题目内容,只有你自己提交时才会发出,不包含学习记录。

    ' + + '

    联系

    对这些说明有疑问,可以在 GitHub Issues 中提出。说明有变化时会更新本页的日期。

    ' + + '
    '; + } + + // ---------- 路由 ---------- + var currentView = null, entering = false, presetRoute = null; + var TITLES = { home: '今天', setup: '设置', placement: '定级', plan: '学习计划', day: '', q: '', review: '复习', mock: '模拟面试', 'new': '练习新题', bank: '题库', progress: '进度', data: '学习记录', privacy: '数据与隐私' }; + // 页面出错时不留白屏:说明情况,给出刷新和导出记录的办法(导出不依赖出错的页面) + function render() { + try { renderPage(); } catch (error) { + if (window.console) console.error(error); + main.innerHTML = '

    页面出错了

    这个页面没有正常显示,学习记录没有受影响。可以先刷新;如果一直出错,请先导出记录,再到 GitHub 反馈。

    ' + + '
    返回今天
    '; + } + } + main.addEventListener('click', function (event) { + var rescue = event.target.closest && event.target.closest('[data-rescue]'); + if (!rescue) return; + if (rescue.dataset.rescue === 'reload') location.reload(); + else downloadBlob(new Blob([localStorage.getItem(KEY) || '{}'], { type: 'application/json' }), 'cs-notes-learn-' + now() + '.json'); + }); + function renderPage() { + var hash = decodeURIComponent(location.hash.replace(/^#\/?/, '')); + var parts = hash.split('/'), name = parts[0] || 'home', html; + // 从笔记网站的学习路线进来:还没有设置时预先选中这条路线,已经在学时回到今天 + if (name === 'start') { + if (!state.profile && route(parts[1])) presetRoute = parts[1]; + location.replace('#/' + (state.profile ? '' : 'setup')); + return; + } + entering = currentView !== (parts[0] || 'home') + '/' + parts.slice(1).join('/'); + onClick = null; + // 还没有设置时,除了单独打开某一天和数据页,都显示设置 + if (!state.profile && ['day', 'data', 'q', 'bank'].indexOf(name) === -1) name = 'setup'; + if (['home', 'setup', 'placement', 'plan', 'review', 'mock', 'new', 'progress', 'data', 'privacy', 'day', 'q', 'bank'].indexOf(name) === -1) name = 'home'; + if (name === 'day') html = viewDay(parts[1]); + else if (name === 'setup') html = viewSetup(); + else if (name === 'placement') html = viewPlacement(); + else if (name === 'plan') html = viewPlan(); + else if (name === 'review' || name === 'mock' || name === 'new') { if (name === 'review' && parts[1] === 'quick' && entering) quickNext = true; html = viewSession(name); } + else if (name === 'data') html = viewData(); + else if (name === 'privacy') html = viewPrivacy(); + else if (name === 'progress') html = viewProgress(); + else if (name === 'q') html = viewQuestion(parts[1]); + else if (name === 'bank') html = viewBank(); + else html = viewHome(); + var view = name + '/' + parts.slice(1).join('/'), changed = currentView !== view; + currentView = view; + main.dataset.view = name; + main.innerHTML = html; + if (name === 'setup' && document.getElementById('setup')) bindSetup(); + else if (name === 'placement') bindPlacement(); + else if (name === 'day') bindDay(parts[1]); + else if (name === 'review' || name === 'mock' || name === 'new') bindSession(name); + else if (name === 'data') bindData(); + else if (name === 'progress' && state.profile) bindProgress(); + else if (name === 'bank') bindBank(); + else if (name === 'plan' && state.profile) onClick = function (event) { + if (!event.target.closest('[data-action="calendar"]')) return; + var stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+/, ''); + downloadBlob(new Blob([C.planCalendar(currentPlan(), data, siteUrl(), stamp)], { type: 'text/calendar' }), 'cs-notes-plan.ics'); + toast('已导出日历文件,计划调整后可以重新导出'); + }; + else onClick = function (event) { + var dismiss = event.target.closest('[data-action="dismiss-update"]'); + if (dismiss) { + state.seenUpdate = Math.max(state.seenUpdate || 0, Number(dismiss.dataset.seq) || 0); + save(); + dismiss.closest('.update').remove(); + return; + } + handleCard(event); + }; + var heading = main.querySelector('h1'); + document.title = ((name === 'day' || name === 'q') && heading ? heading.textContent : TITLES[name] || '') + ' · CS-Notes 学习'; + document.querySelectorAll('[data-nav]').forEach(function (link) { + var owner = { day: 'plan', mock: 'review', 'new': 'review', data: 'progress', privacy: 'progress', q: 'bank' }[name] || name; + if (link.dataset.nav === owner) link.setAttribute('aria-current', 'page'); + else link.removeAttribute('aria-current'); + }); + updateBadge(); + if (changed) { + window.scrollTo(0, 0); + main.focus({ preventScroll: true }); + } + } + // 回答框随内容变高,空着时只占两行 + main.addEventListener('input', function (event) { + var t = event.target; + if (!t.matches || !t.matches('textarea[data-answer]')) return; + t.style.height = 'auto'; + t.style.height = Math.min(t.scrollHeight + 2, 420) + 'px'; + }); + main.addEventListener('change', function (event) { + if (event.target.name === 'skin') { setSkin(event.target.value); toast('已切换为“' + SKINS.filter(function (x) { return x[0] === event.target.value; })[0][1] + '”'); } + }); + main.addEventListener('click', function (event) { + // 页内跳转:hash 已经用于路由,用按钮滚动到对应的元素 + var jump = event.target.closest('[data-jump]'); + if (jump) { + var target = document.getElementById(jump.dataset.jump); + if (target) { + target.scrollIntoView({ behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'start' }); + var focusable = target.querySelector('input, button'); + if (focusable && jump.dataset.jump === 'setup') setTimeout(function () { focusable.focus({ preventScroll: true }); }, 400); + } + return; + } + if (onClick) onClick(event); + }); + window.addEventListener('hashchange', render); + // 其它标签页修改了记录时同步过来,避免两个标签页互相覆盖 + window.addEventListener('storage', function (event) { + if (event.key !== KEY) return; + state = load(); + sessions = { review: null, mock: null, 'new': null }; + render(); + }); + + // 离线缓存只在 HTTPS 或本机地址下可用 + if ('serviceWorker' in navigator && (location.protocol === 'https:' || /^(127\.0\.0\.1|localhost)$/.test(location.hostname))) { + // 部署新版本后:打开着的页面在切回前台时检查更新,新版本接管后提示刷新;页面本身先走网络,刷新即可拿到新内容 + var hadController = !!navigator.serviceWorker.controller; + // 新版本接管时,从网络取一次最新的入口页(加查询参数,不读离线缓存):里面引用的脚本和数据版本号和本页不同(本页是旧版本)才提示刷新; + // 刚刷新过的页面已经是新版本,不再提示;取不到(离线)时也不提示,反正刷新不了 + var pageFiles = [document.currentScript.getAttribute('src'), document.currentScript.dataset.src]; + navigator.serviceWorker.addEventListener('controllerchange', function () { + if (!hadController) { hadController = true; return; } + fetch('./?check=' + Date.now(), { cache: 'no-store', credentials: 'omit' }).then(function (response) { return response.text(); }).then(function (html) { + if (pageFiles.some(function (f) { return html.indexOf('"' + f + '"') === -1; })) showUpdateBar(); + }).catch(function () { /* 离线时不提示 */ }); + }); + window.addEventListener('load', function () { + navigator.serviceWorker.register('sw.js').then(function (registration) { + document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') registration.update().catch(function () {}); }); + }).catch(function () { /* 不支持时照常使用 */ }); + }); + } + + function showUpdateBar(message) { + if (document.getElementById('update-bar')) return; + var bar = document.createElement('div'); + bar.id = 'update-bar'; + bar.className = 'update-bar'; + bar.setAttribute('role', 'status'); + bar.innerHTML = '' + (message || '学习站已更新,刷新后使用新的内容。你的记录不受影响。') + ''; + bar.querySelector('button').addEventListener('click', function () { location.reload(); }); + document.body.appendChild(bar); + } + + setSkin(skin()); + document.querySelectorAll('[data-nav]').forEach(function (link) { link.insertAdjacentHTML('afterbegin', icon(link.dataset.nav)); }); + document.querySelector('.notes-link').insertAdjacentHTML('afterbegin', icon('notes')); + var source = document.currentScript.dataset.src; + fetch(source, { credentials: 'omit' }).then(function (response) { + if (!response.ok) throw new Error(response.status); + return response.json(); + }).then(function (loaded) { + if (loaded.version !== 1) throw new Error('数据版本不兼容'); + data = loaded; + state = load(); + render(); + }).catch(function () { + main.innerHTML = '

    载入失败

    题目数据没有载入,请检查网络后重试。学习记录保存在浏览器中,不受影响。

    '; + }); +})(); diff --git a/learn/src/core.js b/learn/src/core.js new file mode 100644 index 000000000..5574c1989 --- /dev/null +++ b/learn/src/core.js @@ -0,0 +1,583 @@ +// 学习站的核心逻辑:日期、复习调度、定级、学习计划、提示词和本地数据。 +// 不访问页面和存储,浏览器和 Node 测试共用同一份代码。 +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory(); + else root.LearnCore = factory(); +})(typeof self !== 'undefined' ? self : this, function () { + 'use strict'; + + // ---------- 日期:一律用本地日期的 YYYY-MM-DD,避免时区让“今天”跨天 ---------- + function pad(n) { return (n < 10 ? '0' : '') + n; } + function today(now) { + var d = now ? new Date(now) : new Date(); + return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); + } + function parseDate(s) { + var p = String(s).split('-').map(Number); + return new Date(p[0], p[1] - 1, p[2]); + } + function addDays(s, n) { + var d = parseDate(s); + d.setDate(d.getDate() + n); + return today(d); + } + function daysBetween(a, b) { + return Math.round((parseDate(b) - parseDate(a)) / 86400000); + } + function isDate(s) { + return typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(s) && today(parseDate(s)) === s; + } + + // ---------- 复习调度 ---------- + // 固定间隔:答“会”时间隔逐级拉长,“模糊”保持当前级别并减半,“不会”回到第一级。 + // 不用 SM-2、FSRS:题卡只有几百张,自评只有三档,固定间隔可解释,读者能预期下次什么时候出现。 + var INTERVALS = [1, 3, 7, 15, 30, 60]; + var FORGOT = 0, FUZZY = 1, KNOWN = 2; + + function newCard() { return { stage: -1, due: null, reps: 0, lapses: 0, last: null }; } + + function schedule(card, rating, on) { + var next = Object.assign(newCard(), card); + var interval; + if (rating === KNOWN) { + next.stage = Math.min(Math.max(next.stage, 0) + 1, INTERVALS.length - 1); + interval = INTERVALS[next.stage]; + } else if (rating === FUZZY) { + next.stage = Math.max(next.stage, 0); + interval = Math.max(1, Math.floor(INTERVALS[next.stage] / 2)); + } else if (rating === FORGOT) { + if (next.stage > 0) next.lapses += 1; + next.stage = 0; + interval = INTERVALS[0]; + } else { + throw new Error('未知的自评:' + rating); + } + next.reps += 1; + next.last = on; + next.due = addDays(on, interval); + return next; + } + + function isDue(card, on) { return !!card && !!card.due && card.due <= on; } + + // 今天要复习的题:先到期最久的,同样到期的高频题优先 + function dueList(cards, questions, on) { + return Object.keys(cards).filter(function (id) { return questions[id] && isDue(cards[id], on); }) + .sort(function (a, b) { + return cards[a].due.localeCompare(cards[b].due) || (questions[b].heat - questions[a].heat) || a.localeCompare(b); + }); + } + + // 有学习活动的日期:当天自评过题,或完成了计划中的一天 + function activeDays(log, done) { + var days = {}; + Object.keys(log || {}).forEach(function (d) { if (log[d] > 0) days[d] = true; }); + Object.keys(done || {}).forEach(function (id) { days[done[id]] = true; }); + return days; + } + // 连续学习天数:从今天(今天还没学则从昨天)往前数 + // 连续学习:每连续学满 7 天得到 1 次“保护”(最多存 2 次),某天没学时自动用掉 1 次,连续天数不断(没学的那天不计数)。 + // 偶尔有事中断一天不至于前功尽弃;连着几天不学仍会归零。今天还没学时不算中断 + var FREEZE_EVERY = 7, FREEZE_MAX = 2; + function streakInfo(log, on, done) { + var active = activeDays(log, done), days = Object.keys(active).filter(function (d) { return d <= on; }).sort(); + var out = { days: 0, freezes: 0, used: [] }; + if (!days.length) return out; + var run = 0, earned = 0, freezes = 0, used = []; + for (var d = days[0]; d <= on; d = addDays(d, 1)) { + if (active[d]) { + run += 1; earned += 1; + if (earned === FREEZE_EVERY) { freezes = Math.min(FREEZE_MAX, freezes + 1); earned = 0; } + } else if (d === on) { + break; + } else if (freezes > 0 && run > 0) { + freezes -= 1; used.push(d); + } else { + run = 0; earned = 0; used = []; + } + } + out.days = run; out.freezes = run ? freezes : 0; out.used = run ? used : []; + return out; + } + function streak(log, on, done) { return streakInfo(log, on, done).days; } + // 值得庆祝的连续天数 + var MILESTONES = [3, 7, 14, 21, 30, 50, 75, 100, 150, 200, 365]; + function isMilestone(n) { return MILESTONES.indexOf(n) !== -1 || (n > 365 && n % 100 === 0); } + // 一次复习的题数上限,约占每天时间的三成(每题约 1.5 分钟);超出的留到下一次,避免中断几天后一次面对几十道题 + function reviewLimit(minutes) { + return Math.max(15, Math.round((minutes || 90) * 0.3 / 1.5)); + } + // 某条路线各阶段的掌握情况:连续答“会”到间隔 7 天以上算掌握,自评过但还没到算复习中 + function mastery(route, data, cards) { + return route.stages.map(function (stage) { + var ids = []; + stage.days.forEach(function (d) { data.days[d].questions.forEach(function (q) { if (ids.indexOf(q) === -1) ids.push(q); }); }); + var solid = 0, learning = 0; + ids.forEach(function (q) { + var c = cards[q]; + if (c && c.stage >= 2) solid += 1; + else if (c && c.stage >= 0) learning += 1; + }); + return { title: stage.title, total: ids.length, solid: solid, learning: learning }; + }); + } + + // ---------- 定级 ---------- + // 每个阶段先问两道题(高频优先、来自不同的天);两道结论一致就判定,不一致再问第三道。 + // 读者可以整段跳过没学过的阶段。结果按阶段给出“掌握、部分、薄弱、未学”。 + function stageCandidates(route, stageIndex, data) { + var stage = route.stages[stageIndex], seen = {}, list = []; + stage.days.forEach(function (dayId, order) { + data.days[dayId].questions.forEach(function (qid, i) { + if (seen[qid]) return; + seen[qid] = true; + list.push({ id: qid, day: dayId, heat: data.questions[qid].heat, order: order * 10 + i }); + }); + }); + // 高频在前;同热度按路线顺序,保证能覆盖阶段中不同的天 + list.sort(function (a, b) { return b.heat - a.heat || a.order - b.order; }); + var picked = [], days = {}; + list.forEach(function (c) { if (!days[c.day]) { days[c.day] = true; picked.push(c.id); } }); + list.forEach(function (c) { if (picked.indexOf(c.id) === -1) picked.push(c.id); }); + return picked; + } + + // 每个阶段记下标题:路线增删阶段后按标题找回定级结果 + function createPlacement(route, data) { + return { + route: route.id, + stages: route.stages.map(function (s, i) { return { title: s.title, pool: stageCandidates(route, i, data), asked: [], ratings: {}, skipped: false }; }) + }; + } + + function stageResult(stage) { + if (stage.skipped) return 'unknown'; + var values = stage.asked.map(function (id) { return stage.ratings[id]; }).filter(function (v) { return v !== undefined; }); + if (!values.length) return null; + var known = values.filter(function (v) { return v === KNOWN; }).length; + var forgot = values.filter(function (v) { return v === FORGOT; }).length; + if (values.length >= 2 && known === values.length) return 'mastered'; + if (values.length >= 2 && forgot === values.length) return 'weak'; + if (values.length >= 3 || values.length >= stage.pool.length) { + // “模糊”算半个“会”:都答模糊时是部分掌握,答“会”的天在计划中只做自测,其余的天照常学 + var fuzzy = values.length - known - forgot; + return (known + fuzzy / 2) * 2 >= values.length ? 'partial' : 'weak'; + } + return null; + } + + // 下一道题:{stage, id};全部问完返回 null + function nextPlacementQuestion(placement) { + for (var i = 0; i < placement.stages.length; i++) { + var stage = placement.stages[i]; + if (stageResult(stage)) continue; + var id = stage.pool.filter(function (q) { return stage.asked.indexOf(q) === -1; })[0]; + if (id) return { stage: i, id: id }; + } + return null; + } + + function answerPlacement(placement, stageIndex, id, rating) { + var stage = placement.stages[stageIndex]; + if (stage.asked.indexOf(id) === -1) stage.asked.push(id); + stage.ratings[id] = rating; + return placement; + } + + function skipStage(placement, stageIndex) { + placement.stages[stageIndex].skipped = true; + return placement; + } + + function placementProgress(placement) { + var done = placement.stages.filter(function (s) { return stageResult(s); }).length; + return { done: done, total: placement.stages.length }; + } + + // ---------- 学习计划 ---------- + // 每天的设计用时(学习路线说明中写的范围取中间值),已掌握的天只做当天的自测 + var DAY_MINUTES = { java: 75, llm: 75, sprint: 105 }; + var REVIEW_MINUTES = 15; + var REVIEW_RESERVE = 15; + + // 每天的处理方式:learn 按路线学习,check 只做自测确认(定级显示已掌握) + function dayModes(route, data, placement) { + var modes = {}; + route.stages.forEach(function (stage, i) { + var st = placement ? placement.stages[i] : null; + var result = st ? stageResult(st) : null; + stage.days.forEach(function (dayId) { + var mode = 'learn'; + if (result === 'mastered') mode = 'check'; + else if (result === 'partial') { + // 这一天被问到的题都答“会”,才认为这一天已掌握 + var asked = data.days[dayId].questions.filter(function (q) { return st.ratings[q] !== undefined; }); + if (asked.length && asked.every(function (q) { return st.ratings[q] === KNOWN; })) mode = 'check'; + } + modes[dayId] = mode; + }); + }); + return modes; + } + + function dayHeat(dayId, data) { + return data.days[dayId].questions.reduce(function (sum, q) { return sum + data.questions[q].heat; }, 0); + } + + // 生成计划:保持路线顺序(前面的内容是后面的基础),按每天的可用时间装箱; + // 有面试日期且时间不够时,先去掉“只做自测”的天,再去掉热度最低的学习日 + function buildPlan(options, data) { + var route = data.routes.filter(function (r) { return r.id === options.route; })[0]; + if (!route) throw new Error('未知的路线:' + options.route); + var budget = Math.max(30, options.minutes || 90); + // 每天先留出复习的时间,剩下的用来学新内容 + var room = Math.max(30, budget - REVIEW_RESERVE); + var modes = dayModes(route, data, options.placement); + var items = [], doneToday = []; + route.stages.forEach(function (stage) { + stage.days.forEach(function (dayId) { + var doneOn = options.done && options.done[dayId]; + // 今天完成的天仍算在今天,占用今天的时间;否则完成一天后,明天的内容会被顺延到今天 + if (doneOn && doneOn === options.start) { + var m = modes[dayId]; + doneToday.push({ day: dayId, mode: m, minutes: m === 'learn' ? DAY_MINUTES[route.id] || 75 : REVIEW_MINUTES, heat: dayHeat(dayId, data), done: true }); + return; + } + if (doneOn) return; + var mode = modes[dayId]; + items.push({ day: dayId, mode: mode, minutes: mode === 'learn' ? DAY_MINUTES[route.id] || 75 : REVIEW_MINUTES, heat: dayHeat(dayId, data) }); + }); + }); + var start = options.start; + var available = options.deadline && isDate(options.deadline) ? Math.max(0, daysBetween(start, options.deadline)) : null; + var dropped = []; + var usedToday = doneToday.reduce(function (n, i) { return n + i.minutes; }, 0); + function pack(list) { + var days = doneToday.length ? [doneToday.slice()] : [], used = usedToday; + list.forEach(function (item) { + // 一项超过当天剩余时间时放到下一天;一天至少放一项,即使它本身超过预算 + if (!days.length || (used > 0 && used + item.minutes > room * 1.15)) { days.push([]); used = 0; } + days[days.length - 1].push(item); + used += item.minutes; + }); + return days; + } + var calendar = pack(items); + if (available !== null) { + // 每天之外还要留出复习的时间,计划只排到面试前一天 + var order = items.slice().sort(function (a, b) { + return (a.mode === 'check' ? 0 : 1) - (b.mode === 'check' ? 0 : 1) || a.heat - b.heat || items.indexOf(b) - items.indexOf(a); + }); + while (calendar.length > available && order.length) { + var cut = order.shift(); + dropped.push(cut); + items = items.filter(function (i) { return i !== cut; }); + calendar = pack(items); + } + } + return { + route: route.id, budget: budget, start: start, deadline: options.deadline || null, + days: calendar.map(function (list, i) { return { date: addDays(start, i), items: list }; }), + dropped: dropped.map(function (d) { return d.day; }), + counts: { + learn: items.filter(function (i) { return i.mode === 'learn'; }).length, + check: items.filter(function (i) { return i.mode === 'check'; }).length + } + }; + } + + // ---------- 复制给 AI 的提示词 ---------- + function aiPrompt(question, answer) { + var refs = question.links.map(function (l) { return '- ' + l.text + ':' + l.href; }).join('\n'); + var mine = answer && answer.trim() ? answer.trim() : '(还没作答。请先给一个提示,不要直接给出完整答案。)'; + return [ + '我在准备技术面试,下面是一道题和我的回答。', + '请先指出我的回答中正确、错误和遗漏的地方,再像面试官一样追问 2~3 个相关问题,每次只问一个,等我回答后再继续。', + '参考要点来自 CS-Notes,已经人工核对;如果你的看法与参考要点不一致,请说明依据。', + '', + '题目:' + question.text, + '', + '我的回答:' + mine, + '', + '参考要点:' + question.points, + '', + '原文:', + refs + ].join('\n'); + } + + // ---------- 模拟面试 ---------- + // 从路线中抽题:高频题优先,自评过但还不牢的题加分,random 用于打乱同分的题 + function mockSet(route, data, cards, n, random) { + random = random || Math.random; + var ids = []; + route.stages.forEach(function (stage) { + stage.days.forEach(function (d) { data.days[d].questions.forEach(function (q) { if (ids.indexOf(q) === -1) ids.push(q); }); }); + }); + return ids.map(function (q) { + var c = cards[q], shaky = c && c.stage >= 0 && c.stage < 2; + return { id: q, score: data.questions[q].heat * 2 + (shaky ? 3 : 0) + random() * 2 }; + }).sort(function (a, b) { return b.score - a.score; }).slice(0, n).map(function (x) { return x.id; }); + } + + // ---------- 日历 ---------- + // 把计划导出为 iCalendar(RFC 5545):每个计划日一个全天事件 + function icsText(value) { + return String(value).replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n'); + } + // 每行不超过 75 个字节,续行以空格开头;按 UTF-8 计算,不拆开一个字符 + function icsFold(line) { + var out = [], current = '', bytes = 0; + Array.from(line).forEach(function (ch) { + var cp = ch.codePointAt(0), size = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4; + if (bytes + size > (out.length ? 74 : 75)) { out.push(current); current = ''; bytes = 0; } + current += ch; + bytes += size; + }); + out.push(current); + return out.join('\r\n '); + } + function planCalendar(plan, data, siteUrl, stamp) { + var lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//CS-Notes//Learn//ZH', 'CALSCALE:GREGORIAN', 'X-WR-CALNAME:CS-Notes 学习计划']; + plan.days.forEach(function (entry) { + var titles = entry.items.map(function (i) { var d = data.days[i.day]; return '第 ' + d.n + ' 天:' + d.title; }); + var date = entry.date.replace(/-/g, ''); + lines.push('BEGIN:VEVENT', 'UID:' + plan.route + '-' + date + '@cs-notes-learn', 'DTSTAMP:' + stamp, + 'DTSTART;VALUE=DATE:' + date, 'DTEND;VALUE=DATE:' + addDays(entry.date, 1).replace(/-/g, ''), + 'SUMMARY:' + icsText('CS-Notes ' + titles.join('、')), + 'DESCRIPTION:' + icsText(entry.items.map(function (i) { return siteUrl + '#/day/' + i.day; }).join('\n')), + 'URL:' + siteUrl + '#/day/' + entry.items[0].day, 'TRANSP:TRANSPARENT', 'END:VEVENT'); + }); + lines.push('END:VCALENDAR'); + return lines.map(icsFold).join('\r\n') + '\r\n'; + } + + // ---------- 本地数据 ---------- + // 版本 2 起增加 archive(下线内容的记录)和 seenUpdate(读者看过的最后一条更新记录) + var STORE_VERSION = 2; + var ARCHIVE_LIMIT = 2000; + function emptyState() { + return { version: STORE_VERSION, profile: null, placement: null, cards: {}, done: {}, log: {}, archive: { cards: {}, done: {}, profile: null }, seenUpdate: 0 }; + } + function cleanCard(c) { + if (!c || typeof c !== 'object') return null; + var stage = Number(c.stage); + if (!(stage >= -1 && stage < INTERVALS.length && stage === Math.floor(stage))) return null; + return { + stage: stage, due: isDate(c.due) ? c.due : null, + reps: Math.max(0, Math.floor(Number(c.reps) || 0)), lapses: Math.max(0, Math.floor(Number(c.lapses) || 0)), + last: isDate(c.last) ? c.last : null + }; + } + function cleanProfile(p) { + if (!p || typeof p !== 'object' || typeof p.route !== 'string' || !/^[a-z][a-z0-9-]{0,30}$/.test(p.route)) return null; + var minutes = Number(p.minutes); + return { + route: p.route, + minutes: [60, 90, 120, 180].indexOf(minutes) !== -1 ? minutes : 90, + deadline: isDate(p.deadline) ? p.deadline : null, + start: isDate(p.start) ? p.start : null + }; + } + // 编号只允许小写字母和数字;查找时只看对象自身的属性,避免 constructor 这类名字碰到原型上的属性 + var QUESTION_ID = /^[0-9a-z]{1,32}$/, DAY_ID = /^[a-z][a-z0-9]*-\d{2,}$/; + function own(map, key) { return !!map && Object.prototype.hasOwnProperty.call(map, key); } + function limit(map) { + var keys = Object.keys(map); + if (keys.length <= ARCHIVE_LIMIT) return map; + var out = {}; + keys.slice(keys.length - ARCHIVE_LIMIT).forEach(function (k) { out[k] = map[k]; }); + return out; + } + + // 读入记录:来自本机的旧版本或用户选择的文件,逐项检查,只保留认识的字段。 + // 题目或天在当前内容中找不到(已下线,或来自更新的版本)时放进 archive,不丢弃;它们重新上线后自动恢复。 + function sanitize(input, data) { + if (!input || typeof input !== 'object' || typeof input.version !== 'number' || input.version < 1) throw new Error('不是本站导出的学习记录'); + if (input.version > STORE_VERSION) throw new Error('这份记录来自更新版本的学习站,请刷新页面后再试'); + var questions = data.questions, days = data.days || {}, routes = data.routes; + var out = emptyState(), routeIds = routes.map(function (r) { return r.id; }); + var archive = input.archive && typeof input.archive === 'object' ? input.archive : {}; + function eachCard(map, fn) { + if (!map || typeof map !== 'object') return; + Object.keys(map).forEach(function (id) { if (QUESTION_ID.test(id)) { var c = cleanCard(map[id]); if (c) fn(id, c); } }); + } + // 先放归档中的,再放当前的:同一道题两边都有时以当前的为准 + [archive.cards, input.cards].forEach(function (map) { + eachCard(map, function (id, c) { + if (own(questions, id)) { out.cards[id] = c; delete out.archive.cards[id]; } else out.archive.cards[id] = c; + }); + }); + [archive.done, input.done].forEach(function (map) { + if (!map || typeof map !== 'object') return; + Object.keys(map).forEach(function (id) { + if (!DAY_ID.test(id) || !isDate(map[id])) return; + if (own(days, id)) { out.done[id] = map[id]; delete out.archive.done[id]; } else out.archive.done[id] = map[id]; + }); + }); + out.archive.cards = limit(out.archive.cards); + out.archive.done = limit(out.archive.done); + var log = input.log && typeof input.log === 'object' ? input.log : {}; + Object.keys(log).forEach(function (day) { + var n = Math.floor(Number(log[day])); + if (isDate(day) && n > 0) out.log[day] = Math.min(n, 10000); + }); + // 每天一条,最多保留 10 年 + var keys = Object.keys(out.log).sort(); + if (keys.length > 3660) keys.slice(0, keys.length - 3660).forEach(function (d) { delete out.log[d]; }); + // 路线下线时把设置放进归档,读者换路线后不再需要;路线重新上线且读者没有换路线时恢复 + var profile = cleanProfile(input.profile), archived = cleanProfile(archive.profile); + if (profile && routeIds.indexOf(profile.route) !== -1) out.profile = profile; + else if (profile) out.archive.profile = profile; + else if (archived && routeIds.indexOf(archived.route) !== -1) out.profile = archived; + else if (archived) out.archive.profile = archived; + if (out.profile && out.archive.profile && out.archive.profile.route === out.profile.route) out.archive.profile = null; + var placement = input.placement || (out.profile && archive.placement); + if (placement && out.profile && placement.route === out.profile.route && Array.isArray(placement.stages)) { + out.placement = remapPlacement(placement, routes.filter(function (r) { return r.id === out.profile.route; })[0], data); + } else if (input.placement && out.archive.profile && input.placement.route === out.archive.profile.route && Array.isArray(input.placement.stages)) { + // 路线下线时定级结果也先存着,只留格式正确的字段 + out.archive.placement = { + route: input.placement.route, + stages: input.placement.stages.slice(0, 50).filter(function (st) { return st && typeof st === 'object'; }).map(function (st) { + var asked = (Array.isArray(st.asked) ? st.asked : []).filter(function (q) { return QUESTION_ID.test(q); }).slice(0, 50), ratings = {}; + asked.forEach(function (q) { var v = st.ratings && st.ratings[q]; if (v === 0 || v === 1 || v === 2) ratings[q] = v; }); + return { title: typeof st.title === 'string' ? st.title.slice(0, 100) : undefined, asked: asked, ratings: ratings, skipped: st.skipped === true }; + }) + }; + } + var seen = Math.floor(Number(input.seenUpdate)); + out.seenUpdate = seen > 0 ? seen : 0; + return out; + } + // ---------- 回答对照 ---------- + // 从参考要点里取出术语(英文名词、类名、方法名和带引号的说法),去掉题目中已经出现的和单位; + // 读者写了回答时,对照哪些提到了、哪些没提到,只作提示,不打分 + var TERM_STOP = ['a', 'an', 'the', 'is', 'of', 'to', 'in', 'and', 'or', 'vs', 'on', 'for', 'it', 'be', 'by', 'as', 'at', 'id', 'kb', 'mb', 'gb', 'tb', 'ms', 'ns', 'us', 'qps', 'tps']; + function termKey(t) { return t.toLowerCase().replace(/\(\)$/, ''); } + function keyTerms(question, points) { + var seen = {}, out = [], asked = String(question || '').toLowerCase(); + function add(t) { + var k = termKey(t); + if (k.length < 2 || TERM_STOP.indexOf(k) !== -1 || seen[k] || asked.indexOf(k) !== -1) return; + seen[k] = true; out.push(t); + } + var text = String(points || ''), m; + var en = /[A-Za-z][A-Za-z0-9+#_\-]*(?:\.[A-Za-z][A-Za-z0-9_]*)*(?:\(\))?/g, quoted = /“([^”]{2,10})”/g; + while ((m = en.exec(text))) add(m[0]); + while ((m = quoted.exec(text))) add(m[1]); + return out.slice(0, 10); + } + function compareAnswer(answer, terms) { + var a = String(answer || '').toLowerCase().replace(/\s+/g, ' '); + var hit = [], miss = []; + terms.forEach(function (t) { (a.indexOf(termKey(t)) !== -1 ? hit : miss).push(t); }); + return { hit: hit, miss: miss }; + } + + // 合并两份已经清理过的记录(换设备、两台设备都学过时用): + // 同一道题取最近一次自评的那份,同样新时取复习次数多的;完成的天取较早的日期;每天的学习量取较大的; + // 路线设置、定级以当前浏览器为准,当前没有时用导入的 + function mergeState(current, incoming) { + var out = JSON.parse(JSON.stringify(current)); + function newer(a, b) { + if (!a) return b; + if (!b) return a; + var la = a.last || '', lb = b.last || ''; + if (la !== lb) return la > lb ? a : b; + return (b.reps || 0) > (a.reps || 0) ? b : a; + } + [['cards', out.cards, incoming.cards], ['archive', out.archive.cards, incoming.archive.cards]].forEach(function (x) { + Object.keys(x[2]).forEach(function (id) { x[1][id] = newer(x[1][id], x[2][id]); }); + }); + [[out.done, incoming.done], [out.archive.done, incoming.archive.done]].forEach(function (x) { + Object.keys(x[1]).forEach(function (id) { if (!x[0][id] || x[1][id] < x[0][id]) x[0][id] = x[1][id]; }); + }); + // 当前的和归档的只留一份:题目或天在当前记录中有时,以当前为准 + Object.keys(out.cards).forEach(function (id) { delete out.archive.cards[id]; }); + Object.keys(out.done).forEach(function (id) { delete out.archive.done[id]; }); + Object.keys(incoming.log).forEach(function (d) { out.log[d] = Math.max(out.log[d] || 0, incoming.log[d]); }); + if (!out.profile) { out.profile = incoming.profile; out.placement = incoming.placement; } + else if (!out.placement && incoming.placement && incoming.profile && incoming.profile.route === out.profile.route) out.placement = incoming.placement; + if (!out.archive.profile) out.archive.profile = incoming.archive.profile; + out.seenUpdate = Math.max(out.seenUpdate || 0, incoming.seenUpdate || 0); + return out; + } + // 一份记录的概况,导入前给读者看 + function stateSummary(st, data) { + var route = st.profile && data.routes.filter(function (r) { return r.id === st.profile.route; })[0]; + var days = Object.keys(st.log).sort(); + return { route: route ? route.title : null, cards: Object.keys(st.cards).length, done: Object.keys(st.done).length, + archived: Object.keys(st.archive.cards).length + Object.keys(st.archive.done).length, last: days.length ? days[days.length - 1] : null }; + } + // 定级结果按阶段标题对应到当前路线;旧版本的记录没有标题,阶段数相同时按顺序对应。 + // 每个阶段的候选题按当前内容重新排,已经问过的题保留自评,下线的题去掉 + function remapPlacement(input, route, data) { + var old = input.stages.filter(function (s) { return s && typeof s === 'object'; }); + var byIndex = old.length === route.stages.length && old.every(function (s) { return typeof s.title !== 'string'; }); + return { + route: route.id, + stages: route.stages.map(function (stage, i) { + var match = byIndex ? old[i] : old.filter(function (s) { return s.title === stage.title; })[0]; + var fresh = stageCandidates(route, i, data); + if (!match) return { title: stage.title, pool: fresh, asked: [], ratings: {}, skipped: false }; + var asked = (Array.isArray(match.asked) ? match.asked : []).filter(function (q, k, list) { return fresh.indexOf(q) !== -1 && list.indexOf(q) === k; }); + var ratings = {}; + asked.forEach(function (q) { var v = match.ratings && match.ratings[q]; if (v === 0 || v === 1 || v === 2) ratings[q] = v; }); + asked = asked.filter(function (q) { return ratings[q] !== undefined; }); + return { title: stage.title, pool: asked.concat(fresh.filter(function (q) { return asked.indexOf(q) === -1; })), asked: asked, ratings: ratings, skipped: match.skipped === true }; + }) + }; + } + + // ---------- 内容更新 ---------- + // 读者上次看过之后的更新,按读者自己的记录归纳:学过的天里新增的题、当前路线新增的天、练过的题中修改和下线的 + function updateSummary(state, data) { + var entries = (data.updates || []).filter(function (u) { return u.seq > (state.seenUpdate || 0); }); + if (!entries.length) return null; + var routeId = state.profile && state.profile.route; + var fresh = {}, newDays = {}, edited = {}, retired = {}, changedDays = {}; + entries.forEach(function (u) { + (u.added.questions || []).forEach(function (q) { + var qd = data.questions[q]; + if (qd && !state.cards[q] && qd.days.some(function (d) { return state.done[d]; })) fresh[q] = true; + }); + (u.added.days || []).forEach(function (d) { if (data.days[d] && data.days[d].route === routeId && !state.done[d]) newDays[d] = true; }); + (u.edited.questions || []).forEach(function (q) { if (state.cards[q] && data.questions[q]) edited[q] = true; }); + (u.retired.questions || []).forEach(function (q) { if (state.archive.cards[q]) retired[q] = true; }); + (u.edited.days || []).forEach(function (d) { if (state.done[d] && data.days[d]) changedDays[d] = true; }); + }); + // 页面只带最近的若干条更新;读者落下的比这还多时,最早的几次看不到明细,只提示有过调整 + var oldest = (data.updates || [])[0]; + var summary = { + latest: entries[entries.length - 1].seq, date: entries[entries.length - 1].date, + fresh: Object.keys(fresh), newDays: Object.keys(newDays), edited: Object.keys(edited), retired: Object.keys(retired), + changedDays: Object.keys(changedDays), missedOlder: !!(oldest && state.seenUpdate > 0 && state.seenUpdate < oldest.seq - 1) + }; + summary.relevant = summary.fresh.length + summary.newDays.length + summary.edited.length + summary.retired.length + summary.changedDays.length > 0 || summary.missedOlder; + return summary; + } + // 学过的天里还没练过的题,按路线顺序;内容更新后新增的题从这里练 + function unpracticed(route, data, state) { + var out = []; + route.stages.forEach(function (stage) { + stage.days.forEach(function (d) { + if (!state.done[d]) return; + data.days[d].questions.forEach(function (q) { if (!state.cards[q] && out.indexOf(q) === -1) out.push(q); }); + }); + }); + return out; + } + + return { + today: today, addDays: addDays, daysBetween: daysBetween, isDate: isDate, + INTERVALS: INTERVALS, FORGOT: FORGOT, FUZZY: FUZZY, KNOWN: KNOWN, + newCard: newCard, schedule: schedule, isDue: isDue, dueList: dueList, streak: streak, streakInfo: streakInfo, isMilestone: isMilestone, FREEZE_EVERY: FREEZE_EVERY, activeDays: activeDays, + reviewLimit: reviewLimit, mastery: mastery, + createPlacement: createPlacement, nextPlacementQuestion: nextPlacementQuestion, answerPlacement: answerPlacement, + skipStage: skipStage, stageResult: stageResult, placementProgress: placementProgress, + dayModes: dayModes, buildPlan: buildPlan, DAY_MINUTES: DAY_MINUTES, REVIEW_MINUTES: REVIEW_MINUTES, + aiPrompt: aiPrompt, mockSet: mockSet, planCalendar: planCalendar, icsFold: icsFold, + updateSummary: updateSummary, unpracticed: unpracticed, mergeState: mergeState, stateSummary: stateSummary, keyTerms: keyTerms, compareAnswer: compareAnswer, remapPlacement: remapPlacement, emptyState: emptyState, sanitize: sanitize, STORE_VERSION: STORE_VERSION + }; +}); diff --git a/learn/src/icons/favicon-32.png b/learn/src/icons/favicon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..25356e4c6b32f9378ca09f9e4925709240b70c2b GIT binary patch literal 1113 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 literal 0 HcmV?d00001 diff --git a/learn/src/icons/icon-192.png b/learn/src/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..ce827004b21163c6b95a82473bb0aee43087beb7 GIT binary patch literal 6508 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 P0zn$8y7wxT?IQmVjkP@) literal 0 HcmV?d00001 diff --git a/learn/src/icons/maskable-512.png b/learn/src/icons/maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..a5f2677144b28a2c1152f809103b5d403bde8837 GIT binary patch literal 8182 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 + + + + + + + + CS-Notes 学习 + + + + + + + + + + + + + + + +
    +
    + CS-Notes 学习 + +
    + + 笔记 +
    +
    +
    +

    正在载入…

    + + + + + + + + diff --git a/learn/src/manifest.webmanifest b/learn/src/manifest.webmanifest new file mode 100644 index 000000000..ed109473f --- /dev/null +++ b/learn/src/manifest.webmanifest @@ -0,0 +1,16 @@ +{ + "name": "CS-Notes 学习", + "short_name": "CS-Notes 学习", + "description": "先测水平,再按天学,用间隔复习记牢面试题。", + "lang": "zh-CN", + "start_url": "./", + "scope": "./", + "display": "standalone", + "background_color": "#f6f6f8", + "theme_color": "#5b5bd6", + "icons": [ + { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/learn/src/og.png b/learn/src/og.png new file mode 100644 index 0000000000000000000000000000000000000000..76863f42b58cf8243b04bbedce712c0f67ba163f GIT binary patch literal 73063 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 0; i--) { + z = (z << 1) ^ ((z >>> 7) * 0x11D); + z ^= ((y >>> i) & 1) * x; + } + return z; + } + function divisor(degree) { + var result = [], root = 1, i, j; + for (i = 0; i < degree; i++) result.push(0); + result[degree - 1] = 1; + for (i = 0; i < degree; i++) { + for (j = 0; j < degree; j++) { + result[j] = mul(result[j], root); + if (j + 1 < degree) result[j] ^= result[j + 1]; + } + root = mul(root, 2); + } + return result; + } + function remainder(data, div) { + var result = div.map(function () { return 0; }); + data.forEach(function (b) { + var factor = b ^ result.shift(); + result.push(0); + for (var i = 0; i < div.length; i++) result[i] ^= mul(div[i], factor); + }); + return result; + } + function utf8(text) { + if (typeof TextEncoder !== 'undefined') return Array.from(new TextEncoder().encode(text)); + return Array.from(unescape(encodeURIComponent(text)), function (c) { return c.charCodeAt(0); }); + } + function capacity(v) { var b = BLOCKS[v]; return b[1] * b[2] + b[3] * b[4]; } + + function encode(text) { + var bytes = utf8(text), version = 0; + for (var v = 1; v <= 10; v++) { + var countBits = v < 10 ? 8 : 16; + if (4 + countBits + bytes.length * 8 <= capacity(v) * 8) { version = v; break; } + } + if (!version) throw new Error('内容过长,无法生成二维码'); + // 数据位流:模式指示 0100、字符数、内容、终止符,补齐到整字节后用 0xEC、0x11 填充 + var bits = []; + function put(value, n) { for (var i = n - 1; i >= 0; i--) bits.push((value >>> i) & 1); } + put(4, 4); + put(bytes.length, version < 10 ? 8 : 16); + bytes.forEach(function (b) { put(b, 8); }); + var cap = capacity(version) * 8; + put(0, Math.min(4, cap - bits.length)); + put(0, (8 - bits.length % 8) % 8); + for (var pad = 0xEC; bits.length < cap; pad ^= 0xEC ^ 0x11) put(pad, 8); + var codewords = []; + for (var i = 0; i < bits.length; i += 8) codewords.push(parseInt(bits.slice(i, i + 8).join(''), 2)); + + // 分块计算纠错码,再交错排列 + var spec = BLOCKS[version], div = divisor(spec[0]), blocks = [], offset = 0; + for (var g = 0; g < 2; g++) { + for (var k = 0; k < spec[1 + g * 2]; k++) { + var part = codewords.slice(offset, offset + spec[2 + g * 2]); + offset += part.length; + blocks.push({ data: part, ecc: remainder(part, div) }); + } + } + var all = [], maxData = Math.max.apply(null, blocks.map(function (b) { return b.data.length; })); + for (i = 0; i < maxData; i++) blocks.forEach(function (b) { if (i < b.data.length) all.push(b.data[i]); }); + for (i = 0; i < spec[0]; i++) blocks.forEach(function (b) { all.push(b.ecc[i]); }); + return draw(version, all); + } + + function draw(version, all) { + var size = version * 4 + 17, modules = [], fixed = [], x, y, i; + for (y = 0; y < size; y++) { modules.push([]); fixed.push([]); for (x = 0; x < size; x++) { modules[y].push(false); fixed[y].push(false); } } + function set(px, py, dark) { modules[py][px] = dark; fixed[py][px] = true; } + for (i = 0; i < size; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); } + [[3, 3], [size - 4, 3], [3, size - 4]].forEach(function (c) { + for (var dy = -4; dy <= 4; dy++) for (var dx = -4; dx <= 4; dx++) { + var d = Math.max(Math.abs(dx), Math.abs(dy)), xx = c[0] + dx, yy = c[1] + dy; + if (xx >= 0 && xx < size && yy >= 0 && yy < size) set(xx, yy, d !== 2 && d !== 4); + } + }); + var pos = ALIGN[version], n = pos.length; + for (i = 0; i < n; i++) for (var j = 0; j < n; j++) { + if ((i === 0 && j === 0) || (i === 0 && j === n - 1) || (i === n - 1 && j === 0)) continue; + for (var ay = -2; ay <= 2; ay++) for (var ax = -2; ax <= 2; ax++) set(pos[i] + ax, pos[j] + ay, Math.max(Math.abs(ax), Math.abs(ay)) !== 1); + } + function formatBits(mask) { + // 纠错等级 M 的指示位为 00 + var value = mask, rem = value; + for (var k = 0; k < 10; k++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537); + var b = ((value << 10) | rem) ^ 0x5412; + function bit(k) { return ((b >>> k) & 1) !== 0; } + for (k = 0; k <= 5; k++) set(8, k, bit(k)); + set(8, 7, bit(6)); set(8, 8, bit(7)); set(7, 8, bit(8)); + for (k = 9; k < 15; k++) set(14 - k, 8, bit(k)); + for (k = 0; k < 8; k++) set(size - 1 - k, 8, bit(k)); + for (k = 8; k < 15; k++) set(8, size - 15 + k, bit(k)); + set(8, size - 8, true); + } + formatBits(0); + if (version >= 7) { + var rem = version; + for (i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1F25); + var vb = (version << 12) | rem; + for (i = 0; i < 18; i++) { + var on = ((vb >>> i) & 1) !== 0, a = size - 11 + i % 3, b = Math.floor(i / 3); + set(a, b, on); set(b, a, on); + } + } + // 从右下角开始,两列一组,按之字形放入数据位 + var bit = 0; + for (var right = size - 1; right >= 1; right -= 2) { + if (right === 6) right = 5; + for (var vert = 0; vert < size; vert++) for (var c = 0; c < 2; c++) { + x = right - c; + y = ((right + 1) & 2) === 0 ? size - 1 - vert : vert; + if (!fixed[y][x] && bit < all.length * 8) { + modules[y][x] = ((all[bit >>> 3] >>> (7 - (bit & 7))) & 1) !== 0; + bit++; + } + } + } + var MASKS = [ + function (x, y) { return (x + y) % 2 === 0; }, function (x, y) { return y % 2 === 0; }, + function (x) { return x % 3 === 0; }, function (x, y) { return (x + y) % 3 === 0; }, + function (x, y) { return (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0; }, + function (x, y) { return x * y % 2 + x * y % 3 === 0; }, + function (x, y) { return (x * y % 2 + x * y % 3) % 2 === 0; }, + function (x, y) { return ((x + y) % 2 + x * y % 3) % 2 === 0; }]; + function apply(m) { + for (var yy = 0; yy < size; yy++) for (var xx = 0; xx < size; xx++) if (!fixed[yy][xx] && MASKS[m](xx, yy)) modules[yy][xx] = !modules[yy][xx]; + } + // 选罚分最低的掩码(计算连续同色、2×2 同色块和深浅比例三项) + var best = 0, bestScore = Infinity; + for (var m = 0; m < 8; m++) { + apply(m); formatBits(m); + var score = penalty(modules, size); + if (score < bestScore) { best = m; bestScore = score; } + apply(m); + } + apply(best); formatBits(best); + return { version: version, size: size, modules: modules }; + } + function penalty(mod, size) { + var score = 0, dark = 0, x, y; + for (var dir = 0; dir < 2; dir++) { + for (var a = 0; a < size; a++) { + var run = 1; + for (var b = 1; b <= size; b++) { + var cur = b < size ? (dir ? mod[b][a] : mod[a][b]) : null, prev = dir ? mod[b - 1][a] : mod[a][b - 1]; + if (cur === prev) run++; + else { if (run >= 5) score += run - 2; run = 1; } + } + } + } + for (y = 0; y < size; y++) for (x = 0; x < size; x++) { + if (mod[y][x]) dark++; + if (x < size - 1 && y < size - 1 && mod[y][x] === mod[y][x + 1] && mod[y][x] === mod[y + 1][x] && mod[y][x] === mod[y + 1][x + 1]) score += 3; + } + return score + Math.floor(Math.abs(dark * 20 - size * size * 10) / (size * size)) * 10; + } + return { encode: encode }; +}); diff --git a/learn/src/sw.js b/learn/src/sw.js new file mode 100644 index 000000000..b103619e1 --- /dev/null +++ b/learn/src/sw.js @@ -0,0 +1,22 @@ +// 离线缓存:安装时缓存本次构建的全部文件,之后优先读缓存;构建号变化时换用新的缓存并删除旧的。 +// 下面的构建号和文件列表由 build.py 写入。 +var CACHE = 'cs-learn-__BUILD__'; +var FILES = __FILES__; +self.addEventListener('install', function (event) { + event.waitUntil(caches.open(CACHE).then(function (cache) { return cache.addAll(FILES); }).then(function () { return self.skipWaiting(); })); +}); +self.addEventListener('activate', function (event) { + event.waitUntil(caches.keys().then(function (keys) { + return Promise.all(keys.filter(function (k) { return k.indexOf('cs-learn-') === 0 && k !== CACHE; }).map(function (k) { return caches.delete(k); })); + }).then(function () { return self.clients.claim(); })); +}); +self.addEventListener('fetch', function (event) { + var request = event.request; + if (request.method !== 'GET' || new URL(request.url).origin !== self.location.origin) return; + // 页面本身先走网络,保证更新后能拿到新的入口;离线时用缓存 + if (request.mode === 'navigate') { + event.respondWith(fetch(request).catch(function () { return caches.match('./'); })); + return; + } + event.respondWith(caches.match(request).then(function (hit) { return hit || fetch(request); })); +}); diff --git a/learn/tests/core.test.cjs b/learn/tests/core.test.cjs new file mode 100644 index 000000000..f3aa9de3d --- /dev/null +++ b/learn/tests/core.test.cjs @@ -0,0 +1,480 @@ +// 核心逻辑的单元测试:node learn/tests/core.test.cjs [data.json] +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const C = require('../src/core.js'); + +let passed = 0; +function test(name, fn) { + try { fn(); passed++; } catch (error) { console.error('失败:' + name); throw error; } +} + +// ---------- 日期 ---------- +test('日期加减跨月、跨年和闰年', () => { + assert.equal(C.addDays('2026-01-31', 1), '2026-02-01'); + assert.equal(C.addDays('2026-12-31', 1), '2027-01-01'); + assert.equal(C.addDays('2028-02-28', 1), '2028-02-29'); + assert.equal(C.addDays('2026-03-01', -1), '2026-02-28'); + assert.equal(C.daysBetween('2026-10-30', '2026-11-30'), 31); + assert.equal(C.today(new Date(2026, 9, 30, 23, 59)), '2026-10-30'); +}); +test('日期校验拒绝不存在的日子和其它格式', () => { + assert(C.isDate('2026-02-28')); + assert(!C.isDate('2026-02-30')); + assert(!C.isDate('2026-2-3')); + assert(!C.isDate('