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(或受保護雲端) |
前台(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 | 刪除完成後清除 |
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/ |
| ⑤ | 寫入新 JSON | save_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
每次寫入 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建立文章資料母檔
[
{
"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": "複利是利息持續加入本金後,再產生下一期報酬的過程。"
}
]
| 欄位 | 型態 | 必填 | 說明與規則 |
|---|---|---|---|
| id | string | 是 | 唯一識別碼,不可重複、不可修改 |
| slug | string | 是 | 只允許英文小寫、數字、連字號,不可重複 |
| cover_image | string | 否 | 相對路徑,不得使用本機絕對路徑 |
| active | boolean | 是 | true = 上架,false = 下架(必須是布林值) |
| sort_order | number | 是 | 顯示順序,整數,數字越小越前面 |
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-yield | bond price yield | 不允許空格 |
| retirement-planning-101 | Bond_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('文章修改完成。')
表單的 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/webp | type=['jpg','jpeg','png','webp'] |
| 安全檔名 | 依 slug 產生英文小寫檔名 | re.sub(r'[^a-z0-9-]', '', slug) |
| 相對路徑 | 儲存 images/xxx.jpg,而非 C:\xxx.jpg | f'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)
# 每次修改書籍後自動呼叫此函數
每次修改書籍母資料後,應立即呼叫 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 | 不可空白(解析對學習很重要) |
答案分布不均衡(例如 80% 選A)會降低題庫品質,管理後台可加入答案分布統計,提醒管理者調整。
21設定 secrets.toml 與 GitHub 發布
# 此檔案必須加入 .gitignore!絕對不能上傳 GitHub [github] token = "YOUR_GITHUB_TOKEN" ← Fine-grained Token(最小權限) owner = "YOUR_GITHUB_ACCOUNT" repo = "financial-platform" branch = "main" # 若有後台登入密碼 [admin] password = "YOUR_ADMIN_PASSWORD"
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() 顯示成功 |
| 401 | Token 錯誤或過期 | 確認 Token 有效;重新產生 |
| 403 | Token 權限不足或速率限制 | 確認 Token 有 Contents 寫入權限 |
| 409 | sha 已過期(版本衝突) | 重新取得最新 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() # 密碼錯誤時停止執行
# ... 其他功能 ...
正式對外開放的管理後台應考慮:身分驗證服務、多因素驗證(MFA)、角色權限、登入紀錄、Session 逾時、Cloudflare Access 保護。簡易密碼登入只適合低風險的個人教學場景。
25多人操作與資料衝突
| 風險情境 | 後果 | 降低風險的方法 |
|---|---|---|
| 兩人同時讀取、各自修改、先後儲存 | 後儲存者覆蓋前一位管理者的修改 | 限制同時只有一位管理者;儲存前先重新讀取 |
| 網路中斷在儲存到 GitHub 時 | 本機已修改,GitHub 未更新 | 每次操作都先備份;操作後查看 GitHub commit |
本書主要設計為單人管理後台。若需要多人同時操作,應考慮改用 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 401 | Token 錯誤、過期或已撤銷 | 確認 Token 有效,重新設定 |
| GitHub API 409 | sha 過期(多人同時修改) | 重新取得最新 sha 再提交 |
28後台安全檢查表
| 類別 | 檢查項目 |
|---|---|
| ① 資料安全 | 儲存前自動建立備份、id 不可重複、slug 不可重複、圖片路徑使用相對路徑、active 使用布林值 |
| ② 帳號安全 | 後台不公開或設有登入控制、Token 使用最小必要權限、secrets.toml 未上傳 GitHub、.gitignore 已排除 |
| ③ 發布安全 | 發布前本機預覽、列出將修改的檔案讓管理者確認、commit 訊息清楚、確認 Cloudflare 部署完成 |
29關鍵字
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。
後台首頁
新增文章
文章列表
| id | 標題 | 分類 | 狀態 | 操作 |
|---|
JSON 預覽(等同於 data/articles.json 的內容)
—觀念測驗
共 15 題選擇題,涵蓋本章所有核心觀念。每題作答後會立即顯示解析。
挑戰任務:程式碼診斷
Streamlit 沒辦法直接在瀏覽器裡執行,所以這裡不是「執行測試」,而是「程式碼診斷」——看一段藏有問題的 Python 程式碼,判斷「這樣寫會發生什麼問題」,選完立即看到詳解與正確寫法。
任務一:Session State 與重新執行特性
對應教材「六、Streamlit 的重新執行特性與 Session State」
下面這段程式碼片段,藏著 Streamlit 初學者最常踩的一個坑:
這樣寫最可能發生什麼問題?
任務二:資料驗證與安全性
對應教材「十一、建立唯一識別碼」與「十二、資料驗證」
下面這段是資料驗證或 id 產生邏輯,但缺少了一個重要的檢查:
這樣寫最可能發生什麼問題?
任務三:GitHub 發布與安全性
對應教材「二十一、設定 secrets.toml 與 GitHub 發布」
下面是後台程式在處理 GitHub 發布時的一段程式碼:
這樣做,會發生什麼問題?