tcm_exam.rb 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. # encoding:utf-8
  2. class TcmExam < ActiveRecord::Base
  3. has_paper_trail
  4. self.table_name = "tcm_exams"
  5. belongs_to :wx_user
  6. belongs_to :scale_user, :foreign_key => :scale_user_id
  7. STATUS_ENUM = [["待处理", "pending"], ["处理中", "processing"], ["已完成", "completed"], ["失败", "failed"]]
  8. # 大模型名称映射(技术名 → 展示名)
  9. AI_MODEL_LABELS = {
  10. 'GLM-5.1' => 'GLM-5.1',
  11. 'glm-5.1' => 'GLM-5.1',
  12. 'gemini-3.1-flash-lite-preview' => 'GLM-5.1',
  13. 'gemini-3-pro-preview' => 'GLM-5.1',
  14. }.freeze
  15. def wx_user_contact
  16. return "-" if wx_user.blank?
  17. user = wx_user.user
  18. return "-" if user.blank?
  19. contact = user.tel
  20. contact = user.email if contact.blank?
  21. contact.blank? ? "-" : contact
  22. end
  23. def scale_user_name
  24. return "-" if scale_user.blank?
  25. scale_user.nick_name || "-"
  26. end
  27. # 诊疗大模型展示名称,默认 GLM-5.1
  28. def report_no
  29. "FH-TCM-#{id}"
  30. end
  31. def ai_model_display
  32. AI_MODEL_LABELS[model.to_s] || model.presence || 'GLM-5.1'
  33. end
  34. # 解析 AI 报告内容
  35. def parsed_ai_summary
  36. return {} if ai_summary.blank?
  37. begin
  38. JSON.parse(ai_summary)
  39. rescue
  40. {}
  41. end
  42. end
  43. # 解析观察图片
  44. def parsed_observation_images
  45. return {} if observation_images.blank?
  46. begin
  47. JSON.parse(observation_images)
  48. rescue
  49. {}
  50. end
  51. end
  52. # 解析问诊答案
  53. def parsed_question_answers
  54. return {} if question_answers.blank?
  55. begin
  56. JSON.parse(question_answers)
  57. rescue
  58. {}
  59. end
  60. end
  61. # 解析最新体脂秤结果
  62. def parsed_latest_scale_result
  63. return {} if latest_scale_result.blank?
  64. begin
  65. JSON.parse(latest_scale_result)
  66. rescue
  67. {}
  68. end
  69. end
  70. # 状态标签
  71. def status_label
  72. case status
  73. when "pending" then "待处理"
  74. when "processing" then "处理中"
  75. when "completed" then "已完成"
  76. when "failed" then "失败"
  77. else status || "-"
  78. end
  79. end
  80. # 虚拟方法:调养与建议
  81. def advice_section
  82. parsed_ai_summary["advice"] || "-"
  83. end
  84. # 虚拟方法:望诊记录
  85. def observation_section
  86. parsed_ai_summary["observation"] || "-"
  87. end
  88. # 虚拟方法:AI 总结段落
  89. def summary_section
  90. parsed_ai_summary["summary"] || "-"
  91. end
  92. # 格式化内容,处理换行和 markdown 格式
  93. def format_content(content)
  94. return "-" if content.blank?
  95. formatted = content.to_s
  96. .gsub(/\\n/, "<br>")
  97. .gsub(/\n/, "<br>")
  98. .gsub(/\*\*([^*]+)\*\*/, '<strong>\1</strong>')
  99. .gsub(/\*([^*]+)\*/, '<em>\1</em>')
  100. formatted
  101. end
  102. # 格式化调养与建议的嵌套 JSON 结构
  103. def format_advice(advice)
  104. return "-" if advice.blank?
  105. if advice.is_a?(String)
  106. begin
  107. advice = JSON.parse(advice)
  108. rescue
  109. return format_content(advice)
  110. end
  111. end
  112. return format_content(advice.to_s) unless advice.is_a?(Hash)
  113. category_info = {
  114. '饮食' => { icon: '🍵', color: '#059669' },
  115. '运动' => { icon: '🏃', color: '#2563eb' },
  116. '作息' => { icon: '🌙', color: '#7c3aed' },
  117. '情志' => { icon: '💆', color: '#db2777' }
  118. }
  119. html = ''
  120. advice.each do |category, items|
  121. info = category_info[category] || { icon: '📌', color: '#6b7280' }
  122. html += %Q{<div style="margin-bottom:12px;">}
  123. html += %Q{<div style="font-weight:600;color:#{info[:color]};margin-bottom:6px;font-size:14px;">#{info[:icon]} #{category}</div>}
  124. html += %Q{<ul style="margin:0;padding-left:18px;list-style-type:disc;">}
  125. if items.is_a?(Array)
  126. items.each { |item| html += %Q{<li style="margin-bottom:4px;line-height:1.5;font-size:13px;">#{format_content(item)}</li>} }
  127. else
  128. html += %Q{<li style="margin-bottom:4px;line-height:1.5;font-size:13px;">#{format_content(items)}</li>}
  129. end
  130. html += '</ul></div>'
  131. end
  132. html
  133. end
  134. rails_admin do
  135. navigation_label 'AI诊疗记录'
  136. parent ScaleDevice
  137. weight 3
  138. list do
  139. filters [:wx_user, :scale_user, :status, :created_at]
  140. field :id do
  141. label 'ID'
  142. column_width 60
  143. end
  144. field :report_no do
  145. label '报告ID'
  146. column_width 140
  147. pretty_value do
  148. %Q{<span style="font-family:monospace;font-weight:600;color:#1a56db;">FH-TCM-#{bindings[:object].id}</span>}.html_safe
  149. end
  150. end
  151. field :wx_user do
  152. label '微信用户'
  153. pretty_value { bindings[:object].wx_user_contact }
  154. end
  155. field :scale_user do
  156. label '用户名'
  157. pretty_value { bindings[:object].scale_user_name }
  158. end
  159. field :model do
  160. label '诊疗大模型'
  161. column_width 130
  162. pretty_value do
  163. name = bindings[:object].ai_model_display
  164. %Q{<span style="display:inline-flex;align-items:center;gap:5px;background:linear-gradient(135deg,#1a56db,#7e3af2);color:#fff;padding:3px 10px;border-radius:20px;font-size:12px;font-weight:600;letter-spacing:0.3px;">🤖 #{name}</span>}.html_safe
  165. end
  166. end
  167. field :status do
  168. label '状态'
  169. column_width 80
  170. pretty_value do
  171. label = bindings[:object].status_label
  172. color = case bindings[:object].status
  173. when 'completed' then '#059669'
  174. when 'processing' then '#d97706'
  175. when 'failed' then '#dc2626'
  176. else '#6b7280'
  177. end
  178. %Q{<span style="color:#{color};font-weight:600;">#{label}</span>}.html_safe
  179. end
  180. end
  181. field :ai_summary do
  182. label '报告详情'
  183. formatted_value do
  184. exam = bindings[:object]
  185. bindings[:view].link_to('查看报告', bindings[:view].rails_admin.show_path(model_name: 'tcm_exam', id: exam.id), class: 'btn btn-info btn-sm')
  186. end
  187. end
  188. field :created_at do
  189. label '创建时间'
  190. column_width 150
  191. strftime_format '%Y-%m-%d %H:%M'
  192. end
  193. end
  194. show do
  195. # ── 顶部信息卡:大模型徽章 + 基础信息 ──────────────────────────────
  196. field :id do
  197. label '诊疗概览'
  198. pretty_value do
  199. exam = bindings[:object]
  200. model_name = exam.ai_model_display
  201. status_color = case exam.status
  202. when 'completed' then '#059669'
  203. when 'processing' then '#d97706'
  204. when 'failed' then '#dc2626'
  205. else '#6b7280'
  206. end
  207. status_text = exam.status_label
  208. %Q{
  209. <div style="background:linear-gradient(135deg,#0f172a 0%,#1e3a5f 60%,#1a3c6e 100%);border-radius:14px;padding:18px 22px;margin:4px 0 16px;color:#fff;">
  210. <div style="display:flex;align-items:center;gap:12px;margin-bottom:14px;">
  211. <div style="background:linear-gradient(135deg,#2563eb,#7c3aed);border-radius:10px;padding:8px 16px;display:inline-flex;align-items:center;gap:6px;">
  212. <span style="font-size:16px;">🤖</span>
  213. <span style="font-size:18px;font-weight:700;letter-spacing:1px;color:#fff;">#{model_name}</span>
  214. </div>
  215. <div style="background:rgba(255,255,255,0.12);border-radius:8px;padding:4px 12px;font-size:13px;color:#e2e8f0;">诊疗大模型</div>
  216. <div style="background:rgba(99,179,237,0.18);border:1px solid rgba(99,179,237,0.4);border-radius:8px;padding:4px 14px;font-size:13px;font-family:monospace;font-weight:700;color:#93c5fd;letter-spacing:0.5px;">FH-TCM-#{exam.id}</div>
  217. </div>
  218. <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:10px;font-size:13px;">
  219. <div style="background:rgba(255,255,255,0.08);border-radius:8px;padding:8px 12px;">
  220. <div style="color:#94a3b8;font-size:11px;margin-bottom:3px;">诊疗ID</div>
  221. <div style="font-weight:700;font-family:monospace;color:#93c5fd;">FH-TCM-#{exam.id}</div>
  222. </div>
  223. <div style="background:rgba(255,255,255,0.08);border-radius:8px;padding:8px 12px;">
  224. <div style="color:#94a3b8;font-size:11px;margin-bottom:3px;">微信用户</div>
  225. <div style="font-weight:600;">#{exam.wx_user_contact}</div>
  226. </div>
  227. <div style="background:rgba(255,255,255,0.08);border-radius:8px;padding:8px 12px;">
  228. <div style="color:#94a3b8;font-size:11px;margin-bottom:3px;">称用户名</div>
  229. <div style="font-weight:600;">#{exam.scale_user_name}</div>
  230. </div>
  231. <div style="background:rgba(255,255,255,0.08);border-radius:8px;padding:8px 12px;">
  232. <div style="color:#94a3b8;font-size:11px;margin-bottom:3px;">状态</div>
  233. <div style="font-weight:700;color:#{status_color};">#{status_text}</div>
  234. </div>
  235. </div>
  236. <div style="margin-top:10px;font-size:12px;color:#64748b;">
  237. 创建:#{exam.created_at&.strftime('%Y-%m-%d %H:%M:%S')} &nbsp;|&nbsp; 更新:#{exam.updated_at&.strftime('%Y-%m-%d %H:%M:%S')}
  238. </div>
  239. </div>
  240. }.html_safe
  241. end
  242. end
  243. # 望诊图片
  244. field :observation_images do
  245. label '📷 望诊图片'
  246. pretty_value do
  247. exam = bindings[:object]
  248. images = exam.parsed_observation_images
  249. return "-".html_safe if images.empty?
  250. image_labels = { 'tongue' => '舌面照', 'tongueBottom' => '舌底照', 'face' => '面部照', 'body' => '全身照' }
  251. html = '<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:6px 0;">'
  252. ['tongue', 'tongueBottom', 'face', 'body'].each do |key|
  253. urls = images[key]
  254. next if urls.blank? || urls.empty?
  255. url = urls.first
  256. next if url.blank?
  257. html += %Q{
  258. <div style="text-align:center;">
  259. <img src="#{url}" style="width:100%;max-width:180px;height:130px;object-fit:cover;border-radius:8px;border:1px solid #e5e7eb;cursor:pointer;" onclick="window.open('#{url}')" />
  260. <div style="margin-top:4px;font-size:11px;color:#5d4037;font-weight:600;">#{image_labels[key]}</div>
  261. </div>
  262. }
  263. end
  264. html += '</div>'
  265. html.html_safe
  266. end
  267. end
  268. # ── 诊断内容各节(紧凑样式)─────────────────────────────────────────
  269. field :ai_summary do
  270. label '壹 · 一般情况'
  271. pretty_value do
  272. exam = bindings[:object]
  273. content = exam.parsed_ai_summary['general'] || exam.parsed_ai_summary['summary'] || '-'
  274. %Q{<div style="background:#f8fafc;border-left:3px solid #2563eb;border-radius:0 8px 8px 0;padding:10px 14px;margin:4px 0;line-height:1.65;font-size:13px;">#{exam.format_content(content)}</div>}.html_safe
  275. end
  276. end
  277. field :question_answers do
  278. label '贰 · 问诊 (十问歌)'
  279. pretty_value do
  280. exam = bindings[:object]
  281. content = exam.parsed_ai_summary['inquiry'] || '-'
  282. %Q{<div style="background:#f8fafc;border-left:3px solid #7c3aed;border-radius:0 8px 8px 0;padding:10px 14px;margin:4px 0;line-height:1.65;font-size:13px;">#{exam.format_content(content)}</div>}.html_safe
  283. end
  284. end
  285. field :observation_section do
  286. label '叁 · 望诊与闻诊'
  287. pretty_value do
  288. exam = bindings[:object]
  289. content = exam.parsed_ai_summary['observation'] || '-'
  290. %Q{<div style="background:#f8fafc;border-left:3px solid #059669;border-radius:0 8px 8px 0;padding:10px 14px;margin:4px 0;line-height:1.65;font-size:13px;">#{exam.format_content(content)}</div>}.html_safe
  291. end
  292. end
  293. field :latest_scale_result do
  294. label '肆 · 总体评估与体质辨识'
  295. pretty_value do
  296. exam = bindings[:object]
  297. content = exam.parsed_ai_summary['constitution'] || '-'
  298. %Q{<div style="background:#f8fafc;border-left:3px solid #d97706;border-radius:0 8px 8px 0;padding:10px 14px;margin:4px 0;line-height:1.65;font-size:13px;">#{exam.format_content(content)}</div>}.html_safe
  299. end
  300. end
  301. field :advice_section do
  302. label '伍 · 调养与建议'
  303. pretty_value do
  304. exam = bindings[:object]
  305. advice = exam.parsed_ai_summary['advice']
  306. return "-".html_safe if advice.blank?
  307. formatted = exam.format_advice(advice)
  308. %Q{<div style="background:#f8fafc;border-left:3px solid #db2777;border-radius:0 8px 8px 0;padding:10px 14px;margin:4px 0;line-height:1.65;font-size:13px;">#{formatted}</div>}.html_safe
  309. end
  310. end
  311. field :summary_section do
  312. label '陆 · 总结'
  313. pretty_value do
  314. exam = bindings[:object]
  315. content = exam.parsed_ai_summary['summary']
  316. return "-".html_safe if content.blank?
  317. %Q{<div style="background:#fff7ed;border-left:3px solid #ea580c;border-radius:0 8px 8px 0;padding:10px 14px;margin:4px 0;line-height:1.65;font-size:13px;color:#9a3412;">#{exam.format_content(content)}</div>}.html_safe
  318. end
  319. end
  320. end
  321. edit do
  322. field :status, :enum do
  323. enum { STATUS_ENUM }
  324. end
  325. end
  326. end
  327. end