This commit is contained in:
Your Name
2026-08-11 09:12:51 +08:00
parent c3ceb0dd0f
commit cfe4c82c90
111 changed files with 26110 additions and 826 deletions
@@ -28,6 +28,7 @@ from .repository import (
AuditAction,
_audit_action,
_body,
_daily_record_body,
_identified_body,
_material_kind,
_prescription_payload,
@@ -53,6 +54,16 @@ DEMO_PERMISSIONS: tuple[str, ...] = (
"tcm.diagnosis/add",
"tcm.diagnosis/delete",
"tcm.diagnosis/readonlyDetail",
"tcm.diagnosis/dailyRecord",
"tcm.diagnosis/chufang",
"tcm.diagnosis/huifang",
"tcm.diagnosis/chat",
"tcm.diagnosis/patientOrders",
"tcm.diagnosis/setRevisitSlotStartOffset",
"tcm.diagnosis/guahaoLogList",
"tcm.diagnosis/guahao",
"tcm.diagnosis/order",
"tcm.diagnosis/qrcode",
"tcm.diagnosis/videoQr",
"tcm.diagnosis/kaifang",
"tcm.diagnosis/getCallSignature",
@@ -140,6 +151,105 @@ class DemoDoctorRepository:
504: [],
}
self._calls: dict[int, dict[str, Any]] = {}
self._call_records: dict[int, list[dict[str, Any]]] = {
501: [
{
"id": 1,
"diagnosis_id": 501,
"call_type": 2,
"room_id": "demo-room-501",
"status": 2,
"status_text": "已结束",
"recording_status_text": "录制完成",
"recording_urls_list": ["https://media.example.invalid/demo/diagnosis-501.mp4"],
"start_time_text": f"{self._today.isoformat()} 09:10:00",
"end_time_text": f"{self._today.isoformat()} 09:22:00",
"duration_text": "12分00秒",
}
],
502: [],
503: [],
504: [],
}
self._im_messages: dict[int, list[dict[str, Any]]] = {
501: [
{
"msg_id": "demo-text-1",
"msg_type": "text",
"text": "今天空腹血糖 5.8,早餐后精神不错。",
"is_from_doctor": False,
"time": int(datetime.now().timestamp()) - 900,
},
{
"msg_id": "demo-image-1",
"msg_type": "image",
"image_url": "https://media.example.invalid/demo/blood-log.jpg",
"is_from_doctor": False,
"time": int(datetime.now().timestamp()) - 600,
},
{
"msg_id": "demo-file-1",
"msg_type": "file",
"file_url": "https://media.example.invalid/demo/check-report.pdf",
"file_name": "检查报告.pdf",
"is_from_doctor": True,
"from_staff_name": "陈医生",
"time": int(datetime.now().timestamp()) - 300,
},
],
502: [],
503: [],
504: [],
}
self._blood_records: dict[int, list[dict[str, Any]]] = {
501: [
{
"id": 1,
"diagnosis_id": 501,
"patient_id": 301,
"record_date": self._today.isoformat(),
"fasting_blood_sugar": 5.8,
"systolic_pressure": 126,
"diastolic_pressure": 78,
"source": 0,
}
],
502: [],
503: [],
504: [],
}
self._diet_records: dict[int, list[dict[str, Any]]] = {
501: [
{
"id": 2,
"diagnosis_id": 501,
"patient_id": 301,
"record_date": self._today.isoformat(),
"breakfast_foods": "小米粥、鸡蛋",
"note": "清淡饮食",
}
],
502: [],
503: [],
504: [],
}
self._exercise_records: dict[int, list[dict[str, Any]]] = {
501: [
{
"id": 3,
"diagnosis_id": 501,
"patient_id": 301,
"record_date": self._today.isoformat(),
"exercise_type": "散步",
"duration": 30,
"intensity": 1,
}
],
502: [],
503: [],
504: [],
}
self._diagnosis_orders: list[dict[str, Any]] = []
self._assign_logs: dict[int, list[dict[str, Any]]] = {
501: [
{
@@ -168,7 +278,9 @@ class DemoDoctorRepository:
}
self._next_note_id = 2
self._next_material_id = 1
self._next_call_id = 1
self._next_call_id = 2
self._next_daily_record_id = 4
self._next_generic_order_id = 1
self._next_order_id = max((row["id"] for row in self._patient_orders), default=0) + 1
self._next_todo_id = 1
@@ -387,7 +499,7 @@ class DemoDoctorRepository:
def upload_material(
self,
path: str | PathLike[str],
material_type: Literal["image", "file", "tongue_images", "report_files"],
material_type: Literal["image", "video", "file", "tongue_images", "report_files"],
cid: int = 0,
) -> str:
"""Copy a local-material identity into a safe synthetic server URI."""
@@ -1365,6 +1477,13 @@ class DemoDoctorRepository:
consultation.has_appointment = False
return {"id": appointment_id, "status": 2, "status_desc": "已取消"}
def cancel_diagnosis_appointment(self, appointment_id: int) -> dict[str, Any]:
"""Exact demo counterpart of ``doctor.appointment/cancel``."""
if appointment_id <= 0:
raise ValueError("appointment_id must be positive")
return self.cancel_patient_appointment(appointment_id)
def patient_detail(self, diagnosis_id: int) -> dict[str, Any]:
"""Return the complete readonly diagnosis aggregate."""
@@ -1547,6 +1666,11 @@ class DemoDoctorRepository:
self._assign_logs[diagnosis_id] = []
self._tracking_notes[diagnosis_id] = []
self._todos[diagnosis_id] = []
self._blood_records[diagnosis_id] = []
self._diet_records[diagnosis_id] = []
self._exercise_records[diagnosis_id] = []
self._call_records[diagnosis_id] = []
self._im_messages[diagnosis_id] = []
return deepcopy(dict(consultation.raw))
def update_diagnosis(
@@ -1593,11 +1717,19 @@ class DemoDoctorRepository:
self._assign_logs.pop(diagnosis_id, None)
self._tracking_notes.pop(diagnosis_id, None)
self._todos.pop(diagnosis_id, None)
self._blood_records.pop(diagnosis_id, None)
self._diet_records.pop(diagnosis_id, None)
self._exercise_records.pop(diagnosis_id, None)
self._call_records.pop(diagnosis_id, None)
self._im_messages.pop(diagnosis_id, None)
return {"id": diagnosis_id, "deleted": True}
def set_revisit_slot_start_offset(self, diagnosis_id: int, offset: int) -> dict[str, Any]:
"""Persist the demo revisit-statistics offset."""
if offset < 0 or offset > 20:
raise ValueError("offset must be between 0 and 20")
with self._lock:
consultation = self._find_consultation(diagnosis_id)
consultation.raw["revisit_slot_start_offset"] = offset
@@ -1677,24 +1809,31 @@ class DemoDoctorRepository:
) -> dict[str, Any]:
"""Return representative blood, diet and exercise records."""
self._find_consultation(diagnosis_id)
return {
"diagnosis_id": diagnosis_id,
"start_date": start_date,
"end_date": end_date,
"blood_records": [
with self._lock:
self._find_consultation(diagnosis_id)
def in_range(row: Mapping[str, Any]) -> bool:
value = str(row.get("record_date") or "")[:10]
return (not start_date or value >= start_date) and (
not end_date or value <= end_date
)
return deepcopy(
{
"record_date": self._today.isoformat(),
"fasting_glucose": 5.8,
"systolic": 126,
"diastolic": 78,
"diagnosis_id": diagnosis_id,
"start_date": start_date,
"end_date": end_date,
"blood_records": [
row for row in self._blood_records.get(diagnosis_id, []) if in_range(row)
],
"diet_records": [
row for row in self._diet_records.get(diagnosis_id, []) if in_range(row)
],
"exercise_records": [
row for row in self._exercise_records.get(diagnosis_id, []) if in_range(row)
],
}
],
"diet_records": [{"record_date": self._today.isoformat(), "content": "清淡饮食"}],
"exercise_records": [
{"record_date": self._today.isoformat(), "content": "步行 30 分钟"}
],
}
)
def list_tracking_notes(self, diagnosis_id: int) -> list[dict[str, Any]]:
"""Return persisted demo tracking notes newest first."""
@@ -1706,8 +1845,8 @@ class DemoDoctorRepository:
def add_tracking_note(self, diagnosis_id: int, content: str) -> dict[str, Any]:
"""Append a durable demo tracking note."""
if not content.strip():
raise ValueError("tracking_content is required")
if not content.strip() or len(content.strip()) > 1000:
raise ValueError("tracking_content must contain 1 to 1000 characters")
with self._lock:
self._find_consultation(diagnosis_id)
note = {
@@ -1719,6 +1858,111 @@ class DemoDoctorRepository:
self._tracking_notes[diagnosis_id].append(note)
return deepcopy(note)
def add_blood_record(
self, payload: Mapping[str, Any] | None = None, **fields: Any
) -> dict[str, Any]:
"""Persist a validated demo blood glucose / pressure record."""
return self._add_daily_record("blood", payload, fields)
def update_blood_record(
self,
record: int | Mapping[str, Any],
changes: Mapping[str, Any] | None = None,
**fields: Any,
) -> dict[str, Any]:
"""Update a demo blood record."""
return self._update_daily_record("blood", record, changes, fields)
def add_diet_record(
self, payload: Mapping[str, Any] | None = None, **fields: Any
) -> dict[str, Any]:
"""Persist a validated demo diet record."""
return self._add_daily_record("diet", payload, fields)
def update_diet_record(
self,
record: int | Mapping[str, Any],
changes: Mapping[str, Any] | None = None,
**fields: Any,
) -> dict[str, Any]:
"""Update a demo diet record."""
return self._update_daily_record("diet", record, changes, fields)
def add_exercise_record(
self, payload: Mapping[str, Any] | None = None, **fields: Any
) -> dict[str, Any]:
"""Persist a validated demo exercise record."""
return self._add_daily_record("exercise", payload, fields)
def update_exercise_record(
self,
record: int | Mapping[str, Any],
changes: Mapping[str, Any] | None = None,
**fields: Any,
) -> dict[str, Any]:
"""Update a demo exercise record."""
return self._update_daily_record("exercise", record, changes, fields)
def _add_daily_record(
self,
kind: Literal["blood", "diet", "exercise"],
payload: Mapping[str, Any] | None,
fields: Mapping[str, Any],
) -> dict[str, Any]:
body = _daily_record_body(kind, payload, fields)
diagnosis_id = int(body["diagnosis_id"])
with self._lock:
self._find_consultation(diagnosis_id)
body["id"] = self._next_daily_record_id
body.setdefault("source", 0)
self._next_daily_record_id += 1
self._daily_records(kind).setdefault(diagnosis_id, []).append(body)
return deepcopy(body)
def _update_daily_record(
self,
kind: Literal["blood", "diet", "exercise"],
record: int | Mapping[str, Any],
changes: Mapping[str, Any] | None,
fields: Mapping[str, Any],
) -> dict[str, Any]:
body = _identified_body(record, changes, fields)
record_id = int(body["id"])
diagnosis_id = int(body.get("diagnosis_id") or 0)
with self._lock:
collections = self._daily_records(kind)
candidates = (
collections.get(diagnosis_id, [])
if diagnosis_id > 0
else [row for rows in collections.values() for row in rows]
)
current = next(
(row for row in candidates if int(row.get("id") or 0) == record_id), None
)
if current is None:
raise RepositoryNotFoundError(f"{kind} record {record_id} not found")
merged = dict(current)
merged.update(body)
validated = _daily_record_body(kind, merged, {})
current.clear()
current.update(validated)
return deepcopy(current)
def _daily_records(
self, kind: Literal["blood", "diet", "exercise"]
) -> dict[int, list[dict[str, Any]]]:
if kind == "blood":
return self._blood_records
if kind == "diet":
return self._diet_records
return self._exercise_records
def list_diagnosis_todos(
self,
diagnosis_id: int,
@@ -1743,15 +1987,24 @@ class DemoDoctorRepository:
) -> dict[str, Any]:
"""Create and retain a demo follow-up todo."""
clean_content = content.strip()
if not clean_content or len(clean_content) > 500:
raise ValueError("todo content must contain 1 to 500 characters")
if remind_time <= int(datetime.now().timestamp()) + 30:
raise ValueError("remind_time must be at least 30 seconds in the future")
with self._lock:
self._find_consultation(diagnosis_id)
todo = {
"id": self._next_todo_id,
"diagnosis_id": diagnosis_id,
"content": content.strip(),
"content": clean_content,
"remind_time": remind_time,
"remind_time_text": datetime.fromtimestamp(remind_time).strftime("%Y-%m-%d %H:%M"),
"status": 0,
"status_text": "待执行",
"creator_id": 1001,
"creator_name": "陈医生(演示)",
"can_cancel": True,
}
self._next_todo_id += 1
self._todos[diagnosis_id].append(todo)
@@ -1764,10 +2017,226 @@ class DemoDoctorRepository:
for rows in self._todos.values():
for todo in rows:
if int(todo.get("id") or 0) == todo_id:
if int(todo.get("status") or 0) != 0:
raise ValueError("only pending todos can be cancelled")
todo["status"] = 2
todo["status_text"] = "已取消"
todo["can_cancel"] = False
return deepcopy(todo)
raise RepositoryNotFoundError(f"diagnosis todo {todo_id} not found")
def list_call_records(self, diagnosis_id: int) -> list[dict[str, Any]]:
"""Return deterministic demo replay records."""
with self._lock:
self._find_consultation(diagnosis_id)
return deepcopy(self._call_records.get(diagnosis_id, []))
def create_manual_call_record(self, diagnosis_id: int) -> dict[str, Any]:
"""Create an ended demo record capable of receiving a replay."""
with self._lock:
self._find_consultation(diagnosis_id)
record = {
"id": self._next_call_id,
"diagnosis_id": diagnosis_id,
"call_type": 2,
"room_id": "manual-upload",
"status": 2,
"status_text": "已结束",
"recording_status_text": "待上传",
"recording_urls_list": [],
"start_time_text": datetime.now().replace(microsecond=0).isoformat(sep=" "),
"end_time_text": datetime.now().replace(microsecond=0).isoformat(sep=" "),
"duration_text": "0秒",
}
self._next_call_id += 1
self._call_records.setdefault(diagnosis_id, []).insert(0, record)
return deepcopy(record)
def attach_local_call_recording(
self,
diagnosis_id: int,
file_url: str,
*,
call_record_id: int | None = None,
) -> dict[str, Any]:
"""Attach a safe demo upload URI to the selected call record."""
clean_url = file_url.strip()
if not clean_url:
raise ValueError("file_url is required")
with self._lock:
self._find_consultation(diagnosis_id)
rows = self._call_records.setdefault(diagnosis_id, [])
record = next(
(
row
for row in rows
if call_record_id is None or int(row.get("id") or 0) == int(call_record_id)
),
None,
)
if record is None:
raise RepositoryNotFoundError("call record not found")
urls = record.setdefault("recording_urls_list", [])
if clean_url not in urls:
urls.append(clean_url)
record["recording_status_text"] = "录制完成"
return deepcopy(record)
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
) -> dict[str, Any]:
"""Upload then attach a demo video using the production sequence."""
file_url = self.upload_material(path, "video")
target_id = call_record_id
if target_id is None:
target_id = int(self.create_manual_call_record(diagnosis_id)["id"])
self.attach_local_call_recording(diagnosis_id, file_url, call_record_id=target_id)
return {
"diagnosis_id": diagnosis_id,
"call_record_id": target_id,
"file_url": file_url,
}
def list_im_chat_messages(
self, diagnosis_id: int, *, only_archived: bool = True
) -> dict[str, Any]:
"""Return the demo archive in the server response envelope shape."""
with self._lock:
consultation = self._find_consultation(diagnosis_id)
return deepcopy(
{
"lists": self._im_messages.get(diagnosis_id, []),
"patient_im_id": f"patient_{consultation.patient_id}",
"patient_name": consultation.patient_name,
"doctor_accounts_queried": [] if only_archived else ["doctor_1001"],
"only_archived": bool(only_archived),
}
)
def sync_im_chat_messages(self, diagnosis_id: int) -> dict[str, Any]:
"""Acknowledge the same asynchronous sync contract in demo mode."""
self._find_consultation(diagnosis_id)
return {"queued": True, "diagnosis_id": diagnosis_id}
def list_diagnosis_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]:
"""Build demo audit rows from the diagnosis appointments."""
with self._lock:
self._find_consultation(diagnosis_id)
return [
{
"id": row.id,
"diagnosis_id": diagnosis_id,
"action": "挂号" if int(row.status) != 2 else "取消挂号",
"appointment_id": row.id,
"operator_name": "陈医生(演示)",
"create_time": row.created_at or self._today.isoformat(),
}
for row in self._appointments
if row.diagnosis_id == diagnosis_id
]
def list_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]:
"""Compatibility name used by the diagnosis list."""
return self.list_diagnosis_appointment_logs(diagnosis_id)
def generate_mini_program_qrcode(
self, payload: Mapping[str, Any] | None = None, **fields: Any
) -> dict[str, Any]:
"""Return a deterministic non-production QR reference."""
body = _body(payload, fields)
patient_id = int(body.get("patient_id") or 0)
share_user_id = int(body.get("share_user_id") or 0)
if patient_id <= 0 or share_user_id <= 0:
raise ValueError("patient_id and share_user_id must be positive")
page = str(body.get("mini_program_path") or "")
scene_id = int(
body.get("doctor_id") if page == "pages/login/login" else body.get("diagnosis_id") or 0
)
if scene_id <= 0:
raise ValueError("QR scene id must be positive")
kind = "video" if page == "pages/login/login" else "diagnosis"
return {
"qrcode_url": f"https://demo.invalid/qrcode/{kind}/{scene_id}.png",
"diagnosis_id": int(body.get("diagnosis_id") or 0),
"patient_id": patient_id,
}
def generate_video_qrcode(
self, doctor_id: int, patient_id: int, share_user_id: int
) -> dict[str, Any]:
"""Generate the demo video-login QR."""
return self.generate_mini_program_qrcode(
diagnosis_id=doctor_id,
doctor_id=doctor_id,
patient_id=patient_id,
share_user_id=share_user_id,
mini_program_path="pages/login/login",
)
def generate_diagnosis_qrcode(
self, diagnosis_id: int, doctor_id: int, patient_id: int, share_user_id: int
) -> dict[str, Any]:
"""Generate the demo diagnosis-confirmation QR."""
return self.generate_mini_program_qrcode(
diagnosis_id=diagnosis_id,
doctor_id=doctor_id,
patient_id=patient_id,
share_user_id=share_user_id,
)
def create_diagnosis_order(
self,
patient_id: int,
order_type: int,
amount: float,
*,
remark: str = "",
) -> dict[str, Any]:
"""Create the generic diagnosis-list payment order in memory."""
if patient_id <= 0:
raise ValueError("patient_id must be positive")
if order_type not in range(1, 9):
raise ValueError("order_type must be between 1 and 8")
if amount <= 0:
raise ValueError("amount must be positive")
with self._lock:
order = {
"id": self._next_generic_order_id,
"order_no": f"DEMO{self._next_generic_order_id:08d}",
"patient_id": patient_id,
"order_type": order_type,
"amount": round(float(amount), 2),
"remark": remark.strip(),
"status": 1,
}
self._next_generic_order_id += 1
self._diagnosis_orders.append(order)
return deepcopy(order)
def generate_order_qrcode(self, order_no: str) -> dict[str, Any]:
"""Return a deterministic demo payment QR reference."""
clean = order_no.strip()
if not clean:
raise ValueError("order_no is required")
return {"qrcode_url": f"https://demo.invalid/qrcode/order/{clean}.png"}
def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> CallTicket:
"""Return non-production placeholder credentials for UI demonstration."""
@@ -1802,6 +2271,16 @@ class DemoDoctorRepository:
}
self._next_call_id += 1
self._calls[diagnosis_id] = record
replay = {
**record,
"status_text": "呼叫中",
"recording_status_text": "未录制",
"recording_urls_list": [],
"start_time_text": datetime.now().replace(microsecond=0).isoformat(sep=" "),
"end_time_text": "",
"duration_text": "",
}
self._call_records.setdefault(diagnosis_id, []).insert(0, replay)
return deepcopy(record)
def end_call(self, diagnosis_id: int) -> dict[str, Any]:
@@ -1813,6 +2292,22 @@ class DemoDoctorRepository:
raise RepositoryNotFoundError(f"active call for {diagnosis_id} not found")
record["status"] = "ended"
record["ended_at"] = datetime.now().replace(microsecond=0).isoformat(sep=" ")
replay = next(
(
row
for row in self._call_records.get(diagnosis_id, [])
if int(row.get("id") or 0) == int(record.get("id") or 0)
),
None,
)
if replay is not None:
replay.update(
{
"status": 2,
"status_text": "已结束",
"end_time_text": record["ended_at"],
}
)
return deepcopy(record)
def bind_call_room(self, diagnosis_id: int, room_id: str) -> dict[str, Any]:
@@ -1826,6 +2321,22 @@ class DemoDoctorRepository:
raise RepositoryNotFoundError(f"active call for {diagnosis_id} not found")
record["room_id"] = room_id.strip()
record["status"] = "connected"
replay = next(
(
row
for row in self._call_records.get(diagnosis_id, [])
if int(row.get("id") or 0) == int(record.get("id") or 0)
),
None,
)
if replay is not None:
replay.update(
{
"room_id": room_id.strip(),
"status": 1,
"status_text": "通话中",
}
)
return {
"diagnosis_id": diagnosis_id,
"room_id": room_id.strip(),
@@ -3,6 +3,8 @@
from __future__ import annotations
import mimetypes
import re
import time
from collections.abc import Mapping
from contextlib import suppress
from datetime import date
@@ -102,7 +104,7 @@ class DoctorRepository(Protocol):
def upload_material(
self,
path: str | PathLike[str],
material_type: Literal["image", "file", "tongue_images", "report_files"],
material_type: Literal["image", "video", "file", "tongue_images", "report_files"],
cid: int = 0,
) -> str:
"""Upload one local note material and return its server URI."""
@@ -224,6 +226,9 @@ class DoctorRepository(Protocol):
def cancel_patient_appointment(self, appointment_id: int) -> Any:
"""Cancel an appointment from the patient workspace."""
def cancel_diagnosis_appointment(self, appointment_id: int) -> Any:
"""Cancel a diagnosis-list appointment through the doctor route."""
def get_diagnosis_detail(self, diagnosis_id: int, *, readonly: bool = False) -> dict[str, Any]:
"""Return an editable or permission-aware readonly diagnosis detail."""
@@ -453,6 +458,39 @@ class DoctorRepository(Protocol):
def add_tracking_note(self, diagnosis_id: int, content: str) -> Any:
"""Append a diagnosis tracking note."""
def add_blood_record(self, payload: Mapping[str, Any] | None = None, **fields: Any) -> Any:
"""Create one doctor-entered blood glucose / pressure record."""
def update_blood_record(
self,
record: int | Mapping[str, Any],
changes: Mapping[str, Any] | None = None,
**fields: Any,
) -> Any:
"""Update one blood glucose / pressure record."""
def add_diet_record(self, payload: Mapping[str, Any] | None = None, **fields: Any) -> Any:
"""Create one daily diet record."""
def update_diet_record(
self,
record: int | Mapping[str, Any],
changes: Mapping[str, Any] | None = None,
**fields: Any,
) -> Any:
"""Update one daily diet record."""
def add_exercise_record(self, payload: Mapping[str, Any] | None = None, **fields: Any) -> Any:
"""Create one daily exercise record."""
def update_exercise_record(
self,
record: int | Mapping[str, Any],
changes: Mapping[str, Any] | None = None,
**fields: Any,
) -> Any:
"""Update one daily exercise record."""
def check_diagnosis_phone(self, payload: Mapping[str, Any]) -> Any:
"""Check diagnosis phone uniqueness."""
@@ -479,6 +517,72 @@ class DoctorRepository(Protocol):
def cancel_diagnosis_todo(self, todo_id: int) -> Any:
"""Cancel a diagnosis follow-up todo."""
def list_call_records(self, diagnosis_id: int) -> list[dict[str, Any]]:
"""Return persisted audio/video call records and replay URLs."""
def create_manual_call_record(self, diagnosis_id: int) -> dict[str, Any]:
"""Create the server record used to hold a manually uploaded replay."""
def attach_local_call_recording(
self,
diagnosis_id: int,
file_url: str,
*,
call_record_id: int | None = None,
) -> Any:
"""Attach one uploaded replay URI to a call record."""
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
) -> dict[str, Any]:
"""Upload a local video then attach it to a real call record."""
def list_im_chat_messages(
self, diagnosis_id: int, *, only_archived: bool = True
) -> dict[str, Any]:
"""Return diagnosis IM messages, defaulting to the fast archive-only path."""
def sync_im_chat_messages(self, diagnosis_id: int) -> Any:
"""Queue the server-side IM archive synchronisation job."""
def list_diagnosis_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]:
"""Return per-diagnosis registration / cancellation audit rows."""
def generate_mini_program_qrcode(
self, payload: Mapping[str, Any] | None = None, **fields: Any
) -> dict[str, Any]:
"""Generate the diagnosis or video-login mini-program QR code."""
def generate_video_qrcode(
self, doctor_id: int, patient_id: int, share_user_id: int
) -> dict[str, Any]:
"""Generate the video-login QR variant."""
def generate_diagnosis_qrcode(
self, diagnosis_id: int, doctor_id: int, patient_id: int, share_user_id: int
) -> dict[str, Any]:
"""Generate the diagnosis-confirmation QR variant."""
def list_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]:
"""Compatibility name for diagnosis registration logs."""
def create_diagnosis_order(
self,
patient_id: int,
order_type: int,
amount: float,
*,
remark: str = "",
) -> dict[str, Any]:
"""Create the diagnosis-list generic payment order (not a prescription order)."""
def generate_order_qrcode(self, order_no: str) -> dict[str, Any]:
"""Generate the payment mini-program QR code for a generic order."""
def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> CallTicket:
"""Return short-lived call credentials."""
@@ -796,7 +900,7 @@ class RemoteDoctorRepository:
def upload_material(
self,
path: str | PathLike[str],
material_type: Literal["image", "file", "tongue_images", "report_files"],
material_type: Literal["image", "video", "file", "tongue_images", "report_files"],
cid: int = 0,
) -> str:
"""Upload a local note material and return only its server reference."""
@@ -1437,6 +1541,13 @@ class RemoteDoctorRepository:
return self.client.post("firstvisit.myPatient/cancelAppointment", {"id": appointment_id})
def cancel_diagnosis_appointment(self, appointment_id: int) -> Any:
"""Cancel through the exact route used by the admin diagnosis list."""
if appointment_id <= 0:
raise ValueError("appointment_id must be positive")
return self.client.post("doctor.appointment/cancel", {"id": appointment_id})
def patient_detail(self, diagnosis_id: int) -> dict[str, Any]:
"""Compatibility name for permission-aware readonly diagnosis details."""
@@ -1516,6 +1627,11 @@ class RemoteDoctorRepository:
def set_revisit_slot_start_offset(self, diagnosis_id: int, offset: int) -> Any:
"""Set the diagnosis revisit-statistics starting offset."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
if offset not in range(0, 21):
raise ValueError("revisit_slot_start_offset must be between 0 and 20")
return self.client.post(
"tcm.diagnosis/setRevisitSlotStartOffset",
{"id": diagnosis_id, "revisit_slot_start_offset": offset},
@@ -1623,13 +1739,66 @@ class RemoteDoctorRepository:
def add_tracking_note(self, diagnosis_id: int, content: str) -> Any:
"""Append a textual daily tracking note."""
if not content.strip():
raise ValueError("tracking_content is required")
clean_content = content.strip()
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
if not clean_content or len(clean_content) > 1000:
raise ValueError("tracking_content must contain 1 to 1000 characters")
return self.client.post(
"tcm.diagnosis/addTrackingNote",
{"diagnosis_id": diagnosis_id, "tracking_content": content.strip()},
{"diagnosis_id": diagnosis_id, "tracking_content": clean_content},
)
def add_blood_record(self, payload: Mapping[str, Any] | None = None, **fields: Any) -> Any:
"""Create a blood record using the fields consumed by ``DailyMatrix``."""
return self.client.post("tcm.bloodRecord/add", _daily_record_body("blood", payload, fields))
def update_blood_record(
self,
record: int | Mapping[str, Any],
changes: Mapping[str, Any] | None = None,
**fields: Any,
) -> Any:
"""Update a blood record without weakening add-time validation."""
body = _identified_body(record, changes, fields)
return self.client.post("tcm.bloodRecord/edit", _daily_record_body("blood", body, {}))
def add_diet_record(self, payload: Mapping[str, Any] | None = None, **fields: Any) -> Any:
"""Create a breakfast/lunch/dinner daily record."""
return self.client.post("tcm.dietRecord/add", _daily_record_body("diet", payload, fields))
def update_diet_record(
self,
record: int | Mapping[str, Any],
changes: Mapping[str, Any] | None = None,
**fields: Any,
) -> Any:
"""Update a diet record."""
body = _identified_body(record, changes, fields)
return self.client.post("tcm.dietRecord/edit", _daily_record_body("diet", body, {}))
def add_exercise_record(self, payload: Mapping[str, Any] | None = None, **fields: Any) -> Any:
"""Create a validated exercise record."""
return self.client.post(
"tcm.exerciseRecord/add", _daily_record_body("exercise", payload, fields)
)
def update_exercise_record(
self,
record: int | Mapping[str, Any],
changes: Mapping[str, Any] | None = None,
**fields: Any,
) -> Any:
"""Update an exercise record."""
body = _identified_body(record, changes, fields)
return self.client.post("tcm.exerciseRecord/edit", _daily_record_body("exercise", body, {}))
def list_diagnosis_todos(
self,
diagnosis_id: int,
@@ -1660,11 +1829,19 @@ class RemoteDoctorRepository:
def add_diagnosis_todo(self, diagnosis_id: int, content: str, remind_time: int) -> Any:
"""Create a follow-up todo using a Unix-seconds reminder."""
clean_content = content.strip()
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
if not clean_content or len(clean_content) > 500:
raise ValueError("todo content must contain 1 to 500 characters")
if remind_time <= int(time.time()) + 30:
raise ValueError("remind_time must be at least 30 seconds in the future")
return self.client.post(
"tcm.diagnosisTodo/add",
{
"diagnosis_id": diagnosis_id,
"content": content.strip(),
"content": clean_content,
"remind_time": remind_time,
},
)
@@ -1672,8 +1849,204 @@ class RemoteDoctorRepository:
def cancel_diagnosis_todo(self, todo_id: int) -> Any:
"""Cancel an outstanding diagnosis todo."""
if todo_id <= 0:
raise ValueError("todo_id must be positive")
return self.client.post("tcm.diagnosisTodo/cancel", {"id": todo_id})
def list_call_records(self, diagnosis_id: int) -> list[dict[str, Any]]:
"""Load the confirmed replay list endpoint."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
return _mapping_rows(
self.client.get("tcm.diagnosis/getCallRecords", {"diagnosis_id": diagnosis_id})
)
def create_manual_call_record(self, diagnosis_id: int) -> dict[str, Any]:
"""Create a synthetic call record before a toolbar video upload."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
result = self.client.post(
"tcm.diagnosis/createManualCallRecord", {"diagnosis_id": diagnosis_id}
)
return dict(_require_mapping(result, "tcm.diagnosis/createManualCallRecord"))
def attach_local_call_recording(
self,
diagnosis_id: int,
file_url: str,
*,
call_record_id: int | None = None,
) -> Any:
"""Attach an uploaded replay URI to the selected call record."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
clean_url = file_url.strip()
if not clean_url:
raise ValueError("file_url is required")
body: dict[str, Any] = {"diagnosis_id": diagnosis_id, "file_url": clean_url}
if call_record_id is not None:
if call_record_id <= 0:
raise ValueError("call_record_id must be positive")
body["call_record_id"] = call_record_id
return self.client.post("tcm.diagnosis/attachLocalCallRecording", body)
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
) -> dict[str, Any]:
"""Perform the same upload/create/attach sequence as ``CallRecordPanel``."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
# Match the admin component exactly: upload first so a failed upload does
# not leave behind an empty synthetic call record.
file_url = self.upload_material(path, "video")
target_id = call_record_id
if target_id is None:
target_id = int(self.create_manual_call_record(diagnosis_id).get("id") or 0)
if target_id <= 0:
raise ApiProtocolError("tcm.diagnosis/createManualCallRecord returned no id")
self.attach_local_call_recording(diagnosis_id, file_url, call_record_id=target_id)
return {
"diagnosis_id": diagnosis_id,
"call_record_id": target_id,
"file_url": file_url,
}
def list_im_chat_messages(
self, diagnosis_id: int, *, only_archived: bool = True
) -> dict[str, Any]:
"""Load archive-only messages unless the caller explicitly requests live merging."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
payload = self.client.get(
"tcm.diagnosis/getImChatMessages",
{"diagnosis_id": diagnosis_id, "only_archived": int(only_archived)},
)
return dict(_require_mapping(payload, "tcm.diagnosis/getImChatMessages"))
def sync_im_chat_messages(self, diagnosis_id: int) -> Any:
"""Queue the confirmed shutdown-function archive sync endpoint."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
return self.client.post("tcm.diagnosis/triggerImChatSync", {"diagnosis_id": diagnosis_id})
def list_diagnosis_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]:
"""Return registration/cancellation logs from ``guahaoLogList``."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
return _mapping_rows(self.client.get("tcm.diagnosis/guahaoLogList", {"id": diagnosis_id}))
def generate_mini_program_qrcode(
self, payload: Mapping[str, Any] | None = None, **fields: Any
) -> dict[str, Any]:
"""Generate either QR variant using the server's shared DTO."""
body = _body(payload, fields)
for key in ("patient_id", "share_user_id"):
if int(body.get(key) or 0) <= 0:
raise ValueError(f"{key} must be positive")
page = str(body.get("mini_program_path") or "").strip()
if page == "pages/login/login":
if int(body.get("doctor_id") or 0) <= 0:
raise ValueError("doctor_id must be positive for a video QR code")
# The current server validator mistakenly inspects diagnosis_id for this case;
# mirror the admin DTO until that server contract is fixed.
body.setdefault("diagnosis_id", body["doctor_id"])
elif int(body.get("diagnosis_id") or 0) <= 0:
raise ValueError("diagnosis_id must be positive")
payload_result = self.client.post("tcm.diagnosis/generateMiniProgramQrcode", body)
result = dict(_require_mapping(payload_result, "tcm.diagnosis/generateMiniProgramQrcode"))
if not str(result.get("qrcode_url") or "").strip():
raise ApiProtocolError(
"tcm.diagnosis/generateMiniProgramQrcode returned no qrcode_url",
data=result,
)
return result
def generate_video_qrcode(
self, doctor_id: int, patient_id: int, share_user_id: int
) -> dict[str, Any]:
"""Generate the video QR with the admin's confirmed login-page DTO."""
return self.generate_mini_program_qrcode(
diagnosis_id=doctor_id,
doctor_id=doctor_id,
patient_id=patient_id,
share_user_id=share_user_id,
mini_program_path="pages/login/login",
)
def generate_diagnosis_qrcode(
self, diagnosis_id: int, doctor_id: int, patient_id: int, share_user_id: int
) -> dict[str, Any]:
"""Generate the diagnosis-confirmation QR."""
return self.generate_mini_program_qrcode(
diagnosis_id=diagnosis_id,
doctor_id=doctor_id,
patient_id=patient_id,
share_user_id=share_user_id,
)
def list_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]:
"""Compatibility name used by the diagnosis-list menu."""
return self.list_diagnosis_appointment_logs(diagnosis_id)
def create_diagnosis_order(
self,
patient_id: int,
order_type: int,
amount: float,
*,
remark: str = "",
) -> dict[str, Any]:
"""Create the generic order used by the diagnosis-list action."""
if patient_id <= 0:
raise ValueError("patient_id must be positive")
if order_type not in range(1, 9):
raise ValueError("order_type must be between 1 and 8")
if amount <= 0:
raise ValueError("amount must be positive")
payload = self.client.post(
"order.order/create",
{
"patient_id": patient_id,
"order_type": order_type,
"amount": round(float(amount), 2),
"remark": remark.strip(),
},
)
return dict(_require_mapping(payload, "order.order/create"))
def generate_order_qrcode(self, order_no: str) -> dict[str, Any]:
"""Generate the QR shown immediately after a generic order is created."""
clean_order_no = order_no.strip()
if not clean_order_no:
raise ValueError("order_no is required")
payload = self.client.post(
"tcm.diagnosis/generateOrderQrcode", {"order_no": clean_order_no}
)
result = dict(_require_mapping(payload, "tcm.diagnosis/generateOrderQrcode"))
if not str(result.get("qrcode_url") or "").strip():
raise ApiProtocolError(
"tcm.diagnosis/generateOrderQrcode returned no qrcode_url", data=result
)
return result
def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> CallTicket:
"""Obtain short-lived Tencent credentials for a consultation call."""
@@ -1767,15 +2140,17 @@ def _require_mapping(value: object, endpoint: str) -> Mapping[str, Any]:
def _material_kind(
material_type: str,
) -> Literal["image", "file"]:
"""Map reception concepts to the two audited upload endpoints."""
) -> Literal["image", "video", "file"]:
"""Map workstation concepts to the three audited upload endpoints."""
value = material_type.strip().lower()
if value in {"image", "tongue_images"}:
return "image"
if value == "video":
return "video"
if value in {"file", "report_files"}:
return "file"
raise ValueError("material_type must be image/file or tongue_images/report_files")
raise ValueError("material_type must be image/video/file or tongue_images/report_files")
def _is_local_material_reference(value: str) -> bool:
@@ -1910,6 +2285,111 @@ def _body(
return result
_RECORD_DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_RECORD_TIME_PATTERN = re.compile(r"^(?:[01]\d|2[0-3]):[0-5]\d$")
def _daily_record_body(
kind: Literal["blood", "diet", "exercise"],
payload: Mapping[str, Any] | None,
fields: Mapping[str, Any],
) -> dict[str, Any]:
"""Validate the exact DTOs used by the admin ``DailyMatrix`` dialogs."""
body = _body(payload, fields)
for key in ("diagnosis_id", "patient_id"):
value = _to_int(body.get(key), 0)
if value <= 0:
raise ValueError(f"{key} must be positive")
body[key] = value
record_date = str(body.get("record_date") or "").strip()
if not _RECORD_DATE_PATTERN.fullmatch(record_date):
raise ValueError("record_date must use YYYY-MM-DD")
parsed_date = date.fromisoformat(record_date)
if parsed_date > date.today():
raise ValueError("record_date cannot be in the future")
body["record_date"] = record_date
if kind == "blood":
record_time = str(body.get("record_time") or "").strip()
if record_time and not _RECORD_TIME_PATTERN.fullmatch(record_time):
raise ValueError("record_time must use HH:MM")
body["record_time"] = record_time
limits = {
"fasting_blood_sugar": 50.0,
"postprandial_blood_sugar": 50.0,
"other_blood_sugar": 50.0,
"systolic_pressure": 300.0,
"diastolic_pressure": 200.0,
}
has_content = False
for key, maximum in limits.items():
raw = body.get(key)
if raw in (None, ""):
continue
try:
number = float(raw)
except (TypeError, ValueError) as exc:
raise ValueError(f"{key} must be numeric") from exc
if number < 0 or number > maximum:
raise ValueError(f"{key} must be between 0 and {maximum:g}")
body[key] = number
has_content = has_content or number > 0
for key in ("western_medicine", "insulin", "remark"):
clean = str(body.get(key) or "").strip()
body[key] = clean
has_content = has_content or bool(clean)
if not has_content:
raise ValueError("a blood record must contain at least one measurement or note")
return body
if kind == "diet":
has_content = False
for meal in ("breakfast", "lunch", "dinner"):
foods_key = f"{meal}_foods"
foods = str(body.get(foods_key) or body.get(meal) or "").strip()
body[foods_key] = foods
has_content = has_content or bool(foods)
images_key = f"{meal}_images"
raw_images = body.get(images_key) or []
if not isinstance(raw_images, (list, tuple)):
raise ValueError(f"{images_key} must be an array")
if len(raw_images) > 3:
raise ValueError(f"{images_key} cannot contain more than 3 images")
body[images_key] = _server_materials(raw_images, images_key)
has_content = has_content or bool(body[images_key])
body["note"] = str(body.get("note") or "").strip()
has_content = has_content or bool(body["note"])
if not has_content:
raise ValueError("a diet record must contain food, an image or a note")
return body
exercise_type = str(body.get("exercise_type") or "").strip()
if not exercise_type:
raise ValueError("exercise_type is required")
duration = _to_int(body.get("duration"), 0)
if duration < 1 or duration > 300:
raise ValueError("duration must be between 1 and 300 minutes")
intensity = _to_int(body.get("intensity"), 0)
if intensity not in {1, 2, 3}:
raise ValueError("intensity must be 1, 2 or 3")
raw_images = body.get("images") or []
if not isinstance(raw_images, (list, tuple)):
raise ValueError("images must be an array")
if len(raw_images) > 3:
raise ValueError("images cannot contain more than 3 items")
body.update(
{
"exercise_type": exercise_type,
"duration": duration,
"intensity": intensity,
"images": _server_materials(raw_images, "images"),
"note": str(body.get("note") or "").strip(),
}
)
return body
def _identified_body(
value: int | Mapping[str, Any],
changes: Mapping[str, Any] | None,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,403 @@
"""Focused editors and responsive layouts used by diagnosis detail views."""
from __future__ import annotations
from collections.abc import Mapping
from contextlib import suppress
from typing import Any
from PySide6.QtCore import QDate, QRect, QSize, Qt, QTime
from PySide6.QtWidgets import (
QComboBox,
QDateEdit,
QDialog,
QDoubleSpinBox,
QFormLayout,
QHBoxLayout,
QLabel,
QLayout,
QLayoutItem,
QLineEdit,
QPlainTextEdit,
QPushButton,
QScrollArea,
QSpinBox,
QTimeEdit,
QVBoxLayout,
QWidget,
)
class FlowLayout(QLayout):
"""A small Qt flow layout that wraps visible widgets at the available width."""
def __init__(
self,
parent: QWidget | None = None,
*,
horizontal_spacing: int = 8,
vertical_spacing: int = 8,
) -> None:
super().__init__(parent)
self._items: list[QLayoutItem] = []
self._horizontal_spacing = horizontal_spacing
self._vertical_spacing = vertical_spacing
self.setContentsMargins(0, 0, 0, 0)
def addItem(self, item: QLayoutItem) -> None: # noqa: N802 - Qt virtual
self._items.append(item)
def count(self) -> int:
return len(self._items)
def itemAt(self, index: int) -> QLayoutItem | None: # noqa: N802 - Qt virtual
return self._items[index] if 0 <= index < len(self._items) else None
def takeAt(self, index: int) -> QLayoutItem | None: # noqa: N802 - Qt virtual
return self._items.pop(index) if 0 <= index < len(self._items) else None
def expandingDirections(self) -> Qt.Orientations: # noqa: N802 - Qt virtual
return Qt.Orientation(0)
def hasHeightForWidth(self) -> bool: # noqa: N802 - Qt virtual
return True
def heightForWidth(self, width: int) -> int: # noqa: N802 - Qt virtual
return self._do_layout(QRect(0, 0, max(0, width), 0), test_only=True)
def setGeometry(self, rect: QRect) -> None: # noqa: N802 - Qt virtual
super().setGeometry(rect)
self._do_layout(rect, test_only=False)
def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual
return self.minimumSize()
def minimumSize(self) -> QSize: # noqa: N802 - Qt virtual
size = QSize()
for item in self._visible_items():
size = size.expandedTo(item.minimumSize())
margins = self.contentsMargins()
size += QSize(margins.left() + margins.right(), margins.top() + margins.bottom())
return size
def _visible_items(self) -> list[QLayoutItem]:
return [
item for item in self._items if item.widget() is None or not item.widget().isHidden()
]
def _do_layout(self, rect: QRect, *, test_only: bool) -> int:
margins = self.contentsMargins()
effective = rect.adjusted(
margins.left(), margins.top(), -margins.right(), -margins.bottom()
)
x = effective.x()
y = effective.y()
line_height = 0
for item in self._visible_items():
hint = item.sizeHint().expandedTo(item.minimumSize())
next_x = x + hint.width()
if line_height and next_x > effective.right() + 1:
x = effective.x()
y += line_height + self._vertical_spacing
next_x = x + hint.width()
line_height = 0
if not test_only:
item.setGeometry(QRect(x, y, hint.width(), hint.height()))
x = next_x + self._horizontal_spacing
line_height = max(line_height, hint.height())
return y + line_height - rect.y() + margins.bottom()
def _mapping(value: Any) -> dict[str, Any]:
if isinstance(value, Mapping):
return dict(value)
with suppress(TypeError):
return dict(vars(value))
return {}
def _value(source: Mapping[str, Any], *keys: str, default: Any = "") -> Any:
for key in keys:
value = source.get(key)
if value not in (None, ""):
return value
return default
class DailyRecordEditorDialog(QDialog):
"""Create/edit the exact blood, diet, and exercise DTOs used by admin."""
_LABELS = {"blood": "血糖 / 血压", "diet": "饮食", "exercise": "运动"}
def __init__(
self,
kind: str,
record: Any,
*,
parent: QWidget | None = None,
) -> None:
if kind not in self._LABELS:
raise ValueError(f"unsupported daily record kind: {kind}")
super().__init__(parent)
self.kind = kind
self.source = _mapping(record)
self.record_id = _positive_int(self.source.get("id"))
self.setObjectName("DiagnosisDailyEditor")
self.setModal(True)
self.setWindowTitle(f"{'编辑' if self.record_id else '新增'}{self._LABELS[kind]}记录")
self.resize(650, 620 if kind == "blood" else 590)
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18)
root.setSpacing(14)
heading = QLabel(self.windowTitle())
heading.setObjectName("DiagnosisDialogHeading")
root.addWidget(heading)
guidance = QLabel("保存后将重新加载当前日期范围;带 * 的字段为必填项。")
guidance.setObjectName("DiagnosisDialogGuidance")
guidance.setWordWrap(True)
root.addWidget(guidance)
scroll = QScrollArea()
scroll.setObjectName("DiagnosisEditorScroll")
scroll.setWidgetResizable(True)
content = QWidget()
self.form = QFormLayout(content)
self.form.setContentsMargins(8, 8, 12, 8)
self.form.setHorizontalSpacing(18)
self.form.setVerticalSpacing(11)
scroll.setWidget(content)
root.addWidget(scroll, 1)
self.date_edit = QDateEdit()
self.date_edit.setCalendarPopup(True)
self.date_edit.setDisplayFormat("yyyy-MM-dd")
self.date_edit.setMaximumDate(QDate.currentDate())
date = QDate.fromString(str(self.source.get("record_date") or ""), "yyyy-MM-dd")
self.date_edit.setDate(date if date.isValid() else QDate.currentDate())
self._field(self.date_edit)
self.form.addRow("记录日期 *", self.date_edit)
if kind == "blood":
self._build_blood()
elif kind == "diet":
self._build_diet()
else:
self._build_exercise()
self.error_label = QLabel()
self.error_label.setObjectName("DiagnosisEditorError")
self.error_label.setWordWrap(True)
self.error_label.hide()
root.addWidget(self.error_label)
actions = QHBoxLayout()
actions.addStretch(1)
cancel = QPushButton("取消")
cancel.setProperty("variant", "ghost")
cancel.clicked.connect(self.reject)
actions.addWidget(cancel)
save = QPushButton("保存记录")
save.setObjectName("DiagnosisDailyEditorSave")
save.setProperty("variant", "primary")
save.setDefault(True)
save.clicked.connect(self.accept)
actions.addWidget(save)
root.addLayout(actions)
@staticmethod
def _field(widget: QWidget) -> QWidget:
widget.setProperty("diagnosisField", True)
return widget
def _double(self, *keys: str, maximum: float = 50.0) -> QDoubleSpinBox:
editor = QDoubleSpinBox()
editor.setRange(0.0, maximum)
editor.setDecimals(2)
editor.setSingleStep(0.1)
editor.setSpecialValueText("未填写")
with suppress(TypeError, ValueError):
editor.setValue(float(_value(self.source, *keys, default=0) or 0))
self._field(editor)
return editor
def _integer(self, *keys: str, maximum: int) -> QSpinBox:
editor = QSpinBox()
editor.setRange(0, maximum)
editor.setSpecialValueText("未填写")
editor.setValue(_positive_int(_value(self.source, *keys, default=0), allow_zero=True))
self._field(editor)
return editor
def _line(self, *keys: str) -> QLineEdit:
editor = QLineEdit(str(_value(self.source, *keys, default="") or ""))
editor.setMaxLength(500)
self._field(editor)
return editor
def _plain(self, *keys: str, height: int = 72) -> QPlainTextEdit:
editor = QPlainTextEdit(str(_value(self.source, *keys, default="") or ""))
editor.setMaximumHeight(height)
self._field(editor)
return editor
def _build_blood(self) -> None:
self.time_edit = QTimeEdit()
self.time_edit.setDisplayFormat("HH:mm")
parsed = QTime.fromString(str(self.source.get("record_time") or ""), "HH:mm")
self.time_edit.setTime(parsed if parsed.isValid() else QTime.currentTime())
self._field(self.time_edit)
self.form.addRow("记录时间", self.time_edit)
self.fasting = self._double("fasting_blood_sugar", "fasting_glucose")
self.postprandial = self._double("postprandial_blood_sugar", "postprandial_glucose")
self.other = self._double("other_blood_sugar", "other_glucose")
self.systolic = self._integer("systolic_pressure", "systolic", maximum=300)
self.diastolic = self._integer("diastolic_pressure", "diastolic", maximum=200)
self.western = self._line("western_medicine")
self.insulin = self._line("insulin")
self.remark = self._plain("remark", height=82)
self.form.addRow("空腹血糖(mmol/L", self.fasting)
self.form.addRow("餐后 2 小时血糖(mmol/L", self.postprandial)
self.form.addRow("其他血糖(mmol/L", self.other)
self.form.addRow("收缩压(mmHg", self.systolic)
self.form.addRow("舒张压(mmHg", self.diastolic)
self.form.addRow("西药", self.western)
self.form.addRow("胰岛素", self.insulin)
self.form.addRow("备注", self.remark)
def _build_diet(self) -> None:
self.breakfast = self._plain("breakfast_foods", "breakfast")
self.lunch = self._plain("lunch_foods", "lunch")
self.dinner = self._plain("dinner_foods", "dinner")
self.note = self._plain("note", height=82)
self.form.addRow("早餐", self.breakfast)
self.form.addRow("午餐", self.lunch)
self.form.addRow("晚餐", self.dinner)
self.form.addRow("备注", self.note)
preserved = QLabel("已有餐食图片会原样保留;图片增删仍由服务端素材入口处理。")
preserved.setObjectName("DiagnosisDialogGuidance")
preserved.setWordWrap(True)
self.form.addRow("图片", preserved)
def _build_exercise(self) -> None:
self.exercise_type = QComboBox()
self.exercise_type.addItem("请选择运动类型", "")
for value in ("散步", "慢跑", "游泳", "瑜伽", "骑自行车", "打太极拳", "其他"):
self.exercise_type.addItem(value, value)
current_type = str(self.source.get("exercise_type") or "")
if current_type and self.exercise_type.findData(current_type) < 0:
self.exercise_type.addItem(current_type, current_type)
if current_type:
self.exercise_type.setCurrentIndex(self.exercise_type.findData(current_type))
self._field(self.exercise_type)
self.duration = self._integer("duration", maximum=300)
self.intensity = QComboBox()
for label, value in (("低强度", 1), ("中强度", 2), ("高强度", 3)):
self.intensity.addItem(label, value)
requested = _positive_int(self.source.get("intensity"), allow_zero=True) or 1
self.intensity.setCurrentIndex(max(0, self.intensity.findData(requested)))
self._field(self.intensity)
self.note = self._plain("note", height=90)
self.form.addRow("运动类型 *", self.exercise_type)
self.form.addRow("运动时长(分钟)*", self.duration)
self.form.addRow("运动强度 *", self.intensity)
self.form.addRow("备注", self.note)
preserved = QLabel("已有运动图片会原样保留。")
preserved.setObjectName("DiagnosisDialogGuidance")
self.form.addRow("图片", preserved)
def payload(self) -> dict[str, Any]:
payload = dict(self.source)
payload["record_date"] = self.date_edit.date().toString("yyyy-MM-dd")
if self.kind == "blood":
payload.update(
{
"record_time": self.time_edit.time().toString("HH:mm"),
"fasting_blood_sugar": self.fasting.value(),
"postprandial_blood_sugar": self.postprandial.value(),
"other_blood_sugar": self.other.value(),
"systolic_pressure": self.systolic.value(),
"diastolic_pressure": self.diastolic.value(),
"western_medicine": self.western.text().strip(),
"insulin": self.insulin.text().strip(),
"remark": self.remark.toPlainText().strip(),
}
)
elif self.kind == "diet":
payload.update(
{
"breakfast_foods": self.breakfast.toPlainText().strip(),
"lunch_foods": self.lunch.toPlainText().strip(),
"dinner_foods": self.dinner.toPlainText().strip(),
"note": self.note.toPlainText().strip(),
"breakfast_images": payload.get("breakfast_images") or [],
"lunch_images": payload.get("lunch_images") or [],
"dinner_images": payload.get("dinner_images") or [],
}
)
else:
payload.update(
{
"exercise_type": str(self.exercise_type.currentData() or "").strip(),
"duration": self.duration.value(),
"intensity": int(self.intensity.currentData() or 1),
"images": payload.get("images") or [],
"note": self.note.toPlainText().strip(),
}
)
return payload
def validation_error(self) -> str:
if not self.date_edit.date().isValid():
return "请选择记录日期。"
if self.date_edit.date() > QDate.currentDate():
return "记录日期不能晚于今天。"
payload = self.payload()
if self.kind == "blood":
measurements = (
payload["fasting_blood_sugar"],
payload["postprandial_blood_sugar"],
payload["other_blood_sugar"],
payload["systolic_pressure"],
payload["diastolic_pressure"],
)
if not any(float(value or 0) > 0 for value in measurements) and not any(
payload[key] for key in ("western_medicine", "insulin", "remark")
):
return "请至少填写一项血糖、血压、用药或备注。"
elif self.kind == "diet":
if not any(
payload[key]
for key in (
"breakfast_foods",
"lunch_foods",
"dinner_foods",
"breakfast_images",
"lunch_images",
"dinner_images",
"note",
)
):
return "请至少填写一餐、图片或备注。"
elif not payload["exercise_type"] or int(payload["duration"] or 0) <= 0:
return "请选择运动类型并填写大于 0 的运动时长。"
return ""
def accept(self) -> None:
error = self.validation_error()
if error:
self.error_label.setText(error)
self.error_label.show()
return
self.error_label.hide()
super().accept()
def _positive_int(value: Any, *, allow_zero: bool = False) -> int:
try:
parsed = int(value or 0)
except (TypeError, ValueError):
return 0
return parsed if parsed > 0 or (allow_zero and parsed == 0) else 0
__all__ = ["DailyRecordEditorDialog", "FlowLayout"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,628 @@
"""Safe in-application replay players for diagnosis call recordings.
The web diagnosis page keeps the preferred recording in the table and lists
the remaining sources underneath it. This module mirrors that contract with
QtMultimedia while retaining the older standalone dialog as a codec fallback.
"""
from __future__ import annotations
import re
import weakref
from collections.abc import Sequence
from typing import Any
from PySide6.QtCore import Qt, QUrl, Signal
from PySide6.QtGui import QCloseEvent, QDesktopServices
from PySide6.QtWidgets import (
QDialog,
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QSlider,
QStackedLayout,
QVBoxLayout,
QWidget,
)
try:
from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer
from PySide6.QtMultimediaWidgets import QVideoWidget
MULTIMEDIA_AVAILABLE = True
except ImportError: # pragma: no cover - exercised only by minimal PySide builds
QAudioOutput = QMediaPlayer = QVideoWidget = None # type: ignore[assignment,misc]
MULTIMEDIA_AVAILABLE = False
def safe_http_url(target: str) -> QUrl | None:
url = QUrl(str(target).strip())
if not url.isValid() or url.scheme().lower() not in {"http", "https"}:
return None
if not url.host():
return None
return url
def normalize_recording_urls(urls: Sequence[Any] | Any) -> list[str]:
"""Trim and de-duplicate backend recording URLs without inventing values."""
if isinstance(urls, (str, bytes, bytearray)) or not isinstance(urls, Sequence):
candidates: Sequence[Any] = [urls]
else:
candidates = urls
normalized: list[str] = []
for candidate in candidates:
value = str(candidate or "").strip()
if value and value not in normalized:
normalized.append(value)
return normalized
_INLINE_RECORDING_PATTERN = re.compile(
r"\.(?:mp4|webm|ogg|mov|mkv|m4v|m3u8)(?:\?|#|$)", re.IGNORECASE
)
def should_inline_recording(target: str) -> bool:
"""Return whether a safe remote source is suitable for native inline replay."""
return safe_http_url(target) is not None and bool(
_INLINE_RECORDING_PATTERN.search(str(target).strip())
)
def preferred_recording_url(urls: Sequence[Any] | Any) -> str | None:
"""Choose the same preferred source order as the diagnosis web client."""
normalized = [url for url in normalize_recording_urls(urls) if safe_http_url(url)]
if not normalized:
return None
priorities = (
lambda url: bool(re.search(r"\.mp4(?:\?|#|$)", url, re.IGNORECASE)),
lambda url: bool(
re.search(r"\.m3u8(?:\?|#|$)", url, re.IGNORECASE)
and re.search(r"vod-qcloud\.com", url, re.IGNORECASE)
),
lambda url: bool(
re.search(r"\.m3u8(?:\?|#|$)", url, re.IGNORECASE)
and re.search(r"\.cos\.[^/]+\.myqcloud\.com", url, re.IGNORECASE)
),
lambda url: bool(re.search(r"\.m3u8(?:\?|#|$)", url, re.IGNORECASE)),
)
for matches in priorities:
preferred = next((url for url in normalized if matches(url)), None)
if preferred:
return preferred
return normalized[0]
def alternate_recording_label(target: str, index: int) -> str:
"""Return the source label used by the web playback block."""
value = str(target or "").strip()
ordinal = index + 1
if re.search(r"\.mp4(?:\?|#|$)", value, re.IGNORECASE):
return f"MP4 {ordinal}"
if re.search(r"vod-qcloud\.com", value, re.IGNORECASE) and re.search(
r"\.m3u8", value, re.IGNORECASE
):
return f"点播 {ordinal}"
if re.search(r"\.cos\.[^/]+\.myqcloud\.com", value, re.IGNORECASE) and re.search(
r"\.m3u8", value, re.IGNORECASE
):
return f"COS HLS {ordinal}"
if re.search(r"\.m3u8", value, re.IGNORECASE):
return f"HLS {ordinal}"
return f"链接 {ordinal}"
def open_safe_http_url(target: str) -> bool:
"""Open only an absolute HTTP(S) target with the operating system."""
url = safe_http_url(target)
return bool(url is not None and QDesktopServices.openUrl(url))
_INLINE_PLAYER_QSS = """
QWidget#DiagnosisInlineRecordingPlayer {
background: #111827;
border: 1px solid #1F2937;
border-radius: 6px;
}
QFrame#DiagnosisInlineRecordingSurface {
background: #05070A;
border: 0;
border-radius: 5px 5px 0 0;
}
QLabel#DiagnosisInlineRecordingPlaceholder {
color: #D1D5DB;
font-size: 12px;
line-height: 1.4;
}
QLabel#DiagnosisInlineRecordingTime {
color: #CBD5E1;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
QPushButton[recordingControl="true"] {
min-height: 24px;
max-height: 24px;
padding: 0 8px;
color: #E5E7EB;
background: #1F2937;
border: 1px solid #374151;
border-radius: 4px;
font-size: 11px;
}
QPushButton[recordingControl="true"]:hover,
QPushButton[recordingControl="true"]:focus {
color: #FFFFFF;
background: #2563EB;
border-color: #2563EB;
}
QPushButton[recordingControl="true"]:disabled { color: #6B7280; }
QSlider::groove:horizontal { height: 3px; background: #4B5563; border-radius: 1px; }
QSlider::sub-page:horizontal { background: #3B82F6; border-radius: 1px; }
QSlider::handle:horizontal {
width: 10px;
margin: -4px 0;
background: #F8FAFC;
border: 1px solid #94A3B8;
border-radius: 5px;
}
"""
class InlineRecordingPlayer(QWidget):
"""A lazy, owner-safe QtMultimedia player capped at 180 px."""
fallback_requested = Signal(str)
def __init__(
self,
target: str,
*,
render_owner: object | None = None,
owner_generation: int | None = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.target = str(target).strip()
self.url = safe_http_url(self.target)
self._owner_ref = weakref.ref(render_owner) if render_owner is not None else None
self._owner_generation = owner_generation
self._source_attached = False
self.player = None
self.audio_output = None
self.setObjectName("DiagnosisInlineRecordingPlayer")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setProperty("maximumPlaybackHeight", 180)
self.setMinimumHeight(158)
self.setMaximumHeight(180)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.setStyleSheet(_INLINE_PLAYER_QSS)
root = QVBoxLayout(self)
root.setContentsMargins(1, 1, 1, 5)
root.setSpacing(4)
surface = QFrame()
surface.setObjectName("DiagnosisInlineRecordingSurface")
surface.setMinimumHeight(122)
surface.setMaximumHeight(142)
self._surface_stack = QStackedLayout(surface)
self._surface_stack.setContentsMargins(0, 0, 0, 0)
self._surface_stack.setStackingMode(QStackedLayout.StackingMode.StackOne)
if MULTIMEDIA_AVAILABLE:
self.video = QVideoWidget()
self.video.setObjectName("DiagnosisInlineRecordingVideo")
self._surface_stack.addWidget(self.video)
self.audio_output = QAudioOutput(self)
self.player = QMediaPlayer(self)
self.player.setAudioOutput(self.audio_output)
self.player.setVideoOutput(self.video)
self.player.positionChanged.connect(self._position_changed)
self.player.durationChanged.connect(self._duration_changed)
self.player.playbackStateChanged.connect(self._state_changed)
self.player.errorOccurred.connect(self._player_error)
else:
self.video = None
self.placeholder = QLabel(
"预览待加载\n点击播放开始加载"
if MULTIMEDIA_AVAILABLE
else "当前环境未包含 QtMultimedia\n可安全外部打开"
)
self.placeholder.setObjectName("DiagnosisInlineRecordingPlaceholder")
self.placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.placeholder.setWordWrap(True)
self._surface_stack.addWidget(self.placeholder)
self._surface_stack.setCurrentWidget(self.placeholder)
root.addWidget(surface, 1)
controls = QHBoxLayout()
controls.setContentsMargins(5, 0, 5, 0)
controls.setSpacing(5)
self.play_button = QPushButton("播放")
self.play_button.setObjectName("DiagnosisInlineRecordingPlay")
self.play_button.setProperty("recordingControl", True)
self.play_button.clicked.connect(self._toggle_playback)
controls.addWidget(self.play_button)
self.position = QSlider(Qt.Orientation.Horizontal)
self.position.setObjectName("DiagnosisInlineRecordingPosition")
self.position.setRange(0, 0)
self.position.sliderMoved.connect(self._seek)
controls.addWidget(self.position, 1)
self.time_label = QLabel("00:00 / 00:00")
self.time_label.setObjectName("DiagnosisInlineRecordingTime")
controls.addWidget(self.time_label)
external = QPushButton("外部打开")
external.setObjectName("DiagnosisInlineRecordingExternal")
external.setProperty("recordingControl", True)
external.setToolTip("使用系统默认程序安全打开此 HTTP(S) 回放地址")
external.clicked.connect(self._open_external)
controls.addWidget(external)
fallback = QPushButton("独立窗口")
fallback.setObjectName("DiagnosisInlineRecordingFallback")
fallback.setProperty("recordingControl", True)
fallback.setToolTip("系统解码器兼容性不佳时在独立播放器中重试")
fallback.clicked.connect(lambda: self.fallback_requested.emit(self.target))
controls.addWidget(fallback)
root.addLayout(controls)
valid = self.url is not None
self.play_button.setEnabled(valid and self.player is not None)
external.setEnabled(valid)
fallback.setEnabled(valid)
if not valid:
self.placeholder.setText("回放地址无效\n仅允许包含主机名的 HTTP(S) 地址")
def _owner_is_current(self) -> bool:
if self._owner_ref is None or self._owner_generation is None:
return True
owner = self._owner_ref()
if owner is None:
return False
generations = getattr(owner, "_tab_generations", None)
if isinstance(generations, dict) and "video" in generations:
return generations["video"] == self._owner_generation
current = getattr(owner, "_media_generation", self._owner_generation)
return current == self._owner_generation
def _attach_source(self) -> bool:
if self.player is None or self.url is None:
return False
if not self._owner_is_current():
self.play_button.setEnabled(False)
self.placeholder.setText("该回放列表已刷新\n请在最新记录中播放")
self._surface_stack.setCurrentWidget(self.placeholder)
return False
if not self._source_attached:
self.player.setSource(self.url)
self._source_attached = True
self.placeholder.setText("正在加载回放…")
return True
def _toggle_playback(self) -> None:
if not self._attach_source() or self.player is None:
return
if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
self.player.pause()
else:
self.player.play()
def _state_changed(self, state: object) -> None:
if self.player is None:
return
playing = state == QMediaPlayer.PlaybackState.PlayingState
self.play_button.setText("暂停" if playing else "播放")
if playing and self.video is not None:
self._surface_stack.setCurrentWidget(self.video)
def _player_error(self, _error: object, message: str = "") -> None:
detail = message.strip() if message else "系统媒体后端无法播放该格式"
self.placeholder.setText(f"{detail}\n可安全外部打开或使用独立窗口")
self._surface_stack.setCurrentWidget(self.placeholder)
def _duration_changed(self, duration: int) -> None:
self.position.setRange(0, max(0, duration))
current = self.player.position() if self.player is not None else 0
self.time_label.setText(f"{_clock(current)} / {_clock(duration)}")
def _position_changed(self, position: int) -> None:
if not self.position.isSliderDown():
self.position.setValue(position)
duration = self.player.duration() if self.player is not None else 0
self.time_label.setText(f"{_clock(position)} / {_clock(duration)}")
def _seek(self, position: int) -> None:
if self.player is not None and self._owner_is_current():
self.player.setPosition(position)
def _open_external(self) -> None:
if not open_safe_http_url(self.target):
self.placeholder.setText("系统未能打开该安全外部链接")
self._surface_stack.setCurrentWidget(self.placeholder)
def stop(self) -> None:
if self.player is not None:
self.player.stop()
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt virtual
self.stop()
super().closeEvent(event)
class RecordingPlaybackCell(QWidget):
"""One call-record playback cell: preferred inline, alternates underneath."""
fallback_requested = Signal(str)
def __init__(
self,
urls: Sequence[Any] | Any,
*,
record_id: int,
render_owner: object | None = None,
owner_generation: int | None = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("DiagnosisRecordingPlaybackCell")
self.setProperty("callRecordId", record_id)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.urls = normalize_recording_urls(urls)
self.preferred = preferred_recording_url(self.urls)
self.inline_player: InlineRecordingPlayer | None = None
layout = QVBoxLayout(self)
layout.setContentsMargins(5, 5, 5, 5)
layout.setSpacing(6)
if self.preferred and should_inline_recording(self.preferred):
self.inline_player = InlineRecordingPlayer(
self.preferred,
render_owner=render_owner,
owner_generation=owner_generation,
parent=self,
)
self.inline_player.fallback_requested.connect(self.fallback_requested.emit)
layout.addWidget(self.inline_player)
elif self.preferred:
primary = QPushButton("安全打开回放")
primary.setObjectName("DiagnosisRecordingPrimaryLink")
primary.setProperty("variant", "link")
primary.setToolTip(self.preferred)
primary.clicked.connect(
lambda _checked=False, target=self.preferred: self._open_external(target)
)
layout.addWidget(primary, 0, Qt.AlignmentFlag.AlignLeft)
elif self.urls:
invalid = QLabel("回放地址无效(仅支持 HTTP(S)")
invalid.setObjectName("DiagnosisUnsupportedState")
invalid.setWordWrap(True)
layout.addWidget(invalid)
else:
empty = QLabel("暂无录制回放")
empty.setObjectName("DiagnosisEmptyState")
layout.addWidget(empty)
alternates = [url for url in self.urls if url != self.preferred]
if alternates:
separator = QFrame()
separator.setObjectName("DiagnosisRecordingAlternateSeparator")
separator.setFrameShape(QFrame.Shape.HLine)
separator.setStyleSheet("color:#E5E7EB;")
layout.addWidget(separator)
label = QLabel("备用地址")
label.setObjectName("DiagnosisRecordingAlternateLabel")
label.setStyleSheet("color:#909399; font-size:12px;")
layout.addWidget(label)
links = QHBoxLayout()
links.setContentsMargins(0, 0, 0, 0)
links.setSpacing(10)
for index, target in enumerate(alternates):
button = QPushButton(alternate_recording_label(target, index))
button.setObjectName("DiagnosisRecordingAlternateLink")
button.setProperty("variant", "link")
button.setProperty("recordingUrl", target)
button.setToolTip(target)
button.setEnabled(safe_http_url(target) is not None)
button.clicked.connect(
lambda _checked=False, selected=target: self._open_external(selected)
)
links.addWidget(button)
links.addStretch(1)
layout.addLayout(links)
self.link_status = QLabel("")
self.link_status.setObjectName("DiagnosisRecordingLinkStatus")
self.link_status.setStyleSheet("color:#DC2626; font-size:11px;")
self.link_status.setWordWrap(True)
self.link_status.hide()
layout.addWidget(self.link_status)
def required_table_row_height(self) -> int:
"""Return the real height a table row needs to avoid clipping this cell."""
cell_layout = self.layout()
if cell_layout is not None:
cell_layout.activate()
# The diagnosis table style gives every item 7 px of vertical padding,
# and QTableWidget also reserves a grid line. Cell-widget geometry is
# inset by those pixels, so the section must include that allowance in
# addition to the player's own layout hint.
content_height = max(self.sizeHint().height(), self.minimumSizeHint().height())
return content_height + 16
def _open_external(self, target: str) -> None:
if open_safe_http_url(target):
self.link_status.hide()
return
self.link_status.setText("系统未能打开该安全外部链接。")
self.link_status.show()
def stop(self) -> None:
if self.inline_player is not None:
self.inline_player.stop()
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt virtual
self.stop()
super().closeEvent(event)
class RecordingPlayerDialog(QDialog):
"""Native, cross-platform replay window with a safe browser fallback."""
def __init__(self, target: str, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.target = str(target).strip()
self.url = safe_http_url(self.target)
self.setObjectName("DiagnosisRecordingPlayer")
self.setWindowTitle("视频回放")
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
self.resize(820, 560)
root = QVBoxLayout(self)
root.setContentsMargins(18, 16, 18, 16)
root.setSpacing(12)
heading = QLabel("视频回放")
heading.setObjectName("DiagnosisDialogHeading")
root.addWidget(heading)
self.status = QLabel("点击播放,在工作站内查看服务端回放。")
self.status.setObjectName("DiagnosisDialogGuidance")
self.status.setWordWrap(True)
root.addWidget(self.status)
self.player = None
self.audio_output = None
if MULTIMEDIA_AVAILABLE:
surface = QFrame()
surface.setObjectName("DiagnosisRecordingSurface")
surface.setMinimumHeight(360)
surface_stack = QStackedLayout(surface)
surface_stack.setContentsMargins(1, 1, 1, 1)
surface_stack.setStackingMode(QStackedLayout.StackingMode.StackOne)
self.video = QVideoWidget()
surface_stack.addWidget(self.video)
self.placeholder = QLabel("应用内播放器已就绪\n点击下方“播放”开始查看")
self.placeholder.setObjectName("DiagnosisRecordingPlaceholder")
self.placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
surface_stack.addWidget(self.placeholder)
surface_stack.setCurrentWidget(self.placeholder)
self._surface_stack = surface_stack
root.addWidget(surface, 1)
self.audio_output = QAudioOutput(self)
self.player = QMediaPlayer(self)
self.player.setAudioOutput(self.audio_output)
self.player.setVideoOutput(self.video)
self.player.positionChanged.connect(self._position_changed)
self.player.durationChanged.connect(self._duration_changed)
self.player.playbackStateChanged.connect(self._state_changed)
self.player.errorOccurred.connect(self._player_error)
if self.url is not None:
self.player.setSource(self.url)
else:
unavailable = QLabel("当前 PySide6 构建未包含 QtMultimedia,仍可安全外部打开。")
unavailable.setObjectName("DiagnosisUnsupportedState")
unavailable.setAlignment(Qt.AlignmentFlag.AlignCenter)
unavailable.setMinimumHeight(320)
root.addWidget(unavailable, 1)
controls = QHBoxLayout()
self.play_button = QPushButton("播放")
self.play_button.setProperty("variant", "primary")
self.play_button.clicked.connect(self._toggle_playback)
controls.addWidget(self.play_button)
self.position = QSlider(Qt.Orientation.Horizontal)
self.position.setRange(0, 0)
self.position.sliderMoved.connect(self._seek)
controls.addWidget(self.position, 1)
self.time_label = QLabel("00:00 / 00:00")
self.time_label.setObjectName("DiagnosisRecordingTime")
controls.addWidget(self.time_label)
external = QPushButton("安全外部打开")
external.setProperty("variant", "ghost")
external.clicked.connect(self._open_external)
controls.addWidget(external)
close = QPushButton("关闭")
close.setProperty("variant", "secondary")
close.clicked.connect(self.close)
controls.addWidget(close)
root.addLayout(controls)
valid = self.url is not None
self.play_button.setEnabled(valid and self.player is not None)
external.setEnabled(valid)
if not valid:
self.status.setText("回放地址无效:仅允许包含主机名的 HTTP(S) 地址。")
self.status.setProperty("kind", "danger")
def _toggle_playback(self) -> None:
if self.player is None or self.url is None:
return
if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
self.player.pause()
else:
self.player.play()
def _state_changed(self, state: object) -> None:
if self.player is None:
return
playing = state == QMediaPlayer.PlaybackState.PlayingState
self.play_button.setText("暂停" if playing else "播放")
if playing:
self._surface_stack.setCurrentWidget(self.video)
self.status.setText("正在应用内播放;如格式不受系统解码器支持,可安全外部打开。")
def _player_error(self, _error: object, message: str = "") -> None:
self.status.setText(
(message.strip() if message else "系统媒体后端无法播放该格式。")
+ " 可使用右侧按钮安全外部打开。"
)
self.status.setProperty("kind", "warning")
def _duration_changed(self, duration: int) -> None:
self.position.setRange(0, max(0, duration))
current = self.player.position() if self.player is not None else 0
self.time_label.setText(f"{_clock(current)} / {_clock(duration)}")
def _position_changed(self, position: int) -> None:
if not self.position.isSliderDown():
self.position.setValue(position)
duration = self.player.duration() if self.player is not None else 0
self.time_label.setText(f"{_clock(position)} / {_clock(duration)}")
def _seek(self, position: int) -> None:
if self.player is not None:
self.player.setPosition(position)
def _open_external(self) -> None:
if not open_safe_http_url(self.target):
self.status.setText("系统未能打开该安全外部链接。")
self.status.setProperty("kind", "danger")
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt virtual
if self.player is not None:
self.player.stop()
super().closeEvent(event)
def _clock(milliseconds: int) -> str:
seconds = max(0, int(milliseconds) // 1000)
return f"{seconds // 60:02d}:{seconds % 60:02d}"
__all__ = [
"MULTIMEDIA_AVAILABLE",
"InlineRecordingPlayer",
"RecordingPlaybackCell",
"RecordingPlayerDialog",
"alternate_recording_label",
"normalize_recording_urls",
"open_safe_http_url",
"preferred_recording_url",
"safe_http_url",
"should_inline_recording",
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -36,6 +36,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from ..appointment_drawer import AppointmentDrawer
from ..dialogs import DiagnosisDialog
from ..widgets import (
EmptyState,
@@ -216,7 +217,7 @@ def _remote_order_locked(row: Any) -> bool:
return claim_status in {"PENDING", "UNKNOWN", "PENDING_RECONCILE", "SUCCESS"}
class _AppointmentDialog(QDialog):
class _LegacyAppointmentDialog(QDialog):
"""Server-driven appointment form; arbitrary dates and slots are never accepted."""
CHANNEL_DETAIL_NAMES = {"自媒体4H", "自媒体3Q", "自媒体3H", "自媒体2H", "自媒体2Q"}
@@ -644,6 +645,26 @@ class _AppointmentDialog(QDialog):
super().accept()
class _AppointmentDialog(AppointmentDrawer):
"""Compatibility entry point for the page-scoped appointment drawer."""
def __init__(
self,
row: Any,
repository: Any = None,
parent: QWidget | None = None,
*,
autoload: bool = True,
) -> None:
super().__init__(
row,
repository,
parent,
autoload=autoload,
async_runner=run_async,
)
class _PaymentDialog(QDialog):
def __init__(self, row: Any, parent: QWidget | None = None) -> None:
super().__init__(parent)
+550 -77
View File
@@ -6,15 +6,20 @@ from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any
from PySide6.QtCore import Qt, Signal
from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPen, QPixmap, QPolygonF
from PySide6.QtWidgets import (
QButtonGroup,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QMenu,
QPushButton,
QSizePolicy,
QStackedWidget,
QTabBar,
QToolButton,
QVBoxLayout,
QWidget,
)
@@ -249,6 +254,199 @@ def _resolve_navigation(
return []
def _painted_shell_icon(kind: str, size: int = 18) -> QIcon:
"""Create a font-independent shell icon once, before widget painting."""
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
try:
color = QColor("#606266")
center_x = size / 2
center_y = size / 2
pen = QPen(color, 1.6)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
if kind in {"fold", "expand"}:
for offset in (-6.0, 0.0, 6.0):
painter.drawLine(
QPointF(center_x - 7.0, center_y + offset),
QPointF(center_x + 7.0, center_y + offset),
)
painter.setBrush(color)
painter.setPen(Qt.PenStyle.NoPen)
points = (
[
QPointF(center_x - 2.0, center_y - 3.5),
QPointF(center_x - 6.0, center_y),
QPointF(center_x - 2.0, center_y + 3.5),
]
if kind == "fold"
else [
QPointF(center_x + 2.0, center_y - 3.5),
QPointF(center_x + 6.0, center_y),
QPointF(center_x + 2.0, center_y + 3.5),
]
)
painter.drawPolygon(QPolygonF(points))
elif kind == "refresh":
painter.drawArc(QRectF(center_x - 7, center_y - 7, 14, 14), 42 * 16, 286 * 16)
painter.setBrush(color)
painter.setPen(Qt.PenStyle.NoPen)
painter.drawPolygon(
QPolygonF(
[
QPointF(center_x + 5.4, center_y - 7.2),
QPointF(center_x + 8.8, center_y - 6.4),
QPointF(center_x + 7.2, center_y - 3.2),
]
)
)
elif kind == "fullscreen":
corner = 6.5
inset = 7.0
painter.drawLine(
QPointF(center_x - inset, center_y - 2.0),
QPointF(center_x - inset, center_y - corner),
)
painter.drawLine(
QPointF(center_x - inset, center_y - corner),
QPointF(center_x - 2.0, center_y - corner),
)
painter.drawLine(
QPointF(center_x + 2.0, center_y - corner),
QPointF(center_x + inset, center_y - corner),
)
painter.drawLine(
QPointF(center_x + inset, center_y - corner),
QPointF(center_x + inset, center_y - 2.0),
)
painter.drawLine(
QPointF(center_x - inset, center_y + 2.0),
QPointF(center_x - inset, center_y + corner),
)
painter.drawLine(
QPointF(center_x - inset, center_y + corner),
QPointF(center_x - 2.0, center_y + corner),
)
painter.drawLine(
QPointF(center_x + 2.0, center_y + corner),
QPointF(center_x + inset, center_y + corner),
)
painter.drawLine(
QPointF(center_x + inset, center_y + corner),
QPointF(center_x + inset, center_y + 2.0),
)
elif kind == "down":
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(color)
painter.drawPolygon(
QPolygonF(
[
QPointF(center_x - 4.5, center_y - 2.0),
QPointF(center_x + 4.5, center_y - 2.0),
QPointF(center_x, center_y + 3.0),
]
)
)
elif kind == "close":
painter.drawLine(
QPointF(center_x - 3.5, center_y - 3.5),
QPointF(center_x + 3.5, center_y + 3.5),
)
painter.drawLine(
QPointF(center_x + 3.5, center_y - 3.5),
QPointF(center_x - 3.5, center_y + 3.5),
)
finally:
painter.end()
return QIcon(pixmap)
class _PaintedIconButton(QToolButton):
"""Font-independent shell icon used for every directional affordance."""
def __init__(
self,
kind: str,
*,
size: int = 40,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.kind = kind
icon_size = 12 if kind == "close" else 18
self.setObjectName("ShellPaintedIconButton")
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setAutoRaise(True)
self.setFixedSize(size, size)
self.setIcon(_painted_shell_icon(kind, icon_size))
self.setIconSize(QSize(icon_size, icon_size))
self.setStyleSheet(
"""
QToolButton#ShellPaintedIconButton { background: transparent; border: 0; }
QToolButton#ShellPaintedIconButton:hover { background: #F0F2F5; }
QToolButton#ShellPaintedIconButton:pressed { background: #E6E8EB; }
QToolButton#ShellPaintedIconButton::menu-indicator { image: none; width: 0; }
"""
)
class _UserMenuButton(QToolButton):
"""Compact admin-style user dropdown without font-glyph arrows."""
def __init__(self, display_name: str, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.display_name = display_name or "医生"
self.setObjectName("ShellUserMenu")
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
self.setFixedHeight(50)
name_width = self.fontMetrics().horizontalAdvance(self.display_name)
self.setFixedWidth(max(104, min(174, name_width + 72)))
self.setAccessibleName(f"用户菜单:{self.display_name}")
self.setText(self.display_name)
avatar = QPixmap(34, 34)
avatar.fill(Qt.GlobalColor.transparent)
painter = QPainter(avatar)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#EEF0FF"))
painter.drawEllipse(QRectF(0, 0, 34, 34))
painter.setPen(QColor("#4A5DFF"))
avatar_font = QFont(painter.font())
avatar_font.setWeight(QFont.Weight.DemiBold)
painter.setFont(avatar_font)
painter.drawText(QRectF(0, 0, 34, 34), Qt.AlignmentFlag.AlignCenter, self.display_name[:1])
painter.end()
self.setIcon(QIcon(avatar))
self.setIconSize(QSize(34, 34))
self.setStyleSheet(
"""
QToolButton#ShellUserMenu {
color: #303133;
background: transparent;
border: 0;
padding: 0 18px 0 7px;
font-size: 13px;
font-weight: 500;
}
QToolButton#ShellUserMenu:hover { background: #F0F2F5; }
QToolButton#ShellUserMenu:pressed { background: #E6E8EB; }
QToolButton#ShellUserMenu::menu-indicator {
subcontrol-origin: padding;
subcontrol-position: right center;
right: 7px;
}
"""
)
class ShellWindow(QMainWindow):
"""Main workstation window.
@@ -302,6 +500,8 @@ class ShellWindow(QMainWindow):
self.pages: dict[str, QWidget] = {}
self.nav_buttons: dict[str, QPushButton] = {}
self.page_titles: dict[int, str] = {}
self._fixed_tab_key: str | None = None
self._sidebar_collapsed = False
self.setWindowTitle("臻阳堂 · 医生工作站")
self.setMinimumSize(1024, 640)
@@ -313,87 +513,148 @@ class ShellWindow(QMainWindow):
root = QHBoxLayout(canvas)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
root.addWidget(self._build_sidebar())
self.sidebar = self._build_sidebar()
root.addWidget(self.sidebar)
workspace = QWidget()
workspace_layout = QVBoxLayout(workspace)
self.workspace = QWidget()
self.workspace.setObjectName("ShellWorkspace")
self.workspace.setStyleSheet("QWidget#ShellWorkspace { background-color: #F6F6F6; }")
workspace_layout = QVBoxLayout(self.workspace)
workspace_layout.setContentsMargins(0, 0, 0, 0)
workspace_layout.setSpacing(0)
workspace_layout.addWidget(self._build_topbar())
self.topbar = self._build_topbar()
workspace_layout.addWidget(self.topbar)
self.tabs_host = self._build_tab_strip()
workspace_layout.addWidget(self.tabs_host)
self.stack = QStackedWidget()
self.stack.setObjectName("ShellPageStack")
self.stack.setStyleSheet("QStackedWidget#ShellPageStack { background-color: #F6F6F6; }")
workspace_layout.addWidget(self.stack, 1)
root.addWidget(workspace, 1)
root.addWidget(self.workspace, 1)
self._register_pages()
def _build_sidebar(self) -> QWidget:
sidebar = QWidget()
sidebar.setObjectName("Sidebar")
sidebar.setFixedWidth(216)
layout = QVBoxLayout(sidebar)
layout.setContentsMargins(18, 22, 18, 18)
layout.setSpacing(8)
brand = QHBoxLayout()
mark = QLabel("")
mark.setAlignment(Qt.AlignmentFlag.AlignCenter)
mark.setFixedSize(38, 38)
mark.setStyleSheet(
"color:#0F6D64; background:#DDF1EC; border-radius:11px; font-size:18px; font-weight:700;"
sidebar.setFixedWidth(183)
sidebar.setStyleSheet(
"""
QWidget#Sidebar { background-color: #1D2124; border-right: 1px solid #25292D; }
QFrame#ShellBrand { background-color: #1D2124; border: 0; }
QLabel#ShellBrandMark {
color: #FFFFFF;
background-color: #4A5DFF;
border: 0;
border-radius: 7px;
font-size: 15px;
font-weight: 700;
}
QLabel#ShellBrandName { color: #FFFFFF; font-size: 14px; font-weight: 600; }
QPushButton#ShellNavButton {
min-height: 46px;
max-height: 46px;
padding: 0 16px;
border: 0;
border-radius: 0;
background-color: transparent;
color: #E5EAF3;
text-align: left;
font-size: 14px;
font-weight: 400;
}
QPushButton#ShellNavButton:hover { background-color: #292E32; color: #FFFFFF; }
QPushButton#ShellNavButton:pressed { background-color: #34393E; }
QPushButton#ShellNavButton:checked { background-color: #4A5DFF; color: #FFFFFF; }
QPushButton#ShellNavButton[collapsed="true"] { padding: 0; text-align: center; }
"""
)
brand.addWidget(mark)
brand_text = QVBoxLayout()
brand_text.setSpacing(0)
name = QLabel("医生工作站")
name.setStyleSheet("color:#FFFFFF; font-size:15px; font-weight:700;")
brand_text.addWidget(name)
institution = QLabel("臻阳堂医疗")
institution.setStyleSheet("color:#82A198; font-size:10px;")
brand_text.addWidget(institution)
brand.addLayout(brand_text)
brand.addStretch(1)
layout.addLayout(brand)
layout.addSpacing(28)
layout = QVBoxLayout(sidebar)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
brand = QFrame()
brand.setObjectName("ShellBrand")
brand.setFixedHeight(50)
brand_layout = QHBoxLayout(brand)
brand_layout.setContentsMargins(10, 0, 8, 0)
brand_layout.setSpacing(9)
self.brand_mark = QLabel("")
self.brand_mark.setObjectName("ShellBrandMark")
self.brand_mark.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.brand_mark.setFixedSize(30, 30)
brand_layout.addWidget(self.brand_mark)
self.brand_name = QLabel("医生工作站")
self.brand_name.setObjectName("ShellBrandName")
brand_layout.addWidget(self.brand_name, 1)
layout.addWidget(brand)
navigation_label = QLabel("工作区")
navigation_label.setStyleSheet("color:#78998F; font-size:10px; font-weight:700;")
layout.addWidget(navigation_label)
self.nav_layout = QVBoxLayout()
self.nav_layout.setSpacing(6)
self.nav_layout.setContentsMargins(0, 0, 0, 0)
self.nav_layout.setSpacing(0)
layout.addLayout(self.nav_layout)
layout.addStretch(1)
safety = QFrame()
safety.setStyleSheet("background:#20483D; border-radius:12px;")
safety_layout = QVBoxLayout(safety)
safety_layout.setContentsMargins(12, 11, 12, 11)
safety_layout.setSpacing(4)
safety_title = QLabel("● 安全连接")
safety_title.setStyleSheet("color:#B9DDD3; font-size:11px; font-weight:700;")
safety_layout.addWidget(safety_title)
safety_text = QLabel("医疗数据按账号权限展示")
safety_text.setWordWrap(True)
safety_text.setStyleSheet("color:#91ACA3; font-size:10px;")
safety_layout.addWidget(safety_text)
layout.addWidget(safety)
version = QLabel("Doctor Workstation")
version.setAlignment(Qt.AlignmentFlag.AlignCenter)
version.setStyleSheet("color:#617F76; font-size:9px;")
layout.addWidget(version)
return sidebar
def _build_topbar(self) -> QWidget:
topbar = QFrame()
topbar.setObjectName("TopBar")
topbar.setFixedHeight(68)
topbar.setFixedHeight(50)
topbar.setStyleSheet(
"""
QFrame#TopBar { background-color: #FFFFFF; border-bottom: 1px solid #E4E7ED; }
QLabel#ShellBreadcrumbHome { color: #909399; font-size: 13px; }
QLabel#ShellBreadcrumbSeparator { color: #C0C4CC; font-size: 13px; }
QLabel#ShellBreadcrumbCurrent { color: #303133; font-size: 13px; font-weight: 500; }
QLabel#ShellConnectionBadge {
color: #606266;
background-color: #F0F2F5;
border: 0;
border-radius: 3px;
padding: 3px 7px;
font-size: 11px;
font-weight: 500;
}
QLabel#ShellConnectionBadge[kind="success"] { color: #529B2E; background-color: #F0F9EB; }
QLabel#ShellConnectionBadge[kind="danger"] { color: #C45656; background-color: #FEF0F0; }
QMenu {
color: #303133;
background-color: #FFFFFF;
border: 1px solid #E4E7ED;
padding: 5px;
}
QMenu::item { min-width: 112px; min-height: 30px; padding: 0 12px; }
QMenu::item:selected { color: #4A5DFF; background-color: #EEF0FF; }
"""
)
layout = QHBoxLayout(topbar)
layout.setContentsMargins(24, 0, 22, 0)
layout.setSpacing(11)
layout.setContentsMargins(8, 0, 8, 0)
layout.setSpacing(0)
self.fold_button = _PaintedIconButton("fold")
self.fold_button.setToolTip("收起或展开菜单")
self.fold_button.setAccessibleName("收起或展开菜单")
self.fold_button.clicked.connect(self.toggle_sidebar)
layout.addWidget(self.fold_button)
self.refresh_button = _PaintedIconButton("refresh")
self.refresh_button.setToolTip("刷新当前页面")
self.refresh_button.setAccessibleName("刷新当前页面")
self.refresh_button.clicked.connect(self.refresh_current_page)
layout.addWidget(self.refresh_button)
breadcrumb_home = QLabel("首页")
breadcrumb_home.setObjectName("ShellBreadcrumbHome")
layout.addWidget(breadcrumb_home)
breadcrumb_separator = QLabel(" / ")
breadcrumb_separator.setObjectName("ShellBreadcrumbSeparator")
layout.addWidget(breadcrumb_separator)
self.context_label = QLabel("工作台")
self.context_label.setStyleSheet("color:#315147; font-size:14px; font-weight:600;")
self.context_label.setObjectName("ShellBreadcrumbCurrent")
layout.addWidget(self.context_label)
layout.addStretch(1)
self.connection_badge = StatusBadge("服务正常", "success")
self.connection_badge.setObjectName("ShellConnectionBadge")
layout.addWidget(self.connection_badge)
display_name = display_text(
@@ -401,27 +662,97 @@ class ShellWindow(QMainWindow):
self.current_user, "name", "display_name", "nickname", "account", default="医生"
)
)
avatar = QLabel(display_name[:1] if display_name else "")
avatar.setObjectName("UserAvatar")
avatar.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(avatar)
identity = QVBoxLayout()
identity.setSpacing(0)
user_name = QLabel(display_name)
user_name.setStyleSheet("font-weight:700; color:#17382F;")
identity.addWidget(user_name)
role = QLabel(self._role_text())
role.setProperty("role", "muted")
role.setStyleSheet("font-size:10px;")
identity.addWidget(role)
layout.addLayout(identity)
logout = QPushButton("退出")
logout.setProperty("variant", "ghost")
logout.setToolTip("退出当前账号")
logout.clicked.connect(lambda: self.logout_requested.emit())
layout.addWidget(logout)
self.fullscreen_button = _PaintedIconButton("fullscreen")
self.fullscreen_button.setToolTip("全屏模式")
self.fullscreen_button.setAccessibleName("切换全屏模式")
self.fullscreen_button.clicked.connect(self._toggle_fullscreen)
layout.addWidget(self.fullscreen_button)
self.user_menu_button = _UserMenuButton(display_name)
self.user_menu_button.setToolTip(f"{display_name} · {self._role_text()}")
user_menu = QMenu(self.user_menu_button)
account_action = user_menu.addAction(self._role_text())
account_action.setEnabled(False)
user_menu.addSeparator()
logout_action = user_menu.addAction("退出登录")
logout_action.triggered.connect(lambda: self.logout_requested.emit())
self.user_menu_button.setMenu(user_menu)
layout.addWidget(self.user_menu_button)
return topbar
def _build_tab_strip(self) -> QWidget:
tabs_host = QFrame()
tabs_host.setObjectName("MultipleTabs")
tabs_host.setFixedHeight(40)
tabs_host.setStyleSheet(
"""
QFrame#MultipleTabs {
background-color: #FFFFFF;
border-top: 0;
border-bottom: 1px solid #E4E7ED;
}
QTabBar#ShellTabs { background-color: #FFFFFF; }
QTabBar#ShellTabs::tab {
min-height: 38px;
max-height: 38px;
min-width: 72px;
padding: 0 14px;
margin: 0;
color: #606266;
background-color: #FFFFFF;
border: 0;
border-top: 2px solid transparent;
border-radius: 0;
font-size: 13px;
font-weight: 400;
}
QTabBar#ShellTabs::tab:hover { color: #303133; background-color: #F5F7FA; }
QTabBar#ShellTabs::tab:selected {
color: #303133;
background-color: #EEF0FF;
border-top: 2px solid #4A5DFF;
}
QMenu {
color: #303133;
background-color: #FFFFFF;
border: 1px solid #E4E7ED;
padding: 5px;
}
QMenu::item { min-width: 112px; min-height: 30px; padding: 0 12px; }
QMenu::item:selected { color: #4A5DFF; background-color: #EEF0FF; }
"""
)
layout = QHBoxLayout(tabs_host)
layout.setContentsMargins(16, 0, 0, 0)
layout.setSpacing(0)
self.tab_bar = QTabBar()
self.tab_bar.setObjectName("ShellTabs")
self.tab_bar.setDocumentMode(True)
self.tab_bar.setDrawBase(False)
self.tab_bar.setExpanding(False)
self.tab_bar.setUsesScrollButtons(True)
self.tab_bar.setElideMode(Qt.TextElideMode.ElideRight)
self.tab_bar.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.tab_bar.currentChanged.connect(self._tab_selected)
layout.addWidget(self.tab_bar, 1)
self.tabs_menu_button = _PaintedIconButton("down")
self.tabs_menu_button.setToolTip("标签页操作")
self.tabs_menu_button.setAccessibleName("标签页操作")
self.tabs_menu_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
tabs_menu = QMenu(self.tabs_menu_button)
self.close_current_action = tabs_menu.addAction("关闭当前")
self.close_current_action.triggered.connect(self.close_current_tab)
self.close_other_action = tabs_menu.addAction("关闭其他")
self.close_other_action.triggered.connect(self.close_other_tabs)
self.close_all_action = tabs_menu.addAction("关闭全部")
self.close_all_action.triggered.connect(self.close_all_tabs)
self.tabs_menu_button.setMenu(tabs_menu)
layout.addWidget(self.tabs_menu_button)
self._update_tab_actions()
return tabs_host
def _role_text(self) -> str:
department = first_value(
self.current_user, "department_name", "department.name", default=""
@@ -433,6 +764,143 @@ class ShellWindow(QMainWindow):
role = "医生" if "1" in role_values else "医助" if "2" in role_values else "医疗人员"
return f"{department} · {role}" if department else role
def toggle_sidebar(self) -> None:
"""Toggle the admin-style compact menu while preserving the page state."""
self._sidebar_collapsed = not self._sidebar_collapsed
self.sidebar.setFixedWidth(64 if self._sidebar_collapsed else 183)
self.brand_name.setVisible(not self._sidebar_collapsed)
self.fold_button.kind = "expand" if self._sidebar_collapsed else "fold"
self.fold_button.setIcon(_painted_shell_icon(self.fold_button.kind))
for button in self.nav_buttons.values():
glyph = str(button.property("navGlyph") or "")
title = str(button.property("navTitle") or "")
button.setText(glyph if self._sidebar_collapsed else f"{glyph} {title}")
button.setToolTip(title if self._sidebar_collapsed else "")
button.setProperty("collapsed", self._sidebar_collapsed)
button.style().unpolish(button)
button.style().polish(button)
def _toggle_fullscreen(self) -> None:
if self.isFullScreen():
self.showNormal()
self.fullscreen_button.setToolTip("全屏模式")
else:
self.showFullScreen()
self.fullscreen_button.setToolTip("退出全屏")
def _tab_index_for_key(self, key: str) -> int:
for index in range(self.tab_bar.count()):
if self.tab_bar.tabData(index) == key:
return index
return -1
def visited_tab_keys(self) -> tuple[str, ...]:
"""Return open tabs in visit order for tests and session diagnostics."""
return tuple(str(self.tab_bar.tabData(index)) for index in range(self.tab_bar.count()))
def _ensure_tab(self, key: str, title: str) -> int:
index = self._tab_index_for_key(key)
if index >= 0:
self.tab_bar.setTabText(index, title)
return index
index = self.tab_bar.addTab(title)
self.tab_bar.setTabData(index, key)
if self._fixed_tab_key is None:
self._fixed_tab_key = key
else:
close_button = _PaintedIconButton("close", size=18, parent=self.tab_bar)
close_button.setToolTip(f"关闭 {title}")
close_button.setAccessibleName(f"关闭标签页:{title}")
close_button.clicked.connect(
lambda _checked=False, page_key=key: self.close_tab(page_key)
)
self.tab_bar.setTabButton(index, QTabBar.ButtonPosition.RightSide, close_button)
self._update_tab_actions()
return index
def _set_current_tab(self, key: str) -> None:
index = self._tab_index_for_key(key)
if index < 0:
return
blocked = self.tab_bar.blockSignals(True)
self.tab_bar.setCurrentIndex(index)
self.tab_bar.blockSignals(blocked)
self._update_tab_actions()
def _tab_selected(self, index: int) -> None:
if index < 0:
return
key = str(self.tab_bar.tabData(index) or "")
page = self.pages.get(key)
if page is None:
return
self._navigate(self.stack.indexOf(page), key)
def _update_tab_actions(self) -> None:
if not hasattr(self, "close_current_action"):
return
current_index = self.tab_bar.currentIndex()
current_key = str(self.tab_bar.tabData(current_index) or "") if current_index >= 0 else ""
removable = [key for key in self.visited_tab_keys() if key != self._fixed_tab_key]
self.close_current_action.setEnabled(
bool(current_key and current_key != self._fixed_tab_key)
)
self.close_other_action.setEnabled(any(key != current_key for key in removable))
self.close_all_action.setEnabled(bool(removable))
def close_tab(self, key: str) -> bool:
"""Close one non-fixed visited page and re-route if it was active."""
index = self._tab_index_for_key(key)
if index < 0 or key == self._fixed_tab_key:
return False
active_key = ""
if self.tab_bar.currentIndex() >= 0:
active_key = str(self.tab_bar.tabData(self.tab_bar.currentIndex()) or "")
keys = list(self.visited_tab_keys())
fallback = keys[index - 1] if index > 0 else keys[index + 1]
close_button = self.tab_bar.tabButton(index, QTabBar.ButtonPosition.RightSide)
blocked = self.tab_bar.blockSignals(True)
self.tab_bar.removeTab(index)
self.tab_bar.blockSignals(blocked)
if close_button is not None:
close_button.deleteLater()
if active_key == key:
self.navigate(fallback)
else:
self._set_current_tab(active_key)
self._update_tab_actions()
return True
def close_current_tab(self) -> bool:
index = self.tab_bar.currentIndex()
if index < 0:
return False
return self.close_tab(str(self.tab_bar.tabData(index) or ""))
def close_other_tabs(self) -> None:
current_index = self.tab_bar.currentIndex()
current_key = str(self.tab_bar.tabData(current_index) or "") if current_index >= 0 else ""
keep = {current_key, self._fixed_tab_key}
for key in reversed(self.visited_tab_keys()):
if key not in keep:
self.close_tab(key)
if current_key:
self._set_current_tab(current_key)
self._update_tab_actions()
def close_all_tabs(self) -> None:
for key in reversed(self.visited_tab_keys()):
if key != self._fixed_tab_key:
self.close_tab(key)
if self._fixed_tab_key:
self.navigate(self._fixed_tab_key)
self._update_tab_actions()
def _register_pages(self) -> None:
self.nav_group = QButtonGroup(self)
self.nav_group.setExclusive(True)
@@ -450,7 +918,9 @@ class ShellWindow(QMainWindow):
self.page_titles[index] = title
button = QPushButton(f"{item.glyph} {title}")
button.setProperty("variant", "nav")
button.setObjectName("ShellNavButton")
button.setProperty("navGlyph", item.glyph)
button.setProperty("navTitle", title)
button.setCheckable(True)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.clicked.connect(
@@ -485,7 +955,10 @@ class ShellWindow(QMainWindow):
if index < 0 or index >= self.stack.count():
return
self.stack.setCurrentIndex(index)
self.context_label.setText(self.page_titles.get(index, "工作台"))
title = self.page_titles.get(index, "工作台")
self.context_label.setText(title)
self._ensure_tab(key, title)
self._set_current_tab(key)
button = self.nav_buttons.get(key)
if button is not None:
button.setChecked(True)