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,98 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import typing as t
6
+
7
+ from starlette.middleware.base import BaseHTTPMiddleware
8
+ from starlette.requests import Request
9
+ from starlette.responses import RedirectResponse
10
+ from starlette.responses import Response
11
+
12
+ from audex.helper.mixin import LoggingMixin
13
+ from audex.lib.repos.doctor import DoctorRepository
14
+ from audex.lib.server.types import DoctorSessionData
15
+
16
+ if t.TYPE_CHECKING:
17
+ from starlette.types import ASGIApp
18
+
19
+
20
+ class AuthMiddleware(BaseHTTPMiddleware, LoggingMixin):
21
+ """Authentication middleware using cookie-based sessions."""
22
+
23
+ __logtag__ = "audex.lib.http.auth"
24
+
25
+ COOKIE_NAME: t.ClassVar[str] = "audex_session"
26
+ COOKIE_MAX_AGE: t.ClassVar[int] = 86400 * 7 # 7 days
27
+
28
+ # Public routes that don't require auth
29
+ PUBLIC_ROUTES: t.ClassVar[set[str]] = {"/login", "/api/login", "/static"}
30
+
31
+ def __init__(self, app: ASGIApp, doctor_repo: DoctorRepository):
32
+ super().__init__(app)
33
+ self.doctor_repo = doctor_repo
34
+
35
+ async def dispatch(
36
+ self, request: Request, call_next: t.Callable[[Request], t.Awaitable[Response]]
37
+ ) -> Response:
38
+ """Process request with authentication check."""
39
+ # Check if route is public
40
+ if self._is_public_route(request.url.path):
41
+ return await call_next(request)
42
+
43
+ # Get session from cookie
44
+ session_data = self._get_session_from_cookie(request)
45
+
46
+ if not session_data:
47
+ # Not authenticated, redirect to login
48
+ if request.url.path.startswith("/api/"):
49
+ return Response(
50
+ content=json.dumps({"error": "Unauthorized"}),
51
+ status_code=401,
52
+ media_type="application/json",
53
+ )
54
+ return RedirectResponse(url="/login", status_code=303)
55
+
56
+ # Verify doctor still exists and is active
57
+ doctor = await self.doctor_repo.read(session_data["doctor_id"])
58
+ if not doctor or not doctor.is_active:
59
+ # Session invalid, clear cookie
60
+ response = RedirectResponse(url="/login", status_code=303)
61
+ response.delete_cookie(self.COOKIE_NAME)
62
+ return response
63
+
64
+ # Attach session data to request state
65
+ request.state.doctor_session = session_data
66
+
67
+ return await call_next(request)
68
+
69
+ def _is_public_route(self, path: str) -> bool:
70
+ """Check if route is public."""
71
+ return any(path.startswith(public) for public in self.PUBLIC_ROUTES)
72
+
73
+ def _get_session_from_cookie(self, request: Request) -> DoctorSessionData | None:
74
+ """Extract and decode session from cookie."""
75
+ cookie_value = request.cookies.get(self.COOKIE_NAME)
76
+ if not cookie_value:
77
+ return None
78
+
79
+ try:
80
+ # Decode base64
81
+ decoded = base64.b64decode(cookie_value).decode("utf-8")
82
+ session_dict = json.loads(decoded)
83
+
84
+ # Validate required fields
85
+ if not all(k in session_dict for k in ["doctor_id", "eid", "doctor_name"]):
86
+ return None
87
+
88
+ return t.cast(DoctorSessionData, session_dict)
89
+
90
+ except Exception as e:
91
+ self.logger.warning(f"Failed to decode session cookie: {e}")
92
+ return None
93
+
94
+ @staticmethod
95
+ def create_session_cookie(session_data: DoctorSessionData) -> str:
96
+ """Create encoded session cookie value."""
97
+ json_str = json.dumps(session_data)
98
+ return base64.b64encode(json_str.encode("utf-8")).decode("utf-8")
@@ -0,0 +1,248 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import typing as t
5
+
6
+ from starlette.requests import Request
7
+ from starlette.responses import Response
8
+ from starlette.templating import Jinja2Templates
9
+
10
+ from audex.filters.generated import doctor_filter
11
+ from audex.filters.generated import session_filter
12
+ from audex.helper.mixin import LoggingMixin
13
+ from audex.lib.server.auth import AuthMiddleware
14
+ from audex.lib.server.types import DoctorSessionData
15
+ from audex.lib.server.types import ErrorResponse
16
+ from audex.lib.server.types import ExportMultipleRequest
17
+ from audex.lib.server.types import LoginRequest
18
+ from audex.lib.server.types import LoginResponse
19
+ from audex.lib.server.types import SessionListResponse
20
+ from audex.valueobj.common.auth import Password
21
+
22
+ if t.TYPE_CHECKING:
23
+ from audex.lib.exporter import Exporter
24
+ from audex.lib.repos.doctor import DoctorRepository
25
+
26
+
27
+ class RequestHandlers(LoggingMixin):
28
+ """HTTP request handlers."""
29
+
30
+ __logtag__ = "audex.lib.http.handlers"
31
+
32
+ def __init__(
33
+ self,
34
+ templates: Jinja2Templates,
35
+ doctor_repo: DoctorRepository,
36
+ exporter: Exporter,
37
+ ):
38
+ super().__init__()
39
+ self.templates = templates
40
+ self.doctor_repo = doctor_repo
41
+ self.exporter = exporter
42
+
43
+ async def login_page(self, request: Request) -> Response:
44
+ """Render login page."""
45
+ return self.templates.TemplateResponse("login.html.j2", {"request": request})
46
+
47
+ async def index_page(self, request: Request) -> Response:
48
+ """Render main export page."""
49
+ session_data: DoctorSessionData = request.state.doctor_session
50
+ return self.templates.TemplateResponse(
51
+ "index.html.j2",
52
+ {
53
+ "request": request,
54
+ "doctor_name": session_data["doctor_name"],
55
+ },
56
+ )
57
+
58
+ async def api_login(self, request: Request) -> Response:
59
+ """Handle login request."""
60
+ try:
61
+ body = await request.json()
62
+ login_req = t.cast(LoginRequest, body)
63
+
64
+ eid = login_req.get("eid")
65
+ password = login_req.get("password")
66
+
67
+ if not eid or not password:
68
+ return self._error_response("Missing eid or password", 400)
69
+
70
+ # Find doctor
71
+ f = doctor_filter().eid.eq(eid)
72
+ doctor = await self.doctor_repo.first(f.build())
73
+
74
+ if not doctor:
75
+ return self._error_response("Invalid credentials", 401)
76
+
77
+ if not doctor.is_active:
78
+ return self._error_response("Account inactive", 401)
79
+
80
+ # Verify password
81
+ if not doctor.verify_password(Password.parse(password)):
82
+ return self._error_response("Invalid credentials", 401)
83
+
84
+ # Create session
85
+ session_data = DoctorSessionData(
86
+ doctor_id=doctor.id,
87
+ eid=doctor.eid,
88
+ doctor_name=doctor.name,
89
+ )
90
+
91
+ login_response = LoginResponse(
92
+ success=True,
93
+ doctor_id=doctor.id,
94
+ doctor_name=doctor.name,
95
+ )
96
+
97
+ response = Response(
98
+ content=json.dumps(login_response, ensure_ascii=False),
99
+ media_type="application/json",
100
+ )
101
+
102
+ # Set cookie
103
+ response.set_cookie(
104
+ key=AuthMiddleware.COOKIE_NAME,
105
+ value=AuthMiddleware.create_session_cookie(session_data),
106
+ max_age=AuthMiddleware.COOKIE_MAX_AGE,
107
+ httponly=True,
108
+ samesite="lax",
109
+ )
110
+
111
+ self.logger.info(f"Doctor {eid} logged in")
112
+ return response
113
+
114
+ except Exception as e:
115
+ self.logger.error(f"Login failed: {e}")
116
+ return self._error_response(str(e), 500)
117
+
118
+ async def api_logout(self, _: Request) -> Response:
119
+ """Handle logout request."""
120
+ response = Response(
121
+ content=json.dumps({"success": True}),
122
+ media_type="application/json",
123
+ )
124
+ response.delete_cookie(AuthMiddleware.COOKIE_NAME)
125
+ return response
126
+
127
+ async def api_list_sessions(self, request: Request) -> Response:
128
+ """List sessions for current doctor."""
129
+ try:
130
+ session_data: DoctorSessionData = request.state.doctor_session
131
+ doctor_id = session_data["doctor_id"]
132
+
133
+ # Get query params
134
+ page = int(request.query_params.get("page", "0"))
135
+ page_size = int(request.query_params.get("page_size", "50"))
136
+
137
+ # Build filter
138
+ f = session_filter().doctor_id.eq(doctor_id).created_at.desc()
139
+
140
+ # Get sessions
141
+ sessions = await self.exporter.session_repo.list(
142
+ f.build(),
143
+ page_index=page,
144
+ page_size=page_size,
145
+ )
146
+
147
+ # Get total count
148
+ total = await self.exporter.session_repo.count(
149
+ session_filter().doctor_id.eq(doctor_id).build()
150
+ )
151
+
152
+ # Convert to response
153
+ list_response = SessionListResponse(
154
+ sessions=[self.exporter._session_to_dict(s) for s in sessions],
155
+ total=total,
156
+ page=page,
157
+ page_size=page_size,
158
+ )
159
+
160
+ return Response(
161
+ content=json.dumps(list_response, ensure_ascii=False),
162
+ media_type="application/json",
163
+ )
164
+
165
+ except Exception as e:
166
+ self.logger.error(f"Failed to list sessions: {e}")
167
+ return self._error_response(str(e), 500)
168
+
169
+ async def api_export_session(self, request: Request) -> Response:
170
+ """Export single session."""
171
+ try:
172
+ session_data: DoctorSessionData = request.state.doctor_session
173
+ doctor_id = session_data["doctor_id"]
174
+ session_id = request.path_params["session_id"]
175
+
176
+ # Verify ownership
177
+ session = await self.exporter.session_repo.read(session_id)
178
+ if not session:
179
+ return self._error_response("Session not found", 404)
180
+
181
+ if session.doctor_id != doctor_id:
182
+ return self._error_response("Access denied", 403)
183
+
184
+ # Generate ZIP
185
+ zip_data = await self.exporter.export_session_zip(session_id)
186
+
187
+ # Generate filename
188
+ filename = f"{session_id}"
189
+ if session.patient_name:
190
+ filename = f"{session.patient_name}_{session_id}"
191
+ filename += ".zip"
192
+
193
+ self.logger.info(f"Exported session {session_id} for doctor {doctor_id}")
194
+
195
+ return Response(
196
+ content=zip_data,
197
+ media_type="application/zip",
198
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
199
+ )
200
+
201
+ except Exception as e:
202
+ self.logger.error(f"Failed to export session: {e}")
203
+ return self._error_response(str(e), 500)
204
+
205
+ async def api_export_multiple(self, request: Request) -> Response:
206
+ """Export multiple sessions."""
207
+ try:
208
+ session_data: DoctorSessionData = request.state.doctor_session
209
+ doctor_id = session_data["doctor_id"]
210
+
211
+ body = await request.json()
212
+ export_req = t.cast(ExportMultipleRequest, body)
213
+ session_ids = export_req.get("session_ids", [])
214
+
215
+ if not session_ids:
216
+ return self._error_response("No session IDs provided", 400)
217
+
218
+ # Verify all sessions belong to doctor
219
+ for session_id in session_ids:
220
+ session = await self.exporter.session_repo.read(session_id)
221
+ if not session or session.doctor_id != doctor_id:
222
+ return self._error_response(f"Access denied for session {session_id}", 403)
223
+
224
+ # Generate ZIP
225
+ zip_data = await self.exporter.export_multiple_sessions_zip(session_ids)
226
+
227
+ filename = f"sessions_export_{len(session_ids)}.zip"
228
+
229
+ self.logger.info(f"Exported {len(session_ids)} sessions for doctor {doctor_id}")
230
+
231
+ return Response(
232
+ content=zip_data,
233
+ media_type="application/zip",
234
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
235
+ )
236
+
237
+ except Exception as e:
238
+ self.logger.error(f"Failed to export multiple sessions: {e}")
239
+ return self._error_response(str(e), 500)
240
+
241
+ def _error_response(self, message: str, status_code: int = 500) -> Response:
242
+ """Create error response."""
243
+ error: ErrorResponse = {"error": message, "details": None}
244
+ return Response(
245
+ content=json.dumps(error, ensure_ascii=False),
246
+ status_code=status_code,
247
+ media_type="application/json",
248
+ )
@@ -0,0 +1,226 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>会话导出系统</title>
7
+ <link rel="stylesheet" href="/static/style.css">
8
+ </head>
9
+ <body>
10
+ <div class="container">
11
+ <header class="page-header">
12
+ <div class="header-content">
13
+ <div class="header-left">
14
+ <h1>会话导出系统</h1>
15
+ </div>
16
+ <div class="header-right">
17
+ <span class="doctor-name">{{ doctor_name }}</span>
18
+ <button id="logoutBtn" class="btn btn-ghost">退出</button>
19
+ </div>
20
+ </div>
21
+ <div class="session-count" id="sessionCount">正在加载...</div>
22
+ </header>
23
+
24
+ <div class="actions-bar">
25
+ <button id="exportSelected" class="btn btn-primary" disabled>
26
+ <span>导出选中</span>
27
+ <span id="selectedCount" class="count-badge" style="display: none;">0</span>
28
+ </button>
29
+ <button id="selectAll" class="btn btn-secondary">全选</button>
30
+ <button id="deselectAll" class="btn btn-secondary">取消全选</button>
31
+ </div>
32
+
33
+ <div id="sessionsGrid" class="sessions-grid">
34
+ <div class="loading">
35
+ <div class="spinner"></div>
36
+ <p>加载中</p>
37
+ </div>
38
+ </div>
39
+ </div>
40
+
41
+ <script src="/static/script.js"></script>
42
+ <script>
43
+ let sessions = [];
44
+ let selectedSessions = new Set();
45
+
46
+ window.addEventListener('DOMContentLoaded', () => {
47
+ loadSessions();
48
+ setupEventListeners();
49
+ });
50
+
51
+ function setupEventListeners() {
52
+ document.getElementById('exportSelected').addEventListener('click', exportMultiple);
53
+ document.getElementById('selectAll').addEventListener('click', selectAll);
54
+ document.getElementById('deselectAll').addEventListener('click', deselectAll);
55
+ document.getElementById('logoutBtn').addEventListener('click', logout);
56
+ }
57
+
58
+ async function loadSessions() {
59
+ try {
60
+ const response = await fetch('/api/sessions? page_size=100');
61
+
62
+ if (response.status === 401) {
63
+ window.location.href = '/login';
64
+ return;
65
+ }
66
+
67
+ const data = await response.json();
68
+ sessions = data.sessions;
69
+
70
+ // Update count
71
+ const countText = sessions.length === 0 ? '暂无会话' : `共 ${sessions.length} 个会话`;
72
+ document.getElementById('sessionCount').textContent = countText;
73
+
74
+ renderSessions();
75
+ } catch (error) {
76
+ console.error('Failed to load sessions:', error);
77
+ document.getElementById('sessionCount').textContent = '加载失败';
78
+ document.getElementById('sessionsGrid').innerHTML =
79
+ '<div class="error-state"><p>加载失败</p></div>';
80
+ }
81
+ }
82
+
83
+ function renderSessions() {
84
+ const grid = document.getElementById('sessionsGrid');
85
+
86
+ if (sessions.length === 0) {
87
+ grid.innerHTML = '<div class="empty-state"><p>暂无会话记录</p></div>';
88
+ return;
89
+ }
90
+
91
+ grid.innerHTML = sessions.map((session, index) => `
92
+ <div class="session-card" style="animation-delay: ${index * 0.03}s">
93
+ <div class="session-header">
94
+ <div class="checkbox-wrapper">
95
+ <input
96
+ type="checkbox"
97
+ class="checkbox session-checkbox"
98
+ data-session-id="${session.id}"
99
+ id="checkbox-${session.id}"
100
+ onchange="toggleSession('${session.id}')">
101
+ <label for="checkbox-${session.id}" class="checkbox-custom"></label>
102
+ </div>
103
+ <div class="session-info">
104
+ <h3>${escapeHtml(session.patient_name || '未知患者')}</h3>
105
+ ${session.clinic_number ?
106
+ `<div class="session-meta">门诊号: ${escapeHtml(session.clinic_number)}</div>` : ''}
107
+ ${session.medical_record_number ?
108
+ `<div class="session-meta">病历号: ${escapeHtml(session.medical_record_number)}</div>` : ''}
109
+ ${session.diagnosis ?
110
+ `<div class="session-meta">诊断: ${escapeHtml(session.diagnosis)}</div>` : ''}
111
+ <div class="session-meta">${formatDate(session.created_at)}</div>
112
+ <span class="status-badge status-${session.status.toLowerCase().replace('_', '-')}">
113
+ ${getStatusText(session.status)}
114
+ </span>
115
+ </div>
116
+ </div>
117
+ <button class="export-btn" onclick="exportSingle('${session.id}')">
118
+ 导出
119
+ </button>
120
+ </div>
121
+ `).join('');
122
+ }
123
+
124
+ function toggleSession(sessionId) {
125
+ if (selectedSessions.has(sessionId)) {
126
+ selectedSessions.delete(sessionId);
127
+ } else {
128
+ selectedSessions.add(sessionId);
129
+ }
130
+ updateSelectedCount();
131
+ }
132
+
133
+ function selectAll() {
134
+ sessions.forEach(session => {
135
+ selectedSessions.add(session.id);
136
+ const checkbox = document.querySelector(`[data-session-id="${session.id}"]`);
137
+ if (checkbox) checkbox.checked = true;
138
+ });
139
+ updateSelectedCount();
140
+ }
141
+
142
+ function deselectAll() {
143
+ selectedSessions.clear();
144
+ document.querySelectorAll('.session-checkbox').forEach(cb => cb.checked = false);
145
+ updateSelectedCount();
146
+ }
147
+
148
+ function updateSelectedCount() {
149
+ const count = selectedSessions.size;
150
+ const countBadge = document.getElementById('selectedCount');
151
+ const exportBtn = document.getElementById('exportSelected');
152
+
153
+ if (! countBadge || !exportBtn) return;
154
+
155
+ if (count > 0) {
156
+ countBadge.textContent = count;
157
+ countBadge.style.display = 'flex';
158
+ exportBtn.disabled = false;
159
+ } else {
160
+ countBadge.style.display = 'none';
161
+ exportBtn.disabled = true;
162
+ }
163
+ }
164
+
165
+ async function exportSingle(sessionId) {
166
+ window.location.href = `/api/sessions/${sessionId}/export`;
167
+ }
168
+
169
+ async function exportMultiple() {
170
+ if (selectedSessions.size === 0) return;
171
+
172
+ const btn = document.getElementById('exportSelected');
173
+ const originalHTML = btn.innerHTML;
174
+ btn.disabled = true;
175
+ btn.innerHTML = '<span>导出中...</span>';
176
+
177
+ try {
178
+ const response = await fetch('/api/sessions/export-multiple', {
179
+ method: 'POST',
180
+ headers: { 'Content-Type': 'application/json' },
181
+ body: JSON.stringify({
182
+ session_ids: Array.from(selectedSessions)
183
+ })
184
+ });
185
+
186
+ if (! response.ok) {
187
+ const error = await response.json();
188
+ throw new Error(error.error || 'Export failed');
189
+ }
190
+
191
+ const blob = await response.blob();
192
+ downloadBlob(blob, `sessions_export_${selectedSessions.size}.zip`);
193
+
194
+ deselectAll();
195
+ } catch (error) {
196
+ console.error('Export failed:', error);
197
+ alert('导出失败: ' + error.message);
198
+ } finally {
199
+ btn.disabled = false;
200
+ btn.innerHTML = originalHTML;
201
+ updateSelectedCount();
202
+ }
203
+ }
204
+
205
+ async function logout() {
206
+ try {
207
+ await fetch('/api/logout', { method: 'POST' });
208
+ } catch (error) {
209
+ console.error('Logout failed:', error);
210
+ } finally {
211
+ window.location.href = '/login';
212
+ }
213
+ }
214
+
215
+ function getStatusText(status) {
216
+ const map = {
217
+ 'COMPLETED': 'completed',
218
+ 'IN_PROGRESS': 'in_progress',
219
+ 'DRAFT': 'draft',
220
+ 'CANCELLED': 'cancelled'
221
+ };
222
+ return map[status] || status.toLowerCase();
223
+ }
224
+ </script>
225
+ </body>
226
+ </html>
@@ -0,0 +1,111 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>登录 - 会话导出系统</title>
7
+ <link rel="stylesheet" href="/static/style.css">
8
+ </head>
9
+ <body class="login-body">
10
+ <div class="login-container">
11
+ <div class="login-card">
12
+ <div class="login-header">
13
+ <h1>会话导出系统</h1>
14
+ <p>登录以访问导出功能</p>
15
+ </div>
16
+
17
+ <form id="loginForm" class="login-form">
18
+ <div class="form-group">
19
+ <label for="eid">工号</label>
20
+ <input
21
+ type="text"
22
+ id="eid"
23
+ name="eid"
24
+ required
25
+ autocomplete="username"
26
+ placeholder="请输入工号">
27
+ </div>
28
+
29
+ <div class="form-group">
30
+ <label for="password">密码</label>
31
+ <input
32
+ type="password"
33
+ id="password"
34
+ name="password"
35
+ required
36
+ autocomplete="current-password"
37
+ placeholder="请输入密码">
38
+ </div>
39
+
40
+ <div id="errorMessage" class="error-message" style="display: none;"></div>
41
+
42
+ <button type="submit" class="btn btn-primary btn-block" id="loginBtn">
43
+ 登录
44
+ </button>
45
+ </form>
46
+ </div>
47
+ </div>
48
+
49
+ <script src="/static/script.js"></script>
50
+ <script>
51
+ const form = document.getElementById('loginForm');
52
+ const eidInput = document.getElementById('eid');
53
+ const passwordInput = document.getElementById('password');
54
+ const errorMessage = document.getElementById('errorMessage');
55
+ const loginBtn = document.getElementById('loginBtn');
56
+
57
+ form.addEventListener('submit', async (e) => {
58
+ e.preventDefault();
59
+
60
+ const eid = eidInput.value.trim();
61
+ const password = passwordInput.value;
62
+
63
+ if (!eid || ! password) {
64
+ showError('请输入工号和密码');
65
+ return;
66
+ }
67
+
68
+ hideError();
69
+ loginBtn.disabled = true;
70
+ loginBtn.textContent = '登录中...';
71
+
72
+ try {
73
+ const response = await fetch('/api/login', {
74
+ method: 'POST',
75
+ headers: { 'Content-Type': 'application/json' },
76
+ body: JSON.stringify({ eid, password }),
77
+ });
78
+
79
+ const data = await response.json();
80
+
81
+ if (response.ok && data.success) {
82
+ loginBtn.textContent = '登录成功';
83
+ setTimeout(() => {
84
+ window.location.href = '/';
85
+ }, 200);
86
+ } else {
87
+ showError(data.error || '登录失败,请检查工号和密码');
88
+ }
89
+ } catch (error) {
90
+ showError('网络错误,请稍后重试');
91
+ } finally {
92
+ if (! errorMessage.style.display || errorMessage.style.display === 'none') {
93
+ loginBtn.disabled = false;
94
+ loginBtn.textContent = '登录';
95
+ }
96
+ }
97
+ });
98
+
99
+ function showError(message) {
100
+ errorMessage.textContent = message;
101
+ errorMessage.style.display = 'block';
102
+ }
103
+
104
+ function hideError() {
105
+ errorMessage.style.display = 'none';
106
+ }
107
+
108
+ eidInput.focus();
109
+ </script>
110
+ </body>
111
+ </html>