audex 1.0.7a3__py3-none-any.whl

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.
Files changed (192) hide show
  1. audex/__init__.py +9 -0
  2. audex/__main__.py +7 -0
  3. audex/cli/__init__.py +189 -0
  4. audex/cli/apis/__init__.py +12 -0
  5. audex/cli/apis/init/__init__.py +34 -0
  6. audex/cli/apis/init/gencfg.py +130 -0
  7. audex/cli/apis/init/setup.py +330 -0
  8. audex/cli/apis/init/vprgroup.py +125 -0
  9. audex/cli/apis/serve.py +141 -0
  10. audex/cli/args.py +356 -0
  11. audex/cli/exceptions.py +44 -0
  12. audex/cli/helper/__init__.py +0 -0
  13. audex/cli/helper/ansi.py +193 -0
  14. audex/cli/helper/display.py +288 -0
  15. audex/config/__init__.py +64 -0
  16. audex/config/core/__init__.py +30 -0
  17. audex/config/core/app.py +29 -0
  18. audex/config/core/audio.py +45 -0
  19. audex/config/core/logging.py +163 -0
  20. audex/config/core/session.py +11 -0
  21. audex/config/helper/__init__.py +1 -0
  22. audex/config/helper/client/__init__.py +1 -0
  23. audex/config/helper/client/http.py +28 -0
  24. audex/config/helper/client/websocket.py +21 -0
  25. audex/config/helper/provider/__init__.py +1 -0
  26. audex/config/helper/provider/dashscope.py +13 -0
  27. audex/config/helper/provider/unisound.py +18 -0
  28. audex/config/helper/provider/xfyun.py +23 -0
  29. audex/config/infrastructure/__init__.py +31 -0
  30. audex/config/infrastructure/cache.py +51 -0
  31. audex/config/infrastructure/database.py +48 -0
  32. audex/config/infrastructure/recorder.py +32 -0
  33. audex/config/infrastructure/store.py +19 -0
  34. audex/config/provider/__init__.py +18 -0
  35. audex/config/provider/transcription.py +109 -0
  36. audex/config/provider/vpr.py +99 -0
  37. audex/container.py +40 -0
  38. audex/entity/__init__.py +468 -0
  39. audex/entity/doctor.py +109 -0
  40. audex/entity/doctor.pyi +51 -0
  41. audex/entity/fields.py +401 -0
  42. audex/entity/segment.py +115 -0
  43. audex/entity/segment.pyi +38 -0
  44. audex/entity/session.py +133 -0
  45. audex/entity/session.pyi +47 -0
  46. audex/entity/utterance.py +142 -0
  47. audex/entity/utterance.pyi +48 -0
  48. audex/entity/vp.py +68 -0
  49. audex/entity/vp.pyi +35 -0
  50. audex/exceptions.py +157 -0
  51. audex/filters/__init__.py +692 -0
  52. audex/filters/generated/__init__.py +21 -0
  53. audex/filters/generated/doctor.py +987 -0
  54. audex/filters/generated/segment.py +723 -0
  55. audex/filters/generated/session.py +978 -0
  56. audex/filters/generated/utterance.py +939 -0
  57. audex/filters/generated/vp.py +815 -0
  58. audex/helper/__init__.py +1 -0
  59. audex/helper/hash.py +33 -0
  60. audex/helper/mixin.py +65 -0
  61. audex/helper/net.py +19 -0
  62. audex/helper/settings/__init__.py +830 -0
  63. audex/helper/settings/fields.py +317 -0
  64. audex/helper/stream.py +153 -0
  65. audex/injectors/__init__.py +1 -0
  66. audex/injectors/config.py +12 -0
  67. audex/injectors/lifespan.py +7 -0
  68. audex/lib/__init__.py +1 -0
  69. audex/lib/cache/__init__.py +383 -0
  70. audex/lib/cache/inmemory.py +513 -0
  71. audex/lib/database/__init__.py +83 -0
  72. audex/lib/database/sqlite.py +406 -0
  73. audex/lib/exporter.py +189 -0
  74. audex/lib/injectors/__init__.py +1 -0
  75. audex/lib/injectors/cache.py +25 -0
  76. audex/lib/injectors/container.py +47 -0
  77. audex/lib/injectors/exporter.py +26 -0
  78. audex/lib/injectors/recorder.py +33 -0
  79. audex/lib/injectors/server.py +17 -0
  80. audex/lib/injectors/session.py +18 -0
  81. audex/lib/injectors/sqlite.py +24 -0
  82. audex/lib/injectors/store.py +13 -0
  83. audex/lib/injectors/transcription.py +42 -0
  84. audex/lib/injectors/usb.py +12 -0
  85. audex/lib/injectors/vpr.py +65 -0
  86. audex/lib/injectors/wifi.py +7 -0
  87. audex/lib/recorder.py +844 -0
  88. audex/lib/repos/__init__.py +149 -0
  89. audex/lib/repos/container.py +23 -0
  90. audex/lib/repos/database/__init__.py +1 -0
  91. audex/lib/repos/database/sqlite.py +672 -0
  92. audex/lib/repos/decorators.py +74 -0
  93. audex/lib/repos/doctor.py +286 -0
  94. audex/lib/repos/segment.py +302 -0
  95. audex/lib/repos/session.py +285 -0
  96. audex/lib/repos/tables/__init__.py +70 -0
  97. audex/lib/repos/tables/doctor.py +137 -0
  98. audex/lib/repos/tables/segment.py +113 -0
  99. audex/lib/repos/tables/session.py +140 -0
  100. audex/lib/repos/tables/utterance.py +131 -0
  101. audex/lib/repos/tables/vp.py +102 -0
  102. audex/lib/repos/utterance.py +288 -0
  103. audex/lib/repos/vp.py +286 -0
  104. audex/lib/restful.py +251 -0
  105. audex/lib/server/__init__.py +97 -0
  106. audex/lib/server/auth.py +98 -0
  107. audex/lib/server/handlers.py +248 -0
  108. audex/lib/server/templates/index.html.j2 +226 -0
  109. audex/lib/server/templates/login.html.j2 +111 -0
  110. audex/lib/server/templates/static/script.js +68 -0
  111. audex/lib/server/templates/static/style.css +579 -0
  112. audex/lib/server/types.py +123 -0
  113. audex/lib/session.py +503 -0
  114. audex/lib/store/__init__.py +238 -0
  115. audex/lib/store/localfile.py +411 -0
  116. audex/lib/transcription/__init__.py +33 -0
  117. audex/lib/transcription/dashscope.py +525 -0
  118. audex/lib/transcription/events.py +62 -0
  119. audex/lib/usb.py +554 -0
  120. audex/lib/vpr/__init__.py +38 -0
  121. audex/lib/vpr/unisound/__init__.py +185 -0
  122. audex/lib/vpr/unisound/types.py +469 -0
  123. audex/lib/vpr/xfyun/__init__.py +483 -0
  124. audex/lib/vpr/xfyun/types.py +679 -0
  125. audex/lib/websocket/__init__.py +8 -0
  126. audex/lib/websocket/connection.py +485 -0
  127. audex/lib/websocket/pool.py +991 -0
  128. audex/lib/wifi.py +1146 -0
  129. audex/lifespan.py +75 -0
  130. audex/service/__init__.py +27 -0
  131. audex/service/decorators.py +73 -0
  132. audex/service/doctor/__init__.py +652 -0
  133. audex/service/doctor/const.py +36 -0
  134. audex/service/doctor/exceptions.py +96 -0
  135. audex/service/doctor/types.py +54 -0
  136. audex/service/export/__init__.py +236 -0
  137. audex/service/export/const.py +17 -0
  138. audex/service/export/exceptions.py +34 -0
  139. audex/service/export/types.py +21 -0
  140. audex/service/injectors/__init__.py +1 -0
  141. audex/service/injectors/container.py +53 -0
  142. audex/service/injectors/doctor.py +34 -0
  143. audex/service/injectors/export.py +27 -0
  144. audex/service/injectors/session.py +49 -0
  145. audex/service/session/__init__.py +754 -0
  146. audex/service/session/const.py +34 -0
  147. audex/service/session/exceptions.py +67 -0
  148. audex/service/session/types.py +91 -0
  149. audex/types.py +39 -0
  150. audex/utils.py +287 -0
  151. audex/valueobj/__init__.py +81 -0
  152. audex/valueobj/common/__init__.py +1 -0
  153. audex/valueobj/common/auth.py +84 -0
  154. audex/valueobj/common/email.py +16 -0
  155. audex/valueobj/common/ops.py +22 -0
  156. audex/valueobj/common/phone.py +84 -0
  157. audex/valueobj/common/version.py +72 -0
  158. audex/valueobj/session.py +19 -0
  159. audex/valueobj/utterance.py +15 -0
  160. audex/view/__init__.py +51 -0
  161. audex/view/container.py +17 -0
  162. audex/view/decorators.py +303 -0
  163. audex/view/pages/__init__.py +1 -0
  164. audex/view/pages/dashboard/__init__.py +286 -0
  165. audex/view/pages/dashboard/wifi.py +407 -0
  166. audex/view/pages/login.py +110 -0
  167. audex/view/pages/recording.py +348 -0
  168. audex/view/pages/register.py +202 -0
  169. audex/view/pages/sessions/__init__.py +196 -0
  170. audex/view/pages/sessions/details.py +224 -0
  171. audex/view/pages/sessions/export.py +443 -0
  172. audex/view/pages/settings.py +374 -0
  173. audex/view/pages/voiceprint/__init__.py +1 -0
  174. audex/view/pages/voiceprint/enroll.py +195 -0
  175. audex/view/pages/voiceprint/update.py +195 -0
  176. audex/view/static/css/dashboard.css +452 -0
  177. audex/view/static/css/glass.css +22 -0
  178. audex/view/static/css/global.css +541 -0
  179. audex/view/static/css/login.css +386 -0
  180. audex/view/static/css/recording.css +439 -0
  181. audex/view/static/css/register.css +293 -0
  182. audex/view/static/css/sessions/styles.css +501 -0
  183. audex/view/static/css/settings.css +186 -0
  184. audex/view/static/css/voiceprint/enroll.css +43 -0
  185. audex/view/static/css/voiceprint/styles.css +209 -0
  186. audex/view/static/css/voiceprint/update.css +44 -0
  187. audex/view/static/images/logo.svg +95 -0
  188. audex/view/static/js/recording.js +42 -0
  189. audex-1.0.7a3.dist-info/METADATA +361 -0
  190. audex-1.0.7a3.dist-info/RECORD +192 -0
  191. audex-1.0.7a3.dist-info/WHEEL +4 -0
  192. audex-1.0.7a3.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,374 @@
