CH.08 · 財金程式設計互動教材

Streamlit 金融網站管理後台

把容易出錯的手動 JSON 操作,變成管理者只要填表單就能安全完成的新增、修改、上下架與發布流程。

表單 → 驗證 → 發布

本章學習目標

  • 理解 CRUD 四種操作與 Streamlit 的重新執行特性
  • 用 Session State 保存操作狀態,用 st.form 集中提交
  • 設計資料驗證、自動備份與安全刪除機制
  • 用 secrets.toml 保護 Token,透過 GitHub API 發布內容

01前台與後台的差異

前七章完成了金融網站前台、計算工具、JSON 資料庫、Python 分析、互動圖表、GitHub 版本控制、Cloudflare Pages 部署,以及 SEO 與流量分析。網站現在可以公開使用,但新增一篇文章仍可能需要手動開啟 JSON、修改欄位、確認逗號括號、執行完整的 Git 流程——這對非程式背景的內容編輯者而言,很容易產生格式錯誤。本章使用 Streamlit 建立 Python 管理後台,讓管理者只需填寫表單即可完成內容維護。

項目前台(Cloudflare Pages)後台(Streamlit)
使用對象一般大眾(學生、投資人、教師)管理者(內容編輯、教師、系統管理員)
主要功能閱讀文章、使用工具、完成測驗新增/修改/刪除內容、上下架、發布
技術堆疊HTML/CSS/JS、Cloudflare Pages(靜態)Python + Streamlit(本機或伺服器執行)
資料操作只讀取 JSON(fetch)讀取與寫入 JSON + 備份 + 發布 GitHub
部署位置Cloudflare Pages(公開網址)本機 localhost:8501(或受保護雲端)
管理者填寫 Streamlit 表單
後台處理驗證 → 備份 → 寫入
GitHub API按下「發布」推送
Cloudflare偵測更新,重新部署
使用者看到更新後的內容
i

前台(Cloudflare Pages)與後台(Streamlit)是兩個完全獨立的系統,不需要把網站全部改成 Streamlit。前台繼續負責公開內容、SEO 與速度;後台只供管理者操作。

02CRUD 的基本概念

CRUD 是內容管理系統最核心的四種操作。無論是文章、書籍、題庫還是工具設定,都可以用這四個操作涵蓋。

操作金融網站範例安全風險
Create 新增新增文章、新增書籍、新增考題無(全新資料)
Read 讀取顯示全部文章、搜尋書籍、查看上下架狀態無(只讀取)
Update 修改修改文章標題、更換封面、調整排序中(建議儲存前備份)
Delete 刪除刪除錯誤文章、移除重複書籍⚠️ 高(先下架,再確認刪除)
!

Delete(刪除)風險最高,管理後台應設計多重確認:先選取資料→顯示完整內容→勾選確認→輸入 id 再次確認→建立備份→執行刪除。一般情況建議先「下架」(設 active: false),不要立即永久刪除。

03為何選擇 Streamlit

import streamlit as st

st.title('金融網站管理後台')          # 頁面標題
title = st.text_input('文章標題')     # 文字輸入欄
if st.button('儲存'):                  # 按鈕
    st.success(f'已儲存:{title}')

三行 Python 就能產生標題、輸入欄位與按鈕,不需要先建立完整的 HTML/CSS/JS 前端框架。

需求Streamlit 的對應能力
讀寫 JSON直接使用 Python json 模組,無需額外設定
建立表單st.form + st.text_input / st.selectbox 等元件
上傳圖片st.file_uploader 支援 jpg/png/webp
呼叫 GitHub API透過 requests 套件,配合 secrets.toml 管理 Token

04安裝與啟動 Streamlit

# 確認已啟動虛擬環境(提示字元前有 (.venv))
cd C:\financial-platform
.venv\Scripts\Activate.ps1

# 安裝 Streamlit
python -m pip install streamlit requests

# 建立後台資料夾與主程式
mkdir admin
# 在 admin/ 資料夾建立 app.py

# 啟動後台(在專案根目錄執行)
python -m streamlit run admin\app.py
# 執行後瀏覽器自動開啟:http://localhost:8501
# 停止後台:回到 PowerShell 按 Ctrl + C
financial-platform/
├─ index.html
├─ css/ / js/ / data/ / images/
├─ admin/
│  └─ app.py                    ← 後台主程式(新增)
├─ .streamlit/
│  └─ secrets.toml              ← 機密設定(新增,加入 .gitignore)
├─ data/
│  ├─ articles.json             ← 文章母資料
│  ├─ book-details.json         ← 書籍完整母資料
│  ├─ books.json                ← 書籍前台索引(由程式產生)
│  └─ backups/                  ← 自動備份資料夾
└─ .gitignore                   ← 包含 .streamlit/secrets.toml

05第一個後台主程式(基本架構)

import streamlit as st

