Files
zyt/app/src/doctor_workstation/ui/diagnosis_terms.py
T
2026-08-28 18:24:37 +08:00

492 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""诊单字段的中文翻译:字典项、枚举与时间戳。
后台 ``AppointmentLogic::enrichDiagnosisLabels`` 会给只读接口补一份
``<field>_text``,所以读取时永远优先用它(与 admin 的 ``makeTextOf`` 同一约定)。
处方快照一类的历史数据没有这些字段,就按字段所属字典把 code 翻成中文:字典优先
取仓储实时下发的 ``config/dict``,缺失时回退到与
``server/sql/present_illness_dict_data.sql`` / ``tcm_diagnosis.sql`` 一致的内置种子。
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import Any
# 与 AppointmentLogic::enrichDiagnosisLabels 的 $singleDictFields 保持一致。
SINGLE_VALUE_DICTIONARIES: dict[str, str] = {
"diagnosis_type": "diagnosis_type",
"syndrome_type": "syndrome_type",
"diabetes_type": "diabetes_type",
"water_intake": "water_intake",
"weight_change": "weight_change",
"fatty_liver_degree": "fatty_liver_degree",
}
# 与 $multiDictFields 保持一致:值是数组或逗号/顿号分隔的字符串。
MULTI_VALUE_DICTIONARIES: dict[str, str] = {
"past_history": "past_history",
"appetite": "appetite",
"diet_condition": "diet_condition",
"body_feeling": "body_feeling",
"sleep_condition": "sleep_condition",
"eye_condition": "eye_condition",
"head_feeling": "head_feeling",
"sweat_condition": "sweat_condition",
"skin_condition": "skin_condition",
"urine_condition": "urine_condition",
"stool_condition": "stool_condition",
"kidney_condition": "kidney_condition",
}
FIELD_DICTIONARIES: dict[str, str] = {
**SINGLE_VALUE_DICTIONARIES,
**MULTI_VALUE_DICTIONARIES,
}
DICTIONARY_TYPES: tuple[str, ...] = tuple(sorted(set(FIELD_DICTIONARIES.values())))
# 内置种子:与 server/sql/present_illness_dict_data.sql、tcm_diagnosis.sql 的
# zyt_dict_data 初始数据一致;线上字典可被管理员改写,所以实时字典优先。
SEED_DICTIONARIES: dict[str, dict[str, str]] = {
"appetite": {
"dry": "干",
"bitter": "苦",
"greasy": "腻",
},
"body_feeling": {
"numbness": "麻木",
"weakness": "乏力",
"pain": "疼痛",
"cold_aversion": "畏寒",
"fever": "烧热",
},
"diagnosis_type": {
"first_visit": "初诊",
"follow_up": "复诊",
"consultation": "会诊",
},
"diet_condition": {
"overeating": "多食",
"poor_appetite": "纳呆",
"stomach_bloating": "胃胀",
"stomach_pain": "胃痛",
"acid_reflux": "反酸",
"loss_of_appetite": "食欲减退",
},
"eye_condition": {
"blurred": "模糊",
"dry": "干涩",
"tearing": "流泪",
"floaters": "飞蚊症",
"bleeding": "出血",
},
"fatty_liver_degree": {
"mild": "轻度",
"moderate": "中度",
"severe": "重度",
},
"head_feeling": {
"fatigue": "疲劳困倦",
"dizziness": "头晕",
"headache": "头痛",
"tinnitus": "耳鸣",
},
"kidney_condition": {
"soreness": "酸胀",
"pain": "疼痛",
"lower_back_pain": "腰痛",
"sexual_dysfunction": "性功能下降",
},
"past_history": {
"hypertension": "高血压",
"diabetes": "糖尿病",
"gastric_ulcer": "胃溃疡",
"hyperlipidemia": "高血脂",
"thyroid_nodule": "甲状腺结节",
"superficial_gastritis": "浅表性胃炎",
"stomach_disease": "胃病",
"cerebral_infarction": "脑梗",
"breast_nodule": "乳腺结节",
"atrophic_gastritis": "萎缩性胃炎",
"heart_disease": "心脏病",
"cerebral_ischemia": "脑缺血",
"intestinal_obstruction": "肠梗阻",
"hepatitis_a": "甲肝",
"hepatitis_b": "乙肝",
"hepatitis_c": "丙肝",
"big_three_positive": "大三阳",
"cerebral_thrombosis": "脑血栓",
"coronary_heart_disease": "冠心病",
"angina_pectoris": "心绞痛",
"palpitation": "心悸",
"renal_insufficiency": "肾功能不全",
"benign_tumor": "良性肿瘤",
"pancreatitis": "胰腺炎",
"small_three_positive": "小三阳",
"palpitations": "心慌",
"edema": "水肿",
"infectious_disease": "传染病",
"fundus_congestion": "眼底充血",
"tuberculosis": "肺结核",
"pneumonia": "肺炎",
"pulmonary_nodule": "肺结节",
"cardiac_stent": "心脏支架",
"renal_stent": "肾脏支架",
"hepatitis": "肝炎",
"tumor": "肿瘤",
"emphysema": "肺气肿",
"moderate_fatty_liver": "中度脂肪肝",
"lacunar_infarction": "腔梗",
"alcoholic_liver": "酒精肝",
"brain_atrophy": "脑萎缩",
"liver_cyst": "肝囊肿",
"stroke": "中风",
"cerebral_hemorrhage": "脑出血",
"hepatic_insufficiency": "肝功能不全",
"arterial_plaque": "动脉斑块",
"uterine_fibroids": "子宫肌瘤",
"splenomegaly": "脾大",
"gastric_perforation": "胃穿孔",
"gastric_bleeding": "胃出血",
},
"skin_condition": {
"dry": "干燥",
"itching": "瘙痒",
"peeling": "脱皮",
"edema": "水肿",
"eczema": "湿疹",
},
"sleep_condition": {
"difficulty_falling_asleep": "入睡难",
"easy_to_wake": "容易醒",
"early_waking": "早醒",
"many_dreams": "多梦",
},
"stool_condition": {
"dry": "干燥",
"constipation": "便秘",
"sticky": "粘腻",
"diarrhea": "腹泻",
},
"sweat_condition": {
"daytime_sweating": "日间出汗",
"night_sweating": "夜间出汗",
"sticky_sweat": "汗粘",
"excessive_sweating": "多汗",
},
"syndrome_type": {
"qi_deficiency": "气虚",
"blood_deficiency": "血虚",
"yin_deficiency": "阴虚",
"yang_deficiency": "阳虚",
"qi_stagnation": "气滞",
"blood_stasis": "血瘀",
"phlegm_dampness": "痰湿",
"damp_heat": "湿热",
"cold_dampness": "寒湿",
"wind_cold": "风寒",
"wind_heat": "风热",
},
"urine_condition": {
"urgency": "尿急",
"yellow_urine": "尿黄",
"foamy": "有泡",
"frequency": "尿频",
"painful": "尿痛",
"nocturia": "夜尿多",
},
"water_intake": {
"one_bottle": "1瓶矿泉水",
"one_half_bottle": "1.5瓶矿泉水",
"three_bottles": "3瓶矿泉水",
"four_bottles": "4瓶矿泉水",
},
"weight_change": {
"lose_5_jin": "瘦5斤",
"lose_10_jin": "瘦10斤",
"lose_over_10_jin": "瘦10斤以上",
},
}
_GENDER_LABELS: dict[str, str] = {
"1": "男",
"m": "男",
"male": "男",
"男": "男",
"0": "女",
"2": "女",
"f": "女",
"female": "女",
"女": "女",
}
_MARITAL_LABELS: dict[str, str] = {"0": "未婚", "1": "已婚", "2": "离异"}
# 后台 yesNoText1 有,其余 无。
_YES_NO_FIELDS = frozenset(
{
"trauma_history",
"surgery_history",
"allergy_history",
"family_history",
"pregnancy_history",
}
)
_CREATE_SOURCE_LABELS: dict[str, str] = {
"mnp": "小程序建档",
"mnp_daily": "小程序快捷建档",
"admin": "后台创建",
"doctor": "医生创建",
}
_RECORD_SOURCE_LABELS: dict[str, str] = {"0": "医生录入", "1": "患者自录"}
# 纯内部列:启停标记、统计端展示位、排班偏移与软删除时间对医生没有意义。
INTERNAL_FIELDS = frozenset(
{
"status",
"show_card",
"revisit_slot_start_offset",
"delete_time",
"is_delete",
"is_deleted",
"sort",
"assistant_id",
"doctor_id",
"admin_id",
}
)
# 附件字段单独渲染成缩略图,不再以 URL 文本出现在字段网格里。
IMAGE_FIELDS = frozenset(
{
"tongue_images",
"report_files",
"images",
"breakfast_images",
"lunch_images",
"dinner_images",
}
)
_TIMESTAMP_SUFFIXES = ("_time", "_at", "_date")
_TIMESTAMP_MIN = 10**9 # 2001-09-09,早于本项目任何真实数据
_TIMESTAMP_MAX = 4 * 10**9 # 2096 年,之后按普通数字显示
# 只读页的单位,与 admin PatientCaseCard / BloodRecordList 一致。
_UNIT_SUFFIXES: dict[str, str] = {
"height": " cm",
"weight": " kg",
"systolic_pressure": " mmHg",
"diastolic_pressure": " mmHg",
"fasting_blood_sugar": " mmol/L",
"postprandial_blood_sugar": " mmol/L",
"other_blood_sugar": " mmol/L",
"blood_sugar": " mmol/L",
"duration": " 分钟",
"diabetes_discovery_year": "年",
}
def unit_suffix(field: str, value: Any) -> str:
"""Return the unit a numeric readonly field should carry, if any."""
suffix = _UNIT_SUFFIXES.get(str(field or "").strip())
if not suffix:
return ""
text = str(value).strip()
if not text or text.endswith(suffix.strip()):
return ""
# 只给纯数字补单位,"17多"、"五年" 这类自由文本保持原样。
normalized = text.replace(".", "", 1)
return suffix if normalized.isdigit() else ""
def format_timestamp(value: Any, *, with_time: bool = True) -> str | None:
"""把后端的 Unix 秒级时间戳转成中文界面用的日期时间。"""
if isinstance(value, bool):
return None
try:
seconds = int(str(value).strip())
except (TypeError, ValueError):
return None
if not _TIMESTAMP_MIN <= seconds <= _TIMESTAMP_MAX:
return None
moment = datetime.fromtimestamp(seconds)
return moment.strftime("%Y-%m-%d %H:%M" if with_time else "%Y-%m-%d")
def is_timestamp_field(field: str) -> bool:
return str(field or "").endswith(_TIMESTAMP_SUFFIXES)
def gender_label(value: Any) -> str | None:
return _GENDER_LABELS.get(str(value or "").strip().lower())
def split_values(value: Any) -> list[str]:
"""按后台 enrichDiagnosisLabels 的方式拆多值字段。"""
if value in (None, "", []):
return []
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
items = [str(item or "").strip() for item in value]
else:
items = [part.strip() for part in _split_text(str(value))]
return [item for item in items if item]
def _split_text(text: str) -> list[str]:
normalized = text.replace("", ",").replace("、", ",")
return normalized.split(",")
class TermIndex:
"""字典翻译表:实时字典覆盖内置种子,两者都缺就回显原值。"""
def __init__(self, dictionaries: Mapping[str, Any] | None = None) -> None:
self._dictionaries: dict[str, dict[str, str]] = {
dictionary_type: dict(options)
for dictionary_type, options in SEED_DICTIONARIES.items()
}
self.merge(dictionaries)
def merge(self, dictionaries: Mapping[str, Any] | None) -> None:
"""并入 ``config/dict`` 下发的 ``{type: [{name, value}, ...]}``。"""
if not isinstance(dictionaries, Mapping):
return
for dictionary_type, rows in dictionaries.items():
options = _options_from_rows(rows)
if options:
self._dictionaries.setdefault(str(dictionary_type), {}).update(options)
def dictionary(self, dictionary_type: str) -> dict[str, str]:
return dict(self._dictionaries.get(str(dictionary_type), {}))
def dictionary_label(self, field: str, value: Any) -> str | None:
"""翻译一个字典字段;不是字典字段或没有可翻译内容时返回 None。"""
dictionary_type = FIELD_DICTIONARIES.get(str(field or "").strip())
if dictionary_type is None:
return None
options = self._dictionaries.get(dictionary_type, {})
items = split_values(value)
if not items:
return None
labels = [options.get(item, item) for item in items]
return "、".join(label for label in labels if label) or None
def value_label(self, field: str, value: Any) -> str | None:
"""翻译字典项或枚举;无法翻译时返回 None,由调用方回显原值。"""
key = str(field or "").strip()
if value in (None, "", [], {}):
return None
dictionary_label = self.dictionary_label(key, value)
if dictionary_label is not None:
return dictionary_label
if key in {"gender", "patient_gender", "sex"}:
return gender_label(value)
if key in {"marital_status", "marriage"}:
return _MARITAL_LABELS.get(str(value).strip())
if key in _YES_NO_FIELDS:
text = str(value).strip()
if text in {"0", "1"}:
return "有" if text == "1" else "无"
return None
if key == "create_source":
return _CREATE_SOURCE_LABELS.get(str(value).strip().lower())
if key == "source":
return _RECORD_SOURCE_LABELS.get(str(value).strip())
if is_timestamp_field(key):
return format_timestamp(value, with_time=not key.endswith("_date"))
return None
def display(self, source: Any, field: str, *, default: str = "") -> str:
"""按 admin ``textOf`` 的口径取值:先 ``<field>_text``,再字典/枚举,最后原值。"""
mapping = source if isinstance(source, Mapping) else {}
key = str(field or "").strip()
translated = mapping.get(f"{key}_text")
if translated in (None, "", []):
translated = mapping.get(f"{key}_desc")
if translated not in (None, "", []):
return _join(translated) + unit_suffix(key, translated)
raw = mapping.get(key)
if raw in (None, "", [], {}):
return default
labelled = self.value_label(key, raw)
if labelled is not None:
return labelled
rendered = _join(raw)
return rendered + unit_suffix(key, rendered)
_SHARED_INDEX = TermIndex()
def shared_terms() -> TermIndex:
"""The process-wide index every readonly screen renders through.
只读界面只用它翻译展示文案,所以共享一份即可:任何界面取回实时字典后,
处方快照那种拿不到 ``*_text`` 的旧数据也能跟着翻译正确。
"""
return _SHARED_INDEX
def merge_shared_dictionaries(dictionaries: Mapping[str, Any] | None) -> None:
_SHARED_INDEX.merge(dictionaries)
def _join(value: Any) -> str:
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return "、".join(str(item).strip() for item in value if str(item).strip())
return str(value).strip()
def _options_from_rows(rows: Any) -> dict[str, str]:
"""从 ``config/dict`` 的行里取 ``value -> name``。"""
options: dict[str, str] = {}
if isinstance(rows, Mapping):
for value, name in rows.items():
code = str(value).strip()
label = str(name).strip()
if code and label:
options[code] = label
return options
if not isinstance(rows, Sequence) or isinstance(rows, (str, bytes, bytearray)):
return options
for row in rows:
if not isinstance(row, Mapping):
continue
code = str(row.get("value", "")).strip()
label = str(row.get("name", "")).strip()
if code and label:
options[code] = label
return options
__all__ = [
"DICTIONARY_TYPES",
"FIELD_DICTIONARIES",
"IMAGE_FIELDS",
"INTERNAL_FIELDS",
"MULTI_VALUE_DICTIONARIES",
"SEED_DICTIONARIES",
"SINGLE_VALUE_DICTIONARIES",
"TermIndex",
"format_timestamp",
"merge_shared_dictionaries",
"shared_terms",
"gender_label",
"is_timestamp_field",
"split_values",
]