本文同步發表於個人部落格:呈現臨床資料(二)
火線超人有一個 LIFF 頁面,列出伺服器上的病人讓使用者挑。LIFF 就是在 LINE 裡開起來的網頁。
那個頁面每一列顯示四個欄位:identifier、姓名、性別、生日。其中姓名那一欄有一個 placeholder,因為撈到的病人可能沒有名字。
day15 講過同一件事。今天的病況跟用藥更麻煩。一筆病況的名稱,FHIR 裡有三個不同的地方可以放。三個都可能是空的,你必須一層一層往下找。
一筆 Condition,這裡只留跟畫面有關的欄位:
{
"resourceType": "Condition",
"clinicalStatus": {
"coding": [
{
"system": "http://terminology.hl7.org/CodeSystem/condition-clinical",
"code": "resolved"
}
]
},
"code": {
"coding": [
{
"system": "http://snomed.info/sct",
"code": "444470001",
"display": "Injury of anterior cruciate ligament"
}
],
"text": "Injury of anterior cruciate ligament"
},
"onsetDateTime": "2017-03-21T23:37:54+00:00",
"abatementDateTime": "2017-05-27T23:37:54+00:00"
}
注意 code 跟 clinicalStatus 的結構是一樣的,都是 coding 陣列加一個可選的 text。這個結構叫 CodeableConcept。FHIR 裡幾乎所有「這是什麼」的欄位都長這樣。
但兩者的內容差很多。code 那個有 display 也有 text。clinicalStatus 那個只有 system 跟 code,另外兩層都沒有。
再看一筆 MedicationRequest:
{
"resourceType": "MedicationRequest",
"status": "stopped",
"intent": "order",
"medicationCodeableConcept": {
"coding": [
{
"system": "http://www.nlm.nih.gov/research/umls/rxnorm",
"code": "310965",
"display": "Ibuprofen 200 MG Oral Tablet"
}
],
"text": "Ibuprofen 200 MG Oral Tablet"
},
"authoredOn": "2017-03-21T23:37:54+00:00",
"dosageInstruction": [{ "sequence": 1, "asNeededBoolean": true }]
}
status 這裡不是 CodeableConcept,是一個單純的字串。同一份規範裡兩種寫法並存,取值的程式就得分開寫。
CodeableConcept 取顯示文字要自己寫一個函式,規則寫死成三層。這跟 day15 取姓名那四層 fallback 是同一招:
export function displayOf(concept, fallback = '(未提供名稱)') {
if (!concept) return fallback
if (concept.text) return concept.text
const coding = concept.coding?.[0]
return coding?.display ?? coding?.code ?? fallback
}
text 優先,因為那是來源系統原本就寫給人看的那一句。它可能是醫師自己打的字,比標準名稱貼近臨床實際。
text 沒有就往下找,最後只剩 code 本身。一串數字不好看,但至少讓人知道有這筆資料,工程師也查得出是什麼。
重點是這是一條規則,不是每個欄位各寫一套。每個 CodeableConcept 都走同一條。不要在 Condition 寫一種、MedicationRequest 寫另一種。
三筆病況我逐筆看過,病名那個欄位都有 text,所以三筆都停在第一層。第二層的 display 與第三層的 code 在這批資料上輪不到。但 text 在規範裡是選填的,換一台伺服器就可能只給你 display。

狀態欄位要另外一個函式:
export function codeOf(concept) {
return concept?.coding?.[0]?.code ?? 'unknown'
}
差別在目的。displayOf 要的是給人看的字,codeOf 要的是給程式判斷的值。clinicalStatus 用 displayOf 會拿到 resolved 這個英文字,直接顯示在中文畫面上很怪。拿它去比對狀態也靠不住,那兩層的文字不保證存在,code 才是值集定死的那個。
拿到 code 之後自己對照成中文:
const CONDITION_STATUS = {
active: '仍在追蹤',
recurrence: '復發',
relapse: '復發',
inactive: '未在追蹤',
remission: '緩解',
resolved: '已解決',
unknown: '狀態不明',
}
這張表要涵蓋規範列出的所有值,不是只寫你在測試資料裡看到的那兩三個。查不到就回原字串,不要讓 undefined 上畫面。
上面那筆 Condition 除了 clinicalStatus,還有一個 verificationStatus。
兩個欄位講的是不同的事。clinicalStatus 是這個病況現在怎麼樣,值有 active、resolved、remission 這些。verificationStatus 是這個診斷本身有沒有被確認,值包含 unconfirmed、provisional、differential、refuted、entered-in-error。
refuted 的意思是「查過了,不是這個病」。把它當成確診顯示在病況清單上,是臨床安全問題不是顯示問題。
這批 sandbox 資料我取樣兩百筆,那道檢查你在這裡看不出效果。但欄位在規範裡,真實系統會用到,清單至少要把非 confirmed 的標出來或濾掉。