st.set_page_config(
    page_title='金融網站管理後台',
    page_icon='📊',
    layout='wide'   # 使用全寬版面(適合資料管理)
)

st.title('金融網站管理後台')
st.caption('管理金融文章、書籍、題庫、圖片與發布。')

# ── 左側功能選單 ─────────────────────────────────
menu = st.sidebar.radio(
    '管理功能',
    ['後台首頁', '文章列表', '新增文章', '修改文章',
     '刪除文章', '書籍管理', '題庫管理', '資料備份', '發布網站']
)

# ── 依選單渲染對應頁面 ───────────────────────────
if menu == '後台首頁': render_dashboard(articles)
elif menu == '文章列表': render_article_table(articles)
elif menu == '新增文章': render_create_article(articles)
elif menu == '修改文章': render_edit_article(articles)
elif menu == '刪除文章': render_delete_article(articles)
elif menu == '資料備份': render_backup_page()
elif menu == '發布網站': render_publish_page()

06Streamlit 的重新執行特性與 Session State

!

初學者最重要的概念:每次使用者改變欄位值或點擊按鈕,Streamlit 就會重新執行整份 Python 程式。這表示普通變數在每次重新執行後都會歸回初始值,無法保存操作期間的狀態。

# ✗ 普通變數無法跨次執行保存
selected_id = None   # 每次重新執行都歸為 None

# ✓ 使用 Session State 保存狀態
if 'selected_article_id' not in st.session_state:
    st.session_state.selected_article_id = None

# 之後可用 st.session_state.selected_article_id 存取
用途Session State 變數注意事項
保存目前選取的文章st.session_state.selected_id選單改變時同步更新
保存登入狀態st.session_state.authenticated重新整理後消失,需重新登入
確認刪除狀態st.session_state.confirm_delete刪除完成後清除
i

Session State 不是永久資料庫。使用者關閉瀏覽器或重新整理後,狀態會消失。正式內容(文章、書籍資料)仍必須寫入 JSON 檔案,不能只存在 Session State。

07讀取與寫入 JSON

from pathlib import Path
import json

BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = BASE_DIR / 'data'
ARTICLE_FILE = DATA_DIR / 'articles.json'

# ── 讀取函數 ─────────────────────────────────────
def load_json(file_path: Path, default_value):
    if not file_path.exists():
        return default_value
    try:
        with open(file_path, 'r', encoding='utf-8') as f:
            return json.load(f)
    except json.JSONDecodeError as error:
        raise ValueError(f'JSON 格式錯誤:{error}') from error

# ── 寫入函數 ─────────────────────────────────────
def save_json(file_path: Path, data) -> None:
    file_path.parent.mkdir(parents=True, exist_ok=True)
    with open(file_path, 'w', encoding='utf-8') as f:
        json.dump(
            data, f,
            ensure_ascii=False,   # 保留中文字元(不轉 \uXXXX)
            indent=2              # 縮排格式,容易閱讀
        )
!

ensure_ascii=False 是關鍵設定。若不加此參數,儲存的 JSON 中所有中文字都會變成 \u4e2d\u6587 等形式,前台讀取後仍可顯示,但人工維護非常困難。

08建立自動備份

步驟說明
讀取最新資料每次操作前重新讀取 JSON,避免使用過時版本
資料驗證validate_article() 檢查必填欄位、格式、唯一性
建立備份backup_file() 將舊版 JSON 存至 data/backups/
寫入新 JSONsave_json() 覆蓋原檔,ensure_ascii=False 保留中文
顯示成功訊息st.success('文章已儲存') + 必要時 st.rerun()
from datetime import datetime
import shutil

def backup_file(file_path: Path, keep_versions: int = 10) -> Path | None:
    if not file_path.exists():
        return None
    BACKUP_DIR.mkdir(parents=True, exist_ok=True)

    # 備份檔名:articles_20260623_101530.json
    timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
    backup_name = f'{file_path.stem}_{timestamp}{file_path.suffix}'
    backup_path = BACKUP_DIR / backup_name

    shutil.copy2(file_path, backup_path)   # 保留原始時間戳

    # 只保留最新的 keep_versions 個備份,自動刪除舊的
    old_backups = sorted(
        BACKUP_DIR.glob(f'{file_path.stem}_*{file_path.suffix}'),
        key=lambda p: p.stat().st_mtime, reverse=True
    )
    for old in old_backups[keep_versions:]:
        old.unlink()

    return backup_path
i

每次寫入 JSON 前呼叫 backup_file(),是保護資料的最基本做法。備份預設保留最新 10 個版本,自動清除更舊的版本,避免無限占用磁碟空間。

09Streamlit 常用元件速查表

