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,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;")