範例是把資料撈回來之後在前端排:
.sort((a, b) => b.onset.localeCompare(a.onset))
日期用字串比大小就好。FHIR 的日期是 ISO 8601 格式,年月日由大到小。截到日這一層字典序跟時間序一致,不必轉成 Date 物件。
伺服器端排序這條路也走得通,Condition?patient=<id>&_sort=-onset-date 實測回 200,順序跟前端排出來的一樣。
這裡選前端排有兩個理由。一是這位病人的資料量很小,三筆病況兩筆用藥,多發一個參數換不到什麼。
二是排序參數的名稱每種資源都不一樣。Condition 叫 onset-date,MedicationRequest 叫 authoredon,不是統一的 date。要用伺服器排就得一種一種查清楚,搜尋參數怎麼查怎麼組,day20 會專門講。
medicationCodeableConcept 是把藥名內嵌在這筆處方裡。另一種寫法是 medicationReference,指向一個獨立的 Medication 資源。
兩種都合法,伺服器選哪一種你不能假設:
name: one.medicationCodeableConcept
? displayOf(one.medicationCodeableConcept)
: `(需另外讀取 ${one.medicationReference?.reference})`
碰到 reference 那種要多發一次請求才拿得到藥名。這個範例先不看,只把它標出來讓你知道有這條路。
dosageInstruction 也有同樣的問題。它的 text 是給人看的整句用法,真實 EHR 多半會給。這台 sandbox 只給 asNeededBoolean,連 text 都沒有:
function dosageText(dosage) {
if (!dosage) return ''
if (dosage.text) return dosage.text
return dosage.asNeededBoolean ? '需要時服用' : ''
}
實際跑出來,兩筆用藥有一筆的用法欄位是空的。那筆 dosageInstruction 連 asNeededBoolean 都沒有,只有一個 sequence。空白就空白,不要編一句「依醫囑服用」填進去。
實跑結果是兩筆用藥,狀態都是 stopped。
這件事值得多說一句。你很容易把這一段寫成「列出病人正在吃的藥」,然後用 status === 'active' 過濾。在這位病人身上,那個清單會是空的。
所以清單不過濾,全部列出來並把狀態寫在旁邊。使用者看到「已停用」跟看到空清單,得到的資訊完全不同。前者知道這個人有用藥史但停了,後者只會以為系統壞了。
病況那邊三筆,兩筆 resolved 一筆 active,剛好夠示範狀態不是裝飾。
到現在為止畫面上有三個區塊:基本資料、趨勢圖、兩張表。原生 DOM 生出來的表格沒有任何樣式,欄位擠在一起,看不出哪裡是一列的邊界。
要處理版面,但這個專案沒有裝 npm,也沒有建置步驟。Tailwind 的正常流程是兩樣都要。
另外,還有一條路:@tailwindcss/browser。它是一個跑在瀏覽器裡的 JIT 編譯器,掃 DOM 上的 class 名稱即時產生 CSS。一個 script 標籤就結束,沒有設定檔。
curl -o vendor/tailwind-browser.js \
https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.3/dist/index.global.js
不過有件事要先講:這個版本官方說只適合用在開發,不要用在正式環境。 理由是每個使用者的瀏覽器都要跑一次編譯。正式做法是裝 npm 加一個建置步驟,讓 Tailwind 事先產出一份靜態 CSS。裡面只有你真的用到的那些 class。day28 的上線檢查清單會再回來處理這件事。
你載了一個編譯器進瀏覽器,只為了 9 KB 的輸出。開發環境是可以,但是正式環境就不合適了。
vendor 目錄現在三個檔案。這是免建置的代價,展開來讓你自己判斷。