Streamlit 元件Python 寫法適合用途
文字輸入st.text_input('文章標題')標題、slug 等短文字
多行文字st.text_area('摘要', height=100)摘要、內容等長文字
下拉選單st.selectbox('分類', [...])分類、類型等固定選項
勾選框st.checkbox('上架', value=True)上架/精選等布林欄位
資料表格st.dataframe(df)文章/書籍列表
側邊欄選單st.sidebar.radio('功能', [...])後台主功能選單
表單容器with st.form('key'): ...集中多欄位一次提交
圖片上傳st.file_uploader('封面', type=[...])文章/書籍封面圖片
指標卡片st.metric('文章總數', total)儀表板統計數字
重新執行st.rerun()刪除/儲存後重新整理

10建立文章資料母檔

程式區塊data/articles.json(節錄)
[
  {
    "id": "ARTICLE001",              ← 唯一識別碼,格式固定
    "title": "複利效果為何在後期加速",
    "slug": "compound-growth",       ← 英文小寫 + 連字號
    "summary": "從本金、利率與時間說明複利成長的核心原理。",
    "category": "理財規劃",
    "content_type": "article",
    "publish_date": "2026-06-23",    ← YYYY-MM-DD 格式
    "cover_image": "images/compound-growth.jpg",  ← 相對路徑
    "tags": ["複利", "時間價值", "理財"],           ← 陣列(非字串)
    "featured": true,                ← 布林值(非字串 "true")
    "active": true,                  ← 布林值(false = 下架)
    "sort_order": 1,
    "content": "複利是利息持續加入本金後,再產生下一期報酬的過程。"
  }
]
欄位型態必填說明與規則
idstring唯一識別碼,不可重複、不可修改
slugstring只允許英文小寫、數字、連字號,不可重複
cover_imagestring相對路徑,不得使用本機絕對路徑
activebooleantrue = 上架,false = 下架(必須是布林值)
sort_ordernumber顯示順序,整數,數字越小越前面

11建立唯一識別碼

def generate_next_id(records: list[dict], prefix: str) -> str:
    """從現有記錄中找出最大數字,回傳下一個 id。"""
    numbers = []
    for record in records:
        record_id = str(record.get('id', ''))
        if not record_id.startswith(prefix):
            continue
        # 取出前綴後的數字部分(例如 'ARTICLE001' → '001')
        num_text = record_id[len(prefix):]
        if num_text.isdigit():
            numbers.append(int(num_text))
    next_num = max(numbers, default=0) + 1
    return f'{prefix}{next_num:03d}'   # 補零至三位(001, 002...)

# 使用範例:現有 ARTICLE001、ARTICLE002 → 產生 ARTICLE003
new_id = generate_next_id(articles, 'ARTICLE')
!

即使某筆資料被刪除,也不應把舊 id 重新分配給新資料。例如 ARTICLE005 刪除後,下一筆仍使用 ARTICLE006,不重用 ARTICLE005,避免外部連結或分析工具混淆。

12資料驗證

✓ 好的 slug✗ 不好的 slug問題
compound-growth複利成長不允許中文
bond-price-yieldbond price yield不允許空格
retirement-planning-101Bond_Price_Yield不允許底線或大寫
import re

ALLOWED_CONTENT_TYPES = {'article','infographic','exam','quiz','external','resource'}

def validate_slug(slug: str) -> bool:
    # 只允許英文小寫、數字、連字號,且不能以連字號開頭或結尾
    return bool(re.fullmatch(r'^[a-z0-9]+(?:-[a-z0-9]+)*$', slug))

def validate_article(article: dict, all_articles: list[dict], editing_id: str | None = None) -> list[str]:
    errors = []
    title = str(article.get('title', '')).strip()
    slug = str(article.get('slug', '')).strip()

    if not title: errors.append('文章標題不得空白。')
    if not slug: errors.append('網址代稱不得空白。')
    elif not validate_slug(slug): errors.append('網址代稱只能使用英文小寫、數字與連字號。')

    # 檢查 slug 是否與其他文章重複
    for existing in all_articles:
        if existing.get('id') == editing_id:
            continue   # 修改模式:跳過自身
        if existing.get('slug') == slug:
            errors.append('網址代稱已被其他文章使用。')

    # 檢查圖片路徑不使用 Windows 絕對路徑
    if ':\\' in str(article.get('cover_image', '')):
        errors.append('圖片路徑不可使用 Windows 本機絕對路徑。')

    return errors

13建立文章新增表單

from datetime import date

