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.
- audex/__init__.py +9 -0
- audex/__main__.py +7 -0
- audex/cli/__init__.py +189 -0
- audex/cli/apis/__init__.py +12 -0
- audex/cli/apis/init/__init__.py +34 -0
- audex/cli/apis/init/gencfg.py +130 -0
- audex/cli/apis/init/setup.py +330 -0
- audex/cli/apis/init/vprgroup.py +125 -0
- audex/cli/apis/serve.py +141 -0
- audex/cli/args.py +356 -0
- audex/cli/exceptions.py +44 -0
- audex/cli/helper/__init__.py +0 -0
- audex/cli/helper/ansi.py +193 -0
- audex/cli/helper/display.py +288 -0
- audex/config/__init__.py +64 -0
- audex/config/core/__init__.py +30 -0
- audex/config/core/app.py +29 -0
- audex/config/core/audio.py +45 -0
- audex/config/core/logging.py +163 -0
- audex/config/core/session.py +11 -0
- audex/config/helper/__init__.py +1 -0
- audex/config/helper/client/__init__.py +1 -0
- audex/config/helper/client/http.py +28 -0
- audex/config/helper/client/websocket.py +21 -0
- audex/config/helper/provider/__init__.py +1 -0
- audex/config/helper/provider/dashscope.py +13 -0
- audex/config/helper/provider/unisound.py +18 -0
- audex/config/helper/provider/xfyun.py +23 -0
- audex/config/infrastructure/__init__.py +31 -0
- audex/config/infrastructure/cache.py +51 -0
- audex/config/infrastructure/database.py +48 -0
- audex/config/infrastructure/recorder.py +32 -0
- audex/config/infrastructure/store.py +19 -0
- audex/config/provider/__init__.py +18 -0
- audex/config/provider/transcription.py +109 -0
- audex/config/provider/vpr.py +99 -0
- audex/container.py +40 -0
- audex/entity/__init__.py +468 -0
- audex/entity/doctor.py +109 -0
- audex/entity/doctor.pyi +51 -0
- audex/entity/fields.py +401 -0
- audex/entity/segment.py +115 -0
- audex/entity/segment.pyi +38 -0
- audex/entity/session.py +133 -0
- audex/entity/session.pyi +47 -0
- audex/entity/utterance.py +142 -0
- audex/entity/utterance.pyi +48 -0
- audex/entity/vp.py +68 -0
- audex/entity/vp.pyi +35 -0
- audex/exceptions.py +157 -0
- audex/filters/__init__.py +692 -0
- audex/filters/generated/__init__.py +21 -0
- audex/filters/generated/doctor.py +987 -0
- audex/filters/generated/segment.py +723 -0
- audex/filters/generated/session.py +978 -0
- audex/filters/generated/utterance.py +939 -0
- audex/filters/generated/vp.py +815 -0
- audex/helper/__init__.py +1 -0
- audex/helper/hash.py +33 -0
- audex/helper/mixin.py +65 -0
- audex/helper/net.py +19 -0
- audex/helper/settings/__init__.py +830 -0
- audex/helper/settings/fields.py +317 -0
- audex/helper/stream.py +153 -0
- audex/injectors/__init__.py +1 -0
- audex/injectors/config.py +12 -0
- audex/injectors/lifespan.py +7 -0
- audex/lib/__init__.py +1 -0
- audex/lib/cache/__init__.py +383 -0
- audex/lib/cache/inmemory.py +513 -0
- audex/lib/database/__init__.py +83 -0
- audex/lib/database/sqlite.py +406 -0
- audex/lib/exporter.py +189 -0
- audex/lib/injectors/__init__.py +1 -0
- audex/lib/injectors/cache.py +25 -0
- audex/lib/injectors/container.py +47 -0
- audex/lib/injectors/exporter.py +26 -0
- audex/lib/injectors/recorder.py +33 -0
- audex/lib/injectors/server.py +17 -0
- audex/lib/injectors/session.py +18 -0
- audex/lib/injectors/sqlite.py +24 -0
- audex/lib/injectors/store.py +13 -0
- audex/lib/injectors/transcription.py +42 -0
- audex/lib/injectors/usb.py +12 -0
- audex/lib/injectors/vpr.py +65 -0
- audex/lib/injectors/wifi.py +7 -0
- audex/lib/recorder.py +844 -0
- audex/lib/repos/__init__.py +149 -0
- audex/lib/repos/container.py +23 -0
- audex/lib/repos/database/__init__.py +1 -0
- audex/lib/repos/database/sqlite.py +672 -0
- audex/lib/repos/decorators.py +74 -0
- audex/lib/repos/doctor.py +286 -0
- audex/lib/repos/segment.py +302 -0
- audex/lib/repos/session.py +285 -0
- audex/lib/repos/tables/__init__.py +70 -0
- audex/lib/repos/tables/doctor.py +137 -0
- audex/lib/repos/tables/segment.py +113 -0
- audex/lib/repos/tables/session.py +140 -0
- audex/lib/repos/tables/utterance.py +131 -0
- audex/lib/repos/tables/vp.py +102 -0
- audex/lib/repos/utterance.py +288 -0
- audex/lib/repos/vp.py +286 -0
- audex/lib/restful.py +251 -0
- audex/lib/server/__init__.py +97 -0
- audex/lib/server/auth.py +98 -0
- audex/lib/server/handlers.py +248 -0
- audex/lib/server/templates/index.html.j2 +226 -0
- audex/lib/server/templates/login.html.j2 +111 -0
- audex/lib/server/templates/static/script.js +68 -0
- audex/lib/server/templates/static/style.css +579 -0
- audex/lib/server/types.py +123 -0
- audex/lib/session.py +503 -0
- audex/lib/store/__init__.py +238 -0
- audex/lib/store/localfile.py +411 -0
- audex/lib/transcription/__init__.py +33 -0
- audex/lib/transcription/dashscope.py +525 -0
- audex/lib/transcription/events.py +62 -0
- audex/lib/usb.py +554 -0
- audex/lib/vpr/__init__.py +38 -0
- audex/lib/vpr/unisound/__init__.py +185 -0
- audex/lib/vpr/unisound/types.py +469 -0
- audex/lib/vpr/xfyun/__init__.py +483 -0
- audex/lib/vpr/xfyun/types.py +679 -0
- audex/lib/websocket/__init__.py +8 -0
- audex/lib/websocket/connection.py +485 -0
- audex/lib/websocket/pool.py +991 -0
- audex/lib/wifi.py +1146 -0
- audex/lifespan.py +75 -0
- audex/service/__init__.py +27 -0
- audex/service/decorators.py +73 -0
- audex/service/doctor/__init__.py +652 -0
- audex/service/doctor/const.py +36 -0
- audex/service/doctor/exceptions.py +96 -0
- audex/service/doctor/types.py +54 -0
- audex/service/export/__init__.py +236 -0
- audex/service/export/const.py +17 -0
- audex/service/export/exceptions.py +34 -0
- audex/service/export/types.py +21 -0
- audex/service/injectors/__init__.py +1 -0
- audex/service/injectors/container.py +53 -0
- audex/service/injectors/doctor.py +34 -0
- audex/service/injectors/export.py +27 -0
- audex/service/injectors/session.py +49 -0
- audex/service/session/__init__.py +754 -0
- audex/service/session/const.py +34 -0
- audex/service/session/exceptions.py +67 -0
- audex/service/session/types.py +91 -0
- audex/types.py +39 -0
- audex/utils.py +287 -0
- audex/valueobj/__init__.py +81 -0
- audex/valueobj/common/__init__.py +1 -0
- audex/valueobj/common/auth.py +84 -0
- audex/valueobj/common/email.py +16 -0
- audex/valueobj/common/ops.py +22 -0
- audex/valueobj/common/phone.py +84 -0
- audex/valueobj/common/version.py +72 -0
- audex/valueobj/session.py +19 -0
- audex/valueobj/utterance.py +15 -0
- audex/view/__init__.py +51 -0
- audex/view/container.py +17 -0
- audex/view/decorators.py +303 -0
- audex/view/pages/__init__.py +1 -0
- audex/view/pages/dashboard/__init__.py +286 -0
- audex/view/pages/dashboard/wifi.py +407 -0
- audex/view/pages/login.py +110 -0
- audex/view/pages/recording.py +348 -0
- audex/view/pages/register.py +202 -0
- audex/view/pages/sessions/__init__.py +196 -0
- audex/view/pages/sessions/details.py +224 -0
- audex/view/pages/sessions/export.py +443 -0
- audex/view/pages/settings.py +374 -0
- audex/view/pages/voiceprint/__init__.py +1 -0
- audex/view/pages/voiceprint/enroll.py +195 -0
- audex/view/pages/voiceprint/update.py +195 -0
- audex/view/static/css/dashboard.css +452 -0
- audex/view/static/css/glass.css +22 -0
- audex/view/static/css/global.css +541 -0
- audex/view/static/css/login.css +386 -0
- audex/view/static/css/recording.css +439 -0
- audex/view/static/css/register.css +293 -0
- audex/view/static/css/sessions/styles.css +501 -0
- audex/view/static/css/settings.css +186 -0
- audex/view/static/css/voiceprint/enroll.css +43 -0
- audex/view/static/css/voiceprint/styles.css +209 -0
- audex/view/static/css/voiceprint/update.css +44 -0
- audex/view/static/images/logo.svg +95 -0
- audex/view/static/js/recording.js +42 -0
- audex-1.0.7a3.dist-info/METADATA +361 -0
- audex-1.0.7a3.dist-info/RECORD +192 -0
- audex-1.0.7a3.dist-info/WHEEL +4 -0
- 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")
|