dsh-tingdong 0.5.2 → 0.6.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/README.md CHANGED
@@ -23,7 +23,10 @@ dsh plugin --profile desktop add github:AIcivilization/dsh-tingdong # 源码
23
23
 
24
24
  ## 它做什么
25
25
 
26
- 首页有两个入口:**导入音频**(有声书、讲座、引导音频)和**文档转音频**(文章、手册、书)。
26
+ 首页有两个入口:**导入音频**(有声书、讲座、引导音频)和**文档转音频**(文章、手册、书)。除了做成听力课,每一步的成品都能下载到本地:
27
+
28
+ - **英文音频 → 带时间的字幕 → 中文配音**:识别出每一句英文和它的起止时间,导出字幕(SRT / LRC / 录音稿);再按同样的时间生成另一种语言(中文)的音频(MP3 / FLAC)。
29
+ - **文档 / 文字 → 朗读音频 → 下载到本地**:PDF、Word、EPUB、TXT 或粘贴的文字,AI 朗读成 MP3,单课或整篇下载;同样能再导出带时间的字幕和中文配音。
27
30
 
28
31
  **文档转音频**:直接选文件(或拖进来)——PDF、Word(.docx)、EPUB 电子书、网页(.html)、TXT、Markdown;没有文件也可以粘贴英文。