def render_create_article(articles: list[dict]) -> None:
    st.subheader('新增文章')

    # st.form 讓所有欄位集中後,按下提交才執行
    with st.form('create_article_form', clear_on_submit=False):
        title = st.text_input('文章標題 *')
        slug = st.text_input('網址代稱 *', help='例如:bond-price-yield')
        summary = st.text_area('文章摘要', height=100)
        category = st.selectbox('文章分類', ['理財規劃','固定收益','股票投資'])
        active = st.checkbox('立即上架', value=True)
        featured = st.checkbox('設為精選', value=False)
        sort_order = st.number_input('顯示順序', min_value=1, step=1, value=len(articles)+1)
        content = st.text_area('文章內容', height=200)

        submitted = st.form_submit_button('新增文章', type='primary')
        if not submitted:
            return

        new_article = {
            'id': generate_next_id(articles, 'ARTICLE'),
            'title': title.strip(), 'slug': slug.strip(),
            'summary': summary.strip(), 'category': category,
            'featured': bool(featured), 'active': bool(active),
            'sort_order': int(sort_order), 'content': content.strip(),
        }

        # ── 驗證 → 備份 → 儲存 ────────────────────────
        errors = validate_article(new_article, articles)
        if errors:
            for error in errors: st.error(error)
            return

        backup_file(ARTICLE_FILE)          # 先備份
        articles.append(new_article)       # 加入新文章
        save_json(ARTICLE_FILE, articles)  # 寫入 JSON
        st.success(f'文章已新增:{new_article["title"]}({new_article["id"]})')

14文章列表與搜尋

def filter_articles(articles, keyword='', category='全部') -> list[dict]:
    results = []
    kw = keyword.strip().lower()
    for article in articles:
        if category != '全部' and article.get('category') != category:
            continue
        # 合併所有可搜尋文字(含標籤)
        searchable = ' '.join([
            str(article.get('title', '')), str(article.get('summary', '')),
            ' '.join(article.get('tags', [])),
        ]).lower()
        if kw and kw not in searchable:
            continue
        results.append(article)
    return results

15修改文章

def render_edit_article(articles: list[dict]) -> None:
    st.subheader('修改文章')
    if not articles:
        st.info('目前沒有可修改的文章。'); return

    # 下拉選單同時顯示 id 與標題,方便辨識
    option_map = {f"{a['id']}|{a['title']}": a['id'] for a in articles}
    selected_label = st.selectbox('選擇文章', list(option_map.keys()))
    selected_id = option_map[selected_label]
    selected = next(a for a in articles if a['id'] == selected_id)

    # 表單的 key 含 selected_id,避免切換文章時出現 DuplicateWidgetID
    with st.form(f'edit_article_{selected_id}'):
        title = st.text_input('文章標題', value=selected.get('title', ''))
        slug = st.text_input('網址代稱', value=selected.get('slug', ''))
        active = st.checkbox('上架', value=bool(selected.get('active', True)))
        submitted = st.form_submit_button('儲存修改', type='primary')
        if not submitted: return

        updated = {**selected,   # 保留其他欄位不變
                   'title': title.strip(), 'slug': slug.strip(), 'active': bool(active)}

        errors = validate_article(updated, articles, editing_id=selected_id)
        if errors:
            for e in errors: st.error(e)
            return

        backup_file(ARTICLE_FILE)
        for i, a in enumerate(articles):
            if a['id'] == selected_id: articles[i] = updated; break
        save_json(ARTICLE_FILE, articles)
        st.success('文章修改完成。')
i

表單的 key 值加入 selected_id(如 'edit_article_ARTICLE001'),當使用者切換到不同文章時,Streamlit 才會建立新的表單,避免 DuplicateWidgetID 錯誤導致畫面異常。

16安全刪除機制

方式Python 操作適用情境能否回復
軟刪除(下架)article['active'] = False暫時不公開,可能再上架是(改回 True)
永久刪除從陣列中移除,覆蓋 JSON確定不再需要的資料需靠備份或 Git
def render_delete_article(articles: list[dict]) -> None:
    st.subheader('刪除文章')
    st.warning('建議先將文章下架。永久刪除後,只能從備份或 Git 版本回復。')

    option_map = {f"{a['id']}|{a['title']}": a['id'] for a in articles}
    selected_label = st.selectbox('選擇要刪除的文章', list(option_map.keys()))
    selected_id = option_map[selected_label]
    selected = next(a for a in articles if a['id'] == selected_id)

    # ── 雙重確認機制 ─────────────────────────────
    confirm_check = st.checkbox('我了解這是永久刪除,無法還原')
    confirm_text = st.text_input(f'請輸入 {selected_id} 確認刪除')

    if st.button('永久刪除文章', type='primary'):
        if not confirm_check:
            st.error('請先勾選確認。'); return
        if confirm_text.strip() != selected_id:
            st.error('輸入的 id 不一致,取消刪除。'); return

        backup_file(ARTICLE_FILE)   # 先備份!
        remaining = [a for a in articles if a['id'] != selected_id]
        save_json(ARTICLE_FILE, remaining)
        st.success('文章已永久刪除。')
        st.rerun()   # 刷新頁面更新列表

17上下架功能與排序管理

# ── 快速上下架 ───────────────────────────────────
def toggle_article_status(articles, article_id):
    backup_file(ARTICLE_FILE)
    for article in articles:
        if article['id'] == article_id:
            article['active'] = not bool(article.get('active', False))
            save_json(ARTICLE_FILE, articles)
            return article['active']
    raise ValueError('找不到指定文章。')

