diff --git a/CLAUDE.md b/CLAUDE.md
index edfb954..fd9c6b6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -137,8 +137,8 @@ frontend/
scheduleCompare.js # 比較已儲存班表 Modal(人員異動/填補率/勤務設定差異)
exportWeekFilter.js # 匯出週次篩選(getExportData,供複製/Excel/PDF/人員Excel 共用)
personTaskStats.js # 值勤統計 Modal(人員 × 勤務次數,加總 + 每週明細,Excel/PDF 匯出共用同一份計算)
- pdfExport.js # PDF 匯出(html2canvas + jsPDF,支援每頁 N 週自動分頁,末頁附值勤統計;若後端有設定 PDF_PAYLOAD_ROOT_SECRET 會額外把班表資料加密嵌入 PDF metadata)
- pdfImport.js # 從匯出的 PDF 檔案讀出隱藏資料還原班表(需搭配 pdf-lib 解析 metadata + 後端解密)
+ pdfExport.js # PDF 匯出(html2canvas + jsPDF,支援每頁 N 週自動分頁,末頁附值勤統計;若後端有設定 PDF_PAYLOAD_ROOT_SECRET 會額外把 { schedule, settings }(班表+目前設定檔的人員/勤務設定)加密嵌入 PDF metadata)
+ pdfImport.js # 從匯出的 PDF 檔案讀出隱藏資料還原班表(需搭配 pdf-lib 解析 metadata + 後端解密);若內含 settings 會詢問要套用到目前設定檔還是建立新設定檔(splitPayload() 相容改版前只有班表陣列的舊格式 PDF)
scheduleShare.js # 分享班表 Modal:產生免登入連結(可選只分享單一人員、可設有效期限),並列出/撤銷已產生的連結
sharedView.js # 免登入分享連結的頁面(接管整個 document.body,不含後台功能)
scheduleGenerator.js # 前端排班產生流程
diff --git a/frontend/src/features/schedule/pdfExport.js b/frontend/src/features/schedule/pdfExport.js
index af3c3a8..faa3316 100644
--- a/frontend/src/features/schedule/pdfExport.js
+++ b/frontend/src/features/schedule/pdfExport.js
@@ -208,10 +208,14 @@ export async function exportToPdf() {
activeStyle = null;
}
- // 若後端有設定 PDF_PAYLOAD_SECRET,把這份班表資料加密後嵌入 PDF 的 keywords 欄位——
- // 一般開啟 PDF 完全看不到,只有本系統的「從 PDF 匯入班表」能解密還原。後端未設定
+ // 若後端有設定 PDF_PAYLOAD_SECRET,把這份班表資料連同目前設定檔的人員/勤務設定
+ // 一起加密後嵌入 PDF 的 keywords 欄位——一般開啟 PDF 完全看不到,只有本系統的
+ // 「從 PDF 匯入班表」能解密還原(連設定檔一起還原,不是只有班表本身)。後端未設定
// 該金鑰、或加密請求失敗,都靜默略過,不影響 PDF 本身正常匯出。
- const keywords = await buildKeywordsWithPayload(exportData);
+ const keywords = await buildKeywordsWithPayload({
+ schedule: exportData,
+ settings: getActiveProfile()?.settings || null,
+ });
if (keywords) pdf.setProperties({ keywords });
pdf.save(buildExportFilename(exportData, 'pdf'));
diff --git a/frontend/src/features/schedule/pdfImport.js b/frontend/src/features/schedule/pdfImport.js
index eaf9e35..1d707fd 100644
--- a/frontend/src/features/schedule/pdfImport.js
+++ b/frontend/src/features/schedule/pdfImport.js
@@ -1,9 +1,51 @@
-import { setGeneratedData, setCurrentScheduleName } from '../../state/appState.js';
+import { setGeneratedData, setCurrentScheduleName, getAppState, setAppState } from '../../state/appState.js';
import { extractScheduleFromPdfBytes } from '../../utils/pdfPayload.js';
import { api } from '../../api/client.js';
import { showToast } from '../../ui/toast.js';
+import { showConfirm, showInput } from '../../ui/modal.js';
+import { renderAll } from '../settings/settingsRenderer.js';
import { displaySchedule } from './scheduleGenerator.js';
+// 匯出時的隱藏資料格式是 { schedule, settings };改版前(舊格式)匯出的 PDF
+// 沒有這層包裝,data 本身就是班表陣列——沒有 settings 可還原,只還原班表。
+export const splitPayload = (data) => {
+ if (Array.isArray(data)) return { schedule: data, settings: null };
+ return { schedule: data?.schedule ?? null, settings: data?.settings ?? null };
+};
+
+// PDF 內含設定檔時,問使用者要套用到目前設定檔,還是另外建立一個新的;
+// 都不要就維持原設定檔不動(僅還原班表)。重新抓一次 profiles 列表+重繪,
+// 讓左側設定檔/人員/勤務 UI 跟後端同步(不呼叫 main.js 的 initApp,避免
+// 循環 import 和重複啟動狀態檢查計時器等 initApp 專屬的一次性副作用)。
+const applyImportedSettings = async (settings) => {
+ const applyToCurrent = await showConfirm(
+ `這份 PDF 內含設定檔(人員/勤務設定),要套用到目前的設定檔「${getAppState().activeProfile}」嗎?`
+ );
+
+ if (applyToCurrent) {
+ await api.put(`profiles/${getAppState().activeProfile}`, { settings });
+ showToast('已套用設定檔內容', 'success');
+ } else {
+ const name = await showInput('建立新設定檔', '');
+ if (!name) return;
+ if (getAppState().profiles[name]) {
+ showToast('該名稱已存在,設定檔內容未套用(僅還原班表)', 'warning');
+ return;
+ }
+ const created = await api.post('profiles', { name, settings });
+ if (!created) return;
+ await api.put('profiles/active', { name });
+ showToast(`已建立設定檔「${name}」`, 'success');
+ }
+
+ const data = await api.get('profiles');
+ if (data?.profiles) {
+ setAppState({ activeProfile: data.activeProfile, profiles: data.profiles });
+ sessionStorage.setItem('activeProfile', data.activeProfile);
+ renderAll();
+ }
+};
+
// 從一份由本系統匯出的 PDF 檔案讀出隱藏(加密)的班表資料並還原到畫面上,
// 效果等同重新產生一份班表:不綁定任何已儲存班表名稱,使用者可另外選擇「儲存班表」。
export async function importScheduleFromPdfFile(file) {
@@ -14,9 +56,14 @@ export async function importScheduleFromPdfFile(file) {
return;
}
- setGeneratedData(result.data);
+ const { schedule, settings } = splitPayload(result.data);
+ if (settings) {
+ await applyImportedSettings(settings);
+ }
+
+ setGeneratedData(schedule);
setCurrentScheduleName(null);
- const response = await api.post('render-schedule', result.data);
+ const response = await api.post('render-schedule', schedule);
if (response?.html) {
displaySchedule(response.html);
showToast('已從 PDF 匯入班表!', 'success');
diff --git a/frontend/src/features/schedule/pdfImport.test.js b/frontend/src/features/schedule/pdfImport.test.js
new file mode 100644
index 0000000..304442f
--- /dev/null
+++ b/frontend/src/features/schedule/pdfImport.test.js
@@ -0,0 +1,25 @@
+import { describe, it, expect } from 'vitest';
+import { splitPayload } from './pdfImport.js';
+
+describe('splitPayload', () => {
+ it('舊格式(純班表陣列)沒有 settings 可還原', () => {
+ const legacy = [{ week: 1 }];
+ expect(splitPayload(legacy)).toEqual({ schedule: legacy, settings: null });
+ });
+
+ it('新格式({ schedule, settings })正確拆出兩部分', () => {
+ const schedule = [{ week: 1 }];
+ const settings = { tasks: [], personnel: [] };
+ expect(splitPayload({ schedule, settings })).toEqual({ schedule, settings });
+ });
+
+ it('新格式但沒有 settings 欄位時 settings 為 null', () => {
+ const schedule = [{ week: 1 }];
+ expect(splitPayload({ schedule })).toEqual({ schedule, settings: null });
+ });
+
+ it('新格式但 settings 為 null(匯出時沒有作用中設定檔)時保持 null', () => {
+ const schedule = [{ week: 1 }];
+ expect(splitPayload({ schedule, settings: null })).toEqual({ schedule, settings: null });
+ });
+});
diff --git a/frontend/src/utils/escapeHtml.js b/frontend/src/utils/escapeHtml.js
index 3d56c76..495ba75 100644
--- a/frontend/src/utils/escapeHtml.js
+++ b/frontend/src/utils/escapeHtml.js
@@ -1,11 +1,14 @@
/**
* HTML 轉義,防止 XSS 注入。
+ * 一律先用 String() 轉成字串再轉義,不能只在 typeof === 'string' 時才處理——
+ * 否則陣列/物件會原樣通過這個函式,後面樣板字串插值時呼叫它們自己的
+ * toString()(例如陣列會 join 內容),繞過所有跳脫直接把原始 HTML 注入。
* @param {*} unsafe
* @returns {*}
*/
export const escapeHtml = (unsafe) => {
- if (typeof unsafe !== 'string') return unsafe;
- return unsafe
+ if (unsafe === null || unsafe === undefined) return unsafe;
+ return String(unsafe)
.replace(/&/g, '&')
.replace(//g, '>')
diff --git a/frontend/src/utils/escapeHtml.test.js b/frontend/src/utils/escapeHtml.test.js
new file mode 100644
index 0000000..3fec306
--- /dev/null
+++ b/frontend/src/utils/escapeHtml.test.js
@@ -0,0 +1,26 @@
+import { describe, it, expect } from 'vitest';
+import { escapeHtml } from './escapeHtml.js';
+
+describe('escapeHtml', () => {
+ it('轉義 &', () => expect(escapeHtml('a&b')).toBe('a&b'));
+ it('轉義 <', () => expect(escapeHtml('' });
+ expect(result).not.toContain('