29
32
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-tingdong",
3
- "version": "0.5.2",
4
- "description": "听懂:把英文有声读物(MP3)或英文文档变成听力课。本地识别 / AI 朗读每一句英文、AI 翻译讲解,再用盲听、精听、间隔复习把它练到「一听就懂」。DeepSeek Harness (dsh) 插件。",
3
+ "version": "0.6.0",
4
+ "description": "听懂:把英文有声读物(MP3)或英文文档变成听力课,练到「一听就懂」。英文音频 → 带时间的字幕 → 时间对齐的中文配音;文档 / 文字 → AI 朗读音频 → 下载到本地。DeepSeek Harness (dsh) 插件。",
5
5
  "keywords": [
6
6
  "dsh-plugin",
7
7
  "dsh-plugins",
package/web/panel.css CHANGED
@@ -1,345 +1,344 @@
1
+ /* 听懂的学习面板。紧凑的工具界面:13px 正文、小圆角、行式列表,常驻在 DSH 的右侧栏(约 420px 宽)里也不挤。
2
+ 尺寸只对触屏放大(pointer: coarse),不按宽度——侧栏很窄,但用的是鼠标。 */
1
3
  :root {
2
- --bg: #f7f6f3;
4
+ --bg: #f6f6f4;
3
5
  --card: #ffffff;
4
- --text: #1d1d1f;
5
- --sub: #6b6b70;
6
- --faint: #9a9aa0;
7
- --line: rgba(0, 0, 0, .09);
6
+ --hover: rgba(0, 0, 0, .035);
7
+ --text: #1c1c1e;
8
+ --sub: #66666c;
9
+ --faint: #98989f;
10
+ --line: rgba(0, 0, 0, .085);
8
11
  --accent: #2f6bff;
9
- --accent-soft: rgba(47, 107, 255, .1);
12
+ --accent-soft: rgba(47, 107, 255, .09);
10
13
  --good: #1f9d55;
11
- --mid: #c98a12;
14
+ --mid: #b97d0c;
12
15
  --bad: #d14343;
13
- --mark: rgba(255, 196, 0, .32);
14
- --radius: 12px;
16
+ --mark: rgba(255, 196, 0, .3);
17
+ --radius: 8px;
18
+ --top-h: 37px;
15
19
  color-scheme: light;
16
20
  }
17
21
  @media (prefers-color-scheme: dark) {
18
22
  :root:not([data-theme="light"]) {
19
- --bg: #17171a;
20
- --card: #222226;
21
- --text: #ececef;
22
- --sub: #a2a2a8;
23
- --faint: #75757c;
24
- --line: rgba(255, 255, 255, .1);
25
- --accent: #6c95ff;
26
- --accent-soft: rgba(108, 149, 255, .16);
27
- --good: #3cc47c;
28
- --mid: #e0a73a;
29
- --bad: #ef6b6b;
30
- --mark: rgba(255, 196, 0, .25);
31
- color-scheme: dark;
23
+ --bg: #18181b; --card: #212124; --hover: rgba(255, 255, 255, .045); --text: #e9e9ec; --sub: #a0a0a7; --faint: #6f6f77;
24
+ --line: rgba(255, 255, 255, .09); --accent: #7098ff; --accent-soft: rgba(112, 152, 255, .14);
25
+ --good: #3cc47c; --mid: #e0a73a; --bad: #ef6b6b; --mark: rgba(255, 196, 0, .22); color-scheme: dark;
32
26
  }
33
27
  }
34
28
  :root[data-theme="dark"] {
35
- --bg: #17171a; --card: #222226; --text: #ececef; --sub: #a2a2a8; --faint: #75757c;
36
- --line: rgba(255, 255, 255, .1); --accent: #6c95ff; --accent-soft: rgba(108, 149, 255, .16);
37
- --good: #3cc47c; --mid: #e0a73a; --bad: #ef6b6b; --mark: rgba(255, 196, 0, .25); color-scheme: dark;
29
+ --bg: #18181b; --card: #212124; --hover: rgba(255, 255, 255, .045); --text: #e9e9ec; --sub: #a0a0a7; --faint: #6f6f77;
30
+ --line: rgba(255, 255, 255, .09); --accent: #7098ff; --accent-soft: rgba(112, 152, 255, .14);
31
+ --good: #3cc47c; --mid: #e0a73a; --bad: #ef6b6b; --mark: rgba(255, 196, 0, .22); color-scheme: dark;
38
32
  }
39
33
 
40
34
  * { box-sizing: border-box; }
41
35
  html, body { margin: 0; }
42
36
  body {
43
- background: var(--bg);
44
- color: var(--text);
45
- font: 14px/1.6 -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Segoe UI", sans-serif;
37
+ background: var(--bg); color: var(--text);
38
+ font: 13px/1.5 -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Segoe UI", sans-serif;
46
39
  -webkit-font-smoothing: antialiased;
47
40
  }
48
- button { font: inherit; color: inherit; }
41
+ button, select, input, textarea { font: inherit; color: inherit; }
49
42
  [hidden] { display: none !important; }
43
+ svg.ico { width: 16px; height: 16px; flex: none; stroke: currentColor; fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
50
44
 
45
+ /* —— 顶栏 —— */
51
46
  .top {
52
- position: sticky; top: 0; z-index: 5;
53
- display: flex; align-items: center; gap: 8px;
54
- padding: 10px 16px; background: var(--bg);
55
- border-bottom: 1px solid var(--line);
47
+ position: sticky; top: 0; z-index: 5; height: var(--top-h);
48
+ display: flex; align-items: center; gap: 6px; padding: 0 12px;
49
+ background: var(--bg); border-bottom: 1px solid var(--line);
56
50
  }
57
- .brand { font-weight: 650; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
51
+ .brand { font-weight: 600; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
58
52
  .spacer { flex: 1; }
59
- .link { color: var(--accent); text-decoration: none; font-size: 12px; white-space: nowrap; }
60
- .icon-btn {
61
- appearance: none; border: 0; background: transparent; cursor: pointer;
62
- font-size: 24px; line-height: 1; padding: 0 6px 2px; color: var(--sub);
63
- }
53
+ .link { color: var(--accent); text-decoration: none; font-size: 12px; white-space: nowrap; cursor: pointer; }
54
+ .link:hover { text-decoration: underline; }
55
+ .icon-btn { appearance: none; border: 0; background: transparent; cursor: pointer; font-size: 18px; line-height: 1; padding: 2px 6px; color: var(--sub); border-radius: 6px; }
56
+ .icon-btn:hover { background: var(--hover); color: var(--text); }
57
+ .manage { display: inline-flex; align-items: center; gap: 6px; margin-left: 2px; }
58
+ .manage .ver { font-variant-numeric: tabular-nums; }
64
59
 
65
- main { padding: 14px 16px 40px; max-width: 760px; margin: 0 auto; }
60
+ main { padding: 10px 12px 32px; max-width: 720px; margin: 0 auto; }
66
61
 
67
- .card {
68
- background: var(--card); border: 1px solid var(--line); border-radius: var(--radius);
69
- padding: 14px; margin-bottom: 12px;
70
- }
71
- .card h3 { margin: 0 0 8px; font-size: 14px; }
62
+ /* —— 基础件 —— */
63
+ .card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 12px; margin-bottom: 8px; }
64
+ .card h3 { margin: 0 0 6px; font-size: 13px; font-weight: 600; }
72
65
  .muted { color: var(--sub); }
73
66
  .faint { color: var(--faint); font-size: 12px; }
74
67
  .small { font-size: 12px; }
75
- .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
68
+ .row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
69
+ .row.nowrap { flex-wrap: nowrap; }
76
70
  .grow { flex: 1 1 auto; min-width: 0; }
71
+ .ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
72
+ .section-title { font-size: 12px; font-weight: 600; color: var(--sub); margin: 14px 2px 6px; display: flex; align-items: center; gap: 6px; }
73
+ .section-title .n { font-weight: 400; color: var(--faint); }
74
+ .empty { text-align: center; padding: 22px 10px; color: var(--sub); }
77
75
 
78
76
  .btn {
79
- appearance: none; cursor: pointer; white-space: nowrap;
77
+ appearance: none; cursor: pointer; white-space: nowrap; text-decoration: none; display: inline-flex; align-items: center; gap: 4px;
80
78
  border: 1px solid var(--line); background: var(--card); color: var(--text);
81
- border-radius: 8px; padding: 6px 12px; font-size: 13px; line-height: 20px;
79
+ border-radius: 6px; padding: 3px 10px; font-size: 12.5px; line-height: 20px;
82
80
  }
83
81
  .btn:hover { border-color: var(--accent); }
84
- .btn:disabled { opacity: .5; cursor: default; }
82
+ .btn:disabled { opacity: .5; cursor: default; border-color: var(--line); }
85
83
  .btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
84
+ .btn.primary:hover { filter: brightness(1.06); }
86
85
  .btn.ghost { background: transparent; }
87
86
  .btn.danger { color: var(--bad); }
88
- .btn.big { padding: 10px 16px; font-size: 15px; border-radius: 10px; }
87
+ .btn.big { padding: 5px 14px; font-size: 13px; font-weight: 500; }
88
+ .btn.small { padding: 1px 8px; font-size: 12px; line-height: 18px; }
89
89
  .btn.on { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
90
- .btn kbd { font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; opacity: .55; margin-left: 4px; }
90
+ .btn kbd { font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; opacity: .5; }
91
91
 
92
- input[type=text] {
93
- width: 100%; font: inherit; color: var(--text); background: var(--bg);
94
- border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; outline: none;
92
+ input[type=text], textarea, select {
93
+ color: var(--text); background: var(--bg); border: 1px solid var(--line); border-radius: 6px; outline: none; max-width: 100%;
95
94
  }
96
- input[type=text]:focus { border-color: var(--accent); }
97
-
98
- .drop { border: 1.5px dashed var(--line); border-radius: var(--radius); padding: 14px; text-align: center; }
99
- .drop.over { border-color: var(--accent); background: var(--accent-soft); }
95
+ input[type=text] { width: 100%; padding: 4px 8px; }
96
+ textarea { width: 100%; padding: 6px 8px; resize: vertical; }
97
+ select { padding: 3px 6px; font-size: 12.5px; background: var(--card); }
98
+ input[type=text]:focus, textarea:focus, select:focus { border-color: var(--accent); }
100
99
 
101
- .bar { height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; }
102
- .bar > i { display: block; height: 100%; background: var(--accent); border-radius: 3px; transition: width .3s; }
100
+ .bar { height: 4px; border-radius: 2px; background: var(--line); overflow: hidden; }
101
+ .bar > i { display: block; height: 100%; background: var(--accent); border-radius: 2px; transition: width .3s; }
103
102
  .bar.good > i { background: var(--good); }
103
+ .bar.two { position: relative; }
104
+ .bar.two > i { position: absolute; left: 0; top: 0; }
105
+ .bar.two > i.seen { background: var(--accent-soft); filter: saturate(2); }
106
+ .bar.two > i:not(.seen) { background: var(--good); }
104
107
 
105
- .badge { display: inline-block; font-size: 11px; line-height: 18px; padding: 0 7px; border-radius: 9px; background: var(--accent-soft); color: var(--accent); white-space: nowrap; }
106
- .badge.warn { background: rgba(201, 138, 18, .14); color: var(--mid); }
108
+ .badge { display: inline-block; font-size: 11px; line-height: 16px; padding: 0 6px; border-radius: 8px; background: var(--accent-soft); color: var(--accent); white-space: nowrap; }
109
+ .badge.warn { background: rgba(201, 138, 18, .13); color: var(--mid); }
107
110
  .badge.err { background: rgba(209, 67, 67, .12); color: var(--bad); }
108
111
  .badge.ok { background: rgba(31, 157, 85, .12); color: var(--good); }
109
112
 
110
- .group-title { font-size: 12px; color: var(--sub); margin: 18px 2px 8px; font-weight: 600; }
111
- .lesson { cursor: pointer; }
112
- .lesson:hover { border-color: var(--accent); }
113
- .lesson .t { font-weight: 600; }
114
- .lesson .en { font-size: 12px; color: var(--sub); overflow-wrap: anywhere; }
113
+ .chips { display: flex; gap: 4px; flex-wrap: wrap; margin: 0 0 8px; }
114
+ .chip {
115
+ appearance: none; cursor: pointer; border: 1px solid var(--line); background: transparent; color: var(--sub);
116
+ border-radius: 6px; padding: 1px 9px; font-size: 12px; line-height: 20px;
117
+ }
118
+ .chip:hover { color: var(--text); }
119
+ .chip.on { background: var(--accent-soft); border-color: transparent; color: var(--accent); font-weight: 600; }
120
+ .form { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; align-items: center; margin-top: 8px; }
121
+ .form label { font-size: 12px; color: var(--sub); white-space: nowrap; }
122
+ .form .chips { margin: 0; }
115
123
 
116
- .tabs { display: flex; gap: 4px; margin: 0 0 12px; overflow-x: auto; scrollbar-width: none; }
124
+ .drop { border: 1px dashed var(--line); border-radius: var(--radius); padding: 10px 12px; text-align: center; }
125
+ .drop.over { border-color: var(--accent); background: var(--accent-soft); }
126
+
127
+ .toast {
128
+ position: fixed; left: 50%; bottom: 14px; transform: translateX(-50%); z-index: 30;
129
+ background: var(--text); color: var(--bg); padding: 6px 12px; border-radius: 6px; font-size: 12.5px; max-width: calc(100% - 24px);
130
+ }
131
+ code.cmd { font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; padding: 4px 8px; border: 1px solid var(--line); border-radius: 6px; user-select: all; overflow-wrap: anywhere; }
132
+ .prompt { white-space: pre-wrap; font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; background: var(--bg); border-radius: 6px; padding: 6px 8px; margin: 6px 0 0; color: var(--sub); max-height: 180px; overflow: auto; }
133
+ details > summary { cursor: pointer; list-style: none; }
134
+ details > summary::-webkit-details-marker { display: none; }
135
+
136
+ /* —— 首页:今日一行 —— */
137
+ .today { display: flex; align-items: center; gap: 10px; padding: 8px 10px 8px 12px; }
138
+ .today .t { font-weight: 600; }
139
+ .today .t b { color: var(--accent); font-variant-numeric: tabular-nums; }
140
+ .spark { display: flex; align-items: flex-end; gap: 2px; height: 18px; }
141
+ .spark i { width: 4px; min-height: 2px; background: var(--line); border-radius: 1px; }
142
+ .spark i.has { background: var(--accent); opacity: .55; }
143
+ .spark i.now { opacity: 1; }
144
+
145
+ /* —— 首页:能做什么(两条流程,点了展开导入) —— */
146
+ .flows { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
147
+ .flow {
148
+ appearance: none; width: 100%; text-align: left; cursor: pointer; border: 0; background: transparent;
149
+ display: flex; align-items: center; gap: 10px; padding: 8px 10px 8px 12px;
150
+ }
151
+ .flow + .flow { border-top: 1px solid var(--line); }
152
+ .flow:hover, .flow.on { background: var(--hover); }
153
+ .flow > .ico { color: var(--accent); width: 18px; height: 18px; }
154
+ .flow-line { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; font-weight: 600; }
155
+ .flow-line .arr { color: var(--faint); font-weight: 400; }
156
+ .flow-line .out { font-weight: 500; color: var(--text); }
157
+ .flow .grow > .faint { display: block; }
158
+ .flow .cta { color: var(--accent); font-size: 12px; white-space: nowrap; }
159
+ .flow.on .cta { font-weight: 600; }
160
+ #newPanel .card { margin: 8px 0 0; }
161
+ #jobs:not(:empty) { margin-top: 8px; }
162
+ .job + .job { margin-top: 6px; }
163
+ .job .bar { margin-top: 3px; }
164
+ #lessonJob:not(:empty) { margin-bottom: 8px; }
165
+ .manage-row { margin: 10px 2px 0; }
166
+ details.card > .steps { margin-top: 8px; }
167
+ details.card[open] > summary .faint { display: none; }
168
+
169
+ /* —— 首页:课程列表(一行一课) —— */
170
+ .list { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; margin-bottom: 8px; }
171
+ .list-head { display: flex; align-items: center; gap: 8px; padding: 5px 12px; font-size: 12px; color: var(--sub); background: var(--hover); }
172
+ .list-head b { font-weight: 600; }
173
+ .lrow { display: flex; align-items: center; gap: 10px; padding: 7px 10px 7px 12px; cursor: pointer; border-top: 1px solid var(--line); }
174
+ .lrow:hover { background: var(--hover); }
175
+ .lrow > .ico { color: var(--faint); }
176
+ .lrow.doc > .ico { color: var(--mid); }
177
+ .lrow .t { font-weight: 500; }
178
+ .lrow .m { font-size: 11.5px; color: var(--faint); font-variant-numeric: tabular-nums; }
179
+ .lrow .prog { width: 44px; flex: none; }
180
+ .lrow .chev { color: var(--faint); font-size: 14px; }
181
+ .lrow .err { color: var(--bad); font-size: 11.5px; }
182
+
183
+ /* —— 课程页:页签、声音条 —— */
184
+ .tabs { display: flex; gap: 2px; margin: -2px 0 10px; overflow-x: auto; scrollbar-width: none; border-bottom: 1px solid var(--line); }
185
+ .tabs::-webkit-scrollbar { display: none; }
117
186
  .tab {
118
187
  appearance: none; border: 0; background: transparent; cursor: pointer; white-space: nowrap;
119
- padding: 6px 12px; border-radius: 8px; color: var(--sub); font-size: 13px;
188
+ padding: 5px 9px 6px; color: var(--sub); font-size: 12.5px; border-bottom: 2px solid transparent; margin-bottom: -1px;
120
189
  }
121
- .tab.on { background: var(--card); color: var(--text); font-weight: 600; box-shadow: 0 0 0 1px var(--line); }
190
+ .tab:hover { color: var(--text); }
191
+ .tab.on { color: var(--text); font-weight: 600; border-bottom-color: var(--accent); }
192
+ .voicebar { margin: -2px 0 8px; flex-wrap: nowrap; }
193
+ .voicebar select { flex: 1 1 auto; min-width: 0; }
194
+
195
+ /* —— 概览 —— */
196
+ .lesson-head .title { font-size: 15px; font-weight: 600; line-height: 1.35; }
197
+ .summary { white-space: pre-wrap; color: var(--sub); margin: 6px 0 0; }
198
+ .summary.clamp { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
199
+ .statline { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--sub); margin: 8px 0 5px; }
200
+ .statline b { color: var(--text); font-variant-numeric: tabular-nums; margin-right: 2px; }
201
+ .steps { padding-left: 18px; margin: 6px 0 0; color: var(--sub); font-size: 12.5px; }
202
+ .steps li { margin: 2px 0; }
203
+ .track { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding: 6px 0; border-top: 1px solid var(--line); }
204
+ .track > .grow { flex: 1 1 0; }
205
+ .track .name { font-weight: 500; }
206
+ .track.on .name { color: var(--accent); font-weight: 600; }
207
+ .track .opts { flex-basis: 100%; display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
208
+ .inst { padding: 6px 0; border-top: 1px solid var(--line); }
209
+ .stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; text-align: center; margin: 4px 0 8px; }
210
+ .stat-grid b { display: block; font-size: 16px; font-variant-numeric: tabular-nums; }
211
+ .stat-grid span { font-size: 11px; color: var(--sub); }
122
212
 
123
- /* —— 学习卡片 —— */
124
- .study { text-align: center; padding: 18px 14px; }
125
- .pos { font-size: 12px; color: var(--faint); margin-bottom: 10px; display: flex; justify-content: space-between; }
213
+ /* —— 学习卡片(精听 / 复习 / 磨耳朵) —— */
214
+ .study { text-align: center; padding: 10px 12px 12px; }
215
+ .pos { font-size: 11.5px; color: var(--faint); margin-bottom: 4px; display: flex; justify-content: space-between; align-items: center; }
126
216
  .play {
127
- width: 76px; height: 76px; border-radius: 50%; border: 0; cursor: pointer;
128
- background: var(--accent); color: #fff; font-size: 30px; margin: 6px auto 4px; display: grid; place-items: center;
217
+ width: 52px; height: 52px; border-radius: 50%; border: 0; cursor: pointer;
218
+ background: var(--accent); color: #fff; font-size: 20px; margin: 2px auto 2px; display: grid; place-items: center; padding-left: 3px;
129
219
  }
130
220
  .play.playing { animation: pulse 1.2s ease-in-out infinite; }
131
- @keyframes pulse { 50% { box-shadow: 0 0 0 10px var(--accent-soft); } }
132
- .hint { color: var(--sub); font-size: 13px; margin: 8px 0 14px; }
133
- .en-line { font-size: 19px; line-height: 1.55; font-weight: 500; margin: 12px 0 6px; overflow-wrap: anywhere; }
134
- .en-line mark { background: var(--mark); color: inherit; border-radius: 3px; padding: 0 1px; }
135
- .zh-line { font-size: 16px; margin: 6px 0 10px; }
136
- .asr { font-size: 12px; color: var(--faint); margin-top: -2px; }
137
- .tip { text-align: left; font-size: 13px; background: var(--bg); border-radius: 8px; padding: 8px 10px; margin: 10px 0; }
221
+ @keyframes pulse { 50% { box-shadow: 0 0 0 7px var(--accent-soft); } }
222
+ .controls { display: flex; gap: 4px; justify-content: center; flex-wrap: wrap; margin: 8px 0 2px; }
223
+ .hint { color: var(--sub); font-size: 12.5px; margin: 6px 0 10px; }
224
+ .en-line { font-size: 17px; line-height: 1.5; font-weight: 500; margin: 10px 0 4px; overflow-wrap: anywhere; }
225
+ .en-line .w { cursor: pointer; border-radius: 3px; }
226
+ .en-line .w:hover { background: var(--accent-soft); }
227
+ .en-line .w.hl { background: var(--mark); }
228
+ .zh-line { font-size: 14px; margin: 4px 0 8px; }
229
+ .asr { font-size: 11.5px; color: var(--faint); }
230
+ .tap-hint { font-size: 11px; }
231
+ .tip { text-align: left; font-size: 12.5px; background: var(--bg); border-radius: 6px; padding: 5px 8px; margin: 8px 0; }
138
232
  .tip b { color: var(--mid); font-weight: 600; }
139
- .words { text-align: left; margin: 10px 0 4px; }
140
- .word { display: flex; gap: 8px; align-items: baseline; padding: 5px 0; border-top: 1px solid var(--line); flex-wrap: wrap; }
141
- .word .w { font-weight: 600; }
142
- .word .ipa { color: var(--faint); font-size: 12px; font-family: "Lucida Sans Unicode", "Arial Unicode MS", sans-serif; }
143
- .controls { display: flex; gap: 6px; justify-content: center; flex-wrap: wrap; margin: 12px 0 4px; }
144
- .grades { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 14px; }
145
- .grades .btn { padding: 10px 4px; font-size: 14px; }
233
+ .words { text-align: left; margin: 8px 0 2px; }
234
+ .word { display: flex; gap: 8px; align-items: baseline; padding: 3px 0; border-top: 1px solid var(--line); flex-wrap: wrap; }
235
+ .w0 { font-weight: 600; }
236
+ .word .ipa { color: var(--faint); font-size: 11.5px; font-family: "Lucida Sans Unicode", "Arial Unicode MS", sans-serif; }
237
+ .grades { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 10px; }
238
+ .grades .btn { justify-content: center; padding: 5px 4px; font-size: 13px; }
146
239
  .g0 { color: var(--bad); }
147
240
  .g1 { color: var(--mid); }
148
241
  .g2 { color: var(--good); }
149
- .nav { display: flex; justify-content: space-between; margin-top: 12px; }
150
- .dots { display: flex; flex-wrap: wrap; gap: 3px; margin: 10px 0 0; justify-content: center; }
151
- .dots i { width: 7px; height: 7px; border-radius: 2px; background: var(--line); cursor: pointer; }
242
+ .nav { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; }
243
+ .dots { display: flex; flex-wrap: wrap; gap: 2px; margin: 8px 0 0; justify-content: center; }
244
+ .dots i { width: 6px; height: 6px; border-radius: 1.5px; background: var(--line); cursor: pointer; }
152
245
  .dots i.s1 { background: var(--bad); }
153
246
  .dots i.s2 { background: var(--mid); }
154
247
  .dots i.s3 { background: var(--good); }
155
- .dots i.cur { outline: 2px solid var(--accent); outline-offset: 1px; }
248
+ .dots i.cur { outline: 1.5px solid var(--accent); outline-offset: 1px; }
249
+ .dots i.st { box-shadow: inset 0 -2px 0 var(--mid); }
250
+ .tools { display: inline-flex; gap: 0; }
251
+ .tools .icon-btn { font-size: 15px; padding: 1px 5px; }
252
+ .star.on { color: var(--mid); }
253
+ .swipe-hint { display: none; }
254
+ .speed { font-variant-numeric: tabular-nums; }
156
255
 
157
- /* —— 全文 —— */
158
- .sent { display: flex; gap: 10px; padding: 8px 6px; border-radius: 8px; cursor: pointer; }
159
- .sent:hover { background: var(--accent-soft); }
160
- .sent.cur { background: var(--accent-soft); box-shadow: inset 3px 0 0 var(--accent); }
161
- .sent .tm { font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--faint); padding-top: 3px; min-width: 38px; }
256
+ .choices { display: grid; gap: 6px; margin-top: 4px; }
257
+ .choice { text-align: left; white-space: normal; padding: 6px 10px; font-size: 13.5px; line-height: 1.5; justify-content: space-between; }
258
+ .verdict { font-weight: 600; margin-top: 8px; }
259
+ .verdict.ok { color: var(--good); }
260
+ .verdict.mid { color: var(--mid); }
261
+ .verdict.no { color: var(--bad); }
262
+ .dict-diff { font-size: 14.5px; margin: 4px 0; line-height: 1.8; }
263
+ .dict-diff .ok { color: var(--good); }
264
+ .dict-diff .miss { color: var(--bad); text-decoration: underline wavy; text-underline-offset: 4px; }
265
+ .study input[type=text] { font-size: 15px; padding: 7px 10px; }
266
+ .drill-text { min-height: 78px; display: flex; flex-direction: column; justify-content: center; }
267
+ .drill .en-line { font-size: 18px; }
268
+
269
+ /* —— 连续听、词汇 —— */
270
+ .sent { display: flex; gap: 8px; padding: 5px 6px; border-radius: 6px; cursor: pointer; }
271
+ .sent:hover { background: var(--hover); }
272
+ .sent.cur { background: var(--accent-soft); box-shadow: inset 2px 0 0 var(--accent); }
273
+ .sent .tm { font: 10.5px ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--faint); padding-top: 3px; min-width: 34px; }
162
274
  .sent .e { overflow-wrap: anywhere; }
163
- .sent .z { color: var(--sub); font-size: 13px; }
275
+ .sent .z { color: var(--sub); font-size: 12.5px; }
164
276
  .sent.hide-e .e, .sent.hide-z .z { display: none; }
165
277
  .sent .blank { color: var(--faint); font-size: 12px; }
166
- .sticky-ctl { position: sticky; top: 46px; z-index: 3; background: var(--bg); padding: 6px 0 8px; }
167
-
168
- .term { padding: 8px 0; border-top: 1px solid var(--line); }
278
+ .sticky-ctl { position: sticky; top: var(--top-h); z-index: 3; background: var(--bg); padding: 4px 0 6px; }
279
+ .term { padding: 5px 0; border-top: 1px solid var(--line); }
169
280
  .term:first-child { border-top: 0; }
170
- .term .w { font-weight: 600; }
171
- .summary { white-space: pre-wrap; }
172
- .steps { padding-left: 20px; margin: 6px 0 0; color: var(--sub); font-size: 13px; }
173
- .steps li { margin: 2px 0; }
174
- .stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; text-align: center; margin: 4px 0 10px; }
175
- .stat-grid b { display: block; font-size: 20px; font-variant-numeric: tabular-nums; }
176
- .stat-grid span { font-size: 11px; color: var(--sub); }
177
- .empty { text-align: center; padding: 30px 10px; color: var(--sub); }
178
- .toast {
179
- position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%); z-index: 9;
180
- background: var(--text); color: var(--bg); padding: 8px 14px; border-radius: 8px; font-size: 13px; max-width: calc(100% - 32px);
181
- }
182
- .speed { font-variant-numeric: tabular-nums; }
183
- @media (max-width: 380px) {
184
- .stat-grid { grid-template-columns: repeat(2, 1fr); }
185
- .en-line { font-size: 17px; }
186
- }
187
281
 
188
- /* —— 声音 —— */
189
- select {
190
- font: inherit; font-size: 13px; color: var(--text); background: var(--card);
191
- border: 1px solid var(--line); border-radius: 8px; padding: 5px 8px; max-width: 100%;
192
- }
193
- .voicebar { margin: -4px 0 12px; }
194
- .voicebar select { flex: 1 1 auto; min-width: 0; }
195
- .track { padding: 10px 0; border-top: 1px solid var(--line); }
196
- .track:first-of-type { border-top: 0; }
197
- .track.on b { color: var(--accent); }
198
- a.btn { text-decoration: none; display: inline-block; }
199
-
200
- /* —— 今日学习 —— */
201
- .today .big-num { font-size: 18px; font-weight: 600; }
202
- .today .big-num b { font-size: 26px; color: var(--accent); }
203
- .streak { text-align: center; min-width: 64px; }
204
- .streak b { display: block; font-size: 24px; color: var(--mid); font-variant-numeric: tabular-nums; }
205
- .streak span { font-size: 11px; color: var(--sub); }
206
- .week { display: flex; gap: 6px; align-items: flex-end; height: 54px; margin-top: 10px; }
207
- .week .day { flex: 1; display: flex; flex-direction: column; align-items: center; height: 100%; justify-content: flex-end; }
208
- .week .day i { width: 100%; max-width: 26px; min-height: 2px; background: var(--accent-soft); border-radius: 4px 4px 1px 1px; display: block; }
209
- .week .day.now i { background: var(--accent); }
210
- .week .day span { font-size: 10px; color: var(--faint); margin-top: 2px; }
211
- .bar.two { position: relative; }
212
- .bar.two > i { position: absolute; left: 0; top: 0; }
213
- .bar.two > i.seen { background: var(--accent-soft); }
214
- .bar.two > i:not(.seen) { background: var(--good); }
215
- .ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
216
- .inst { padding: 8px 0; border-top: 1px solid var(--line); }
217
-
218
- /* —— 选项小块 —— */
219
- .chips { display: flex; gap: 6px; flex-wrap: wrap; margin: 0 0 10px; }
220
- .chip {
221
- appearance: none; cursor: pointer; border: 1px solid var(--line); background: var(--card); color: var(--sub);
222
- border-radius: 16px; padding: 3px 12px; font-size: 13px; line-height: 22px;
223
- }
224
- .chip.on { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); font-weight: 600; }
225
- .form { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; align-items: start; margin-top: 12px; }
226
- .form label { font-size: 12px; color: var(--sub); padding-top: 5px; }
227
- .form .chips { margin: 0; }
228
-
229
- /* —— 卡片上的工具:收藏、编辑 —— */
230
- .pos { align-items: center; }
231
- .tools { display: inline-flex; gap: 2px; }
232
- .tools .icon-btn { font-size: 18px; padding: 0 6px; }
233
- .star.on { color: var(--mid); }
234
- .dots i.st { box-shadow: inset 0 -2px 0 var(--mid); }
235
-
236
- /* —— 可以点的英文词、查词弹层 —— */
237
- .en-line .w { cursor: pointer; border-radius: 3px; }
238
- .en-line .w:hover { background: var(--accent-soft); }
239
- .en-line .w.hl { background: var(--mark); }
240
- .tap-hint { margin-top: -2px; }
241
- .w0 { font-weight: 600; }
282
+ /* —— 查词弹层、编辑面板 —— */
242
283
  .pop {
243
- position: fixed; z-index: 20; background: var(--card); border: 1px solid var(--line); border-radius: 10px;
244
- padding: 10px 12px; box-shadow: 0 8px 24px rgba(0, 0, 0, .18); font-size: 13px;
245
- }
246
-
247
- /* —— 编辑面板 —— */
248
- .sheet-wrap { position: fixed; inset: 0; z-index: 15; background: rgba(0, 0, 0, .35); display: flex; align-items: center; justify-content: center; padding: 16px; }
249
- .sheet { background: var(--card); border-radius: 14px; padding: 14px; width: 100%; max-width: 520px; max-height: 90vh; overflow: auto; }
250
- .sheet label { display: block; margin-top: 8px; }
251
- textarea {
252
- width: 100%; font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--line);
253
- border-radius: 8px; padding: 7px 10px; resize: vertical; outline: none;
284
+ position: fixed; z-index: 20; background: var(--card); border: 1px solid var(--line); border-radius: 8px;
285
+ padding: 8px 10px; box-shadow: 0 6px 20px rgba(0, 0, 0, .16); font-size: 12.5px;
254
286
  }
