diff --git a/js/api.js b/js/api.js new file mode 100644 index 0000000..b656630 --- /dev/null +++ b/js/api.js @@ -0,0 +1,270 @@ +/** + * API - API 呼叫函式 + * 包含所有與後端 API 的通訊邏輯 + */ + +import { API_BASE_URL } from './config.js'; +import { showCopyableError } from './utils.js'; + +// ==================== LLM API ==================== + +/** + * 獲取當前選擇的 Ollama 模型 + * @returns {string} - 模型名稱 + */ +function getOllamaModel() { + return localStorage.getItem('selectedOllamaModel') || 'deepseek-r1:8b'; +} + +/** + * 調用 Claude/Ollama LLM API + * @param {string} prompt - Prompt 內容 + * @param {string} api - API 類型('ollama' 或其他) + * @returns {Promise} - 解析後的 JSON 回應 + */ +export async function callClaudeAPI(prompt, api = 'ollama') { + try { + // 準備請求資料 + const requestData = { + api: api, + prompt: prompt, + max_tokens: 2000 + }; + + // 如果使用 Ollama API,加入選擇的模型 + if (api === 'ollama') { + requestData.model = getOllamaModel(); + } + + // 調用後端 Flask API,避免 CORS 錯誤 + const response = await fetch(`${API_BASE_URL}/llm/generate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestData) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `API 請求失敗: ${response.status}`); + } + + const data = await response.json(); + + if (!data.success) { + throw new Error(data.error || 'API 調用失敗'); + } + + // 清理 JSON 代碼塊標記 + let responseText = data.text; + responseText = responseText.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim(); + return JSON.parse(responseText); + } catch (error) { + console.error('Error calling LLM API:', error); + + // 嘗試解析更詳細的錯誤訊息 + let errorDetails = error.message; + try { + const errorJson = JSON.parse(error.message); + errorDetails = JSON.stringify(errorJson, null, 2); + } catch (e) { + // 不是 JSON,使用原始訊息 + } + + // 創建可複製的錯誤對話框 + showCopyableError({ + title: 'AI 生成錯誤', + message: error.message, + details: errorDetails, + suggestions: [ + 'Flask 後端已啟動 (python start_server.py)', + '已在 .env 文件中配置有效的 LLM API Key', + '網路連線正常', + '嘗試使用不同的 LLM API (DeepSeek 或 OpenAI)' + ] + }); + + throw error; + } +} + +// ==================== Position API ==================== + +/** + * 保存崗位至崗位清單 + * @param {Object} positionData - 崗位資料 + * @returns {Promise} - API 回應 + */ +export async function savePositionToList(positionData) { + const response = await fetch(`${API_BASE_URL}/positions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(positionData) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || '保存失敗'); + } + + return await response.json(); +} + +/** + * 載入崗位清單 + * @returns {Promise} - 崗位清單 + */ +export async function loadPositionList() { + const response = await fetch(`${API_BASE_URL}/position-list`); + + if (!response.ok) { + throw new Error('載入崗位清單失敗'); + } + + const data = await response.json(); + return data.data || []; +} + +/** + * 獲取單一崗位描述 + * @param {string} positionCode - 崗位編號 + * @returns {Promise} - 崗位描述資料 + */ +export async function getPositionDescription(positionCode) { + const response = await fetch(`${API_BASE_URL}/position-descriptions/${positionCode}`); + + if (!response.ok) { + if (response.status === 404) { + return null; // 未找到描述 + } + throw new Error('載入崗位描述失敗'); + } + + const data = await response.json(); + return data.data; +} + +/** + * 保存崗位描述 + * @param {Object} descData - 崗位描述資料 + * @returns {Promise} - API 回應 + */ +export async function savePositionDescription(descData) { + const response = await fetch(`${API_BASE_URL}/position-descriptions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(descData) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || '保存崗位描述失敗'); + } + + return await response.json(); +} + +// ==================== CSV API ==================== + +/** + * 下載崗位 CSV 範本 + */ +export async function downloadPositionCSVTemplate() { + window.location.href = `${API_BASE_URL}/positions/csv-template`; +} + +/** + * 下載職務 CSV 範本 + */ +export async function downloadJobCSVTemplate() { + window.location.href = `${API_BASE_URL}/jobs/csv-template`; +} + +/** + * 匯入崗位 CSV + * @param {File} file - CSV 檔案 + * @returns {Promise} - API 回應 + */ +export async function importPositionsCSV(file) { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch(`${API_BASE_URL}/positions/import-csv`, { + method: 'POST', + body: formData + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'CSV 匯入失敗'); + } + + return await response.json(); +} + +/** + * 匯入職務 CSV + * @param {File} file - CSV 檔案 + * @returns {Promise} - API 回應 + */ +export async function importJobsCSV(file) { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch(`${API_BASE_URL}/jobs/import-csv`, { + method: 'POST', + body: formData + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'CSV 匯入失敗'); + } + + return await response.json(); +} + +/** + * 匯出完整崗位資料 + */ +export async function exportCompletePositionData() { + window.location.href = `${API_BASE_URL}/position-list/export`; +} + +// ==================== Ollama Connection Test ==================== + +/** + * 測試 Ollama 連線 + * @returns {Promise} - 連線是否成功 + */ +export async function testOllamaConnection() { + try { + const response = await fetch(`${API_BASE_URL}/llm/generate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + api: 'ollama', + model: getOllamaModel(), + prompt: '請回答:「連線測試成功」', + max_tokens: 50 + }) + }); + + if (!response.ok) { + return false; + } + + const data = await response.json(); + return data.success === true; + } catch (error) { + console.error('Ollama 連線測試失敗:', error); + return false; + } +} diff --git a/js/config.js b/js/config.js new file mode 100644 index 0000000..e5b0875 --- /dev/null +++ b/js/config.js @@ -0,0 +1,82 @@ +/** + * Configuration - 設定檔 + * 包含 API 端點、常數定義、資料對應表 + */ + +// ==================== API Configuration ==================== +export const API_BASE_URL = '/api'; + +// ==================== 下拉選單資料 (從 Excel 提取) ==================== + +// 事業體 +export const businessUnits = [ + '半導體事業群', '汽車事業體', '法務室', '岡山製造事業體', '產品事業體', + '晶圓三廠', '集團人資行政事業體', '集團財務事業體', '集團會計事業體', + '集團資訊事業體', '新創事業體', '稽核室', '總經理室', '總品質事業體', '營業事業體' +]; + +// 處級單位 +export const deptLevel1Units = [ + '半導體事業群', '汽車事業體', '法務室', '生產處', '岡山製造事業體', '封裝工程處', + '副總辦公室', '測試工程與研發處', '資材處', '廠務與環安衛管理處', '產品事業體', + '先進產品事業處', '成熟產品事業處', '晶圓三廠', '製程工程處', '集團人資行政事業體', + '集團財務事業體', '岡山強茂財務處', '集團會計事業體', '岡山會計處', '集團會計處', + '集團資訊事業體', '資安行動小組', '資訊一處', '資訊二處', '新創事業體', + '中低壓產品研發處', '研發中心', '高壓產品研發處', '稽核室', '總經理室', + 'ESG專案辦公室', '專案管理室', '總品質事業體', '營業事業體', '商業開發暨市場應用處', + '海外銷售事業處', '全球技術服務處', '全球行銷暨業務支援處', '大中華區銷售事業處' +]; + +// 部級單位 +export const deptLevel2Units = [ + '生產部', '生產企劃部', '岡山品質管制部', '製程工程一部', '製程工程二部', '設備一部', + '設備二部', '工業工程部', '測試工程部', '新產品導入部', '研發部', '採購部', + '外部資源部', '生管部', '原物料控制部', '廠務部', '產品管理部(APD)', '產品管理部(MPD)', + '品質部', '製造部', '廠務部(Fab3)', '工程一部', '工程二部', '工程三部', + '製程整合部(Fab3)', '行政總務管理部', '招募任用部', '訓練發展部', '薪酬管理部', + '岡山強茂財務部', '會計部', '管理會計部', '集團合併報表部', '應用系統部', + '電腦整合製造部', '系統網路服務部', '資源管理部', '客戶品質管理部', '產品品質管理部', + '品質系統及客戶工程整合部', '封測外包品質管理部', '品質保證部', '日本區暨代工業務部', + '歐亞區業務部', '韓國區業務部-韓國區', '美洲區業務部', '應用工程部(GTS)', '系統工程部', + '特性測試部', '業務生管部', '市場行銷企劃部', 'MOSFET晶圓採購部', '台灣區業務部', + '業務一部', '業務二部' +]; + +// 崗位名稱 +export const positionNames = [ + '營運長', '營運長助理', '副總經理', '專案經理', '經副理', '法務專員', '專利工程師', + '處長', '專員', '課長', '組長', '班長', '副班長', '作業員', '工程師', '副總經理助理', + '副理', '專案經副理', '顧問', '人資長', '助理', '財務長', '專案副理', '會計長', + '資訊長', '主任', '總裁', '總經理', '專員/工程師', '經理', '技術經副理', '處長/資深經理' +]; + +// ==================== 資料對應表 ==================== + +// 崗位類別代碼對應中文名稱 +export const categoryMap = { + '01': '技術職', + '02': '管理職', + '03': '業務職', + '04': '行政職' +}; + +// 崗位性質代碼對應中文名稱 +export const natureMap = { + 'FT': '全職', + 'PT': '兼職', + 'CT': '約聘', + 'IN': '實習' +}; + +// 職務類別代碼對應中文名稱 +export const jobCategoryMap = { + 'MGR': '管理職', + 'TECH': '技術職', + 'SALE': '業務職', + 'ADMIN': '行政職', + 'RD': '研發職', + 'PROD': '生產職' +}; + +// ==================== Toast 持續時間 ==================== +export const TOAST_DURATION = 3000; diff --git a/js/utils.js b/js/utils.js new file mode 100644 index 0000000..af3ba44 --- /dev/null +++ b/js/utils.js @@ -0,0 +1,206 @@ +/** + * Utilities - 工具函式 + * 包含 XSS 防護、表單欄位工具、UI 回饋工具 + */ + +import { TOAST_DURATION } from './config.js'; + +// ==================== XSS 防護工具 ==================== + +/** + * 消毒 HTML 字串,防止 XSS 攻擊 + * @param {string} str - 需要消毒的字串 + * @returns {string} - 安全的字串 + */ +export function sanitizeHTML(str) { + if (str === null || str === undefined) return ''; + const temp = document.createElement('div'); + temp.textContent = str; + return temp.innerHTML; +} + +/** + * 安全設定元素文字內容 + * @param {HTMLElement} element - 目標元素 + * @param {string} text - 文字內容 + */ +export function safeSetText(element, text) { + if (element) { + element.textContent = text; + } +} + +// ==================== 表單欄位工具 ==================== + +/** + * 只在欄位為空時填入值 + * @param {string} elementId - DOM 元素 ID + * @param {string} value - 要填入的值 + * @returns {boolean} - 是否有填入值 + */ +export function fillIfEmpty(elementId, value) { + const el = document.getElementById(elementId); + if (el && !el.value.trim() && value) { + el.value = value; + return true; + } + return false; +} + +/** + * 獲取欄位當前值 + * @param {string} elementId - DOM 元素 ID + * @returns {string} - 欄位值(已 trim) + */ +export function getFieldValue(elementId) { + const el = document.getElementById(elementId); + return el ? el.value.trim() : ''; +} + +/** + * 獲取空白欄位列表 + * @param {string[]} fieldIds - 欄位 ID 陣列 + * @returns {string[]} - 空白欄位 ID 陣列 + */ +export function getEmptyFields(fieldIds) { + return fieldIds.filter(id => !getFieldValue(id)); +} + +// ==================== UI 回饋工具 ==================== + +/** + * 顯示 Toast 提示訊息 + * @param {string} message - 訊息內容 + * @param {number} duration - 顯示時長(毫秒),預設 3000 + */ +export function showToast(message, duration = TOAST_DURATION) { + const existingToast = document.querySelector('.toast'); + if (existingToast) { + existingToast.remove(); + } + + const toast = document.createElement('div'); + toast.className = 'toast'; + toast.innerHTML = ` + + + + ${sanitizeHTML(message)} + `; + document.body.appendChild(toast); + + setTimeout(() => toast.classList.add('show'), 10); + setTimeout(() => { + toast.classList.remove('show'); + setTimeout(() => toast.remove(), 300); + }, duration); +} + +/** + * 設定按鈕載入狀態 + * @param {HTMLElement} btn - 按鈕元素 + * @param {boolean} loading - 是否載入中 + */ +export function setButtonLoading(btn, loading) { + if (loading) { + btn.disabled = true; + btn.innerHTML = '
AI 生成中...'; + } else { + btn.disabled = false; + btn.innerHTML = '✨ I\'m feeling lucky'; + } +} + +// ==================== 錯誤處理工具 ==================== + +/** + * 顯示可複製的錯誤對話框 + * @param {Object} options - 錯誤選項 + * @param {string} options.title - 錯誤標題 + * @param {string} options.message - 錯誤訊息 + * @param {string} options.details - 錯誤詳情 + * @param {string[]} options.suggestions - 建議列表 + */ +export function showCopyableError(options) { + const { title, message, details, suggestions } = options; + + // 移除舊的錯誤對話框 + const existingModal = document.getElementById('errorModal'); + if (existingModal) { + existingModal.remove(); + } + + const modalHTML = ` + + `; + + document.body.insertAdjacentHTML('beforeend', modalHTML); +} + +/** + * 關閉錯誤對話框 + * @param {HTMLElement} button - 按鈕元素 + */ +export function closeErrorModal(button) { + const modal = button.closest('.modal-overlay'); + if (modal) { + modal.remove(); + } +} + +/** + * 複製錯誤詳情到剪貼板 + */ +export function copyErrorDetails() { + const modal = document.getElementById('errorModal'); + if (!modal) return; + + const errorText = modal.querySelector('.modal-body').textContent; + navigator.clipboard.writeText(errorText).then(() => { + showToast('錯誤詳情已複製到剪貼板'); + }).catch(err => { + console.error('複製失敗:', err); + }); +} + +// 將函式掛載到 window 上以便內聯事件處理器使用 +if (typeof window !== 'undefined') { + window.closeErrorModal = closeErrorModal; + window.copyErrorDetails = copyErrorDetails; +}