尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从熟悉度错觉到数据看板:ECharts+Python 可视化复习系统

从熟悉度错觉到数据看板:ECharts+Python 可视化复习系统 1. 为什么再看一遍这件事值得用可视化重做一次去年备考最后两周我做了一件挺蠢的事把三十多页的手写笔记从头到尾抄了第二遍。抄完的那一刻,心里踏实得不行,觉得自己掌握了。结果第三天做模拟题,同一个知识点第三次错在完全相同的位置。那一刻我才意识到我复习的不是知识是我见过这页纸的熟悉感。这种感觉在认知心理学里有个很直白的说法叫熟悉度错觉——反复阅读会让信息变得眼熟但眼熟不等于能提取。真正能被调用的记忆需要你主动把它从脑子里拽出来而不是让它在眼前晃过去。可视化复习这套方法就是冲着这个毛病来的。它做的事情听起来很朴素把复习过程中产生的所有可量化痕迹——错题分布、章节耗时、正确率走势、连续打卡天数、知识点之间的层级关系——全部转成图形摊在一块看板上让我到底哪里没学明白这件事从模糊的主观感受变成一眼能看出来的客观事实。它适合的人其实比想象中多。如果你是在校学生、备考人员或者需要长期维护某项技能的从业者只要有一批需要反复过的东西 一个能记录的过程这套方法就能落地。技术上不复杂前端一块 ECharts 看板后端一个 Python 脚本加一个 SQLite 文件最多再加个 Redis 缓存打卡状态一台普通笔记本就能跑。下面我把这个项目从数据采集到图表设计、从部署踩坑到复习节奏的完整链路拆开讲尽量把每个选择的理由说清楚而不是丢一堆配置让你抄。1.1 复习失效的真实原因信息从未被重新组织大多数人复习的流程是打开资料 → 从头读到尾 → 合上 → 感觉会了。这个流程的致命问题在于输入顺序完全由资料本身决定而不是由你的掌握程度决定。资料的第三章可能你已经滚瓜烂熟第七章才是真正的窟窿但你在第三章上花的时间和第七章一样多因为你根本不知道自己哪里弱。可视化要解决的第一个问题就是这个把优先级排序从感觉移交到数据。当你的错题按知识点聚类后画成一张矩形树图面积最大的那一块就是你最该花时间的地方。这不是什么高深的技术只是把你本来就有、但散落在各个本子和 App 里的数据重新聚合了一次。第二个问题更隐蔽。人的记忆是有偏的你更容易记住刚刚做对的那道题而忘记上周反复错的那三道。这种近因效应会让复习计划越跑越偏。折线图在这里的价值就体现出来了——它不讲感觉只把过去四周的正确率画成一条线往上还是往下纸上清清楚楚。第三个问题是反馈缺失。复习本身是一件延迟反馈的事你今天背的内容效果要到考试或者实际应用时才显现。这个延迟太长长到大脑直接放弃建立因果联系。而看板把反馈周期压缩到了今天录入 → 明天看到趋势变化这在行为设计上是很关键的一步。1.2 可视化复习的四个层次先明确自己在哪一层我把这套东西分成四层不是让你全做而是让你知道每层解决什么、成本多高避免一上来就搭大屏结果三天后弃坑。层次形态解决的核心问题上手成本维护成本L1 时间轴打卡表、进度条、甘特式条带我有没有按计划推进极低表格软件即可低L2 结构图知识点树、思维导图、依赖关系图知识之间怎么连起来中需要整理中L3 数据看板折线、柱状、热力图、矩形树图我弱在哪、趋势如何中需要脚本中L4 交互式可筛选、可下钻、实时刷新的页面快速定位具体问题高需要前后端较高L1 几乎零门槛一个带条件格式的表格就能做出热力图效果很多人卡在我要做个炫酷大屏结果连 L1 都没坚持两周。我个人建议的顺序是L1 稳定跑一个月 → L3 补上趋势分析 → 再回头做 L2 的结构化。L2 之所以放在后面是因为知识结构的梳理是件很耗心力的事没有前面的数据积累你连我这个月到底在学什么都说不清直接画知识树会变成抄目录。至于 L4它更像是给已经跑顺的流程做加速不是必需品。我的看板做了筛选和下钻之后最常用的其实只有一个交互点击某个知识点看它最近二十天的正确率变化。其他花里胡哨的功能用过两次就再也不点了。1.3 哪些内容适合可视化哪些纯属自欺欺人这点必须说清楚否则很容易把可视化做成一门自我安慰艺术。适合可视化的有明确分类维度的科目、章节、题型、有时间维度的每天、每周、有数值维度的正确率、耗时、题量、有关系维度的前置知识、关联知识点。这四类只要有其中两类画图就有意义。不适合可视化的纯推导过程、纯概念辨析、需要口述或手写才能检验的内容。比如数学证明题的思路、语文的阅读理解、外语的口语表达这些东西的可视化只能停留在我练了几道、正确率多少再往下挖是画不出来的。提示可视化能告诉你哪里有问题但它不能替你把问题解决。如果你的复习时间有八成花在做图表上两成花在真正的提取练习上那这套系统就是负收益。健康的比例大概反过来八成时间在做题和复述两成时间看板复盘。我见过最典型的翻车场景是一个朋友花了整整一个周末调 ECharts 的渐变色和阴影做出了一张特别漂亮的能力雷达图然后接下来一周没做一道题。图很漂亮数据全是空的。所以下面讲技术实现之前先把这句话放在这里——看板是仪表盘不是发动机。2. 复习数据的采集与清洗先想清楚到底要画什么技术实现里最容易翻车的地方从来不是图表是数据。大多数人的复习数据散落在三个地方纸质笔记、手机备忘录、各个做题 App 的截图。这三样东西格式不统一、字段不统一、时间口径不统一直接汇总出来的图一定是错的。所以第一步不是写代码是定字段。我的做法是先画一张表把我未来三个月想看到的每一个图形列出来然后反推需要哪些字段。比如我想看各科目错题量的周环比那就必须有三样东西日期、科目、是否错。很简单但如果你想看错题的知识点分布那就额外需要知识点标签想看同一知识点间隔多久复习一次最有效那还需要复习轮次和间隔天数。字段定的越早后面改数据的痛苦越小。我自己返工过一次第一版只有日期和科目做了两周发现根本看不出问题在哪只能回头把三百多条记录重新打标签那种感觉相当难受。2.1 三类数据源与统一字段设计我把数据源限定为三类尽量少而稳定Markdown 笔记文件每篇笔记在开头用一段元信息记录科目、标签、创建日期、复习轮次。Markdown 好处是纯文本、随时可改、天然适合版本管理。做题记录表一个 CSV 或直接在数据库里维护字段是日期、题目 ID、所属知识点、对错、耗时、难度。打卡与任务记录每天的学习时段起止时间、计划项、完成项。这部分数据量小但它是算连续天数和投入时长的唯一来源。统一到一张宽表之后字段大概是这样字段类型说明是否必填dateTEXT统一 YYYY-MM-DD不带时区是subjectTEXT科目或领域值域必须收敛是topicTEXT知识点标签可多值用竖线分隔是item_idTEXT题目或笔记的唯一标识是resultINTEGER1 正确 / 0 错误 / 2 未评是cost_minREAL耗时单位分钟否difficultyINTEGER1 到 5否roundINTEGER第几轮复习否这里有两个容易忽略的细节。第一时间一律用本地日期的字符串不要存时间戳。复习这件事的粒度是天存时间戳只会给你带来跨时区、夏令时、前端格式化三份麻烦。第二subject 和 topic 的值域必须收敛一旦你出现数学数学分析高数三个词指同一件事的情况图表立刻就成了笑话。我现在的做法是维护一个映射表任何新出现的写法先规范化再入库。2.2 用 Python 把零散笔记变成结构化数据解析 Markdown 的脚本不复杂核心是两件事识别元信息块、提取标签。我用一段---包起来的简易 YAML然后用正则处理标签。from pathlib import Path import re import sqlite3 import hashlib from datetime import date ROOT Path(./notes) TAG_RE re.compile(r#([\w\u4e00-\u9fa5\-])) def parse_front_matter(text: str) - dict: 解析文件开头的 --- 元信息块返回键值对。 meta {} if not text.startswith(---): return meta end text.find(\n---, 3) if end -1: return meta block text[3:end].strip() for line in block.splitlines(): if : in line: k, v line.split(:, 1) meta[k.strip()] v.strip() return meta def scan(conn: sqlite3.Connection) - int: count 0 for path in ROOT.rglob(*.md): text path.read_text(encodingutf-8) meta parse_front_matter(text) subject meta.get(subject, 未分类) # 标签来源元信息优先其次正文里的 #tag tags meta.get(tags, ) if not tags: tags |.join(TAG_RE.findall(text)) tags |.join(sorted(set(t.strip() for t in tags.split(|) if t.strip()))) item_id hashlib.md5(str(path).encode(utf-8)).hexdigest()[:12] mtime date.fromtimestamp(path.stat().st_mtime).isoformat() conn.execute( INSERT INTO review_log(date, subject, topic, item_id, result, round) VALUES(?,?,?,?,?,?) ON CONFLICT(item_id) DO UPDATE SET topicexcluded.topic, subjectexcluded.subject, (meta.get(date, mtime), subject, tags, item_id, 2, int(meta.get(round, 1))), ) count 1 conn.commit() return count if __name__ __main__: conn sqlite3.connect(review.db) conn.execute(CREATE TABLE IF NOT EXISTS review_log( item_id TEXT PRIMARY KEY, date TEXT, subject TEXT, topic TEXT, result INTEGER, cost_min REAL, difficulty INTEGER, round INTEGER)) print(已处理文件数, scan(conn))这段脚本里最有价值的部分其实不是解析本身而是source_file的哈希作为主键 UPSERT。复习数据是典型的反复更新同一条如果你每次全量删表重建历史轮次信息就全丢了。用文件路径哈希做主键重复运行只会更新而不会产生脏数据这一点在增量处理时特别重要。跑完这条流水线你会得到一张可以随便切的宽表。剩下的就是 SQL 的功夫了比如最近四周各科目错误率SELECT subject, COUNT(*) FILTER (WHERE result 0) * 1.0 / COUNT(*) AS err_rate, COUNT(*) AS total FROM review_log WHERE result IN (0, 1) AND date date(now, -28 day) GROUP BY subject ORDER BY err_rate DESC;2.3 存储选型SQLite、MySQL、Redis 各自负责哪一块个人复习这种规模的数据选型上完全没必要过度设计但各自负责什么要分清楚不然会出现把缓存当仓库、把仓库当缓存的混乱。存储在本项目中的角色为什么选它什么时候该换掉SQLite唯一的事实来源存全部明细单文件、零运维、备份就是复制一个文件多人协作写入或数据超千万行MySQL需要复杂分析、多端同步时的替代方案窗口函数和索引更成熟方便接 BI 工具个人场景基本用不上Redis缓存当天状态、连续天数、排行榜读写快天然适合计数和有序集合只是单机个人用其实可以省掉我的实际配置是 SQLite Redis 的组合。SQLite 存明细Redis 只做三件事当天打卡状态、连续天数计数器、以及最近一次数据更新时间戳用来给前端做短轮询的版本号。MySQL 我是后来在做需要跨设备同步的版本时才引入的个人单机完全没必要。Redis 里最有用的一个结构是ZSet用来排最需要复习的知识点。做法是把每个知识点的错误次数作为 score每次答对减一点答错加两分然后随时取 top 20 就是当前最该攻的方向# 答错一个知识点权重 2 ZINCRBY review:weakness 2 线性代数:特征值 # 答对同一个知识点权重 -1但不能低于 0 ZINCRBY review:weakness -1 线性代数:特征值 # 取当前最薄弱的 20 个 ZREVRANGE review:weakness 0 19 WITHSCORES这个设计的好处是它天然带遗忘衰减的意味——老问题只要不再答错权重会被新问题挤下去不需要你手动清理。这比维护一张静态的错题清单要省心得多。3. 用 ECharts 搭一块真正能看懂的复习看板图表选型是这块最容易走弯路的地方。网上一搜数据可视化大屏全是深色背景加发光边框、三十个图表挤在一屏的效果图。这种东西放在展厅里好看放在你自己的复习场景里基本没用因为你需要的不是信息密度高而是三秒钟看出问题在哪。我的原则是一块看板最多五个图每个图回答一个具体问题回答不了的问题就砍掉。下面这五种是我实际留下来、且用了三个月以上的。3.1 五种图表各自回答什么问题日历热力图回答我哪天没复习。它是所有图里最有行为干预效果的一张因为连续空白的格子在视觉上非常刺眼比任何提醒都管用。用 ECharts 的heatmap配calendar坐标系数据就是每天的复习条目数。折线图回答我的正确率是在涨还是在跌。每周一个点跨度至少十二周太短看不出趋势。这里有个坑不要用每日正确率画线日粒度噪声太大会画出一条剧烈震荡的锯齿让你误判。按周聚合之后再画趋势才读得出来。横向柱状图回答哪个科目占的错题最多。用横向而不是纵向是因为科目名是文字横向排列可读性好得多不需要旋转标签。矩形树图回答错题集中在哪个知识点的哪个子节点。这是最有信息量的一张但你得先把知识点做成分层结构比如高等数学 / 极限 / 未定式 / 洛必达这样四级。面积代表错题数一眼就能定位到最深的那个窟窿。雷达图回答我的能力结构是不是偏科。用五个固定维度比如理解、记忆、计算、应用、速度。说实话这张图的实际指导价值是五种里最低的它更多是给你一个整体印象不要拿它做决策依据。// 周粒度正确率折线关键是先把日数据聚合再画 option { xAxis: { type: category, data: weeks }, // [W1,W2,...] yAxis: { type: value, min: 0, max: 100, axisLabel: { formatter: {value}% } }, series: [{ type: line, smooth: false, // 复习数据不要平滑会掩盖真实波动 symbolSize: 6, data: weeklyAccuracy, // 已聚合的百分比 markLine: { silent: true, data: [{ yAxis: 85, name: 目标线 }], label: { formatter: 目标 85% } } }], grid: { left: 48, right: 24, top: 40, bottom: 32 } };markLine这个目标线是我强烈建议加上的。没有参照线的折线图你只能看出涨了还是跌了加了线之后才能看出离目标还差多少这两种信息在决策上的价值完全不同。3.2 大屏适配等比缩放方案的取舍如果你的看板就是自己电脑上看直接响应式布局就够了。但只要涉及投屏、副屏或者不同分辨率的设备适配问题一定会出现。目前主流有两种方案。方案一是 vw/rem 相对单位好处是元素真正响应式文字和间距都会跟着缩坏处是 ECharts 内部的 canvas 不会跟着缩你得手动调配置项工作量成倍增长。方案二是等比缩放把整个看板按设计稿尺寸比如 1920×1080画好然后用 transform 整体缩放function fitScreen(designW 1920, designH 1080) { const el document.getElementById(dashboard); const scaleX window.innerWidth / designW; const scaleY window.innerHeight / designH; const scale Math.min(scaleX, scaleY); // 保持比例允许留边 el.style.transform scale(${scale}); el.style.transformOrigin left top; // 居中处理留白 el.style.position absolute; el.style.left (window.innerWidth - designW * scale) / 2 px; el.style.top (window.innerHeight - designH * scale) / 2 px; } window.addEventListener(resize, fitScreen); fitScreen();我最后选的是方案二原因很实在复习看板的元素位置是固定的没有手机上重新排一遍的真实需求。等比缩放只写一次所有分辨率都能用维护成本极低。代价是超宽屏两侧会留黑边但对我来说完全可接受。注意用等比缩放时ECharts 实例必须监听 resize 并调用chart.resize()否则缩放后 canvas 里会出现模糊或者鼠标悬浮位置偏移的问题。这个坑我调了半小时才找到原因。另外还有一个 devtools 里看不出来、投屏时才暴露的问题字体最小值。设计稿上 12px 的字缩放到 1024 宽屏后实际只有 6px 左右远看完全糊成一片。我的做法是所有文字基准值不低于 14px标题不低于 24px。3.3 从静态图表到实时刷新别用 WebSocket实时刷新这件事在个人项目里被严重高估了。你的复习数据一天更新不了几次用 WebSocket 维护长连接纯属给自己找麻烦——心跳、断线重连、内存泄漏每一个都能耗掉你一晚上。真实需要的是录入之后几秒内看到变化。这个需求有三种实现路径方案延迟复杂度适用场景定时轮询10 到 60 秒极低个人看板首选SSE 服务端推送接近实时低想在录入后立刻看到更新WebSocket实时高多人协作、高频交互我第一版用的是 30 秒轮询加一个版本号做条件请求后端返回一个last_update时间戳前端每次请求带上上次拿到的值没变化就返回 304几乎不消耗资源。后来改成 SSE 是因为想在录入脚本跑完的瞬间自动刷新实现也就三十行# Flask 侧SSE 推送数据版本号 from flask import Flask, Response, jsonify import time, json app Flask(__name__) VERSION {v: 0} app.route(/api/version) def version(): return jsonify(VERSION) app.route(/events) def events(): def gen(): last -1 while True: if VERSION[v] ! last: last VERSION[v] yield fdata: {json.dumps({v: last})}\n\n time.sleep(2) return Response(gen(), mimetypetext/event-stream)前端用EventSource监听收到版本变化就重新拉一次数据。这套东西简单到不需要任何库而且断线之后浏览器会自己重连省心。4. 让看板自己动起来增量更新、缓存与节奏管理看板做出来之后最大的敌人不是技术问题是每天手动跑一遍脚本这件事本身。人对手动重复操作的耐心大概只有两周。所以自动化不是加分项是保命项。4.1 增量更新只处理变过的文件全量扫描在数据量小的时候没问题但当你有一千多个笔记文件、几万条做题记录每次跑十秒以上你就不会想跑了。增量的核心是记录每份文件的修改时间和大小只处理变化的。import sqlite3, os from pathlib import Path def scan_incremental(root./notes, dbreview.db): conn sqlite3.connect(db) conn.execute(CREATE TABLE IF NOT EXISTS file_state( path TEXT PRIMARY KEY, mtime REAL, size INTEGER)) state {r[0]: (r[1], r[2]) for r in conn.execute(SELECT path, mtime, size FROM file_state)} changed [] for p in Path(root).rglob(*.md): st p.stat() key str(p) if state.get(key) ! (st.st_mtime, st.st_size): changed.append(p) for p in changed: # 这里调用前面的单文件解析逻辑 ... st p.stat() conn.execute(INSERT INTO file_state(path, mtime, size) VALUES(?,?,?) ON CONFLICT(path) DO UPDATE SET mtimeexcluded.mtime, sizeexcluded.size, (str(p), st.st_mtime, st.st_size)) conn.commit() return len(changed)用 mtime 加 size 双字段而不是只用 mtime是因为有些编辑器保存时 mtime 精度只有秒级同一秒内的两次保存会被漏掉加上文件大小作为辅助判断能挡掉大部分这种情况。调度我用APScheduler而不是系统 cron原因是它跟着 Flask 进程一起跑不需要额外的运维配置而且任务失败了能在同一个日志里看到from apscheduler.schedulers.background import BackgroundScheduler sched BackgroundScheduler(timezoneAsia/Shanghai) sched.add_job(scan_incremental, interval, minutes10, idscan) sched.add_job(refresh_cache, interval, hours1, idcache) sched.start()提示定时任务一定要加日志和异常捕获。我踩过一次坑某个笔记文件的编码不是 UTF-8解析时抛异常整个调度线程直接挂掉然后我连续三天以为是自己没学习。后来给每个 job 外面包了 try/except出错就记录文件名继续跑。4.2 用 Redis 维护打卡状态与连续天数连续天数这个指标看起来很鸡汤但它是所有指标里对行为影响最直接的一个因为损失厌恶——你已经连续 27 天了第 28 天不打卡会特别难受。实现上要小心两个坑一是补打卡会毁掉指标的可信度二是跨零点的时间判断。我的做法是用 Redis 存每天的状态字符串连续天数按需计算而不是实时累加# 当天有记录就打标 SETBIT checkin:2024 202 1 # 202 是该年的第几天 # 判断连续天数从今天往前数连续为 1 的位数 BITCOUNT checkin:2024用 bitmap 存一年 365 天的打卡状态只需要 46 个字节查询和统计都快得离谱。连续天数用从今天往前扫描连续 1的方式算避免维护一个会漂移的计数器。这里有个细节值得说一下连续天数应该以有录入记录为准而不是以打开了看板为准。前者代表你真的做了复习后者只代表你焦虑地点开看了一眼。这两个数据千万别混在一起否则指标会立刻失去意义。4.3 用 Git 提交热力图管长期节奏但别被它绑架如果你的复习对象本身和代码相关那么 Git 提交记录是一份天然、无需录入的时间序列数据。统计方式很简单# 按天统计提交次数 git log --since120 days ago --dateshort --prettyformat:%ad \ | sort | uniq -c | sort -k2把这行输出接到热力图组件上你会得到一张和 GitHub 风格一样的贡献图。它对长期节奏管理确实有效——空白的格子会持续给你压力。但这里必须泼一盆冷水提交次数和复习质量没有任何必然关系。我见过太多人为了把格子填绿写一堆无意义的提交。所以我的建议是给这张图加一个约束只有包含实质内容变更比如新增了笔记文件、修正了错题记录的提交才计入纯格式调整或者空提交直接过滤掉。用--diff-filter参数就能做到git log --diff-filterAMR --name-only --prettyformat:%ad --dateshort \ | grep -E ^20[0-9]{2}- | sort | uniq -c这样得到的才是我真正产出内容的天数而不是我打开了编辑器的天数。5. 上线之后翻车实录六个真实踩过的坑这部分可能是整篇里最值钱的内容因为下面每一条都是我花时间换来的而不是从文档里抄的。5.1 图表越堆越多结果什么都看不清第一版看板我放了十一个图表塞满了一整屏配色还用了深色主题加发光边框。刚做完的时候看着特别有成就感截图发朋友圈都有人问用的什么工具。但真实使用第三天我就发现问题了我根本不会去看它。因为信息密度太高每次打开都要重新扫一遍才能找到想看的东西成本太高索性不看了。后来我做了个减法规则是每个图表必须能回答一个具体的、会在决策中用到的问句。回答不了的就删。十一张图砍到五张留下的正好是热力图、折线、横向柱状、矩形树图、雷达图。删完之后使用频率反而上升了因为打开三秒就能定位问题。这条经验可以推广可视化的价值不在于展示了多少信息而在于降低了多少决策成本。任何增加决策成本的设计再好看都是负分。5.2 时间口径不统一导致的数据失真这个坑非常隐蔽。我做题记录的日期用的是提交时间带时区的时间戳笔记的日期用的是文件修改时间打卡表的日期是手填的。三个来源的时间口径都不一样结果就是晚上十一点之后的学习记录会被算到第二天而如果笔记是在第二天早上补改的又会覆盖掉原来的日期。表现出来的现象是热力图上有几天特别亮有几天特别暗看起来像是学习强度剧烈波动实际上只是时间归属错乱了。我花了挺久才定位到这个问题因为图表本身没有任何报错它只是忠实地把错误数据画了出来。修复方式是把时间口径统一到事件发生的那一刻按本地日历日切分并且所有写入入口手动录入、脚本扫描、定时任务都必须走同一个日期规范化函数不允许任何地方自己生成日期字符串。这个函数就三行from datetime import datetime, timezone, timedelta def local_day(tsNone, tz_offset8): 统一日期口径本地日历日YYYY-MM-DD now ts or datetime.now(timezone.utc) return (now timedelta(hourstz_offset)).strftime(%Y-%m-%d)5.3 本地跑得好好的部署后白屏完整排查链路这个坑最典型也最有代表性我把当时的排查过程完整写下来。现象本地python app.py打开 127.0.0.1 一切正常部署到服务器之后访问域名页面全白控制台没有明显报错Network 里 index.html 是 200。第一步看控制台 Console 而不是 Network。打开之后发现一条红色的Uncaught SyntaxError: Unexpected token 。这个错误的含义是浏览器请求一个 JS 文件返回的却是 HTML。为什么是 HTML因为服务器没找到这个 JS 文件走了默认的 404 页面。第二步确认请求路径。Network 里那个 JS 的路径是/static/js/echarts.min.js而服务器上文件实际在/var/www/dashboard/static/js/。路径是对的。继续查发现是 Nginx 的location /static/配置只指向了一个不存在的目录。第三步验证静态资源能否直接访问。直接在浏览器打开那个 JS 的完整 URL返回的是后端框架的 404 页面确认了 Nginx 没有正确代理静态资源。第四步改配置并加缓存头。把location /static/的root指到项目根目录同时加上长缓存因为 ECharts 这种库文件不会变location /static/ { root /var/www/dashboard; expires 30d; add_header Cache-Control public, immutable; }第五步如果还是白屏检查相对路径和跨域。第二个白屏是我在后面加 API 时遇到的前端用相对路径请求/api/data但页面被挂在了子路径/review/下请求就变成了/review/api/data后端自然 404。解决办法是给所有 API 请求加一个统一的前缀变量而不是到处写死路径。第六步还要检查时区。服务器时区是 UTC容器里也是 UTC结果今天的数据在服务器看来还没到热力图上当天永远是空的。这个问题不会导致白屏但会让数据看起来少一天特别容易被误判成采集脚本坏了。解决办法是在容器启动时设置时区环境变量并且在应用层也用统一的本地日期函数双保险。排查这类问题我总结出一条经验白屏问题永远先怀疑资源加载再怀疑接口最后才怀疑业务逻辑。因为业务逻辑报错通常会在控制台留下明确的堆栈而资源加载失败给出的错误信息往往是间接的、看起来和问题本身无关的。5.4 数据只增不减看板越来越慢复习数据的增长速度比你想的快。每天几十条做题记录一个月就是上千条一年上万条。如果折线图每次都把全部历史数据查出来再传给前端页面加载时间会肉眼可见地变长。我的处理方式是分层聚合 时间窗口。明细数据只保留最近 90 天在前端可查更早的数据按周聚合后存入一张汇总表。折线图默认只加载最近 12 周需要看更长周期时再按需请求。聚合表每晚更新一次INSERT INTO weekly_stat(week, subject, total, err_count) SELECT strftime(%Y-%W, date) AS week, subject, COUNT(*), SUM(result 0) FROM review_log WHERE date date(now, -7 day) GROUP BY week, subject ON CONFLICT(week, subject) DO UPDATE SET total excluded.total, err_count excluded.err_count;这套做法把前端加载的数据量从全部历史压到了一个窗口 少量汇总行实际效果是首屏时间从三秒多回到了一秒以内。5.5 最容易犯的错把可视化当成了复习本身前面说了很多技术但最严重的坑其实和代码无关。有一段时间我每天雷打不动地打开看板看热力图、看折线、看矩形树图看得很投入甚至会给每个图表加注释。但那一周我的实际做题量是零。我在管理复习这件事上花的时间远远超过了复习本身。后来我给自己定了一条硬规则只有在完成当天的做题和复述任务之后才允许打开看板。看板从开始复习的入口变成结束复习的仪式。这个顺序调换之后效果完全不一样了因为看板上的每一个数字背后都是已经完成的真实工作量而不是还没做的焦虑。这条经验值得单独强调一遍可视化解决的是我不知道该复习什么和我不知道有没有进步这两个问题。它不解决我不想复习这个问题。后者只能靠习惯和节奏任何工具都替代不了。5.6 数据备份这件事别等到丢了才想起来最后一条简单但重要。你的复习数据全在一个 SQLite 文件里一旦损坏就是几个月的积累。我的做法是每天凌晨自动把数据库文件按日期复制一份保留最近 30 天同时把笔记目录本身放进 Git 仓库纯文本天然适合版本管理。#!/bin/bash # 每日备份保留 30 天 DB/var/www/dashboard/review.db DEST/backup/review mkdir -p $DEST cp $DB $DEST/review-$(date %F).db find $DEST -name *.db -mtime 30 -delete顺手加进定时任务就行两条命令的事。但它挡掉的风险是几个月数据全没了这个投入产出比不需要犹豫。6. 复习闭环看板上的数字之后该干什么图表只是中间产物真正产生效果的是看完图表之后你做了什么。如果没有一个固定的动作流程看板很快就会变成一块漂亮的壁纸。6.1 每周固定一次的三问复盘我现在的节奏是每周日晚固定花十五分钟只问三个问题每个问题都必须从看板上找到对应的图来回答第一问这周哪三个知识点的错误最集中答案来自矩形树图直接取面积最大的前三块。然后针对这三块下周安排专项练习不再泛泛地刷题。第二问周正确率曲线是涨还是跌答案来自折线图。如果是跌的继续追问是题目变难了还是投入时间少了这两个原因的处理方式完全不同。前者不用慌后者需要调整排期。第三问这周有多少天是空白的答案来自热力图。空白天数超过两天说明节奏被打断了下周的重点不是加量而是先把节奏恢复回来。三个问题对应三张图每次复盘不超过十五分钟。这个流程我跑了小半年最大的价值不是发现了什么惊人的洞见而是让调整复习计划这件事从一个模糊的感觉变成一个有依据的动作。以前我调整计划全靠最近感觉数学有点虚现在至少能说出立体几何最近三周错误率从 20% 涨到了 45%。6.2 让看板用旧而不是一直做新这是我想说的最后一点也是我自己反复掉进去的坑。做可视化的人很容易陷入一种循环做一个版本 → 用两周 → 觉得某个图不好看 → 重构 → 再做一个版本。表面上在优化工具实际上是在用做工具这件事逃避用工具这件事因为前者有即时反馈、有成就感后者枯燥且没有短期回报。我的判断标准很简单如果看板的某次改动不能让我在下周的复盘里做出一个和之前不同的决定那这个改动就不该做。按照这个标准我现在看板的样式大概已经三个月没动过了但它每周都在用累计帮我发现了七八个真正拖后腿的知识点。工具的价值从来不在于它有多精致而在于它有没有被用旧。一块配色粗糙但数据准确、每周都看的看板胜过一个动画流畅但打开三次就吃灰的大屏项目。如果你准备开始做自己的可视化复习系统我的建议是先用一个下午把 L1 的时间轴跑起来用两周看看它有没有真的改变你的复习行为。有的话再往 L3 加图表没有的话问题可能根本不在可视化上。
返回列表