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,348 @@
|
|
|
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 fastapi import Query
|
|
10
|
+
from nicegui import ui
|
|
11
|
+
|
|
12
|
+
from audex.container import Container
|
|
13
|
+
from audex.service.doctor import DoctorService
|
|
14
|
+
from audex.service.session import SessionService
|
|
15
|
+
from audex.service.session.types import CreateSessionCommand
|
|
16
|
+
from audex.service.session.types import Delta
|
|
17
|
+
from audex.service.session.types import Done
|
|
18
|
+
from audex.service.session.types import Start
|
|
19
|
+
from audex.view.decorators import handle_errors
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@ui.page("/recording")
|
|
23
|
+
@handle_errors
|
|
24
|
+
@inject
|
|
25
|
+
async def render(
|
|
26
|
+
doctor_service: DoctorService = Depends(Provide[Container.service.doctor]),
|
|
27
|
+
session_service: SessionService = Depends(Provide[Container.service.session]),
|
|
28
|
+
session_id: str | None = Query(default=None),
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Render recording session page with lyrics-style scrolling."""
|
|
31
|
+
|
|
32
|
+
doctor = await doctor_service.current_doctor()
|
|
33
|
+
has_vp = await doctor_service.has_voiceprint()
|
|
34
|
+
|
|
35
|
+
if not has_vp:
|
|
36
|
+
ui.notify("请先注册声纹后再使用录音功能", type="warning", position="top")
|
|
37
|
+
ui.navigate.to("/voiceprint/enroll")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
ui.add_head_html('<link rel="stylesheet" href="/static/css/recording.css">')
|
|
41
|
+
|
|
42
|
+
# Global auto-scroll JavaScript
|
|
43
|
+
ui.add_head_html('<script src="/static/js/recording.js"></script>')
|
|
44
|
+
|
|
45
|
+
# Tasks
|
|
46
|
+
asyncio_tasks: dict[str, asyncio.Task] = {}
|
|
47
|
+
|
|
48
|
+
# State variables
|
|
49
|
+
session_id_state: dict[str, str | None] = {"value": session_id}
|
|
50
|
+
is_recording = {"value": False}
|
|
51
|
+
session_context: dict[str, t.Any] = {"value": None}
|
|
52
|
+
is_session_completed = {"value": False}
|
|
53
|
+
|
|
54
|
+
current_utterance_element: dict[str, t.Any] = {"element": None}
|
|
55
|
+
current_sequence: dict[str, int] = {"value": 0}
|
|
56
|
+
|
|
57
|
+
# Header with glass effect
|
|
58
|
+
with (
|
|
59
|
+
ui.header().classes("header-glass items-center justify-between px-6 py-3"),
|
|
60
|
+
ui.row().classes("items-center gap-3"),
|
|
61
|
+
):
|
|
62
|
+
ui.button(icon="arrow_back", on_click=lambda: ui.navigate.to("/")).props(
|
|
63
|
+
"flat round"
|
|
64
|
+
).tooltip("返回主面板")
|
|
65
|
+
ui.label("录音会话").classes("text-h6 font-semibold text-grey-8")
|
|
66
|
+
|
|
67
|
+
# Scroll to bottom button
|
|
68
|
+
(
|
|
69
|
+
ui.button(
|
|
70
|
+
icon="keyboard_arrow_down", on_click=lambda: ui.run_javascript("scrollToBottom()")
|
|
71
|
+
)
|
|
72
|
+
.props("round color=white text-color=grey-8")
|
|
73
|
+
.classes("scroll-bottom-btn")
|
|
74
|
+
.tooltip("滚动到底部")
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Main scrollable container
|
|
78
|
+
lyrics_container = ui.element("div").classes("lyrics-container").props('id="lyrics-container"')
|
|
79
|
+
with lyrics_container:
|
|
80
|
+
lyrics_column = ui.column().classes("w-full items-center")
|
|
81
|
+
|
|
82
|
+
# Footer glass overlay
|
|
83
|
+
ui.element("div").classes("footer-glass")
|
|
84
|
+
|
|
85
|
+
async def load_existing_utterances():
|
|
86
|
+
"""Load existing conversation history."""
|
|
87
|
+
if not session_id_state["value"]:
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
utterances = await session_service.get_utterances(session_id_state["value"])
|
|
92
|
+
|
|
93
|
+
for utterance in utterances:
|
|
94
|
+
with lyrics_column:
|
|
95
|
+
elem = ui.element("div").classes("utterance-final")
|
|
96
|
+
|
|
97
|
+
if utterance.speaker.value == "doctor":
|
|
98
|
+
elem.classes(add="is-doctor")
|
|
99
|
+
with elem:
|
|
100
|
+
ui.label("医生").classes("speaker-label")
|
|
101
|
+
ui.html(
|
|
102
|
+
f'<div class="bubble-doctor">{utterance.text}</div>', sanitize=False
|
|
103
|
+
)
|
|
104
|
+
else:
|
|
105
|
+
elem.classes(add="is-patient")
|
|
106
|
+
with elem:
|
|
107
|
+
ui.label("患者").classes("speaker-label")
|
|
108
|
+
ui.html(
|
|
109
|
+
f'<div class="bubble-patient">{utterance.text}</div>',
|
|
110
|
+
sanitize=False,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
current_sequence["value"] = max(current_sequence["value"], utterance.sequence)
|
|
114
|
+
|
|
115
|
+
# JavaScript handles auto-scroll via MutationObserver
|
|
116
|
+
|
|
117
|
+
except Exception as e:
|
|
118
|
+
print(f"Failed to load utterances: {e}")
|
|
119
|
+
|
|
120
|
+
async def wait_for_vpr_and_render(
|
|
121
|
+
elem: ui.element, sequence: int, text: str, max_retries: int = 30
|
|
122
|
+
):
|
|
123
|
+
"""Wait for VPR completion and render final bubble."""
|
|
124
|
+
ctx = session_context["value"]
|
|
125
|
+
|
|
126
|
+
for _ in range(max_retries):
|
|
127
|
+
is_doctor = ctx._vpr_results.get(sequence)
|
|
128
|
+
|
|
129
|
+
if is_doctor is not None:
|
|
130
|
+
elem.classes(remove="utterance-item utterance-current utterance-past")
|
|
131
|
+
elem.classes(add="utterance-final")
|
|
132
|
+
|
|
133
|
+
if is_doctor:
|
|
134
|
+
elem.classes(add="is-doctor")
|
|
135
|
+
else:
|
|
136
|
+
elem.classes(add="is-patient")
|
|
137
|
+
|
|
138
|
+
elem.clear()
|
|
139
|
+
with elem:
|
|
140
|
+
ui.label("医生" if is_doctor else "患者").classes("speaker-label")
|
|
141
|
+
ui.html(
|
|
142
|
+
f'<div class="bubble-{"doctor" if is_doctor else "patient"}">{text}</div>',
|
|
143
|
+
sanitize=False,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# JavaScript MutationObserver will handle auto-scroll
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
await asyncio.sleep(0.1)
|
|
150
|
+
|
|
151
|
+
# Timeout - default to patient
|
|
152
|
+
elem.classes(remove="utterance-item utterance-current utterance-past")
|
|
153
|
+
elem.classes(add="utterance-final is-patient")
|
|
154
|
+
elem.clear()
|
|
155
|
+
with elem:
|
|
156
|
+
ui.label("患者").classes("speaker-label")
|
|
157
|
+
ui.html(f'<div class="bubble-patient">{text}</div>', sanitize=False)
|
|
158
|
+
|
|
159
|
+
async def process_transcription():
|
|
160
|
+
"""Process transcription events."""
|
|
161
|
+
ctx = session_context["value"]
|
|
162
|
+
|
|
163
|
+
async for event in ctx.receive():
|
|
164
|
+
if isinstance(event, Start):
|
|
165
|
+
with lyrics_column:
|
|
166
|
+
elem = ui.element("div").classes("utterance-item utterance-current slide-up")
|
|
167
|
+
with elem:
|
|
168
|
+
ui.label("")
|
|
169
|
+
current_utterance_element["element"] = elem
|
|
170
|
+
|
|
171
|
+
elif isinstance(event, Delta):
|
|
172
|
+
elem = current_utterance_element["element"]
|
|
173
|
+
|
|
174
|
+
if elem is None:
|
|
175
|
+
with lyrics_column:
|
|
176
|
+
elem = ui.element("div").classes(
|
|
177
|
+
"utterance-item utterance-current slide-up"
|
|
178
|
+
)
|
|
179
|
+
with elem:
|
|
180
|
+
ui.label("")
|
|
181
|
+
current_utterance_element["element"] = elem
|
|
182
|
+
|
|
183
|
+
elem.clear()
|
|
184
|
+
with elem:
|
|
185
|
+
ui.label(event.text)
|
|
186
|
+
|
|
187
|
+
if not event.interim and event.sequence is not None:
|
|
188
|
+
current_sequence["value"] = event.sequence
|
|
189
|
+
elem.classes(remove="utterance-current")
|
|
190
|
+
elem.classes(add="utterance-past")
|
|
191
|
+
|
|
192
|
+
# JavaScript MutationObserver handles auto-scroll
|
|
193
|
+
|
|
194
|
+
elif isinstance(event, Done):
|
|
195
|
+
elem = current_utterance_element["element"]
|
|
196
|
+
if elem and current_sequence["value"] > 0:
|
|
197
|
+
vpr_and_render_task = asyncio.create_task(
|
|
198
|
+
wait_for_vpr_and_render(elem, current_sequence["value"], event.full_text)
|
|
199
|
+
)
|
|
200
|
+
asyncio_tasks.setdefault(
|
|
201
|
+
f"vpr_render_{current_sequence['value']}", vpr_and_render_task
|
|
202
|
+
)
|
|
203
|
+
current_utterance_element["element"] = None
|
|
204
|
+
|
|
205
|
+
@handle_errors
|
|
206
|
+
async def toggle_recording():
|
|
207
|
+
"""Toggle recording state."""
|
|
208
|
+
if not is_recording["value"]:
|
|
209
|
+
if is_session_completed["value"] and session_id_state["value"]:
|
|
210
|
+
# Continue existing session
|
|
211
|
+
record_btn.props("loading")
|
|
212
|
+
try:
|
|
213
|
+
ctx = await session_service.session(session_id_state["value"])
|
|
214
|
+
session_context["value"] = ctx
|
|
215
|
+
await ctx.start()
|
|
216
|
+
|
|
217
|
+
is_recording["value"] = True
|
|
218
|
+
is_session_completed["value"] = False
|
|
219
|
+
|
|
220
|
+
record_btn.props("icon=stop color=negative")
|
|
221
|
+
record_btn.classes(add="recording-pulse")
|
|
222
|
+
|
|
223
|
+
ui.notify("继续录音", type="positive")
|
|
224
|
+
|
|
225
|
+
transcription_task = asyncio.create_task(process_transcription())
|
|
226
|
+
asyncio_tasks.setdefault("transcription", transcription_task)
|
|
227
|
+
|
|
228
|
+
finally:
|
|
229
|
+
record_btn.props(remove="loading")
|
|
230
|
+
|
|
231
|
+
else:
|
|
232
|
+
# Create new session
|
|
233
|
+
with (
|
|
234
|
+
ui.dialog() as dialog,
|
|
235
|
+
ui.card().style("width: 550px; padding: 32px; border-radius: 20px;"),
|
|
236
|
+
):
|
|
237
|
+
ui.label("创建录音会话").classes("text-h5 font-bold mb-2 text-grey-9")
|
|
238
|
+
ui.label("填写患者信息以开始录音").classes("text-body2 text-grey-7 mb-6")
|
|
239
|
+
|
|
240
|
+
with ui.row().classes("w-full gap-4"):
|
|
241
|
+
patient_name = (
|
|
242
|
+
ui.input("", placeholder="患者姓名")
|
|
243
|
+
.classes("flex-1 clean-input")
|
|
244
|
+
.props("standout dense hide-bottom-space")
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
clinic_number = (
|
|
248
|
+
ui.input("", placeholder="门诊号")
|
|
249
|
+
.classes("flex-1 clean-input")
|
|
250
|
+
.props("standout dense hide-bottom-space")
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
with ui.row().classes("w-full gap-4 mt-3"):
|
|
254
|
+
medical_record = (
|
|
255
|
+
ui.input("", placeholder="病历号")
|
|
256
|
+
.classes("flex-1 clean-input")
|
|
257
|
+
.props("standout dense hide-bottom-space")
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
diagnosis = (
|
|
261
|
+
ui.input("", placeholder="诊断")
|
|
262
|
+
.classes("flex-1 clean-input")
|
|
263
|
+
.props("standout dense hide-bottom-space")
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
notes = (
|
|
267
|
+
ui.textarea("", placeholder="备注")
|
|
268
|
+
.classes("w-full mt-3 clean-input notes-textarea")
|
|
269
|
+
.props("standout hide-bottom-space")
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
@handle_errors
|
|
273
|
+
async def do_start():
|
|
274
|
+
dialog.close()
|
|
275
|
+
record_btn.props("loading")
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
session = await session_service.create(
|
|
279
|
+
CreateSessionCommand(
|
|
280
|
+
doctor_id=doctor.id,
|
|
281
|
+
patient_name=patient_name.value.strip() or None,
|
|
282
|
+
clinic_number=clinic_number.value.strip() or None,
|
|
283
|
+
medical_record_number=medical_record.value.strip() or None,
|
|
284
|
+
diagnosis=diagnosis.value.strip() or None,
|
|
285
|
+
notes=notes.value.strip() or None,
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
session_id_state["value"] = session.id
|
|
290
|
+
is_session_completed["value"] = False
|
|
291
|
+
|
|
292
|
+
ctx = await session_service.session(session_id_state["value"])
|
|
293
|
+
session_context["value"] = ctx
|
|
294
|
+
await ctx.start()
|
|
295
|
+
|
|
296
|
+
is_recording["value"] = True
|
|
297
|
+
|
|
298
|
+
record_btn.props("icon=stop color=negative")
|
|
299
|
+
record_btn.classes(add="recording-pulse")
|
|
300
|
+
|
|
301
|
+
ui.notify("开始录音", type="positive")
|
|
302
|
+
|
|
303
|
+
transcription_task = asyncio.create_task(process_transcription())
|
|
304
|
+
asyncio_tasks.setdefault("transcription", transcription_task)
|
|
305
|
+
|
|
306
|
+
finally:
|
|
307
|
+
record_btn.props(remove="loading")
|
|
308
|
+
|
|
309
|
+
with ui.row().classes("w-full justify-end gap-2 mt-6"):
|
|
310
|
+
ui.button("取消", on_click=dialog.close).props("flat no-caps").classes(
|
|
311
|
+
"action-button"
|
|
312
|
+
)
|
|
313
|
+
ui.button("开始录音", on_click=do_start).props(
|
|
314
|
+
"unelevated color=primary size=lg no-caps"
|
|
315
|
+
).classes("action-button").style("height: 48px;")
|
|
316
|
+
|
|
317
|
+
dialog.open()
|
|
318
|
+
|
|
319
|
+
else:
|
|
320
|
+
# Stop recording
|
|
321
|
+
record_btn.props("loading")
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
ctx = session_context["value"]
|
|
325
|
+
await ctx.close()
|
|
326
|
+
|
|
327
|
+
await session_service.complete(session_id_state["value"])
|
|
328
|
+
|
|
329
|
+
is_recording["value"] = False
|
|
330
|
+
is_session_completed["value"] = True
|
|
331
|
+
|
|
332
|
+
record_btn.props("icon=mic color=primary")
|
|
333
|
+
record_btn.classes(remove="recording-pulse")
|
|
334
|
+
|
|
335
|
+
ui.notify("录音已保存,可继续添加内容", type="positive", timeout=3000)
|
|
336
|
+
|
|
337
|
+
finally:
|
|
338
|
+
record_btn.props(remove="loading")
|
|
339
|
+
|
|
340
|
+
# Recording button
|
|
341
|
+
record_btn = (
|
|
342
|
+
ui.button(icon="mic", on_click=toggle_recording)
|
|
343
|
+
.props("round unelevated color=primary size=xl")
|
|
344
|
+
.classes("record-btn")
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Load existing utterances on page load
|
|
348
|
+
await load_existing_utterances()
|
|
@@ -0,0 +1,202 @@
|
|
|
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.config import Config
|
|
9
|
+
from audex.container import Container
|
|
10
|
+
from audex.exceptions import PermissionDeniedError
|
|
11
|
+
from audex.exceptions import ValidationError
|
|
12
|
+
from audex.service.doctor import DoctorService
|
|
13
|
+
from audex.service.doctor.types import RegisterCommand
|
|
14
|
+
from audex.valueobj.common.auth import Password
|
|
15
|
+
from audex.valueobj.common.email import Email
|
|
16
|
+
from audex.valueobj.common.phone import CNPhone
|
|
17
|
+
from audex.view.decorators import handle_errors
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@ui.page("/register")
|
|
21
|
+
@handle_errors
|
|
22
|
+
@inject
|
|
23
|
+
async def render(
|
|
24
|
+
doctor_service: DoctorService = Depends(Provide[Container.service.doctor]),
|
|
25
|
+
config: Config = Depends(Provide[Container.config]),
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Render registration page with clean design."""
|
|
28
|
+
|
|
29
|
+
# Check if already logged in
|
|
30
|
+
try:
|
|
31
|
+
await doctor_service.current_doctor()
|
|
32
|
+
ui.navigate.to("/")
|
|
33
|
+
return
|
|
34
|
+
except PermissionDeniedError:
|
|
35
|
+
pass # Not logged in, continue
|
|
36
|
+
|
|
37
|
+
# Add consistent CSS (same as login)
|
|
38
|
+
ui.add_head_html('<link rel="stylesheet" href="/static/css/register.css">')
|
|
39
|
+
|
|
40
|
+
# Full screen container - allow scrolling
|
|
41
|
+
with (
|
|
42
|
+
(
|
|
43
|
+
ui.element("div")
|
|
44
|
+
.classes("w-full bg-white")
|
|
45
|
+
.style(
|
|
46
|
+
"min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 40px 20px;"
|
|
47
|
+
)
|
|
48
|
+
),
|
|
49
|
+
ui.card().classes("register-card").style("width: 480px; padding: 32px 36px;"),
|
|
50
|
+
): # Register card
|
|
51
|
+
# Logo
|
|
52
|
+
ui.image("/static/images/logo.svg").classes("mx-auto mb-3 register-logo")
|
|
53
|
+
|
|
54
|
+
# Title
|
|
55
|
+
ui.label("创建账号").classes("gradient-title text-h5 text-center w-full mb-1")
|
|
56
|
+
ui.label(f"注册 {config.core.app.app_name} 医生账号").classes(
|
|
57
|
+
"text-sm text-grey-7 text-center w-full mb-5"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Required fields section
|
|
61
|
+
ui.label("基本信息").classes("text-subtitle2 font-semibold text-grey-8 mb-3")
|
|
62
|
+
|
|
63
|
+
eid_input = (
|
|
64
|
+
ui.input("", placeholder="工号 *")
|
|
65
|
+
.classes("w-full mb-3 clean-input")
|
|
66
|
+
.props("standout dense hide-bottom-space")
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
name_input = (
|
|
70
|
+
ui.input("", placeholder="姓名 *")
|
|
71
|
+
.classes("w-full mb-3 clean-input")
|
|
72
|
+
.props("standout dense hide-bottom-space")
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
password_input = (
|
|
76
|
+
ui.input(
|
|
77
|
+
"",
|
|
78
|
+
placeholder="密码 *",
|
|
79
|
+
password=True,
|
|
80
|
+
password_toggle_button=True,
|
|
81
|
+
)
|
|
82
|
+
.classes("w-full mb-3 clean-input")
|
|
83
|
+
.props("standout dense hide-bottom-space")
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
password2_input = (
|
|
87
|
+
ui.input(
|
|
88
|
+
"",
|
|
89
|
+
placeholder="确认密码 *",
|
|
90
|
+
password=True,
|
|
91
|
+
password_toggle_button=True,
|
|
92
|
+
)
|
|
93
|
+
.classes("w-full mb-3 clean-input")
|
|
94
|
+
.props("standout dense hide-bottom-space")
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Optional fields section
|
|
98
|
+
ui.separator().classes("my-4")
|
|
99
|
+
ui.label("其他信息(选填)").classes("text-subtitle2 font-semibold text-grey-8 mb-3")
|
|
100
|
+
|
|
101
|
+
department_input = (
|
|
102
|
+
ui.input("", placeholder="科室")
|
|
103
|
+
.classes("w-full mb-3 clean-input")
|
|
104
|
+
.props("standout dense hide-bottom-space")
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
title_input = (
|
|
108
|
+
ui.input("", placeholder="职称")
|
|
109
|
+
.classes("w-full mb-3 clean-input")
|
|
110
|
+
.props("standout dense hide-bottom-space")
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
hospital_input = (
|
|
114
|
+
ui.input("", placeholder="医院")
|
|
115
|
+
.classes("w-full mb-3 clean-input")
|
|
116
|
+
.props("standout dense hide-bottom-space")
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
phone_input = (
|
|
120
|
+
ui.input("", placeholder="手机号")
|
|
121
|
+
.classes("w-full mb-3 clean-input")
|
|
122
|
+
.props("standout dense hide-bottom-space")
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
email_input = (
|
|
126
|
+
ui.input("", placeholder="邮箱")
|
|
127
|
+
.classes("w-full mb-3 clean-input")
|
|
128
|
+
.props("standout dense hide-bottom-space")
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
@handle_errors
|
|
132
|
+
async def do_register() -> None:
|
|
133
|
+
"""Handle registration action."""
|
|
134
|
+
# Validate required fields
|
|
135
|
+
if not eid_input.value.strip():
|
|
136
|
+
ui.notify("请输入工号", type="warning", position="top")
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
if not name_input.value.strip():
|
|
140
|
+
ui.notify("请输入姓名", type="warning", position="top")
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
if not password_input.value:
|
|
144
|
+
ui.notify("请输入密码", type="warning", position="top")
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
if password_input.value != password2_input.value:
|
|
148
|
+
ui.notify("两次密码不一致", type="warning", position="top")
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
# Parse optional fields
|
|
152
|
+
phone = None
|
|
153
|
+
if phone_input.value.strip():
|
|
154
|
+
try:
|
|
155
|
+
phone_str: str = phone_input.value.strip()
|
|
156
|
+
if not phone_str.startswith("+86 "):
|
|
157
|
+
phone_str = "+86 " + phone_str
|
|
158
|
+
phone = CNPhone.parse(phone_str)
|
|
159
|
+
except ValidationError:
|
|
160
|
+
ui.notify("手机号格式不正确", type="warning", position="top")
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
email = None
|
|
164
|
+
if email_input.value.strip():
|
|
165
|
+
try:
|
|
166
|
+
email = Email.parse(email_input.value.strip())
|
|
167
|
+
except ValidationError:
|
|
168
|
+
ui.notify("邮箱格式不正确", type="warning", position="top")
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
# Call service to register
|
|
172
|
+
await doctor_service.register(
|
|
173
|
+
RegisterCommand(
|
|
174
|
+
eid=eid_input.value.strip(),
|
|
175
|
+
password=Password.parse(password_input.value),
|
|
176
|
+
name=name_input.value.strip(),
|
|
177
|
+
department=department_input.value.strip() or None,
|
|
178
|
+
title=title_input.value.strip() or None,
|
|
179
|
+
hospital=hospital_input.value.strip() or None,
|
|
180
|
+
phone=phone,
|
|
181
|
+
email=email,
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
ui.notify("注册成功", type="positive", position="top")
|
|
186
|
+
ui.navigate.to("/")
|
|
187
|
+
|
|
188
|
+
# Register button
|
|
189
|
+
ui.button("注册", on_click=do_register).props(
|
|
190
|
+
"unelevated color=primary size=lg no-caps"
|
|
191
|
+
).classes("w-full register-button mt-4").style("height: 48px;")
|
|
192
|
+
|
|
193
|
+
# Divider
|
|
194
|
+
with ui.row().classes("w-full items-center gap-4 my-3"):
|
|
195
|
+
ui.separator().classes("flex-1")
|
|
196
|
+
ui.label("或").classes("text-xs text-grey-6")
|
|
197
|
+
ui.separator().classes("flex-1")
|
|
198
|
+
|
|
199
|
+
# Back to login button
|
|
200
|
+
ui.button("返回登录", on_click=lambda: ui.navigate.to("/login")).props(
|
|
201
|
+
"flat size=lg no-caps"
|
|
202
|
+
).classes("w-full back-button").style("height: 48px;")
|