# ── 排序輔助 ─────────────────────────────────────
def sort_records(records):
    return sorted(records, key=lambda r: (
        int(r.get('sort_order', 999999)), str(r.get('title', ''))
    ))

def move_record(records, record_id, direction):
    """將指定 id 的記錄上移('up')或下移('down')。"""
    sorted_records = sort_records(records)
    idx = next((i for i, r in enumerate(sorted_records) if r.get('id') == record_id), None)
    if idx is None: return records

    swap_idx = idx - 1 if direction == 'up' else idx + 1
    if not (0 <= swap_idx < len(sorted_records)): return records

    # 交換 sort_order,而非直接移動陣列位置
    a_order = sorted_records[idx].get('sort_order', idx + 1)
    b_order = sorted_records[swap_idx].get('sort_order', swap_idx + 1)
    for r in records:
        if r.get('id') == sorted_records[idx]['id']: r['sort_order'] = b_order
        elif r.get('id') == sorted_records[swap_idx]['id']: r['sort_order'] = a_order
    return records

18圖片上傳與安全路徑

原則說明程式實作
限制格式只接受 jpg/jpeg/png/webptype=['jpg','jpeg','png','webp']
安全檔名依 slug 產生英文小寫檔名re.sub(r'[^a-z0-9-]', '', slug)
相對路徑儲存 images/xxx.jpg,而非 C:\xxx.jpgf'images/{safe_filename}'
def create_safe_filename(original_name: str, slug: str) -> str:
    suffix = Path(original_name).suffix.lower()   # .jpg / .png
    allowed = {'.jpg', '.jpeg', '.png', '.webp'}
    if suffix not in allowed:
        raise ValueError('只允許 jpg、jpeg、png 或 webp 圖片。')
    safe_slug = re.sub(r'[^a-z0-9-]', '', slug.lower())
    if not safe_slug:
        raise ValueError('無法產生安全圖片檔名。')
    return f'{safe_slug}{suffix}'

def save_uploaded_image(uploaded_file, slug: str) -> str:
    if uploaded_file is None: return ''
    IMAGE_DIR.mkdir(parents=True, exist_ok=True)
    safe_filename = create_safe_filename(uploaded_file.name, slug)
    target_path = IMAGE_DIR / safe_filename
    file_bytes = uploaded_file.getbuffer()
    max_size = 5 * 1024 * 1024   # 5 MB
    if len(file_bytes) > max_size:
        raise ValueError('圖片檔案不可超過 5 MB。')
    with open(target_path, 'wb') as f:
        f.write(file_bytes)
    return f'images/{safe_filename}'   # 回傳相對路徑存入 JSON
!

圖片上傳後必須一併發布至 GitHub,否則前台會出現圖片 404。本機路徑(C:\xxx.jpg)對 Cloudflare 完全無效,JSON 欄位只能使用相對路徑。

19書籍母資料與前台索引自動產生

資料檔保存內容維護方式
data/book-details.json完整書籍資料(所有欄位)後台管理介面直接修改
data/books.json精簡前台索引由程式從母資料自動產生
def generate_book_index(book_details: list[dict]) -> list[dict]:
    """篩選上架書籍,抽取前台需要的欄位,依 sort_order 排序。"""
    active = [b for b in book_details if b.get('active') is True]
    sorted_books = sorted(active, key=lambda b: (int(b.get('sort_order', 999999)), str(b.get('title', ''))))
    return [{
        'id': b.get('id'), 'title': b.get('title'), 'author': b.get('author'),
        'cover_image': b.get('cover_image'), 'featured': bool(b.get('featured', False)),
        'sort_order': int(b.get('sort_order', 999999)),
    } for b in sorted_books]

def rebuild_book_index() -> None:
    book_details = load_json(BOOK_DETAILS_FILE, [])
    index_records = generate_book_index(book_details)
    backup_file(BOOK_INDEX_FILE)
    save_json(BOOK_INDEX_FILE, index_records)
    # 每次修改書籍後自動呼叫此函數
i

每次修改書籍母資料後,應立即呼叫 rebuild_book_index(),確保前台索引同步更新。若人工維護兩份資料,容易出現已下架書籍仍顯示、封面未更新等問題。

20題庫資料設計與管理

[
  {
    "id": "QUESTION001",
    "module": "NM1",           ← 科目模組
    "chapter": 1,               ← 章節(正整數)
    "question": "貨幣時間價值的核心概念為何?",
    "options": {
      "A": "不同時間點的相同金額價值可能不同",
      "B": "所有利率皆固定不變",
      "C": "未來金額一定低於現值",
      "D": "報酬與時間無關"
    },
    "answer": "A",              ← 只能是 A/B/C/D
    "explanation": "現在資金可以投資,因此不同時間點的相同金額具有不同價值。",
    "difficulty": "基礎",       ← 來自允許清單
    "active": true,
    "sort_order": 1
  }
]
欄位驗證規則
question不可空白
options A–D四個選項都不可空白
answer必須是 A、B、C 或 D(大寫單字母)
explanation不可空白(解析對學習很重要)
i

