acmer-coach 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +28 -0
- package/README.md +258 -0
- package/assets/extension/luogu/README.md +104 -0
- package/assets/extension/luogu/content.js +137 -0
- package/assets/extension/luogu/manifest.json +22 -0
- package/assets/knowledge/DEPENDS.yaml +391 -0
- package/assets/knowledge/LICENSE +41 -0
- package/assets/knowledge/MAP.yaml +2540 -0
- package/assets/knowledge/NODE_ENTRY.yaml +465 -0
- package/assets/knowledge/NODE_META.yaml +457 -0
- package/assets/knowledge/skilltree.html +1702 -0
- package/assets/oiwiki/LICENSE +49 -0
- package/assets/oiwiki/chunks.jsonl +2177 -0
- package/assets/oiwiki/node-pages.json +1208 -0
- package/assets/pool/LICENSE +83 -0
- package/assets/pool/problems.jsonl.gz +0 -0
- package/assets/rules/coach-rules.md +425 -0
- package/build-node-meta.mjs +98 -0
- package/coach/analyze_map.py +138 -0
- package/coach/build_map.py +230 -0
- package/coach/build_node_pages.py +105 -0
- package/coach/check_page_syntax.mjs +48 -0
- package/coach/coach-metrics.py +313 -0
- package/coach/export_oiwiki_chunks.py +100 -0
- package/coach/export_problem_pool.py +64 -0
- package/coach/extract_oiwiki.py +110 -0
- package/coach/oiwiki_tree.json +2987 -0
- package/coach/render_map.py +226 -0
- package/coach/render_skilltree.py +197 -0
- package/coach/serve_map.py +181 -0
- package/coach/skilltree_template.html +1702 -0
- package/coach/validate_map.py +184 -0
- package/coach/verify_drag.mjs +51 -0
- package/coach/verify_interact.mjs +82 -0
- package/coach/verify_serve.py +138 -0
- package/cordis.patch.yml +10 -0
- package/dev/parity-check.mjs +169 -0
- package/dev/run-all.sh +70 -0
- package/dev/setup-check.mjs +191 -0
- package/dev/w1-check.mjs +170 -0
- package/dev/wiki-check.mjs +100 -0
- package/dev.sh +142 -0
- package/docs//350/256/276/350/256/241/346/226/271/351/222/210.md +325 -0
- package/index.js +5820 -0
- package/lib/client.js +209 -0
- package/lib/setup.js +455 -0
- package/package.json +33 -0
- package/progress-cli.mjs +67 -0
- package/verify-shape.mjs +315 -0
- package/verify.mjs +3535 -0
|
@@ -0,0 +1,1702 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<!-- ⚠️ style 里的背景色是**故意内联**的,不是重复。
|
|
3
|
+
坑:刷新的一瞬间会闪一下黑。因为这页常被嵌在 dsh 的暗色界面里
|
|
4
|
+
(iframe),在 CSS 解析完之前,透出来的是父页面的深色底 ——
|
|
5
|
+
看着就像"黑的技能树"。内联背景在 HTML 一到达就生效,不给透底的机会。
|
|
6
|
+
改配色时记得这一处也要跟着改(就这一行,别的地方还是走变量)。 -->
|
|
7
|
+
<html lang="zh-CN" data-theme="light" style="background:#eef5fc">
|
|
8
|
+
<head>
|
|
9
|
+
<meta charset="utf-8">
|
|
10
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
11
|
+
<!-- 声明本页是浅色的。不加这条的话,系统处于暗色模式时浏览器会用深色
|
|
12
|
+
渲染"还没加载完"的页面(默认背景/滚动条/表单控件)—— 和上面内联背景
|
|
13
|
+
是防的同一件事,两个都要。 -->
|
|
14
|
+
<meta name="color-scheme" content="light">
|
|
15
|
+
<title>小鲸教练 · 知识地图</title>
|
|
16
|
+
<style>
|
|
17
|
+
/* ═══ 色板照抄 VP 平台 frontend/src/index.css ═══
|
|
18
|
+
双轨制:色块轨(--X) 管描边/淡底,只要 3:1;文字轨(--X-text) 管 color:,压深过 4.5:1。
|
|
19
|
+
学科色同理 —— 只做描边和淡底,正文字色一律 --text-1,所以不必压深。 */
|
|
20
|
+
:root{
|
|
21
|
+
--bg-0:#eef5fc; --bg-1:#ffffff; --bg-2:#f6fafe; --bg-3:#e6effa;
|
|
22
|
+
--fill-0:rgba(13,27,42,.028); --fill-1:rgba(13,27,42,.045); --fill-2:rgba(13,27,42,.07);
|
|
23
|
+
--header-bg:rgba(255,255,255,.82); --surface:rgba(255,255,255,.72);
|
|
24
|
+
--stroke:rgba(13,27,42,.085); --stroke-strong:rgba(13,27,42,.16);
|
|
25
|
+
--text-1:#0d1b2a; --text-2:#3d4f66; --text-3:#5a6b82; --text-4:#62708a; --text-5:#78889e;
|
|
26
|
+
--brand:#2b7fe0; --brand-fill:#1a6fd4; --brand-soft:rgba(43,127,224,.10);
|
|
27
|
+
--brand-glow:rgba(43,127,224,.22); --brand-text:#14589f;
|
|
28
|
+
--ac:#16a24a; --ac-text:#0f7a3d; --wa:#dc2626; --tle:#d97706;
|
|
29
|
+
--ac-soft:rgba(22,162,74,.09); --ac-s:rgba(22,162,74,.38);
|
|
30
|
+
--radius-xs:6px; --radius-s:10px; --radius-m:14px; --radius-l:20px; --radius-pill:999px;
|
|
31
|
+
--sp-1:4px; --sp-2:8px; --sp-3:12px; --sp-4:16px; --sp-5:24px; --sp-6:32px;
|
|
32
|
+
--shadow-1:0 1px 2px rgba(13,27,42,.05),0 4px 12px rgba(13,27,42,.05);
|
|
33
|
+
--shadow-2:0 2px 8px rgba(13,27,42,.07),0 16px 40px rgba(13,27,42,.09);
|
|
34
|
+
--glow-brand:0 0 0 1px rgba(43,127,224,.18),0 0 20px rgba(43,127,224,.11);
|
|
35
|
+
--ease-spring:cubic-bezier(.32,.72,0,1); --ease-out:cubic-bezier(.16,1,.3,1);
|
|
36
|
+
--speed-1:150ms; --speed-2:280ms;
|
|
37
|
+
--card-stroke:rgba(13,27,42,.13); --node-tint:.05; --dim:.16;
|
|
38
|
+
/* 重点色阶。**不用色相,用"从实到虚"** —— 10 个学科色已经把色相占满了,
|
|
39
|
+
再塞 4 个新色相就分不清哪个维度是哪套。重点本来就是"等级"不是"类别"。
|
|
40
|
+
skip 额外配虚线描边(形状通道),不只靠颜色区分。 */
|
|
41
|
+
--tier-core:#1a6fd4; --tier-normal:#0e9aa7; --tier-rare:#93a4b8; --tier-skip:#c2ccd8;
|
|
42
|
+
/* planned 块(教练排的训练)。**不占新色相** —— 10 个学科色 + 6 个事由色
|
|
43
|
+
已经把色相分完了,再塞一个谁也说不清哪套是哪套。
|
|
44
|
+
换成**另一套视觉语言**:busy 是实心渐变(已经发生的事实),
|
|
45
|
+
planned 是描边 + 半透明底(还没发生的计划)。扫一眼就分得开。 */
|
|
46
|
+
--pl-ink:#14589f; --pl-line:rgba(43,127,224,.62); --pl-solid:rgba(43,127,224,.26);
|
|
47
|
+
--pl-1:rgba(43,127,224,.34); --pl-2:rgba(43,127,224,.20); --pl-3:rgba(43,127,224,.09);
|
|
48
|
+
}
|
|
49
|
+
html[data-theme="dark"]{
|
|
50
|
+
--bg-0:#071528; --bg-1:#0d2138; --bg-2:#122a46; --bg-3:#17334f;
|
|
51
|
+
--fill-0:rgba(255,255,255,.02); --fill-1:rgba(255,255,255,.055); --fill-2:rgba(255,255,255,.09);
|
|
52
|
+
--header-bg:rgba(13,33,56,.72); --surface:rgba(255,255,255,.045);
|
|
53
|
+
--stroke:rgba(255,255,255,.075); --stroke-strong:rgba(255,255,255,.14);
|
|
54
|
+
--text-1:#eef4fb; --text-2:#a9bcd4; --text-3:#8ba0bd; --text-4:#7e93af; --text-5:#748aa6;
|
|
55
|
+
--brand:#4aa3f7; --brand-fill:#1f6fd0; --brand-soft:rgba(74,163,247,.14);
|
|
56
|
+
--brand-glow:rgba(74,163,247,.28); --brand-text:#9ed0ff;
|
|
57
|
+
--ac:#3fd968; --ac-text:#3fd968; --wa:#ff5c52; --tle:#ffa726;
|
|
58
|
+
--ac-soft:rgba(63,217,104,.12); --ac-s:rgba(63,217,104,.42);
|
|
59
|
+
--shadow-1:0 1px 2px rgba(0,0,0,.3),0 4px 12px rgba(0,0,0,.25);
|
|
60
|
+
--shadow-2:0 2px 8px rgba(0,0,0,.35),0 16px 40px rgba(0,0,0,.35);
|
|
61
|
+
--glow-brand:0 0 0 1px rgba(74,163,247,.28),0 0 20px rgba(74,163,247,.22);
|
|
62
|
+
--card-stroke:rgba(255,255,255,.11); --node-tint:.10; --dim:.12;
|
|
63
|
+
--tier-core:#4aa3f7; --tier-normal:#2dd4bf; --tier-rare:#7e93af; --tier-skip:#4c5c72;
|
|
64
|
+
--pl-ink:#9ed0ff; --pl-line:rgba(74,163,247,.58); --pl-solid:rgba(74,163,247,.24);
|
|
65
|
+
--pl-1:rgba(74,163,247,.32); --pl-2:rgba(74,163,247,.19); --pl-3:rgba(74,163,247,.09);
|
|
66
|
+
}
|
|
67
|
+
*{box-sizing:border-box}
|
|
68
|
+
html,body{height:100%;margin:0}
|
|
69
|
+
body{
|
|
70
|
+
background:var(--bg-0); color:var(--text-1);
|
|
71
|
+
font:500 14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI Variable Text","Segoe UI",
|
|
72
|
+
"PingFang SC","Microsoft YaHei",Roboto,sans-serif;
|
|
73
|
+
-webkit-font-smoothing:antialiased; overflow:hidden;
|
|
74
|
+
display:grid; grid-template-rows:auto 1fr; grid-template-columns:auto 1fr;
|
|
75
|
+
grid-template-areas:"hdr hdr" "side stage";
|
|
76
|
+
transition:background var(--speed-2) var(--ease-out);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* ── 顶栏 ── */
|
|
80
|
+
.hdr{
|
|
81
|
+
grid-area:hdr; display:flex; align-items:center; gap:var(--sp-4);
|
|
82
|
+
padding:10px var(--sp-5); background:var(--header-bg);
|
|
83
|
+
backdrop-filter:blur(20px) saturate(1.4); border-bottom:1px solid var(--stroke);
|
|
84
|
+
z-index:20;
|
|
85
|
+
}
|
|
86
|
+
.brand{display:flex;align-items:center;gap:9px;font-weight:650;font-size:15px;letter-spacing:-.01em}
|
|
87
|
+
.brand .vp-ico{color:var(--brand)}
|
|
88
|
+
.brand small{color:var(--text-3);font-weight:500;font-size:12.5px;letter-spacing:0}
|
|
89
|
+
|
|
90
|
+
/* 图标统一用 VP 那套内联 SVG(components/Icon.jsx 的 ICO_PATHS)。
|
|
91
|
+
规格:24 视口 / 1.75 线宽 / 圆头圆角 / currentColor —— 颜色由父级 color 决定。 */
|
|
92
|
+
.vp-ico{flex:none;vertical-align:-.14em;display:inline-block}
|
|
93
|
+
.icon-btn .vp-ico,.leg .vp-ico{vertical-align:0}
|
|
94
|
+
.search .vp-ico{color:var(--text-5);pointer-events:none}
|
|
95
|
+
.hdr .spacer{flex:1}
|
|
96
|
+
|
|
97
|
+
.seg{display:flex;padding:2px;background:var(--fill-1);border-radius:var(--radius-pill);gap:2px}
|
|
98
|
+
.seg button{
|
|
99
|
+
appearance:none;border:0;background:transparent;color:var(--text-3);
|
|
100
|
+
font:inherit;font-size:12.5px;font-weight:550;padding:5px 13px;border-radius:var(--radius-pill);
|
|
101
|
+
cursor:pointer;transition:all var(--speed-1) var(--ease-out);white-space:nowrap;
|
|
102
|
+
}
|
|
103
|
+
.seg button:hover{color:var(--text-1)}
|
|
104
|
+
.seg button[aria-pressed="true"]{background:var(--bg-1);color:var(--text-1);box-shadow:var(--shadow-1)}
|
|
105
|
+
|
|
106
|
+
.search{
|
|
107
|
+
display:flex;align-items:center;gap:6px;padding:0 11px;height:32px;min-width:190px;
|
|
108
|
+
background:var(--fill-1);border:1px solid transparent;border-radius:var(--radius-pill);
|
|
109
|
+
transition:all var(--speed-1) var(--ease-out);
|
|
110
|
+
}
|
|
111
|
+
.search:focus-within{background:var(--bg-1);border-color:var(--brand);box-shadow:var(--glow-brand)}
|
|
112
|
+
.search input{
|
|
113
|
+
appearance:none;border:0;background:transparent;color:var(--text-1);
|
|
114
|
+
font:inherit;font-size:13px;width:100%;outline:none;
|
|
115
|
+
}
|
|
116
|
+
.search input::placeholder{color:var(--text-5)}
|
|
117
|
+
.icon-btn{
|
|
118
|
+
appearance:none;border:1px solid var(--stroke);background:var(--bg-1);color:var(--text-2);
|
|
119
|
+
width:32px;height:32px;border-radius:var(--radius-s);cursor:pointer;font-size:15px;
|
|
120
|
+
display:grid;place-items:center;transition:all var(--speed-1) var(--ease-out);
|
|
121
|
+
}
|
|
122
|
+
.icon-btn:hover{border-color:var(--stroke-strong);color:var(--text-1);transform:translateY(-1px)}
|
|
123
|
+
|
|
124
|
+
/* ── 左侧图例 / 筛选 ── */
|
|
125
|
+
.side{
|
|
126
|
+
grid-area:side; width:216px; padding:var(--sp-4) var(--sp-3) var(--sp-4) var(--sp-4);
|
|
127
|
+
overflow-y:auto; border-right:1px solid var(--stroke); background:var(--bg-2);
|
|
128
|
+
}
|
|
129
|
+
.side h2{
|
|
130
|
+
margin:0 0 10px; font-size:11px; font-weight:650; letter-spacing:.07em;
|
|
131
|
+
text-transform:uppercase; color:var(--text-4);
|
|
132
|
+
}
|
|
133
|
+
.leg{display:flex;flex-direction:column;gap:2px}
|
|
134
|
+
.leg button{
|
|
135
|
+
appearance:none;border:0;background:transparent;font:inherit;text-align:left;
|
|
136
|
+
display:flex;align-items:center;gap:9px;padding:6px 9px;border-radius:var(--radius-xs);
|
|
137
|
+
cursor:pointer;color:var(--text-2);font-size:13px;transition:all var(--speed-1) var(--ease-out);
|
|
138
|
+
}
|
|
139
|
+
.leg button:hover{background:var(--fill-1);color:var(--text-1)}
|
|
140
|
+
.leg button[aria-pressed="false"] .nm{opacity:.38;text-decoration:line-through}
|
|
141
|
+
.leg button[aria-pressed="false"] .dot{opacity:.28}
|
|
142
|
+
.dot{width:9px;height:9px;border-radius:3px;flex:none;transition:opacity var(--speed-1)}
|
|
143
|
+
.leg .nm{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
144
|
+
.leg .ct{color:var(--text-5);font-size:11.5px;font-variant-numeric:tabular-nums}
|
|
145
|
+
.side hr{border:0;border-top:1px solid var(--stroke);margin:var(--sp-4) 0}
|
|
146
|
+
.meta{font-size:12px;color:var(--text-3);line-height:1.75;font-variant-numeric:tabular-nums}
|
|
147
|
+
.meta b{color:var(--text-1);font-weight:600}
|
|
148
|
+
.note{
|
|
149
|
+
margin-top:var(--sp-4); padding:9px 11px; border-radius:var(--radius-s);
|
|
150
|
+
background:var(--brand-soft); border:1px solid var(--stroke);
|
|
151
|
+
font-size:11.5px; line-height:1.6; color:var(--text-3);
|
|
152
|
+
}
|
|
153
|
+
.note b{color:var(--brand-text)}
|
|
154
|
+
/* 写进度失败时变红。没有现成的 danger token,就地补一对(明暗各一)。 */
|
|
155
|
+
.note.err{background:#fdecea;border-color:#f3b7b0;color:#a3241a}
|
|
156
|
+
html[data-theme="dark"] .note.err{background:#3a1a17;border-color:#6b2b24;color:#ff9d92}
|
|
157
|
+
.note code{font-size:11px}
|
|
158
|
+
|
|
159
|
+
/* ── 画布 ── */
|
|
160
|
+
.stage{
|
|
161
|
+
grid-area:stage; position:relative; overflow:hidden; cursor:grab;
|
|
162
|
+
background:
|
|
163
|
+
radial-gradient(1100px 520px at 22% -12%,var(--brand-soft),transparent 62%),
|
|
164
|
+
var(--bg-0);
|
|
165
|
+
/* 图里每个标签都是 <text>,默认全都能被框选 —— 拖动平移时会糊出一片蓝色高亮。
|
|
166
|
+
画布整体禁掉选区;详情面板和输入框再单独放开。 */
|
|
167
|
+
user-select:none; -webkit-user-select:none;
|
|
168
|
+
-webkit-user-drag:none; touch-action:none;
|
|
169
|
+
}
|
|
170
|
+
.stage.dragging{cursor:grabbing}
|
|
171
|
+
.stage.dragging *{cursor:grabbing !important}
|
|
172
|
+
/* 面板里的知识点名是可以复制的,别一起禁了 */
|
|
173
|
+
.panel, .panel *{user-select:text; -webkit-user-select:text}
|
|
174
|
+
svg{display:block;transform-origin:0 0;will-change:transform}
|
|
175
|
+
.band{fill:var(--fill-0)}
|
|
176
|
+
.bandline{stroke:var(--stroke);stroke-width:1;stroke-dasharray:2 6}
|
|
177
|
+
.bandlabel{fill:var(--text-5);font-size:11px;font-weight:600;letter-spacing:.06em;
|
|
178
|
+
font-variant-numeric:tabular-nums}
|
|
179
|
+
.bandcount{fill:var(--text-5);font-size:10.5px;font-weight:500}
|
|
180
|
+
|
|
181
|
+
.edge{stroke:var(--stroke-strong);stroke-width:1.25;fill:none;transition:all var(--speed-1) var(--ease-out)}
|
|
182
|
+
.edge.hot{stroke:var(--brand);stroke-width:2.1}
|
|
183
|
+
.edge.dim{opacity:var(--dim)}
|
|
184
|
+
#arrow{fill:var(--stroke-strong)}
|
|
185
|
+
#arrowHot{fill:var(--brand)}
|
|
186
|
+
|
|
187
|
+
/* 节点 = 中性白卡片 + 一个学科色圆点。
|
|
188
|
+
早先试过整格铺学科淡底 —— 349 格铺满颜色就是一面彩色药丸墙,
|
|
189
|
+
颜色互相打架,读不出层级。颜色降级成圆点后,跟左侧图例也呼应上了。 */
|
|
190
|
+
.node{cursor:pointer}
|
|
191
|
+
/* fallback 值**不是装饰**:节点是顶层同步循环造出来的,而 --nc-fill 要等
|
|
192
|
+
fetch 进度回来才设上。中间那几十毫秒里 var() 取不到值 → SVG 默认黑,
|
|
193
|
+
那个瞬间被抓到过(满屏黑块)。给了 fallback 至少是淡色不吓人。 */
|
|
194
|
+
.node .chip{
|
|
195
|
+
fill:var(--nc-fill,#e7eef8); stroke:var(--card-stroke); stroke-width:1;
|
|
196
|
+
transition:all var(--speed-1) var(--ease-out);
|
|
197
|
+
}
|
|
198
|
+
.node .ndot{fill:var(--nc,#b9c8d8);transition:fill var(--speed-1) var(--ease-out)}
|
|
199
|
+
.node text{
|
|
200
|
+
fill:var(--text-1); font-size:12.5px; font-weight:550;
|
|
201
|
+
text-anchor:start; dominant-baseline:central; pointer-events:none;
|
|
202
|
+
}
|
|
203
|
+
.node:hover .chip{stroke:var(--nc-s);stroke-width:1.5}
|
|
204
|
+
.node.hot .chip{stroke:var(--nc);stroke-width:1.8}
|
|
205
|
+
.node.hot text{font-weight:680}
|
|
206
|
+
.node.dim{opacity:var(--dim)}
|
|
207
|
+
/* skip 类加虚线描边 —— **形状通道**,不只靠颜色。
|
|
208
|
+
「这个不用学」是个强判断,不该只体现在一个更浅的蓝色上(色觉障碍、屏幕差都看不见)。 */
|
|
209
|
+
.node.tier-skip .chip{stroke-dasharray:4 3}
|
|
210
|
+
/* 段位标签:贴在节点右端,弱到不该抢名字的注意力 */
|
|
211
|
+
.node .entry{
|
|
212
|
+
text-anchor:end; fill:var(--text-5); font-size:10.5px;
|
|
213
|
+
font-weight:600; font-variant-numeric:tabular-nums;
|
|
214
|
+
}
|
|
215
|
+
.node.done .chip{fill:var(--ac-soft);stroke:var(--ac-s)}
|
|
216
|
+
.node.done .ndot{fill:var(--ac)}
|
|
217
|
+
/* 「在学」单独一种 —— 它和「学过」不是一回事,不能共用样式。
|
|
218
|
+
用琥珀(--tle 那支),和「学过」的绿、「已验证」的绿底区分开。 */
|
|
219
|
+
.node.studying .chip{fill:var(--tle);fill-opacity:.10;stroke:var(--tle);stroke-opacity:.55}
|
|
220
|
+
.node.studying .ndot{fill:var(--tle)}
|
|
221
|
+
/* 「游标」用**形状**区分而不是颜色 —— 颜色已经被状态占满了。
|
|
222
|
+
虚线加粗,扫一眼就知道今天该看哪。 */
|
|
223
|
+
.node.cursor .chip{stroke:var(--brand);stroke-width:2.2;stroke-dasharray:6 3}
|
|
224
|
+
.node.cursor text{font-weight:680}
|
|
225
|
+
|
|
226
|
+
/* ── 详情面板 ── */
|
|
227
|
+
.panel{
|
|
228
|
+
position:absolute;top:var(--sp-4);right:var(--sp-4);width:290px;max-height:calc(100% - 32px);
|
|
229
|
+
overflow-y:auto;background:var(--bg-1);border:1px solid var(--stroke);
|
|
230
|
+
border-radius:var(--radius-m);box-shadow:var(--shadow-2);padding:var(--sp-4);
|
|
231
|
+
transform:translateX(calc(100% + 24px));opacity:0;pointer-events:none;
|
|
232
|
+
transition:transform var(--speed-2) var(--ease-spring),opacity var(--speed-2) var(--ease-out);
|
|
233
|
+
}
|
|
234
|
+
.panel.on{transform:none;opacity:1;pointer-events:auto}
|
|
235
|
+
.panel .ttl{font-size:16px;font-weight:650;letter-spacing:-.01em;margin:0 0 6px;line-height:1.35}
|
|
236
|
+
.tags{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:var(--sp-3)}
|
|
237
|
+
.tag{
|
|
238
|
+
font-size:11.5px;font-weight:550;padding:3px 9px;border-radius:var(--radius-pill);
|
|
239
|
+
background:var(--fill-1);color:var(--text-2);
|
|
240
|
+
}
|
|
241
|
+
.tag.dom{background:var(--nc);color:var(--text-1);border:1px solid var(--nc-s)}
|
|
242
|
+
/* 面板里的状态标签:四种各一色,和图上一致 */
|
|
243
|
+
.tag.st{background:var(--fill-2);color:var(--text-1)}
|
|
244
|
+
.tag.st[data-st="studying"]{background:var(--tle);color:#fff}
|
|
245
|
+
.tag.st[data-st="learned"]{background:var(--brand-fill);color:#fff}
|
|
246
|
+
.tag.st[data-st="verified"]{background:var(--ac);color:#fff}
|
|
247
|
+
.tag.st[data-st="none"]{background:var(--fill-2);color:var(--text-3)}
|
|
248
|
+
.tag.cur{background:var(--brand-soft);color:var(--brand-text);border:1px dashed var(--brand)}
|
|
249
|
+
/* 「多久没碰了」—— 从日期算出来的,不是手标的状态。
|
|
250
|
+
7 天以内不额外提示(刚学的本来就正常),8~30 天转琥珀,30 天开外转红:
|
|
251
|
+
那时候基本等于忘了。**是提醒不是判决** —— 它不拦任何操作。 */
|
|
252
|
+
.tag.age{background:var(--fill-1);color:var(--text-3)}
|
|
253
|
+
.tag.age-warm{background:rgba(217,119,6,.11);color:#8a5208}
|
|
254
|
+
.tag.age-stale{background:rgba(220,38,38,.11);color:#a11d1d}
|
|
255
|
+
|
|
256
|
+
/* ══ 日程视图(L6 时间容量)════════════════════════════════════════
|
|
257
|
+
审美跟 VP 一套:蓝白、卡片、圆角、轻阴影、悬停有回馈。
|
|
258
|
+
|
|
259
|
+
几个刻意的决定(都不是随手定的):
|
|
260
|
+
· **10 天全展开,不做翻页** —— 滚动窗口本来就是"一眼看全"才叫窗口
|
|
261
|
+
· **回顾期(今天-3 ~ 今天-1)压暗** —— 过去的天只读,但得看得见
|
|
262
|
+
(不然会忘掉前天干了什么),所以不是藏起来是压暗
|
|
263
|
+
· **1 小时一格**,不做半小时 —— 半小时太碎,点起来像扫雷
|
|
264
|
+
· **busy 用斜纹不用纯色** —— 纯色会和 planned 的实心块抢眼;
|
|
265
|
+
斜纹在语义上是"背景",实心块才是"内容"
|
|
266
|
+
· **今天单独一列高亮**,不靠颜色深浅认 —— 10 列里认"哪列是今天"
|
|
267
|
+
是高频动作,得一眼看到
|
|
268
|
+
*/
|
|
269
|
+
.viewseg{display:flex;gap:2px;background:var(--fill-1);padding:3px;border-radius:var(--radius-s)}
|
|
270
|
+
.viewseg button{
|
|
271
|
+
font:inherit;font-size:12.5px;font-weight:550;color:var(--text-3);
|
|
272
|
+
background:transparent;border:0;border-radius:7px;padding:5px 13px;cursor:pointer;
|
|
273
|
+
transition:background var(--speed-1),color var(--speed-1);
|
|
274
|
+
}
|
|
275
|
+
.viewseg button:hover{color:var(--text-1)}
|
|
276
|
+
.viewseg button[aria-pressed="true"]{background:var(--bg-1);color:var(--brand-text);box-shadow:var(--shadow-1)}
|
|
277
|
+
|
|
278
|
+
/* ⚠️ body 本身是 grid("hdr hdr" / "side stage")—— 不显式给 grid-area 的话,
|
|
279
|
+
日程会被自动放进**侧栏那一列**,卡片就只有 2/3 宽(踩过)。
|
|
280
|
+
切到日程时把模板改成整行,它就占满宽度了。 */
|
|
281
|
+
.sched{grid-area:sched;display:none}
|
|
282
|
+
body[data-view="sched"]{grid-template-areas:"hdr hdr" "sched sched"}
|
|
283
|
+
body[data-view="sched"] .sched{display:flex;flex-direction:column}
|
|
284
|
+
body[data-view="sched"] #stage,
|
|
285
|
+
body[data-view="sched"] .side,
|
|
286
|
+
body[data-view="sched"] #panel{display:none!important}
|
|
287
|
+
|
|
288
|
+
/* overflow 必须是 auto 不能是 hidden —— 内容(工具栏+网格+两行图例)
|
|
289
|
+
会超过视口高度,hidden 的话底下的颜色对照直接被裁掉,等于没写。
|
|
290
|
+
「备注没改」这种反馈,一部分原因就是它压根看不见。 */
|
|
291
|
+
.sched{padding:var(--sp-5) var(--sp-5) var(--sp-4);overflow-y:auto;overflow-x:hidden}
|
|
292
|
+
|
|
293
|
+
.sched .card{flex:none;display:flex;flex-direction:column;margin-bottom:var(--sp-3);height:min(660px,60vh)}
|
|
294
|
+
.sched .legend2{flex:none}
|
|
295
|
+
|
|
296
|
+
/* ── 时间面:**不要格子,要连续的时间轴** ─────────────────────────
|
|
297
|
+
第一版是"一格一小时",太粗了(「1h 分块太大了」)——
|
|
298
|
+
他要的是拉一段起止时间,而不是点格子。所以:
|
|
299
|
+
· 视觉上只留**每小时一条淡线**当刻度(参考),不做离散格子
|
|
300
|
+
· 交互是**按住拖**:落点吸附到 15 分钟,拖到哪算到哪
|
|
301
|
+
· 松手弹个小框写「什么事」—— 备注是给小鲸看的,不是装饰
|
|
302
|
+
数据结构没变(busy 本来就是 { from, to, label } 任意区间),只换交互。 */
|
|
303
|
+
.sgrid{display:grid;grid-template-columns:52px repeat(10,minmax(86px,1fr));
|
|
304
|
+
grid-template-rows:auto 1fr;height:100%;min-height:0}
|
|
305
|
+
.sgrid .corner{background:var(--bg-2);border-bottom:1px solid var(--stroke)}
|
|
306
|
+
.sgrid .dh{padding:8px 6px 7px;text-align:center;background:var(--bg-2);
|
|
307
|
+
border-bottom:1px solid var(--stroke);border-left:1px solid var(--stroke)}
|
|
308
|
+
.sgrid .dh .dow{font-size:11px;color:var(--text-4);font-weight:550}
|
|
309
|
+
.sgrid .dh .dnum{font-size:14px;color:var(--text-1);font-weight:650;font-variant-numeric:tabular-nums}
|
|
310
|
+
.sgrid .dh.today{background:var(--brand-soft)}
|
|
311
|
+
.sgrid .dh.today .dnum{color:var(--brand-text)}
|
|
312
|
+
.sgrid .dh.past{opacity:.55}
|
|
313
|
+
|
|
314
|
+
.sgrid .stimes{position:relative;background:var(--bg-2);border-right:1px solid var(--stroke)}
|
|
315
|
+
.sgrid .stimes span{position:absolute;right:8px;transform:translateY(-50%);
|
|
316
|
+
font-size:10.5px;color:var(--text-5);font-variant-numeric:tabular-nums;line-height:1}
|
|
317
|
+
/* ⚠️ 这里**故意不画横向分割线**。这是明确要求去掉的:
|
|
318
|
+
格子里那些每小时一条的横线看着乱,而左边的时间标签已经够定位了。
|
|
319
|
+
(第一版还踩过 gradient + 缩放的坑,那些线在 150% 下会整条消失 ——
|
|
320
|
+
干脆不要了,两个问题一起没。)
|
|
321
|
+
留着的两条线各有用途:列边框(竖,分日期)、悬停跟随线(交互反馈)。 */
|
|
322
|
+
.sgrid .dcol{position:relative;border-left:1px solid var(--stroke);
|
|
323
|
+
cursor:crosshair;overflow:hidden;touch-action:none}
|
|
324
|
+
.sgrid .dcol:hover{background-color:var(--fill-0)}
|
|
325
|
+
.sgrid .dcol.past{opacity:.45}
|
|
326
|
+
.sgrid .dcol.today{background-color:rgba(43,127,224,.045)}
|
|
327
|
+
|
|
328
|
+
/* 一段「有事」:实心暖色块 + 备注文字。跨小时是一条连续块,
|
|
329
|
+
不是一格一格拼的 —— 拼的话接缝处会有 1px 缝,看着像没对齐。 */
|
|
330
|
+
/* 「有事」的色块。**颜色按事由自动分**(一条明确要求:不要全一个黄)。
|
|
331
|
+
每套都压得够深,白字在 AA 以上;深色主题下靠 --ev-*-dark 换一档,
|
|
332
|
+
不靠 opacity —— 半透明会让白字对比度掉下去。 */
|
|
333
|
+
/* 色相**故意收敛在蓝—靛—青**这一片(VP 的主色域),只留一个暖色给比赛。
|
|
334
|
+
第一版用了红紫蓝绿青灰六个色相,反馈是「跟随机的一样」——
|
|
335
|
+
六个色相摆一起就是彩虹,再准的映射看着也乱。邻近色域才显得是一套。 */
|
|
336
|
+
:root{
|
|
337
|
+
--ev-amber:#d97706; --ev-amber-2:#c2660a; /* 兜底(默认) */
|
|
338
|
+
--ev-blue:#3b7ddd; --ev-blue-2:#2b64bd; /* 上课 */
|
|
339
|
+
--ev-violet:#6366f1;--ev-violet-2:#4f52d6; /* 活动 / 宣讲 */
|
|
340
|
+
--ev-red:#d9534f; --ev-red-2:#bf3f3b; /* 比赛(唯一暖色,用来跳出来) */
|
|
341
|
+
--ev-teal:#0d9488; --ev-teal-2:#0a7a70; /* 运动 */
|
|
342
|
+
--ev-cyan:#0e7490; --ev-cyan-2:#0b5f75; /* 工作 / 兼职 */
|
|
343
|
+
--ev-slate:#5c718a; --ev-slate-2:#4a5c72; /* 生活(吃饭睡觉,最淡) */
|
|
344
|
+
}
|
|
345
|
+
.bbar{position:absolute;left:3px;right:3px;border-radius:5px;
|
|
346
|
+
color:#fff;font-size:11px;font-weight:550;line-height:1.25;padding:3px 7px;
|
|
347
|
+
overflow:hidden;cursor:pointer;transition:filter var(--speed-1);
|
|
348
|
+
background:linear-gradient(180deg,var(--ev-amber),var(--ev-amber-2));
|
|
349
|
+
box-shadow:0 1px 4px rgba(120,70,0,.25)}
|
|
350
|
+
.bbar:hover{filter:brightness(1.07)}
|
|
351
|
+
/* 文字按**块高**分三档。踩过:时间选得短,字直接被压没了。
|
|
352
|
+
判据用**时长(分钟)**不用像素 —— 像素依赖列高(窗口一缩就变),
|
|
353
|
+
分钟数是数据本身的属性,稳定。
|
|
354
|
+
≥75 分钟(1¼h):时间一行 + 事由一行,信息全
|
|
355
|
+
40~74 分钟(≈45min):只放事由,挤一行居中
|
|
356
|
+
<40 分钟:不放字,只留色条;完整信息挂 title,悬停可见 */
|
|
357
|
+
.bbar.s-full{padding:3px 7px}
|
|
358
|
+
.bbar.s-full .t{display:block;font-size:10px;opacity:.82;font-weight:500}
|
|
359
|
+
.bbar.s-mid{display:flex;align-items:center;padding:0 7px;font-size:10.5px;
|
|
360
|
+
white-space:nowrap;overflow:hidden}
|
|
361
|
+
.bbar.s-mini{padding:0}
|
|
362
|
+
.bbar.c-blue {background:linear-gradient(180deg,var(--ev-blue),var(--ev-blue-2));
|
|
363
|
+
box-shadow:0 1px 4px rgba(30,70,150,.22)}
|
|
364
|
+
.bbar.c-violet{background:linear-gradient(180deg,var(--ev-violet),var(--ev-violet-2));
|
|
365
|
+
box-shadow:0 1px 4px rgba(70,70,180,.22)}
|
|
366
|
+
.bbar.c-red {background:linear-gradient(180deg,var(--ev-red),var(--ev-red-2));
|
|
367
|
+
box-shadow:0 1px 4px rgba(150,45,40,.24)}
|
|
368
|
+
.bbar.c-teal {background:linear-gradient(180deg,var(--ev-teal),var(--ev-teal-2));
|
|
369
|
+
box-shadow:0 1px 4px rgba(8,90,82,.22)}
|
|
370
|
+
.bbar.c-cyan {background:linear-gradient(180deg,var(--ev-cyan),var(--ev-cyan-2));
|
|
371
|
+
box-shadow:0 1px 4px rgba(8,70,90,.22)}
|
|
372
|
+
.bbar.c-slate {background:linear-gradient(180deg,var(--ev-slate),var(--ev-slate-2));
|
|
373
|
+
box-shadow:0 1px 4px rgba(40,55,75,.20)}
|
|
374
|
+
/* 没写事由的:斜纹,说明"这里有安排但没说是什么" */
|
|
375
|
+
.bbar.nolabel{background:repeating-linear-gradient(45deg,
|
|
376
|
+
rgba(217,119,6,.36) 0 5px, rgba(217,119,6,.15) 5px 10px);color:#7a4300;
|
|
377
|
+
box-shadow:none}
|
|
378
|
+
|
|
379
|
+
/* ── planned 块:教练排的训练 ────────────────────────────────────
|
|
380
|
+
和 busy 走**两套视觉语言**(变量那儿的注释写了理由):一个是描边空心的
|
|
381
|
+
计划,一个是实心渐变的事实。
|
|
382
|
+
循环块用**一段渐变**画出 40/10/10 的比例 —— 不用分割元素,多一层 DOM
|
|
383
|
+
就多一处能画错的地方;而那个比例是块的灵魂:40 分钟到点必须停,
|
|
384
|
+
止损点就守在这儿。
|
|
385
|
+
**只读**:页面上不给改动入口。给了就会手滑改计划,
|
|
386
|
+
那"计划 vs 实际"的对照就没了 —— 而那个对照正是排计划的意义。 */
|
|
387
|
+
.pbar{position:absolute;left:3px;right:3px;border-radius:5px;
|
|
388
|
+
border:1.5px solid var(--pl-line);background:var(--pl-solid);
|
|
389
|
+
color:var(--pl-ink);font-size:11px;font-weight:600;line-height:1.25;
|
|
390
|
+
padding:2px 6px;overflow:hidden;cursor:default}
|
|
391
|
+
.pbar .t{display:block;font-size:10px;opacity:.78;font-weight:500}
|
|
392
|
+
.pbar.k-cycle{background:linear-gradient(180deg,
|
|
393
|
+
var(--pl-1) 0 66.67%, var(--pl-2) 66.67% 83.33%, var(--pl-3) 83.33% 100%)}
|
|
394
|
+
.pbar.s-mini{padding:0;border-width:1px}
|
|
395
|
+
|
|
396
|
+
/* 拖选时的预览条 */
|
|
397
|
+
.selbar{position:absolute;left:3px;right:3px;border-radius:5px;pointer-events:none;
|
|
398
|
+
background:rgba(217,119,6,.22);border:1px dashed rgba(217,119,6,.7)}
|
|
399
|
+
|
|
400
|
+
/* 悬停指示:一条横贯全宽的细线 + 跟着鼠标的时间标签。
|
|
401
|
+
踩过「鼠标放上去看不到位置的时间,很容易选错」——
|
|
402
|
+
光有刻度线不够,得让他在按下去**之前**就知道会选到哪一分钟。 */
|
|
403
|
+
.hoverline{position:fixed;height:1px;background:var(--brand);
|
|
404
|
+
opacity:.6;pointer-events:none;z-index:40;transition:opacity var(--speed-1)}
|
|
405
|
+
.hoverlabel{position:fixed;pointer-events:none;z-index:41;
|
|
406
|
+
background:var(--text-1);color:var(--bg-1);
|
|
407
|
+
font:600 11px/1 var(--dsw-font-family,inherit);
|
|
408
|
+
font-variant-numeric:tabular-nums;padding:3px 7px;border-radius:5px;
|
|
409
|
+
white-space:nowrap;box-shadow:var(--shadow-1)}
|
|
410
|
+
|
|
411
|
+
/* 写「什么事」的小浮层 */
|
|
412
|
+
.pop{position:fixed;z-index:60;width:236px;padding:12px;
|
|
413
|
+
background:var(--bg-1);border:1px solid var(--stroke-strong);border-radius:var(--radius-s);
|
|
414
|
+
box-shadow:var(--shadow-2)}
|
|
415
|
+
.pop .pt{font-size:12.5px;font-weight:650;color:var(--text-1);margin-bottom:8px;
|
|
416
|
+
font-variant-numeric:tabular-nums}
|
|
417
|
+
.pop input{width:100%;box-sizing:border-box;font:inherit;font-size:13px;
|
|
418
|
+
padding:7px 9px;border:1px solid var(--stroke-strong);border-radius:var(--radius-xs);
|
|
419
|
+
background:var(--bg-0);color:var(--text-1);outline:none}
|
|
420
|
+
.pop input:focus{border-color:var(--brand);box-shadow:var(--glow-brand)}
|
|
421
|
+
.pop .prow{display:flex;gap:6px;margin-top:10px}
|
|
422
|
+
.pop .prow button{flex:1;font:inherit;font-size:12.5px;font-weight:600;
|
|
423
|
+
padding:6px 0;border-radius:var(--radius-xs);cursor:pointer;border:1px solid var(--stroke-strong);
|
|
424
|
+
background:var(--bg-2);color:var(--text-2)}
|
|
425
|
+
.pop .prow button.pri{background:var(--brand-fill);border-color:var(--brand-fill);color:#fff}
|
|
426
|
+
.pop .prow button:hover{filter:brightness(1.05)}
|
|
427
|
+
.pop .prow button.del{flex:0 0 62px;color:var(--wa);border-color:rgba(220,38,38,.3)}
|
|
428
|
+
/* 「今天」按钮:纯图标按钮的宽度放不下两个字,会被挤成竖排 */
|
|
429
|
+
#schedToday{width:auto;padding:0 11px;font-size:12.5px;font-weight:600;white-space:nowrap}
|
|
430
|
+
.schedbar{display:flex;align-items:center;gap:var(--sp-3);margin-bottom:var(--sp-4)}
|
|
431
|
+
.schedbar h2{margin:0;font-size:15px;font-weight:650;color:var(--text-1)}
|
|
432
|
+
.schedbar .span{font-size:12.5px;color:var(--text-4);font-variant-numeric:tabular-nums}
|
|
433
|
+
.schedbar .spacer{flex:1}
|
|
434
|
+
|
|
435
|
+
.card{
|
|
436
|
+
background:var(--bg-1);border:1px solid var(--stroke);border-radius:var(--radius-m);
|
|
437
|
+
box-shadow:var(--shadow-1);overflow:hidden;
|
|
438
|
+
}
|
|
439
|
+
.grid{display:grid;grid-template-columns:56px repeat(10,minmax(88px,1fr))}
|
|
440
|
+
.grid .corner{background:var(--bg-2);border-bottom:1px solid var(--stroke)}
|
|
441
|
+
.grid .dh{
|
|
442
|
+
padding:8px 6px 7px;text-align:center;background:var(--bg-2);
|
|
443
|
+
border-bottom:1px solid var(--stroke);border-left:1px solid var(--stroke);
|
|
444
|
+
}
|
|
445
|
+
.grid .dh .dow{font-size:11px;color:var(--text-4);font-weight:550}
|
|
446
|
+
.grid .dh .dnum{font-size:14px;color:var(--text-1);font-weight:650;font-variant-numeric:tabular-nums;letter-spacing:-.01em}
|
|
447
|
+
.grid .dh .dmon{font-size:10px;color:var(--text-5)}
|
|
448
|
+
.grid .dh.today{background:var(--brand-soft)}
|
|
449
|
+
.grid .dh.today .dnum{color:var(--brand-text)}
|
|
450
|
+
.grid .dh.past{opacity:.55}
|
|
451
|
+
.grid .hh{
|
|
452
|
+
font-size:10.5px;color:var(--text-5);text-align:right;padding:0 7px 0 0;
|
|
453
|
+
height:22px;line-height:22px;background:var(--bg-2);border-right:1px solid var(--stroke);
|
|
454
|
+
font-variant-numeric:tabular-nums;
|
|
455
|
+
}
|
|
456
|
+
.grid .cell{
|
|
457
|
+
height:22px;border-left:1px solid var(--stroke);border-top:1px dashed var(--stroke);
|
|
458
|
+
cursor:pointer;position:relative;transition:background var(--speed-1);
|
|
459
|
+
}
|
|
460
|
+
.grid .cell:hover{background:var(--brand-soft)}
|
|
461
|
+
.grid .cell.busy{
|
|
462
|
+
background-color:rgba(217,119,6,.08);
|
|
463
|
+
background-image:repeating-linear-gradient(45deg,
|
|
464
|
+
rgba(217,119,6,.30) 0 4px, transparent 4px 9px);
|
|
465
|
+
cursor:pointer;
|
|
466
|
+
}
|
|
467
|
+
.grid .cell.busy:hover{background-color:rgba(217,119,6,.16)}
|
|
468
|
+
.grid .cell.past{opacity:.45}
|
|
469
|
+
.grid .cell.today{border-left-color:var(--brand)}
|
|
470
|
+
|
|
471
|
+
/* 排好的块:浮在格子上的实心条 */
|
|
472
|
+
.blk{
|
|
473
|
+
position:absolute;left:2px;right:2px;top:1px;bottom:1px;
|
|
474
|
+
border-radius:5px;background:linear-gradient(180deg,var(--brand),var(--brand-fill));
|
|
475
|
+
color:#fff;font-size:10px;line-height:20px;padding:0 6px;overflow:hidden;white-space:nowrap;
|
|
476
|
+
box-shadow:0 1px 3px rgba(43,127,224,.35);pointer-events:none;
|
|
477
|
+
}
|
|
478
|
+
.blk.k-resolve{background:linear-gradient(180deg,#5b9be8,#4a86d8)}
|
|
479
|
+
.blk.k-rewrite{background:linear-gradient(180deg,#7fb3ee,#6a9fe0)}
|
|
480
|
+
.blk.k-review{background:linear-gradient(180deg,#0e9aa7,#0b8792)}
|
|
481
|
+
.blk.k-test{background:linear-gradient(180deg,#7c5cd6,#6a4bc4)}
|
|
482
|
+
|
|
483
|
+
.legend2{display:flex;gap:16px;align-items:center;margin-top:var(--sp-3);font-size:12px;color:var(--text-3);flex-wrap:wrap}
|
|
484
|
+
.legend2 i{display:inline-block;width:14px;height:10px;border-radius:3px;margin-right:5px;vertical-align:middle}
|
|
485
|
+
.legend2 i.busy{background-color:rgba(217,119,6,.10);background-image:repeating-linear-gradient(45deg,rgba(217,119,6,.32) 0 3px,transparent 3px 6px)}
|
|
486
|
+
.legend2 i.plan{background:var(--pl-solid);border:1.5px solid var(--pl-line)}
|
|
487
|
+
.legend2 .hint{margin-left:auto;color:var(--text-4)}
|
|
488
|
+
.legend3{display:flex;gap:14px;align-items:center;margin-top:7px;
|
|
489
|
+
font-size:11.5px;color:var(--text-3);flex-wrap:wrap}
|
|
490
|
+
.legend3 .lt{color:var(--text-5);font-weight:600;letter-spacing:.02em}
|
|
491
|
+
.legend3 .lt2{color:var(--text-4)}
|
|
492
|
+
.legend3 span{display:inline-flex;align-items:center;gap:5px}
|
|
493
|
+
.legend3 .sw{display:inline-block;width:12px;height:9px;border-radius:3px}
|
|
494
|
+
.sw-blue{background:var(--ev-blue)}
|
|
495
|
+
.sw-violet{background:var(--ev-violet)}
|
|
496
|
+
.sw-red{background:var(--ev-red)}
|
|
497
|
+
.sw-teal{background:var(--ev-teal)}
|
|
498
|
+
.sw-cyan{background:var(--ev-cyan)}
|
|
499
|
+
.sw-slate{background:var(--ev-slate)}
|
|
500
|
+
.sec{margin-top:var(--sp-4)}
|
|
501
|
+
.sec h3{
|
|
502
|
+
margin:0 0 7px;font-size:11px;font-weight:650;letter-spacing:.07em;
|
|
503
|
+
text-transform:uppercase;color:var(--text-4);
|
|
504
|
+
}
|
|
505
|
+
/* 检测记录 / 判因记录。数据是 L5 才落盘的,之前写进去了界面上看不见 ——
|
|
506
|
+
那等于没写,是这个项目一直在治的病。 */
|
|
507
|
+
.hist{display:flex;flex-direction:column;gap:4px;margin-top:4px}
|
|
508
|
+
.hrow{
|
|
509
|
+
display:flex;align-items:baseline;gap:var(--sp-2);
|
|
510
|
+
padding:5px 8px;border-radius:var(--radius-xs);
|
|
511
|
+
background:var(--fill-0);border:1px solid var(--stroke);
|
|
512
|
+
font-size:12.5px;
|
|
513
|
+
}
|
|
514
|
+
.hrow b{flex:none;font-weight:680}
|
|
515
|
+
.ho-passed{color:var(--ac-text)}
|
|
516
|
+
.ho-overTime{color:var(--tle)}
|
|
517
|
+
.ho-failed{color:var(--wa)}
|
|
518
|
+
.hc{color:var(--brand-text)}
|
|
519
|
+
.hd{flex:none;color:var(--text-4);font-variant-numeric:tabular-nums}
|
|
520
|
+
.hs{flex:1;min-width:0;color:var(--text-2);
|
|
521
|
+
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
522
|
+
.he{flex:none;color:var(--text-5);font-variant-numeric:tabular-nums}
|
|
523
|
+
|
|
524
|
+
.chips{display:flex;flex-wrap:wrap;gap:5px}
|
|
525
|
+
.chip{
|
|
526
|
+
appearance:none;font:inherit;font-size:12px;padding:4px 10px;border-radius:var(--radius-xs);
|
|
527
|
+
background:var(--fill-1);border:1px solid var(--stroke);color:var(--text-2);
|
|
528
|
+
cursor:pointer;transition:all var(--speed-1) var(--ease-out);
|
|
529
|
+
}
|
|
530
|
+
.chip:hover{background:var(--bg-2);border-color:var(--stroke-strong);color:var(--text-1)}
|
|
531
|
+
.none{font-size:12px;color:var(--text-5)}
|
|
532
|
+
.acts{display:flex;gap:7px;margin-top:var(--sp-4);padding-top:var(--sp-4);border-top:1px solid var(--stroke)}
|
|
533
|
+
.btn{
|
|
534
|
+
appearance:none;font:inherit;font-size:13px;font-weight:550;flex:1;padding:8px 12px;
|
|
535
|
+
border-radius:var(--radius-s);cursor:pointer;border:1px solid var(--stroke);
|
|
536
|
+
background:var(--bg-1);color:var(--text-2);transition:all var(--speed-1) var(--ease-out);
|
|
537
|
+
display:inline-flex;align-items:center;justify-content:center;gap:6px;
|
|
538
|
+
}
|
|
539
|
+
.btn:hover{border-color:var(--stroke-strong);color:var(--text-1)}
|
|
540
|
+
.btn.primary{background:var(--brand-fill);border-color:var(--brand-fill);color:#fff}
|
|
541
|
+
.btn.primary:hover{filter:brightness(1.08)}
|
|
542
|
+
.btn.primary[data-on="1"]{background:var(--ac);border-color:var(--ac)}
|
|
543
|
+
|
|
544
|
+
.hint{
|
|
545
|
+
position:absolute;left:var(--sp-5);bottom:var(--sp-4);font-size:11.5px;color:var(--text-5);
|
|
546
|
+
background:var(--surface);backdrop-filter:blur(10px);padding:6px 12px;border-radius:var(--radius-pill);
|
|
547
|
+
border:1px solid var(--stroke);pointer-events:none;
|
|
548
|
+
}
|
|
549
|
+
kbd{
|
|
550
|
+
font:inherit;font-size:10.5px;padding:1px 5px;border-radius:4px;
|
|
551
|
+
background:var(--fill-2);color:var(--text-3);border:1px solid var(--stroke);
|
|
552
|
+
}
|
|
553
|
+
</style>
|
|
554
|
+
<script>
|
|
555
|
+
/* 主题要在**首次渲染之前**定下来。
|
|
556
|
+
踩过:「调成暗色后刷新又变亮」—— 因为主题原来只写在 HTML 属性上,
|
|
557
|
+
刷新就没了。存 localStorage 能解决持久化,但**必须在这段同步脚本里读**:
|
|
558
|
+
放到 DOMContentLoaded 或者主脚本里,页面会先按浅色画一遍、再翻成暗色 ——
|
|
559
|
+
对暗色用户就是白闪一下(和上一轮那个黑闪是同一个病的两面)。
|
|
560
|
+
所以它得在 <head> 里、同步执行,抢在浏览器第一次画之前。 */
|
|
561
|
+
try {
|
|
562
|
+
if (localStorage.getItem("coach.theme") === "dark") {
|
|
563
|
+
document.documentElement.dataset.theme = "dark";
|
|
564
|
+
document.documentElement.style.background = "#071528"; // 和内联背景同步,防透底
|
|
565
|
+
}
|
|
566
|
+
} catch (e) { /* 隐私模式下 localStorage 会抛,忽略即可,退回浅色 */ }
|
|
567
|
+
</script>
|
|
568
|
+
</head>
|
|
569
|
+
<body>
|
|
570
|
+
|
|
571
|
+
<header class="hdr">
|
|
572
|
+
<div class="brand"><span data-ico="whale" data-size="20"></span><span>小鲸教练 · 知识地图</span>
|
|
573
|
+
<small id="hdrCount"></small></div>
|
|
574
|
+
<div class="viewseg" id="viewSeg" role="group" aria-label="视图">
|
|
575
|
+
<button data-view="tree" aria-pressed="true">技能树</button>
|
|
576
|
+
<button data-view="sched" aria-pressed="false">日程</button>
|
|
577
|
+
</div>
|
|
578
|
+
<div class="spacer"></div>
|
|
579
|
+
<div class="seg" id="modeSeg" role="group" aria-label="着色模式">
|
|
580
|
+
<button data-mode="domain" aria-pressed="true">按学科</button>
|
|
581
|
+
<button data-mode="state" aria-pressed="false">按状态</button>
|
|
582
|
+
<button data-mode="tier" aria-pressed="false">按重点</button>
|
|
583
|
+
</div>
|
|
584
|
+
<label class="search"><span data-ico="search" data-size="15"></span>
|
|
585
|
+
<input id="q" type="search" placeholder="搜知识点…" aria-label="搜索知识点"></label>
|
|
586
|
+
<button class="icon-btn" id="wires" title="显示全部连线(会糊,慎用)" data-ico="link"></button>
|
|
587
|
+
<button class="icon-btn" id="fit" title="回到全图(铺满宽度)" data-ico="expand"></button>
|
|
588
|
+
<button class="icon-btn" id="theme" title="明暗切换" data-ico="moon"></button>
|
|
589
|
+
</header>
|
|
590
|
+
|
|
591
|
+
<aside class="side">
|
|
592
|
+
<h2 id="legTitle">学科</h2>
|
|
593
|
+
<div class="leg" id="legend"></div>
|
|
594
|
+
<hr>
|
|
595
|
+
<div class="meta" id="meta"></div>
|
|
596
|
+
<div class="note" id="note">
|
|
597
|
+
载入中…(这段是占位,真实状态由 <code>./api/progress</code> 覆盖)
|
|
598
|
+
</div>
|
|
599
|
+
</aside>
|
|
600
|
+
|
|
601
|
+
<main class="stage" id="stage">
|
|
602
|
+
<svg id="cv">
|
|
603
|
+
<defs>
|
|
604
|
+
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5"
|
|
605
|
+
markerWidth="5.5" markerHeight="5.5" orient="auto-start-reverse">
|
|
606
|
+
<path d="M0,1 L9,5 L0,9 z"/>
|
|
607
|
+
</marker>
|
|
608
|
+
<marker id="arrowHot" viewBox="0 0 10 10" refX="9" refY="5"
|
|
609
|
+
markerWidth="5.5" markerHeight="5.5" orient="auto-start-reverse">
|
|
610
|
+
<path d="M0,1 L9,5 L0,9 z"/>
|
|
611
|
+
</marker>
|
|
612
|
+
</defs>
|
|
613
|
+
<g id="bands"></g><g id="edges"></g><g id="nodes"></g>
|
|
614
|
+
</svg>
|
|
615
|
+
<div class="panel" id="panel"></div>
|
|
616
|
+
<div class="hint">滚轮缩放 · 拖拽平移 · <b>点节点看前置链和「多久没碰了」</b> ·
|
|
617
|
+
选中后 <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> = 未学/在学/学过(标完不跳,想往前走按
|
|
618
|
+
<kbd>↓</kbd>/<kbd>→</kbd>)· 点空白/<kbd>Esc</kbd> 取消</div>
|
|
619
|
+
</main>
|
|
620
|
+
|
|
621
|
+
<!-- 日程视图(L6 时间容量)。和技能树同一张页面、同一套样式 ——
|
|
622
|
+
两份实现就是两份真相,所以它是"另一个视图"不是"另一个页面"。 -->
|
|
623
|
+
<section class="sched" id="sched">
|
|
624
|
+
<div class="schedbar">
|
|
625
|
+
<h2>日程</h2>
|
|
626
|
+
<span class="span" id="schedSpan"></span>
|
|
627
|
+
<div class="spacer"></div>
|
|
628
|
+
<button class="icon-btn" id="schedPrev" title="窗口往前挪 3 天" data-ico="back"></button>
|
|
629
|
+
<button class="icon-btn" id="schedToday" title="回到今天">今天</button>
|
|
630
|
+
<button class="icon-btn" id="schedNext" title="窗口往后挪 3 天" data-ico="fwd"></button>
|
|
631
|
+
</div>
|
|
632
|
+
<div class="card"><div class="sgrid" id="schedGrid"></div></div>
|
|
633
|
+
<div class="legend2">
|
|
634
|
+
<span><i class="busy"></i>有事(拖一段标出来)</span>
|
|
635
|
+
<span><i class="plan"></i>教练排的块:40 自己做 + 10 解决遗留 + 10 重写,排完固化</span>
|
|
636
|
+
<span class="hint">按住拖出起止时间,松手写「什么事」· 点已有的块可改可删 · 回顾期只读</span>
|
|
637
|
+
</div>
|
|
638
|
+
<!-- 颜色对照。只写"按事由自动分"等于没说 —— 得让他一眼查到哪个色是哪类事,
|
|
639
|
+
不然每次都得回忆(「备注没改」就是这么来的)。 -->
|
|
640
|
+
<div class="legend3">
|
|
641
|
+
<span class="lt">颜色对照</span>
|
|
642
|
+
<span><i class="sw sw-blue"></i>上课 / 作业 / 考试</span>
|
|
643
|
+
<span><i class="sw sw-violet"></i>活动 / 宣讲 / 开会</span>
|
|
644
|
+
<span><i class="sw sw-red"></i>比赛</span>
|
|
645
|
+
<span><i class="sw sw-teal"></i>运动</span>
|
|
646
|
+
<span><i class="sw sw-cyan"></i>工作 / 兼职</span>
|
|
647
|
+
<span><i class="sw sw-slate"></i>吃饭 / 睡觉 / 陪人</span>
|
|
648
|
+
<span class="lt2">没写事由的 → 斜纹</span>
|
|
649
|
+
</div>
|
|
650
|
+
</section>
|
|
651
|
+
|
|
652
|
+
<script>
|
|
653
|
+
const DATA = /*__DATA__*/null;
|
|
654
|
+
const SVGNS = "http://www.w3.org/2000/svg";
|
|
655
|
+
const $ = (s) => document.querySelector(s);
|
|
656
|
+
const el = (t, a = {}) => { const n = document.createElementNS(SVGNS, t);
|
|
657
|
+
for (const k in a) n.setAttribute(k, a[k]); return n; };
|
|
658
|
+
const hexA = (hex, a) => { const v = parseInt(hex.slice(1), 16);
|
|
659
|
+
return `rgba(${v >> 16 & 255},${v >> 8 & 255},${v & 255},${a})`; };
|
|
660
|
+
|
|
661
|
+
/* ── 图标 ──
|
|
662
|
+
路径表抄自 VP 平台 frontend/src/components/Icon.jsx 的 ICO_PATHS。
|
|
663
|
+
规格一致:24 视口 / 1.75 线宽 / 圆头圆角 / currentColor(颜色由父级 color 决定)。
|
|
664
|
+
只有 expand 是我按同一规格补的 —— VP 那 45 个里没有「回到全图」的语义,
|
|
665
|
+
与其硬套 layers 或 target,不如照规格画一个。 */
|
|
666
|
+
const ICO = {
|
|
667
|
+
whale: '<path d="M2 14c2.5 0 3.5-2 5-2s2.5 2 5 2 3.5-2 5-2 2.5 2 5 2"/><path d="M6 9c1.5-3 4-5 6-5s4.5 2 6 5"/>',
|
|
668
|
+
search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.6-3.6"/>',
|
|
669
|
+
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
|
|
670
|
+
moon: '<path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z"/>',
|
|
671
|
+
link: '<path d="M9.5 14.5a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7L11 7.3"/><path d="M14.5 9.5a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7L13 16.7"/>',
|
|
672
|
+
check: '<path d="m5 13 4.5 4.5L19 7"/>',
|
|
673
|
+
expand: '<path d="M9 4H4v5"/><path d="M15 4h5v5"/><path d="M15 20h5v-5"/><path d="M9 20H4v-5"/>', // ← 按规格补的
|
|
674
|
+
back: '<path d="m14.5 6-6 6 6 6"/>',
|
|
675
|
+
fwd: '<path d="m9.5 6 6 6-6 6"/>',
|
|
676
|
+
};
|
|
677
|
+
const ico = (name, size = 16) =>
|
|
678
|
+
`<svg class="vp-ico" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none"`
|
|
679
|
+
+ ` stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"`
|
|
680
|
+
+ ` aria-hidden="true" focusable="false">${ICO[name] || ""}</svg>`;
|
|
681
|
+
|
|
682
|
+
const NODES = DATA.nodes, N = new Map(NODES.map(n => [n.id, n]));
|
|
683
|
+
const byId = (id) => N.get(id);
|
|
684
|
+
|
|
685
|
+
// ── 进度:从 PROGRESS.yaml 来(经 ./api/progress,相对路径)──────────
|
|
686
|
+
// 用相对路径是**故意的**:同一份 html 有两种挂法 ——
|
|
687
|
+
// acmer-coach 插件的 /coach 路由(/coach/skilltree.html → /coach/api/progress)
|
|
688
|
+
// serve_map.py 的离线预览(/skilltree.html → /api/progress)
|
|
689
|
+
// 写成绝对路径 /api/progress 就只会第二种通,在 dsh 里会打到 API 网关上。
|
|
690
|
+
// **这里绝不存第二份。** 之前标记存在 localStorage,那是两份真相:
|
|
691
|
+
// 页面显示「已学」,教练那边根本不知道。后来接上了真相源。
|
|
692
|
+
//
|
|
693
|
+
// 状态和 PROGRESS.yaml 里**逐字对齐**(别自己造词,否则又是两套词汇):
|
|
694
|
+
// none(未学) → studying(在学) → learned(学过·自评) → verified(已验证·过检测)
|
|
695
|
+
//
|
|
696
|
+
// ⚠️ CYCLE 只有**三级** —— verified 不在循环里。
|
|
697
|
+
// 手标不了它:自评不能冒充检测结论,已验证只能由 coach_grade 判卷产生。
|
|
698
|
+
// 键盘也从 1/2/3/4 改成 1/2/3。
|
|
699
|
+
// 但 verified 仍留在下面的 LABEL / COLOR 里 —— 那是**显示**用的:
|
|
700
|
+
// 节点可能真的过检测了,图上要认得出来。
|
|
701
|
+
const CYCLE = ["none", "studying", "learned"];
|
|
702
|
+
const STATUS_LABEL = { none: "未学", studying: "在学", learned: "学过", verified: "已验证" };
|
|
703
|
+
// [浅色, 深色] 两套 —— 和 VP 那边的双轨 token 一个道理,暗色下要提亮才看得清
|
|
704
|
+
const STATUS_COLOR = {
|
|
705
|
+
none: ["#9fb2c8", "#3d5470"],
|
|
706
|
+
studying: ["#d97706", "#ffa726"],
|
|
707
|
+
learned: ["#2b7fe0", "#5aa9f0"],
|
|
708
|
+
verified: ["#16a24a", "#3fd968"],
|
|
709
|
+
};
|
|
710
|
+
let progress = { cursor: "", nodes: {} };
|
|
711
|
+
const statusOf = (id) => progress.nodes?.[id]?.status ?? "none";
|
|
712
|
+
|
|
713
|
+
// ── 重点(tier)──────────────────────────────────────────────
|
|
714
|
+
// 回答的是另一个问题:地图只说「谁是谁的前置」——**允许**学什么;
|
|
715
|
+
// tier 说「这知识点重不重要」——**该不该**学。两条轴缺一不可。
|
|
716
|
+
//
|
|
717
|
+
// 颜色走 CSS 变量(--tier-*),因为色阶要跟主题联动;
|
|
718
|
+
// 但**形状通道**(skip 的虚线)在 CSS 里 —— 不能只靠颜色表达「这个不用学」。
|
|
719
|
+
const TIER_LABEL = { core: "核心", normal: "常见", rare: "少见", skip: "不考" };
|
|
720
|
+
const tierOf = (id) => byId(id)?.tier ?? "";
|
|
721
|
+
const entryOf = (id) => byId(id)?.entry ?? 0;
|
|
722
|
+
const tierColor = (t) =>
|
|
723
|
+
getComputedStyle(document.documentElement).getPropertyValue(`--tier-${t || "rare"}`).trim();
|
|
724
|
+
|
|
725
|
+
function setNote(html, isError) {
|
|
726
|
+
const el = $("#note");
|
|
727
|
+
el.innerHTML = html;
|
|
728
|
+
el.classList.toggle("err", Boolean(isError));
|
|
729
|
+
}
|
|
730
|
+
let noteTimer = null;
|
|
731
|
+
function toast(msg, isError) {
|
|
732
|
+
setNote(msg, isError);
|
|
733
|
+
clearTimeout(noteTimer);
|
|
734
|
+
noteTimer = setTimeout(showStatus, isError ? 7000 : 2000);
|
|
735
|
+
}
|
|
736
|
+
function showStatus() {
|
|
737
|
+
const all = Object.values(progress.nodes || {});
|
|
738
|
+
const k = (s) => all.filter((x) => x?.status === s).length;
|
|
739
|
+
setNote(
|
|
740
|
+
`<b>进度读自 PROGRESS.yaml</b>(点节点循环:未学 → 在学 → 学过)。<br>` +
|
|
741
|
+
`「已验证」手标不了 —— 它只能由检测产生。<br>` +
|
|
742
|
+
`游标 <code>${progress.cursor || "(未设)"}</code> ` +
|
|
743
|
+
`已验证 ${k("verified")} · 学过 ${k("learned")} · 在学 ${k("studying")}`
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async function loadProgress() {
|
|
748
|
+
try {
|
|
749
|
+
const r = await fetch("./api/progress");
|
|
750
|
+
const j = await r.json();
|
|
751
|
+
if (!j.ok) throw new Error(j.reject || "读进度失败");
|
|
752
|
+
progress = { cursor: j.cursor || "", nodes: j.nodes || {} };
|
|
753
|
+
showStatus();
|
|
754
|
+
} catch (e) {
|
|
755
|
+
// 读不到就**明说**,别退回「全部未学」—— 那看着像正常的空进度,
|
|
756
|
+
// 实际是接口挂了,人会以为自己的标记丢了。
|
|
757
|
+
setNote(`<b>进度读不到</b>:${e.message}<br>` +
|
|
758
|
+
`标记会失败。查:页面是从哪来的(dsh 的 /coach 路由,还是 serve_map.py),那里就是接口的提供方。`, true);
|
|
759
|
+
progress = { cursor: "", nodes: {} };
|
|
760
|
+
}
|
|
761
|
+
paint(); apply(); if (selected) panel(selected);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// 写进度:乐观更新 + 失败回滚。
|
|
765
|
+
// 点了没反应比点错了更让人困惑,所以先画再发;发失败了再退回去并说明。
|
|
766
|
+
async function postProgress(payload) {
|
|
767
|
+
const r = await fetch("./api/progress", {
|
|
768
|
+
method: "POST",
|
|
769
|
+
headers: { "Content-Type": "application/json" },
|
|
770
|
+
body: JSON.stringify(payload),
|
|
771
|
+
});
|
|
772
|
+
const j = await r.json();
|
|
773
|
+
if (!j.ok) throw new Error(j.reject || "写失败");
|
|
774
|
+
return j;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
async function setStatus(id, status) {
|
|
778
|
+
const before = JSON.parse(JSON.stringify(progress));
|
|
779
|
+
if (status === "none") delete progress.nodes[id];
|
|
780
|
+
else progress.nodes[id] = { ...(progress.nodes[id] || {}), status };
|
|
781
|
+
paint(); apply(); if (selected === id) panel(id);
|
|
782
|
+
try {
|
|
783
|
+
await postProgress({ op: "mark", node: id, status });
|
|
784
|
+
showStatus();
|
|
785
|
+
} catch (e) {
|
|
786
|
+
progress = before;
|
|
787
|
+
paint(); apply(); if (selected === id) panel(id);
|
|
788
|
+
toast(`<b>标记没存上</b>:${e.message}`, true);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function setCursor(id) {
|
|
793
|
+
const before = progress.cursor;
|
|
794
|
+
progress.cursor = id; paint();
|
|
795
|
+
try {
|
|
796
|
+
await postProgress({ op: "cursor", node: id });
|
|
797
|
+
showStatus();
|
|
798
|
+
} catch (e) {
|
|
799
|
+
progress.cursor = before; paint();
|
|
800
|
+
toast(`<b>游标没存上</b>:${e.message}`, true);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
let mode = "domain"; // domain | state
|
|
805
|
+
let hidden = new Set(); // 被筛掉的学科
|
|
806
|
+
let query = "";
|
|
807
|
+
let selected = null;
|
|
808
|
+
// 401 条边一起画必然是面条,而且盖住节点。默认一条不画,
|
|
809
|
+
// 悬停/选中某个节点时才把「它的祖先链 + 后代链」画出来 —— 这本来就是这张图的用法。
|
|
810
|
+
let showAll = false;
|
|
811
|
+
|
|
812
|
+
/* ── 祖先/后代闭包:悬停时高亮整条链 ── */
|
|
813
|
+
const ancCache = new Map(), desCache = new Map();
|
|
814
|
+
function ancestors(id) {
|
|
815
|
+
if (ancCache.has(id)) return ancCache.get(id);
|
|
816
|
+
const out = new Set(), st = [id];
|
|
817
|
+
while (st.length) for (const p of byId(st.pop()).pre)
|
|
818
|
+
if (!out.has(p)) { out.add(p); st.push(p); }
|
|
819
|
+
ancCache.set(id, out); return out;
|
|
820
|
+
}
|
|
821
|
+
function descendants(id) {
|
|
822
|
+
if (desCache.has(id)) return desCache.get(id);
|
|
823
|
+
const out = new Set(), st = [id];
|
|
824
|
+
while (st.length) for (const s of byId(st.pop()).suc)
|
|
825
|
+
if (!out.has(s)) { out.add(s); st.push(s); }
|
|
826
|
+
desCache.set(id, out); return out;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/* ── 画静态结构 ── */
|
|
830
|
+
const gBands = $("#bands"), gEdges = $("#edges"), gNodes = $("#nodes");
|
|
831
|
+
const svg = $("#cv");
|
|
832
|
+
svg.setAttribute("viewBox", `0 0 ${DATA.size[0]} ${DATA.size[1]}`);
|
|
833
|
+
svg.setAttribute("width", DATA.size[0]);
|
|
834
|
+
svg.setAttribute("height", DATA.size[1]);
|
|
835
|
+
|
|
836
|
+
DATA.bands.forEach((b, i) => {
|
|
837
|
+
if (i % 2) gBands.appendChild(el("rect", { class: "band", x: 0, y: b.y,
|
|
838
|
+
width: DATA.size[0], height: b.h, rx: 0 }));
|
|
839
|
+
gBands.appendChild(el("line", { class: "bandline", x1: 24, x2: DATA.size[0] - 24, y1: b.y + 2, y2: b.y + 2 }));
|
|
840
|
+
const t = el("text", { class: "bandlabel", x: 24, y: b.y + 22 });
|
|
841
|
+
t.textContent = `L${b.depth}`;
|
|
842
|
+
gBands.appendChild(t);
|
|
843
|
+
const c = el("text", { class: "bandcount", x: 24 + 26, y: b.y + 22 });
|
|
844
|
+
c.textContent = `${b.count} 个`;
|
|
845
|
+
gBands.appendChild(c);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
const nodeEls = new Map(), edgeEls = [];
|
|
849
|
+
for (const n of NODES) {
|
|
850
|
+
for (const p of n.pre) {
|
|
851
|
+
const a = byId(p), b = n;
|
|
852
|
+
const y1 = a.y + a.h, y2 = b.y, x1 = a.x + a.w / 2, x2 = b.x + b.w / 2;
|
|
853
|
+
const my = (y1 + y2) / 2;
|
|
854
|
+
const path = el("path", { class: "edge", d: `M${x1},${y1} C${x1},${my} ${x2},${my} ${x2},${y2}`,
|
|
855
|
+
"marker-end": "url(#arrow)", "data-a": p, "data-b": b.id });
|
|
856
|
+
gEdges.appendChild(path);
|
|
857
|
+
edgeEls.push(path);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
for (const n of NODES) {
|
|
861
|
+
const g = el("g", { class: "node", "data-id": n.id });
|
|
862
|
+
g.appendChild(el("rect", { class: "chip", x: n.x, y: n.y, width: n.w, height: n.h, rx: 9 }));
|
|
863
|
+
g.appendChild(el("circle", { class: "ndot", cx: n.x + 15, cy: n.y + n.h / 2, r: 4.6 }));
|
|
864
|
+
const t = el("text", { x: n.x + 27, y: n.y + n.h / 2 + .5 });
|
|
865
|
+
t.textContent = n.name;
|
|
866
|
+
g.appendChild(t);
|
|
867
|
+
// ⚠️ 必须**先入文档再量**。getComputedTextLength() 在游离元素上恒返回 0,
|
|
868
|
+
// 而 `x+27+0+34 <= x+w-8` 对绝大多数节点都成立 —— 于是"量过再放"会变成
|
|
869
|
+
// "无条件全放",标签全压在名字上。这就是"验了个寂寞"。
|
|
870
|
+
gNodes.appendChild(g);
|
|
871
|
+
if (n.entry) {
|
|
872
|
+
const need = 34; // "≈2300" + 右边距
|
|
873
|
+
if (t.getComputedTextLength() + need <= n.w - 27 - 8) {
|
|
874
|
+
const e = el("text", { class: "entry", x: n.x + n.w - 10, y: n.y + n.h / 2 + .5 });
|
|
875
|
+
e.textContent = "≈" + n.entry;
|
|
876
|
+
g.appendChild(e);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
nodeEls.set(n.id, g);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/* ── 着色 ── */
|
|
883
|
+
function paint() {
|
|
884
|
+
const dark = document.documentElement.dataset.theme === "dark";
|
|
885
|
+
for (const n of NODES) {
|
|
886
|
+
const g = nodeEls.get(n.id);
|
|
887
|
+
let c;
|
|
888
|
+
const st = statusOf(n.id);
|
|
889
|
+
if (mode === "state") {
|
|
890
|
+
c = STATUS_COLOR[st][dark ? 1 : 0];
|
|
891
|
+
} else if (mode === "tier") {
|
|
892
|
+
c = tierColor(n.tier) || STATUS_COLOR.none[dark ? 1 : 0];
|
|
893
|
+
} else {
|
|
894
|
+
c = DATA.domainColors[n.domain][dark ? 1 : 0];
|
|
895
|
+
}
|
|
896
|
+
g.style.setProperty("--nc", c);
|
|
897
|
+
g.style.setProperty("--nc-fill", hexA(c, dark ? .10 : .05));
|
|
898
|
+
g.style.setProperty("--nc-s", hexA(c, dark ? .72 : .62));
|
|
899
|
+
// 状态标记在两种着色模式下都要看得见。
|
|
900
|
+
// 早先写成 mode==="state" 才加 done —— 结果在「按学科」模式点标记,屏幕上毫无反应。
|
|
901
|
+
g.classList.toggle("done", st === "learned" || st === "verified");
|
|
902
|
+
// 在学单独一个类:它和「学过」不是一回事,不能共用样式
|
|
903
|
+
g.classList.toggle("studying", st === "studying");
|
|
904
|
+
// 游标也要画出来 —— 它是「今天聚焦哪个」,和「学到什么程度」是两个维度
|
|
905
|
+
g.classList.toggle("cursor", progress.cursor === n.id);
|
|
906
|
+
// 「不考」用虚线描边,**形状通道**。它是条强判断(别浪费时间),
|
|
907
|
+
// 不该只体现在一个更浅的蓝上。
|
|
908
|
+
g.classList.toggle("tier-skip", n.tier === "skip");
|
|
909
|
+
}
|
|
910
|
+
$("#legTitle").textContent =
|
|
911
|
+
mode === "state" ? "状态(点节点切换)" : mode === "tier" ? "重点" : "学科";
|
|
912
|
+
buildLegend();
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/* ── 图例 / 筛选 ── */
|
|
916
|
+
function buildLegend() {
|
|
917
|
+
const box = $("#legend"); box.innerHTML = "";
|
|
918
|
+
let entries;
|
|
919
|
+
if (mode === "state") {
|
|
920
|
+
// 图例走**状态字符串**,不再用 0/1/2 数字 —— 和 PROGRESS.yaml 一套词
|
|
921
|
+
entries = CYCLE.slice().reverse().map((k) => {
|
|
922
|
+
const label = STATUS_LABEL[k];
|
|
923
|
+
const c = STATUS_COLOR[k][document.documentElement.dataset.theme === "dark" ? 1 : 0];
|
|
924
|
+
const ct = NODES.filter((n) => statusOf(n.id) === k).length;
|
|
925
|
+
return { key: "s:" + k, label, c, ct };
|
|
926
|
+
});
|
|
927
|
+
} else if (mode === "tier") {
|
|
928
|
+
// 顺序定死 core → skip,图例本身就是优先级排序 —— 别按数量排,
|
|
929
|
+
// 那会让「不考」跑到最上面。
|
|
930
|
+
entries = ["core", "normal", "rare", "skip"].map((t) => {
|
|
931
|
+
const ct = NODES.filter((n) => n.tier === t).length;
|
|
932
|
+
return { key: "t:" + t, label: TIER_LABEL[t], c: tierColor(t), ct, dashed: t === "skip" };
|
|
933
|
+
}).filter((e) => e.ct);
|
|
934
|
+
} else {
|
|
935
|
+
const cnt = {};
|
|
936
|
+
for (const n of NODES) cnt[n.domain] = (cnt[n.domain] || 0) + 1;
|
|
937
|
+
entries = Object.keys(DATA.domainColors)
|
|
938
|
+
.filter(d => cnt[d])
|
|
939
|
+
.map(d => ({ key: "d:" + d, label: d, c: DATA.domainColors[d][0], ct: cnt[d] }));
|
|
940
|
+
}
|
|
941
|
+
for (const e of entries) {
|
|
942
|
+
const b = document.createElement("button");
|
|
943
|
+
b.setAttribute("aria-pressed", hidden.has(e.key) ? "false" : "true");
|
|
944
|
+
// skip 的色块画成虚线空心 —— 和节点上的描边同一套语言,
|
|
945
|
+
// 图例和图上长得一样才认得出。
|
|
946
|
+
const swatch = e.dashed
|
|
947
|
+
? `background:transparent;border:1.5px dashed ${e.c}`
|
|
948
|
+
: `background:${e.c}`;
|
|
949
|
+
b.innerHTML = `<span class="dot" style="${swatch}"></span>
|
|
950
|
+
<span class="nm">${e.label}</span><span class="ct">${e.ct}</span>`;
|
|
951
|
+
b.onclick = () => { hidden.has(e.key) ? hidden.delete(e.key) : hidden.add(e.key); apply(); };
|
|
952
|
+
box.appendChild(b);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/* ── 可见性 + 高亮 ── */
|
|
957
|
+
function visible(n) {
|
|
958
|
+
// 筛选键按模式分流。**三种模式各自一套键** —— 在「按重点」里点到「核心」
|
|
959
|
+
// 不该把「按学科」里的东西也筛掉,反过来也一样。
|
|
960
|
+
if (mode === "state") {
|
|
961
|
+
if (hidden.has("s:" + statusOf(n.id))) return false;
|
|
962
|
+
} else if (mode === "tier") {
|
|
963
|
+
if (hidden.has("t:" + (n.tier || "rare"))) return false;
|
|
964
|
+
} else if (hidden.has("d:" + n.domain)) { return false; }
|
|
965
|
+
if (query && !n.name.toLowerCase().includes(query)) return false;
|
|
966
|
+
return true;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function apply() {
|
|
970
|
+
const shown = new Set(NODES.filter(visible).map(n => n.id));
|
|
971
|
+
for (const n of NODES) nodeEls.get(n.id).classList.toggle("hide", !shown.has(n.id));
|
|
972
|
+
for (const n of NODES) nodeEls.get(n.id).style.display = shown.has(n.id) ? "" : "none";
|
|
973
|
+
for (const p of edgeEls) {
|
|
974
|
+
p.dataset.ok = (shown.has(p.dataset.a) && shown.has(p.dataset.b)) ? "1" : "0";
|
|
975
|
+
}
|
|
976
|
+
buildLegend();
|
|
977
|
+
paintChain(selected);
|
|
978
|
+
$("#hdrCount").textContent = `${shown.size} / ${NODES.length} 个知识点`;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// 只在**点选之后**才画路径。
|
|
982
|
+
// 早先是悬停触发 —— 鼠标扫过整张图就闪一下,不是"看"是"晃"。
|
|
983
|
+
// 悬停的反馈交给纯 CSS(.node:hover 加粗描边),不碰别的节点、不画线。
|
|
984
|
+
function paintChain(id) {
|
|
985
|
+
const paintEdge = (e, inChain, allowed) => {
|
|
986
|
+
if (!allowed || (!inChain && !showAll)) {
|
|
987
|
+
// 隐藏时必须把类也清掉 —— 只设 display:none 会留下脏状态,
|
|
988
|
+
// 之后一开「显示全部连线」就会冒出一批旧的 hot 边。
|
|
989
|
+
e.style.display = "none";
|
|
990
|
+
e.classList.remove("hot", "dim");
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
e.style.display = "";
|
|
994
|
+
e.classList.toggle("hot", inChain);
|
|
995
|
+
e.classList.toggle("dim", !inChain);
|
|
996
|
+
e.setAttribute("marker-end", inChain ? "url(#arrowHot)" : "url(#arrow)");
|
|
997
|
+
};
|
|
998
|
+
if (!id) {
|
|
999
|
+
nodeEls.forEach(g => g.classList.remove("hot", "dim"));
|
|
1000
|
+
edgeEls.forEach(e => paintEdge(e, false, e.dataset.ok === "1"));
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
const keep = new Set([id, ...ancestors(id), ...descendants(id)]);
|
|
1004
|
+
nodeEls.forEach((g, k) => { g.classList.toggle("hot", keep.has(k)); g.classList.toggle("dim", !keep.has(k)); });
|
|
1005
|
+
edgeEls.forEach(e => {
|
|
1006
|
+
// 这条边在链上 ⇔ 两端都在闭包里,且 a 确实是 b 的前置(方向不能反)
|
|
1007
|
+
const inChain = keep.has(e.dataset.a) && keep.has(e.dataset.b) &&
|
|
1008
|
+
ancestors(e.dataset.b).has(e.dataset.a);
|
|
1009
|
+
paintEdge(e, inChain, e.dataset.ok === "1");
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/* ── 详情面板 ── */
|
|
1014
|
+
/* 检测记录 + 判因记录 —— L5 刚落盘的两样东西。
|
|
1015
|
+
判因**按分类计数**:「不会」和「不认真」是两种病,混在一起看不出该治哪个。 */
|
|
1016
|
+
function histSection(n) {
|
|
1017
|
+
const rec = progress.nodes?.[n.id] ?? {};
|
|
1018
|
+
const checks = Array.isArray(rec.checks) ? rec.checks : [];
|
|
1019
|
+
const diags = Array.isArray(rec.diagnoses) ? rec.diagnoses : [];
|
|
1020
|
+
if (!checks.length && !diags.length) return "";
|
|
1021
|
+
const OUTCOME = { passed: "过", overTime: "超时", failed: "没过" };
|
|
1022
|
+
const out = [];
|
|
1023
|
+
if (checks.length) {
|
|
1024
|
+
out.push(`<div class="sec"><h3>检测记录(${checks.length} 次)</h3><div class="hist">`
|
|
1025
|
+
+ checks.slice(-4).reverse().map((c) =>
|
|
1026
|
+
`<div class="hrow"><b class="ho-${c.outcome}">${OUTCOME[c.outcome] ?? c.outcome}</b>`
|
|
1027
|
+
+ `<span class="hd">${c.date ?? ""}</span>`
|
|
1028
|
+
+ `<span class="hs">${c.score ?? "?"}/${c.maxScore ?? "?"} 分 · ${c.minutes ?? "?"}/${c.limit ?? "?"} 分钟</span>`
|
|
1029
|
+
+ (c.verifiedAt ? `<span class="he">验证于 ≈${c.verifiedAt}</span>` : "")
|
|
1030
|
+
+ `</div>`).join("")
|
|
1031
|
+
+ `</div></div>`);
|
|
1032
|
+
}
|
|
1033
|
+
if (diags.length) {
|
|
1034
|
+
const byCat = {};
|
|
1035
|
+
for (const d of diags) byCat[d.category] = (byCat[d.category] || 0) + 1;
|
|
1036
|
+
const tally = Object.entries(byCat).map(([k, v]) => `${k}×${v}`).join(" · ");
|
|
1037
|
+
out.push(`<div class="sec"><h3>判因记录(${diags.length} 次 ${tally})</h3><div class="hist">`
|
|
1038
|
+
+ diags.slice(-3).reverse().map((d) =>
|
|
1039
|
+
`<div class="hrow"><b class="hc">${d.category ?? ""}</b>`
|
|
1040
|
+
+ `<span class="hd">${d.date ?? ""}</span>`
|
|
1041
|
+
+ `<span class="hs" title="${(d.summary ?? "").replace(/"/g, """)}">${d.summary ?? ""}</span>`
|
|
1042
|
+
+ `</div>`).join("")
|
|
1043
|
+
+ `</div></div>`);
|
|
1044
|
+
}
|
|
1045
|
+
return out.join("");
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/* 距今多少天。按**本地零点**算,不是拿时间戳直接减 ——
|
|
1049
|
+
直接减的话「昨天 23:00」和「今天 09:00」差 10 小时,取整成 0 天,
|
|
1050
|
+
显示「今天」,可它明明是昨天标的。零点对齐就没这问题。 */
|
|
1051
|
+
function daysSince(dateStr) {
|
|
1052
|
+
if (!dateStr) return null;
|
|
1053
|
+
const d = new Date(dateStr + "T00:00:00");
|
|
1054
|
+
if (isNaN(d.getTime())) return null;
|
|
1055
|
+
const n = new Date();
|
|
1056
|
+
return Math.round((new Date(n.getFullYear(), n.getMonth(), n.getDate()) - d) / 86400000);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/* 「多久没碰了」的标签。
|
|
1060
|
+
rec.at 是**状态变成的时间**(不是"最后点它")—— 见 index.js 的 markStatus。
|
|
1061
|
+
没有 at 就什么都不显示:存量数据是早先标的,那时还没记这字段,
|
|
1062
|
+
下次标记会补上;在那之前显示「无日期」比不显示更让人困惑。 */
|
|
1063
|
+
function ageTag(rec) {
|
|
1064
|
+
const days = daysSince(rec && rec.at);
|
|
1065
|
+
if (days === null) return "";
|
|
1066
|
+
const label = days <= 0 ? "今天" : days === 1 ? "昨天" : `${days} 天前`;
|
|
1067
|
+
const cls = days <= 7 ? "" : days <= 30 ? " age-warm" : " age-stale";
|
|
1068
|
+
return `<span class="tag age${cls}" title="${rec.at} 变成这个状态">`
|
|
1069
|
+
+ label + (days > 30 ? " · 该复习了" : "") + `</span>`;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function panel(id) {
|
|
1073
|
+
const n = byId(id), p = $("#panel");
|
|
1074
|
+
if (!n) { p.classList.remove("on"); return; }
|
|
1075
|
+
const s = statusOf(id);
|
|
1076
|
+
const isCursor = progress.cursor === id;
|
|
1077
|
+
const chip = (x, cls = "") => `<button class="chip ${cls}" data-go="${x}">${x}</button>`;
|
|
1078
|
+
p.innerHTML = `
|
|
1079
|
+
<h2 class="ttl">${n.name}</h2>
|
|
1080
|
+
<div class="tags">
|
|
1081
|
+
<span class="tag dom">${n.domain}</span>
|
|
1082
|
+
<span class="tag st" data-st="${s}">${STATUS_LABEL[s]}</span>
|
|
1083
|
+
${ageTag(progress.nodes?.[id])}
|
|
1084
|
+
${isCursor ? '<span class="tag cur">▶ 当前游标</span>' : ''}
|
|
1085
|
+
${n.tier ? `<span class="tag" title="赛场重不重要">${TIER_LABEL[n.tier] ?? n.tier}</span>` : ''}
|
|
1086
|
+
${n.entry ? `<span class="tag" title="题池难度的 p25 —— 什么水平开始学得起">≈${n.entry}</span>` : ''}
|
|
1087
|
+
<span class="tag">L${n.depth}</span>
|
|
1088
|
+
<span class="tag">${n.pre.length} 前置 · ${n.suc.length} 后继</span>
|
|
1089
|
+
</div>
|
|
1090
|
+
${histSection(n, s)}
|
|
1091
|
+
<div class="sec"><h3>前置(学会这些才轮到它)</h3>
|
|
1092
|
+
<div class="chips">${n.pre.length ? n.pre.map(x => chip(x)).join("") : '<span class="none">无 —— 这是根节点</span>'}</div></div>
|
|
1093
|
+
<div class="sec"><h3>后继(学完它能开这些)</h3>
|
|
1094
|
+
<div class="chips">${n.suc.length ? n.suc.map(x => chip(x)).join("") : '<span class="none">无 —— 它是叶子</span>'}</div></div>
|
|
1095
|
+
<div class="acts">
|
|
1096
|
+
<button class="btn primary" id="mark" data-on="${s === "verified" ? 1 : 0}">
|
|
1097
|
+
${ico("check", 15)}${STATUS_LABEL[s]} → ${STATUS_LABEL[CYCLE[(CYCLE.indexOf(s) + 1) % CYCLE.length]]}</button>
|
|
1098
|
+
<button class="btn" id="cur" data-on="${isCursor ? 1 : 0}">
|
|
1099
|
+
${isCursor ? "▶ 就是当前游标" : "设为游标"}</button>
|
|
1100
|
+
<button class="btn" id="close">关闭</button>
|
|
1101
|
+
</div>`;
|
|
1102
|
+
p.classList.add("on");
|
|
1103
|
+
p.querySelectorAll("[data-go]").forEach(b => {
|
|
1104
|
+
b.onclick = () => select(b.dataset.go, true);
|
|
1105
|
+
});
|
|
1106
|
+
p.querySelector("#close").onclick = () => select(null);
|
|
1107
|
+
// 点一下**前进一级**,绕完一圈回未学。
|
|
1108
|
+
// 按钮上直接写出「当前 → 下一个」,省得靠记忆猜点几下到哪。
|
|
1109
|
+
p.querySelector("#mark").onclick = () => {
|
|
1110
|
+
const next = CYCLE[(CYCLE.indexOf(statusOf(id)) + 1) % CYCLE.length];
|
|
1111
|
+
setStatus(id, next);
|
|
1112
|
+
};
|
|
1113
|
+
p.querySelector("#cur").onclick = () => setCursor(id);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function select(id, fly) {
|
|
1117
|
+
selected = id;
|
|
1118
|
+
paintChain(id); // 别忘了这一行:悬停路径去掉后,画路径只剩这里
|
|
1119
|
+
panel(id);
|
|
1120
|
+
if (id && fly) focusNode(id);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/* ── 视口:缩放 / 平移 / 聚焦 ── */
|
|
1124
|
+
let vx = 0, vy = 0, vs = 1;
|
|
1125
|
+
function transform() { svg.style.transform = `translate(${vx}px,${vy}px) scale(${vs})`; }
|
|
1126
|
+
|
|
1127
|
+
// 节点造好、视口变量也就位了 —— **立刻**上色 + 定缩放,别等进度回来。
|
|
1128
|
+
//
|
|
1129
|
+
// paint():默认模式是「按学科」,不依赖进度。等 loadProgress 的话,
|
|
1130
|
+
// fetch 那几十毫秒里全部节点是 SVG 默认的黑(被截图抓到过)。
|
|
1131
|
+
// fit():地图 2336×2194,不先缩放的话就按原始尺寸铺出来 ——
|
|
1132
|
+
// 比视口大得多,看着像"页面被放大了一下",然后才缩回去。
|
|
1133
|
+
// 两个是同一个病:**把「等数据」当成了「等渲染」**,
|
|
1134
|
+
// 可数据没回来时页面并不是空白,而是用默认样式渲染出来的一份错的。
|
|
1135
|
+
//
|
|
1136
|
+
// ⚠️ 位置有讲究:必须在 `let vx/vy/vs` **之后** —— 放前面会撞 TDZ
|
|
1137
|
+
// (Cannot access 'vs' before initialization),整个主脚本当场中断,
|
|
1138
|
+
// 所有按钮全绑不上。踩过。这中间几百行都是同步的,不会有渲染帧。
|
|
1139
|
+
paint();
|
|
1140
|
+
fit();
|
|
1141
|
+
function fit(pad = 28) {
|
|
1142
|
+
const st = $("#stage").getBoundingClientRect();
|
|
1143
|
+
// 地图是竖长的(2336×2194)。早先按最短边缩 —— 结果是被高度卡住,
|
|
1144
|
+
// 字压到 5px。改成铺满宽度、纵向拖拽看;顶对齐还有个好处:
|
|
1145
|
+
// 一进来就是 L0「起点」,跟技能树的读法一致。
|
|
1146
|
+
vs = Math.max((st.width - pad * 2) / DATA.size[0], .06);
|
|
1147
|
+
const h = DATA.size[1] * vs;
|
|
1148
|
+
vx = (st.width - DATA.size[0] * vs) / 2;
|
|
1149
|
+
vy = h < st.height - pad * 2 ? (st.height - h) / 2 : pad;
|
|
1150
|
+
transform();
|
|
1151
|
+
}
|
|
1152
|
+
function focusNode(id) {
|
|
1153
|
+
const n = byId(id), st = $("#stage").getBoundingClientRect();
|
|
1154
|
+
vs = Math.max(vs, 1);
|
|
1155
|
+
vx = st.width / 2 - (n.x + n.w / 2) * vs;
|
|
1156
|
+
vy = st.height / 2 - (n.y + n.h / 2) * vs;
|
|
1157
|
+
transform();
|
|
1158
|
+
}
|
|
1159
|
+
const stage = $("#stage");
|
|
1160
|
+
stage.addEventListener("wheel", (e) => {
|
|
1161
|
+
e.preventDefault();
|
|
1162
|
+
const r = stage.getBoundingClientRect(), mx = e.clientX - r.left, my = e.clientY - r.top;
|
|
1163
|
+
const k = Math.exp(-e.deltaY * .0015), ns = Math.min(2.6, Math.max(.06, vs * k));
|
|
1164
|
+
vx = mx - (mx - vx) * (ns / vs); vy = my - (my - vy) * (ns / vs); vs = ns;
|
|
1165
|
+
transform();
|
|
1166
|
+
}, { passive: false });
|
|
1167
|
+
/* ⚠️ 这里**不能**用 stage.setPointerCapture()。
|
|
1168
|
+
指针被捕获到 stage 之后,click 的目标也跟着变成 stage,
|
|
1169
|
+
挂在节点上的 click 监听就再也不会触发 —— 真实鼠标点击节点会彻底失灵。
|
|
1170
|
+
(合成事件 dispatchEvent 绕过了原生命中测试,所以早先没测出来。)
|
|
1171
|
+
改成 document 级监听 move/up,既不丢捕获的好处,也不劫持 click 目标。 */
|
|
1172
|
+
let drag = null, dragMoved = false;
|
|
1173
|
+
|
|
1174
|
+
const onMove = (e) => {
|
|
1175
|
+
if (!drag) return;
|
|
1176
|
+
if (Math.abs(e.clientX - drag.x) + Math.abs(e.clientY - drag.y) > 4) dragMoved = true;
|
|
1177
|
+
vx = drag.vx + (e.clientX - drag.x); vy = drag.vy + (e.clientY - drag.y); transform();
|
|
1178
|
+
};
|
|
1179
|
+
const onUp = () => {
|
|
1180
|
+
drag = null;
|
|
1181
|
+
stage.classList.remove("dragging");
|
|
1182
|
+
document.removeEventListener("pointermove", onMove);
|
|
1183
|
+
document.removeEventListener("pointerup", onUp);
|
|
1184
|
+
document.removeEventListener("pointercancel", onUp);
|
|
1185
|
+
};
|
|
1186
|
+
stage.addEventListener("pointerdown", (e) => {
|
|
1187
|
+
if (e.target.closest(".panel") || e.button !== 0) return;
|
|
1188
|
+
e.preventDefault(); // 掐掉浏览器的「开始选择文本 / 拖拽图片」
|
|
1189
|
+
drag = { x: e.clientX, y: e.clientY, vx, vy };
|
|
1190
|
+
dragMoved = false; // 只在按下时重置 —— 要活到随后的 click 判断
|
|
1191
|
+
stage.classList.add("dragging");
|
|
1192
|
+
document.addEventListener("pointermove", onMove);
|
|
1193
|
+
document.addEventListener("pointerup", onUp);
|
|
1194
|
+
document.addEventListener("pointercancel", onUp);
|
|
1195
|
+
});
|
|
1196
|
+
// 双保险:万一还有漏网的(比如从空白处拖到画布外),直接把选区清掉
|
|
1197
|
+
document.addEventListener("selectstart", (e) => {
|
|
1198
|
+
if (e.target.closest?.(".stage")) e.preventDefault();
|
|
1199
|
+
});
|
|
1200
|
+
// 点空白 = 收起路径。dragMoved 为真说明刚拖过,那次 click 不算数。
|
|
1201
|
+
stage.addEventListener("click", (e) => {
|
|
1202
|
+
if (e.target.closest?.(".node") || e.target.closest?.(".panel") || dragMoved) return;
|
|
1203
|
+
select(null);
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
// 没有 pointerover/pointerout 监听 —— 悬停不触发任何重绘,鼠标划过是零成本的
|
|
1207
|
+
gNodes.addEventListener("click", (e) => {
|
|
1208
|
+
// ⚠️ dragMoved 必须在这里也查一遍。stage 那层查了,但**事件先到这一层** ——
|
|
1209
|
+
// 拖拽结束时 mousedown/mouseup 落在同一个节点上,click 就派发到那个节点,
|
|
1210
|
+
// 于是"想扒拉图"变成"又选中了一下"。stage 那层的判断救不了,因为它后到。
|
|
1211
|
+
if (dragMoved) return;
|
|
1212
|
+
const g = e.target.closest(".node"); if (g) select(g.dataset.id);
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
/* 静态按钮上的图标:HTML 里只写 data-ico,路径表只此一份 */
|
|
1216
|
+
document.querySelectorAll("[data-ico]").forEach((el) => {
|
|
1217
|
+
el.innerHTML = ico(el.dataset.ico, +el.dataset.size || 16);
|
|
1218
|
+
});
|
|
1219
|
+
const syncThemeIcon = () => {
|
|
1220
|
+
const dark = document.documentElement.dataset.theme === "dark";
|
|
1221
|
+
const b = $("#theme");
|
|
1222
|
+
b.innerHTML = ico(dark ? "sun" : "moon", 16);
|
|
1223
|
+
b.title = dark ? "切到亮色" : "切到暗色";
|
|
1224
|
+
};
|
|
1225
|
+
|
|
1226
|
+
/* ── 顶栏 ── */
|
|
1227
|
+
$("#modeSeg").onclick = (e) => {
|
|
1228
|
+
const b = e.target.closest("button"); if (!b) return;
|
|
1229
|
+
mode = b.dataset.mode; hidden.clear();
|
|
1230
|
+
[...e.currentTarget.children].forEach(x => x.setAttribute("aria-pressed", x === b));
|
|
1231
|
+
paint(); apply();
|
|
1232
|
+
};
|
|
1233
|
+
$("#q").oninput = (e) => { query = e.target.value.trim().toLowerCase(); apply(); };
|
|
1234
|
+
$("#wires").onclick = (e) => {
|
|
1235
|
+
showAll = !showAll;
|
|
1236
|
+
e.currentTarget.style.color = showAll ? "var(--brand)" : "";
|
|
1237
|
+
e.currentTarget.style.borderColor = showAll ? "var(--brand)" : "";
|
|
1238
|
+
paintChain(selected);
|
|
1239
|
+
};
|
|
1240
|
+
$("#fit").onclick = () => fit();
|
|
1241
|
+
$("#theme").onclick = () => {
|
|
1242
|
+
const h = document.documentElement;
|
|
1243
|
+
h.dataset.theme = h.dataset.theme === "dark" ? "light" : "dark";
|
|
1244
|
+
// 存起来,刷新后要还在(不然调成暗色一刷新又变亮)。
|
|
1245
|
+
// 内联背景也跟着切 —— 它是防透底用的,不跟就会在暗色下闪白。
|
|
1246
|
+
try { localStorage.setItem("coach.theme", h.dataset.theme); } catch (e) { /* 同上 */ }
|
|
1247
|
+
h.style.background = h.dataset.theme === "dark" ? "#071528" : "#eef5fc";
|
|
1248
|
+
paint(); syncThemeIcon();
|
|
1249
|
+
};
|
|
1250
|
+
/* ── 键盘盘点 ────────────────────────────────────────────────
|
|
1251
|
+
349 个节点靠"点节点 → 点按钮"要 700 下,标不完的。
|
|
1252
|
+
这里:选中一个节点后按 1/2/3/4 直接定级,并**自动跳到下一个** ——
|
|
1253
|
+
一路按下去就是一次盘点。
|
|
1254
|
+
|
|
1255
|
+
下一个按**图上顺序**(上到下、左到右),不是按数据顺序 ——
|
|
1256
|
+
人的眼睛在图上是这么走的,跳着走会跟丢。 */
|
|
1257
|
+
const ORDER = [...NODES].sort((a, b) => (a.y - b.y) || (a.x - b.x)).map((n) => n.id);
|
|
1258
|
+
const nextAfter = (id) => {
|
|
1259
|
+
const i = ORDER.indexOf(id);
|
|
1260
|
+
if (i < 0) return null;
|
|
1261
|
+
// 跳过一个已选中但还没标过的节点没意义,就老实取下一个;
|
|
1262
|
+
// 到末尾回绕,方便补漏。
|
|
1263
|
+
return ORDER[(i + 1) % ORDER.length];
|
|
1264
|
+
};
|
|
1265
|
+
addEventListener("keydown", (e) => {
|
|
1266
|
+
if (e.key === "Escape") { select(null); return; }
|
|
1267
|
+
// 在输入框里打字时不抢键
|
|
1268
|
+
if (e.target?.tagName === "INPUT" || e.target?.tagName === "TEXTAREA") return;
|
|
1269
|
+
if (!selected) return;
|
|
1270
|
+
|
|
1271
|
+
const idx = "123".indexOf(e.key); // 三档:verified 手标不了(见 CYCLE 上面的说明)
|
|
1272
|
+
if (idx >= 0) {
|
|
1273
|
+
e.preventDefault();
|
|
1274
|
+
// **标完不跳。** 原本按完 1-4 自动跳下一个
|
|
1275
|
+
// (本意是"一路按下去就是一次盘点"),实测反馈是「直接蹦到别的节点了」。
|
|
1276
|
+
// 标记是个**定点动作** —— 标完人还想看这个节点、还想接着扒拉图,
|
|
1277
|
+
// 工具不该自作主张把人挪走。想往前走,自己按方向键。
|
|
1278
|
+
setStatus(selected, CYCLE[idx]); // ["none","studying","learned"]
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
// 想往前走的时候自己按。方向键 / 空格 —— 图上顺序(上到下、左到右)。
|
|
1282
|
+
if (e.key === "ArrowDown" || e.key === "ArrowRight" || e.key === " ") {
|
|
1283
|
+
e.preventDefault();
|
|
1284
|
+
const to = nextAfter(selected);
|
|
1285
|
+
if (to) select(to, true);
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
|
|
1289
|
+
e.preventDefault();
|
|
1290
|
+
const i = ORDER.indexOf(selected);
|
|
1291
|
+
if (i >= 0) select(ORDER[(i - 1 + ORDER.length) % ORDER.length], true);
|
|
1292
|
+
}
|
|
1293
|
+
});
|
|
1294
|
+
addEventListener("resize", () => fit());
|
|
1295
|
+
|
|
1296
|
+
// ══════════════════════════════════════════════════════════════════
|
|
1297
|
+
// 日程视图(L6 时间容量)
|
|
1298
|
+
// ══════════════════════════════════════════════════════════════════
|
|
1299
|
+
// 三级数据写它们的人不同:busy = 他手标的 / planned = 教练排的(固化不重排)/
|
|
1300
|
+
// actual = 实际用时(校准真实速度的唯一来源)。
|
|
1301
|
+
//
|
|
1302
|
+
// 这个视图**只读 planned、只写 busy** —— 排块是教练的活,页面上不给入口。
|
|
1303
|
+
// 给了就会手滑改计划,那"计划"就不算数了,也没法拿它去对照实际。
|
|
1304
|
+
let sched = { days: {} };
|
|
1305
|
+
let schedAnchor = null; // 窗口起点;null = 「今天 - 3 天」
|
|
1306
|
+
let schedLoaded = false;
|
|
1307
|
+
|
|
1308
|
+
const HOUR_FROM = 8, HOUR_TO = 24; // 8:00 - 24:00,16 格
|
|
1309
|
+
const WINDOW_BACK = 3, WINDOW_DAYS = 10; // 前 3 回顾 + 后 7 可排(方针 §4.1)
|
|
1310
|
+
|
|
1311
|
+
const pad2 = (n) => String(n).padStart(2, "0");
|
|
1312
|
+
const ymdOf = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
1313
|
+
const ymdToday = () => ymdOf(new Date());
|
|
1314
|
+
|
|
1315
|
+
function schedStart() {
|
|
1316
|
+
if (schedAnchor) return new Date(`${schedAnchor}T00:00:00`);
|
|
1317
|
+
const t = new Date();
|
|
1318
|
+
const d = new Date(t.getFullYear(), t.getMonth(), t.getDate());
|
|
1319
|
+
d.setDate(d.getDate() - WINDOW_BACK);
|
|
1320
|
+
return d;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/* ── 时间 ↔ 坐标 ──
|
|
1324
|
+
只有两个刻度:日期用 "YYYY-MM-DD" 字符串比(定长同格式,字典序=时间序),
|
|
1325
|
+
列内位置用「离 8:00 多少分钟」。别再引入第三种刻度(比如像素直接参与运算),
|
|
1326
|
+
多一层转换就多一处能写错的地方。 */
|
|
1327
|
+
const TOTAL_MIN = (HOUR_TO - HOUR_FROM) * 60;
|
|
1328
|
+
// 落点吸附粒度:**5 分钟**。
|
|
1329
|
+
// 第一版是 15 分钟,太粗了(「粒度不够,能不能 1/5 分钟一档」)——
|
|
1330
|
+
// 他的时间不是按刻钟走的(13:20 下课、19:05 开始都常见),
|
|
1331
|
+
// 15 分钟一档会逼他要么多标要么少标。5 分钟已经比手表的分钟刻度还细,
|
|
1332
|
+
// 而列高 400+px 下每档约 2px,鼠标能停住 —— 再细就没有可操作性了。
|
|
1333
|
+
const SNAP = 5;
|
|
1334
|
+
|
|
1335
|
+
// 块型短名。**必须和 host 的 SCHED_KINDS[k].name 逐字一致** ——
|
|
1336
|
+
// 不一致时页面会拿 id 兜底显示(不会崩),但那块看着就像坏了。
|
|
1337
|
+
// host 是真相源,这里只管显示。verify.mjs 有一条盯着这个一致性。
|
|
1338
|
+
const KIND_NAME = { cycle: "题目循环", review: "复习一题", exam: "章节检测" };
|
|
1339
|
+
|
|
1340
|
+
const minToHM = (m) => {
|
|
1341
|
+
const t = HOUR_FROM * 60 + m;
|
|
1342
|
+
return `${pad2(Math.floor(t / 60))}:${pad2(t % 60)}`;
|
|
1343
|
+
};
|
|
1344
|
+
const hmToMin = (s) => {
|
|
1345
|
+
const p = String(s ?? "").split(":");
|
|
1346
|
+
const h = Number(p[0]), m = Number(p[1]);
|
|
1347
|
+
return Number.isFinite(h) && Number.isFinite(m) ? (h - HOUR_FROM) * 60 + m : null;
|
|
1348
|
+
};
|
|
1349
|
+
/** 列内 y 像素 → 分钟(吸附 + 夹在 [0, TOTAL_MIN]) */
|
|
1350
|
+
const yToMin = (y, colH) =>
|
|
1351
|
+
Math.max(0, Math.min(TOTAL_MIN, Math.round(((y / colH) * TOTAL_MIN) / SNAP) * SNAP));
|
|
1352
|
+
const pct = (min) => `${(min / TOTAL_MIN) * 100}%`;
|
|
1353
|
+
|
|
1354
|
+
/* 事由 → 颜色档。要求「不要全一个黄」。
|
|
1355
|
+
走**自动配色**而不是让他手选:同一种事永远同一个色(一致性靠函数保证,
|
|
1356
|
+
不靠他每次都记得选对),而且他少一步操作。
|
|
1357
|
+
顺序有讲究 —— 先匹配的赢:"打比赛"要判成比赛,不能被别的词抢走。 */
|
|
1358
|
+
/* ⚠️ 顺序 = 优先级,**越具体的越靠前**。
|
|
1359
|
+
踩过的坑:标成「排练和宣讲」这类含"赛"字的,会被 red 抢走判成比赛 ——
|
|
1360
|
+
可那是宣讲不是比赛,颜色误导。所以"宣讲/排练"必须排在"赛"前面。
|
|
1361
|
+
关键词是照**真实会写的词**收的(课表 / 午睡 / 陪…):
|
|
1362
|
+
第一版按想当然写"课""睡觉",真实用词一个都没命中,全掉进兜底 ——
|
|
1363
|
+
那看着就跟随机配色一样。 */
|
|
1364
|
+
const EVENT_TAGS = [
|
|
1365
|
+
["violet", ["宣讲", "排练", "社团", "活动", "讲座", "开会", "例会", "会议", "值班"]],
|
|
1366
|
+
["red", ["比赛", "模拟赛", "训练赛", "vp", "cf", "codeforces", "icpc", "测评"]],
|
|
1367
|
+
// "上"兜住「上高数」这种不写"课"字的课表 —— 口语里就是这么写的
|
|
1368
|
+
["blue", ["上课", "课", "实验", "作业", "考试", "自习", "复习", "上"]],
|
|
1369
|
+
["cyan", ["兼职", "工作", "实习", "项目", "面试"]],
|
|
1370
|
+
["teal", ["球", "运动", "健身", "跑步", "锻炼", "体测"]],
|
|
1371
|
+
["slate", ["饭", "睡", "休息", "玩", "游戏", "洗澡", "陪"]],
|
|
1372
|
+
];
|
|
1373
|
+
|
|
1374
|
+
function colorOf(label) {
|
|
1375
|
+
const s = String(label ?? "").trim();
|
|
1376
|
+
if (!s) return "";
|
|
1377
|
+
const low = s.toLowerCase();
|
|
1378
|
+
for (const [c, keys] of EVENT_TAGS) {
|
|
1379
|
+
if (keys.some((k) => low.includes(k))) return c;
|
|
1380
|
+
}
|
|
1381
|
+
// 兜底:按文字算一个**稳定**的档位(同输入同输出),而且**只从蓝青系里挑** ——
|
|
1382
|
+
// 兜底色要是乱跳,整体观感立刻退回"随机配色"(第一版就是这么翻车的)。
|
|
1383
|
+
let h = 0;
|
|
1384
|
+
for (const ch of s) h = (h * 31 + ch.codePointAt(0)) % 997;
|
|
1385
|
+
return ["blue", "teal", "slate"][h % 3];
|
|
1386
|
+
}
|
|
1387
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) =>
|
|
1388
|
+
({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
1389
|
+
|
|
1390
|
+
function renderSched() {
|
|
1391
|
+
const start = schedStart(), today = ymdToday();
|
|
1392
|
+
const days = [];
|
|
1393
|
+
for (let i = 0; i < WINDOW_DAYS; i++) {
|
|
1394
|
+
const d = new Date(start);
|
|
1395
|
+
d.setDate(d.getDate() + i);
|
|
1396
|
+
days.push(d);
|
|
1397
|
+
}
|
|
1398
|
+
$("#schedSpan").textContent = `${ymdOf(days[0])} ~ ${ymdOf(days[WINDOW_DAYS - 1])}`;
|
|
1399
|
+
|
|
1400
|
+
const DOW = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
|
1401
|
+
const parts = ['<div class="corner"></div>'];
|
|
1402
|
+
for (const d of days) {
|
|
1403
|
+
const key = ymdOf(d);
|
|
1404
|
+
const cls = key === today ? " today" : key < today ? " past" : "";
|
|
1405
|
+
parts.push(`<div class="dh${cls}"><div class="dow">${DOW[d.getDay()]}</div>` +
|
|
1406
|
+
`<div class="dnum">${d.getMonth() + 1}/${d.getDate()}</div></div>`);
|
|
1407
|
+
}
|
|
1408
|
+
// 左侧整点标签:现在它是**唯一的纵向定位依据**了(列里不放横线)。
|
|
1409
|
+
// 不画最后一个(24:00)—— 它正好压在底边上,标签会被切掉一半。
|
|
1410
|
+
parts.push('<div class="stimes">');
|
|
1411
|
+
for (let h = HOUR_FROM; h < HOUR_TO; h++) {
|
|
1412
|
+
parts.push(`<span style="top:${((h - HOUR_FROM) / (HOUR_TO - HOUR_FROM)) * 100}%">` +
|
|
1413
|
+
`${pad2(h)}:00</span>`);
|
|
1414
|
+
}
|
|
1415
|
+
parts.push("</div>");
|
|
1416
|
+
// 每天一列。busy 画成**连续块**不是拼格子 —— 拼的话接缝处会有 1px 缝。
|
|
1417
|
+
for (const d of days) {
|
|
1418
|
+
const key = ymdOf(d);
|
|
1419
|
+
const day = sched.days[key] ?? {};
|
|
1420
|
+
const bars = (day.busy ?? []).map((b, i) => {
|
|
1421
|
+
const a = hmToMin(b.from), z = hmToMin(b.to);
|
|
1422
|
+
if (a === null || z === null || z <= a) return "";
|
|
1423
|
+
const has = String(b.label ?? "").trim().length > 0;
|
|
1424
|
+
const col = colorOf(b.label);
|
|
1425
|
+
const mins = z - a;
|
|
1426
|
+
// 三档:够高放两行 / 中等放一行 / 太矮不放字(信息挂 title)
|
|
1427
|
+
const size = mins >= 75 ? "s-full" : mins >= 40 ? "s-mid" : "s-mini";
|
|
1428
|
+
const body = size === "s-full"
|
|
1429
|
+
? `<span class="t">${esc(b.from)}–${esc(b.to)}</span>${has ? esc(b.label) : "—"}`
|
|
1430
|
+
: size === "s-mid"
|
|
1431
|
+
? (has ? esc(b.label) : esc(b.from))
|
|
1432
|
+
: "";
|
|
1433
|
+
// title 里永远放**完整**信息 —— 小块的文字虽然不显示,
|
|
1434
|
+
// 但鼠标停上去能看全,这是它不丢信息的兜底。
|
|
1435
|
+
const tip = `${b.from}–${b.to}${has ? ` ${b.label}` : "(没写事由)"}`;
|
|
1436
|
+
return `<div class="bbar ${size}${has ? (col ? ` c-${col}` : "") : " nolabel"}" ` +
|
|
1437
|
+
`data-i="${i}" title="${esc(tip)}" style="top:${pct(a)};height:${pct(mins)}">` +
|
|
1438
|
+
`${body}</div>`;
|
|
1439
|
+
}).join("");
|
|
1440
|
+
// planned:教练排的块,**只读**(页面上不给改动入口,理由见上面 CSS 那段)。
|
|
1441
|
+
// 画在 busy 之后,同位置时压在忙事上面 —— 正常情况两者不重叠(工具排块时
|
|
1442
|
+
// 会拦),真压上了说明数据是手改进去的,那时候看得见比看不见强。
|
|
1443
|
+
const plans = (day.planned ?? []).map((b) => {
|
|
1444
|
+
const a = hmToMin(b.from), z = hmToMin(b.to);
|
|
1445
|
+
if (a === null || z === null || z <= a) return "";
|
|
1446
|
+
const mins = z - a;
|
|
1447
|
+
const kind = String(b.kind ?? "");
|
|
1448
|
+
// 地图改过名、甚至删过节点,而计划是历史数据 ——
|
|
1449
|
+
// 查不到名字就退回 id,**不能因为查不到就不画**(那等于计划凭空消失)。
|
|
1450
|
+
const nm = byId(b.node)?.name ?? String(b.node ?? "");
|
|
1451
|
+
const size = mins >= 75 ? "s-full" : mins >= 40 ? "s-mid" : "s-mini";
|
|
1452
|
+
// 中等高度(40~74 分钟,循环块正好 60)挤不下两行,但**时间要留住** ——
|
|
1453
|
+
// busy 那边中档只放事由就够(他自己知道标了什么),
|
|
1454
|
+
// planned 不一样:这是要他**照着执行**的,"几点开始"就是那条执行信息。
|
|
1455
|
+
// 长了会被 nowrap 裁掉,全名挂在 title 上。
|
|
1456
|
+
const body = size === "s-full"
|
|
1457
|
+
? `<span class="t">${esc(b.from)}–${esc(b.to)}</span>${esc(nm)}`
|
|
1458
|
+
: size === "s-mid" ? `${esc(b.from)} ${esc(nm)}` : "";
|
|
1459
|
+
const tip = `${b.from}–${b.to} ${KIND_NAME[kind] ?? kind} ${nm}` +
|
|
1460
|
+
(kind === "cycle" ? " (40 自己做 → 10 解决遗留 → 10 重写)" : "");
|
|
1461
|
+
return `<div class="pbar k-${esc(kind)} ${size}" title="${esc(tip)}" ` +
|
|
1462
|
+
`style="top:${pct(a)};height:${pct(mins)}">${body}</div>`;
|
|
1463
|
+
}).join("");
|
|
1464
|
+
// 列里**只有内容,没有分隔线**(那些横线是明确要求去掉的)。
|
|
1465
|
+
// 定位靠左边的时间标签 + 悬停跟随线。
|
|
1466
|
+
const cls = key === today ? " today" : key < today ? " past" : "";
|
|
1467
|
+
parts.push(`<div class="dcol${cls}" data-date="${key}">${bars}${plans}</div>`);
|
|
1468
|
+
}
|
|
1469
|
+
$("#schedGrid").innerHTML = parts.join("");
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/* ── 拖出一段「有事」──────────────────────────────────────────────
|
|
1473
|
+
交互:按住 → 拖 → 松手弹框写事由,落点吸附 15 分钟。
|
|
1474
|
+
|
|
1475
|
+
为什么不做格子点选(第一版就是那样,被否了):1 小时的分块太粗了 ——
|
|
1476
|
+
人的时间不是按整点切的,用格子会逼你要么多标要么少标
|
|
1477
|
+
(13:20 下课,格子只能点 13:00 或 14:00)。拖就没有这个问题。 */
|
|
1478
|
+
let schedDrag = null;
|
|
1479
|
+
|
|
1480
|
+
$("#schedGrid").addEventListener("pointerdown", (e) => {
|
|
1481
|
+
const col = e.target.closest?.(".dcol");
|
|
1482
|
+
if (!col) return;
|
|
1483
|
+
if (e.target.closest(".bbar")) return; // 点已有的块走编辑,不拖
|
|
1484
|
+
if (col.dataset.date < ymdToday()) return; // 回顾期只读
|
|
1485
|
+
const r = col.getBoundingClientRect();
|
|
1486
|
+
const m = yToMin(e.clientY - r.top, r.height);
|
|
1487
|
+
const el = document.createElement("div");
|
|
1488
|
+
el.className = "selbar";
|
|
1489
|
+
el.style.top = pct(m);
|
|
1490
|
+
el.style.height = pct(SNAP);
|
|
1491
|
+
col.appendChild(el);
|
|
1492
|
+
schedDrag = { col, date: col.dataset.date, a: m, b: m, el };
|
|
1493
|
+
col.setPointerCapture?.(e.pointerId);
|
|
1494
|
+
e.preventDefault();
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
/* 悬停指示:一条横贯全宽的细线 + 跟着鼠标的时间标签。
|
|
1498
|
+
踩过「鼠标放上去看不到位置的时间,很容易选错」——
|
|
1499
|
+
刻度线只说明"整点在哪",不回答"我现在会选到哪一分钟"。 */
|
|
1500
|
+
const hoverLine = document.createElement("div");
|
|
1501
|
+
hoverLine.className = "hoverline";
|
|
1502
|
+
hoverLine.hidden = true;
|
|
1503
|
+
const hoverLabel = document.createElement("div");
|
|
1504
|
+
hoverLabel.className = "hoverlabel";
|
|
1505
|
+
hoverLabel.hidden = true;
|
|
1506
|
+
document.body.append(hoverLine, hoverLabel);
|
|
1507
|
+
const hideHover = () => { hoverLine.hidden = true; hoverLabel.hidden = true; };
|
|
1508
|
+
|
|
1509
|
+
/** 把指示线放到某列的第 m 分钟,标签跟着鼠标。
|
|
1510
|
+
* 文字由调用方给 —— 悬停时给单个时刻("会选到哪"),
|
|
1511
|
+
* 拖拽时给区间("起止到哪",因为那时候他已经知道起点了,
|
|
1512
|
+
* 关心的是松手会落在哪一分钟、总共多长)。 */
|
|
1513
|
+
function showHoverAt(rect, m, e, text) {
|
|
1514
|
+
const g = $("#schedGrid").getBoundingClientRect();
|
|
1515
|
+
hoverLine.hidden = false;
|
|
1516
|
+
hoverLine.style.left = `${g.left}px`;
|
|
1517
|
+
hoverLine.style.width = `${g.width}px`;
|
|
1518
|
+
hoverLine.style.top = `${rect.top + (m / TOTAL_MIN) * rect.height}px`;
|
|
1519
|
+
hoverLabel.hidden = false;
|
|
1520
|
+
hoverLabel.textContent = text;
|
|
1521
|
+
hoverLabel.style.left = `${e.clientX + 14}px`;
|
|
1522
|
+
hoverLabel.style.top = `${e.clientY - 10}px`;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
$("#schedGrid").addEventListener("pointermove", (e) => {
|
|
1526
|
+
if (schedDrag) {
|
|
1527
|
+
// 拖动中:预览条画出"多长",标签画出"起止到哪"。
|
|
1528
|
+
// ⚠️ 一开始这里是把指示**收起来**的(怕两套线看花眼)——
|
|
1529
|
+
// 踩过:「按下鼠标后时间标又不存在了,不知道准备放开的时间是多少」。
|
|
1530
|
+
// 拖动时才是最需要它的时候,收起来正好反了。
|
|
1531
|
+
const r = schedDrag.col.getBoundingClientRect();
|
|
1532
|
+
schedDrag.b = yToMin(e.clientY - r.top, r.height);
|
|
1533
|
+
const a = Math.min(schedDrag.a, schedDrag.b), z = Math.max(schedDrag.a, schedDrag.b);
|
|
1534
|
+
schedDrag.el.style.top = pct(a);
|
|
1535
|
+
schedDrag.el.style.height = pct(Math.max(z - a, SNAP));
|
|
1536
|
+
showHoverAt(r, z, e, `${minToHM(a)} – ${minToHM(z)}`);
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
const col = e.target.closest?.(".dcol");
|
|
1540
|
+
if (!col || col.dataset.date < ymdToday()) { hideHover(); return; } // 回顾期不给指示
|
|
1541
|
+
const r = col.getBoundingClientRect();
|
|
1542
|
+
const m = yToMin(e.clientY - r.top, r.height);
|
|
1543
|
+
showHoverAt(r, m, e, minToHM(m));
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
$("#schedGrid").addEventListener("pointerleave", hideHover);
|
|
1547
|
+
|
|
1548
|
+
$("#schedGrid").addEventListener("pointerup", (e) => {
|
|
1549
|
+
if (!schedDrag) return;
|
|
1550
|
+
const { col, date, el } = schedDrag;
|
|
1551
|
+
const a = Math.min(schedDrag.a, schedDrag.b), z = Math.max(schedDrag.a, schedDrag.b);
|
|
1552
|
+
schedDrag = null;
|
|
1553
|
+
el.remove();
|
|
1554
|
+
if (z - a < SNAP) return; // 只是点了一下,没拖出区间 —— 什么都不做
|
|
1555
|
+
openPop(date, a, z, null, e.clientX, e.clientY);
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
/* 点已有的块 → 改事由 / 删掉 */
|
|
1559
|
+
$("#schedGrid").addEventListener("click", (e) => {
|
|
1560
|
+
const bar = e.target.closest(".bbar");
|
|
1561
|
+
if (!bar) return;
|
|
1562
|
+
const col = bar.closest(".dcol");
|
|
1563
|
+
if (!col || col.dataset.date < ymdToday()) return;
|
|
1564
|
+
const b = (sched.days[col.dataset.date]?.busy ?? [])[Number(bar.dataset.i)];
|
|
1565
|
+
if (!b) return;
|
|
1566
|
+
const a = hmToMin(b.from), z = hmToMin(b.to);
|
|
1567
|
+
if (a === null || z === null) return;
|
|
1568
|
+
openPop(col.dataset.date, a, z, Number(bar.dataset.i), e.clientX, e.clientY);
|
|
1569
|
+
});
|
|
1570
|
+
|
|
1571
|
+
/* ── 写「什么事」的小浮层 ─────────────────────────────────────────
|
|
1572
|
+
事由不是装饰:**它是给小鲸看的**。小鲸靠它知道"这段为什么不能用",
|
|
1573
|
+
而不是只看到一个光秃秃的忙碌标记 —— 那样它排程时只能当黑箱。 */
|
|
1574
|
+
let popEl = null;
|
|
1575
|
+
const closePop = () => { popEl?.remove(); popEl = null; };
|
|
1576
|
+
|
|
1577
|
+
function openPop(date, aMin, zMin, idx, px, py) {
|
|
1578
|
+
closePop();
|
|
1579
|
+
const cur = idx === null ? null : ((sched.days[date]?.busy ?? [])[idx] ?? null);
|
|
1580
|
+
const mins = zMin - aMin;
|
|
1581
|
+
const dur = mins >= 60
|
|
1582
|
+
? `${(mins / 60).toFixed(mins % 60 ? 1 : 0)} 小时`
|
|
1583
|
+
: `${mins} 分钟`;
|
|
1584
|
+
const el = document.createElement("div");
|
|
1585
|
+
el.className = "pop";
|
|
1586
|
+
el.innerHTML =
|
|
1587
|
+
`<div class="pt">${date} ${minToHM(aMin)} – ${minToHM(zMin)}` +
|
|
1588
|
+
`<span style="color:var(--text-4);font-weight:500"> ${dur}</span></div>` +
|
|
1589
|
+
`<input type="text" maxlength="40" placeholder="什么事?(上课 / 开会 / 打比赛…)" ` +
|
|
1590
|
+
`value="${esc(cur?.label ?? "")}">` +
|
|
1591
|
+
`<div class="prow">` +
|
|
1592
|
+
(cur ? `<button class="del">删除</button>` : "") +
|
|
1593
|
+
`<button class="cancel">取消</button><button class="pri ok">保存</button>` +
|
|
1594
|
+
`</div>`;
|
|
1595
|
+
document.body.appendChild(el);
|
|
1596
|
+
popEl = el;
|
|
1597
|
+
const w = 236, h = el.offsetHeight || 132;
|
|
1598
|
+
el.style.left = `${Math.max(8, Math.min(innerWidth - w - 8, (px ?? innerWidth / 2) - w / 2))}px`;
|
|
1599
|
+
el.style.top = `${Math.max(8, Math.min(innerHeight - h - 8, (py ?? 200) + 10))}px`;
|
|
1600
|
+
|
|
1601
|
+
const inp = el.querySelector("input");
|
|
1602
|
+
inp.focus();
|
|
1603
|
+
inp.select();
|
|
1604
|
+
|
|
1605
|
+
const commit = () => {
|
|
1606
|
+
const list = [...(sched.days[date]?.busy ?? [])];
|
|
1607
|
+
const entry = { from: minToHM(aMin), to: minToHM(zMin), label: inp.value.trim() };
|
|
1608
|
+
if (cur) list[idx] = entry; else list.push(entry);
|
|
1609
|
+
closePop();
|
|
1610
|
+
saveBusy(date, list);
|
|
1611
|
+
};
|
|
1612
|
+
el.querySelector(".ok").onclick = commit;
|
|
1613
|
+
el.querySelector(".cancel").onclick = closePop;
|
|
1614
|
+
el.querySelector(".del")?.addEventListener("click", () => {
|
|
1615
|
+
const list = [...(sched.days[date]?.busy ?? [])];
|
|
1616
|
+
list.splice(idx, 1);
|
|
1617
|
+
closePop();
|
|
1618
|
+
saveBusy(date, list);
|
|
1619
|
+
});
|
|
1620
|
+
inp.addEventListener("keydown", (ev) => {
|
|
1621
|
+
if (ev.key === "Enter") { ev.preventDefault(); commit(); }
|
|
1622
|
+
else if (ev.key === "Escape") { ev.preventDefault(); closePop(); }
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// 点浮层外面关掉它(和面板/弹层的习惯一致)
|
|
1627
|
+
document.addEventListener("pointerdown", (e) => {
|
|
1628
|
+
if (popEl && !popEl.contains(e.target)) closePop();
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
/* 乐观更新 + 失败回滚 —— 点了没反应比点错了更让人困惑。 */
|
|
1632
|
+
async function saveBusy(date, list) {
|
|
1633
|
+
const before = JSON.parse(JSON.stringify(sched));
|
|
1634
|
+
const sorted = [...list].sort((a, b) => (a.from < b.from ? -1 : a.from > b.from ? 1 : 0));
|
|
1635
|
+
const day = { ...(sched.days[date] ?? {}) };
|
|
1636
|
+
if (sorted.length) day.busy = sorted; else delete day.busy;
|
|
1637
|
+
if (Object.keys(day).length) sched.days[date] = day; else delete sched.days[date];
|
|
1638
|
+
renderSched();
|
|
1639
|
+
try {
|
|
1640
|
+
const r = await fetch("./api/schedule", {
|
|
1641
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
1642
|
+
body: JSON.stringify({ date, field: "busy", value: sorted }),
|
|
1643
|
+
});
|
|
1644
|
+
const j = await r.json();
|
|
1645
|
+
if (!j.ok) throw new Error(j.reject || "写失败");
|
|
1646
|
+
} catch (err) {
|
|
1647
|
+
sched = before;
|
|
1648
|
+
renderSched();
|
|
1649
|
+
setNote(`<b>没存上</b>:${err.message}`, true);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
async function loadSchedule() {
|
|
1654
|
+
try {
|
|
1655
|
+
const r = await fetch("./api/schedule");
|
|
1656
|
+
const j = await r.json();
|
|
1657
|
+
if (!j.ok) throw new Error(j.reject || "读日程失败");
|
|
1658
|
+
sched = { days: j.days || {} };
|
|
1659
|
+
} catch (e) {
|
|
1660
|
+
// 读不到就明说,别退回"全空"装作没事 —— 那看着像"你没标过"
|
|
1661
|
+
setNote(`<b>日程读不到</b>:${e.message}`, true);
|
|
1662
|
+
sched = { days: {} };
|
|
1663
|
+
}
|
|
1664
|
+
renderSched();
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
/* ⚠️ 视图状态要写进**地址栏**,不能只改内存。
|
|
1668
|
+
踩过:「点日程以后刷新,又回到技能树了」——
|
|
1669
|
+
原来 setView 只改 DOM,刷新就丢。地址栏是唯一真相,刷新/收藏/分享都靠它。
|
|
1670
|
+
(用 replaceState 不用 pushState:切视图不该污染后退历史,
|
|
1671
|
+
按一下后退跳回"上一个视图"而不是"上一页"会让人莫名其妙。) */
|
|
1672
|
+
function setView(v) {
|
|
1673
|
+
document.body.dataset.view = v;
|
|
1674
|
+
document.querySelectorAll("#viewSeg button").forEach((b) =>
|
|
1675
|
+
b.setAttribute("aria-pressed", String(b.dataset.view === v)));
|
|
1676
|
+
const want = v === "sched" ? "#sched" : "";
|
|
1677
|
+
if ((location.hash || "") !== want) {
|
|
1678
|
+
history.replaceState(null, "", want || location.pathname + location.search);
|
|
1679
|
+
}
|
|
1680
|
+
if (v === "sched") {
|
|
1681
|
+
if (!schedLoaded) { schedLoaded = true; loadSchedule(); } else renderSched();
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
$("#viewSeg").addEventListener("click", (e) => {
|
|
1686
|
+
const b = e.target.closest("button[data-view]");
|
|
1687
|
+
if (b) setView(b.dataset.view);
|
|
1688
|
+
});
|
|
1689
|
+
$("#schedPrev").onclick = () => { const s = schedStart(); s.setDate(s.getDate() - 3); schedAnchor = ymdOf(s); renderSched(); };
|
|
1690
|
+
$("#schedNext").onclick = () => { const s = schedStart(); s.setDate(s.getDate() + 3); schedAnchor = ymdOf(s); renderSched(); };
|
|
1691
|
+
$("#schedToday").onclick = () => { schedAnchor = null; renderSched(); };
|
|
1692
|
+
|
|
1693
|
+
// 先把进度拉回来再画 —— 不然会先闪一屏「全部未学」,然后才跳成真实状态。
|
|
1694
|
+
// 那一下闪烁会让人以为自己之前标的没存上。
|
|
1695
|
+
loadProgress().then(() => {
|
|
1696
|
+
paint(); apply(); fit(); syncThemeIcon();
|
|
1697
|
+
// 刷新后停在原来的视图 —— 地址栏是唯一真相(读它,别读内存)
|
|
1698
|
+
if (location.hash === "#sched") setView("sched");
|
|
1699
|
+
});
|
|
1700
|
+
</script>
|
|
1701
|
+
</body>
|
|
1702
|
+
</html>
|