255
- textarea:focus { border-color: var(--accent); }
287
+ .sheet-wrap { position: fixed; inset: 0; z-index: 15; background: rgba(0, 0, 0, .35); display: flex; align-items: center; justify-content: center; padding: 12px; }
288
+ .sheet { background: var(--card); border-radius: 10px; padding: 12px; width: 100%; max-width: 480px; max-height: 90vh; overflow: auto; }
289
+ .sheet label { display: block; margin-top: 6px; }
256
290
  .split-words { display: flex; flex-wrap: wrap; gap: 4px; }
257
- .split-words .btn { padding: 3px 8px; }
258
291
 
259
- /* —— 复习:选意思、听写 —— */
260
- .choices { display: grid; gap: 8px; margin-top: 4px; }
261
- .choice { text-align: left; white-space: normal; padding: 10px 12px; font-size: 15px; line-height: 1.5; }
262
- .verdict { font-weight: 600; margin-top: 12px; }
263
- .verdict.ok { color: var(--good); }
264
- .verdict.mid { color: var(--mid); }
265
- .verdict.no { color: var(--bad); }
266
- .dict-diff { font-size: 16px; margin: 6px 0; line-height: 1.8; }
267
- .dict-diff .ok { color: var(--good); }
268
- .dict-diff .miss { color: var(--bad); text-decoration: underline wavy; text-underline-offset: 4px; }
269
- .study input[type=text] { font-size: 16px; padding: 10px 12px; }
270
-
271
- /* —— 磨耳朵 —— */
272
- .drill-text { min-height: 96px; display: flex; flex-direction: column; justify-content: center; }
273
- .drill .en-line { font-size: 20px; }
292
+ /* —— 导出 —— */
293
+ .pipe { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 12px; color: var(--sub); margin-bottom: 8px; }
294
+ .pipe b { color: var(--text); font-weight: 600; }
295
+ .pipe .arr { color: var(--faint); }
296
+ .exp-h { display: flex; align-items: baseline; gap: 6px; margin: 0 0 4px; }
297
+ .exp-h .num { color: var(--accent); font-weight: 700; font-variant-numeric: tabular-nums; }
298
+ .exp-h b { font-weight: 600; }
299
+ .sub-h { font-size: 13px; font-weight: 600; margin: 4px 0 2px; }
300
+ .row.wrap { flex-wrap: wrap; }
274
301
 
