Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
641c1c4
feat: 匯出檔名改用「班表名稱_日期範圍_時間戳記」,避免固定檔名互相覆蓋
Cody8722 Sep 4, 2026
0dbe2cf
fix: 匯出按鈕加上防連點,避免同時觸發兩個匯出流程互相干擾
Cody8722 Sep 5, 2026
2a464a6
feat: /api/status 加上假日 CDN/學校行事曆自動抓取的健康狀態回報
Cody8722 Sep 5, 2026
f65d8cf
fix: 修復人員進階設定 Modal 無法編輯每週班次上限的問題
Cody8722 Sep 5, 2026
6c83687
fix: 修復 undo/redo 未同步未儲存修改旗標、列印時編輯工具列外洩
Cody8722 Sep 5, 2026
d43bed8
fix: 拖曳對調改為對調實際放開的人員、修復刪除/改名設定檔後編輯狀態殘留
Cody8722 Sep 5, 2026
f27d762
feat: 匯出 PDF 時加密嵌入班表資料,支援從 PDF 反向匯入
Cody8722 Sep 5, 2026
3a63694
feat: 新增設定檔快速複製、自動更新健康狀態警示、離開頁面未儲存警告
Cody8722 Sep 5, 2026
73672f7
fix: 設定檔管理按鈕列在新增「複製」按鈕後排版跑掉
Cody8722 Sep 5, 2026
7f09105
fix: 瀏覽器列印仍會印出「已儲存的班表」清單與頁尾
Cody8722 Sep 5, 2026
f89e371
fix: 「從 PDF 匯入」按鈕缺少連續匯入的防呆
Cody8722 Sep 5, 2026
7a3023c
fix: 全部 7 個 Modal 的關閉鈕都寫著「關閉 (Esc)」,但按 Esc 大多沒反應
Cody8722 Sep 5, 2026
e513ce3
fix: 「產生智慧班表」按鈕上印的 ⌘↵ 快捷鍵從未真正實作
Cody8722 Sep 5, 2026
208bbc2
fix: 快捷鍵提示在 Windows/Linux 上仍寫死顯示 Mac 的 ⌘ 符號
Cody8722 Sep 5, 2026
4f64fdb
fix: 通用確認框補 Enter 確認、移除查無用途的 ⌘K 快捷鍵提示
Cody8722 Sep 5, 2026
3a069f9
feat: 新增班表分享連結,免登入查看整份班表或單一人員的班次
Cody8722 Sep 5, 2026
62d1554
feat: 分享連結加上有效期限(永久或 1-365 天後失效)
Cody8722 Sep 5, 2026
ae6fb28
feat: 分享班表 Modal 加上「已產生的連結」清單與撤銷功能
Cody8722 Sep 5, 2026
135645c
fix: 更新 E2E 匯出檔名測試以符合新的動態檔名規則
Cody8722 Sep 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ POST /api/generate-schedule
POST /api/render-schedule
GET /api/school-events
POST /api/school-events/refresh
POST /api/pdf-payload/encrypt
POST /api/pdf-payload/decrypt
POST /api/schedule-shares
GET /api/schedule-shares
DELETE /api/schedule-shares/:token
GET /api/schedule-shares/:token
GET /
GET /robots.txt
```
Expand All @@ -61,6 +67,7 @@ MongoDB 集合清單(`scheduleApp` 資料庫):
- `profiles`:設定檔與排班資料
- `holidays`:台灣國定假日(`_id` 為日期字串如 `2025-01-01`)
- `schoolEvents`:學校行事曆快取(`_id` 為學期代碼如 `1142`,7 天 TTL)
- `scheduleShares`:班表分享連結(`_id` 為隨機 token,記錄對應的 profile/scheduleName/personFilter,無過期機制)

---

Expand All @@ -85,15 +92,17 @@ backend/
app.js # Express 設定、middleware、路由掛載
config.js # 環境變數
validators.js # 輸入驗證(profile/schedule 名稱、settings)
db/connect.js # MongoDB 連線管理(profiles、holidays、schoolEvents 三個 collection)
routes/ # status, holidays, profiles, schedules, generate, schoolCalendar
db/connect.js # MongoDB 連線管理(profiles、holidays、schoolEvents、scheduleShares 四個 collection)
routes/ # status, holidays, profiles, schedules, generate, schoolCalendar, pdfPayload, shares
services/
scheduleAlgorithm.js # 排班核心演算法(純函數)
scheduleRenderer.js # HTML 渲染
holidayService.js # 假日快取、CDN 更新
schoolCalendar.js # 學校行事曆(記憶體 6h + MongoDB 7 天持久快取)
pdfPayloadCrypto.js # PDF 匯入/匯出隱藏資料的 AES-256-GCM 加解密(金鑰來自 PDF_PAYLOAD_SECRET)
repositories/
profileRepository.js # MongoDB CRUD(profiles、schedules)
shareRepository.js # 班表分享連結的 token 產生/查詢(scheduleShares collection)
tests/

frontend/
Expand All @@ -115,16 +124,20 @@ frontend/
escapeHtml.js
debounce.js
capacityStatus.js # 容量/填補率狀態計算
connectionStatus.js # 後端連線狀態顯示
connectionStatus.js # 後端連線狀態顯示;同時解讀 /api/status 的假日/學校行事曆自動更新結果,異常時顯示警示圖示(點擊看原因)
excelDownload.js # ExcelJS Workbook → Blob 觸發瀏覽器下載
exportFilename.js # 組合匯出檔名(班表名稱_日期範圍_時間戳記),PDF/Excel/人員Excel 共用
features/schedule/
personnelView.js # 人員月曆視圖 + Excel 下載
diffSummary.js # 變更摘要 Modal(buildDiff、共用 renderDiffSection)
editableSchedule.js # 可編輯班表(含單格重排、拖曳對調)
scheduleCompare.js # 比較已儲存班表 Modal(人員異動/填補率/勤務設定差異)
exportWeekFilter.js # 匯出週次篩選(getExportData,供複製/Excel/PDF/人員Excel 共用)
personTaskStats.js # 值勤統計 Modal(人員 × 勤務次數,加總 + 每週明細,Excel/PDF 匯出共用同一份計算)
pdfExport.js # PDF 匯出(html2canvas + jsPDF,支援每頁 N 週自動分頁,末頁附值勤統計)
pdfExport.js # PDF 匯出(html2canvas + jsPDF,支援每頁 N 週自動分頁,末頁附值勤統計;若後端有設定 PDF_PAYLOAD_SECRET 會額外把班表資料加密嵌入 PDF metadata)
pdfImport.js # 從匯出的 PDF 檔案讀出隱藏資料還原班表(需搭配 pdf-lib 解析 metadata + 後端解密)
scheduleShare.js # 分享班表 Modal:產生免登入連結(可選只分享單一人員、可設有效期限),並列出/撤銷已產生的連結
sharedView.js # 免登入分享連結的頁面(接管整個 document.body,不含後台功能)
scheduleGenerator.js # 前端排班產生流程
features/settings/
settingsRenderer.js # 設定頁渲染
Expand Down Expand Up @@ -185,9 +198,11 @@ MONGODB_URI=mongodb+srv://<user>:<password>@<cluster>.mongodb.net/
DB_NAME=scheduleApp # 預設值
PORT=3000 # 預設值
CORS_ORIGIN=* # 預設值;未設定時 config.js 也會 fallback 成 *,僅適合開發環境
PDF_PAYLOAD_SECRET= # 選填,64 字元 hex(32 bytes)。未設定時 PDF 匯入/匯出隱藏資料功能自動停用,其他功能不受影響
```