答案分布不均衡(例如 80% 選A)會降低題庫品質,管理後台可加入答案分布統計,提醒管理者調整。

21設定 secrets.toml 與 GitHub 發布

程式區塊.streamlit/secrets.toml
# 此檔案必須加入 .gitignore!絕對不能上傳 GitHub
[github]
token = "YOUR_GITHUB_TOKEN"   ← Fine-grained Token(最小權限)
owner = "YOUR_GITHUB_ACCOUNT"
repo = "financial-platform"
branch = "main"

# 若有後台登入密碼
[admin]
password = "YOUR_ADMIN_PASSWORD"
程式區塊GitHub API 發布 JSON(含注解)
import base64, requests

def get_github_headers() -> dict:
    token = st.secrets['github']['token']
    return {
        'Accept': 'application/vnd.github+json',
        'Authorization': f'Bearer {token}',   # ← 不可用 st.write 顯示!
        'X-GitHub-Api-Version': '2022-11-28'
    }

def get_github_file_sha(owner, repo, branch, repo_path) -> str | None:
    """取得 GitHub 現有檔案的 sha(更新時必須提供)。"""
    url = f'https://api.github.com/repos/{owner}/{repo}/contents/{repo_path}'
    resp = requests.get(url, headers=get_github_headers(), params={'ref': branch}, timeout=30)
    if resp.status_code == 404: return None   # 新檔案
    resp.raise_for_status()
    return resp.json()['sha']

def publish_file_to_github(local_file: Path, repo_path: str, commit_message: str):
    """將本機 JSON 發布至 GitHub(新建或更新)。"""
    if not local_file.exists():
        raise FileNotFoundError(f'找不到本機檔案:{local_file}')

    owner = st.secrets['github']['owner']
    repo = st.secrets['github']['repo']
    branch = st.secrets['github'].get('branch', 'main')

    sha = get_github_file_sha(owner, repo, branch, repo_path)
    content_b64 = base64.b64encode(local_file.read_bytes()).decode('utf-8')

    payload = {'message': commit_message, 'content': content_b64, 'branch': branch}
    if sha: payload['sha'] = sha   # 更新既有檔案時必須提供 sha

    url = f'https://api.github.com/repos/{owner}/{repo}/contents/{repo_path}'
    resp = requests.put(url, headers=get_github_headers(), json=payload, timeout=30)
    resp.raise_for_status()
    return resp.json()
狀態碼意義處理方式
200/201成功(更新/建立)st.success() 顯示成功
401Token 錯誤或過期確認 Token 有效;重新產生
403Token 權限不足或速率限制確認 Token 有 Contents 寫入權限
409sha 已過期(版本衝突)重新取得最新 sha 再提交
!

Token 外洩處理:立即 revoke → 建立新 Token → 更新 secrets.toml → 不可寫入 app.py 或任何 .py 檔 → 確認 secrets.toml 已在 .gitignore 中。即使 Repository 為私人,Token 寫入程式碼仍有外洩風險。

22建立發布介面

def render_publish_page() -> None:
    st.subheader('發布網站內容')
    st.warning('發布後將修改 GitHub 正式 Repository,並可能觸發 Cloudflare 重新部署。')

    publish_options = {
        '文章資料': (ARTICLE_FILE, 'data/articles.json'),
        '書籍前台索引': (BOOK_INDEX_FILE, 'data/books.json'),
    }
    selected_names = st.multiselect('選擇要發布的檔案', list(publish_options.keys()))
    commit_message = st.text_input('Commit 訊息', value='更新網站內容')
    confirm = st.checkbox('我已確認資料已檢查與備份,同意發布至 GitHub')

    if not st.button('開始發布', type='primary'): return
    if not selected_names: st.error('請選擇至少一個要發布的檔案。'); return
    if not confirm: st.error('請先確認資料已檢查與備份。'); return

    progress = st.progress(0)
    results = []
    total = len(selected_names)
    for i, name in enumerate(selected_names, start=1):
        local_file, repo_path = publish_options[name]
        try:
            publish_file_to_github(local_file, repo_path, commit_message)
            results.append((name, True, '發布成功'))
        except Exception as e:
            results.append((name, False, str(e)))
        progress.progress(i / total)

    for name, success, msg in results:
        (st.success if success else st.error)(f'{name}:{msg}')

23後台首頁儀表板

