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 +4 -1
- package/package.json +2 -2
- package/web/panel.css +263 -264
- package/web/panel.js +182 -147
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.
|
|
4
|
-
"description": "听懂:把英文有声读物(MP3
|
|
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: #
|
|
4
|
+
--bg: #f6f6f4;
|
|
3
5
|
--card: #ffffff;
|
|
4
|
-
--
|
|
5
|
-
--
|
|
6
|
-
--
|
|
7
|
-
--
|
|
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, .
|
|
12
|
+
--accent-soft: rgba(47, 107, 255, .09);
|
|
10
13
|
--good: #1f9d55;
|
|
11
|
-
--mid: #
|
|
14
|
+
--mid: #b97d0c;
|
|
12
15
|
--bad: #d14343;
|
|
13
|
-
--mark: rgba(255, 196, 0, .
|
|
14
|
-
--radius:
|
|
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: #
|
|
20
|
-
--
|
|
21
|
-
--
|
|
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: #
|
|
36
|
-
--line: rgba(255, 255, 255, .
|
|
37
|
-
--good: #3cc47c; --mid: #e0a73a; --bad: #ef6b6b; --mark: rgba(255, 196, 0, .
|
|
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
|
-
|
|
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:
|
|
54
|
-
|
|
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:
|
|
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
|
-
.
|
|
61
|
-
|
|
62
|
-
|
|
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:
|
|
60
|
+
main { padding: 10px 12px 32px; max-width: 720px; margin: 0 auto; }
|
|
66
61
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
90
|
+
.btn kbd { font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; opacity: .5; }
|
|
91
91
|
|
|
92
|
-
input[type=text] {
|
|
93
|
-
|
|
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]
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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:
|
|
102
|
-
.bar > i { display: block; height: 100%; background: var(--accent); border-radius:
|
|
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:
|
|
106
|
-
.badge.warn { background: rgba(201, 138, 18, .
|
|
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
|
-
.
|
|
111
|
-
.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
.
|
|
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:
|
|
188
|
+
padding: 5px 9px 6px; color: var(--sub); font-size: 12.5px; border-bottom: 2px solid transparent; margin-bottom: -1px;
|
|
120
189
|
}
|
|
121
|
-
.tab
|
|
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:
|
|
125
|
-
.pos { font-size:
|
|
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:
|
|
128
|
-
background: var(--accent); color: #fff; font-size:
|
|
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
|
|
132
|
-
.
|
|
133
|
-
.
|
|
134
|
-
.en-line
|
|
135
|
-
.
|
|
136
|
-
.
|
|
137
|
-
.
|
|
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:
|
|
140
|
-
.word { display: flex; gap: 8px; align-items: baseline; padding:
|
|
141
|
-
.
|
|
142
|
-
.word .ipa { color: var(--faint); font-size:
|
|
143
|
-
.
|
|
144
|
-
.grades {
|
|
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:
|
|
150
|
-
.dots { display: flex; flex-wrap: wrap; gap:
|
|
151
|
-
.dots i { width:
|
|
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:
|
|
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
|
-
.
|
|
159
|
-
.
|
|
160
|
-
.
|
|
161
|
-
.
|
|
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:
|
|
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:
|
|
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:
|
|
244
|
-
padding: 10px
|
|
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
|
-
|
|
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
|
-
.
|
|
261
|
-
.
|
|
262
|
-
.
|
|
263
|
-
.
|
|
264
|
-
.
|
|
265
|
-
.
|
|
266
|
-
.
|
|
267
|
-
.
|
|
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
|
-
|
|
276
|
-
|
|
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 (
|
|
280
|
-
|
|
281
|
-
.btn { padding:
|
|
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:
|
|
284
|
-
.
|
|
285
|
-
.
|
|
286
|
-
.
|
|
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:
|
|
329
|
+
padding: 8px 0 calc(8px + env(safe-area-inset-bottom)); margin-bottom: -4px;
|
|
290
330
|
}
|
|
291
|
-
.study .actions .btn.big { width: 100%; }
|
|
292
|
-
|
|
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
|
-
.
|
|
303
|
-
.
|
|
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
|
-
|
|
321
|
-
.
|
|
322
|
-
.
|
|
323
|
-
.
|
|
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
|
|
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
|
|
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)}"
|
|
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"
|
|
152
|
-
<div class="
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
164
|
-
<div class="section-title"
|
|
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="
|
|
168
|
-
${ls.map(
|
|
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('.
|
|
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
|
-
<
|
|
200
|
-
|
|
201
|
-
|
|
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:
|
|
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"
|
|
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:
|
|
249
|
-
<details style="margin-top:
|
|
250
|
-
<textarea id="docText" rows="
|
|
251
|
-
<button class="btn" id="docPaste" style="margin-top:
|
|
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:
|
|
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
|
-
<
|
|
307
|
-
|
|
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
|
-
?
|
|
314
|
-
: '
|
|
315
|
-
: '
|
|
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:
|
|
336
|
-
<button class="btn primary big" id="docGo" ${blocked ? 'disabled' : ''}
|
|
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="
|
|
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="
|
|
400
|
-
<div class="
|
|
401
|
-
|
|
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
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
<
|
|
429
|
-
<div class="
|
|
430
|
-
|
|
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="
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
<button class="btn
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 ? '
|
|
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
|
|
590
|
-
|
|
591
|
-
${
|
|
592
|
-
${
|
|
593
|
-
<button class="btn ghost" data-act="
|
|
594
|
-
<button class="btn ghost
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
${l.
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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:
|
|
640
|
-
<button class="btn primary big" id="goStudy">${pos > 0 ?
|
|
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
|
-
${
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
<button class="
|
|
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
|
-
|
|
653
|
-
|
|
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
|
-
</
|
|
663
|
-
|
|
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
|
|
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 `<
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
<
|
|
723
|
-
${
|
|
724
|
-
|
|
725
|
-
|
|
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}"
|
|
728
|
-
${meta ? `<a class="btn ghost" href="audio?id=${encodeURIComponent(l.id)}&track=${name}&download=1" download
|
|
729
|
-
${
|
|
730
|
-
${
|
|
731
|
-
|
|
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"
|
|
735
|
-
<div class="
|
|
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:
|
|
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
|
-
|
|
799
|
-
|
|
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:
|
|
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=${
|
|
805
|
-
${meta.flac ? `<a class="btn primary" href="audio?id=${
|
|
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
|
|
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
|
|