若未提供 `MONGODB_URI`,伺服器仍會啟動,但所有資料庫功能停用(會顯示警告)。
若未提供 `PDF_PAYLOAD_SECRET`,`/api/pdf-payload/encrypt`、`/api/pdf-payload/decrypt` 會回傳 503,前端的 PDF 匯入按鈕與匯出時的隱藏資料嵌入都會靜默跳過(不影響一般匯出/檢視)。

---

Expand Down Expand Up @@ -292,6 +307,15 @@ GitHub Actions(`.github/workflows/ci-cd.yml`)在推送到 `develop`/`release
但 `POST /api/generate-schedule`、`POST /api/render-schedule` **沒有**這層檢查:內部呼叫的 `getHolidaysForYear()` 在無 DB 時直接回傳空 `Map`,假日/行事曆資料會被靜默忽略、班表照樣「成功」產生,前端不會收到任何錯誤或警告。
診斷方式:`GET /api/status` 確認 `"database": "connected"` 還是 `"disconnected"`。

### 🟡 班表分享連結(`GET /api/schedule-shares/:token`)刻意不需要登入

這是全站唯一一個刻意設計成公開、無需任何驗證就能讀取的端點——分享連結的目的就是讓拿到連結的人不用進後台。安全性完全依賴 token 本身夠隨機(128 bits,`crypto.randomBytes(16)`),不是靠登入機制擋。

- 建立連結時可選「永久有效」或設定 1-365 天後失效(`expiresInDays`)。過期判斷在 `GET /api/schedule-shares/:token` 讀取當下即時檢查,不能只依賴 MongoDB TTL index(`expiresAt` 欄位,`expireAfterSeconds: 0`)——TTL 背景清除任務約每 60 秒才跑一次,不是精準即時刪除,永久連結因為沒有 `expiresAt` 欄位不受 TTL index 影響。
- 分享 Modal 內會列出該份班表已產生的所有連結(`GET /api/schedule-shares?profile=&scheduleName=`),可用「撤銷」按鈕呼叫 `DELETE /api/schedule-shares/:token` 立即刪除、讓連結失效。這兩個管理端點本身沒有額外驗證(跟系統其他管理端點一樣,整個後台沒有登入機制),只是回傳/操作 token 中繼資料,不會外洩班表內容。
- `personFilter` 的過濾**必須在後端做**(`shares.js` 的 `filterScheduleForPerson`),不能改成前端拿到完整班表 JSON 後才用 CSS/JS 隱藏其他人——那樣瀏覽器開發者工具的網路分頁還是看得到完整資料,等於沒做過濾。
- 分享連結對應的是**某一份已儲存班表的快照**(`profile` + `scheduleName`),不會跟著該班表之後的修改自動更新;若使用者事後編輯並重新儲存同名班表,舊分享連結會顯示新內容(因為是即時查 `getSchedule`,不是存快照),但若該班表被刪除,連結會直接 404。

### 🟡 Tailwind CDN 動態 HTML 限制

前端使用 Tailwind CDN(JIT 模式),**動態注入的 innerHTML 中的 `dark:` class 不會被編譯**。
Expand All @@ -308,6 +332,8 @@ GitHub Actions(`.github/workflows/ci-cd.yml`)在推送到 `develop`/`release
不會自動更新的是:**去年(含)以前**的資料、以及最初從 `holidays/*.json` 植入(無 `source:'cdn'` 標記)的資料。要強制整批重抓三年資料,呼叫 `POST /api/holidays/reseed`(清空整個 collection + 記憶體快取,全部重新從 CDN 拉)。
直接修改 `holidays/*.json` 不會生效——只有 collection 是空的時候(`seedHolidays()`)才會讀本地檔案;reseed 走的是 CDN,不讀本地 JSON。

