2366 lines
75 KiB
HTML
2366 lines
75 KiB
HTML
<!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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
function escapeAttribute(value) {
|
||
return escapeHtml(value).replace(/`/g, "`");
|
||
}
|
||
|
||
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>
|