275
- .swipe-hint { display: none; }
276
- @media (pointer: coarse) { .swipe-hint { display: inline; } }
302
+ /* —— 导入文档:确认 —— */
303
+ .doc-confirm .ai-note { margin: 8px 0; padding: 6px 10px; border-radius: 6px; background: var(--accent-soft); line-height: 1.55; font-size: 12.5px; }
304
+ .est { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; text-align: center; margin: 4px 0 6px; }
305
+ .est b { display: block; font-size: 16px; font-variant-numeric: tabular-nums; }
306
+ .est span { font-size: 11px; color: var(--sub); }
277
307
 
278
- /* —— 手机 —— */
279
- @media (max-width: 640px) {
280
- main { padding: 10px 12px calc(40px + env(safe-area-inset-bottom)); }
281
- .btn { padding: 9px 14px; font-size: 15px; }
308
+ /* —— 触屏(手机、平板):手指要点得中 —— */
309
+ @media (pointer: coarse) {
310
+ body { font-size: 14px; }
311
+ .btn { padding: 7px 13px; font-size: 14px; }
312
+ .btn.small { padding: 4px 10px; font-size: 13px; }
313
+ .btn.big { padding: 9px 16px; font-size: 15px; }
282
314
  .btn kbd { display: none; }
283
- .chip { padding: 5px 14px; font-size: 14px; }
284
- .play { width: 88px; height: 88px; font-size: 34px; }
285
- .grades .btn { padding: 14px 4px; font-size: 16px; }
286
- .study { padding: 14px 12px; }
315
+ .chip { padding: 4px 12px; font-size: 13.5px; }
316
+ .tab { padding: 8px 11px 9px; font-size: 14px; }
317
+ .lrow { padding: 10px 10px 10px 12px; }
318
+ .flow { padding: 11px 10px 11px 12px; }
319
+ .play { width: 72px; height: 72px; font-size: 28px; }
320
+ .grades .btn { padding: 12px 4px; font-size: 15px; }
321
+ .en-line { font-size: 18px; }
322
+ .zh-line { font-size: 15px; }
323
+ select, input[type=text] { font-size: 16px; padding: 6px 8px; } /* iOS:小于 16px 聚焦时会放大页面 */
324
+ .tools .icon-btn { font-size: 20px; padding: 2px 9px; }
325
+ .swipe-hint { display: inline; }
326
+ .dots { display: none; }
287
327
  .study .actions {
288
328
  position: sticky; bottom: 0; z-index: 4; background: var(--card);
289
- padding: 10px 0 calc(10px + env(safe-area-inset-bottom)); margin-bottom: -4px;
329
+ padding: 8px 0 calc(8px + env(safe-area-inset-bottom)); margin-bottom: -4px;
290
330
  }
291
- .study .actions .btn.big { width: 100%; }
292
- .dots { display: none; }
293
- .sheet-wrap { align-items: flex-end; padding: 0; }
294
- .sheet { border-radius: 16px 16px 0 0; padding-bottom: calc(14px + env(safe-area-inset-bottom)); }
295
- .tools .icon-btn { font-size: 22px; padding: 2px 10px; }
296
- .form { grid-template-columns: 1fr; }
297
- .form label { padding-top: 0; }
298
- .week { height: 44px; }
331
+ .study .actions .btn.big { width: 100%; justify-content: center; }
332
+ main { padding-bottom: calc(32px + env(safe-area-inset-bottom)); }
299
333
  }
300
-
301
- /* —— 导出 —— */
302
- .sub-h { font-size: 13px; font-weight: 600; margin: 4px 0 2px; }
303
- .row.wrap { flex-wrap: wrap; }
304
-
305
- /* —— 标题旁的版本、更新、卸载 —— */
306
- .manage { display: inline-flex; align-items: center; gap: 6px; margin-left: 4px; flex-wrap: wrap; }
307
- .manage .ver { font-variant-numeric: tabular-nums; }
308
- .btn.small { padding: 2px 9px; font-size: 12px; line-height: 18px; }
309
- code.cmd { font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; user-select: all; overflow-wrap: anywhere; }
310
- @media (max-width: 640px) { .btn.small { padding: 4px 10px; font-size: 13px; } .top { flex-wrap: wrap; } }
311
-
312
- /* —— 首页:新建课程 —— */
313
- .section-title { font-size: 13px; font-weight: 600; color: var(--sub); margin: 18px 2px 8px; }
314
- .new-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
315
- .new-card {
316
- appearance: none; cursor: pointer; text-align: left; font: inherit; color: var(--text);
317
- background: var(--card); border: 1.5px solid var(--line); border-radius: var(--radius);
318
- padding: 14px; display: flex; flex-direction: column; gap: 4px;
334
+ /* —— 窄(侧栏、手机):只调布局,不放大 —— */
335
+ @media (max-width: 520px) {
336
+ .sheet-wrap { align-items: flex-end; padding: 0; }
337
+ .sheet { border-radius: 12px 12px 0 0; padding-bottom: calc(12px + env(safe-area-inset-bottom)); }
338
+ .top { flex-wrap: nowrap; }
319
339
  }
320
- .new-card:hover { border-color: var(--accent); }
321
- .new-card.on { border-color: var(--accent); background: var(--accent-soft); }
322
- .new-card b { font-size: 15px; }
323
- .new-card .faint { line-height: 1.5; }
324
- .new-ico { font-size: 22px; }
325
- #newPanel .card { margin-top: 10px; }
326
- .doc-new .step { display: flex; gap: 10px; margin: 12px 0; }
327
- .doc-new .num {
328
- flex: none; width: 22px; height: 22px; border-radius: 50%; background: var(--accent); color: #fff;
329
- font-size: 12px; font-weight: 700; display: grid; place-items: center; margin-top: 1px;
340
+ @media (max-width: 360px) {
341
+ .est, .stat-grid { grid-template-columns: repeat(2, 1fr); }
342
+ .form { grid-template-columns: 1fr; gap: 3px; }
343
+ .form label { margin-top: 5px; }
330
344
  }
331
- .doc-new textarea { margin-top: 6px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; }
332
- .prompt { white-space: pre-wrap; font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; background: var(--bg); border-radius: 8px; padding: 8px 10px; margin: 6px 0 0; color: var(--sub); }
333
- .kind { font-size: 11px; font-weight: 600; padding: 0 6px; border-radius: 4px; margin-right: 6px; vertical-align: 1px; background: var(--accent-soft); color: var(--accent); }
334
- .kind.doc { background: rgba(201, 138, 18, .14); color: var(--mid); }
335
- @media (max-width: 640px) { .new-grid { grid-template-columns: 1fr; } }
336
-
337
- /* —— 导入文档:确认 —— */
338
- #docDrop { padding: 22px 14px; }
339
- #docDrop .new-ico { font-size: 28px; }
340
- .doc-confirm .ai-note { margin: 10px 0; padding: 10px 12px; border-radius: 10px; background: var(--accent-soft); line-height: 1.6; font-size: 13px; }
341
- .doc-confirm .est { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; text-align: center; margin: 6px 0 8px; }
342
- .doc-confirm .est b { display: block; font-size: 20px; font-variant-numeric: tabular-nums; }
343
- .doc-confirm .est span { font-size: 11px; color: var(--sub); }
344
- .doc-confirm .form input[type=text] { margin: 0; }
345
- @media (max-width: 380px) { .doc-confirm .est { grid-template-columns: repeat(2, 1fr); } }
package/web/panel.js CHANGED
@@ -12,6 +12,16 @@ import {
12
12
  import { paneDrill, paneText, paneWords } from './listen.js'
13
13
  import { paneReview, paneStudy, paneToday } from './study.js'
14
14
 
15
+ // ———————————————————— 图标(细线,跟着文字颜色) ————————————————————
16
+
17
+ const ICONS = {
18
+ audio: '<path d="M4 10v4M8 7v10M12 4v16M16 8v8M20 11v2"/>',
19
+ doc: '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M9 13h6M9 17h4"/>',
20
+ down: '<path d="M12 4v11M7 11l5 5 5-5M5 20h14"/>',
21
+ phone: '<rect x="7" y="3" width="10" height="18" rx="2"/><path d="M11 18h2"/>',
22
+ }
23
+ const ico = name => `<svg class="ico" viewBox="0 0 24 24" aria-hidden="true">${ICONS[name]}</svg>`
24
+
15
25
  // ———————————————————— 路由 ————————————————————
16
26
 
17
27
  function route() {
@@ -70,7 +80,7 @@ function renderJobs() {
70
80
  return
71
81
  }
72
82
  const recent = S.jobs.filter(j => j.state === 'running' || j.state === 'queued' || (j.finishedAt && Date.now() - j.finishedAt < 60e3 && j.state !== 'done'))
73
- box.innerHTML = recent.length ? `<div style="margin-top:12px">${recent.map(jobLine).join('')}</div>` : ''
83
+ box.innerHTML = recent.length ? `<div class="card jobs">${recent.map(jobLine).join('')}</div>` : ''
74
84
  box.querySelectorAll('[data-cancel]').forEach(b => {
75
85
  b.onclick = async () => { try { await api('cancel', { job: b.dataset.cancel }); pollJobsNow() } catch (e) { toast(e.message) } }
76
86
  })
@@ -79,10 +89,10 @@ function renderJobs() {
79
89
  function jobLine(j) {
80
90
  const pct = j.total ? Math.round(j.done / j.total * 100) : (j.state === 'running' ? 5 : 0)
81
91
  const state = { queued: '排队中', running: j.stage, error: '出错', cancelled: '已取消', done: '完成' }[j.state]
82
- return `<div style="margin:8px 0">
83
- <div class="row small"><span class="grow ellipsis">${esc(j.title)}</span>
84
- <span class="muted">${esc(state)}${j.total ? ` ${j.done}/${j.total} ${esc(j.message || '')}` : ''}</span>
85
- ${j.state === 'running' || j.state === 'queued' ? `<button class="btn ghost small" data-cancel="${esc(j.id)}" style="padding:0 8px">取消</button>` : ''}</div>
92
+ return `<div class="job">
93
+ <div class="row nowrap small"><span class="grow ellipsis">${esc(j.title)}</span>
94
+ <span class="muted ellipsis">${esc(state)}${j.total ? ` ${j.done}/${j.total} ${esc(j.message || '')}` : ''}</span>
95
+ ${j.state === 'running' || j.state === 'queued' ? `<button class="btn ghost small" data-cancel="${esc(j.id)}">取消</button>` : ''}</div>
86
96
  ${j.state === 'running' ? `<div class="bar"><i style="width:${pct}%"></i></div>` : ''}
87
97
  ${j.error ? `<div class="small" style="color:var(--bad)">${esc(j.error)}</div>` : ''}
88
98
  </div>`
@@ -145,33 +155,37 @@ function renderLibrary() {
145
155
  }
146
156
  const type = store.get('newType', '')
147
157
 
158
+ const flow = (kind, icon, steps, note, cta) => `
159
+ <button class="flow ${type === kind ? 'on' : ''}" data-new="${kind}">
160
+ ${ico(icon)}
161
+ <span class="grow">
162
+ <span class="flow-line">${steps.map((s, k) => `${k ? '<span class="arr">→</span>' : ''}<span>${s}</span>`).join('')}</span>
163
+ <span class="faint">${note}</span>
164
+ </span>
165
+ <span class="cta">${cta} ›</span>
166
+ </button>`
167
+
148
168
  app.innerHTML = `
149
169
  <div id="todayCard">${todayCard(S.today)}</div>
150
170
  ${canImport ? `
151
- <div class="section-title">新建课程</div>
152
- <div class="new-grid">
153
- <button class="new-card ${type === 'audio' ? 'on' : ''}" data-new="audio">
154
- <span class="new-ico">🎧</span><b>导入音频</b>
155
- <span class="faint">有声书、讲座、引导音频……MP3 / M4A / WAV / FLAC。本机识别出每一句英文,AI 翻译讲解。</span>
156
- </button>
157
- <button class="new-card ${type === 'doc' ? 'on' : ''}" data-new="doc">
158
- <span class="new-ico">📄</span><b>文档转音频</b>
159
- <span class="faint">文章、手册、书……整理好的英文文字(粘贴或 TXT / Markdown)。AI 朗读成音频,再翻译讲解。</span>
160
- </button>
171
+ <div class="section-title">新建 <span class="n">做好的同时也是一节听力课,成品都能下载到本地</span></div>
172
+ <div class="flows">
173
+ ${flow('audio', 'audio', ['英文音频', '带时间的字幕', '中文配音'], '识别出每一句英文和它的时间,导出字幕;再按同样的时间生成中文音频', '导入音频')}
174
+ ${flow('doc', 'doc', ['文档 / 文字', '朗读音频', '下载到本地'], 'PDF、Word、EPUB、TXT 或粘贴的文字,AI 朗读成 MP3,单课或整篇下载', '文档转音频')}
161
175
  </div>
162
176
  <div id="newPanel"></div>
163
- <div id="jobs"></div>` : `<div class="card small muted">这里只用来学习:在电脑上导入、做好课,再用电脑上的「发送到 VPS」发过来。<div id="jobs"></div></div>`}
164
- <div class="section-title">我的课程</div>
177
+ <div id="jobs"></div>` : `<div class="card small muted">这里只用来学习:在电脑上导入、做好课,再用电脑上的「发送到 VPS」发过来。</div><div id="jobs"></div>`}
178
+ <div class="section-title">我的课程 ${S.lessons.length ? `<span class="n">${S.lessons.length}</span>` : ''}</div>
165
179
  ${S.lessons.length ? '' : `<div class="empty">还没有课。上面选一种:导入英文音频,或把英文文档转成音频。</div>`}
166
- ${[...groups].map(([g, ls]) => `
167
- ${g ? `<div class="group-title row"><span class="grow">${esc(g)}</span>${docDownload(ls)}</div>` : (groups.size > 1 ? '<div class="group-title">其他</div>' : '')}
168
- ${ls.map(lessonCard).join('')}`).join('')}
180
+ ${[...groups].map(([g, ls]) => `<div class="list">
181
+ ${g || groups.size > 1 ? `<div class="list-head"><b class="ellipsis">${esc(g || '其他')}</b><span class="grow">${ls.length} 课</span>${docDownload(ls)}</div>` : ''}
182
+ ${ls.map(lessonRow).join('')}</div>`).join('')}
169
183
  <div id="vpsCard"></div>
170
184
  `
171
185
  renderJobs()
172
186
  loadToday()
173
187
  renderVps()
174
- app.querySelectorAll('.lesson').forEach(el => { el.onclick = () => go(`#/l/${el.dataset.id}`) })
188
+ app.querySelectorAll('.lrow').forEach(el => { el.onclick = () => go(`#/l/${el.dataset.id}`) })
175
189
  if (!canImport) return
176
190
  app.querySelectorAll('[data-new]').forEach(b => {
177
191
  b.onclick = () => {
@@ -196,17 +210,17 @@ function renderNewPanel(type) {
196
210
  function newAudioPanel(box) {
197
211
  const st = S.status || {}
198
212
  box.innerHTML = `<div class="card">
199
- <h3>导入英文音频</h3>
200
- ${!st.models ? '<div class="faint">第一次导入会先下载语音识别模型(约 240 MB),之后都在本机识别,音频不上传。</div>' : ''}
201
- ${!st.llm ? '<div class="small" style="color:var(--mid)">没找到可用的大模型:只能识别英文,翻译讲解要等配置好模型后点「补全翻译」。</div>' : ''}
202
- <div class="drop" id="drop" style="margin-top:8px">
203
- <div class="row">
204
- <input type="text" class="grow" id="path" placeholder="粘贴音频文件或文件夹的路径" style="flex:1 1 200px">
213
+ <div class="drop" id="drop">
214
+ <div class="row nowrap">
215
+ <input type="text" class="grow" id="path" placeholder="粘贴音频文件或文件夹的路径">
205
216
  <button class="btn primary" id="importBtn">导入</button>
206
217
  </div>
207
- <div class="faint" style="margin-top:8px">也可以把音频拖到这里,或 <a href="#" class="link" id="pick">选择文件</a>。文件夹会把里面的每个音频各做成一课。</div>
218
+ <div class="faint" style="margin-top:6px">或把音频拖到这里 · <a href="#" class="link" id="pick">选择文件</a> · MP3 / M4A / WAV / FLAC,文件夹里每个音频各做成一课</div>
208
219
  <input type="file" id="file" accept="audio/*,.mp3,.m4a,.m4b,.wav,.flac,.ogg,.opus,.aac" multiple hidden>
209
220
  </div>
221
+ <div class="faint" style="margin-top:6px">做好后在课程的「导出」里下载:带时间的字幕(SRT / LRC)、时间对齐的中文配音(MP3 / FLAC)。</div>
222
+ ${!st.models ? '<div class="faint">第一次导入会先下载语音识别模型(约 240 MB),之后都在本机识别,音频不上传。</div>' : ''}
223
+ ${!st.llm ? '<div class="small" style="color:var(--mid)">没找到可用的大模型:只能识别英文,翻译讲解要等配置好模型后点「补全翻译」。</div>' : ''}
210
224
  </div>`
211
225
  const input = $('#path')
212
226
  input.value = store.get('lastPath', '')
@@ -238,21 +252,19 @@ let pendingDoc = null // 已经读出文字、等确认的文档
238
252
  function newDocPanel(box) {
239
253
  if (pendingDoc) return docConfirm(box, pendingDoc)
240
254
  box.innerHTML = `<div class="card">
241
- <h3>文档转音频</h3>
242
255
  <div class="drop" id="docDrop">
243
- <div class="new-ico">📄</div>
244
256
  <div><b>把文档拖到这里</b>,或 <a href="#" class="link" id="docPick">选择文件</a></div>
245
- <div class="faint" style="margin-top:4px">PDF、Word(.docx)、EPUB 电子书、网页(.html)、TXT、Markdown · 最大 100 MB · 约 6 万词以内</div>
257
+ <div class="faint">PDF · Word(.docx)· EPUB · 网页 · TXT · Markdown,最大 100 MB、约 6 万词</div>
246
258
  <input type="file" id="docFile" accept=".pdf,.docx,.epub,.html,.htm,.txt,.md,.markdown" hidden>
247
259
  </div>
248
- <div class="faint" style="margin-top:8px">导入后先让你确认:AI 会把文字整理好(去掉页眉页码、修好断行、分好章节,原文不改),再朗读成音频、逐句翻译讲解。</div>
249
- <details style="margin-top:8px"><summary class="link">没有文件?直接粘贴英文</summary>
250
- <textarea id="docText" rows="6" placeholder="把英文文章粘贴到这里"></textarea>
251
- <button class="btn" id="docPaste" style="margin-top:6px">下一步</button>
260
+ <div class="faint" style="margin-top:6px">先让你确认:AI 把文字整理好(去页眉页码、修断行、分章节,原文不改),再朗读成音频。转好的音频在课程列表和课程页里下载到本地。</div>
261
+ <details style="margin-top:6px"><summary class="link">没有文件?直接粘贴英文</summary>
262
+ <textarea id="docText" rows="5" placeholder="把英文文章粘贴到这里" style="margin-top:6px"></textarea>
263
+ <button class="btn" id="docPaste" style="margin-top:4px">下一步</button>
252
264
  </details>
253
265
  <div id="docBusy"></div>
254
266
  </div>`
255
- const busy = msg => { $('#docBusy').innerHTML = msg ? `<div class="faint" style="margin-top:8px">${msg}</div>` : '' }
267
+ const busy = msg => { $('#docBusy').innerHTML = msg ? `<div class="faint" style="margin-top:6px">${msg}</div>` : '' }
256
268
  const got = doc => { pendingDoc = doc; docConfirm(box, doc) }
257
269
  const upload = f => {
258
270
  if (!f) return
@@ -303,16 +315,14 @@ async function docConfirm(box, doc) {
303
315
  const e = doc.estimate
304
316
  const blocked = engine === 'edge' && !edgeReady
305
317
  box.innerHTML = `<div class="card doc-confirm">
306
- <h3>确认导入</h3>
307
- <div class="row"><span class="new-ico">📄</span><div class="grow"><b>${esc(doc.name)}</b>
308
- <div class="faint">${esc(doc.type)}${doc.pages ? ` · ${doc.pages} 页` : ''} · 约 ${e.words.toLocaleString()} 个英文单词</div></div></div>
309
- <details style="margin-top:6px"><summary class="link">看看读到的文字</summary><pre class="prompt">${esc(doc.preview)}${doc.preview.length >= 600 ? '…' : ''}</pre></details>
318
+ <div class="row nowrap">${ico('doc')}<div class="grow"><b>${esc(doc.name)}</b>
319
+ <div class="faint">${esc(doc.type)}${doc.pages ? ` · ${doc.pages} 页` : ''} · 约 ${e.words.toLocaleString()} 个英文单词 · <details style="display:inline"><summary class="link" style="display:inline">看看读到的文字</summary><pre class="prompt">${esc(doc.preview)}${doc.preview.length >= 600 ? '…' : ''}</pre></details></div></div></div>
310
320
  <div class="ai-note">
311
321
  ${doc.needsCleanup
312
322
  ? (doc.hasModel
313
- ? `🤖 <b>会用 AI 整理文字</b>(${esc(doc.model)}):去掉页眉页码和目录、修好断行、分好章节。只整理格式,原文一个字都不改,不翻译。整理好的文字会存档,随时能下载。`
314
- : '⚠ 没有可用的大模型:只能简单接好断行,页眉页码可能留下。配好 DSH 的默认模型再导入效果更好。')
315
- : '✓ 已经是整理好的格式(有「# 章节」),不用 AI 整理。'}
323
+ ? `<b>会用 AI 整理文字</b>(${esc(doc.model)}):去掉页眉页码和目录、修好断行、分好章节。只整理格式,原文一个字都不改,不翻译。整理好的文字会存档,随时能下载。`
324
+ : '没有可用的大模型:只能简单接好断行,页眉页码可能留下。配好 DSH 的默认模型再导入效果更好。')
325
+ : '已经是整理好的格式(有「# 章节」),不用 AI 整理。'}
316
326
  </div>
317
327
  <div class="est">
318
328
  <div><b>${e.lessons}</b><span>约几课</span></div>
@@ -332,8 +342,8 @@ async function docConfirm(box, doc) {
332
342
  <label>声音</label><select id="docVoice">${group('m', '男声')}${group('f', '女声')}</select>
333
343
  <label>语速</label><div class="chips">${[0.85, 1, 1.15].map(x => `<button class="chip ${x === speed ? 'on' : ''}" data-docspeed="${x}">${x === 1 ? '正常' : x < 1 ? '慢一点' : '快一点'}</button>`).join('')}</div>`}
334
344
  </div>
335
- <div class="row" style="margin-top:12px">
336
- <button class="btn primary big" id="docGo" ${blocked ? 'disabled' : ''}>确定,开始导入</button>
345
+ <div class="row" style="margin-top:10px">
346
+ <button class="btn primary big" id="docGo" ${blocked ? 'disabled' : ''}>确定,开始转音频</button>
337
347
  <button class="btn ghost" id="docCancel">取消</button>
338
348
  </div>
339
349
  </div>`
@@ -378,7 +388,7 @@ function docDownload(ls) {
378
388
  if (ls.some(l => l.kind !== 'doc') || ids.length !== 1) return ''
379
389
  const waiting = ls.filter(l => !l.hasAudio).length
380
390
  if (waiting) return `<span class="faint">还有 ${waiting} 课在转音频</span>`
381
- return `<a class="link" href="doc/audio?id=${ids[0]}" download title="整篇文档的朗读,合成一个 MP3">⬇ 下载整篇音频</a>`
391
+ return `<a class="btn small" href="doc/audio?id=${ids[0]}" download title="整篇文档的朗读,合成一个 MP3 下载到本地">${ico('down')}整篇音频</a>`
382
392
  }
383
393
 
384
394
  // —— 今日学习卡片 ——
@@ -395,24 +405,23 @@ function todayCard(t) {
395
405
  const n = t.queue.length
396
406
  const max = Math.max(1, ...t.week.map(d => d.count))
397
407
  const wd = ['日', '一', '二', '三', '四', '五', '六']
408
+ const sub = [
409
+ n ? `复习 ${t.dueCount} · 新句 ${t.newCount}` : '可以去「连续听」或「磨耳朵」再听一遍',
410
+ t.todayCount ? `今天已学 ${t.todayCount} 句 · 约 ${t.minutes} 分钟` : '',
411
+ t.streak ? `连续 ${t.streak} 天` : '',
412
+ ].filter(Boolean).join(' · ')
398
413
  return `<div class="card today">
399
- <div class="row">
400
- <div class="grow">
401
- <div class="big-num">${n ? `今天要学 <b>${n}</b> 句` : (t.todayCount ? '今天的学完了 🎉' : '今天没有要学的')}</div>
402
- <div class="faint">${n ? `复习 ${t.dueCount} 句 · 新句子 ${t.newCount} 句` : '可以去「连续听」或「磨耳朵」再听一遍'}</div>
403
- </div>
404
- <div class="streak" title="连续学习天数"><b>${t.streak}</b><span>连续天数</span></div>
405
- </div>
406
- <div class="week">${t.week.map(d => `<div class="day ${d.day === t.today ? 'now' : ''}"><i style="height:${Math.round(d.count / max * 100)}%" title="${d.count} 句"></i><span>${wd[new Date(d.day + 'T12:00:00').getDay()]}</span></div>`).join('')}</div>
407
- <div class="row" style="margin-top:10px">
408
- <button class="btn primary big grow" id="goToday" ${n ? '' : 'disabled'}>${n ? '开始今天的学习' : '已完成'}</button>
409
- <span class="faint">今天 ${t.todayCount} 句 · 约 ${t.minutes} 分钟</span>
414
+ <div class="grow">
415
+ <div class="t">${n ? `今天要学 <b>${n}</b> 句` : (t.todayCount ? '今天的学完了' : '今天没有要学的')}</div>
416
+ <div class="faint ellipsis">${sub}</div>
410
417
  </div>
418
+ <div class="spark" title="最近 7 天学了多少句">${t.week.map(d => `<i class="${d.count ? 'has' : ''} ${d.day === t.today ? 'now' : ''}" style="height:${Math.round(d.count / max * 100)}%" title="周${wd[new Date(d.day + 'T12:00:00').getDay()]} ${d.count} 句"></i>`).join('')}</div>
419
+ ${n ? '<button class="btn primary big" id="goToday">开始 ›</button>' : ''}
411
420
  </div>`
412
421
  }
413
422
  function bindToday() { const b = $('#goToday'); if (b) b.onclick = () => go('#/today') }
414
423
 
415
- function lessonCard(l) {
424
+ function lessonRow(l) {
416
425
  const s = l.stats
417
426
  const pct = s.total ? Math.round(s.mastered / s.total * 100) : 0
418
427
  const seenPct = s.total ? Math.round(s.seen / s.total * 100) : 0
@@ -423,11 +432,17 @@ function lessonCard(l) {
423
432
  paused: '<span class="badge warn">已暂停</span>',
424
433
  error: '<span class="badge err">出错</span>',
425
434
  }[l.status] || (s.due ? `<span class="badge">待复习 ${s.due}</span>` : (s.total && s.mastered === s.total ? '<span class="badge ok">已掌握</span>' : ''))
426
- return `<div class="card lesson" data-id="${esc(l.id)}">
427
- <div class="row"><div class="grow"><div class="t"><span class="kind ${l.kind === 'doc' ? 'doc' : ''}">${l.kind === 'doc' ? '文档' : '音频'}</span>${esc(l.titleZh || l.title)}</div>${l.titleZh ? `<div class="en">${esc(l.title)}</div>` : ''}</div>${badge}</div>
428
- <div class="faint" style="margin:6px 0">${fmtDur(l.duration)} · ${l.sentences} 句${l.translated < l.sentences ? ` · 已翻译 ${l.translated}` : ''} · 学过 ${s.seen} · 掌握 ${s.mastered}/${s.total}${s.nextDue && !s.due ? ` · 下次复习 ${when(s.nextDue)}` : ''}</div>
429
- <div class="bar two"><i class="seen" style="width:${seenPct}%"></i><i style="width:${pct}%"></i></div>
430
- ${l.error ? `<div class="small" style="color:var(--bad);margin-top:6px">${esc(l.error)}</div>` : ''}
435
+ const doc = l.kind === 'doc'
436
+ return `<div class="lrow ${doc ? 'doc' : ''}" data-id="${esc(l.id)}" title="${esc(l.title)}">
437
+ <span title="${doc ? '文档转的音频' : '导入的音频'}" style="display:flex">${ico(doc ? 'doc' : 'audio')}</span>
438
+ <div class="grow">
439
+ <div class="t ellipsis">${esc(l.titleZh || l.title)}</div>
440
+ <div class="m ellipsis">${fmtDur(l.duration)} · ${l.sentences} 句${l.translated < l.sentences ? ` · 已翻译 ${l.translated}` : ''} · 掌握 ${s.mastered}/${s.total}${s.nextDue && !s.due ? ` · ${when(s.nextDue)}复习` : ''}</div>
441
+ ${l.error ? `<div class="err ellipsis">${esc(l.error)}</div>` : ''}
442
+ </div>
443
+ ${badge}
444
+ <div class="bar two prog" title="学过 ${s.seen} · 掌握 ${s.mastered} / ${s.total}"><i class="seen" style="width:${seenPct}%"></i><i style="width:${pct}%"></i></div>
445
+ <span class="chev">›</span>
431
446
  </div>`
432
447
  }
433
448
 
@@ -472,24 +487,23 @@ async function renderVps() {
472
487
  if (!v.enabled || (!v.config && !v.hosts.length)) { box.innerHTML = ''; return }
473
488
  const c = v.config
474
489
  const editing = !c || box.dataset.edit === '1'
475
- box.innerHTML = `<div class="card" id="vps">
476
- <h3>手机学习(VPS)</h3>
490
+ box.innerHTML = `<div class="section-title">手机上学</div><div class="card" id="vps">
477
491
  ${!editing ? `
478
- <div class="small">发到:<b>${esc(c.domain || c.host)}</b> <span class="faint">(SSH ${esc(c.host)} · 用户 ${esc(c.user)})</span></div>
479
- ${v.url ? `<div class="row" style="margin-top:8px"><span class="faint">手机打开:</span><a class="link ellipsis grow" href="${esc(v.url)}" target="_blank" rel="noopener">${esc(v.url)}</a><button class="btn ghost" id="copyUrl">复制</button></div>` : ''}
480
- <div class="row" style="margin-top:10px">
481
- <button class="btn primary" id="sendAll">全部发送到 VPS</button>
482
- <button class="btn ghost" id="vpsEdit">改设置</button>
492
+ <div class="row nowrap">${ico('phone')}
493
+ <div class="grow"><div class="ellipsis"><b>${esc(c.domain || c.host)}</b> <span class="faint">SSH ${esc(c.host)} · 用户 ${esc(c.user)}</span></div></div>
494
+ <button class="btn primary small" id="sendAll" title="把全部课程发到 VPS;只发课程内容和声音,不会覆盖那边的学习进度">全部发送</button>
495
+ <button class="btn ghost small" id="vpsEdit">设置</button>
483
496
  </div>
484
- <div class="faint" style="margin-top:6px">只发课程内容和声音,不会覆盖 VPS 上的学习进度。每课页面的「管理」里也能单独发送。VPS 上的 DSH 要装好「听懂」插件。</div>` : `
497
+ ${v.url ? `<div class="row nowrap" style="margin-top:6px"><span class="faint">手机打开</span><a class="link ellipsis grow" href="${esc(v.url)}" target="_blank" rel="noopener">${esc(v.url)}</a><button class="btn ghost small" id="copyUrl">复制</button></div>` : ''}
498
+ <div class="faint" style="margin-top:4px">只发课程和声音,不覆盖 VPS 上的学习进度;每课的「管理」里也能单独发送。</div>` : `
485
499
  <div class="faint">在电脑上做好课,发到 VPS 上的 DSH,手机打开就能学。这台电脑要能用 SSH 钥匙登录 VPS(dsh-vps-manager 添加机器时会自动配好)。</div>
486
- <div class="row" style="margin-top:8px">
500
+ <div class="row" style="margin-top:6px">
487
501
  <span class="faint">SSH 主机</span>
488
502
  <select id="vpsHost">${v.hosts.map(h => `<option ${c && c.host === h ? 'selected' : ''}>${esc(h)}</option>`).join('')}</select>
489
503
  <button class="btn" id="vpsDetect">查找 VPS 上的 DSH</button>
504
+ ${c ? '<button class="btn ghost" id="vpsCancel">取消</button>' : ''}
490
505
  </div>
491
- <div id="vpsFound">${vpsFound ? foundList(vpsFound) : ''}</div>
492
- ${c ? '<button class="btn ghost" id="vpsCancel" style="margin-top:8px">取消</button>' : ''}`}
506
+ <div id="vpsFound">${vpsFound ? foundList(vpsFound) : ''}</div>`}
493
507
  </div>`
494
508
  const on = (sel, fn) => { const el = box.querySelector(sel); if (el) el.onclick = fn }
495
509
  on('#vpsEdit', () => { box.dataset.edit = '1'; renderVps() })
@@ -512,11 +526,11 @@ async function renderVps() {
512
526
 
513
527
  function foundList(f) {
514
528
  if (!f.instances.length) return '<div class="small" style="color:var(--bad);margin-top:8px">这台 VPS 上没找到正在运行的 DSH 网页版。先用 dsh-vps 装好 DSH。</div>'
515
- return `<div style="margin-top:10px">${f.instances.map((x, k) => `
516
- <div class="row inst">
529
+ return `<div style="margin-top:6px">${f.instances.map((x, k) => `
530
+ <div class="row nowrap inst">
517
531
  <div class="grow"><b>${esc(x.domain || `端口 ${x.port}`)}</b> <span class="faint">用户 ${esc(x.user)} · ${esc(x.home)}</span>
518
- <div class="faint">${x.hasPlugin ? '✓ 已装「听懂」插件' : '还没装「听懂」插件'}</div></div>
519
- <button class="btn primary" data-pick="${k}">用这个</button>
532
+ <div class="faint">${x.hasPlugin ? '已装「听懂」插件' : '还没装「听懂」插件'}</div></div>
533
+ <button class="btn primary small" data-pick="${k}">用这个</button>
520
534
  </div>`).join('')}
521
535
  ${!f.root && f.instances.some(x => x.user !== f.sshUser) ? `<div class="faint">注意:SSH 用的是 ${esc(f.sshUser)},和 DSH 的用户不同,可能没有权限写进去。</div>` : ''}</div>`
522
536
  }
@@ -586,12 +600,13 @@ function bindVoiceBar() {
586
600
  function manageButtons(l) {
587
601
  const missing = (l.sentences || []).some(s => !s.zh && !s.skip) || (l.sentences.length && !l.summaryZh) || l.status === 'error' || l.status === 'paused'
588
602
  const canWork = !S.status || S.status.ffmpeg
589
- return `<div class="row" style="margin-top:10px">
590
- ${S.vps && S.vps.config && l.status === 'ready' ? `<button class="btn primary" data-act="send">发送到 VPS</button>` : ''}
591
- ${missing && canWork ? '<button class="btn" data-act="resume">补全翻译</button>' : ''}
592
- ${l.sentences && l.sentences.length && canWork ? '<button class="btn ghost" data-act="retranslate">重新翻译</button>' : ''}
593
- <button class="btn ghost" data-act="reset">重置进度</button>
594
- <button class="btn ghost danger" data-act="delete">删除这课</button>
603
+ return `<div class="row manage-row">
604
+ <span class="faint">管理</span>
605
+ ${S.vps && S.vps.config && l.status === 'ready' ? `<button class="btn small" data-act="send">发送到 VPS</button>` : ''}
606
+ ${missing && canWork ? '<button class="btn small" data-act="resume">补全翻译</button>' : ''}
607
+ ${l.sentences && l.sentences.length && canWork ? '<button class="btn ghost small" data-act="retranslate">重新翻译</button>' : ''}
608
+ <button class="btn ghost small" data-act="reset">重置进度</button>
609
+ <button class="btn ghost small danger" data-act="delete">删除这课</button>
595
610
  </div>`
596
611
  }
597
612
 
@@ -618,39 +633,44 @@ function bindManage(root, l) {
618
633
  function paneOverview(pane, l, st) {
619
634
  const pos = getPos()
620
635
  const stars = (S.cur.progress.stars || []).length
636
+ const doc = l.kind === 'doc'
621
637
  pane.innerHTML = `
622
- <div class="card">
623
- ${l.titleZh ? `<div style="font-weight:600">${esc(l.titleZh)}</div><div class="faint">${esc(l.title)}</div>` : `<div style="font-weight:600">${esc(l.title)}</div>`}
624
- <div class="faint" style="margin-top:2px">${l.kind === 'doc' ? `文档 · 朗读:${esc(l.voiceLabel || 'AI')} · ` : ''}${fmtDur(l.duration)} · ${l.sentences.length} 句${l.model ? ` · 翻译:${esc(l.model)}` : ''}</div>
625
- ${l.source && l.source.docId ? `<div class="faint" style="margin-top:2px">原文档:${esc(l.source.name)} · <a class="link" href="doc/file?id=${l.source.docId}&which=original" download>下载原文件</a> · <a class="link" href="doc/file?id=${l.source.docId}&which=clean" download>整理后的文字</a></div>` : ''}
626
- ${l.summaryZh ? `<p class="summary">${esc(l.summaryZh)}</p>` : ''}
638
+ <div class="card lesson-head">
639
+ <div class="title">${esc(l.titleZh || l.title)}</div>
640
+ ${l.titleZh ? `<div class="faint">${esc(l.title)}</div>` : ''}
641
+ <div class="faint">${doc ? `文档 · 朗读:${esc(l.voiceLabel || 'AI')} · ` : ''}${fmtDur(l.duration)} · ${l.sentences.length} 句${l.model ? ` · 翻译:${esc(l.model)}` : ''}</div>
642
+ ${l.source && l.source.docId ? `<div class="faint">原文档:${esc(l.source.name)} · <a class="link" href="doc/file?id=${l.source.docId}&which=original" download>原文件</a> · <a class="link" href="doc/file?id=${l.source.docId}&which=clean" download>整理后的文字</a></div>` : ''}
643
+ ${l.summaryZh ? `<p class="summary clamp" id="summary" title="点一下展开">${esc(l.summaryZh)}</p>` : ''}
627
644
  ${l.note ? `<div class="small" style="color:var(--mid)">${esc(l.note)}</div>` : ''}
628
645
  ${l.status === 'error' ? `<div class="small" style="color:var(--bad)">${esc(l.error)}</div>` : ''}
629
- </div>
630
- ${l.kind === 'doc' && S.cur.lesson.sentences.length ? docAudioCard(l) : ''}
631
- <div class="card">
632
- <div class="stat-grid">
633
- <div><b>${st.fresh}</b><span>没学过</span></div>
634
- <div><b>${st.learning}</b><span>学习中</span></div>
635
- <div><b style="color:var(--good)">${st.mastered}</b><span>已掌握</span></div>
636
- <div><b style="color:var(--accent)">${st.due}</b><span>待复习</span></div>
646
+ <div class="statline">
647
+ <span><b>${st.fresh}</b>没学过</span><span><b>${st.learning}</b>学习中</span>
648
+ <span><b style="color:var(--good)">${st.mastered}</b>已掌握</span><span><b style="color:var(--accent)">${st.due}</b>待复习</span>
649
+ ${stars ? `<span title="在「复习」或「磨耳朵」里选「收藏」专门练"><b style="color:var(--mid)">${stars}</b>难句</span>` : ''}
637
650
  </div>
638
651
  <div class="bar good"><i style="width:${st.total ? Math.round(st.mastered / st.total * 100) : 0}%"></i></div>
639
- <div class="row" style="margin-top:12px">
640
- <button class="btn primary big" id="goStudy">${pos > 0 ? `继续精听(第 ${pos + 1} 句)` : '开始精听'}</button>
652
+ <div class="row" style="margin-top:10px">
653
+ <button class="btn primary big" id="goStudy">${pos > 0 ? `继续精听 · 第 ${pos + 1} 句` : '开始精听'}</button>
641
654
  <button class="btn big" id="goReview" ${st.due || st.fresh ? '' : 'disabled'}>复习${st.due ? ` ${st.due} 句` : ''}</button>
642
655
  <button class="btn big" id="goDrill">磨耳朵</button>
656
+ ${!st.due && st.nextDue ? `<span class="faint">下一批复习 ${when(st.nextDue)}</span>` : ''}
643
657
  </div>
644
- ${!st.due && st.nextDue ? `<div class="faint" style="margin-top:6px">下一批复习:${when(st.nextDue)}</div>` : ''}
645
- ${stars ? `<div class="faint" style="margin-top:6px">难句本:收藏了 ${stars} 句,在「复习」或「磨耳朵」里选「收藏」专门练。</div>` : ''}
646
658
  </div>
647
- ${voicesCard(l)}
648
- <div class="card row">
649
- <div class="grow"><b>导出</b><div class="faint">中文配音(MP3)、双语字幕、LRC 歌词、录音稿</div></div>
650
- <button class="btn" id="goExport">去导出 ›</button>
659
+ ${doc ? `<div class="card"><div class="row"><b>下载转好的音频</b><span class="faint grow">存到本地,放手机或播放器上听</span></div>
660
+ <div class="row" style="margin-top:6px">${docAudioButtons(l)}</div></div>` : ''}
661
+ <div class="flows" style="margin-bottom:8px">
662
+ <button class="flow" id="goExport">
663
+ ${ico('down')}
664
+ <span class="grow">
665
+ <span class="flow-line"><span>导出到本地</span></span>
666
+ <span class="faint">${doc ? '朗读音频(MP3)' : '英文音频(去空白 / 朗读版)'} · 带时间的字幕(SRT / LRC)· 时间对齐的中文配音</span>
667
+ </span>
668
+ <span class="cta">去导出 ›</span>
669
+ </button>
651
670
  </div>
652
- <div class="card">
653
- <h3>怎么练到「一听就懂」</h3>
671
+ ${voicesCard(l)}
672
+ <details class="card">
673
+ <summary class="row nowrap"><b class="grow">怎么练到「一听就懂」</b><span class="faint">展开</span></summary>
654
674
  <ol class="steps">
655
675
  <li><b>精听</b>:每句先<b>只听不看</b>,猜意思 → 看英文,找出没听出来的词(点词看意思)→ 看中文和生词 → 再听一遍。</li>
656
676
  <li>老实自评:<span class="g0">没听懂</span> / <span class="g1">半懂</span> / <span class="g2">听懂了</span>;拿不准的点 ☆ 收进难句本。</li>
@@ -659,9 +679,11 @@ function paneOverview(pane, l, st) {
659
679
  <li><b>连续听</b>:选「原声·去空白」一句接一句不停地听;先用朗读的声音听清每个词,再回到原声;最后关掉字幕,能跟上就是真听懂了。</li>
660
680
  </ol>
661
681
  <div class="faint" style="margin-top:6px">电脑快捷键:空格 重听 · 回车 下一步 · 1/2/3 评分 · ←/→ 换句 · S 变速 · L 循环。手机上左右滑动换句。</div>
662
- </div>
663
- <div class="card"><h3>管理</h3>${manageButtons(l)}</div>`
682
+ </details>
683
+ ${manageButtons(l)}`
664
684
  bindVoices(pane, l)
685
+ const sum = $('#summary')
686
+ if (sum) sum.onclick = () => sum.classList.toggle('clamp')
665
687
  $('#goExport').onclick = () => go(`#/l/${l.id}/export`)
666
688
  $('#goStudy').onclick = () => go(`#/l/${l.id}/study`)
667
689
  $('#goReview').onclick = () => go(`#/l/${l.id}/review`)
@@ -670,19 +692,13 @@ function paneOverview(pane, l, st) {
670
692
  }
671
693
 
672
694
  /** 文档课:下载朗读音频(这一课 / 整篇) */
673
- function docAudioCard(l) {
695
+ function docAudioButtons(l) {
674
696
  const same = S.lessons.filter(x => x.docId && l.source && x.docId === l.source.docId)
675
697
  const waiting = same.filter(x => !x.hasAudio).length
676
- return `<div class="card">
677
- <h3>下载音频</h3>
678
- <div class="faint">把文档转成的音频下载成 MP3,放到手机或播放器上听(朗读:${esc(l.voiceLabel || 'AI')})。</div>
679
- <div class="row" style="margin-top:8px">
680
- <a class="btn primary" href="audio?id=${encodeURIComponent(l.id)}&track=full&download=1" download>这一课 MP3(${fmt(l.duration)})</a>
681
- ${same.length > 1 ? (waiting
682
- ? `<span class="faint">整篇 ${same.length} 课,还有 ${waiting} 课在转音频,做好后可以合成一个下载</span>`
683
- : `<a class="btn" href="doc/audio?id=${l.source.docId}" download>整篇 ${same.length} 课合成一个 MP3</a>`) : ''}
684
- </div>
685
- </div>`
698
+ return `<a class="btn primary" href="audio?id=${encodeURIComponent(l.id)}&track=full&download=1" download>${ico('down')}这一课 MP3 · ${fmt(l.duration)}</a>
699
+ ${same.length > 1 ? (waiting
700
+ ? `<span class="faint">整篇 ${same.length} 课,还有 ${waiting} 课在转音频</span>`
701
+ : `<a class="btn" href="doc/audio?id=${l.source.docId}" download>${ico('down')}整篇 ${same.length} 课合成一个 MP3</a>`) : ''}`
686
702
  }
687
703
 
688
704
  // —— 概览里的「声音」卡片:每种声音一行:状态、选用、试听、生成、下载 ——
@@ -716,25 +732,25 @@ function voicesCard(l) {
716
732
  ? opts(((v.kokoro && v.kokoro.voices) || []), (meta && meta.voice) || 'am_michael')
717
733
  : opts(sayList, (meta && meta.voice) || (sayList[0] && sayList[0].id))}</select>
718
734
  <select data-speed="${name}">${speeds((meta && meta.speed) || 0.9)}</select>` : ''
719
- return `<div class="track ${name === t ? 'on' : ''}">
720
- <div class="row"><b class="grow">${esc(trackLabel(name))}</b>
721
- ${meta ? `<span class="faint">${fmt(meta.duration)}${meta.voice ? ' · ' + esc(meta.voice) : ''}</span>` : '<span class="faint">未生成</span>'}</div>
722
- <div class="faint">${name === 'full' && isDoc() ? '文档转音频时 AI 朗读生成的音频' : TRACK_NOTES[name]}${name === 'kokoro' && v.kokoro && !v.kokoro.ready && canMake ? ';第一次生成会下载约 103 MB 语音模型' : ''}</div>
723
- ${stale ? '<div class="small" style="color:var(--mid)">句子改过了,这条声音对不上了,建议重新生成</div>' : ''}
724
- ${job ? `<div class="small muted" style="margin-top:4px">${job.state === 'queued' ? '排队中' : esc(job.stage)}${job.total ? ` ${job.done}/${job.total}` : ''}…</div>` : `
725
- <div class="row" style="margin-top:6px">
726
- ${meta ? (name === t ? '<span class="badge ok">正在用</span>' : `<button class="btn" data-use="${name}">选用</button>`) : ''}
727
- ${meta ? `<button class="btn ghost" data-try="${name}">▶ 试听</button>` : ''}
728
- ${meta ? `<a class="btn ghost" href="audio?id=${encodeURIComponent(l.id)}&track=${name}&download=1" download>下载 MP3</a>` : ''}
729
- ${voiceSel}
730
- ${name !== 'full' && canMake ? `<button class="btn ${meta ? 'ghost' : 'primary'}" data-make="${name}">${meta ? '重新生成' : '生成'}</button>` : ''}
731
- </div>`}
735
+ const note = (name === 'full' && isDoc() ? '文档转音频时 AI 朗读生成的音频' : TRACK_NOTES[name]) + (name === 'kokoro' && v.kokoro && !v.kokoro.ready && canMake ? ';第一次生成会下载约 103 MB 语音模型' : '')
736
+ const make = name !== 'full' && canMake
737
+ return `<div class="track ${name === t ? 'on' : ''}" title="${esc(note)}">
738
+ <span class="name">${esc(trackLabel(name))}</span>
739
+ ${meta ? `<span class="faint">${fmt(meta.duration)}</span>` : ''}
740
+ <span class="faint grow ellipsis">${meta && meta.voice ? esc(meta.voice) + ' · ' : ''}${esc(note)}</span>
741
+ ${job ? `<span class="small muted">${job.state === 'queued' ? '排队中' : esc(job.stage)}${job.total ? ` ${job.done}/${job.total}` : ''}…</span>` : `
742
+ ${meta ? (name === t ? '<span class="badge ok">正在用</span>' : `<button class="btn small" data-use="${name}">选用</button>`) : ''}
743
+ ${meta ? `<button class="btn ghost small" data-try="${name}" title="试听">▶</button>` : ''}
744
+ ${meta ? `<a class="btn ghost small" href="audio?id=${encodeURIComponent(l.id)}&track=${name}&download=1" download title="下载 MP3 到本地">${ico('down')}</a>` : ''}
745
+ ${make && !tts ? `<button class="btn small ${meta ? 'ghost' : 'primary'}" data-make="${name}">${meta ? '重做' : '生成'}</button>` : ''}
746
+ ${make && tts ? `<div class="opts">${voiceSel}<button class="btn small ${meta ? 'ghost' : 'primary'}" data-make="${name}">${meta ? '重新生成' : '生成'}</button></div>` : ''}`}
747
+ ${stale ? '<div class="small" style="color:var(--mid);flex-basis:100%">句子改过了,这条声音对不上了,建议重新生成</div>' : ''}
732
748
  </div>`
733
749
  }
734
- return `<div class="card" id="voices"><h3>声音</h3>
735
- <div class="faint" style="margin-bottom:6px">同样的句子,换一种声音听。去空白和朗读的声音都可以不停顿地连续听,也能下载到手机。</div>
750
+ return `<div class="card" id="voices">
751
+ <div class="row nowrap" style="margin-bottom:2px"><b>声音</b><span class="faint grow ellipsis">同样的句子换一种声音听,都能下载到本地</span></div>
736
752
  ${TRACK_ORDER.map(row).join('')}
737
- ${canMake && v.sayHint && (v.say || []).length && (v.say || []).every(x => x.quality < 2) ? `<div class="faint" style="margin-top:8px">💡 ${esc(v.sayHint)}</div>` : ''}
753
+ ${canMake && v.sayHint && (v.say || []).length && (v.say || []).every(x => x.quality < 2) ? `<div class="faint" style="margin-top:6px">${esc(v.sayHint)}</div>` : ''}
738
754
  </div>`
739
755
  }
740
756
 
@@ -795,14 +811,37 @@ function zhCard(l) {
795
811
  const edgeMissing = engine === 'edge' && !(V.edge && V.edge.ready)
796
812
  const alignChips = attr => `<button class="chip ${align === 'full' ? 'on' : ''}" data-${attr}="full">原音频(${fmt(l.duration)})</button>
797
813
  ${clean ? `<button class="chip ${align === 'clean' ? 'on' : ''}" data-${attr}="clean">原声·去空白(${fmt(clean.duration)})</button>` : ''}`
798
- return `<div class="card" id="zhCard"><h3>导出</h3>
799
- <div class="sub-h">中文配音(音频)</div>
814
+ const doc = l.kind === 'doc'
815
+ const id = encodeURIComponent(l.id)
816
+ // 三步,顺序就是流水线:音频课「字幕 → 中文配音 → 英文音频」,文档课「朗读音频 → 字幕 → 中文配音」
817
+ const audioCard = n => `<div class="card">
818
+ <div class="exp-h"><span class="num">${n}</span><b>${doc ? '朗读音频' : '英文音频'}</b><span class="faint">${doc ? '文档转好的音频,存到本地' : '换一种声音的版本,存到本地'}</span></div>
819
+ <div class="row">${doc ? docAudioButtons(l) : TRACK_ORDER.filter(x => x === 'full' || S.cur.tracks[x]).map(x => {
820
+ const m = x === 'full' ? { duration: l.duration } : S.cur.tracks[x]
821
+ return `<a class="btn" href="audio?id=${id}&track=${x}&download=1" download>${ico('down')}${esc(trackLabel(x))} · ${fmt(m.duration)}</a>`
822
+ }).join('')}</div>
823
+ ${doc ? '' : '<div class="faint" style="margin-top:4px">「去空白」「朗读」这些版本在「概览 → 声音」里生成。</div>'}
824
+ </div>`
825
+ const textCard = n => `<div class="card">
826
+ <div class="exp-h"><span class="num">${n}</span><b>带时间的字幕</b><span class="faint">每句英文、中文对译和它的起止时间</span></div>
827
+ <div class="form" style="margin-top:4px">
828
+ <label>时间按</label><div class="chips">${alignChips('txalign')}</div>
829
+ <label>下载</label><div class="row wrap">${TEXT_FORMATS.map(([f, name]) => `<a class="btn" data-fmt="${f}" href="export?id=${id}&format=${f}&align=${align}" download>${name}</a>`).join('')}</div>
830
+ </div>
831
+ <div class="faint" style="margin-top:4px">想自己录中文:下载「录音稿」,每句标了开始时间和可用时长。</div>
832
+ </div>`
833
+ const steps = doc ? ['文档', '朗读音频', '带时间的字幕', '中文配音'] : ['英文音频', '带时间的字幕', '中文配音']
834
+ return `<div id="zhCard">
835
+ <div class="pipe">${steps.map((s, k) => `${k ? '<span class="arr">→</span>' : ''}<b>${s}</b>`).join('')}<span>每一步的成品都能下载到本地</span></div>
836
+ ${doc ? audioCard(1) + textCard(2) : textCard(1)}
837
+ <div class="card">
838
+ <div class="exp-h"><span class="num">${doc ? 3 : 2}</span><b>中文配音</b><span class="faint">和英文同一时间的中文音频</span></div>
800
839
  <div class="faint">只有中文、没有背景音,每句中文从对应英文开始的时刻开始说;比空档长的句子会读快一点,不会挤到下一句。</div>
801
- ${meta ? `<div class="row" style="margin-top:8px"><span class="badge ok">已生成</span><span class="faint grow">${esc(meta.voiceLabel || meta.voice)} · ${meta.align === 'clean' ? '对齐原声·去空白' : '对齐原音频'} · ${fmt(meta.duration)}${meta.squeezed ? ` · ${meta.squeezed} 句读快了一点` : ''}</span></div>
840
+ ${meta ? `<div class="row nowrap" style="margin-top:6px"><span class="badge ok">已生成</span><span class="faint grow ellipsis">${esc(meta.voiceLabel || meta.voice)} · ${meta.align === 'clean' ? '对齐原声·去空白' : '对齐原音频'} · ${fmt(meta.duration)}${meta.squeezed ? ` · ${meta.squeezed} 句读快了一点` : ''}</span></div>
802
841
  <div class="row" style="margin-top:6px">
803
842
  <button class="btn" id="zhPlay">▶ 试听</button>
804
- <a class="btn primary" href="audio?id=${encodeURIComponent(l.id)}&track=zh&download=1" download>下载 MP3</a>
805
- ${meta.flac ? `<a class="btn primary" href="audio?id=${encodeURIComponent(l.id)}&track=zh&fmt=flac&download=1" download>下载 FLAC(无损)</a>` : '<span class="faint">要 FLAC:重新生成一次</span>'}
843
+ <a class="btn primary" href="audio?id=${id}&track=zh&download=1" download>${ico('down')}MP3</a>
844
+ ${meta.flac ? `<a class="btn primary" href="audio?id=${id}&track=zh&fmt=flac&download=1" download>${ico('down')}FLAC(无损)</a>` : '<span class="faint">要 FLAC:重新生成一次</span>'}
806
845
  </div>` : ''}
807
846
  ${job ? `<div class="small muted" style="margin-top:8px">${job.state === 'queued' ? '排队中' : esc(job.stage)}${job.total ? ` ${job.done}/${job.total}` : ''}…</div>` : canMake ? `
808
847
  <div class="form">
@@ -816,14 +855,10 @@ function zhCard(l) {
816
855
  <label>语速</label><div class="chips">${[0.9, 1, 1.1].map(x => `<button class="chip ${x === speed ? 'on' : ''}" data-zhspeed="${x}">${x === 1 ? '正常' : x < 1 ? '慢一点' : '快一点'}</button>`).join('')}</div>
817
856
  <label>时间对齐</label><div class="chips">${alignChips('zhalign')}</div>`}
818
857
  </div>
819
- ${edgeMissing ? '' : `<div class="faint" style="margin:6px 0 10px">对齐原音频:总长和原音频一样,可以和原音频对着听;对齐去空白:和「原声·去空白」一句对一句。${engine === 'kokoro' && !(V.zh && V.zh.ready) ? '第一次会先下载约 147 MB 中文语音模型。' : ''}${engine === 'edge' && V.edge && V.edge.version ? ` Edge 组件 ${esc(V.edge.version)},<a href="#" class="link" id="edgeUpdate">更新</a>。` : ''}</div>
858
+ ${edgeMissing ? '' : `<div class="faint" style="margin:6px 0 8px">对齐原音频:总长和原音频一样,可以和原音频对着听;对齐去空白:和「原声·去空白」一句对一句。${engine === 'kokoro' && !(V.zh && V.zh.ready) ? '第一次会先下载约 147 MB 中文语音模型。' : ''}${engine === 'edge' && V.edge && V.edge.version ? ` Edge 组件 ${esc(V.edge.version)},<a href="#" class="link" id="edgeUpdate">更新</a>。` : ''}</div>
820
859
  <button class="btn ${meta ? 'ghost' : 'primary'}" id="zhMake">${meta ? '换音色重新生成' : '生成中文配音'}</button>`}` : ''}
821
- <div class="sub-h" style="margin-top:16px">文字(字幕 / 歌词 / 录音稿)</div>
822
- <div class="faint">英文原文和中文对译,带时间。想自己录中文配音,就下载「录音稿」:每句标了开始时间和可用时长。</div>
823
- <div class="form">
824
- <label>时间按</label><div class="chips">${alignChips('txalign')}</div>
825
- <label>下载</label><div class="row wrap">${TEXT_FORMATS.map(([f, name]) => `<a class="btn ghost" data-fmt="${f}" href="export?id=${encodeURIComponent(l.id)}&format=${f}&align=${align}" download>${name}</a>`).join('')}</div>
826
860
  </div>
861
+ ${doc ? '' : audioCard(3)}
827
862
  </div>`
828
863
  }
829
864