假日 CDN 與學校行事曆(`schoolCalendar.js`)這兩個自動抓資料機制若失敗,過去完全沒有地方看得出來(只寫 `debug` log,正式環境預設不顯示)。現在 `GET /api/status` 會回報 `holidaysLastRefresh`(最近一次自動更新每個年份的成功/筆數/錯誤)與 `schoolCalendarLastFetch`(最近一次即時抓取的成功與筆數;`eventCount: 0` 通常代表學校網站格式已改版,regex 抓不到東西了)。

### 🟡 backend/tests/ 的端點路徑

測試直接打 `/api/status`(含前綴),改動路由時要對照確認測試路徑一致。
Expand Down
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@ MONGODB_URI=mongodb+srv://<user>:<password>@<cluster>.mongodb.net/
DB_NAME=scheduleApp
PORT=3000
CORS_ORIGIN=*

# 選填:PDF 匯入/匯出的隱藏資料加密金鑰。未設定時該功能停用(其他功能不受影響)。
# 必須是 64 字元 hex 字串(32 bytes),可用下列指令產生:
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
PDF_PAYLOAD_SECRET=
4 changes: 4 additions & 0 deletions backend/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const profilesRouter = require('./routes/profiles');
const schedulesRouter = require('./routes/schedules');
const generateRouter = require('./routes/generate');
const schoolCalendarRouter = require('./routes/schoolCalendar');
const pdfPayloadRouter = require('./routes/pdfPayload');
const sharesRouter = require('./routes/shares');

