更新
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user