1
+ from __future__ import annotations
2
+
3
+ from dependency_injector.wiring import Provide
4
+ from dependency_injector.wiring import inject
5
+ from fastapi import Depends
6
+ from nicegui import ui
7
+
8
+ from audex.container import Container
9
+ from audex.exceptions import ValidationError
10
+ from audex.service.doctor import DoctorService
11
+ from audex.service.doctor.types import UpdateCommand
12
+ from audex.valueobj.common.auth import Password
13
+ from audex.valueobj.common.email import Email
14
+ from audex.valueobj.common.phone import CNPhone
15
+ from audex.view.decorators import handle_errors
16
+
17
+
18
+ @ui.page("/settings")
19
+ @handle_errors
20
+ @inject
21
+ async def render(
22
+ doctor_service: DoctorService = Depends(Provide[Container.service.doctor]),
23
+ ) -> None:
24
+ """Render settings page."""
25
+
26
+ # Get current doctor
27
+ doctor = await doctor_service.current_doctor()
28
+
29
+ # Add CSS
30
+ ui.add_head_html('<link rel="stylesheet" href="/static/css/settings.css">')
31
+
32
+ # State
33
+ current_tab = {"value": "profile"}
34
+ is_editing = {"value": False}
35
+
36
+ # Header
37
+ with (
38
+ ui.header().classes("header-glass items-center justify-between px-6 py-3"),
39
+ ui.row().classes("items-center gap-3"),
40
+ ):
41
+ ui.button(icon="arrow_back", on_click=lambda: ui.navigate.to("/")).props(
42
+ "flat round"
43
+ ).tooltip("返回主面板")
44
+ ui.label("个人设置").classes("text-h6 font-semibold text-grey-9")
45
+
46
+ # Main container
47
+ with (
48
+ ui.element("div")
49
+ .classes("w-full bg-white")
50
+ .style("display: flex; padding: 40px 80px; gap: 40px;")
51
+ ):
52
+ # Left sidebar: Tabs
53
+ with ui.column().classes("gap-2").style("width: 200px; flex-shrink: 0;"):
54
+
55
+ def switch_tab(tab: str):
56
+ """Switch tab."""
57
+ current_tab["value"] = tab
58
+ is_editing["value"] = False
59
+
60
+ # Update tab styles using classes()
61
+ if tab == "profile":
62
+ profile_tab.classes(remove="tab-button", add="tab-button-active")
63
+ password_tab.classes(remove="tab-button-active", add="tab-button")
64
+ else:
65
+ profile_tab.classes(remove="tab-button-active", add="tab-button")
66
+ password_tab.classes(remove="tab-button", add="tab-button-active")
67
+
68
+ # Show/hide content
69
+ profile_content.visible = tab == "profile"
70
+ password_content.visible = tab == "password"
71
+
72
+ profile_tab = (
73
+ ui.label("个人资料")
74
+ .classes("tab-button-active")
75
+ .on("click", lambda: switch_tab("profile"))
76
+ )
77
+ password_tab = (
78
+ ui.label("修改密码")
79
+ .classes("tab-button")
80
+ .on("click", lambda: switch_tab("password"))
81
+ )
82
+
83
+ # Right content area (scrollable)
84
+ content_scroll = ui.scroll_area().classes("flex-1").style("height: calc(100vh - 190px);")
85
+ with content_scroll:
86
+ # Profile content
87
+ profile_content = ui.column().classes("w-full")
88
+ with profile_content:
89
+ # Header with edit button
90
+ with ui.row().classes("w-full items-center justify-between mb-6"):
91
+ ui.label("个人资料").classes("text-h4 font-bold text-grey-9")
92
+
93
+ def toggle_edit():
94
+ """Toggle edit mode."""
95
+ is_editing["value"] = not is_editing["value"]
96
+
97
+ if is_editing["value"]:
98
+ edit_btn.props("icon=close")
99
+ edit_btn.text = "取消"
100
+ # Show inputs
101
+ info_display.visible = False
102
+ edit_form.visible = True
103
+ else:
104
+ edit_btn.props("icon=edit")
105
+ edit_btn.text = "编辑"
106
+ # Show info display
107
+ info_display.visible = True
108
+ edit_form.visible = False
109
+
110
+ edit_btn = (
111
+ ui.button("编辑", icon="edit", on_click=toggle_edit)
112
+ .props("flat no-caps")
113
+ .classes("action-button")
114
+ )
115
+
116
+ # Info display (read-only)
117
+ info_display = ui.column().classes("w-full gap-0")
118
+ with info_display, ui.column().classes("w-full"):
119
+ with ui.element("div").classes("info-field"):
120
+ ui.label("姓名").classes("text-xs text-grey-6 mb-1")
121
+ ui.label(doctor.name).classes("text-body1 text-grey-9 font-medium")
122
+
123
+ with ui.element("div").classes("info-field"):
124
+ ui.label("工号").classes("text-xs text-grey-6 mb-1")
125
+ ui.label(doctor.eid).classes("text-body1 text-grey-9 font-medium")
126
+
127
+ with ui.row().classes("w-full gap-8"):
128
+ with ui.column().classes("flex-1"), ui.element("div").classes("info-field"):
129
+ ui.label("科室").classes("text-xs text-grey-6 mb-1")
130
+ ui.label(doctor.department or "未填写").classes(
131
+ "text-body1 text-grey-9 font-medium"
132
+ )
133
+
134
+ with ui.column().classes("flex-1"), ui.element("div").classes("info-field"):
135
+ ui.label("职称").classes("text-xs text-grey-6 mb-1")
136
+ ui.label(doctor.title or "未填写").classes(
137
+ "text-body1 text-grey-9 font-medium"
138
+ )
139
+
140
+ with ui.element("div").classes("info-field"):
141
+ ui.label("医院").classes("text-xs text-grey-6 mb-1")
142
+ ui.label(doctor.hospital or "未填写").classes(
143
+ "text-body1 text-grey-9 font-medium"
144
+ )
145
+
146
+ with ui.row().classes("w-full gap-8"):
147
+ with ui.column().classes("flex-1"), ui.element("div").classes("info-field"):
148
+ ui.label("手机号").classes("text-xs text-grey-6 mb-1")
149
+ ui.label(str(doctor.phone) if doctor.phone else "未填写").classes(
150
+ "text-body1 text-grey-9 font-medium"
151
+ )
152
+
153
+ with ui.column().classes("flex-1"), ui.element("div").classes("info-field"):
154
+ ui.label("邮箱").classes("text-xs text-grey-6 mb-1")
155
+ ui.label(str(doctor.email) if doctor.email else "未填写").classes(
156
+ "text-body1 text-grey-9 font-medium"
157
+ )
158
+
159
+ # Edit form (hidden initially)
160
+ edit_form = ui.column().classes("w-full gap-4")
161
+ edit_form.visible = False
162
+ with edit_form:
163
+ name_input = (
164
+ ui.input("", placeholder="姓名")
165
+ .classes("w-full clean-input")
166
+ .props("standout dense hide-bottom-space")
167
+ )
168
+ name_input.value = doctor.name
169
+
170
+ # Two columns
171
+ with ui.row().classes("w-full gap-4"):
172
+ department_input = (
173
+ ui.input("", placeholder="科室")
174
+ .classes("flex-1 clean-input")
175
+ .props("standout dense hide-bottom-space")
176
+ )
177
+ department_input.value = doctor.department or ""
178
+
179
+ title_input = (
180
+ ui.input("", placeholder="职称")
181
+ .classes("flex-1 clean-input")
182
+ .props("standout dense hide-bottom-space")
183
+ )
184
+ title_input.value = doctor.title or ""
185
+
186
+ hospital_input = (
187
+ ui.input("", placeholder="医院")
188
+ .classes("w-full clean-input")
189
+ .props("standout dense hide-bottom-space")
190
+ )
191
+ hospital_input.value = doctor.hospital or ""
192
+
193
+ with ui.row().classes("w-full gap-4"):
194
+ phone_input = (
195
+ ui.input("", placeholder="手机号")
196
+ .classes("flex-1 clean-input")
197
+ .props("standout dense hide-bottom-space")
198
+ )
199
+ phone_input.value = str(doctor.phone) if doctor.phone else ""
200
+
201
+ email_input = (
202
+ ui.input("", placeholder="邮箱")
203
+ .classes("flex-1 clean-input")
204
+ .props("standout dense hide-bottom-space")
205
+ )
206
+ email_input.value = str(doctor.email) if doctor.email else ""
207
+
208
+ @handle_errors
209
+ async def save_profile():
210
+ """Save profile changes."""
211
+ phone = None
212
+ if phone_input.value.strip():
213
+ try:
214
+ phone_str = phone_input.value.strip()
215
+ if not phone_str.startswith("+86 "):
216
+ phone_str = "+86 " + phone_str
217
+ phone = CNPhone.parse(phone_str)
218
+ except ValidationError:
219
+ ui.notify("手机号格式不正确", type="warning", position="top")
220
+ return
221
+
222
+ email = None
223
+ if email_input.value.strip():
224
+ try:
225
+ email = Email.parse(email_input.value.strip())
226
+ except ValidationError:
227
+ ui.notify("邮箱格式不正确", type="warning", position="top")
228
+ return
229
+
230
+ await doctor_service.update(
231
+ UpdateCommand(
232
+ name=name_input.value.strip() or None,
233
+ department=department_input.value.strip() or None,
234
+ title=title_input.value.strip() or None,
235
+ hospital=hospital_input.value.strip() or None,
236
+ phone=phone,
237
+ email=email,
238
+ )
239
+ )
240
+
241
+ ui.notify("个人信息已更新", type="positive", position="top")
242
+
243
+ # Refresh page
244
+ ui.navigate.to("/settings")
245
+
246
+ ui.button("保存更改", on_click=save_profile).props(
247
+ "unelevated color=primary size=lg no-caps"
248
+ ).classes("action-button").style("height: 48px;")
249
+
250
+ # Delete account section
251
+ ui.separator().classes("my-8")
252
+
253
+ ui.label("危险操作").classes("text-h6 font-semibold text-negative mb-2")
254
+ ui.label("删除账号后,所有数据将被永久删除且无法恢复").classes(
255
+ "text-sm text-grey-7 mb-4"
256
+ )
257
+
258
+ @handle_errors
259
+ async def confirm_delete():
260
+ """Show delete confirmation dialog."""
261
+ with ui.dialog() as dialog, ui.card().style("width: 450px; padding: 32px;"):
262
+ ui.label("确认删除账号").classes("text-h5 font-semibold mb-3 text-grey-9")
263
+ ui.label("此操作不可撤销。请输入您的工号以确认删除。").classes(
264
+ "text-body2 text-grey-7 mb-4"
265
+ )
266
+
267
+ eid_confirm = (
268
+ ui.input("", placeholder=f"请输入工号: {doctor.eid}")
269
+ .classes("w-full mb-4 clean-input")
270
+ .props("outlined dense")
271
+ )
272
+
273
+ @handle_errors
274
+ async def do_delete():
275
+ """Delete account."""
276
+ if eid_confirm.value.strip() != doctor.eid:
277
+ ui.notify("工号不正确", type="warning", position="top")
278
+ return
279
+
280
+ await doctor_service.delete_account()
281
+ dialog.close()
282
+ ui.notify("账号已删除", type="info", position="top")
283
+ ui.navigate.to("/login")
284
+
285
+ with ui.row().classes("w-full justify-end gap-2 mt-4"):
286
+ ui.button("取消", on_click=dialog.close).props("flat no-caps").classes(
287
+ "action-button"
288
+ )
289
+ ui.button("确认删除", on_click=do_delete).props(
290
+ "unelevated color=negative no-caps"
291
+ ).classes("action-button")
292
+
293
+ dialog.open()
294
+
295
+ ui.button("删除账号", on_click=confirm_delete).props(
296
+ "outline color=negative size=lg no-caps"
297
+ ).classes("action-button").style("height: 48px;")
298
+
299
+ # Password content (hidden initially)
300
+ password_content = ui.column().classes("w-full")
301
+ password_content.visible = False
302
+ with password_content:
303
+ with ui.row().classes("w-full items-center justify-between mb-6"):
304
+ ui.label("修改密码").classes("text-h4 font-bold text-grey-9")
305
+
306
+ ui.label("请输入当前密码和新密码").classes("text-body2 text-grey-7 mb-6")
307
+
308
+ with (
309
+ ui.column()
310
+ .classes("gap-4")
311
+ .style("max-width: 100%; width: 25vw; min-width: 300px;")
312
+ ):
313
+ old_password_input = (
314
+ ui.input(
315
+ "",
316
+ placeholder="当前密码",
317
+ password=True,
318
+ password_toggle_button=True,
319
+ )
320
+ .classes("w-full clean-input")
321
+ .props("standout dense hide-bottom-space")
322
+ )
323
+
324
+ new_password_input = (
325
+ ui.input(
326
+ "",
327
+ placeholder="新密码",
328
+ password=True,
329
+ password_toggle_button=True,
330
+ )
331
+ .classes("w-full clean-input")
332
+ .props("standout dense hide-bottom-space")
333
+ )
334
+
335
+ confirm_password_input = (
336
+ ui.input(
337
+ "",
338
+ placeholder="确认新密码",
339
+ password=True,
340
+ password_toggle_button=True,
341
+ )
342
+ .classes("w-full clean-input")
343
+ .props("standout dense hide-bottom-space")
344
+ )
345
+
346
+ @handle_errors
347
+ async def change_password():
348
+ """Change password."""
349
+ if not old_password_input.value:
350
+ ui.notify("请输入当前密码", type="warning", position="top")
351
+ return
352
+
353
+ if not new_password_input.value:
354
+ ui.notify("请输入新密码", type="warning", position="top")
355
+ return
356
+
357
+ if new_password_input.value != confirm_password_input.value:
358
+ ui.notify("两次密码不一致", type="warning", position="top")
359
+ return
360
+
361
+ await doctor_service.change_password(
362
+ old_password=Password.parse(old_password_input.value),
363
+ new_password=Password.parse(new_password_input.value),
364
+ )
365
+
366
+ ui.notify("密码修改成功", type="positive", position="top")
367
+
368
+ old_password_input.value = ""
369
+ new_password_input.value = ""
370
+ confirm_password_input.value = ""
371
+
372
+ ui.button("修改密码", on_click=change_password).props(
373
+ "unelevated color=primary size=lg no-caps"
374
+ ).classes("action-button").style("height: 48px;")
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1,195 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import typing as t
5
+
6
+ from dependency_injector.wiring import Provide
7
+ from dependency_injector.wiring import inject
8
+ from fastapi import Depends
9
+ from nicegui import ui
10
+
11
+ from audex.container import Container
12
+ from audex.service.doctor import DoctorService
13
+ from audex.view.decorators import handle_errors
14
+
15
+
16
+ @ui.page("/voiceprint/enroll")
17
+ @handle_errors
18
+ @inject
19
+ async def render(
20
+ doctor_service: DoctorService = Depends(Provide[Container.service.doctor]),
21
+ ) -> None:
22
+ """Render voiceprint enrollment page."""
23
+
24
+ # Check if already has voiceprint
25
+ has_vp = await doctor_service.has_voiceprint()
26
+ if has_vp:
27
+ ui.notify("您已注册声纹,如需更新请使用声纹管理功能", type="info", position="top")
28
+ ui.navigate.to("/voiceprint/update")
29
+ return
30
+
31
+ # Add CSS
32
+ ui.add_head_html('<link rel="stylesheet" href="/static/css/voiceprint/enroll.css">')
33
+
34
+ # State
35
+ is_recording = {"value": False}
36
+ enrollment_context: dict[str, t.Any] = {"value": None}
37
+ elapsed_time = {"value": 0}
38
+ timer_task: dict[str, asyncio.Task[t.Any] | None] = {"value": None}
39
+
40
+ # Header
41
+ with (
42
+ ui.header().classes("header-glass items-center justify-between px-6 py-3"),
43
+ ui.row().classes("items-center gap-3"),
44
+ ):
45
+ ui.button(icon="arrow_back", on_click=lambda: ui.navigate.to("/")).props(
46
+ "flat round"
47
+ ).tooltip("返回主面板")
48
+ ui.label("声纹注册").classes("text-h6 font-semibold text-grey-9")
49
+
50
+ # Main container
51
+ with (
52
+ ui.element("div").classes("voiceprint-container"),
53
+ ui.element("div").classes("voiceprint-content"),
54
+ ):
55
+ # Left side: Steps
56
+ with ui.column().classes("voiceprint-steps"):
57
+ ui.label("操作流程").classes("text-h5 font-bold text-grey-9 mb-2")
58
+
59
+ with ui.column().classes("gap-4"):
60
+ with ui.row().classes("items-start gap-3"):
61
+ ui.label("1").classes(
62
+ "text-sm font-bold text-white w-6 h-6 flex items-center justify-center"
63
+ ).style("background: #f59e0b; border-radius: 50%;")
64
+ with ui.column().classes("gap-1"):
65
+ ui.label("点击按钮开始").classes("text-sm font-medium text-grey-9")
66
+ ui.label("启动录音功能").classes("text-xs text-grey-6")
67
+
68
+ with ui.row().classes("items-start gap-3"):
69
+ ui.label("2").classes(
70
+ "text-sm font-bold text-white w-6 h-6 flex items-center justify-center"
71
+ ).style("background: #f59e0b; border-radius: 50%;")
72
+ with ui.column().classes("gap-1"):
73
+ ui.label("朗读右侧文字").classes("text-sm font-medium text-grey-9")
74
+ ui.label("清晰完整朗读").classes("text-xs text-grey-6")
75
+
76
+ with ui.row().classes("items-start gap-3"):
77
+ ui.label("3").classes(
78
+ "text-sm font-bold text-white w-6 h-6 flex items-center justify-center"
79
+ ).style("background: #f59e0b; border-radius: 50%;")
80
+ with ui.column().classes("gap-1"):
81
+ ui.label("点击停止完成").classes("text-sm font-medium text-grey-9")
82
+ ui.label("时长 5-20 秒").classes("text-xs text-grey-6")
83
+
84
+ # Center: Text to read
85
+ with ui.column().classes("voiceprint-text"):
86
+ ui.label("请朗读:").classes("text-body1 text-grey-6")
87
+ ui.label(doctor_service.config.vpr_text_content).classes(
88
+ "text-h4 text-grey-9 font-semibold leading-relaxed"
89
+ ).style(
90
+ "line-height: 1. 8; "
91
+ "word-break: keep-all; "
92
+ "overflow-wrap: break-word; "
93
+ "white-space: normal;"
94
+ )
95
+
96
+ # Right side: Recording button
97
+ with ui.column().classes("voiceprint-button"):
98
+ # Timer
99
+ timer_label = ui.label("00:00").classes("timer")
100
+
101
+ # Button container
102
+ button_container = ui.element("div").style(
103
+ "position: relative; display: flex; align-items: center; justify-content: center;"
104
+ )
105
+
106
+ with button_container:
107
+ # Rings
108
+ ring1 = ui.element("div").classes("recording-ring")
109
+ ring1.visible = False
110
+ ring2 = ui.element("div").classes("recording-ring").style("animation-delay: 0.8s;")
111
+ ring2.visible = False
112
+ ring3 = ui.element("div").classes("recording-ring").style("animation-delay: 1.6s;")
113
+ ring3.visible = False
114
+
115
+ @handle_errors
116
+ async def toggle_recording():
117
+ """Toggle recording state."""
118
+ if not is_recording["value"]:
119
+ # Start
120
+ ctx = await doctor_service.enroll_vp()
121
+ enrollment_context["value"] = ctx
122
+ await ctx.start()
123
+
124
+ is_recording["value"] = True
125
+ elapsed_time["value"] = 0
126
+
127
+ record_btn.props("icon=stop color=negative")
128
+
129
+ ring1.visible = True
130
+ ring2.visible = True
131
+ ring3.visible = True
132
+
133
+ ui.notify("开始录音", type="info")
134
+ timer_task["value"] = asyncio.create_task(update_timer())
135
+
136
+ else:
137
+ # Stop
138
+ if elapsed_time["value"] < 5:
139
+ ui.notify("录音时间不足 5 秒,请继续", type="warning")
140
+ return
141
+
142
+ if timer_task["value"]:
143
+ timer_task["value"].cancel()
144
+
145
+ record_btn.props("loading icon=mic color=grey")
146
+
147
+ ring1.visible = False
148
+ ring2.visible = False
149
+ ring3.visible = False
150
+
151
+ try:
152
+ ctx = enrollment_context["value"]
153
+ result = await ctx.close()
154
+
155
+ is_recording["value"] = False
156
+
157
+ ui.notify(
158
+ f"声纹注册成功!录音时长: {result.duration_ms / 1000:.1f}秒",
159
+ type="positive",
160
+ )
161
+
162
+ await asyncio.sleep(2)
163
+ ui.navigate.to("/")
164
+
165
+ except Exception:
166
+ record_btn.props(remove="loading")
167
+ record_btn.props("icon=mic color=warning")
168
+ is_recording["value"] = False
169
+ raise
170
+
171
+ async def update_timer():
172
+ try:
173
+ while is_recording["value"]:
174
+ await asyncio.sleep(1)
175
+ elapsed_time["value"] += 1
176
+
177
+ minutes = elapsed_time["value"] // 60
178
+ seconds = elapsed_time["value"] % 60
179
+ timer_label.text = f"{minutes:02d}:{seconds:02d}"
180
+
181
+ if elapsed_time["value"] >= 20:
182
+ await toggle_recording()
183
+ break
184
+ except asyncio.CancelledError:
185
+ pass
186
+
187
+ record_btn = (
188
+ ui.button(icon="mic", on_click=toggle_recording)
189
+ .props("round unelevated color=warning size=xl")
190
+ .classes("record-button")
191
+ .style("font-size: 3em ! important;")
192
+ )
193
+
194
+ # Hint
195
+ ui.label("点击按钮开始录音").classes("text-sm text-grey-6")