def render_dashboard(articles: list[dict]) -> None:
    st.subheader('後台首頁')
    total = len(articles)
    active = sum(1 for a in articles if a.get('active') is True)
    inactive = total - active
    featured = sum(1 for a in articles if a.get('featured') is True)

    # 四個指標卡片並排
    col1, col2, col3, col4 = st.columns(4)
    col1.metric('文章總數', total)
    col2.metric('已上架', active)
    col3.metric('已下架', inactive)
    col4.metric('精選文章', featured)

    st.subheader('最近 5 篇文章')
    recent = sorted(articles, key=lambda a: a.get('publish_date',''), reverse=True)[:5]
    render_article_table(recent)

24後台登入與安全控制

部署方式安全風險建議做法
只在本機執行(localhost:8501)低,外部無法存取適合個人或小型教師團隊,最簡單
部署至公開雲端高,任何人可能存取必須加入登入驗證或 Cloudflare Access
def check_password() -> bool:
    if st.session_state.get('authenticated'):
        return True
    st.title('金融網站管理後台')
    password = st.text_input('管理密碼', type='password')
    if st.button('登入'):
        if password == st.secrets['admin']['password']:
            st.session_state.authenticated = True
            st.rerun()
        else:
            st.error('密碼錯誤。')
    return False

# 在 main() 最開始呼叫
def main():
    if not check_password():
        st.stop()   # 密碼錯誤時停止執行
    # ... 其他功能 ...
i

正式對外開放的管理後台應考慮:身分驗證服務、多因素驗證(MFA)、角色權限、登入紀錄、Session 逾時、Cloudflare Access 保護。簡易密碼登入只適合低風險的個人教學場景。

25多人操作與資料衝突

風險情境後果降低風險的方法
兩人同時讀取、各自修改、先後儲存後儲存者覆蓋前一位管理者的修改限制同時只有一位管理者;儲存前先重新讀取
網路中斷在儲存到 GitHub 時本機已修改,GitHub 未更新每次操作都先備份;操作後查看 GitHub commit
i

本書主要設計為單人管理後台。若需要多人同時操作,應考慮改用 SQLite 或 PostgreSQL 資料庫,並加入樂觀鎖(optimistic locking)機制。

26實務案例

(一)JSON 少一個逗號

管理者直接修改 articles.json,少寫一個逗號,造成整個文章區無法讀取,前台白屏。使用 Streamlit 後台後,管理者只需填寫表單,由 Python 負責產生格式完全正確的 JSON。這是建立後台最直接的價值。

(二)修改錯誤文章(id 選錯)

管理者原本想修改 ARTICLE010,卻誤選 ARTICLE011 並覆蓋內容。改善:選單同時顯示 id 與標題;修改前顯示完整摘要;儲存前自動備份;可透過備份或 Git 回復。

(三)封面圖片存成本機路徑

管理者將圖片路徑存成 C:\Users\user\Desktop\cover.jpg,只在自己電腦有效,Cloudflare 無法讀取,前台圖片 404。正確做法:使用後台上傳功能 → 自動產生安全相對路徑 → 圖片隨 JSON 一起發布至 GitHub。

(五)Token 寫入程式碼上傳 GitHub

管理者將 GitHub Token 直接寫在 app.py 中,並 push 至 Repository(即使是 Private)。正確做法:立即 revoke Token → 建立新 Token → 放入 .streamlit/secrets.toml → 確認 .gitignore 已排除 → 清理 Git 歷史。

(六)永久刪除後才發現需要保留

管理者永久刪除文章後,才發現該文章有外部連結或需要保留。回復順序:先查 data/backups/ 中的 JSON 備份 → 再查 GitHub commit 歷史 → 使用 git revert → Cloudflare 舊部署版本。這就是備份與版本控制同時存在的價值。

27常見錯誤與除錯

錯誤訊息 / 症狀最可能原因修正方式
streamlit 不是內部或外部命令未安裝或虛擬環境未啟動python -m streamlit run admin\app.py
DuplicateWidgetID多個元件使用相同 key表單 key 加入 selected_id
修改後表單仍顯示舊資料Session State 保留舊值st.rerun() 或清除 Session State
圖片上傳後消失雲端執行環境不保留本機檔案同時將圖片發布至 GitHub
GitHub API 401Token 錯誤、過期或已撤銷確認 Token 有效,重新設定
GitHub API 409sha 過期(多人同時修改)重新取得最新 sha 再提交

28後台安全檢查表

類別檢查項目
① 資料安全儲存前自動建立備份、id 不可重複、slug 不可重複、圖片路徑使用相對路徑、active 使用布林值
② 帳號安全後台不公開或設有登入控制、Token 使用最小必要權限、secrets.toml 未上傳 GitHub、.gitignore 已排除
③ 發布安全發布前本機預覽、列出將修改的檔案讓管理者確認、commit 訊息清楚、確認 Cloudflare 部署完成

29關鍵字

Streamlit管理後台CRUD st.formSession Statest.file_uploader json.loadjson.dumpensure_ascii=False 母資料前台索引slug 資料驗證上下架軟刪除 sort_order安全檔名自動備份 secrets.tomlTokenGitHub API Base64 編碼sha資料衝突 最小權限原則st.rerun()DuplicateWidgetID

