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('