Files
2026-05-10 23:08:29 +08:00

2366 lines
75 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>英语六级每日备考编辑器</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='18' fill='%2307110f'/%3E%3Crect x='7' y='7' width='50' height='50' rx='15' fill='%2310231f' stroke='%2365ffb7' stroke-width='2'/%3E%3Cpath d='M19 42c7-1 11-7 12-19 8 5 12 11 14 19' fill='none' stroke='%2365ffb7' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='44' cy='20' r='4' fill='%2364d9ff'/%3E%3C/svg%3E">
<style>
:root {
color-scheme: dark;
--bg: #07110f;
--bg-soft: #0b1916;
--panel: #10231f;
--panel-2: #132b25;
--panel-3: #18342d;
--line: rgba(156, 255, 205, 0.16);
--line-strong: rgba(156, 255, 205, 0.32);
--text: #ecfff6;
--muted: #9bb9ad;
--muted-2: #6f8c82;
--mint: #65ffb7;
--mint-2: #21c987;
--cyan: #64d9ff;
--amber: #ffd57a;
--rose: #ff7fb5;
--danger: #ff8d7a;
--shadow: 0 22px 70px rgba(0, 0, 0, 0.42);
--glow: 0 0 0 1px rgba(101, 255, 183, 0.25), 0 0 28px rgba(101, 255, 183, 0.16);
--radius-lg: 28px;
--radius-md: 20px;
--radius-sm: 14px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
* {
box-sizing: border-box;
}
html {
min-height: 100%;
background: var(--bg);
}
body {
min-height: 100vh;
margin: 0;
color: var(--text);
font-family: var(--sans);
background:
radial-gradient(circle at 12% 4%, rgba(64, 217, 161, 0.18), transparent 30rem),
radial-gradient(circle at 86% 18%, rgba(100, 217, 255, 0.12), transparent 28rem),
linear-gradient(180deg, #06100e 0%, #091713 48%, #07110f 100%);
letter-spacing: 0;
}
button,
input,
textarea,
select {
font: inherit;
}
button {
border: 0;
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.52;
}
a {
color: inherit;
text-decoration: none;
}
.app-shell {
width: min(1440px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 48px;
}
.topbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px;
align-items: center;
margin-bottom: 18px;
}
.brand {
display: flex;
gap: 14px;
align-items: center;
min-width: 0;
}
.brand-mark {
display: grid;
width: 48px;
height: 48px;
place-items: center;
flex: 0 0 auto;
border: 1px solid var(--line-strong);
border-radius: 18px;
color: var(--mint);
background: linear-gradient(145deg, rgba(18, 52, 43, 0.92), rgba(8, 25, 21, 0.94));
box-shadow: var(--glow);
font-family: var(--mono);
font-weight: 800;
}
.brand h1 {
margin: 0;
font-size: clamp(24px, 3vw, 42px);
line-height: 1.08;
letter-spacing: 0;
}
.brand p {
margin: 7px 0 0;
color: var(--muted);
font-size: 14px;
}
.top-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.button {
display: inline-flex;
min-height: 44px;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 16px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--text);
background: rgba(16, 35, 31, 0.78);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
white-space: nowrap;
}
.button:hover {
transform: translateY(-1px);
border-color: var(--line-strong);
background: rgba(21, 49, 42, 0.9);
}
.button.primary {
color: #032117;
border-color: rgba(101, 255, 183, 0.58);
background: linear-gradient(135deg, var(--mint), #45e6a4 58%, #a7ffd4);
box-shadow: 0 0 24px rgba(101, 255, 183, 0.28);
font-weight: 800;
}
.button.primary:hover {
box-shadow: 0 0 32px rgba(101, 255, 183, 0.38);
}
.button.ghost {
color: var(--mint);
border-color: rgba(101, 255, 183, 0.24);
}
.button.small {
min-height: 34px;
padding: 0 12px;
font-size: 13px;
}
.status-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 18px;
}
.status-pill {
min-height: 52px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(10, 25, 21, 0.72);
overflow: hidden;
}
.status-pill span {
display: block;
color: var(--muted-2);
font-size: 12px;
line-height: 1.2;
}
.status-pill strong {
display: block;
margin-top: 5px;
overflow: hidden;
color: var(--text);
font-size: 14px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-pill .current-date {
color: var(--mint);
text-shadow: 0 0 18px rgba(101, 255, 183, 0.28);
}
.quick-grid {
display: grid;
grid-template-columns: minmax(280px, 0.9fr) minmax(320px, 1.1fr);
gap: 16px;
margin-bottom: 18px;
}
.shanbay-card,
.poem-card,
.panel {
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background:
linear-gradient(160deg, rgba(19, 47, 39, 0.9), rgba(9, 23, 20, 0.92)),
rgba(16, 35, 31, 0.9);
box-shadow: var(--shadow);
}
.shanbay-card,
.poem-card {
min-height: 156px;
padding: 22px;
position: relative;
overflow: hidden;
}
.shanbay-card::after,
.poem-card::after {
content: "";
position: absolute;
inset: auto 20px 18px auto;
width: 116px;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(101, 255, 183, 0.58));
}
.card-kicker {
margin: 0 0 12px;
color: var(--mint);
font-size: 12px;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.shanbay-card h2,
.poem-card h2 {
margin: 0;
font-size: 22px;
line-height: 1.25;
}
.shanbay-card p,
.poem-meta {
margin: 10px 0 0;
color: var(--muted);
font-size: 14px;
line-height: 1.6;
}
.shanbay-card .button {
margin-top: 18px;
}
.poem-content {
min-height: 54px;
margin: 0;
color: var(--text);
font-size: clamp(18px, 2.2vw, 28px);
line-height: 1.45;
text-wrap: balance;
}
.poem-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
}
.main-grid {
display: grid;
grid-template-columns: minmax(0, 1.08fr) minmax(360px, 0.92fr);
gap: 18px;
align-items: start;
}
.panel {
overflow: hidden;
}
.panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 22px 22px 0;
}
.panel-tools {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.panel-title {
margin: 0;
font-size: 18px;
line-height: 1.25;
}
.panel-subtitle {
margin: 7px 0 0;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.panel-body {
padding: 20px 22px 22px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.field {
min-width: 0;
}
label,
.field-label {
display: block;
margin: 0 0 7px;
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
.backfill-control {
display: inline-flex;
min-height: 34px;
align-items: center;
gap: 8px;
padding: 0 10px;
border: 1px solid rgba(156, 255, 205, 0.14);
border-radius: 999px;
color: var(--muted);
background: rgba(16, 35, 31, 0.62);
font-size: 13px;
white-space: nowrap;
}
.backfill-control input {
width: 138px;
min-height: 28px;
padding: 0 8px;
border-radius: 999px;
font-size: 13px;
}
input,
textarea,
select {
width: 100%;
border: 1px solid rgba(156, 255, 205, 0.14);
border-radius: var(--radius-sm);
color: var(--text);
background: rgba(5, 15, 13, 0.58);
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
input,
select {
min-height: 42px;
padding: 0 12px;
}
textarea {
min-height: 92px;
resize: vertical;
padding: 12px;
line-height: 1.55;
}
input:focus,
textarea:focus,
select:focus {
border-color: rgba(101, 255, 183, 0.54);
background: rgba(7, 23, 19, 0.88);
box-shadow: 0 0 0 3px rgba(101, 255, 183, 0.09);
}
input[type="date"],
input[type="number"] {
color-scheme: dark;
}
.section {
padding: 16px 0 0;
margin-top: 16px;
border-top: 1px solid var(--line);
}
.section:first-of-type {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.section-title h3 {
margin: 0;
font-size: 15px;
line-height: 1.3;
}
.table-wrap {
overflow-x: auto;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: rgba(5, 15, 13, 0.32);
}
table {
width: 100%;
min-width: 560px;
border-collapse: collapse;
}
th,
td {
padding: 10px;
border-bottom: 1px solid rgba(156, 255, 205, 0.1);
text-align: left;
vertical-align: middle;
}
th {
color: var(--muted);
background: rgba(101, 255, 183, 0.05);
font-size: 12px;
font-weight: 800;
}
tr:last-child td {
border-bottom: 0;
}
td.module-cell {
width: 92px;
color: var(--text);
font-weight: 800;
white-space: nowrap;
}
.two-col {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.three-col {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.form-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 18px;
}
.stats-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-bottom: 16px;
}
.metric {
min-height: 84px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 20px;
background: rgba(5, 15, 13, 0.38);
}
.metric span {
display: block;
color: var(--muted);
font-size: 12px;
}
.metric strong {
display: block;
margin-top: 8px;
color: var(--mint);
font-size: 24px;
line-height: 1.1;
text-shadow: 0 0 18px rgba(101, 255, 183, 0.2);
}
.chart-controls {
display: grid;
grid-template-columns: minmax(160px, 0.7fr) minmax(0, 1.3fr);
gap: 12px;
align-items: end;
margin-bottom: 16px;
padding: 14px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: rgba(5, 15, 13, 0.32);
}
.chart-mode {
min-width: 0;
}
.series-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.check-pill {
display: inline-flex;
min-height: 38px;
align-items: center;
gap: 8px;
padding: 0 11px;
border: 1px solid rgba(156, 255, 205, 0.14);
border-radius: 999px;
color: var(--muted);
background: rgba(16, 35, 31, 0.62);
font-size: 13px;
white-space: nowrap;
}
.check-pill input {
width: 14px;
min-height: 14px;
accent-color: var(--mint);
}
.check-swatch {
width: 9px;
height: 9px;
border-radius: 999px;
box-shadow: 0 0 10px currentColor;
}
.chart-shell {
min-height: 320px;
padding: 14px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: rgba(5, 15, 13, 0.38);
}
.chart-svg {
display: block;
width: 100%;
height: 270px;
}
.chart-empty,
.heatmap-empty {
display: grid;
min-height: 220px;
place-items: center;
color: var(--muted);
text-align: center;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
margin-top: 12px;
color: var(--muted);
font-size: 12px;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 7px;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 999px;
}
.heatmap-shell {
margin-top: 16px;
padding: 14px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: rgba(5, 15, 13, 0.38);
overflow: hidden;
}
.heatmap-title {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
color: var(--muted);
font-size: 12px;
}
.heatmap-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(24px, 1fr));
gap: clamp(5px, 1.1vw, 9px);
width: 100%;
}
.heat-cell {
width: 100%;
aspect-ratio: 1;
border: 1px solid rgba(255, 255, 255, 0.03);
border-radius: clamp(6px, 1.2vw, 10px);
background: rgba(255, 255, 255, 0.045);
}
.heat-cell[data-level="1"] {
background: rgba(59, 171, 120, 0.38);
}
.heat-cell[data-level="2"] {
background: rgba(66, 214, 144, 0.58);
}
.heat-cell[data-level="3"] {
background: rgba(101, 255, 183, 0.78);
box-shadow: 0 0 10px rgba(101, 255, 183, 0.22);
}
.heat-cell[data-level="4"] {
background: #a7ffd4;
box-shadow: 0 0 14px rgba(101, 255, 183, 0.35);
}
.preview {
margin-top: 16px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: rgba(5, 15, 13, 0.5);
overflow: hidden;
}
.preview summary {
padding: 14px 16px;
color: var(--muted);
cursor: pointer;
font-weight: 800;
}
.preview pre {
max-height: 420px;
margin: 0;
padding: 16px;
overflow: auto;
border-top: 1px solid var(--line);
color: #dffced;
font-family: var(--mono);
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.toast {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 20;
max-width: min(420px, calc(100vw - 40px));
padding: 14px 16px;
border: 1px solid var(--line-strong);
border-radius: 18px;
color: var(--text);
background: rgba(8, 22, 18, 0.94);
box-shadow: var(--shadow);
opacity: 0;
transform: translateY(12px);
pointer-events: none;
transition: opacity 180ms ease, transform 180ms ease;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.toast.error {
border-color: rgba(255, 141, 122, 0.42);
}
.hidden {
display: none !important;
}
@media (max-width: 1120px) {
.main-grid,
.quick-grid {
grid-template-columns: 1fr;
}
.main-grid {
align-items: stretch;
}
}
@media (max-width: 820px) {
.app-shell {
width: min(100% - 20px, 1440px);
padding-top: 18px;
}
.topbar {
grid-template-columns: 1fr;
}
.top-actions {
justify-content: flex-start;
}
.status-row,
.stats-summary,
.chart-controls,
.form-grid,
.two-col,
.three-col {
grid-template-columns: 1fr;
}
.panel-header {
display: block;
}
.panel-tools {
justify-content: flex-start;
margin-top: 12px;
}
.panel-header .button {
margin-top: 12px;
}
}
@media (max-width: 520px) {
.brand {
align-items: flex-start;
}
.brand-mark {
width: 42px;
height: 42px;
border-radius: 16px;
}
.top-actions,
.form-actions,
.poem-footer,
.panel-tools {
align-items: stretch;
flex-direction: column;
}
.button {
width: 100%;
}
.backfill-control {
justify-content: space-between;
}
.backfill-control input {
width: min(190px, 54vw);
}
.shanbay-card,
.poem-card,
.panel-header,
.panel-body {
padding-left: 16px;
padding-right: 16px;
}
}
</style>
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div class="brand">
<div class="brand-mark" aria-hidden="true">C6</div>
<div>
<h1>英语六级每日备考编辑器</h1>
<p>填写今天的学习记录,保存为 Markdown并从历史日志生成趋势统计。</p>
</div>
</div>
<div class="top-actions">
<button class="button ghost" id="chooseDirButton" type="button">选择日志目录</button>
<button class="button" id="scanButton" type="button">刷新统计</button>
<button class="button primary" id="saveButton" type="button">保存 Markdown</button>
</div>
</header>
<section class="status-row" aria-label="状态信息">
<div class="status-pill">
<span>今天</span>
<strong class="current-date" id="todayStatus">-</strong>
</div>
<div class="status-pill">
<span>目录授权</span>
<strong id="directoryStatus">未选择目录</strong>
</div>
<div class="status-pill">
<span>保存状态</span>
<strong id="saveStatus">等待填写</strong>
</div>
</section>
<section class="quick-grid">
<article class="shanbay-card">
<p class="card-kicker">Words</p>
<h2>扇贝背单词</h2>
<p>快速进入今日单词学习。</p>
<a class="button primary" href="https://web.shanbay.com/wordsweb/#/study/entry" target="_blank" rel="noopener noreferrer">打开扇贝</a>
</article>
<article class="poem-card">
<p class="card-kicker">Daily Line</p>
<blockquote class="poem-content" id="poemContent">正在加载今日诗句...</blockquote>
<div class="poem-footer">
<p class="poem-meta" id="poemMeta">今日诗词 API</p>
<button class="button small" id="poemRetryButton" type="button">重试</button>
</div>
</article>
</section>
<main class="main-grid">
<section class="panel" aria-label="每日学习记录表单">
<div class="panel-header">
<div>
<h2 class="panel-title">今日记录</h2>
<p class="panel-subtitle">字段会按模板生成 Markdown。</p>
</div>
<div class="panel-tools">
<label class="backfill-control" for="backfillDateInput">
补录日期
<input id="backfillDateInput" type="date">
</label>
<button class="button small" id="backfillButton" type="button">进入补录</button>
<button class="button small" id="resetDraftButton" type="button">清空当天草稿</button>
</div>
</div>
<div class="panel-body">
<form id="studyForm">
<section class="section">
<div class="form-grid">
<div class="field">
<label for="dateInput">日期</label>
<input id="dateInput" name="date" type="date">
</div>
<div class="field">
<label for="dayInput">Day</label>
<input id="dayInput" name="day" type="number" min="1" step="1">
</div>
<div class="field">
<label for="durationInput">总时长</label>
<input id="durationInput" name="duration" type="text" placeholder="例如 2h 或 90分钟">
</div>
<div class="field">
<label for="focusInput">专注度</label>
<input id="focusInput" name="focus" type="number" min="1" max="10" step="1" placeholder="1-10">
</div>
</div>
</section>
<section class="section">
<div class="section-title">
<h3>今日任务完成情况</h3>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>模块</th>
<th>内容</th>
<th>完成情况</th>
</tr>
</thead>
<tbody id="taskRows"></tbody>
</table>
</div>
</section>
<section class="section">
<div class="section-title">
<h3>今日真题记录</h3>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>真题年份</th>
<th>模块</th>
<th>正确率/情况</th>
</tr>
</thead>
<tbody id="examRows"></tbody>
</table>
</div>
</section>
<section class="section">
<div class="section-title">
<h3>今日错题分析</h3>
</div>
<div class="two-col">
<div>
<span class="field-label">听力</span>
<div class="three-col">
<div class="field">
<label for="listenReason">错因</label>
<textarea id="listenReason" name="listenReason"></textarea>
</div>
<div class="field">
<label for="listenKeyword">是否没听到关键词</label>
<textarea id="listenKeyword" name="listenKeyword"></textarea>
</div>
<div class="field">
<label for="listenLocate">是否定位失败</label>
<textarea id="listenLocate" name="listenLocate"></textarea>
</div>
</div>
</div>
<div>
<span class="field-label">阅读</span>
<div class="three-col">
<div class="field">
<label for="readReason">错因</label>
<textarea id="readReason" name="readReason"></textarea>
</div>
<div class="field">
<label for="readSynonym">是否同义替换没识别</label>
<textarea id="readSynonym" name="readSynonym"></textarea>
</div>
<div class="field">
<label for="readTime">是否时间不够</label>
<textarea id="readTime" name="readTime"></textarea>
</div>
</div>
</div>
</div>
</section>
<section class="section">
<div class="section-title">
<h3>错题记录</h3>
<label class="check-pill">
<input id="overwriteWrongRecordInput" type="checkbox">
更新已保存错题记录
</label>
</div>
<div class="field">
<label for="wrongRecordInput">Obsidian/PDF 插件语法原文</label>
<textarea id="wrongRecordInput" name="wrongRecord" placeholder="这里可以直接写 Obsidian 或 PDF 快捷插件需要的原始语法;如果今日真题记录里填写了听力或阅读类正确率,保存时会自动补入对应错题栏目。"></textarea>
</div>
</section>
<section class="section">
<div class="section-title">
<h3>今日积累</h3>
</div>
<div class="two-col">
<div class="field">
<label for="vocabInput">生词</label>
<textarea id="vocabInput" name="vocab" placeholder="每行一个"></textarea>
</div>
<div class="field">
<label for="expressionInput">好句/作文表达</label>
<textarea id="expressionInput" name="expressions" placeholder="每行一条"></textarea>
</div>
</div>
</section>
<section class="section">
<div class="section-title">
<h3>今日总结</h3>
</div>
<div class="two-col">
<div class="field">
<label for="problemInput">今天最大问题</label>
<textarea id="problemInput" name="problem"></textarea>
</div>
<div class="field">
<label for="tomorrowInput">明天重点</label>
<textarea id="tomorrowInput" name="tomorrow"></textarea>
</div>
</div>
</section>
</form>
<div class="form-actions">
<button class="button primary" id="saveButtonBottom" type="button">保存 Markdown</button>
<button class="button" id="downloadButton" type="button">下载当前 Markdown</button>
<button class="button" id="copyButton" type="button">复制 Markdown</button>
</div>
<details class="preview">
<summary>Markdown 预览</summary>
<pre id="markdownPreview"></pre>
</details>
</div>
</section>
<aside class="panel" aria-label="历史统计">
<div class="panel-header">
<div>
<h2 class="panel-title">历史统计</h2>
<p class="panel-subtitle">从所选目录中的 Markdown 文件解析。</p>
</div>
</div>
<div class="panel-body">
<div class="stats-summary">
<div class="metric">
<span>打卡天数</span>
<strong id="metricDays">0</strong>
</div>
<div class="metric">
<span>学习时长</span>
<strong id="metricHours">0h</strong>
</div>
<div class="metric">
<span>平均专注</span>
<strong id="metricFocus">-</strong>
</div>
</div>
<div class="chart-controls" aria-label="统计图设置">
<label class="chart-mode" for="chartModeSelect">
<span class="field-label">图表类型</span>
<select id="chartModeSelect">
<option value="accuracy">真题正确率</option>
<option value="duration">每日时长</option>
</select>
</label>
<div id="seriesBlock">
<span class="field-label">显示模块</span>
<div class="series-controls" id="seriesControls">
<label class="check-pill">
<input type="checkbox" data-chart-series value="听力" checked>
<span class="check-swatch" style="background:#65ffb7;color:#65ffb7"></span>
听力
</label>
<label class="check-pill">
<input type="checkbox" data-chart-series value="长篇阅读" checked>
<span class="check-swatch" style="background:#64d9ff;color:#64d9ff"></span>
长篇阅读
</label>
<label class="check-pill">
<input type="checkbox" data-chart-series value="仔细阅读" checked>
<span class="check-swatch" style="background:#ffd57a;color:#ffd57a"></span>
仔细阅读
</label>
<label class="check-pill">
<input type="checkbox" data-chart-series value="选词填空" checked>
<span class="check-swatch" style="background:#ff7fb5;color:#ff7fb5"></span>
选词填空
</label>
</div>
</div>
</div>
<div class="chart-shell" id="chartShell">
<div class="chart-empty">选择日志目录后显示真题正确率趋势。</div>
</div>
<div class="heatmap-shell">
<div class="heatmap-title">
<span>打卡热力图</span>
<span id="heatmapRange">最近 30 天</span>
</div>
<div id="heatmapMount">
<div class="heatmap-empty">暂无打卡数据。</div>
</div>
</div>
</div>
</aside>
</main>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script>
const DIR_HANDLE_KEY = "cet6-study-editor-dir";
const DB_NAME = "cet6-study-editor-db";
const DB_STORE = "handles";
const POEM_CACHE_KEY = "cet6-study-editor-poem";
const CHART_SETTINGS_KEY = "cet6-study-editor-chart-settings";
const MODULES = ["单词", "听力", "阅读", "翻译", "作文"];
const EXAM_MODULES = ["听力", "长篇阅读", "仔细阅读", "选词填空"];
const CHART_SERIES = [
{ key: "听力", color: "#65ffb7" },
{ key: "长篇阅读", color: "#64d9ff" },
{ key: "仔细阅读", color: "#ffd57a" },
{ key: "选词填空", color: "#ff7fb5" }
];
const els = {
chooseDirButton: document.getElementById("chooseDirButton"),
scanButton: document.getElementById("scanButton"),
saveButton: document.getElementById("saveButton"),
saveButtonBottom: document.getElementById("saveButtonBottom"),
downloadButton: document.getElementById("downloadButton"),
copyButton: document.getElementById("copyButton"),
resetDraftButton: document.getElementById("resetDraftButton"),
backfillButton: document.getElementById("backfillButton"),
backfillDateInput: document.getElementById("backfillDateInput"),
poemRetryButton: document.getElementById("poemRetryButton"),
todayStatus: document.getElementById("todayStatus"),
directoryStatus: document.getElementById("directoryStatus"),
saveStatus: document.getElementById("saveStatus"),
studyForm: document.getElementById("studyForm"),
dateInput: document.getElementById("dateInput"),
dayInput: document.getElementById("dayInput"),
durationInput: document.getElementById("durationInput"),
focusInput: document.getElementById("focusInput"),
overwriteWrongRecordInput: document.getElementById("overwriteWrongRecordInput"),
taskRows: document.getElementById("taskRows"),
examRows: document.getElementById("examRows"),
markdownPreview: document.getElementById("markdownPreview"),
poemContent: document.getElementById("poemContent"),
poemMeta: document.getElementById("poemMeta"),
chartShell: document.getElementById("chartShell"),
chartModeSelect: document.getElementById("chartModeSelect"),
seriesBlock: document.getElementById("seriesBlock"),
seriesControls: document.getElementById("seriesControls"),
seriesInputs: Array.from(document.querySelectorAll("[data-chart-series]")),
heatmapMount: document.getElementById("heatmapMount"),
heatmapRange: document.getElementById("heatmapRange"),
metricDays: document.getElementById("metricDays"),
metricHours: document.getElementById("metricHours"),
metricFocus: document.getElementById("metricFocus"),
toast: document.getElementById("toast")
};
let directoryHandle = null;
let studyEntries = [];
let toastTimer = null;
let applyingFormState = false;
let chartSettings = loadChartSettings();
function formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function parseDate(value) {
const match = String(value || "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
return Number.isNaN(date.getTime()) ? null : date;
}
function addDays(date, amount) {
const next = new Date(date);
next.setDate(next.getDate() + amount);
return next;
}
function defaultChartSettings() {
return {
mode: "accuracy",
series: Object.fromEntries(CHART_SERIES.map((series) => [series.key, true]))
};
}
function loadChartSettings() {
const defaults = defaultChartSettings();
try {
const saved = JSON.parse(localStorage.getItem(CHART_SETTINGS_KEY) || "{}");
const mode = saved.mode === "duration" ? "duration" : "accuracy";
return {
mode,
series: {
...defaults.series,
...(saved.series || {})
}
};
} catch {
return defaults;
}
}
function saveChartSettings() {
localStorage.setItem(CHART_SETTINGS_KEY, JSON.stringify(chartSettings));
}
function syncChartControls() {
if (!els.chartModeSelect) return;
els.chartModeSelect.value = chartSettings.mode;
els.seriesBlock.classList.toggle("hidden", chartSettings.mode === "duration");
els.seriesInputs.forEach((input) => {
input.checked = chartSettings.series[input.value] !== false;
});
}
function showToast(message, type = "info") {
clearTimeout(toastTimer);
els.toast.textContent = message;
els.toast.className = `toast show ${type === "error" ? "error" : ""}`;
toastTimer = setTimeout(() => {
els.toast.className = "toast";
}, 3600);
}
function setSaveStatus(text) {
els.saveStatus.textContent = text;
}
function setDirectoryStatus(text) {
els.directoryStatus.textContent = text;
}
function inferDayNumber(dateString) {
const target = parseDate(dateString);
if (!target || studyEntries.length === 0) return 1;
const sameDay = studyEntries.find((entry) => entry.date === dateString);
if (sameDay && sameDay.day) return sameDay.day;
return studyEntries.filter((entry) => entry.date && entry.date < dateString).length + 1;
}
function defaultData(date = formatDate(new Date())) {
return {
date,
day: inferDayNumber(date),
duration: "",
focus: "",
tasks: MODULES.map((module) => ({
module,
content: module === "单词" ? "高频词 + 真题词" : "",
status: ""
})),
exams: EXAM_MODULES.map((module) => ({ year: "", module, result: "" })),
listenReason: "",
listenKeyword: "",
listenLocate: "",
readReason: "",
readSynonym: "",
readTime: "",
wrongRecord: "",
vocab: "",
expressions: "",
problem: "",
tomorrow: ""
};
}
function switchToStudyDate(date, statusPrefix = "已切换日期") {
if (!parseDate(date)) {
showToast("请输入有效日期。", "error");
return;
}
const draft = loadDraft(date);
setFormData(draft || defaultData(date));
setSaveStatus(draft ? `${statusPrefix},已载入草稿` : statusPrefix);
}
function draftKey(date) {
return `cet6-study-editor-draft-${date || "unknown"}`;
}
function loadDraft(date) {
try {
const raw = localStorage.getItem(draftKey(date));
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
function saveDraft() {
if (applyingFormState) return;
const data = getFormData();
try {
localStorage.setItem(draftKey(data.date), JSON.stringify(data));
setSaveStatus("草稿已暂存");
} catch {
setSaveStatus("草稿暂存失败");
}
renderMarkdownPreview();
}
function renderTaskRows() {
els.taskRows.innerHTML = MODULES.map((module) => `
<tr data-task-row data-module="${escapeAttribute(module)}">
<td class="module-cell">${escapeHtml(module)}</td>
<td><input data-task-content type="text"></td>
<td><input data-task-status type="text" placeholder="完成/部分/未做"></td>
</tr>
`).join("");
}
function renderExamRows() {
els.examRows.innerHTML = EXAM_MODULES.map((module) => `
<tr data-exam-row data-module="${escapeAttribute(module)}">
<td><input data-exam-year type="text" placeholder="例如 2023-12"></td>
<td class="module-cell">${escapeHtml(module)}</td>
<td><input data-exam-result type="text" placeholder="例如 80% 或 12/15"></td>
</tr>
`).join("");
}
function setFormData(data) {
const safe = { ...defaultData(data?.date || formatDate(new Date())), ...(data || {}) };
applyingFormState = true;
els.dateInput.value = safe.date;
els.dayInput.value = safe.day || 1;
els.durationInput.value = safe.duration || "";
els.focusInput.value = safe.focus || "";
document.querySelectorAll("[data-task-row]").forEach((row) => {
const module = row.dataset.module;
const task = (safe.tasks || []).find((item) => item.module === module) || {};
row.querySelector("[data-task-content]").value = task.content || "";
row.querySelector("[data-task-status]").value = task.status || "";
});
document.querySelectorAll("[data-exam-row]").forEach((row) => {
const module = row.dataset.module;
const exam = (safe.exams || []).find((item) => item.module === module) || {};
row.querySelector("[data-exam-year]").value = exam.year || "";
row.querySelector("[data-exam-result]").value = exam.result || "";
});
setValue("listenReason", safe.listenReason);
setValue("listenKeyword", safe.listenKeyword);
setValue("listenLocate", safe.listenLocate);
setValue("readReason", safe.readReason);
setValue("readSynonym", safe.readSynonym);
setValue("readTime", safe.readTime);
setValue("wrongRecordInput", safe.wrongRecord);
els.overwriteWrongRecordInput.checked = false;
setValue("vocabInput", safe.vocab);
setValue("expressionInput", safe.expressions);
setValue("problemInput", safe.problem);
setValue("tomorrowInput", safe.tomorrow);
applyingFormState = false;
renderMarkdownPreview();
els.todayStatus.textContent = safe.date;
}
function setValue(id, value) {
const el = document.getElementById(id);
if (el) el.value = value || "";
}
function getValue(id) {
const el = document.getElementById(id);
return el ? el.value : "";
}
function getFormData() {
const date = els.dateInput.value || formatDate(new Date());
return {
date,
day: Number(els.dayInput.value) || inferDayNumber(date),
duration: els.durationInput.value.trim(),
focus: els.focusInput.value.trim(),
tasks: Array.from(document.querySelectorAll("[data-task-row]")).map((row) => ({
module: row.dataset.module,
content: row.querySelector("[data-task-content]").value.trim(),
status: row.querySelector("[data-task-status]").value.trim()
})),
exams: Array.from(document.querySelectorAll("[data-exam-row]")).map((row) => ({
year: row.querySelector("[data-exam-year]").value.trim(),
module: row.dataset.module,
result: row.querySelector("[data-exam-result]").value.trim()
})),
listenReason: getValue("listenReason").trim(),
listenKeyword: getValue("listenKeyword").trim(),
listenLocate: getValue("listenLocate").trim(),
readReason: getValue("readReason").trim(),
readSynonym: getValue("readSynonym").trim(),
readTime: getValue("readTime").trim(),
wrongRecord: getValue("wrongRecordInput").trim(),
vocab: getValue("vocabInput").trim(),
expressions: getValue("expressionInput").trim(),
problem: getValue("problemInput").trim(),
tomorrow: getValue("tomorrowInput").trim()
};
}
function safeCell(value) {
return String(value || "").replace(/\r?\n/g, " ").replace(/\|/g, "\\|").trim();
}
function safeInline(value) {
return String(value || "").replace(/\r?\n/g, " ").trim();
}
function bulletLines(value, minimum = 1) {
const lines = String(value || "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const count = Math.max(lines.length, minimum);
return Array.from({ length: count }, (_, index) => `- ${lines[index] || ""}`).join("\n");
}
function rawBlock(value) {
return String(value || "").trim();
}
function extractWrongRecordSection(markdown) {
const match = String(markdown || "").match(/(?:^|\n)## 错题记录\s*\n([\s\S]*?)\n---\s*\n\n## 今日积累/);
return match ? match[1].trim() : null;
}
function resolveWrongRecordForSave(currentRecord, existingRecord, shouldUpdate, autoSections = []) {
const current = String(currentRecord || "").trim();
const existing = String(existingRecord || "").trim();
let base = current;
if (existing) {
if (!shouldUpdate) {
base = existing;
} else if (!current) {
base = existing;
} else if (current.includes(existing)) {
base = current;
} else {
base = `${existing}\n\n${current}`;
}
}
return mergeWrongRecordSections(base, autoSections);
}
function getWrongRecordSaveStatus(currentRecord, existingRecord, shouldUpdate, autoSections = []) {
const current = String(currentRecord || "").trim();
const existing = String(existingRecord || "").trim();
const autoText = autoSections.length ? ",错题栏目已自动匹配" : "";
if (!existing) return autoText;
if (!shouldUpdate) return `,错题记录已保留${autoText}`;
if (!current || current.includes(existing)) return `,错题记录已更新${autoText}`;
return `,错题记录已合并更新${autoText}`;
}
function wrongRecordSectionExists(text, topic) {
return new RegExp(`^###\\s*${escapeRegExp(topic)}错题\\s*$`, "m").test(String(text || ""));
}
function getWrongRecordAutoSections(exams) {
const rows = Array.isArray(exams) ? exams : [];
const hasResult = (module) => rows.some((exam) => exam.module === module && String(exam.result || "").trim());
const sections = [];
if (hasResult("听力")) {
sections.push({ topic: "听力", modules: ["听力"] });
}
const readingModules = ["长篇阅读", "仔细阅读", "选词填空"].filter(hasResult);
if (readingModules.length) {
sections.push({ topic: "阅读", modules: readingModules });
}
return sections;
}
function buildWrongRecordSection(section) {
return `### ${section.topic}错题`;
}
function mergeWrongRecordSections(record, sections) {
let nextValue = String(record || "").trim();
const additions = sections
.filter((section) => !wrongRecordSectionExists(nextValue, section.topic))
.map(buildWrongRecordSection);
if (!additions.length) return nextValue;
return [nextValue, additions.join("\n\n")].filter(Boolean).join("\n\n");
}
function buildMarkdown(data) {
const taskRows = data.tasks.map((task) => `| ${safeCell(task.module)} | ${safeCell(task.content)} | ${safeCell(task.status)} |`).join("\n");
const examRows = data.exams.map((exam) => `| ${safeCell(exam.year)} | ${safeCell(exam.module)} | ${safeCell(exam.result)} |`).join("\n");
return `# Day ${data.day || 1}|日期:${safeInline(data.date)}
## 今日学习时长
- 总时长:${safeInline(data.duration)}
- 专注度10分制${safeInline(data.focus)}
---
## 今日任务完成情况
| 模块 | 内容 | 完成情况 |
| --- | --------- | ---- |
${taskRows}
---
## 今日真题记录
| 真题年份 | 模块 | 正确率/情况 |
|---|---|---|
${examRows}
---
## 今日错题分析
### 听力
- 错因:${safeInline(data.listenReason)}
- 是否没听到关键词:${safeInline(data.listenKeyword)}
- 是否定位失败:${safeInline(data.listenLocate)}
### 阅读
- 错因:${safeInline(data.readReason)}
- 是否同义替换没识别:${safeInline(data.readSynonym)}
- 是否时间不够:${safeInline(data.readTime)}
---
## 错题记录
${rawBlock(mergeWrongRecordSections(data.wrongRecord, getWrongRecordAutoSections(data.exams)))}
---
## 今日积累
### 生词
${bulletLines(data.vocab, 3)}
### 好句/作文表达
${bulletLines(data.expressions, 2)}
---
## 今日总结
### 今天最大问题
- ${safeInline(data.problem)}
### 明天重点
- ${safeInline(data.tomorrow)}
`;
}
function renderMarkdownPreview() {
els.markdownPreview.textContent = buildMarkdown(getFormData());
}
function getMarkdownFileName(data = getFormData()) {
return `${data.date || formatDate(new Date())}.md`;
}
function getMarkdownFolderName(data = getFormData()) {
return data.date || formatDate(new Date());
}
function getMarkdownRelativePath(data = getFormData()) {
return `${getMarkdownFolderName(data)}/${getMarkdownFileName(data)}`;
}
function downloadCurrentMarkdown() {
const data = getFormData();
const blob = new Blob([buildMarkdown(data)], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = getMarkdownFileName(data);
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
setSaveStatus("已生成下载文件");
showToast("已下载当前 Markdown。");
}
async function copyMarkdown() {
const markdown = buildMarkdown(getFormData());
try {
await navigator.clipboard.writeText(markdown);
showToast("Markdown 已复制。");
} catch {
showToast("复制失败,可以从预览区域手动复制。", "error");
}
}
function openDatabase() {
return new Promise((resolve, reject) => {
if (!("indexedDB" in window)) {
reject(new Error("IndexedDB is not available."));
return;
}
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => {
request.result.createObjectStore(DB_STORE);
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function idbGet(key) {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const tx = db.transaction(DB_STORE, "readonly");
const request = tx.objectStore(DB_STORE).get(key);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
tx.oncomplete = () => db.close();
});
}
async function idbSet(key, value) {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const tx = db.transaction(DB_STORE, "readwrite");
tx.objectStore(DB_STORE).put(value, key);
tx.oncomplete = () => {
db.close();
resolve();
};
tx.onerror = () => {
db.close();
reject(tx.error);
};
});
}
async function verifyPermission(handle, withRequest = false) {
if (!handle || typeof handle.queryPermission !== "function") return false;
const options = { mode: "readwrite" };
if ((await handle.queryPermission(options)) === "granted") return true;
if (withRequest && typeof handle.requestPermission === "function") {
return (await handle.requestPermission(options)) === "granted";
}
return false;
}
async function restoreDirectoryHandle() {
try {
const handle = await idbGet(DIR_HANDLE_KEY);
if (!handle) {
setDirectoryStatus("未选择目录");
return;
}
directoryHandle = handle;
const granted = await verifyPermission(directoryHandle, false);
setDirectoryStatus(granted ? `已授权:${directoryHandle.name}` : `需重新授权:${directoryHandle.name}`);
if (granted) await scanDirectory();
} catch {
setDirectoryStatus("未选择目录");
}
}
async function chooseDirectory() {
if (!("showDirectoryPicker" in window)) {
setDirectoryStatus("当前浏览器不支持目录写入");
showToast("当前浏览器不支持目录写入,将使用下载保存。", "error");
return null;
}
try {
const handle = await window.showDirectoryPicker({
id: "cet6-study-logs",
mode: "readwrite",
startIn: "documents"
});
directoryHandle = handle;
try {
await idbSet(DIR_HANDLE_KEY, handle);
} catch {
showToast("目录已选择,但浏览器未能持久保存授权。");
}
setDirectoryStatus(`已授权:${handle.name}`);
await scanDirectory();
return handle;
} catch (error) {
if (error && error.name !== "AbortError") {
showToast("目录选择失败。", "error");
}
return null;
}
}
async function ensureDirectory() {
if (!("showDirectoryPicker" in window)) return null;
if (!directoryHandle) return chooseDirectory();
if (await verifyPermission(directoryHandle, true)) return directoryHandle;
setDirectoryStatus(`需重新授权:${directoryHandle.name}`);
return chooseDirectory();
}
async function saveMarkdownFile() {
const data = getFormData();
const fileName = getMarkdownFileName(data);
const folderName = getMarkdownFolderName(data);
const relativePath = getMarkdownRelativePath(data);
const handle = await ensureDirectory();
if (!handle) {
downloadCurrentMarkdown();
return;
}
try {
const folderHandle = await handle.getDirectoryHandle(folderName, { create: true });
let exists = false;
let fileHandle = null;
let existingWrongRecord = null;
try {
fileHandle = await folderHandle.getFileHandle(fileName, { create: false });
exists = true;
const existingFile = await fileHandle.getFile();
existingWrongRecord = extractWrongRecordSection(await existingFile.text());
} catch (error) {
if (!error || error.name !== "NotFoundError") throw error;
}
if (exists && !window.confirm(`${relativePath} 已存在,是否覆盖?`)) {
setSaveStatus("已取消覆盖");
return;
}
const autoSections = getWrongRecordAutoSections(data.exams);
const markdown = buildMarkdown({
...data,
wrongRecord: resolveWrongRecordForSave(data.wrongRecord, existingWrongRecord, els.overwriteWrongRecordInput.checked, autoSections)
});
fileHandle = await folderHandle.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(markdown);
await writable.close();
localStorage.setItem(draftKey(data.date), JSON.stringify(data));
setSaveStatus(`已保存:${relativePath}`);
const wrongRecordText = getWrongRecordSaveStatus(data.wrongRecord, existingWrongRecord, els.overwriteWrongRecordInput.checked, autoSections);
showToast(`已保存 ${relativePath}${wrongRecordText}`);
await scanDirectory();
} catch (error) {
console.error(error);
setSaveStatus("保存失败");
showToast("保存失败,已改用下载方式。", "error");
downloadCurrentMarkdown();
}
}
async function scanDirectory() {
if (!directoryHandle) {
showToast("请先选择日志目录。");
return;
}
const granted = await verifyPermission(directoryHandle, false);
if (!granted) {
setDirectoryStatus(`需重新授权:${directoryHandle.name}`);
return;
}
setDirectoryStatus(`正在扫描:${directoryHandle.name}`);
const files = [];
try {
await collectMarkdownFiles(directoryHandle, "", files);
const entries = [];
for (const item of files) {
try {
const file = await item.handle.getFile();
const markdown = await file.text();
const parsed = parseMarkdownLog(markdown, item.path || file.name);
if (parsed.date) entries.push(parsed);
} catch (error) {
console.warn("Failed to parse markdown file", item.path, error);
}
}
studyEntries = entries.sort((a, b) => a.date.localeCompare(b.date));
setDirectoryStatus(`已授权:${directoryHandle.name}`);
renderStats();
syncCurrentDayAfterScan();
showToast(`已扫描 ${files.length} 个 Markdown 文件。`);
} catch (error) {
console.error(error);
setDirectoryStatus("扫描失败");
showToast("扫描目录失败。", "error");
}
}
async function collectMarkdownFiles(handle, prefix, files) {
for await (const [name, child] of handle.entries()) {
const path = prefix ? `${prefix}/${name}` : name;
if (child.kind === "file" && isStudyLogFile(path)) {
files.push({ path, handle: child });
} else if (child.kind === "directory") {
await collectMarkdownFiles(child, path, files);
}
}
}
function isStudyLogFile(path) {
const normalized = String(path || "").replace(/\\/g, "/");
const match = normalized.match(/(?:^|\/)(\d{4}-\d{2}-\d{2})\/(\d{4}-\d{2}-\d{2})\.md$/i);
return Boolean(match && match[1] === match[2]);
}
function syncCurrentDayAfterScan() {
const data = getFormData();
if (!data.day || Number(data.day) === 1) {
els.dayInput.value = inferDayNumber(data.date);
renderMarkdownPreview();
}
}
function parseMarkdownLog(markdown, path) {
const fileDate = (path.match(/(\d{4}-\d{2}-\d{2})/) || [])[1] || "";
const header = markdown.match(/^#\s*Day\s*(\d+)\s*|日期:\s*([0-9]{4}-[0-9]{2}-[0-9]{2})?/m);
const date = (header && header[2]) || fileDate;
const day = header && header[1] ? Number(header[1]) : null;
const durationText = readLineValue(markdown, "总时长:");
const focusText = readLineValue(markdown, "专注度10分制");
const exams = parseExamSection(markdown);
return {
path,
date,
day,
durationText,
durationMinutes: parseDurationMinutes(durationText),
focus: parseNumber(focusText),
exams
};
}
function readLineValue(markdown, label) {
const pattern = new RegExp(`${escapeRegExp(label)}\\s*([^\\n\\r]*)`);
const match = markdown.match(pattern);
return match ? match[1].trim() : "";
}
function parseExamSection(markdown) {
const sectionMatch = markdown.match(/## 今日真题记录([\s\S]*?)(?:\n---|\n## |\n# |$)/);
const section = sectionMatch ? sectionMatch[1] : "";
const result = {};
section.split(/\r?\n/).forEach((line) => {
const trimmed = line.trim();
if (!trimmed.startsWith("|") || trimmed.includes("---") || trimmed.includes("真题年份")) return;
const cells = trimmed
.replace(/^\|/, "")
.replace(/\|$/, "")
.split("|")
.map((cell) => cell.replace(/\\\|/g, "|").trim());
const module = cells[1];
const rawValue = cells[2] || "";
if (EXAM_MODULES.includes(module)) {
result[module] = {
raw: rawValue,
percent: parseAccuracy(rawValue)
};
}
});
return result;
}
function parseAccuracy(value) {
const text = String(value || "");
const percent = text.match(/(\d+(?:\.\d+)?)\s*%/);
if (percent) return clamp(Number(percent[1]), 0, 100);
const fraction = text.match(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/);
if (fraction && Number(fraction[2]) > 0) {
return clamp((Number(fraction[1]) / Number(fraction[2])) * 100, 0, 100);
}
return null;
}
function parseDurationMinutes(value) {
const text = String(value || "").toLowerCase();
if (!text.trim()) return null;
let minutes = 0;
const hourMatches = text.matchAll(/(\d+(?:\.\d+)?)\s*(小时|h|hr|hrs)/g);
for (const match of hourMatches) minutes += Number(match[1]) * 60;
const minuteMatches = text.matchAll(/(\d+(?:\.\d+)?)\s*(分钟|min|mins|m)/g);
for (const match of minuteMatches) minutes += Number(match[1]);
if (minutes > 0) return minutes;
const plain = text.match(/^\s*(\d+(?:\.\d+)?)\s*$/);
if (plain) return Number(plain[1]) * 60;
return null;
}
function parseNumber(value) {
const match = String(value || "").match(/(\d+(?:\.\d+)?)/);
return match ? Number(match[1]) : null;
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function renderStats() {
renderMetrics();
renderChart();
renderHeatmap();
}
function renderMetrics() {
const uniqueDays = new Set(studyEntries.map((entry) => entry.date)).size;
const totalMinutes = studyEntries.reduce((sum, entry) => sum + (entry.durationMinutes || 0), 0);
const focusValues = studyEntries.map((entry) => entry.focus).filter((value) => Number.isFinite(value));
const avgFocus = focusValues.length
? (focusValues.reduce((sum, value) => sum + value, 0) / focusValues.length).toFixed(1)
: "-";
els.metricDays.textContent = String(uniqueDays);
els.metricHours.textContent = totalMinutes ? `${(totalMinutes / 60).toFixed(totalMinutes >= 600 ? 0 : 1)}h` : "0h";
els.metricFocus.textContent = avgFocus;
}
function renderChart() {
if (chartSettings.mode === "duration") {
renderDurationChart();
return;
}
const activeSeries = CHART_SERIES.filter((series) => chartSettings.series[series.key] !== false);
if (!activeSeries.length) {
els.chartShell.innerHTML = `<div class="chart-empty">请至少勾选一个真题模块。</div>`;
return;
}
const rows = studyEntries.filter((entry) => {
return activeSeries.some((series) => entry.exams[series.key] && Number.isFinite(entry.exams[series.key].percent));
});
if (!rows.length) {
els.chartShell.innerHTML = `<div class="chart-empty">暂无可统计的真题正确率。填写 80% 或 12/15 后会生成趋势图。</div>`;
return;
}
const width = 760;
const height = 270;
const pad = { top: 18, right: 24, bottom: 38, left: 42 };
const chartWidth = width - pad.left - pad.right;
const chartHeight = height - pad.top - pad.bottom;
const xFor = (index) => pad.left + (rows.length === 1 ? chartWidth / 2 : (index / (rows.length - 1)) * chartWidth);
const yFor = (value) => pad.top + chartHeight - (value / 100) * chartHeight;
const xLabels = rows.map((entry, index) => {
if (rows.length > 8 && index % Math.ceil(rows.length / 6) !== 0 && index !== rows.length - 1) return "";
return entry.date.slice(5);
});
const gridLines = [0, 25, 50, 75, 100].map((value) => {
const y = yFor(value);
return `
<line x1="${pad.left}" y1="${y}" x2="${width - pad.right}" y2="${y}" stroke="rgba(156,255,205,0.12)" />
<text x="${pad.left - 10}" y="${y + 4}" text-anchor="end" fill="#6f8c82" font-size="11">${value}</text>
`;
}).join("");
const labels = xLabels.map((label, index) => {
if (!label) return "";
return `<text x="${xFor(index)}" y="${height - 12}" text-anchor="middle" fill="#6f8c82" font-size="11">${escapeHtml(label)}</text>`;
}).join("");
const seriesMarkup = activeSeries.map((series) => {
const points = rows
.map((entry, index) => {
const value = entry.exams[series.key]?.percent;
return Number.isFinite(value) ? { x: xFor(index), y: yFor(value), value, date: entry.date } : null;
})
.filter(Boolean);
if (!points.length) return "";
const path = points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`).join(" ");
const dots = points.map((point) => `
<circle cx="${point.x}" cy="${point.y}" r="4" fill="${series.color}">
<title>${point.date} ${series.key}: ${point.value.toFixed(1)}%</title>
</circle>
`).join("");
return `
<path d="${path}" fill="none" stroke="${series.color}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
${dots}
`;
}).join("");
const legend = activeSeries.map((series) => `
<span class="legend-item"><span class="legend-dot" style="background:${series.color}"></span>${series.key}</span>
`).join("");
els.chartShell.innerHTML = `
<svg class="chart-svg" viewBox="0 0 ${width} ${height}" role="img" aria-label="真题正确率趋势">
<rect x="0" y="0" width="${width}" height="${height}" rx="18" fill="rgba(5,15,13,0.18)" />
${gridLines}
<line x1="${pad.left}" y1="${pad.top}" x2="${pad.left}" y2="${height - pad.bottom}" stroke="rgba(156,255,205,0.18)" />
<line x1="${pad.left}" y1="${height - pad.bottom}" x2="${width - pad.right}" y2="${height - pad.bottom}" stroke="rgba(156,255,205,0.18)" />
${seriesMarkup}
${labels}
</svg>
<div class="legend">${legend}</div>
`;
}
function renderDurationChart() {
const rows = studyEntries.filter((entry) => Number.isFinite(entry.durationMinutes));
if (!rows.length) {
els.chartShell.innerHTML = `<div class="chart-empty">暂无可统计的每日时长。总时长可填写 2h、90分钟 或 1.5。</div>`;
return;
}
const width = 760;
const height = 270;
const pad = { top: 18, right: 24, bottom: 38, left: 46 };
const chartWidth = width - pad.left - pad.right;
const chartHeight = height - pad.top - pad.bottom;
const maxMinutes = Math.max(60, ...rows.map((entry) => entry.durationMinutes));
const step = maxMinutes > 300 ? 60 : 30;
const maxY = Math.ceil(maxMinutes / step) * step;
const xFor = (index) => pad.left + (rows.length === 1 ? chartWidth / 2 : (index / (rows.length - 1)) * chartWidth);
const yFor = (value) => pad.top + chartHeight - (value / maxY) * chartHeight;
const xLabels = rows.map((entry, index) => {
if (rows.length > 8 && index % Math.ceil(rows.length / 6) !== 0 && index !== rows.length - 1) return "";
return entry.date.slice(5);
});
const tickValues = Array.from({ length: 5 }, (_, index) => Math.round((maxY / 4) * index));
const gridLines = tickValues.map((value) => {
const y = yFor(value);
return `
<line x1="${pad.left}" y1="${y}" x2="${width - pad.right}" y2="${y}" stroke="rgba(156,255,205,0.12)" />
<text x="${pad.left - 10}" y="${y + 4}" text-anchor="end" fill="#6f8c82" font-size="11">${formatMinutesLabel(value)}</text>
`;
}).join("");
const labels = xLabels.map((label, index) => {
if (!label) return "";
return `<text x="${xFor(index)}" y="${height - 12}" text-anchor="middle" fill="#6f8c82" font-size="11">${escapeHtml(label)}</text>`;
}).join("");
const points = rows.map((entry, index) => ({
x: xFor(index),
y: yFor(entry.durationMinutes),
minutes: entry.durationMinutes,
date: entry.date
}));
const path = points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`).join(" ");
const dots = points.map((point) => `
<circle cx="${point.x}" cy="${point.y}" r="4" fill="#65ffb7">
<title>${point.date} 每日时长: ${formatMinutesLabel(point.minutes)}</title>
</circle>
`).join("");
els.chartShell.innerHTML = `
<svg class="chart-svg" viewBox="0 0 ${width} ${height}" role="img" aria-label="每日学习时长趋势">
<rect x="0" y="0" width="${width}" height="${height}" rx="18" fill="rgba(5,15,13,0.18)" />
${gridLines}
<line x1="${pad.left}" y1="${pad.top}" x2="${pad.left}" y2="${height - pad.bottom}" stroke="rgba(156,255,205,0.18)" />
<line x1="${pad.left}" y1="${height - pad.bottom}" x2="${width - pad.right}" y2="${height - pad.bottom}" stroke="rgba(156,255,205,0.18)" />
<path d="${path}" fill="none" stroke="#65ffb7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
${dots}
${labels}
</svg>
<div class="legend">
<span class="legend-item"><span class="legend-dot" style="background:#65ffb7"></span>每日时长</span>
</div>
`;
}
function formatMinutesLabel(minutes) {
if (!Number.isFinite(minutes) || minutes <= 0) return "0";
const hours = minutes / 60;
return `${Number.isInteger(hours) ? hours : hours.toFixed(1)}h`;
}
function renderHeatmap() {
if (!studyEntries.length) {
els.heatmapMount.innerHTML = `<div class="heatmap-empty">暂无打卡数据。</div>`;
els.heatmapRange.textContent = "最近 30 天";
return;
}
const today = new Date();
const end = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const start = addDays(end, -29);
const entryByDate = new Map(studyEntries.map((entry) => [entry.date, entry]));
const cells = [];
for (let date = new Date(start); date <= end; date = addDays(date, 1)) {
const dateString = formatDate(date);
const entry = entryByDate.get(dateString);
const level = entry ? durationLevel(entry.durationMinutes) : 0;
const title = entry
? `${dateString}${entry.durationText || "已打卡"}`
: `${dateString}:未打卡`;
cells.push(`<div class="heat-cell" data-level="${level}" title="${escapeAttribute(title)}"></div>`);
}
els.heatmapRange.textContent = `${formatDate(start)}${formatDate(end)}`;
els.heatmapMount.innerHTML = `<div class="heatmap-grid">${cells.join("")}</div>`;
}
function durationLevel(minutes) {
if (!Number.isFinite(minutes)) return 2;
if (minutes < 30) return 1;
if (minutes < 60) return 2;
if (minutes < 120) return 3;
return 4;
}
async function loadDailyPoem(force = false) {
const today = formatDate(new Date());
const cached = readPoemCache();
if (!force && cached && cached.date === today) {
renderPoem(cached, cached.cached ? "缓存" : "");
return;
}
els.poemContent.textContent = "正在加载今日诗句...";
els.poemMeta.textContent = "今日诗词 API";
els.poemRetryButton.disabled = true;
try {
const poem = await fetchJinrishici();
const payload = { ...poem, date: today, cached: false };
localStorage.setItem(POEM_CACHE_KEY, JSON.stringify(payload));
renderPoem(payload);
} catch (error) {
console.warn(error);
if (cached) {
renderPoem({ ...cached, cached: true }, "缓存");
showToast("今日诗句加载失败,已显示缓存。", "error");
} else {
els.poemContent.textContent = "今日诗句加载失败";
els.poemMeta.textContent = "请稍后重试";
showToast("今日诗句加载失败。", "error");
}
} finally {
els.poemRetryButton.disabled = false;
}
}
function readPoemCache() {
try {
const raw = localStorage.getItem(POEM_CACHE_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
async function fetchJinrishici() {
try {
return await fetchJinrishiciDirect();
} catch (error) {
console.warn("Direct jinrishici fetch failed, trying SDK.", error);
return fetchJinrishiciSdk();
}
}
async function fetchJinrishiciDirect() {
const token = localStorage.getItem("jinrishici-token") || localStorage.getItem("jinrishici-token-cet6");
const tokenParam = token ? `&X-User-Token=${encodeURIComponent(token)}` : "";
const response = await fetch(`https://v2.jinrishici.com/one.json?client=browser-cet6${tokenParam}`, {
credentials: "include",
mode: "cors"
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
if (payload.status !== "success") throw new Error(payload.errMessage || "今日诗词返回失败");
if (payload.token) {
localStorage.setItem("jinrishici-token-cet6", payload.token);
localStorage.setItem("jinrishici-token", payload.token);
}
return normalizePoem(payload);
}
function fetchJinrishiciSdk() {
return new Promise((resolve, reject) => {
loadJinrishiciScript()
.then(() => {
if (!window.jinrishici || typeof window.jinrishici.load !== "function") {
reject(new Error("今日诗词 SDK 未加载"));
return;
}
const timer = setTimeout(() => reject(new Error("今日诗词 SDK 超时")), 8000);
window.jinrishici.load((payload) => {
clearTimeout(timer);
try {
resolve(normalizePoem(payload));
} catch (error) {
reject(error);
}
});
})
.catch(reject);
});
}
function loadJinrishiciScript() {
return new Promise((resolve, reject) => {
if (window.jinrishici) {
resolve();
return;
}
const existing = document.querySelector("script[data-jinrishici-sdk]");
if (existing) {
existing.addEventListener("load", () => resolve(), { once: true });
existing.addEventListener("error", () => reject(new Error("今日诗词 SDK 加载失败")), { once: true });
return;
}
const script = document.createElement("script");
script.src = "https://sdk.jinrishici.com/v2/browser/jinrishici.js";
script.charset = "utf-8";
script.async = true;
script.dataset.jinrishiciSdk = "true";
script.onload = () => resolve();
script.onerror = () => reject(new Error("今日诗词 SDK 加载失败"));
document.head.append(script);
});
}
function normalizePoem(payload) {
const data = payload.data || {};
const origin = data.origin || {};
const content = data.content || data.hitokoto || "";
if (!content) throw new Error("诗句内容为空");
const pieces = [origin.dynasty, origin.author, origin.title ? `${origin.title}` : ""].filter(Boolean);
return {
content,
meta: pieces.length ? pieces.join(" · ") : "今日诗词"
};
}
function renderPoem(poem, suffix = "") {
els.poemContent.textContent = poem.content;
const suffixText = suffix ? ` · ${suffix}` : poem.cached ? " · 缓存" : "";
els.poemMeta.textContent = `${poem.meta || "今日诗词"}${suffixText}`;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function escapeAttribute(value) {
return escapeHtml(value).replace(/`/g, "&#096;");
}
function initializeEvents() {
els.chooseDirButton.addEventListener("click", chooseDirectory);
els.scanButton.addEventListener("click", scanDirectory);
els.saveButton.addEventListener("click", saveMarkdownFile);
els.saveButtonBottom.addEventListener("click", saveMarkdownFile);
els.downloadButton.addEventListener("click", downloadCurrentMarkdown);
els.copyButton.addEventListener("click", copyMarkdown);
els.poemRetryButton.addEventListener("click", () => loadDailyPoem(true));
els.chartModeSelect.addEventListener("change", () => {
chartSettings.mode = els.chartModeSelect.value === "duration" ? "duration" : "accuracy";
saveChartSettings();
syncChartControls();
renderChart();
});
els.seriesInputs.forEach((input) => {
input.addEventListener("change", () => {
chartSettings.series[input.value] = input.checked;
saveChartSettings();
renderChart();
});
});
els.backfillButton.addEventListener("click", () => {
const date = els.backfillDateInput.value || formatDate(addDays(new Date(), -1));
els.backfillDateInput.value = date;
switchToStudyDate(date, "已进入补录");
showToast(`正在补录 ${date}`);
});
els.backfillDateInput.addEventListener("change", () => {
if (!els.backfillDateInput.value) return;
switchToStudyDate(els.backfillDateInput.value, "已进入补录");
});
els.resetDraftButton.addEventListener("click", () => {
const date = els.dateInput.value || formatDate(new Date());
if (!window.confirm(`清空 ${date} 的本地草稿?`)) return;
localStorage.removeItem(draftKey(date));
setFormData(defaultData(date));
setSaveStatus("已清空当天草稿");
});
els.studyForm.addEventListener("input", (event) => {
if (event.target === els.dateInput) return;
saveDraft();
});
els.dateInput.addEventListener("change", () => {
const date = els.dateInput.value || formatDate(new Date());
switchToStudyDate(date, "已切换日期");
});
}
function initializeForm() {
renderTaskRows();
renderExamRows();
const today = formatDate(new Date());
els.backfillDateInput.max = today;
els.backfillDateInput.value = formatDate(addDays(new Date(), -1));
const draft = loadDraft(today);
setFormData(draft || defaultData(today));
els.todayStatus.textContent = today;
}
async function initialize() {
initializeForm();
initializeEvents();
syncChartControls();
renderStats();
loadDailyPoem(false);
restoreDirectoryHandle();
if (!("showDirectoryPicker" in window)) {
setDirectoryStatus("目录写入不可用,可下载保存");
}
}
initialize();
</script>
</body>
</html>