30實作練習

(一)基礎操作
  • 安裝 Streamlit,建立 admin/app.py,啟動後台確認可在 localhost:8501 開啟
  • 建立左側選單(後台首頁 / 文章列表 / 新增文章 / 修改文章 / 刪除文章 / 發布)
  • 建立文章新增表單,新增一篇測試文章,確認 id 自動產生
  • 修改測試文章的標題,確認備份自動建立
  • 將測試文章下架,確認列表顯示狀態更新
  • 永久刪除測試文章(需勾選確認 + 輸入 id),確認刪除後重新整理
(二)功能修改
  • 增加圖片上傳欄位,確認產生安全相對路徑並寫入 JSON
  • 增加 slug 自動產生(依標題轉換,去除中文、空格改連字號)
  • 增加文章搜尋(關鍵字 + 分類篩選)
  • 實作上移 / 下移排序按鈕
  • 建立簡易密碼登入,確認錯誤密碼無法進入後台
(三)進階挑戰
  • 實作書籍管理(完整 CRUD + 母資料自動產生前台索引)
  • 建立 GitHub 發布功能,確認 commit 出現在 Repository
  • 部署後台至 Streamlit Cloud,並加入 Cloudflare Access 保護
  • 建立操作稽核紀錄(每次 CRUD 操作記錄至 logs.json)

Streamlit 是伺服器端執行的 Python 框架,無法在瀏覽器裡直接執行,所以本章的挑戰任務(04 分頁)跟第四、六章一樣改成「程式碼診斷」——針對 Session State、資料驗證與 GitHub 發布安全設計選擇題;示範分頁則用 HTML/CSS/JS 重現同一套 CRUD 操作邏輯,讓你先體會後台操作的感覺,實際的 Streamlit 後台請在自己電腦安裝執行。

本章結語——本章完成了金融網站管理後台的核心架構。Streamlit 讓管理者透過 Python 表單新增、修改、刪除與發布網站內容,不需要直接編輯容易出錯的 JSON。透過 CRUD 設計,文章、書籍、題庫及工具資料都可以使用一致的管理流程。

讀者已學會:建立 Streamlit 側邊選單介面、使用 st.form 集中表單提交、用 Session State 保存操作狀態、讀取與寫入 JSON(含中文保留)、產生唯一 id、驗證標題/slug/布林值、上下架(軟刪除)、安全永久刪除(雙重確認)、sort_order 排序管理、圖片上傳產生安全相對路徑、JSON 自動備份、從母資料產生前台索引,以及使用 GitHub API + secrets.toml 發布內容。

下一章進入金融工具的核心實作:債券評價與利率風險分析器。讀者將使用 Python 與 JavaScript 計算債券現金流、定價公式、到期殖利率(YTM)、麥考利存續期間、修正存續期間及凸性,並整合至金融網站前台。

模擬管理後台

重要提醒:Streamlit 是伺服器端執行的 Python 框架,沒辦法在瀏覽器裡直接執行,所以這不是真的 Streamlit。下面用 HTML/CSS/JavaScript 重現了「新增文章 → 驗證 → 列表 → 上下架 → 刪除」這一整套操作邏輯(欄位驗證規則跟課本 validate_article() 完全對應),讓你能實際體驗後台操作的感覺。真正上課請在自己電腦安裝 Streamlit 後執行 admin/app.py。

  localhost:8501 — 金融網站管理後台(模擬)

後台首頁

新增文章

文章列表

id標題分類狀態操作

JSON 預覽(等同於 data/articles.json 的內容)



      

觀念測驗

共 15 題選擇題,涵蓋本章所有核心觀念。每題作答後會立即顯示解析。

0 / 15 已作答

挑戰任務:程式碼診斷

Streamlit 沒辦法直接在瀏覽器裡執行,所以這裡不是「執行測試」,而是「程式碼診斷」——看一段藏有問題的 Python 程式碼,判斷「這樣寫會發生什麼問題」,選完立即看到詳解與正確寫法。

任務一:Session State 與重新執行特性

對應教材「六、Streamlit 的重新執行特性與 Session State」

難度:理解

下面這段程式碼片段,藏著 Streamlit 初學者最常踩的一個坑:


        

這樣寫最可能發生什麼問題?

任務二:資料驗證與安全性

對應教材「十一、建立唯一識別碼」與「十二、資料驗證」

難度:診斷

下面這段是資料驗證或 id 產生邏輯,但缺少了一個重要的檢查:


        

這樣寫最可能發生什麼問題?

任務三:GitHub 發布與安全性

對應教材「二十一、設定 secrets.toml 與 GitHub 發布」

難度:診斷

下面是後台程式在處理 GitHub 發布時的一段程式碼:


        

這樣做,會發生什麼問題?