const app = express();

Expand Down Expand Up @@ -100,6 +102,8 @@ app.use(profilesRouter);
app.use(schedulesRouter);
app.use(generateRouter);
app.use(schoolCalendarRouter);
app.use(pdfPayloadRouter);
app.use(sharesRouter);

// robots.txt(爬蟲不索引 API 路徑)
app.get('/robots.txt', (req, res) => {
Expand Down
3 changes: 3 additions & 0 deletions backend/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const PORT = process.env.PORT || 3000;
const MONGODB_URI = process.env.MONGODB_URI;
const DB_NAME = process.env.DB_NAME || 'scheduleApp';
const CORS_ORIGIN = process.env.CORS_ORIGIN || '*';
// PDF 匯入/匯出的隱藏資料加密金鑰:64 字元 hex 字串(32 bytes)。未設定時該功能停用。
const PDF_PAYLOAD_SECRET = process.env.PDF_PAYLOAD_SECRET;

module.exports = {
SAFE_PROFILE_NAME_REGEX,
Expand All @@ -19,4 +21,5 @@ module.exports = {
MONGODB_URI,
DB_NAME,
CORS_ORIGIN,
PDF_PAYLOAD_SECRET,
};
9 changes: 9 additions & 0 deletions backend/src/db/connect.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ let db;
let configCollection;
let holidaysCollection;
let schoolEventsCollection;
let scheduleSharesCollection;
let isDbConnected = false;

if (MONGODB_URI) {
Expand Down Expand Up @@ -38,6 +39,8 @@ const getHolidaysCollection = () => holidaysCollection;

const getSchoolEventsCollection = () => schoolEventsCollection;

const getScheduleSharesCollection = () => scheduleSharesCollection;

const connect = async () => {
if (!client) return;
debugServer('正在連線至 MongoDB...');
Expand All @@ -48,6 +51,11 @@ const connect = async () => {
configCollection = db.collection('profiles');
holidaysCollection = db.collection('holidays');
schoolEventsCollection = db.collection('schoolEvents');
scheduleSharesCollection = db.collection('scheduleShares');
// TTL index:只會刪除「有 expiresAt 欄位且已過期」的文件,沒有這個欄位的永久
// 分享連結不受影響。MongoDB 背景清除任務約每 60 秒跑一次,不是精準即時刪除,
// 所以 GET /api/schedule-shares/:token 仍須自行檢查 expiresAt,不能只靠這個 index。
await scheduleSharesCollection.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
isDbConnected = true;
};

Expand Down Expand Up @@ -87,6 +95,7 @@ module.exports = {
getConfigCollection,
getHolidaysCollection,
getSchoolEventsCollection,
getScheduleSharesCollection,
connect,
disconnect,
ensureConfigDocument,
Expand Down
4 changes: 2 additions & 2 deletions backend/src/repositories/profileRepository.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ const setActiveProfile = async (name) => {
// matchedCount > 0 但 modifiedCount 為 0 代表本來就是目前的作用中設定檔,視為成功(冪等)
};

const createProfile = async (name) => {
const createProfile = async (name, settings = { tasks: [], personnel: [] }) => {
const configCollection = getConfigCollection();
const update = {
$set: {
[`profiles.${name}`]: { settings: { tasks: [], personnel: [] }, schedules: {} },
[`profiles.${name}`]: { settings, schedules: {} },
},
};
const result = await configCollection.updateOne(
Expand Down
41 changes: 41 additions & 0 deletions backend/src/repositories/shareRepository.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
const crypto = require('crypto');
const { getScheduleSharesCollection } = require('../db/connect');

// token 本身當 _id:128 bits 隨機值,猜中機率可忽略不計,不需要額外唯一性檢查。
// expiresInDays 為 null/undefined 時代表永久有效,不寫入 expiresAt 欄位——
// MongoDB 的 TTL index(見 db/connect.js)只會清掉「有 expiresAt 且已過期」的文件,
// 沒有這個欄位的永久連結完全不受影響。
const createShare = async (profile, scheduleName, personFilter, expiresInDays) => {
const token = crypto.randomBytes(16).toString('hex');
const collection = getScheduleSharesCollection();
const doc = {
_id: token,
profile,
scheduleName,
personFilter: personFilter || null,
createdAt: new Date(),
};
if (expiresInDays) {
doc.expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000);
}
await collection.insertOne(doc);
return token;
};

const getShare = async (token) => {
const collection = getScheduleSharesCollection();
return collection.findOne({ _id: token });
};

const listShares = async (profile, scheduleName) => {
const collection = getScheduleSharesCollection();
return collection.find({ profile, scheduleName }).sort({ createdAt: -1 }).toArray();
};

const deleteShare = async (token) => {
const collection = getScheduleSharesCollection();
const result = await collection.deleteOne({ _id: token });
return result.deletedCount > 0;
};

module.exports = { createShare, getShare, listShares, deleteShare };
39 changes: 39 additions & 0 deletions backend/src/routes/pdfPayload.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
'use strict';

const express = require('express');
const debug = require('debug')('app:pdfPayload');
const { encryptPayload, decryptPayload, isConfigured } = require('../services/pdfPayloadCrypto');

const router = express.Router();

// 把班表資料加密成一段 base64 字串,供前端嵌入匯出 PDF 的 metadata 欄位(人眼開啟 PDF 看不到)。
router.post('/api/pdf-payload/encrypt', (req, res) => {
if (!isConfigured()) return res.status(503).json({ message: 'PDF 加密功能未設定' });
try {
const { data } = req.body;
if (data === undefined) return res.status(400).json({ message: '缺少 data 欄位' });
const payload = encryptPayload(data);
res.json({ payload });
} catch (error) {
debug('加密失敗:', error);
res.status(500).json({ message: '加密時發生未預期的錯誤' });
}
});

// 把從 PDF metadata 讀出的 base64 字串解密還原成班表資料。
router.post('/api/pdf-payload/decrypt', (req, res) => {
if (!isConfigured()) return res.status(503).json({ message: 'PDF 加密功能未設定' });
try {
const { payload } = req.body;
if (typeof payload !== 'string' || !payload) {
return res.status(400).json({ message: '缺少 payload 欄位' });
}
const data = decryptPayload(payload);
res.json({ data });
} catch (error) {
debug('解密失敗(可能不是本系統匯出的 PDF,或內容已損毀):', error);
res.status(400).json({ message: '無法解析此 PDF,可能不是由本系統匯出,或內容已損毀' });
}
});

module.exports = router;
15 changes: 13 additions & 2 deletions backend/src/routes/profiles.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,23 @@ router.put('/api/profiles/active', async (req, res) => {
router.post('/api/profiles', async (req, res) => {
if (!getIsDbConnected()) return res.status(503).json({ message: '資料庫未連線' });
try {
const { name } = req.body;
const { name, settings } = req.body;
const validation = validateProfileName(name);
if (!validation.valid) {
return res.status(400).json({ message: validation.error });
}
await repo.createProfile(name);

// settings 為選填:不帶就沿用舊行為建立空白設定檔;帶了(複製現有設定檔用)就驗證後套用。
if (settings !== undefined) {
const settingsValidation = validateSettings(settings);
if (!settingsValidation.valid) {
return res.status(400).json({ message: settingsValidation.error });
}
await repo.createProfile(name, settings);
} else {
await repo.createProfile(name);
}

res.status(201).json({ message: '設定檔已新增' });
} catch (error) {
debugDb('新增設定檔失敗:', error);
Expand Down
Loading
Loading