import os
import re
import gradio as gr
import pandas as pd
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter
# --- 1. 固定讀取同目錄下的資料庫檔案 ---
DATA_FILE = "data.csv"
def load_fixed_database():
if os.path.exists(DATA_FILE):
try:
df = pd.read_csv(DATA_FILE)
df.columns = df.columns.str.strip()
for col in ['成分', '劑型', '劑量', '廠商', '年度']:
if col in df.columns:
df[col] = df[col].astype(str).str.strip()
return df
except Exception as e:
print(f"讀取本地資料庫失敗: {e}")
return pd.DataFrame()
else:
return pd.DataFrame()
df_global = load_fixed_database()
if not df_global.empty:
ALL_COMPONENTS = sorted(df_global['成分'].dropna().unique())
INIT_MESSAGE = f"✅ 成功載入資料庫!共 {len(df_global)} 筆數據,包含 {len(ALL_COMPONENTS)} 種成分。"
else:
ALL_COMPONENTS = []
INIT_MESSAGE = "❌ 未找到 data.csv 檔案,請將資料庫檔案上傳至同目錄。"
# --- 2. 輔助函式 ---
def parse_dosage_to_numeric(dose_str):
if not isinstance(dose_str, str):
return 0.0
match = re.search(r'([0-9\.]+)', dose_str)
if match:
val = float(match.group(1))
if 'g' in dose_str.lower() and 'mg' not in dose_str.lower() and 'mcg' not in dose_str.lower():
return val * 1000
return val
return 0.0
# --- 3. 動態介面連動函式 ---
def update_component_choices(search_text):
if not ALL_COMPONENTS:
return gr.update(choices=[], value=[])
if not search_text or search_text.strip() == "":
return gr.update(choices=[], value=[])
search_text = search_text.strip().lower()
filtered = [c for c in ALL_COMPONENTS if search_text in c.lower()]
return gr.update(choices=filtered, value=[])
def update_form_choices(selected_comps):
if not selected_comps or df_global.empty:
return gr.update(choices=[], value=[])
df_filtered = df_global[df_global['成分'].isin(selected_comps)]
forms = sorted(df_filtered['劑型'].dropna().unique())
return gr.update(choices=forms, value=forms)
def update_dose_choices(selected_comps, selected_forms):
if not selected_comps or not selected_forms or df_global.empty:
return gr.update(choices=[], value=[])
df_filtered = df_global[
(df_global['成分'].isin(selected_comps)) &
(df_global['劑型'].isin(selected_forms))
]
doses = sorted(df_filtered['劑量'].dropna().unique(), key=parse_dosage_to_numeric)
return gr.update(choices=doses, value=doses)
# --- 4. 產出標準化 Excel 與 HTML 預覽報表 ---
def generate_reports(selected_components, selected_forms, selected_doses):
if df_global.empty:
return None, "❌ 系統未成功載入 data.csv 資料庫。", ""
if not selected_components or not selected_forms or not selected_doses:
return None, "❌ 請確認成分、劑型、劑量皆已勾選!", ""
df_filtered = df_global[
(df_global['成分'].isin(selected_components)) &
(df_global['劑型'].isin(selected_forms)) &
(df_global['劑量'].isin(selected_doses))
].copy()
if df_filtered.empty:
return None, "❌ 找不到符合該條件的資料!", ""
qty_col = '數量(顆)' if '數量(顆)' in df_filtered.columns else [col for col in df_filtered.columns if '數量' in col][0]
df_filtered[qty_col] = df_filtered[qty_col].astype(str).str.replace(',', '').str.strip()
df_filtered[qty_col] = pd.to_numeric(df_filtered[qty_col], errors='coerce').fillna(0)
pivot_df = df_filtered.groupby(['成分', '劑型', '劑量', '廠商', '年度'])[qty_col].sum().unstack(fill_value=0)
for year_col in ['2022年', '2023年', '2024年']:
if year_col not in pivot_df.columns:
pivot_df[year_col] = 0
pivot_df = pivot_df.reindex(columns=['2022年', '2023年', '2024年']).reset_index()
pivot_df['dose_numeric'] = pivot_df['劑量'].apply(parse_dosage_to_numeric)
pivot_df = pivot_df.sort_values(
by=['成分', '劑型', 'dose_numeric', '2024年'],
ascending=[True, True, True, False]
).drop(columns=['dose_numeric'])
# --- Excel 處理邏輯 ---
wb = Workbook()
ws = wb.active
ws.title = "廠商排名報表"
ws.views.sheetView[0].showGridLines = True
font_family = "微軟正黑體"
header_fill = PatternFill(start_color="1F497D", end_color="1F497D", fill_type="solid")
subtotal_fill = PatternFill(start_color="DCE6F1", end_color="DCE6F1", fill_type="solid")
total_fill = PatternFill(start_color="B8CCE4", end_color="B8CCE4", fill_type="solid")
header_font = Font(name=font_family, size=12, bold=True, color="FFFFFF")
data_font = Font(name=font_family, size=12)
bold_font = Font(name=font_family, size=12, bold=True)
center_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
left_align = Alignment(horizontal="left", vertical="center")
right_align = Alignment(horizontal="right", vertical="center")
thin_side = Side(border_style="thin", color="D9D9D9")
thick_bottom_side = Side(border_style="medium", color="000000")
cell_border = Border(left=thin_side, right=thin_side, top=thin_side, bottom=thin_side)
thick_bottom_border = Border(left=thin_side, right=thin_side, top=thin_side, bottom=thick_bottom_side)
# --- HTML 處理邏輯 ---
html_content = f"""
"""
comp_names = "、".join(df_filtered['成分'].unique())
form_mapping = {"注射劑": "Inj.", "一般錠劑膠囊劑": "Tab./Cap.", "膜衣錠": "F.C. Tab.", "膠囊劑": "Cap.", "錠劑": "Tab."}
unique_forms = df_filtered['劑型'].unique()
form_abbr = f" {form_mapping.get(unique_forms[0], unique_forms[0])}" if len(unique_forms) == 1 else ""
doses_for_header = sorted(df_filtered['劑量'].unique(), key=parse_dosage_to_numeric)
doses_str = "、".join(doses_for_header)
header_title_text = f"{comp_names}{form_abbr} {doses_str}廠商申報量排名"
html_content += f'
{header_title_text}
'
html_content += '
'
headers = ["成分", "劑型", "劑量", "廠商", "2022年
數量", "2023年
數量", "2024年
數量", "2024年
占比(%)"]
ws_headers = [h.replace("
", "\n") for h in headers]
ws.append(ws_headers)
ws.row_dimensions[1].height = 30
html_content += ""
for col_idx, h in enumerate(ws_headers, 1):
cell = ws.cell(row=1, column=col_idx)
cell.font = header_font
cell.fill = header_fill
cell.alignment = center_align
cell.border = cell_border
html_content += f"| {headers[col_idx-1]} | "
html_content += "
"
unique_groups = pivot_df[['成分', '劑型', '劑量']].drop_duplicates()
unique_groups_list = list(unique_groups.iterrows())
grand_total_2022 = 0
grand_total_2023 = 0
grand_total_2024 = 0
current_row = 2
last_comp = None
last_form = None
for idx, (_, group_keys) in enumerate(unique_groups_list):
comp = group_keys['成分']
form = group_keys['劑型']
dose = group_keys['劑量']
is_last_of_form = False
if idx == len(unique_groups_list) - 1:
is_last_of_form = True
else:
next_comp = unique_groups_list[idx+1][1]['成分']
next_form = unique_groups_list[idx+1][1]['劑型']
if comp != next_comp or form != next_form:
is_last_of_form = True
dose_group = pivot_df[
(pivot_df['成分'] == comp) &
(pivot_df['劑型'] == form) &
(pivot_df['劑量'] == dose)
]
dose_2024_sum = dose_group['2024年'].sum()
dose_subtotal_2022 = dose_group['2022年'].sum()
dose_subtotal_2023 = dose_group['2023年'].sum()
dose_subtotal_2024 = dose_2024_sum
print_comp = (comp != last_comp)
print_form = print_comp or (form != last_form)
is_first_row_in_dose = True
for _, row in dose_group.iterrows():
qty_2022 = float(row['2022年'])
qty_2023 = float(row['2023年'])
qty_2024 = float(row['2024年'])
ratio = (qty_2024 / dose_2024_sum) if dose_2024_sum > 0 else 0.0
c_val = row['成分'] if print_comp and is_first_row_in_dose else ""
f_val = row['劑型'] if print_form and is_first_row_in_dose else ""
d_val = row['劑量'] if is_first_row_in_dose else ""
ws.append([c_val, f_val, d_val, row['廠商'], qty_2022, qty_2023, qty_2024, ratio])
ws.row_dimensions[current_row].height = 25
html_content += f""
html_content += f"| {c_val} | "
html_content += f"{f_val} | "
html_content += f"{d_val} | "
html_content += f"{row['廠商']} | "
html_content += f"{qty_2022:,.0f} | "
html_content += f"{qty_2023:,.0f} | "
html_content += f"{qty_2024:,.0f} | "
html_content += f"{ratio:.1%} | "
html_content += "
"
for col_idx in range(1, 9):
cell = ws.cell(row=current_row, column=col_idx)
cell.font = data_font
cell.border = cell_border
if col_idx in [1, 2, 3]: cell.alignment = center_align
elif col_idx == 4: cell.alignment = left_align
elif col_idx in [5, 6, 7]: cell.alignment = right_align; cell.number_format = '#,##0'
elif col_idx == 8: cell.alignment = right_align; cell.number_format = '0.0%'
current_row += 1
is_first_row_in_dose = False
last_comp = comp
last_form = form
# 合計列
ws.append(["", "", f"{dose} 合計", "", dose_subtotal_2022, dose_subtotal_2023, dose_subtotal_2024, 1.0])
ws.merge_cells(start_row=current_row, start_column=3, end_row=current_row, end_column=4)
ws.row_dimensions[current_row].height = 25
thick_class = "thick-bottom" if is_last_of_form else ""
html_content += f"""
| |
{dose} 合計 |
{dose_subtotal_2022:,.0f} |
{dose_subtotal_2023:,.0f} |
{dose_subtotal_2024:,.0f} |
100.0% |
"""
for col_idx in range(1, 9):
cell = ws.cell(row=current_row, column=col_idx)
cell.font = bold_font
cell.fill = subtotal_fill
cell.border = thick_bottom_border if is_last_of_form else cell_border
if col_idx == 3: cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=False)
elif col_idx in [5, 6, 7]: cell.alignment = right_align; cell.number_format = '#,##0'
elif col_idx == 8: cell.alignment = right_align; cell.number_format = '0.0%'
grand_total_2022 += dose_subtotal_2022
grand_total_2023 += dose_subtotal_2023
grand_total_2024 += dose_subtotal_2024
current_row += 1
# 總計列
ws.append(["總計", "", "", "", grand_total_2022, grand_total_2023, grand_total_2024, 1.0])
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=4)
ws.row_dimensions[current_row].height = 25
html_content += f"""
| 總計 |
{grand_total_2022:,.0f} |
{grand_total_2023:,.0f} |
{grand_total_2024:,.0f} |
100.0% |
"""
# 頁尾
html_content += """
中央健康保險署 政府資料開放平台 2024年資料
https://data.gov.tw/dataset/22131
"""
for col_idx in range(1, 9):
cell = ws.cell(row=current_row, column=col_idx)
cell.font = bold_font
cell.fill = total_fill
cell.border = cell_border
if col_idx == 1: cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=False)
elif col_idx in [5, 6, 7]: cell.alignment = right_align; cell.number_format = '#,##0'
elif col_idx == 8: cell.alignment = right_align; cell.number_format = '0.0%'
for col in ws.columns:
col_letter = get_column_letter(col[0].column)
if col_letter == 'H':
ws.column_dimensions[col_letter].width = 11.5
continue
max_len = 0
for cell in col:
val_str = str(cell.value or '')
lines = val_str.split('\n')
for line in lines:
line_len = sum(2 if '\u4e00' <= char <= '\u9fff' else 1 for char in line)
if line_len > max_len: max_len = line_len
ws.column_dimensions[col_letter].width = max(max_len + 4, 12)
ws.sheet_properties.pageSetUpPr.fitToPage = True
ws.page_setup.fitToWidth = 1
ws.page_setup.fitToHeight = 0
ws.page_margins.top = 2.7 / 2.54; ws.page_margins.bottom = 2.5 / 2.54
ws.page_margins.left = 1.5 / 2.54; ws.page_margins.right = 1.5 / 2.54
ws.page_margins.header = 1.5 / 2.54; ws.page_margins.footer = 1.0 / 2.54
header_string_for_print = f'&"微軟正黑體,Bold"&16{header_title_text}'
ws.oddHeader.center.text = header_string_for_print
ws.oddFooter.left.text = '&"微軟正黑體,Regular"&12中央健康保險署 政府資料開放平台 2024年資料'
ws.oddFooter.right.text = '&"微軟正黑體,Regular"&12https://data.gov.tw/dataset/22131'
ws.page_setup.scaleWithDoc = True
ws.page_setup.alignWithMargins = True
safe_filename = re.sub(r'[\\/*?:"<>|]', "_", header_title_text) + ".xlsx"
wb.save(safe_filename)
success_msg = "🎉 成功!已順利產出「Excel 報表」與下方「預覽畫面」。請點擊下方按鈕下載檔案或存為圖片!"
return safe_filename, success_msg, html_content
# --- 5. 終極優化:完美截圖與手機互動 JavaScript ---
download_js = """
function() {
var element = document.getElementById('report-capture-area');
if (!element) {
alert('請先產生報表再下載圖片!');
return [];
}
if (typeof html2canvas === 'undefined') {
alert('截圖套件載入中,請稍後再試或重新整理網頁。');
return [];
}
var table = document.querySelector('.report-table');
var wrapper = document.querySelector('.table-responsive');
var originalElementWidth = element.style.width;
var originalWrapperOverflow = wrapper ? wrapper.style.overflowX : '';
var targetWidth = table ? (table.offsetWidth + 20) : element.scrollWidth;
element.style.width = targetWidth + 'px';
if(wrapper) wrapper.style.overflowX = 'visible';
var titleElement = element.querySelector('h2');
var fileName = titleElement ? titleElement.innerText.replace(/[\\\\/*?:"<>|]/g, "_") : '廠商排名報表';
html2canvas(element, {
scale: 2,
backgroundColor: '#FFFFFF',
width: targetWidth,
windowWidth: targetWidth
}).then(function(canvas) {
// 截圖完畢,瞬間把排版縮回去
element.style.width = originalElementWidth;
if(wrapper) wrapper.style.overflowX = originalWrapperOverflow;
// 【修正 1:強化手機與平板判斷,涵蓋 iPad 的桌面網站模式】
var isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
canvas.toBlob(function(blob) {
var file = new File([blob], fileName + '.png', { type: 'image/png' });
var imgDataUrl = canvas.toDataURL('image/png');
// 【修正 2:優先嘗試 Web Share API,呼叫原生「儲存影像」】
if (isMobile && navigator.canShare && navigator.canShare({ files: [file] })) {
navigator.share({
files: [file],
title: fileName
}).then(() => {
console.log('成功呼叫原生分享選單');
}).catch((error) => {
// 若使用者取消或 iframe 權限遭擋,降級使用長按視窗
console.log('無法使用原生分享,改用長按模式', error);
showLongPressModal(imgDataUrl);
});
}
// 【修正 3:若不支援 Share API,強制顯示長按視窗】
else if (isMobile) {
showLongPressModal(imgDataUrl);
}
// 電腦版維持自動下載
else {
var link = document.createElement('a');
link.download = fileName + '.png';
link.href = imgDataUrl;
link.click();
}
}, 'image/png');
// 將長按視窗獨立為函式,方便降級呼叫
function showLongPressModal(imgSrc) {
var modal = document.createElement('div');
modal.style.position = 'fixed';
modal.style.top = '0';
modal.style.left = '0';
modal.style.width = '100vw';
modal.style.height = '100vh';
modal.style.backgroundColor = 'rgba(0,0,0,0.85)';
modal.style.zIndex = '9999';
modal.style.display = 'flex';
modal.style.flexDirection = 'column';
modal.style.alignItems = 'center';
modal.style.justifyContent = 'center';
var closeBtn = document.createElement('button');
closeBtn.innerText = '✕ 關閉';
closeBtn.style.position = 'absolute';
closeBtn.style.top = '20px';
closeBtn.style.right = '20px';
closeBtn.style.padding = '8px 16px';
closeBtn.style.fontSize = '16px';
closeBtn.style.backgroundColor = '#ff4444';
closeBtn.style.color = 'white';
closeBtn.style.border = 'none';
closeBtn.style.borderRadius = '5px';
closeBtn.style.fontWeight = 'bold';
closeBtn.onclick = function() { document.body.removeChild(modal); };
var hint = document.createElement('div');
hint.innerText = '👇 請「長按」下方圖片,選擇「儲存到照片 / 儲存影像」';
hint.style.color = 'white';
hint.style.fontSize = '16px';
hint.style.fontWeight = 'bold';
hint.style.marginBottom = '20px';
hint.style.padding = '10px';
hint.style.textAlign = 'center';
hint.style.backgroundColor = '#1F497D';
hint.style.borderRadius = '8px';
var img = document.createElement('img');
img.src = imgSrc;
img.style.maxWidth = '95%';
img.style.maxHeight = '75vh';
img.style.border = '2px solid white';
img.style.borderRadius = '5px';
img.style.objectFit = 'contain';
// 確保圖片支援 iOS 長按呼叫選單
img.style.webkitTouchCallout = 'default';
img.style.userSelect = 'auto';
modal.appendChild(closeBtn);
modal.appendChild(hint);
modal.appendChild(img);
document.body.appendChild(modal);
}
});
return [];
}
"""
# --- 6. 介面層 (載入 html2canvas 套件) ---
with gr.Blocks(
theme=gr.themes.Default(primary_hue="blue", secondary_hue="slate"), # 【新增這行】將系統按鈕主色調改為藍色
title="健保資料庫數據分析工具",
head=''
) as demo:
gr.Markdown("# 💊 健保資料庫數據分析工具")
gr.Markdown(f"**系統狀態:** {INIT_MESSAGE}")
gr.Markdown("⚠️ **提醒使用手機版的同仁:請務必使用系統預設瀏覽器(如 Safari 或 Chrome)開啟本網頁,才能成功下載檔案與圖片。**")
with gr.Row():
# 左側區塊:輸入條件
with gr.Column(scale=1):
gr.Markdown("### 🔍 輸入條件")
search_input = gr.Textbox(label="第一步:輸入成分關鍵字", placeholder="例如:Levofloxacin", value="")
component_choices = gr.CheckboxGroup(label="📋 第二步:勾選成分品項 (可多選)", choices=[], value=[])
form_choices = gr.CheckboxGroup(label="💊 第三步:勾選欲包含的劑型", choices=[], value=[])
dose_choices = gr.CheckboxGroup(label="🧪 第四步:勾選欲包含的劑量", choices=[], value=[])
submit_btn = gr.Button("🚀 第五步:產生 Excel 報表與預覽", variant="primary")
# 右側區塊:下載與輸出
with gr.Column(scale=1):
gr.Markdown("### 📥 報表與圖片下載")
status_output = gr.Textbox(label="系統處理結果", interactive=False)
# 【修改重點 1】:將 gr.File 換成 gr.DownloadButton,設定 variant="primary" 和 size="lg" 讓它變成大橘色按鈕
download_excel_btn = gr.DownloadButton("📄 一鍵下載 Excel 報表", variant="primary", size="lg")
download_img_btn = gr.Button("🖼️ 一鍵下載為高畫質圖片 (PNG)", variant="primary", size="lg")
gr.Markdown("---")
gr.Markdown("### 網頁即時預覽")
html_output = gr.HTML(label="報表預覽區")
# --- 綁定事件 ---
search_input.change(fn=update_component_choices, inputs=search_input, outputs=component_choices)
component_choices.change(fn=update_form_choices, inputs=component_choices, outputs=form_choices)
form_choices.change(fn=update_dose_choices, inputs=[component_choices, form_choices], outputs=dose_choices)
# 【修改重點 2】:將原本輸出對象的 file_output 替換為 download_excel_btn
submit_btn.click(
fn=generate_reports,
inputs=[component_choices, form_choices, dose_choices],
outputs=[download_excel_btn, status_output, html_output]
)
# 綁定進化版的 JS 截圖指令
download_img_btn.click(fn=None, js=download_js)
if __name__ == "__main__":
demo.launch()