起點是 day16 結束時的專案,四個檔案加兩個 vendor。分別是 index.html、app.js、patient.js、vitals.js,以及 vendor/ 底下的 fhir-client.pure.min.js 與 chart.umd.js。
現在要讀四種資源了,逐一列出太長:
const SCOPE = 'launch/patient patient/*.rs openid fhirUser offline_access'
同意畫面會從 day16 的五行變回四行,Read * records 取代了逐項列舉。
這裡有個反直覺的地方。scope 從逐項改成萬用字元,你要的權限變多了,同意畫面上的資訊卻變少了。畫面上寫得越模糊,使用者能做的判斷就越少。
curl -o vendor/tailwind-browser.js \
https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.3/dist/index.global.js
index.html 的 script 標籤加在最前面:
<script src="vendor/tailwind-browser.js"></script>
<script src="vendor/fhir-client.pure.min.js"></script>
<script src="vendor/chart.umd.js"></script>
取值的部分:
export function displayOf(concept, fallback = '(未提供名稱)') {
if (!concept) return fallback
if (concept.text) return concept.text
const coding = concept.coding?.[0]
return coding?.display ?? coding?.code ?? fallback
}
export function codeOf(concept) {
return concept?.coding?.[0]?.code ?? 'unknown'
}
export async function loadConditions(client) {
const list = await client.request(
`Condition?patient=${client.patient.id}&_count=100`,
{ pageLimit: 0, flat: true }
)
return list
.map((one) => ({
name: displayOf(one.code),
status: codeOf(one.clinicalStatus),
onset: one.onsetDateTime?.slice(0, 10) ?? '',
}))
.sort((a, b) => b.onset.localeCompare(a.onset))
}
export async function loadMedications(client) {
const list = await client.request(
`MedicationRequest?patient=${client.patient.id}&_count=100`,
{ pageLimit: 0, flat: true }
)
return list
.map((one) => ({
name: one.medicationCodeableConcept
? displayOf(one.medicationCodeableConcept)
: `(需另外讀取 ${one.medicationReference?.reference})`,
status: one.status ?? 'unknown',
authoredOn: one.authoredOn?.slice(0, 10) ?? '',
dosage: dosageText(one.dosageInstruction?.[0]),
}))
.sort((a, b) => b.authoredOn.localeCompare(a.authoredOn))
}
function dosageText(dosage) {
if (!dosage) return ''
if (dosage.text) return dosage.text
return dosage.asNeededBoolean ? '需要時服用' : ''
}
const TABLE = 'w-full text-sm border-collapse'
const TH = 'px-3 py-2 text-left font-medium text-slate-600 border-b border-slate-300'
const TD = 'px-3 py-2 border-b border-slate-200'
function statusBadge(label, active) {
const tone = active
? 'bg-emerald-100 text-emerald-800'
: 'bg-slate-100 text-slate-600'
return `<span class="inline-block rounded px-2 py-0.5 text-xs ${tone}">${label}</span>`
}
function table(headers, rows) {
const head = headers.map((one) => `<th class="${TH}">${one}</th>`).join('')
const body = rows
.map((cells) => `<tr>${cells.map((one) => `<td class="${TD}">${one}</td>`).join('')}</tr>`)
.join('')
return `<table class="${TABLE}"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`
}
export function conditionsTable(rows) {
if (!rows.length) return '<p class="text-slate-500">這位病人沒有病況記錄。</p>'
return table(
['病況', '狀態', '起始'],
rows.map((one) => [
one.name,
statusBadge(CONDITION_STATUS[one.status] ?? one.status, one.status === 'active'),
one.onset,
])
)
}
export function medicationsTable(rows) {
if (!rows.length) return '<p class="text-slate-500">這位病人沒有用藥記錄。</p>'
return table(
['藥品', '狀態', '用法', '開立日'],
rows.map((one) => [
one.name,
statusBadge(MEDICATION_STATUS[one.status] ?? one.status, one.status === 'active'),
one.dosage,
one.authoredOn,
])
)
}
CONDITION_STATUS 與 MEDICATION_STATUS 就是前面那兩張中文對照表,一起放進 clinical.js。
class 字串抽成常數,是為了兩張表共用同一套樣式。直接寫在字串模板裡也能動,但改一次要改兩個地方。
顏色只是幫你看快一點。狀態的文字照樣寫出來,不能只靠顏色傳達,那樣色覺不同的人讀不到這個資訊。
<section class="rounded bg-white p-4 shadow-sm">
<h2 class="mb-2 font-semibold">病況</h2>
<div id="conditions" class="text-slate-500">尚未載入</div>
</section>
用藥那個區塊照抄改個 id。body 加 class="bg-slate-50 text-slate-900 p-6 md:p-10",外層包一個 mx-auto max-w-3xl space-y-8 把內容置中並拉開間距。
const conditionsBox = document.querySelector('#conditions')
const medicationsBox = document.querySelector('#medications')
async function showConditions(client) {
const rows = await loadConditions(client)
conditionsBox.className = ''
conditionsBox.innerHTML = conditionsTable(rows)
console.log('病況:', rows.length, '筆')
}
async function showMedications(client) {
const rows = await loadMedications(client)
medicationsBox.className = ''
medicationsBox.innerHTML = medicationsTable(rows)
console.log('用藥:', rows.length, '筆')
}
await Promise.all([
showVitals(client),
showConditions(client),
showMedications(client),
])
那兩行 className = '' 是把「尚未載入」那個灰字的 class 清掉,不然表格會整個變灰。
三塊資料互不相干,沒有理由一個等一個。
跑起來畫面上會有:四欄基本資料、三條線的趨勢圖、三列病況、兩列用藥。console 會多兩行。
打開 Network 面板確認一下,所有 script 都來自 localhost,沒有任何 CDN 請求。

CodeableConcept 取值三層優先序,text 到 display 到 code。寫成一條規則套用到所有欄位。狀態欄位要另外處理,因為程式要的是 code 不是給人看的字。
到今天為止這個 app 只會讀。明天讓這個 app 能寫。把病人在家量的血壓存回伺服器,那筆資料會出現在趨勢圖的最後一個點上。