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,407 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ from nicegui import ui
6
+
7
+ from audex.lib.wifi import WiFiManager
8
+ from audex.lib.wifi import WiFiNetwork
9
+ from audex.lib.wifi import WiFiSecurity
10
+
11
+
12
+ def _get_wifi_icon_and_color(signal_quality: int, is_connected: bool) -> tuple[str, str]:
13
+ """Get WiFi icon and color based on signal quality."""
14
+ if is_connected:
15
+ if signal_quality >= 75:
16
+ return "signal_wifi_4_bar", "text-positive"
17
+ if signal_quality >= 50:
18
+ return "network_wifi_3_bar", "text-positive"
19
+ if signal_quality >= 25:
20
+ return "network_wifi_2_bar", "text-warning"
21
+ return "network_wifi_1_bar", "text-warning"
22
+ return "signal_wifi_off", "text-grey-6"
23
+
24
+
25
+ def _get_security_label(security: WiFiSecurity) -> str:
26
+ """Get human-readable security label."""
27
+ security_map = {
28
+ WiFiSecurity.OPEN: "开放",
29
+ WiFiSecurity.WEP: "WEP",
30
+ WiFiSecurity.WPA: "WPA",
31
+ WiFiSecurity.WPA2: "WPA2",
32
+ WiFiSecurity.WPA3: "WPA3",
33
+ WiFiSecurity.UNKNOWN: "未知",
34
+ }
35
+ return security_map.get(security, "未知")
36
+
37
+
38
+ class WiFiIndicator:
39
+ """WiFi status indicator component."""
40
+
41
+ def __init__(self, wifi_manager: WiFiManager) -> None:
42
+ self.wifi_manager = wifi_manager
43
+ self.icon_display: ui.button | None = None
44
+
45
+ # Dialog state
46
+ self.dialog: ui.dialog | None = None
47
+ self.current_conn_container: ui.column | None = None
48
+ self.networks_container: ui.column | None = None
49
+ self.scanning = False
50
+ self.disconnecting = False
51
+
52
+ # Track expanded state
53
+ self.expanded_ssids: set[str] = set()
54
+
55
+ def render(self) -> ui.button:
56
+ """Render WiFi indicator as a simple icon button."""
57
+ self.icon_display = (
58
+ ui.button(icon="signal_wifi_off", on_click=self._show_dialog)
59
+ .props("flat round")
60
+ .classes("wifi-indicator-btn text-grey-6")
61
+ .tooltip("WiFi 设置")
62
+ )
63
+
64
+ # Start status polling
65
+ asyncio.create_task(self._initial_update()) # noqa
66
+ ui.timer(5.0, self._update_status)
67
+
68
+ return self.icon_display
69
+
70
+ async def _initial_update(self) -> None:
71
+ """Initial status update."""
72
+ await asyncio.sleep(0.1)
73
+ await self._update_status()
74
+
75
+ async def _update_status(self) -> None:
76
+ """Update WiFi status display."""
77
+ if not self.icon_display:
78
+ return
79
+
80
+ try:
81
+ is_available = getattr(self.wifi_manager, "is_available", False)
82
+ if not is_available:
83
+ self.icon_display.props('icon="signal_wifi_off"')
84
+ self.icon_display.classes(
85
+ "text-grey-4", remove="text-positive text-warning text-grey-6"
86
+ )
87
+ return
88
+
89
+ conn_info = await self.wifi_manager.get_connection_info()
90
+ if conn_info:
91
+ icon, color = _get_wifi_icon_and_color(conn_info.signal_quality, True)
92
+ self.icon_display.props(f'icon="{icon}"')
93
+ self.icon_display.classes(
94
+ color, remove="text-positive text-warning text-grey-6 text-grey-4"
95
+ )
96
+ else:
97
+ self.icon_display.props('icon="signal_wifi_off"')
98
+ self.icon_display.classes(
99
+ "text-grey-6", remove="text-positive text-warning text-grey-4"
100
+ )
101
+
102
+ except Exception as e:
103
+ print(f"[WiFi] Update error: {e}")
104
+ if self.icon_display:
105
+ self.icon_display.props('icon="signal_wifi_off"')
106
+ self.icon_display.classes(
107
+ "text-grey-4", remove="text-positive text-warning text-grey-6"
108
+ )
109
+
110
+ def _show_dialog(self) -> None:
111
+ """Show WiFi management dialog."""
112
+ with (
113
+ ui.dialog() as dialog,
114
+ ui.card()
115
+ .classes("wifi-dialog-card")
116
+ .style("width: 540px; max-width: 90vw; padding: 28px;"),
117
+ ):
118
+ self.dialog = dialog
119
+
120
+ # Header
121
+ with ui.row().classes("items-center justify-between w-full mb-4"):
122
+ with ui.row().classes("items-center gap-2"):
123
+ ui.icon("wifi", size="md").classes("text-primary")
124
+ ui.label("WiFi 设置").classes("text-h6 font-bold text-grey-9")
125
+ ui.button(icon="close", on_click=dialog.close).props("flat round dense").classes(
126
+ "press-button"
127
+ )
128
+
129
+ # Loading overlay
130
+ loading_container = ui.column().classes("w-full items-center gap-3 py-8")
131
+ with loading_container:
132
+ ui.spinner(size="lg").classes("text-primary")
133
+ ui.label("正在扫描网络...").classes("text-body2 text-grey-7")
134
+
135
+ # Current connection - fixed at top
136
+ self.current_conn_container = ui.column().classes("w-full").style("display: none;")
137
+
138
+ # Scan button
139
+ with (
140
+ ui.row()
141
+ .classes("items-center justify-between w-full mb-3")
142
+ .style("margin-top: 16px;")
143
+ ):
144
+ ui.label("可用网络").classes("text-subtitle2 font-semibold text-grey-8")
145
+
146
+ async def do_rescan():
147
+ if self.scanning:
148
+ return
149
+ self.scanning = True
150
+ scan_btn.props("loading")
151
+ # Clear expanded state on rescan
152
+ self.expanded_ssids.clear()
153
+ await self._scan_networks()
154
+ scan_btn.props(remove="loading")
155
+ self.scanning = False
156
+
157
+ scan_btn = (
158
+ ui.button(icon="refresh", on_click=do_rescan)
159
+ .props("flat round dense")
160
+ .classes("wifi-scan-btn")
161
+ )
162
+
163
+ # Networks container - scrollable
164
+ self.networks_container = (
165
+ ui.column()
166
+ .classes("w-full gap-2")
167
+ .style("max-height: 380px; overflow-y: auto; display: none; scrollbar-with: none;")
168
+ )
169
+
170
+ # Scan in background
171
+ async def load_networks():
172
+ await self._scan_networks()
173
+ loading_container.style("display: none;")
174
+ if self.current_conn_container:
175
+ self.current_conn_container.style("display: flex;")
176
+ if self.networks_container:
177
+ self.networks_container.style("display: flex;")
178
+
179
+ asyncio.create_task(load_networks()) # noqa
180
+
181
+ dialog.open()
182
+
183
+ async def _scan_networks(self) -> None:
184
+ """Scan and display networks."""
185
+ if not self.current_conn_container or not self.networks_container:
186
+ return
187
+
188
+ self.current_conn_container.clear()
189
+ self.networks_container.clear()
190
+
191
+ try:
192
+ conn_info = await self.wifi_manager.get_connection_info()
193
+ current_ssid = conn_info.ssid if conn_info else None
194
+
195
+ # Current connection card - fixed at top
196
+ if conn_info:
197
+ with (
198
+ self.current_conn_container,
199
+ ui.card().classes("w-full wifi-current-card"),
200
+ ui.row().classes("items-center justify-between w-full"),
201
+ ):
202
+ with ui.row().classes("items-center gap-3"):
203
+ icon, color = _get_wifi_icon_and_color(conn_info.signal_quality, True)
204
+ ui.icon(icon, size="md").classes(color)
205
+ with ui.column().classes("gap-0"):
206
+ ui.label(conn_info.ssid).classes("text-body1 font-semibold text-grey-9")
207
+ status_parts = [f"已连接 · {conn_info.signal_quality}%"]
208
+ if conn_info.ip_address:
209
+ status_parts.append(conn_info.ip_address)
210
+ ui.label(" · ".join(status_parts)).classes("text-xs text-grey-7")
211
+
212
+ async def do_disconnect():
213
+ if self.disconnecting:
214
+ return
215
+ self.disconnecting = True
216
+ disconnect_btn.props("loading")
217
+
218
+ success = await self.wifi_manager.disconnect()
219
+
220
+ disconnect_btn.props(remove="loading")
221
+ self.disconnecting = False
222
+
223
+ if success:
224
+ ui.notify("已断开连接", type="positive")
225
+ await self._update_status()
226
+ await self._scan_networks()
227
+ else:
228
+ ui.notify("断开失败", type="negative")
229
+
230
+ disconnect_btn = (
231
+ ui.button(icon="link_off", on_click=do_disconnect)
232
+ .props("flat")
233
+ .classes("wifi-disconnect-btn")
234
+ .tooltip("断开连接")
235
+ )
236
+
237
+ # Scan networks
238
+ networks = await self.wifi_manager.scan()
239
+
240
+ if not networks:
241
+ with self.networks_container, ui.column().classes("items-center gap-3 py-8"):
242
+ ui.icon("wifi_off", size="xl").classes("text-grey-4")
243
+ ui.label("未找到网络").classes("text-body2 text-grey-6")
244
+ return
245
+
246
+ # Filter out current network from list
247
+ available_networks = [n for n in networks if n.ssid != current_ssid]
248
+
249
+ # Display available networks
250
+ with self.networks_container:
251
+ for network in available_networks:
252
+ self._render_network_card(network)
253
+
254
+ except Exception as e:
255
+ if self.networks_container:
256
+ self.networks_container.clear()
257
+ with self.networks_container, ui.column().classes("items-center gap-3 py-8"):
258
+ ui.icon("error_outline", size="xl").classes("text-negative")
259
+ ui.label(f"扫描失败: {e!s}").classes("text-body2 text-negative")
260
+
261
+ def _render_network_card(self, network: WiFiNetwork) -> None:
262
+ """Render a single network card with expandable connect form."""
263
+ is_expanded = network.ssid in self.expanded_ssids
264
+
265
+ card_container = ui.column().classes("w-full").style("gap: 8px;")
266
+
267
+ with card_container:
268
+ # Store form container reference
269
+ form_container = None
270
+ expand_icon = None
271
+
272
+ # Toggle function - only one expanded at a time
273
+ def make_toggle_handler(ssid: str):
274
+ def toggle():
275
+ # If clicking already expanded, just collapse it
276
+ if ssid in self.expanded_ssids:
277
+ self.expanded_ssids.remove(ssid)
278
+ if form_container:
279
+ # Collapse animation
280
+ form_container.style(
281
+ "display: flex; max-height: 0; opacity: 0; padding-top: 0; padding-bottom: 0;"
282
+ )
283
+ ui.timer(0.4, lambda: form_container.style("display: none;"), once=True)
284
+ if expand_icon:
285
+ expand_icon.props('name="expand_more"')
286
+ else:
287
+ # Collapse all other cards first
288
+ self.expanded_ssids.clear()
289
+
290
+ # Re-render to collapse others (we need to track all form containers)
291
+ # For now, just clear and add this one
292
+ self.expanded_ssids.add(ssid)
293
+
294
+ if form_container:
295
+ # Expand animation
296
+ form_container.style("display: flex; max-height: 0; opacity: 0;")
297
+ ui.timer(
298
+ 0.01,
299
+ lambda: form_container.style(
300
+ "display: flex; max-height: 80px; opacity: 1; padding-top: 12px; padding-bottom: 12px;"
301
+ ),
302
+ once=True,
303
+ )
304
+ if expand_icon:
305
+ expand_icon.props('name="expand_less"')
306
+
307
+ return toggle
308
+
309
+ # Network info card
310
+ with (
311
+ (
312
+ ui.card()
313
+ .classes("w-full wifi-network-card")
314
+ .on("click", make_toggle_handler(network.ssid))
315
+ ),
316
+ ui.row().classes("items-center justify-between w-full"),
317
+ ):
318
+ with ui.row().classes("items-center gap-3 flex-1"):
319
+ icon, color = _get_wifi_icon_and_color(
320
+ network.signal_quality, network.is_connected
321
+ )
322
+ ui.icon(icon, size="md").classes(color)
323
+
324
+ with ui.column().classes("gap-0 flex-1"):
325
+ ui.label(network.ssid).classes("text-body1 font-semibold text-grey-9")
326
+
327
+ info_parts = [
328
+ f"{network.signal_quality}%",
329
+ _get_security_label(network.security),
330
+ ]
331
+ if network.channel:
332
+ info_parts.append(f"信道 {network.channel}")
333
+ ui.label(" · ".join(info_parts)).classes("text-xs text-grey-7")
334
+
335
+ with ui.row().classes("items-center gap-2"):
336
+ if network.security != WiFiSecurity.OPEN:
337
+ ui.icon("lock", size="sm").classes("text-grey-6")
338
+ expand_icon = ui.icon(
339
+ "expand_less" if is_expanded else "expand_more", size="sm"
340
+ ).classes("text-grey-6")
341
+
342
+ # Expandable connect form
343
+ initial_style = (
344
+ "display: flex; max-height: 80px; opacity: 1; padding: 12px;"
345
+ if is_expanded
346
+ else "display: none; max-height: 0; opacity: 0; padding: 0;"
347
+ )
348
+
349
+ form_container = (
350
+ ui.column().classes("w-full wifi-connect-form").style(f"{initial_style} gap: 0;")
351
+ )
352
+ # Row layout: password input + connect button
353
+ with form_container, ui.row().classes("items-center w-full").style("gap: 12px;"):
354
+ if network.security != WiFiSecurity.OPEN:
355
+ password_input = (
356
+ ui.input("密码", password=True, password_toggle_button=True)
357
+ .classes("flex-1 clean-input")
358
+ .props("standout dense outlined")
359
+ .style("margin: 0;")
360
+ )
361
+ else:
362
+ password_input = None
363
+ ui.label("此网络无需密码").classes("text-body2 text-grey-6 flex-1")
364
+
365
+ def make_connect_handler(net: WiFiNetwork, pwd_input):
366
+ async def do_connect():
367
+ # Get password if needed
368
+ password = None
369
+ if pwd_input:
370
+ password = pwd_input.value.strip()
371
+ if not password:
372
+ ui.notify("请输入密码", type="warning")
373
+ return
374
+
375
+ connect_btn.props("loading")
376
+
377
+ # Disconnect current connection first
378
+ conn_info = await self.wifi_manager.get_connection_info()
379
+ if conn_info:
380
+ await self.wifi_manager.disconnect()
381
+ await asyncio.sleep(1)
382
+
383
+ # Connect to new network
384
+ success = await self.wifi_manager.connect(net.ssid, password)
385
+
386
+ connect_btn.props(remove="loading")
387
+
388
+ if success:
389
+ ui.notify(f"已连接到 {net.ssid}", type="positive")
390
+ await self._update_status()
391
+ if self.dialog:
392
+ self.dialog.close()
393
+ else:
394
+ ui.notify("连接失败,请检查密码", type="negative")
395
+
396
+ return do_connect
397
+
398
+ # Connect button with arrow_forward icon
399
+ connect_btn = (
400
+ ui.button(
401
+ icon="arrow_forward",
402
+ on_click=make_connect_handler(network, password_input),
403
+ )
404
+ .props("flat")
405
+ .classes("wifi-connect-btn")
406
+ .tooltip("连接到此网络")
407
+ )
@@ -0,0 +1,110 @@
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.service.doctor import DoctorService
12
+ from audex.service.doctor.exceptions import InvalidCredentialsError
13
+ from audex.service.doctor.types import LoginCommand
14
+ from audex.valueobj.common.auth import Password
15
+ from audex.view.decorators import handle_errors
16
+
17
+
18
+ @ui.page("/login")
19
+ @handle_errors
20
+ @inject
21
+ async def render(
22
+ doctor_service: DoctorService = Depends(Provide[Container.service.doctor]),
23
+ config: Config = Depends(Provide[Container.config]),
24
+ ) -> None:
25
+ """Render login page with clean design."""
26
+
27
+ # Check if already logged in
28
+ try:
29
+ await doctor_service.current_doctor()
30
+ ui.navigate.to("/")
31
+ return
32
+ except PermissionDeniedError:
33
+ pass # Not logged in, continue
34
+
35
+ # Add consistent CSS
36
+ ui.add_head_html('<link rel="stylesheet" href="/static/css/login.css">')
37
+
38
+ # Full screen container
39
+ with (
40
+ (
41
+ ui.element("div")
42
+ .classes("w-full bg-white")
43
+ .style(
44
+ "min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px 0; overflow-y: auto;"
45
+ )
46
+ ),
47
+ ui.card().classes("login-card").style("width: 420px; max-width: 90vw; padding: 32px 36px;"),
48
+ ):
49
+ # Logo
50
+ ui.image("/static/images/logo.svg").classes("mx-auto mb-3 login-logo")
51
+
52
+ # Title
53
+ ui.label("欢迎回来").classes("gradient-title text-h5 text-center w-full mb-1")
54
+ ui.label(f"登录 {config.core.app.app_name}").classes(
55
+ "text-sm text-grey-7 text-center w-full mb-4"
56
+ )
57
+
58
+ # Input fields - only bottom border
59
+ eid_input = (
60
+ ui.input("", placeholder="工号")
61
+ .classes("w-full mb-3 clean-input")
62
+ .props("standout dense hide-bottom-space")
63
+ )
64
+
65
+ password_input = (
66
+ ui.input(
67
+ "",
68
+ placeholder="密码",
69
+ password=True,
70
+ password_toggle_button=True,
71
+ )
72
+ .classes("w-full mb-2 clean-input")
73
+ .props("standout dense hide-bottom-space")
74
+ )
75
+
76
+ @handle_errors
77
+ async def do_login() -> None:
78
+ """Handle login action."""
79
+ eid = eid_input.value.strip()
80
+ pwd = password_input.value
81
+
82
+ if not eid or not pwd:
83
+ ui.notify("请输入工号和密码", type="warning", position="top")
84
+ return
85
+
86
+ try:
87
+ await doctor_service.login(LoginCommand(eid=eid, password=Password.parse(pwd)))
88
+ ui.notify("登录成功", type="positive", position="top")
89
+ ui.navigate.to("/")
90
+ except InvalidCredentialsError as e:
91
+ ui.notify(e.message, type="negative", position="top")
92
+
93
+ # Enter key to login
94
+ password_input.on("keydown.enter", do_login)
95
+
96
+ # Login button
97
+ ui.button("登录", on_click=do_login).props(
98
+ "unelevated color=primary size=lg no-caps"
99
+ ).classes("w-full login-button mt-4").style("height: 48px;")
100
+
101
+ # Divider
102
+ with ui.row().classes("w-full items-center gap-4 my-3"):
103
+ ui.separator().classes("flex-1")
104
+ ui.label("或").classes("text-xs text-grey-6")
105
+ ui.separator().classes("flex-1")
106
+
107
+ # Register button
108
+ ui.button("注册新账号", on_click=lambda: ui.navigate.to("/register")).props(
109
+ "flat size=lg no-caps"
110
+ ).classes("w-full register-button").style("height: 48px;")