backup: 完成 HR_position_ 表格前綴重命名與欄位對照表整理
變更內容: - 所有資料表加上 HR_position_ 前綴 - 整理完整欄位顯示名稱與 ID 對照表 - 模組化 JS 檔案 (admin.js, ai.js, csv.js 等) - 專案結構優化 (docs/, scripts/, tests/ 等) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
406
js/admin.js
Normal file
406
js/admin.js
Normal file
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* 管理功能模組
|
||||
* 處理使用者管理、系統設定和統計功能
|
||||
*/
|
||||
|
||||
import { CSVUtils } from './csv.js';
|
||||
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
// ==================== 使用者管理 ====================
|
||||
|
||||
let usersData = [
|
||||
{ employeeId: 'A001', name: '系統管理員', email: 'admin@company.com', role: 'superadmin', createdAt: '2024-01-01' },
|
||||
{ employeeId: 'A002', name: '人資主管', email: 'hr_manager@company.com', role: 'admin', createdAt: '2024-01-15' },
|
||||
{ employeeId: 'A003', name: '一般員工', email: 'employee@company.com', role: 'user', createdAt: '2024-02-01' }
|
||||
];
|
||||
let editingUserId = null;
|
||||
|
||||
export function showAddUserModal() {
|
||||
editingUserId = null;
|
||||
document.getElementById('userModalTitle').textContent = '新增使用者';
|
||||
document.getElementById('userEmployeeId').value = '';
|
||||
document.getElementById('userName').value = '';
|
||||
document.getElementById('userEmail').value = '';
|
||||
document.getElementById('userRole').value = '';
|
||||
document.getElementById('userEmployeeId').disabled = false;
|
||||
document.getElementById('userModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
export function editUser(employeeId) {
|
||||
const user = usersData.find(u => u.employeeId === employeeId);
|
||||
if (!user) return;
|
||||
|
||||
editingUserId = employeeId;
|
||||
document.getElementById('userModalTitle').textContent = '編輯使用者';
|
||||
document.getElementById('userEmployeeId').value = user.employeeId;
|
||||
document.getElementById('userEmployeeId').disabled = true;
|
||||
document.getElementById('userName').value = user.name;
|
||||
document.getElementById('userEmail').value = user.email;
|
||||
document.getElementById('userRole').value = user.role;
|
||||
document.getElementById('userModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
export function closeUserModal() {
|
||||
document.getElementById('userModal').style.display = 'none';
|
||||
editingUserId = null;
|
||||
}
|
||||
|
||||
export function saveUser(event) {
|
||||
event.preventDefault();
|
||||
const employeeId = document.getElementById('userEmployeeId').value;
|
||||
const name = document.getElementById('userName').value;
|
||||
const email = document.getElementById('userEmail').value;
|
||||
const role = document.getElementById('userRole').value;
|
||||
|
||||
if (!employeeId || !name || !email || !role) {
|
||||
if (typeof showToast === 'function') showToast('請填寫所有必填欄位');
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingUserId) {
|
||||
const index = usersData.findIndex(u => u.employeeId === editingUserId);
|
||||
if (index > -1) {
|
||||
usersData[index] = { ...usersData[index], name, email, role };
|
||||
if (typeof showToast === 'function') showToast('使用者已更新');
|
||||
}
|
||||
} else {
|
||||
if (usersData.some(u => u.employeeId === employeeId)) {
|
||||
if (typeof showToast === 'function') showToast('工號已存在');
|
||||
return;
|
||||
}
|
||||
usersData.push({
|
||||
employeeId,
|
||||
name,
|
||||
email,
|
||||
role,
|
||||
createdAt: new Date().toISOString().split('T')[0]
|
||||
});
|
||||
if (typeof showToast === 'function') showToast('使用者已新增');
|
||||
}
|
||||
|
||||
closeUserModal();
|
||||
renderUserList();
|
||||
}
|
||||
|
||||
export function deleteUser(employeeId) {
|
||||
if (confirm('確定要刪除此使用者嗎?')) {
|
||||
usersData = usersData.filter(u => u.employeeId !== employeeId);
|
||||
renderUserList();
|
||||
if (typeof showToast === 'function') showToast('使用者已刪除');
|
||||
}
|
||||
}
|
||||
|
||||
export function renderUserList() {
|
||||
const tbody = document.getElementById('userListBody');
|
||||
if (!tbody) return;
|
||||
|
||||
const roleLabels = {
|
||||
'superadmin': { text: '最高權限管理者', color: '#e74c3c' },
|
||||
'admin': { text: '管理者', color: '#f39c12' },
|
||||
'user': { text: '一般使用者', color: '#27ae60' }
|
||||
};
|
||||
|
||||
tbody.innerHTML = usersData.map(user => {
|
||||
const roleInfo = roleLabels[user.role] || { text: user.role, color: '#999' };
|
||||
const isSuperAdmin = user.role === 'superadmin';
|
||||
return `
|
||||
<tr>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #eee;">${sanitizeHTML(user.employeeId)}</td>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #eee;">${sanitizeHTML(user.name)}</td>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #eee;">${sanitizeHTML(user.email)}</td>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #eee;">
|
||||
<span style="background: ${roleInfo.color}; color: white; padding: 4px 8px; border-radius: 4px; font-size: 12px;">${roleInfo.text}</span>
|
||||
</td>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #eee;">${sanitizeHTML(user.createdAt)}</td>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #eee; text-align: center;">
|
||||
<button class="btn btn-secondary" style="padding: 4px 8px; font-size: 12px;" onclick="editUser('${user.employeeId}')">編輯</button>
|
||||
${!isSuperAdmin ? `<button class="btn btn-cancel" style="padding: 4px 8px; font-size: 12px;" onclick="deleteUser('${user.employeeId}')">刪除</button>` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
export function exportUsersCSV() {
|
||||
const headers = ['employeeId', 'name', 'email', 'role', 'createdAt'];
|
||||
CSVUtils.exportToCSV(usersData, 'users.csv', headers);
|
||||
if (typeof showToast === 'function') showToast('使用者清單已匯出!');
|
||||
}
|
||||
|
||||
// ==================== 用戶信息與登出功能 ====================
|
||||
|
||||
export function loadUserInfo() {
|
||||
const currentUser = localStorage.getItem('currentUser');
|
||||
|
||||
if (currentUser) {
|
||||
try {
|
||||
const user = JSON.parse(currentUser);
|
||||
|
||||
const userNameEl = document.getElementById('userName');
|
||||
if (userNameEl) {
|
||||
userNameEl.textContent = user.name || user.username;
|
||||
}
|
||||
|
||||
const userRoleEl = document.getElementById('userRole');
|
||||
if (userRoleEl) {
|
||||
let roleText = '';
|
||||
switch(user.role) {
|
||||
case 'user':
|
||||
roleText = '一般使用者 ★☆☆';
|
||||
break;
|
||||
case 'admin':
|
||||
roleText = '管理者 ★★☆';
|
||||
break;
|
||||
case 'superadmin':
|
||||
roleText = '最高管理者 ★★★';
|
||||
break;
|
||||
default:
|
||||
roleText = '一般使用者';
|
||||
}
|
||||
userRoleEl.textContent = roleText;
|
||||
}
|
||||
|
||||
const userAvatarEl = document.getElementById('userAvatar');
|
||||
if (userAvatarEl) {
|
||||
const avatarText = (user.name || user.username || 'U').charAt(0).toUpperCase();
|
||||
userAvatarEl.textContent = avatarText;
|
||||
}
|
||||
|
||||
console.log('用戶信息已載入:', user.name, user.role);
|
||||
} catch (error) {
|
||||
console.error('載入用戶信息失敗:', error);
|
||||
}
|
||||
} else {
|
||||
console.warn('未找到用戶信息,重定向到登入頁面');
|
||||
window.location.href = 'login.html';
|
||||
}
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
if (confirm('確定要登出系統嗎?')) {
|
||||
localStorage.removeItem('currentUser');
|
||||
if (typeof showToast === 'function') showToast('已成功登出系統');
|
||||
setTimeout(() => {
|
||||
window.location.href = 'login.html';
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Ollama 模型設定 ====================
|
||||
|
||||
export function saveOllamaModel() {
|
||||
const saveBtn = document.getElementById('saveModelBtn');
|
||||
if (saveBtn) {
|
||||
saveBtn.style.display = 'inline-flex';
|
||||
}
|
||||
hideAllMessages();
|
||||
}
|
||||
|
||||
export function saveOllamaModelWithConfirmation() {
|
||||
const reasonerRadio = document.getElementById('model-reasoner');
|
||||
const chatRadio = document.getElementById('model-chat');
|
||||
|
||||
let selectedModel = '';
|
||||
if (reasonerRadio && reasonerRadio.checked) {
|
||||
selectedModel = 'deepseek-reasoner';
|
||||
} else if (chatRadio && chatRadio.checked) {
|
||||
selectedModel = 'deepseek-chat';
|
||||
}
|
||||
|
||||
if (selectedModel) {
|
||||
localStorage.setItem('ollamaModel', selectedModel);
|
||||
|
||||
const saveBtn = document.getElementById('saveModelBtn');
|
||||
if (saveBtn) {
|
||||
saveBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
hideAllMessages();
|
||||
const successDiv = document.getElementById('modelSaveSuccess');
|
||||
if (successDiv) {
|
||||
successDiv.style.display = 'block';
|
||||
setTimeout(() => {
|
||||
successDiv.style.display = 'none';
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
console.log('Ollama 模型已設定為:', selectedModel);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadOllamaModel() {
|
||||
const savedModel = localStorage.getItem('ollamaModel') || 'deepseek-reasoner';
|
||||
|
||||
const reasonerRadio = document.getElementById('model-reasoner');
|
||||
const chatRadio = document.getElementById('model-chat');
|
||||
const gptossRadio = document.getElementById('model-gptoss');
|
||||
|
||||
if (savedModel === 'deepseek-reasoner' && reasonerRadio) {
|
||||
reasonerRadio.checked = true;
|
||||
} else if (savedModel === 'deepseek-chat' && chatRadio) {
|
||||
chatRadio.checked = true;
|
||||
} else if (savedModel === 'gpt-oss:120b' && gptossRadio) {
|
||||
gptossRadio.checked = true;
|
||||
}
|
||||
|
||||
console.log('已載入 Ollama 模型設定:', savedModel);
|
||||
}
|
||||
|
||||
export function getOllamaModel() {
|
||||
return localStorage.getItem('ollamaModel') || 'deepseek-reasoner';
|
||||
}
|
||||
|
||||
export function hideAllMessages() {
|
||||
const messages = ['connectionSuccess', 'connectionError', 'modelSaveSuccess'];
|
||||
messages.forEach(id => {
|
||||
const elem = document.getElementById(id);
|
||||
if (elem) elem.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
export async function testOllamaConnection() {
|
||||
const testBtn = document.getElementById('testConnectionBtn');
|
||||
const reasonerRadio = document.getElementById('model-reasoner');
|
||||
const chatRadio = document.getElementById('model-chat');
|
||||
|
||||
let selectedModel = '';
|
||||
if (reasonerRadio && reasonerRadio.checked) {
|
||||
selectedModel = 'deepseek-reasoner';
|
||||
} else if (chatRadio && chatRadio.checked) {
|
||||
selectedModel = 'deepseek-chat';
|
||||
}
|
||||
|
||||
if (!selectedModel) {
|
||||
if (typeof showToast === 'function') showToast('請先選擇一個模型');
|
||||
return;
|
||||
}
|
||||
|
||||
if (testBtn) {
|
||||
testBtn.disabled = true;
|
||||
testBtn.textContent = '測試中...';
|
||||
}
|
||||
|
||||
hideAllMessages();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/llm/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
api: 'ollama',
|
||||
model: selectedModel,
|
||||
prompt: '請用一句話說明你是誰',
|
||||
max_tokens: 100
|
||||
}),
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
const successDiv = document.getElementById('connectionSuccess');
|
||||
if (successDiv) {
|
||||
successDiv.style.display = 'block';
|
||||
setTimeout(() => {
|
||||
successDiv.style.display = 'none';
|
||||
}, 5000);
|
||||
}
|
||||
console.log('測試回應:', data.text);
|
||||
} else {
|
||||
throw new Error(data.error || '未知錯誤');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorDiv = document.getElementById('connectionError');
|
||||
const errorMsg = document.getElementById('connectionErrorMessage');
|
||||
if (errorDiv && errorMsg) {
|
||||
errorMsg.textContent = error.message;
|
||||
errorDiv.style.display = 'block';
|
||||
setTimeout(() => {
|
||||
errorDiv.style.display = 'none';
|
||||
}, 10000);
|
||||
}
|
||||
console.error('連線測試失敗:', error);
|
||||
} finally {
|
||||
if (testBtn) {
|
||||
testBtn.disabled = false;
|
||||
testBtn.innerHTML = `
|
||||
<svg viewBox="0 0 24 24" style="width: 18px; height: 18px; fill: currentColor;">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||
</svg>
|
||||
測試連線
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 崗位資料管理功能 ====================
|
||||
|
||||
export async function exportCompletePositionData() {
|
||||
try {
|
||||
if (typeof showToast === 'function') showToast('正在準備匯出資料...');
|
||||
window.location.href = API_BASE_URL + '/position-list/export';
|
||||
setTimeout(() => {
|
||||
if (typeof showToast === 'function') showToast('崗位資料匯出成功!');
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('匯出錯誤:', error);
|
||||
alert('匯出失敗: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshPositionStats() {
|
||||
try {
|
||||
if (typeof showToast === 'function') showToast('正在更新統計資料...');
|
||||
|
||||
const response = await fetch(API_BASE_URL + '/position-list');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const positions = result.data;
|
||||
const total = positions.length;
|
||||
const described = positions.filter(p => p.hasDescription).length;
|
||||
const undescribed = total - described;
|
||||
|
||||
const totalEl = document.getElementById('totalPositionsCount');
|
||||
const describedEl = document.getElementById('describedPositionsCount');
|
||||
const undescribedEl = document.getElementById('undescribedPositionsCount');
|
||||
|
||||
if (totalEl) totalEl.textContent = total;
|
||||
if (describedEl) describedEl.textContent = described;
|
||||
if (undescribedEl) undescribedEl.textContent = undescribed;
|
||||
|
||||
if (typeof showToast === 'function') showToast('統計資料已更新');
|
||||
} else {
|
||||
alert('更新統計失敗: ' + (result.error || '未知錯誤'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新統計錯誤:', error);
|
||||
alert('更新統計失敗: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 工具函數 ====================
|
||||
|
||||
function sanitizeHTML(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
const temp = document.createElement('div');
|
||||
temp.textContent = str;
|
||||
return temp.innerHTML;
|
||||
}
|
||||
|
||||
// 暴露到全域
|
||||
if (typeof window !== 'undefined') {
|
||||
window.showAddUserModal = showAddUserModal;
|
||||
window.editUser = editUser;
|
||||
window.closeUserModal = closeUserModal;
|
||||
window.saveUser = saveUser;
|
||||
window.deleteUser = deleteUser;
|
||||
window.renderUserList = renderUserList;
|
||||
window.exportUsersCSV = exportUsersCSV;
|
||||
window.logout = logout;
|
||||
window.testOllamaConnection = testOllamaConnection;
|
||||
window.saveOllamaModelWithConfirmation = saveOllamaModelWithConfirmation;
|
||||
window.exportCompletePositionData = exportCompletePositionData;
|
||||
window.refreshPositionStats = refreshPositionStats;
|
||||
}
|
||||
773
js/ai.js
Normal file
773
js/ai.js
Normal file
@@ -0,0 +1,773 @@
|
||||
/**
|
||||
* AI 生成功能模組
|
||||
* 處理 LLM API 調用和表單自動填充
|
||||
*/
|
||||
|
||||
import { DEFAULT_AI_PROMPTS } from './prompts.js';
|
||||
|
||||
// ==================== 工具函數 ====================
|
||||
|
||||
/**
|
||||
* 消毒 HTML 字串,防止 XSS 攻擊
|
||||
*/
|
||||
export function sanitizeHTML(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
const temp = document.createElement('div');
|
||||
temp.textContent = str;
|
||||
return temp.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全設定元素文字內容
|
||||
*/
|
||||
export function safeSetText(element, text) {
|
||||
if (element) {
|
||||
element.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 只在欄位為空時填入值
|
||||
*/
|
||||
export function fillIfEmpty(elementId, value) {
|
||||
const el = document.getElementById(elementId);
|
||||
if (el && !el.value.trim() && value) {
|
||||
el.value = value;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 獲取欄位當前值
|
||||
*/
|
||||
export function getFieldValue(elementId) {
|
||||
const el = document.getElementById(elementId);
|
||||
return el ? el.value.trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 獲取空白欄位列表
|
||||
*/
|
||||
export function getEmptyFields(fieldIds) {
|
||||
return fieldIds.filter(id => !getFieldValue(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 獲取 Ollama 模型選項
|
||||
*/
|
||||
export function getOllamaModel() {
|
||||
const select = document.getElementById('ollamaModel');
|
||||
return select ? select.value : 'llama3.2';
|
||||
}
|
||||
|
||||
// ==================== AI API 調用 ====================
|
||||
|
||||
/**
|
||||
* 調用 LLM API
|
||||
*/
|
||||
export async function callClaudeAPI(prompt, api = 'ollama') {
|
||||
try {
|
||||
const requestData = {
|
||||
api: api,
|
||||
prompt: prompt,
|
||||
max_tokens: 2000
|
||||
};
|
||||
|
||||
if (api === 'ollama') {
|
||||
requestData.model = getOllamaModel();
|
||||
}
|
||||
|
||||
const response = await fetch("/api/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 調用失敗');
|
||||
}
|
||||
|
||||
let responseText = data.text;
|
||||
|
||||
// 移除 markdown 代碼塊標記
|
||||
responseText = responseText.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim();
|
||||
|
||||
// 嘗試提取 JSON 物件
|
||||
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const allMatches = responseText.match(/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g);
|
||||
if (allMatches && allMatches.length > 0) {
|
||||
for (let i = allMatches.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const parsed = JSON.parse(allMatches[i]);
|
||||
if (Object.keys(parsed).length > 0) {
|
||||
return parsed;
|
||||
}
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
return JSON.parse(jsonMatch[0]);
|
||||
} catch (e) {
|
||||
// 繼續嘗試其他方法
|
||||
}
|
||||
}
|
||||
|
||||
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 app.py)',
|
||||
'已在 .env 文件中配置有效的 LLM API Key',
|
||||
'網路連線正常',
|
||||
'確認 Prompt 要求返回純 JSON 格式',
|
||||
'嘗試使用不同的 LLM API (切換到其他模型)',
|
||||
'檢查 LLM 模型是否支援繁體中文'
|
||||
]
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 按鈕狀態控制 ====================
|
||||
|
||||
export function setButtonLoading(btn, loading) {
|
||||
if (loading) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<div class="spinner"></div><span>AI 生成中...</span>';
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg><span>✨ I\'m feeling lucky</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== AI 幫我想功能 ====================
|
||||
|
||||
/**
|
||||
* 初始化所有 prompt 編輯器
|
||||
*/
|
||||
export function initializePromptEditors() {
|
||||
const modules = Object.keys(DEFAULT_AI_PROMPTS);
|
||||
modules.forEach(module => {
|
||||
const textarea = document.getElementById(`prompt_${module}`);
|
||||
if (textarea) {
|
||||
const savedPrompt = localStorage.getItem(`aiPrompt_${module}`);
|
||||
textarea.value = savedPrompt || DEFAULT_AI_PROMPTS[module];
|
||||
|
||||
textarea.addEventListener('input', () => {
|
||||
localStorage.setItem(`aiPrompt_${module}`, textarea.value);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置 prompt 為預設值
|
||||
*/
|
||||
export function resetPromptToDefault(module) {
|
||||
const textarea = document.getElementById(`prompt_${module}`);
|
||||
if (textarea && DEFAULT_AI_PROMPTS[module]) {
|
||||
textarea.value = DEFAULT_AI_PROMPTS[module];
|
||||
localStorage.removeItem(`aiPrompt_${module}`);
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('已重置為預設 Prompt');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 執行 AI 幫我想
|
||||
*/
|
||||
export async function executeAIHelper(module) {
|
||||
const container = document.querySelector(`.ai-helper-container[data-module="${module}"]`);
|
||||
const btn = container.querySelector('.ai-helper-btn');
|
||||
const textarea = document.getElementById(`prompt_${module}`);
|
||||
|
||||
if (!textarea || !textarea.value.trim()) {
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('請輸入 Prompt 指令', 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const originalHTML = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<div class="spinner"></div><span>AI 生成中...</span>';
|
||||
|
||||
try {
|
||||
const prompt = textarea.value.trim();
|
||||
const data = await callClaudeAPI(prompt);
|
||||
fillFormWithAIHelperResult(module, data);
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('AI 生成完成!已填入表單');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('AI Helper error:', error);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalHTML;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根據模組填入 AI 結果
|
||||
*/
|
||||
export function fillFormWithAIHelperResult(module, data) {
|
||||
const fieldMappings = {
|
||||
positionBasic: {
|
||||
positionCode: 'positionCode',
|
||||
positionName: 'positionName',
|
||||
positionCategory: 'positionCategory',
|
||||
positionNature: 'positionNature',
|
||||
headcount: 'headcount',
|
||||
positionLevel: 'positionLevel',
|
||||
positionDesc: 'positionDesc',
|
||||
positionRemark: 'positionRemark'
|
||||
},
|
||||
positionRecruit: {
|
||||
education: 'recruitEducation',
|
||||
experience: 'recruitExperience',
|
||||
skills: 'recruitSkills',
|
||||
certificates: 'recruitCertificates',
|
||||
languages: 'recruitLanguages',
|
||||
specialRequirements: 'recruitSpecialReq'
|
||||
},
|
||||
jobBasic: {
|
||||
jobCode: 'jobCode',
|
||||
jobName: 'jobName',
|
||||
jobGrade: 'jobGrade',
|
||||
jobCategory: 'jobCategory',
|
||||
careerPath: 'careerPath',
|
||||
jobDesc: 'jobDesc'
|
||||
},
|
||||
deptFunction: {
|
||||
deptCode: 'deptCode',
|
||||
deptName: 'deptName',
|
||||
parentDept: 'parentDept',
|
||||
deptHead: 'deptHead',
|
||||
deptFunction: 'deptFunction',
|
||||
kpi: 'kpi'
|
||||
},
|
||||
jobDesc: {
|
||||
positionName: 'descPositionName',
|
||||
department: 'descDepartment',
|
||||
directSupervisor: 'descDirectSupervisor',
|
||||
positionPurpose: 'descPositionPurpose',
|
||||
mainResponsibilities: 'descMainResponsibilities',
|
||||
education: 'descEducation',
|
||||
basicSkills: 'descBasicSkills',
|
||||
professionalKnowledge: 'descProfessionalKnowledge'
|
||||
}
|
||||
};
|
||||
|
||||
const mapping = fieldMappings[module];
|
||||
if (!mapping) return;
|
||||
|
||||
let filledCount = 0;
|
||||
for (const [dataKey, elementId] of Object.entries(mapping)) {
|
||||
if (data[dataKey] !== undefined) {
|
||||
const value = Array.isArray(data[dataKey])
|
||||
? data[dataKey].join('\n')
|
||||
: String(data[dataKey]);
|
||||
if (fillIfEmpty(elementId, value)) {
|
||||
filledCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filledCount > 0) {
|
||||
console.log(`[AI Helper] 已填入 ${filledCount} 個欄位`);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 錯誤顯示 ====================
|
||||
|
||||
/**
|
||||
* 顯示可複製的錯誤訊息
|
||||
*/
|
||||
export function showCopyableError(options) {
|
||||
const { title, message, details, suggestions } = options;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
animation: fadeIn 0.3s;
|
||||
`;
|
||||
|
||||
modal.innerHTML = `
|
||||
<div style="
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
">
|
||||
<div style="
|
||||
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
">
|
||||
<span style="font-size: 2rem;">❌</span>
|
||||
<h3 style="margin: 0; font-size: 1.3rem; flex: 1;">${sanitizeHTML(title)}</h3>
|
||||
<button onclick="this.closest('[style*=\"position: fixed\"]').remove()" style="
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: none;
|
||||
color: white;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
">×</button>
|
||||
</div>
|
||||
<div style="padding: 25px; overflow-y: auto; flex: 1;">
|
||||
<div style="color: #333; line-height: 1.6; margin-bottom: 20px;">${sanitizeHTML(message)}</div>
|
||||
${suggestions && suggestions.length > 0 ? `
|
||||
<div style="background: #fff3cd; border: 1px solid #ffc107; border-radius: 6px; padding: 15px; margin-bottom: 20px;">
|
||||
<strong style="color: #856404; display: block; margin-bottom: 10px;">💡 請確保:</strong>
|
||||
<ul style="margin: 0; padding-left: 20px; color: #856404;">
|
||||
${suggestions.map(s => `<li style="margin: 5px 0;">${sanitizeHTML(s)}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
${details ? `
|
||||
<details style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 15px;">
|
||||
<summary style="cursor: pointer; font-weight: 600; color: #495057;">🔍 詳細錯誤訊息(點擊展開)</summary>
|
||||
<pre id="errorDetailsText" style="background: white; padding: 15px; border-radius: 4px; overflow-x: auto; font-size: 0.85rem; color: #666; margin: 10px 0 0 0; white-space: pre-wrap; word-break: break-word; max-height: 300px; overflow-y: auto;">${sanitizeHTML(details)}</pre>
|
||||
</details>
|
||||
` : ''}
|
||||
</div>
|
||||
<div style="padding: 15px 25px; border-top: 1px solid #f0f0f0; display: flex; justify-content: flex-end;">
|
||||
<button onclick="this.closest('[style*=\"position: fixed\"]').remove()" style="
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 25px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
">確定</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
modal.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 各表單 AI 生成函數 ====================
|
||||
|
||||
/**
|
||||
* 生成崗位基礎資料
|
||||
*/
|
||||
export async function generatePositionBasic(event) {
|
||||
const btn = event.target.closest('.ai-generate-btn');
|
||||
|
||||
const allFields = ['positionCode', 'positionName', 'positionCategory', 'positionNature', 'headcount', 'positionLevel', 'positionDesc', 'positionRemark'];
|
||||
const emptyFields = getEmptyFields(allFields);
|
||||
|
||||
if (emptyFields.length === 0) {
|
||||
if (typeof showToast === 'function') showToast('所有欄位都已填寫完成!');
|
||||
return;
|
||||
}
|
||||
|
||||
setButtonLoading(btn, true);
|
||||
|
||||
try {
|
||||
const existingData = {};
|
||||
allFields.forEach(field => {
|
||||
const value = getFieldValue(field);
|
||||
if (value) existingData[field] = value;
|
||||
});
|
||||
|
||||
const contextInfo = Object.keys(existingData).length > 0
|
||||
? `\n\n已填寫的資料(請參考這些內容來生成相關的資料):\n${JSON.stringify(existingData, null, 2)}`
|
||||
: '';
|
||||
|
||||
const prompt = `你是專業人資顧問,熟悉半導體製造業的人資所有流程。請生成崗位基礎資料。請用繁體中文回覆。
|
||||
${contextInfo}
|
||||
|
||||
請「只生成」以下這些尚未填寫的欄位:${emptyFields.join(', ')}
|
||||
|
||||
欄位說明:
|
||||
- positionCode: 崗位編號(格式如 ENG-001, MGR-002, SAL-003)
|
||||
- positionName: 崗位名稱
|
||||
- positionCategory: 崗位類別代碼(01=技術職, 02=管理職, 03=業務職, 04=行政職)
|
||||
- positionNature: 崗位性質代碼(FT=全職, PT=兼職, CT=約聘, IN=實習)
|
||||
- headcount: 編制人數(1-10之間的數字字串)
|
||||
- positionLevel: 崗位級別(L1到L7)
|
||||
- positionDesc: 崗位描述(條列式,用換行分隔))
|
||||
- positionRemark: 崗位備注(條列式,用換行分隔)
|
||||
|
||||
重要:請「只」返回純JSON格式,不要有任何解釋、思考過程或額外文字。格式如下:
|
||||
{
|
||||
${emptyFields.map(f => `"${f}": "..."`).join(',\n ')}
|
||||
}`;
|
||||
|
||||
const data = await callClaudeAPI(prompt);
|
||||
|
||||
let filledCount = 0;
|
||||
if (fillIfEmpty('positionCode', data.positionCode)) filledCount++;
|
||||
if (fillIfEmpty('positionName', data.positionName)) filledCount++;
|
||||
if (fillIfEmpty('positionCategory', data.positionCategory)) {
|
||||
filledCount++;
|
||||
if (typeof updateCategoryName === 'function') updateCategoryName();
|
||||
}
|
||||
if (fillIfEmpty('positionNature', data.positionNature)) {
|
||||
filledCount++;
|
||||
if (typeof updateNatureName === 'function') updateNatureName();
|
||||
}
|
||||
if (fillIfEmpty('headcount', data.headcount)) filledCount++;
|
||||
if (fillIfEmpty('positionLevel', data.positionLevel)) filledCount++;
|
||||
if (fillIfEmpty('positionDesc', data.positionDesc)) filledCount++;
|
||||
if (fillIfEmpty('positionRemark', data.positionRemark)) filledCount++;
|
||||
|
||||
if (typeof updatePreview === 'function') updatePreview();
|
||||
if (typeof showToast === 'function') showToast(`✨ AI 已補充 ${filledCount} 個欄位!`);
|
||||
} catch (error) {
|
||||
if (typeof showToast === 'function') showToast('生成失敗,請稍後再試');
|
||||
} finally {
|
||||
setButtonLoading(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成招聘要求資料
|
||||
*/
|
||||
export async function generatePositionRecruit(event) {
|
||||
const btn = event.target.closest('.ai-generate-btn');
|
||||
|
||||
const allFields = ['minEducation', 'requiredGender', 'salaryRange', 'workExperience', 'minAge', 'maxAge', 'jobType', 'recruitPosition', 'jobTitle', 'jobDesc', 'positionReq', 'skillReq', 'langReq', 'otherReq'];
|
||||
const emptyFields = getEmptyFields(allFields);
|
||||
|
||||
if (emptyFields.length === 0) {
|
||||
if (typeof showToast === 'function') showToast('所有欄位都已填寫完成!');
|
||||
return;
|
||||
}
|
||||
|
||||
setButtonLoading(btn, true);
|
||||
|
||||
try {
|
||||
const positionName = getFieldValue('positionName') || '一般職位';
|
||||
const existingData = { positionName };
|
||||
allFields.forEach(field => {
|
||||
const value = getFieldValue(field);
|
||||
if (value) existingData[field] = value;
|
||||
});
|
||||
|
||||
const prompt = `請生成「${positionName}」的招聘要求資料。請用繁體中文回覆。
|
||||
|
||||
已填寫的資料(請參考這些內容來生成相關的資料):
|
||||
${JSON.stringify(existingData, null, 2)}
|
||||
|
||||
請「只生成」以下這些尚未填寫的欄位:${emptyFields.join(', ')}
|
||||
|
||||
欄位說明:
|
||||
- minEducation: 最低學歷代碼(HS=高中職, JC=專科, BA=大學, MA=碩士, PHD=博士)
|
||||
- requiredGender: 要求性別(空字串=不限, M=男, F=女)
|
||||
- salaryRange: 薪酬范圍代碼(A=30000以下, B=30000-50000, C=50000-80000, D=80000-120000, E=120000以上, N=面議)
|
||||
- workExperience: 工作經驗年數(0=不限, 1, 3, 5, 10)
|
||||
- minAge: 最小年齡(18-30之間的數字字串)
|
||||
- maxAge: 最大年齡(35-55之間的數字字串)
|
||||
- jobType: 工作性質代碼(FT=全職, PT=兼職, CT=約聘, DP=派遣)
|
||||
- recruitPosition: 招聘職位代碼(ENG=工程師, MGR=經理, AST=助理, OP=作業員, SAL=業務)
|
||||
- jobTitle: 職位名稱
|
||||
- jobDesc: 職位描述(2-3句話)
|
||||
- positionReq: 崗位要求(條列式,用換行分隔)
|
||||
- skillReq: 技能要求(條列式,用換行分隔)
|
||||
- langReq: 語言要求(條列式,用換行分隔)
|
||||
- otherReq: 其他要求(條列式,用換行分隔)
|
||||
|
||||
重要:請「只」返回純JSON格式,不要有任何解釋、思考過程或額外文字。格式如下:
|
||||
{
|
||||
${emptyFields.map(f => `"${f}": "..."`).join(',\n ')}
|
||||
}`;
|
||||
|
||||
const data = await callClaudeAPI(prompt);
|
||||
|
||||
let filledCount = 0;
|
||||
if (fillIfEmpty('minEducation', data.minEducation)) filledCount++;
|
||||
if (fillIfEmpty('requiredGender', data.requiredGender)) filledCount++;
|
||||
if (fillIfEmpty('salaryRange', data.salaryRange)) filledCount++;
|
||||
if (fillIfEmpty('workExperience', data.workExperience)) filledCount++;
|
||||
if (fillIfEmpty('minAge', data.minAge)) filledCount++;
|
||||
if (fillIfEmpty('maxAge', data.maxAge)) filledCount++;
|
||||
if (fillIfEmpty('jobType', data.jobType)) filledCount++;
|
||||
if (fillIfEmpty('recruitPosition', data.recruitPosition)) filledCount++;
|
||||
if (fillIfEmpty('jobTitle', data.jobTitle)) filledCount++;
|
||||
if (fillIfEmpty('jobDesc', data.jobDesc)) filledCount++;
|
||||
if (fillIfEmpty('positionReq', data.positionReq)) filledCount++;
|
||||
if (fillIfEmpty('skillReq', data.skillReq)) filledCount++;
|
||||
if (fillIfEmpty('langReq', data.langReq)) filledCount++;
|
||||
if (fillIfEmpty('otherReq', data.otherReq)) filledCount++;
|
||||
|
||||
if (typeof updatePreview === 'function') updatePreview();
|
||||
if (typeof showToast === 'function') showToast(`✨ AI 已補充 ${filledCount} 個欄位!`);
|
||||
} catch (error) {
|
||||
if (typeof showToast === 'function') showToast('生成失敗,請稍後再試');
|
||||
} finally {
|
||||
setButtonLoading(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成職務基礎資料
|
||||
*/
|
||||
export async function generateJobBasic(event) {
|
||||
const btn = event.target.closest('.ai-generate-btn');
|
||||
|
||||
const allFields = ['jobCategoryCode', 'jobCode', 'jobName', 'jobNameEn', 'jobHeadcount', 'jobSortOrder', 'jobRemark', 'jobLevel'];
|
||||
const emptyFields = getEmptyFields(allFields);
|
||||
|
||||
const attBonusChk = document.getElementById('job_hasAttBonus');
|
||||
const houseAllowChk = document.getElementById('job_hasHouseAllow');
|
||||
const needCheckboxes = !(attBonusChk?.checked) && !(houseAllowChk?.checked);
|
||||
|
||||
if (emptyFields.length === 0 && !needCheckboxes) {
|
||||
if (typeof showToast === 'function') showToast('所有欄位都已填寫完成!');
|
||||
return;
|
||||
}
|
||||
|
||||
setButtonLoading(btn, true);
|
||||
|
||||
try {
|
||||
const existingData = {};
|
||||
allFields.forEach(field => {
|
||||
const value = getFieldValue(field);
|
||||
if (value) existingData[field] = value;
|
||||
});
|
||||
|
||||
const contextInfo = Object.keys(existingData).length > 0
|
||||
? `\n\n已填寫的資料(請參考這些內容來生成相關的資料):\n${JSON.stringify(existingData, null, 2)}`
|
||||
: '';
|
||||
|
||||
const fieldsToGenerate = [...emptyFields];
|
||||
if (needCheckboxes) {
|
||||
fieldsToGenerate.push('hasAttendanceBonus', 'hasHousingAllowance');
|
||||
}
|
||||
|
||||
const prompt = `你是專業人資顧問,熟悉半導體製造業的人資所有流程。請生成職務基礎資料。請用繁體中文回覆。
|
||||
${contextInfo}
|
||||
|
||||
請「只生成」以下這些尚未填寫的欄位:${fieldsToGenerate.join(', ')}
|
||||
|
||||
欄位說明:
|
||||
- jobCategoryCode: 職務類別代碼(MGR=管理職, TECH=技術職, SALE=業務職, ADMIN=行政職, RD=研發職, PROD=生產職)
|
||||
- jobCode: 職務編號(格式如 MGR-001, TECH-002)
|
||||
- jobName: 職務名稱
|
||||
- jobNameEn: 職務英文名稱
|
||||
- jobHeadcount: 編制人數(1-20之間的數字字串)
|
||||
- jobSortOrder: 排列順序(10, 20, 30...的數字字串)
|
||||
- jobRemark: 備注說明
|
||||
- jobLevel: 職務層級(可以是 *保密* 或具體層級)
|
||||
- hasAttendanceBonus: 是否有全勤(true/false)
|
||||
- hasHousingAllowance: 是否住房補貼(true/false)
|
||||
|
||||
重要:請「只」返回純JSON格式,不要有任何解釋、思考過程或額外文字。格式如下:
|
||||
{
|
||||
${fieldsToGenerate.map(f => `"${f}": "..."`).join(',\n ')}
|
||||
}`;
|
||||
|
||||
const data = await callClaudeAPI(prompt);
|
||||
|
||||
let filledCount = 0;
|
||||
if (fillIfEmpty('jobCategoryCode', data.jobCategoryCode)) {
|
||||
filledCount++;
|
||||
if (typeof updateJobCategoryName === 'function') updateJobCategoryName();
|
||||
}
|
||||
if (fillIfEmpty('jobCode', data.jobCode)) filledCount++;
|
||||
if (fillIfEmpty('jobName', data.jobName)) filledCount++;
|
||||
if (fillIfEmpty('jobNameEn', data.jobNameEn)) filledCount++;
|
||||
if (fillIfEmpty('jobHeadcount', data.jobHeadcount)) filledCount++;
|
||||
if (fillIfEmpty('jobSortOrder', data.jobSortOrder)) filledCount++;
|
||||
if (fillIfEmpty('jobRemark', data.jobRemark)) filledCount++;
|
||||
if (fillIfEmpty('jobLevel', data.jobLevel)) filledCount++;
|
||||
|
||||
if (needCheckboxes) {
|
||||
const attendanceCheckbox = document.getElementById('job_hasAttBonus');
|
||||
const housingCheckbox = document.getElementById('job_hasHouseAllow');
|
||||
|
||||
if (data.hasAttendanceBonus === true && attendanceCheckbox) {
|
||||
attendanceCheckbox.checked = true;
|
||||
document.getElementById('attendanceLabel').textContent = '是';
|
||||
filledCount++;
|
||||
}
|
||||
if (data.hasHousingAllowance === true && housingCheckbox) {
|
||||
housingCheckbox.checked = true;
|
||||
document.getElementById('housingLabel').textContent = '是';
|
||||
filledCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof updatePreview === 'function') updatePreview();
|
||||
if (typeof showToast === 'function') showToast(`✨ AI 已補充 ${filledCount} 個欄位!`);
|
||||
} catch (error) {
|
||||
if (typeof showToast === 'function') showToast('生成失敗,請稍後再試');
|
||||
} finally {
|
||||
setButtonLoading(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成崗位描述資料
|
||||
*/
|
||||
export async function generateJobDesc(event) {
|
||||
const btn = event.target.closest('.ai-generate-btn');
|
||||
|
||||
const allFields = ['jd_empNo', 'jd_empName', 'jd_positionCode', 'jd_versionDate', 'jd_positionName', 'jd_department', 'jd_positionEffectiveDate', 'jd_directSupervisor', 'jd_directReports', 'jd_workLocation', 'jd_empAttribute', 'jd_positionPurpose', 'jd_mainResponsibilities', 'jd_education', 'jd_basicSkills', 'jd_professionalKnowledge', 'jd_workExperienceReq', 'jd_otherRequirements'];
|
||||
|
||||
const emptyFields = allFields.filter(id => {
|
||||
const el = document.getElementById(id);
|
||||
const value = el ? el.value.trim() : '';
|
||||
if (id === 'jd_mainResponsibilities') {
|
||||
return !value || value === '1、\n2、\n3、\n4、' || value === '1、\n2、\n3、\n4、\n5、';
|
||||
}
|
||||
return !value;
|
||||
});
|
||||
|
||||
if (emptyFields.length === 0) {
|
||||
if (typeof showToast === 'function') showToast('所有欄位都已填寫完成!');
|
||||
return;
|
||||
}
|
||||
|
||||
setButtonLoading(btn, true);
|
||||
|
||||
try {
|
||||
const existingData = {};
|
||||
allFields.forEach(field => {
|
||||
const el = document.getElementById(field);
|
||||
const value = el ? el.value.trim() : '';
|
||||
if (value && value !== '1、\n2、\n3、\n4、') {
|
||||
existingData[field.replace('jd_', '')] = value;
|
||||
}
|
||||
});
|
||||
|
||||
const contextInfo = Object.keys(existingData).length > 0
|
||||
? `\n\n已填寫的資料(請參考這些內容來生成相關的資料):\n${JSON.stringify(existingData, null, 2)}`
|
||||
: '';
|
||||
|
||||
const fieldsToGenerate = emptyFields.map(f => f.replace('jd_', ''));
|
||||
|
||||
const prompt = `請生成崗位描述資料。請用繁體中文回覆。
|
||||
${contextInfo}
|
||||
|
||||
請「只生成」以下這些尚未填寫的欄位:${fieldsToGenerate.join(', ')}
|
||||
|
||||
欄位說明:
|
||||
- empNo: 工號(格式如 A001234)
|
||||
- empName: 員工姓名
|
||||
- positionCode: 崗位代碼
|
||||
- versionDate: 版本日期(YYYY-MM-DD格式)
|
||||
- positionName: 崗位名稱
|
||||
- department: 所屬部門
|
||||
- positionEffectiveDate: 崗位生效日期(YYYY-MM-DD格式)
|
||||
- directSupervisor: 直接領導職務
|
||||
- directReports: 直接下級(格式如「工程師 x 5人」)
|
||||
- workLocation: 任職地點代碼(HQ=總部, TPE=台北, TYC=桃園, KHH=高雄, SH=上海, SZ=深圳)
|
||||
- empAttribute: 員工屬性代碼(FT=正式員工, CT=約聘, PT=兼職, IN=實習, DP=派遣)
|
||||
- positionPurpose: 崗位設置目的(1句話說明)
|
||||
- mainResponsibilities: 主要崗位職責(用「1、」「2、」「3、」「4、」「5、」格式,每項換行,用\\n分隔)
|
||||
- education: 教育程度要求(條列式,用換行分隔)
|
||||
- basicSkills: 基本技能要求(條列式,用換行分隔)
|
||||
- professionalKnowledge: 專業知識要求(條列式,用換行分隔)
|
||||
- workExperienceReq: 工作經驗要求(條列式,用換行分隔)
|
||||
- otherRequirements: 其他要求(條列式,用換行分隔)
|
||||
|
||||
重要:請「只」返回純JSON格式,不要有任何解釋、思考過程或額外文字。格式如下:
|
||||
{
|
||||
${fieldsToGenerate.map(f => `"${f}": "..."`).join(',\n ')}
|
||||
}`;
|
||||
|
||||
const data = await callClaudeAPI(prompt);
|
||||
|
||||
let filledCount = 0;
|
||||
|
||||
const fieldMapping = {
|
||||
'empNo': 'jd_empNo',
|
||||
'empName': 'jd_empName',
|
||||
'positionCode': 'jd_positionCode',
|
||||
'versionDate': 'jd_versionDate',
|
||||
'positionName': 'jd_positionName',
|
||||
'department': 'jd_department',
|
||||
'positionEffectiveDate': 'jd_positionEffectiveDate',
|
||||
'directSupervisor': 'jd_directSupervisor',
|
||||
'directReports': 'jd_directReports',
|
||||
'workLocation': 'jd_workLocation',
|
||||
'empAttribute': 'jd_empAttribute',
|
||||
'positionPurpose': 'jd_positionPurpose',
|
||||
'mainResponsibilities': 'jd_mainResponsibilities',
|
||||
'education': 'jd_education',
|
||||
'basicSkills': 'jd_basicSkills',
|
||||
'professionalKnowledge': 'jd_professionalKnowledge',
|
||||
'workExperienceReq': 'jd_workExperienceReq',
|
||||
'otherRequirements': 'jd_otherRequirements'
|
||||
};
|
||||
|
||||
Object.keys(fieldMapping).forEach(apiField => {
|
||||
const htmlId = fieldMapping[apiField];
|
||||
if (data[apiField]) {
|
||||
const el = document.getElementById(htmlId);
|
||||
const currentValue = el ? el.value.trim() : '';
|
||||
const isEmpty = !currentValue || currentValue === '1、\n2、\n3、\n4、';
|
||||
if (isEmpty) {
|
||||
el.value = data[apiField];
|
||||
filledCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (typeof updatePreview === 'function') updatePreview();
|
||||
if (typeof showToast === 'function') showToast(`✨ AI 已補充 ${filledCount} 個欄位!`);
|
||||
} catch (error) {
|
||||
if (typeof showToast === 'function') showToast('生成失敗,請稍後再試');
|
||||
} finally {
|
||||
setButtonLoading(btn, false);
|
||||
}
|
||||
}
|
||||
21
js/api.js
21
js/api.js
@@ -59,8 +59,14 @@ export async function callClaudeAPI(prompt, api = 'ollama') {
|
||||
// 清理 JSON 代碼塊標記和其他格式
|
||||
let responseText = data.text;
|
||||
|
||||
// 移除 DeepSeek-R1 等模型的思考標籤 <think>...</think>
|
||||
responseText = responseText.replace(/<think>[\s\S]*?<\/think>/gi, '');
|
||||
|
||||
// 移除 Markdown 代碼塊標記
|
||||
responseText = responseText.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
|
||||
responseText = responseText.replace(/```json\n?/gi, '').replace(/```\n?/g, '').trim();
|
||||
|
||||
// 移除可能的前導文字(如 "Here is the JSON:" 等)
|
||||
responseText = responseText.replace(/^[\s\S]*?(?=\{)/i, '');
|
||||
|
||||
// 嘗試提取 JSON 內容(如果包含其他文字)
|
||||
// 查找第一個 { 和最後一個 }
|
||||
@@ -77,7 +83,18 @@ export async function callClaudeAPI(prompt, api = 'ollama') {
|
||||
} catch (parseError) {
|
||||
// JSON 解析失敗,拋出更詳細的錯誤
|
||||
console.error('JSON 解析失敗,原始響應:', responseText);
|
||||
throw new Error(`LLM 返回的內容不是有效的 JSON 格式。\n\n原始響應前 200 字符:\n${responseText.substring(0, 200)}...`);
|
||||
|
||||
// 嘗試修復常見的 JSON 問題
|
||||
try {
|
||||
// 移除控制字符
|
||||
const cleanedText = responseText
|
||||
.replace(/[\x00-\x1F\x7F]/g, '') // 移除控制字符
|
||||
.replace(/,\s*}/g, '}') // 移除末尾逗號
|
||||
.replace(/,\s*]/g, ']'); // 移除陣列末尾逗號
|
||||
return JSON.parse(cleanedText);
|
||||
} catch (retryError) {
|
||||
throw new Error(`LLM 返回的內容不是有效的 JSON 格式。\n\n原始響應前 300 字符:\n${responseText.substring(0, 300)}...`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error calling LLM API:', error);
|
||||
|
||||
337
js/csv.js
Normal file
337
js/csv.js
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* CSV 匯入匯出模組
|
||||
* 處理各表單的 CSV 資料交換
|
||||
*/
|
||||
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
// ==================== CSV 工具函數 ====================
|
||||
|
||||
export const CSVUtils = {
|
||||
/**
|
||||
* 匯出資料到 CSV
|
||||
*/
|
||||
exportToCSV(data, filename, headers) {
|
||||
if (!data || data.length === 0) {
|
||||
console.warn('No data to export');
|
||||
return;
|
||||
}
|
||||
|
||||
const csvHeaders = headers || Object.keys(data[0]);
|
||||
const csvRows = data.map(row => {
|
||||
return csvHeaders.map(header => {
|
||||
let value = row[header] !== undefined ? row[header] : '';
|
||||
// 處理包含逗號或換行的值
|
||||
if (typeof value === 'string' && (value.includes(',') || value.includes('\n') || value.includes('"'))) {
|
||||
value = '"' + value.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return value;
|
||||
}).join(',');
|
||||
});
|
||||
|
||||
const csvContent = '\uFEFF' + [csvHeaders.join(','), ...csvRows].join('\n'); // BOM for UTF-8
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = filename;
|
||||
link.click();
|
||||
},
|
||||
|
||||
/**
|
||||
* 從 CSV 匯入資料
|
||||
*/
|
||||
importFromCSV(file, callback) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target.result;
|
||||
const lines = text.split('\n').filter(line => line.trim());
|
||||
if (lines.length < 2) {
|
||||
callback([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = this.parseCSVLine(lines[0]);
|
||||
const data = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const values = this.parseCSVLine(lines[i]);
|
||||
const row = {};
|
||||
headers.forEach((header, index) => {
|
||||
row[header.trim()] = values[index] ? values[index].trim() : '';
|
||||
});
|
||||
data.push(row);
|
||||
}
|
||||
|
||||
callback(data);
|
||||
};
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
},
|
||||
|
||||
/**
|
||||
* 解析 CSV 行(處理引號內的逗號)
|
||||
*/
|
||||
parseCSVLine(line) {
|
||||
const result = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
if (char === '"') {
|
||||
if (inQuotes && line[i + 1] === '"') {
|
||||
current += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
result.push(current);
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
result.push(current);
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 崗位資料 CSV ====================
|
||||
|
||||
export function downloadPositionCSVTemplate() {
|
||||
window.location.href = API_BASE_URL + '/positions/csv-template';
|
||||
if (typeof showToast === 'function') showToast('正在下載崗位資料範本...');
|
||||
}
|
||||
|
||||
export function exportPositionsCSV() {
|
||||
const data = [{
|
||||
positionCode: getFieldValue('positionCode'),
|
||||
positionName: getFieldValue('positionName'),
|
||||
positionCategory: getFieldValue('positionCategory'),
|
||||
positionNature: getFieldValue('positionNature'),
|
||||
headcount: getFieldValue('headcount'),
|
||||
positionLevel: getFieldValue('positionLevel'),
|
||||
effectiveDate: getFieldValue('effectiveDate'),
|
||||
positionDesc: getFieldValue('positionDesc'),
|
||||
positionRemark: getFieldValue('positionRemark'),
|
||||
minEducation: getFieldValue('minEducation'),
|
||||
salaryRange: getFieldValue('salaryRange'),
|
||||
workExperience: getFieldValue('workExperience'),
|
||||
minAge: getFieldValue('minAge'),
|
||||
maxAge: getFieldValue('maxAge')
|
||||
}];
|
||||
|
||||
const headers = ['positionCode', 'positionName', 'positionCategory', 'positionNature',
|
||||
'headcount', 'positionLevel', 'effectiveDate', 'positionDesc', 'positionRemark',
|
||||
'minEducation', 'salaryRange', 'workExperience', 'minAge', 'maxAge'];
|
||||
|
||||
CSVUtils.exportToCSV(data, 'positions.csv', headers);
|
||||
if (typeof showToast === 'function') showToast('崗位資料已匯出!');
|
||||
}
|
||||
|
||||
export function importPositionsCSV() {
|
||||
document.getElementById('positionCSVInput').click();
|
||||
}
|
||||
|
||||
export function handlePositionCSVImport(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
if (typeof showToast === 'function') showToast('正在匯入崗位資料...');
|
||||
|
||||
fetch(API_BASE_URL + '/positions/import-csv', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
let message = data.message;
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
message += '\n\n錯誤詳情:\n' + data.errors.join('\n');
|
||||
}
|
||||
alert(message);
|
||||
} else {
|
||||
alert('匯入失敗: ' + (data.error || '未知錯誤'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('匯入錯誤:', error);
|
||||
alert('匯入失敗: ' + error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
event.target.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 職務資料 CSV ====================
|
||||
|
||||
export function downloadJobCSVTemplate() {
|
||||
window.location.href = API_BASE_URL + '/jobs/csv-template';
|
||||
if (typeof showToast === 'function') showToast('正在下載職務資料範本...');
|
||||
}
|
||||
|
||||
export function exportJobsCSV() {
|
||||
const data = [{
|
||||
jobCategoryCode: getFieldValue('jobCategoryCode'),
|
||||
jobCategoryName: getFieldValue('jobCategoryName'),
|
||||
jobCode: getFieldValue('jobCode'),
|
||||
jobName: getFieldValue('jobName'),
|
||||
jobNameEn: getFieldValue('jobNameEn'),
|
||||
jobEffectiveDate: getFieldValue('jobEffectiveDate'),
|
||||
jobHeadcount: getFieldValue('jobHeadcount'),
|
||||
jobSortOrder: getFieldValue('jobSortOrder'),
|
||||
jobRemark: getFieldValue('jobRemark'),
|
||||
jobLevel: getFieldValue('jobLevel'),
|
||||
hasAttendanceBonus: document.getElementById('job_hasAttBonus')?.checked,
|
||||
hasHousingAllowance: document.getElementById('job_hasHouseAllow')?.checked
|
||||
}];
|
||||
|
||||
const headers = ['jobCategoryCode', 'jobCategoryName', 'jobCode', 'jobName', 'jobNameEn',
|
||||
'jobEffectiveDate', 'jobHeadcount', 'jobSortOrder', 'jobRemark', 'jobLevel',
|
||||
'hasAttendanceBonus', 'hasHousingAllowance'];
|
||||
|
||||
CSVUtils.exportToCSV(data, 'jobs.csv', headers);
|
||||
if (typeof showToast === 'function') showToast('職務資料已匯出!');
|
||||
}
|
||||
|
||||
export function importJobsCSV() {
|
||||
document.getElementById('jobCSVInput').click();
|
||||
}
|
||||
|
||||
export function handleJobCSVImport(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
if (typeof showToast === 'function') showToast('正在匯入職務資料...');
|
||||
|
||||
fetch(API_BASE_URL + '/jobs/import-csv', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
let message = data.message;
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
message += '\n\n錯誤詳情:\n' + data.errors.join('\n');
|
||||
}
|
||||
alert(message);
|
||||
} else {
|
||||
alert('匯入失敗: ' + (data.error || '未知錯誤'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('匯入錯誤:', error);
|
||||
alert('匯入失敗: ' + error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
event.target.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 崗位描述 CSV ====================
|
||||
|
||||
export function exportDescriptionsCSV() {
|
||||
const data = [{
|
||||
descPositionCode: getFieldValue('descPositionCode'),
|
||||
descPositionName: getFieldValue('descPositionName'),
|
||||
descEffectiveDate: getFieldValue('descEffectiveDate'),
|
||||
jobDuties: getFieldValue('jobDuties'),
|
||||
requiredSkills: getFieldValue('requiredSkills'),
|
||||
workEnvironment: getFieldValue('workEnvironment'),
|
||||
careerPath: getFieldValue('careerPath'),
|
||||
descRemark: getFieldValue('descRemark')
|
||||
}];
|
||||
|
||||
const headers = ['descPositionCode', 'descPositionName', 'descEffectiveDate', 'jobDuties',
|
||||
'requiredSkills', 'workEnvironment', 'careerPath', 'descRemark'];
|
||||
|
||||
CSVUtils.exportToCSV(data, 'job_descriptions.csv', headers);
|
||||
if (typeof showToast === 'function') showToast('崗位描述已匯出!');
|
||||
}
|
||||
|
||||
export function importDescriptionsCSV() {
|
||||
document.getElementById('descCSVInput').click();
|
||||
}
|
||||
|
||||
export function handleDescCSVImport(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
CSVUtils.importFromCSV(file, (data) => {
|
||||
if (data && data.length > 0) {
|
||||
const firstRow = data[0];
|
||||
Object.keys(firstRow).forEach(key => {
|
||||
const element = document.getElementById(key);
|
||||
if (element) {
|
||||
element.value = firstRow[key];
|
||||
}
|
||||
});
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(`已匯入 ${data.length} 筆崗位描述資料(顯示第一筆)`);
|
||||
}
|
||||
}
|
||||
});
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
// ==================== 崗位清單 CSV ====================
|
||||
|
||||
export function exportPositionListCSV(positionListData) {
|
||||
if (!positionListData || positionListData.length === 0) {
|
||||
if (typeof showToast === 'function') showToast('請先載入清單資料');
|
||||
return;
|
||||
}
|
||||
const headers = ['positionCode', 'positionName', 'businessUnit', 'department', 'positionCategory', 'headcount', 'effectiveDate'];
|
||||
CSVUtils.exportToCSV(positionListData, 'position_list.csv', headers);
|
||||
if (typeof showToast === 'function') showToast('崗位清單已匯出!');
|
||||
}
|
||||
|
||||
// ==================== 部門職責 CSV ====================
|
||||
|
||||
export function importDeptFunctionCSV() {
|
||||
document.getElementById('deptFunctionCsvInput').click();
|
||||
}
|
||||
|
||||
export function handleDeptFunctionCSVImport(event, callback) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
CSVUtils.importFromCSV(file, (data) => {
|
||||
if (data && data.length > 0) {
|
||||
const row = data[0];
|
||||
Object.keys(row).forEach(key => {
|
||||
const el = document.getElementById(key);
|
||||
if (el) el.value = row[key];
|
||||
});
|
||||
if (typeof showToast === 'function') showToast('已匯入 CSV 資料!');
|
||||
if (callback) callback(data);
|
||||
}
|
||||
});
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
export function exportDeptFunctionCSV(formData) {
|
||||
const headers = Object.keys(formData);
|
||||
CSVUtils.exportToCSV([formData], 'dept_function.csv', headers);
|
||||
if (typeof showToast === 'function') showToast('部門職責資料已匯出!');
|
||||
}
|
||||
|
||||
// ==================== 工具函數 ====================
|
||||
|
||||
function getFieldValue(elementId) {
|
||||
const el = document.getElementById(elementId);
|
||||
return el ? el.value.trim() : '';
|
||||
}
|
||||
|
||||
// 暴露到全域
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CSVUtils = CSVUtils;
|
||||
}
|
||||
246
js/csv_utils.js
Normal file
246
js/csv_utils.js
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* CSV 匯入匯出工具
|
||||
* 提供 CSV 文件的匯入和匯出功能
|
||||
*/
|
||||
|
||||
const CSVUtils = {
|
||||
/**
|
||||
* 將數據匯出為 CSV 文件
|
||||
* @param {Array} data - 數據陣列
|
||||
* @param {String} filename - 文件名稱
|
||||
* @param {Array} headers - CSV 標題行(可選)
|
||||
*/
|
||||
exportToCSV(data, filename, headers = null) {
|
||||
if (!data || data.length === 0) {
|
||||
alert('沒有資料可以匯出');
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果沒有提供標題,從第一筆資料取得所有鍵
|
||||
if (!headers) {
|
||||
headers = Object.keys(data[0]);
|
||||
}
|
||||
|
||||
// 構建 CSV 內容
|
||||
let csvContent = '\uFEFF'; // BOM for UTF-8
|
||||
|
||||
// 添加標題行
|
||||
csvContent += headers.join(',') + '\n';
|
||||
|
||||
// 添加數據行
|
||||
data.forEach(row => {
|
||||
const values = headers.map(header => {
|
||||
let value = this.getNestedValue(row, header);
|
||||
|
||||
// 處理空值
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 轉換為字符串
|
||||
value = String(value);
|
||||
|
||||
// 如果包含逗號、引號或換行符,需要用引號包圍
|
||||
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
|
||||
value = '"' + value.replace(/"/g, '""') + '"';
|
||||
}
|
||||
|
||||
return value;
|
||||
});
|
||||
|
||||
csvContent += values.join(',') + '\n';
|
||||
});
|
||||
|
||||
// 創建 Blob 並下載
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
|
||||
if (link.download !== undefined) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', filename);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 從 CSV 文件匯入數據
|
||||
* @param {File} file - CSV 文件
|
||||
* @param {Function} callback - 回調函數,接收解析後的數據
|
||||
*/
|
||||
importFromCSV(file, callback) {
|
||||
if (!file) {
|
||||
alert('請選擇文件');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.name.endsWith('.csv')) {
|
||||
alert('請選擇 CSV 文件');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const text = e.target.result;
|
||||
const data = this.parseCSV(text);
|
||||
|
||||
if (data && data.length > 0) {
|
||||
callback(data);
|
||||
} else {
|
||||
alert('CSV 文件為空或格式錯誤');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('CSV 解析錯誤:', error);
|
||||
alert('CSV 文件解析失敗: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
alert('文件讀取失敗');
|
||||
};
|
||||
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
},
|
||||
|
||||
/**
|
||||
* 解析 CSV 文本
|
||||
* @param {String} text - CSV 文本內容
|
||||
* @returns {Array} 解析後的數據陣列
|
||||
*/
|
||||
parseCSV(text) {
|
||||
// 移除 BOM
|
||||
if (text.charCodeAt(0) === 0xFEFF) {
|
||||
text = text.substr(1);
|
||||
}
|
||||
|
||||
const lines = text.split('\n').filter(line => line.trim());
|
||||
|
||||
if (lines.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 第一行是標題
|
||||
const headers = this.parseCSVLine(lines[0]);
|
||||
const data = [];
|
||||
|
||||
// 解析數據行
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const values = this.parseCSVLine(lines[i]);
|
||||
|
||||
if (values.length === headers.length) {
|
||||
const row = {};
|
||||
headers.forEach((header, index) => {
|
||||
row[header] = values[index];
|
||||
});
|
||||
data.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 解析單行 CSV
|
||||
* @param {String} line - CSV 行
|
||||
* @returns {Array} 值陣列
|
||||
*/
|
||||
parseCSVLine(line) {
|
||||
const values = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
const nextChar = line[i + 1];
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && nextChar === '"') {
|
||||
current += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
values.push(current);
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
values.push(current);
|
||||
return values;
|
||||
},
|
||||
|
||||
/**
|
||||
* 獲取嵌套對象的值
|
||||
* @param {Object} obj - 對象
|
||||
* @param {String} path - 路徑(支援 a.b.c 格式)
|
||||
* @returns {*} 值
|
||||
*/
|
||||
getNestedValue(obj, path) {
|
||||
return path.split('.').reduce((current, key) => {
|
||||
return current ? current[key] : undefined;
|
||||
}, obj);
|
||||
},
|
||||
|
||||
/**
|
||||
* 創建 CSV 匯入按鈕
|
||||
* @param {Function} onImport - 匯入成功的回調函數
|
||||
* @returns {HTMLElement} 按鈕元素
|
||||
*/
|
||||
createImportButton(onImport) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.csv';
|
||||
input.style.display = 'none';
|
||||
|
||||
input.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
this.importFromCSV(file, onImport);
|
||||
}
|
||||
});
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.className = 'btn btn-secondary';
|
||||
button.innerHTML = '📥 匯入 CSV';
|
||||
button.onclick = () => input.click();
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.style.display = 'inline-block';
|
||||
container.appendChild(input);
|
||||
container.appendChild(button);
|
||||
|
||||
return container;
|
||||
},
|
||||
|
||||
/**
|
||||
* 創建 CSV 匯出按鈕
|
||||
* @param {Function} getData - 獲取數據的函數
|
||||
* @param {String} filename - 文件名稱
|
||||
* @param {Array} headers - CSV 標題
|
||||
* @returns {HTMLElement} 按鈕元素
|
||||
*/
|
||||
createExportButton(getData, filename, headers = null) {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'btn btn-secondary';
|
||||
button.innerHTML = '📤 匯出 CSV';
|
||||
button.onclick = () => {
|
||||
const data = getData();
|
||||
this.exportToCSV(data, filename, headers);
|
||||
};
|
||||
|
||||
return button;
|
||||
}
|
||||
};
|
||||
|
||||
// 導出為全局變量
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CSVUtils = CSVUtils;
|
||||
}
|
||||
238
js/data/hierarchy.js
Normal file
238
js/data/hierarchy.js
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 組織階層靜態資料模組
|
||||
* 從 Excel 提取的下拉選單資料
|
||||
*/
|
||||
|
||||
// ==================== 下拉選單資料 ====================
|
||||
|
||||
// 事業體
|
||||
export const businessUnits = [
|
||||
'半導體事業群', '汽車事業體', '法務室', '岡山製造事業體', '產品事業體',
|
||||
'晶圓三廠', '集團人資行政事業體', '集團財務事業體', '集團會計事業體',
|
||||
'集團資訊事業體', '新創事業體', '稽核室', '總經理室', '總品質事業體', '營業事業體'
|
||||
];
|
||||
|
||||
// 處級單位
|
||||
export const deptLevel1Units = [
|
||||
'半導體事業群', '汽車事業體', '法務室', '生產處', '岡山製造事業體',
|
||||
'封裝工程處', '副總辦公室', '測試工程與研發處', '資材處', '廠務與環安衛管理處',
|
||||
'產品事業體', '先進產品事業處', '成熟產品事業處', '晶圓三廠', '製程工程處',
|
||||
'集團人資行政事業體', '集團財務事業體', '岡山強茂財務處', '集團會計事業體',
|
||||
'岡山會計處', '集團會計處', '集團資訊事業體', '資安行動小組', '資訊一處',
|
||||
'資訊二處', '新創事業體', '中低壓產品研發處', '研發中心', '高壓產品研發處',
|
||||
'稽核室', '總經理室', 'ESG專案辦公室', '專案管理室', '總品質事業體',
|
||||
'營業事業體', '商業開發暨市場應用處', '海外銷售事業處', '全球技術服務處',
|
||||
'全球行銷暨業務支援處', '大中華區銷售事業處'
|
||||
];
|
||||
|
||||
// 部級單位
|
||||
export const deptLevel2Units = [
|
||||
'生產部', '生產企劃部', '岡山品質管制部', '製程工程一部', '製程工程二部',
|
||||
'設備一部', '設備二部', '工業工程部', '測試工程部', '新產品導入部',
|
||||
'研發部', '採購部', '外部資源部', '生管部', '原物料控制部', '廠務部',
|
||||
'產品管理部(APD)', '產品管理部(MPD)', '品質部', '製造部', '廠務部(Fab3)',
|
||||
'工程一部', '工程二部', '工程三部', '製程整合部(Fab3)', '行政總務管理部',
|
||||
'招募任用部', '訓練發展部', '薪酬管理部', '岡山強茂財務部', '會計部',
|
||||
'管理會計部', '集團合併報表部', '應用系統部', '電腦整合製造部', '系統網路服務部',
|
||||
'資源管理部', '客戶品質管理部', '產品品質管理部', '品質系統及客戶工程整合部',
|
||||
'封測外包品質管理部', '品質保證部', '日本區暨代工業務部', '歐亞區業務部',
|
||||
'韓國區業務部-韓國區', '美洲區業務部', '應用工程部(GTS)', '系統工程部',
|
||||
'特性測試部', '業務生管部', '市場行銷企劃部', 'MOSFET晶圓採購部',
|
||||
'台灣區業務部', '業務一部', '業務二部'
|
||||
];
|
||||
|
||||
// 崗位名稱
|
||||
export const positionNames = [
|
||||
'營運長', '營運長助理', '副總經理', '專案經理', '經副理', '法務專員',
|
||||
'專利工程師', '處長', '專員', '課長', '組長', '班長', '副班長', '作業員',
|
||||
'工程師', '副總經理助理', '副理', '專案經副理', '顧問', '人資長', '助理',
|
||||
'財務長', '專案副理', '會計長', '資訊長', '主任', '總裁', '總經理',
|
||||
'專員/工程師', '經理', '技術經副理', '處長/資深經理'
|
||||
];
|
||||
|
||||
// ==================== 組織階層級聯映射 ====================
|
||||
|
||||
// 事業體 -> 處級單位 (預設資料,可被 API 覆蓋)
|
||||
export let businessToDivision = {
|
||||
'半導體事業群': ['半導體事業群'],
|
||||
'汽車事業體': ['汽車事業體'],
|
||||
'法務室': ['法務室'],
|
||||
'岡山製造事業體': ['生產處', '岡山製造事業體', '封裝工程處', '副總辦公室', '測試工程與研發處', '資材處', '廠務與環安衛管理處'],
|
||||
'產品事業體': ['產品事業體', '先進產品事業處', '成熟產品事業處'],
|
||||
'晶圓三廠': ['晶圓三廠', '製程工程處'],
|
||||
'集團人資行政事業體': ['集團人資行政事業體'],
|
||||
'集團財務事業體': ['集團財務事業體', '岡山強茂財務處'],
|
||||
'集團會計事業體': ['集團會計事業體', '岡山會計處', '集團會計處'],
|
||||
'集團資訊事業體': ['集團資訊事業體', '資安行動小組', '資訊一處', '資訊二處'],
|
||||
'新創事業體': ['新創事業體', '中低壓產品研發處', '研發中心', '高壓產品研發處'],
|
||||
'稽核室': ['稽核室'],
|
||||
'總經理室': ['總經理室', 'ESG專案辦公室', '專案管理室'],
|
||||
'總品質事業體': ['總品質事業體'],
|
||||
'營業事業體': ['營業事業體', '商業開發暨市場應用處', '海外銷售事業處', '全球技術服務處', '全球行銷暨業務支援處', '大中華區銷售事業處']
|
||||
};
|
||||
|
||||
// 處級單位 -> 部級單位 (預設資料,可被 API 覆蓋)
|
||||
export let divisionToDepartment = {
|
||||
'半導體事業群': ['(直屬)'],
|
||||
'汽車事業體': ['(直屬)'],
|
||||
'法務室': ['(直屬)'],
|
||||
'生產處': ['(直屬)', '生產部', '生產企劃部'],
|
||||
'岡山製造事業體': ['岡山品質管制部'],
|
||||
'封裝工程處': ['(直屬)', '製程工程一部', '製程工程二部', '設備一部', '設備二部'],
|
||||
'副總辦公室': ['工業工程部'],
|
||||
'測試工程與研發處': ['(直屬)', '測試工程部', '新產品導入部', '研發部'],
|
||||
'資材處': ['(直屬)', '採購部', '外部資源部', '生管部', '原物料控制部'],
|
||||
'廠務與環安衛管理處': ['(直屬)', '廠務部'],
|
||||
'產品事業體': ['廠務部'],
|
||||
'先進產品事業處': ['(直屬)', '產品管理部(APD)'],
|
||||
'成熟產品事業處': ['(直屬)', '產品管理部(MPD)'],
|
||||
'晶圓三廠': ['產品管理部(MPD)', '品質部', '製造部', '廠務部(Fab3)'],
|
||||
'製程工程處': ['工程一部', '工程二部', '工程三部', '製程整合部(Fab3)'],
|
||||
'集團人資行政事業體': ['製程整合部(Fab3)', '行政總務管理部', '招募任用部', '訓練發展部', '薪酬管理部'],
|
||||
'集團財務事業體': ['薪酬管理部', '岡山強茂財務部'],
|
||||
'岡山強茂財務處': ['(直屬)', '岡山強茂財務部'],
|
||||
'集團會計事業體': ['岡山強茂財務部'],
|
||||
'岡山會計處': ['(直屬)', '會計部', '管理會計部'],
|
||||
'集團會計處': ['(直屬)', '集團合併報表部'],
|
||||
'集團資訊事業體': ['集團合併報表部'],
|
||||
'資安行動小組': ['集團合併報表部'],
|
||||
'資訊一處': ['應用系統部', '電腦整合製造部', '系統網路服務部'],
|
||||
'資訊二處': ['(直屬)'],
|
||||
'新創事業體': ['(直屬)', '資源管理部'],
|
||||
'中低壓產品研發處': ['(直屬)'],
|
||||
'研發中心': ['(直屬)'],
|
||||
'高壓產品研發處': ['(直屬)'],
|
||||
'稽核室': ['(直屬)'],
|
||||
'總經理室': ['(直屬)'],
|
||||
'ESG專案辦公室': ['(直屬)'],
|
||||
'專案管理室': ['(直屬)'],
|
||||
'總品質事業體': ['(直屬)', '客戶品質管理部', '產品品質管理部', '品質系統及客戶工程整合部', '封測外包品質管理部', '品質保證部'],
|
||||
'營業事業體': ['品質保證部'],
|
||||
'商業開發暨市場應用處': ['(直屬)'],
|
||||
'海外銷售事業處': ['(直屬)', '日本區暨代工業務部', '歐亞區業務部', '韓國區業務部-韓國區', '美洲區業務部'],
|
||||
'全球技術服務處': ['(直屬)', '應用工程部(GTS)', '系統工程部', '特性測試部'],
|
||||
'全球行銷暨業務支援處': ['(直屬)', '業務生管部', '市場行銷企劃部', 'MOSFET晶圓採購部'],
|
||||
'大中華區銷售事業處': ['(直屬)', '台灣區業務部', '業務一部', '業務二部']
|
||||
};
|
||||
|
||||
// 部級單位 -> 崗位名稱 (預設資料,可被 API 覆蓋)
|
||||
export let departmentToPosition = {
|
||||
'(直屬)': ['營運長', '營運長助理', '副總經理', '專案經理', '經副理', '法務專員', '專利工程師', '處長', '專員', '工程師', '課長', '專員/工程師', '主任', '總裁', '總經理', '經理', '助理'],
|
||||
'生產部': ['經副理', '課長', '組長', '班長', '副班長', '作業員'],
|
||||
'生產企劃部': ['經副理', '課長', '專員', '工程師'],
|
||||
'岡山品質管制部': ['經副理', '課長', '工程師', '組長', '班長', '副班長', '作業員', '副總經理', '副總經理助理'],
|
||||
'製程工程一部': ['經副理'],
|
||||
'製程工程二部': ['經副理', '課長', '工程師'],
|
||||
'設備一部': ['經副理'],
|
||||
'設備二部': ['經副理', '課長', '工程師'],
|
||||
'工業工程部': ['經副理', '工程師', '課長', '副理'],
|
||||
'測試工程部': ['經副理', '課長', '工程師'],
|
||||
'新產品導入部': ['經副理', '專員', '工程師'],
|
||||
'研發部': ['經副理', '課長', '工程師', '專員'],
|
||||
'採購部': ['經副理', '課長', '專員'],
|
||||
'外部資源部': ['專員'],
|
||||
'生管部': ['經副理', '課長', '專員', '班長', '副班長', '作業員'],
|
||||
'原物料控制部': ['經副理', '課長', '專員', '班長', '副班長', '作業員'],
|
||||
'廠務部': ['經副理', '課長', '工程師', '專員', '處長'],
|
||||
'產品管理部(APD)': ['經副理', '工程師'],
|
||||
'產品管理部(MPD)': ['經副理', '專案經副理', '工程師', '顧問', '專員'],
|
||||
'品質部': ['經副理', '工程師', '作業員'],
|
||||
'製造部': ['經副理', '課長', '班長', '副班長', '作業員'],
|
||||
'廠務部(Fab3)': ['經副理', '工程師'],
|
||||
'工程一部': ['經副理', '工程師'],
|
||||
'工程二部': ['經副理', '工程師'],
|
||||
'工程三部': ['經副理', '工程師'],
|
||||
'製程整合部(Fab3)': ['經副理', '工程師', '人資長'],
|
||||
'行政總務管理部': ['經副理', '專員', '助理'],
|
||||
'招募任用部': ['經副理', '專員'],
|
||||
'訓練發展部': ['經副理', '專員'],
|
||||
'薪酬管理部': ['經副理', '專員', '財務長'],
|
||||
'岡山強茂財務部': ['經副理', '課長', '專員', '專案副理', '會計長'],
|
||||
'會計部': ['經副理', '課長', '專員'],
|
||||
'管理會計部': ['經副理', '課長', '專員'],
|
||||
'集團合併報表部': ['經副理', '專員', '資訊長', '課長'],
|
||||
'應用系統部': ['經副理', '工程師'],
|
||||
'電腦整合製造部': ['經副理', '工程師'],
|
||||
'系統網路服務部': ['經副理', '工程師'],
|
||||
'資源管理部': ['經副理', '專員'],
|
||||
'客戶品質管理部': ['經副理', '課長', '工程師', '專員'],
|
||||
'產品品質管理部': ['經副理', '課長', '工程師', '班長', '作業員'],
|
||||
'品質系統及客戶工程整合部': ['經副理', '課長', '工程師'],
|
||||
'封測外包品質管理部': ['經副理', '課長', '工程師'],
|
||||
'品質保證部': ['經副理', '課長', '工程師', '班長', '副班長', '作業員', '副總經理', '副總經理助理'],
|
||||
'日本區暨代工業務部': ['經副理', '課長', '專員', '助理'],
|
||||
'歐亞區業務部': ['經副理', '課長', '專員', '助理'],
|
||||
'韓國區業務部-韓國區': ['經副理', '課長', '專員', '助理', '專案經理'],
|
||||
'美洲區業務部': ['經副理', '課長', '專員', '助理'],
|
||||
'應用工程部(GTS)': ['經副理', '專案經副理', '技術經副理', '工程師'],
|
||||
'系統工程部': ['經副理', '工程師'],
|
||||
'特性測試部': ['經副理', '課長', '工程師'],
|
||||
'業務生管部': ['經副理', '課長', '專員'],
|
||||
'市場行銷企劃部': ['處長', '經理', '專員'],
|
||||
'MOSFET晶圓採購部': ['經副理', '課長', '專員'],
|
||||
'台灣區業務部': ['專員', '助理'],
|
||||
'業務一部': ['處長/資深經理', '經副理', '專員', '助理'],
|
||||
'業務二部': ['處長/資深經理', '經副理', '專員', '助理']
|
||||
};
|
||||
|
||||
// ==================== API 資料載入函數 ====================
|
||||
|
||||
/**
|
||||
* 從 API 載入組織階層資料,覆蓋預設資料
|
||||
* @returns {Promise<boolean>} 是否載入成功
|
||||
*/
|
||||
export async function loadHierarchyData() {
|
||||
try {
|
||||
const response = await fetch('/api/hierarchy/cascade');
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
// 更新級聯映射資料
|
||||
if (result.data.businessToDivision) {
|
||||
Object.assign(businessToDivision, result.data.businessToDivision);
|
||||
}
|
||||
if (result.data.divisionToDepartment) {
|
||||
Object.assign(divisionToDepartment, result.data.divisionToDepartment);
|
||||
}
|
||||
if (result.data.departmentToPosition) {
|
||||
Object.assign(departmentToPosition, result.data.departmentToPosition);
|
||||
}
|
||||
console.log('[Hierarchy] 組織階層資料載入成功');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.warn('[Hierarchy] 無法從 API 載入組織階層資料,使用預設資料:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 工具函數 ====================
|
||||
|
||||
/**
|
||||
* 取得指定事業體的處級單位列表
|
||||
* @param {string} businessUnit - 事業體名稱
|
||||
* @returns {string[]} 處級單位列表
|
||||
*/
|
||||
export function getDivisions(businessUnit) {
|
||||
return businessToDivision[businessUnit] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得指定處級單位的部級單位列表
|
||||
* @param {string} division - 處級單位名稱
|
||||
* @returns {string[]} 部級單位列表
|
||||
*/
|
||||
export function getDepartments(division) {
|
||||
return divisionToDepartment[division] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得指定部級單位的崗位名稱列表
|
||||
* @param {string} department - 部級單位名稱
|
||||
* @returns {string[]} 崗位名稱列表
|
||||
*/
|
||||
export function getPositions(department) {
|
||||
return departmentToPosition[department] || [];
|
||||
}
|
||||
333
js/dropdowns.js
Normal file
333
js/dropdowns.js
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* 下拉選單模組
|
||||
* 處理階層式下拉選單的連動
|
||||
*/
|
||||
|
||||
import {
|
||||
businessUnits,
|
||||
businessToDivision,
|
||||
divisionToDepartment,
|
||||
departmentToPosition,
|
||||
loadHierarchyData
|
||||
} from './data/hierarchy.js';
|
||||
|
||||
// ==================== 初始化下拉選單 ====================
|
||||
|
||||
export function initializeDropdowns() {
|
||||
// 初始化崗位基礎資料維護的事業體下拉選單
|
||||
const businessUnitSelect = document.getElementById('businessUnit');
|
||||
if (businessUnitSelect) {
|
||||
businessUnitSelect.innerHTML = '<option value="">請選擇</option>';
|
||||
businessUnits.forEach(unit => {
|
||||
const option = document.createElement('option');
|
||||
option.value = unit;
|
||||
option.textContent = unit;
|
||||
businessUnitSelect.appendChild(option);
|
||||
});
|
||||
businessUnitSelect.addEventListener('change', onBusinessUnitChange);
|
||||
}
|
||||
|
||||
// 初始化處級單位下拉選單
|
||||
const divisionSelect = document.getElementById('division');
|
||||
if (divisionSelect) {
|
||||
divisionSelect.innerHTML = '<option value="">請先選擇事業體</option>';
|
||||
divisionSelect.addEventListener('change', onDivisionChange);
|
||||
}
|
||||
|
||||
// 初始化部級單位下拉選單
|
||||
const departmentSelect = document.getElementById('department');
|
||||
if (departmentSelect) {
|
||||
departmentSelect.innerHTML = '<option value="">請先選擇處級單位</option>';
|
||||
}
|
||||
|
||||
// ========== 初始化崗位描述模組的下拉選單 ==========
|
||||
const jdBusinessUnitSelect = document.getElementById('jd_businessUnit');
|
||||
if (jdBusinessUnitSelect) {
|
||||
jdBusinessUnitSelect.innerHTML = '<option value="">請選擇</option>';
|
||||
businessUnits.forEach(unit => {
|
||||
const option = document.createElement('option');
|
||||
option.value = unit;
|
||||
option.textContent = unit;
|
||||
jdBusinessUnitSelect.appendChild(option);
|
||||
});
|
||||
jdBusinessUnitSelect.addEventListener('change', onJobDescBusinessUnitChange);
|
||||
}
|
||||
|
||||
// 崗位描述的處級單位
|
||||
const jdDivisionSelect = document.getElementById('jd_division');
|
||||
if (jdDivisionSelect) {
|
||||
jdDivisionSelect.addEventListener('change', onJobDescDivisionChange);
|
||||
}
|
||||
|
||||
// 崗位描述的部級單位
|
||||
const jdDepartmentSelect = document.getElementById('jd_department');
|
||||
if (jdDepartmentSelect) {
|
||||
jdDepartmentSelect.addEventListener('change', onJobDescDepartmentChange);
|
||||
}
|
||||
|
||||
// ========== 初始化部門職責維護模組的下拉選單 ==========
|
||||
const deptFuncBusinessUnitSelect = document.getElementById('deptFunc_businessUnit');
|
||||
if (deptFuncBusinessUnitSelect) {
|
||||
deptFuncBusinessUnitSelect.innerHTML = '<option value="">請選擇</option>';
|
||||
businessUnits.forEach(unit => {
|
||||
const option = document.createElement('option');
|
||||
option.value = unit;
|
||||
option.textContent = unit;
|
||||
deptFuncBusinessUnitSelect.appendChild(option);
|
||||
});
|
||||
deptFuncBusinessUnitSelect.addEventListener('change', onDeptFuncBusinessUnitChange);
|
||||
}
|
||||
|
||||
// 部門職責的處級單位
|
||||
const deptFuncDivisionSelect = document.getElementById('deptFunc_division');
|
||||
if (deptFuncDivisionSelect) {
|
||||
deptFuncDivisionSelect.addEventListener('change', onDeptFuncDivisionChange);
|
||||
}
|
||||
|
||||
// 部門職責的部級單位
|
||||
const deptFuncDepartmentSelect = document.getElementById('deptFunc_department');
|
||||
if (deptFuncDepartmentSelect) {
|
||||
deptFuncDepartmentSelect.addEventListener('change', onDeptFuncDepartmentChange);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 崗位基礎資料維護的連動 ====================
|
||||
|
||||
function onBusinessUnitChange(event) {
|
||||
const selectedBusiness = event.target.value;
|
||||
const divisionSelect = document.getElementById('division');
|
||||
const departmentSelect = document.getElementById('department');
|
||||
|
||||
if (divisionSelect) {
|
||||
divisionSelect.innerHTML = '<option value="">請選擇</option>';
|
||||
}
|
||||
if (departmentSelect) {
|
||||
departmentSelect.innerHTML = '<option value="">請先選擇處級單位</option>';
|
||||
}
|
||||
|
||||
if (selectedBusiness && businessToDivision[selectedBusiness]) {
|
||||
const divisions = businessToDivision[selectedBusiness];
|
||||
divisions.forEach(division => {
|
||||
const option = document.createElement('option');
|
||||
option.value = division;
|
||||
option.textContent = division;
|
||||
divisionSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onDivisionChange(event) {
|
||||
const selectedDivision = event.target.value;
|
||||
const departmentSelect = document.getElementById('department');
|
||||
|
||||
if (departmentSelect) {
|
||||
departmentSelect.innerHTML = '<option value="">請選擇</option>';
|
||||
}
|
||||
|
||||
if (selectedDivision && divisionToDepartment[selectedDivision]) {
|
||||
const departments = divisionToDepartment[selectedDivision];
|
||||
departments.forEach(department => {
|
||||
const option = document.createElement('option');
|
||||
option.value = department;
|
||||
option.textContent = department;
|
||||
departmentSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 崗位描述模組的階層式下拉選單 ====================
|
||||
|
||||
function onJobDescBusinessUnitChange(event) {
|
||||
const selectedBusiness = event.target.value;
|
||||
const divisionSelect = document.getElementById('jd_division');
|
||||
const departmentSelect = document.getElementById('jd_department');
|
||||
const positionSelect = document.getElementById('jd_positionTitle');
|
||||
|
||||
if (divisionSelect) divisionSelect.innerHTML = '<option value="">請選擇</option>';
|
||||
if (departmentSelect) departmentSelect.innerHTML = '<option value="">請先選擇處級單位</option>';
|
||||
if (positionSelect) positionSelect.innerHTML = '<option value="">請先選擇部級單位</option>';
|
||||
|
||||
if (selectedBusiness && businessToDivision[selectedBusiness]) {
|
||||
const divisions = businessToDivision[selectedBusiness];
|
||||
divisions.forEach(division => {
|
||||
const option = document.createElement('option');
|
||||
option.value = division;
|
||||
option.textContent = division;
|
||||
divisionSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onJobDescDivisionChange(event) {
|
||||
const selectedDivision = event.target.value;
|
||||
const departmentSelect = document.getElementById('jd_department');
|
||||
const positionSelect = document.getElementById('jd_positionTitle');
|
||||
|
||||
if (departmentSelect) departmentSelect.innerHTML = '<option value="">請選擇</option>';
|
||||
if (positionSelect) positionSelect.innerHTML = '<option value="">請先選擇部級單位</option>';
|
||||
|
||||
if (selectedDivision && divisionToDepartment[selectedDivision]) {
|
||||
const departments = divisionToDepartment[selectedDivision];
|
||||
departments.forEach(department => {
|
||||
const option = document.createElement('option');
|
||||
option.value = department;
|
||||
option.textContent = department;
|
||||
departmentSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onJobDescDepartmentChange(event) {
|
||||
const selectedDepartment = event.target.value;
|
||||
const positionSelect = document.getElementById('jd_positionTitle');
|
||||
|
||||
if (positionSelect) positionSelect.innerHTML = '<option value="">請選擇</option>';
|
||||
|
||||
if (selectedDepartment && departmentToPosition[selectedDepartment]) {
|
||||
const positions = departmentToPosition[selectedDepartment];
|
||||
positions.forEach(position => {
|
||||
const option = document.createElement('option');
|
||||
option.value = position;
|
||||
option.textContent = position;
|
||||
positionSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 部門職責維護模組的階層式下拉選單 ====================
|
||||
|
||||
function onDeptFuncBusinessUnitChange(event) {
|
||||
const selectedBusiness = event.target.value;
|
||||
const divisionSelect = document.getElementById('deptFunc_division');
|
||||
const departmentSelect = document.getElementById('deptFunc_department');
|
||||
const positionSelect = document.getElementById('deptFunc_positionTitle');
|
||||
|
||||
if (divisionSelect) divisionSelect.innerHTML = '<option value="">請選擇</option>';
|
||||
if (departmentSelect) departmentSelect.innerHTML = '<option value="">請先選擇處級單位</option>';
|
||||
if (positionSelect) positionSelect.innerHTML = '<option value="">請先選擇部級單位</option>';
|
||||
|
||||
if (selectedBusiness && businessToDivision[selectedBusiness]) {
|
||||
const divisions = businessToDivision[selectedBusiness];
|
||||
divisions.forEach(division => {
|
||||
const option = document.createElement('option');
|
||||
option.value = division;
|
||||
option.textContent = division;
|
||||
divisionSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onDeptFuncDivisionChange(event) {
|
||||
const selectedDivision = event.target.value;
|
||||
const departmentSelect = document.getElementById('deptFunc_department');
|
||||
const positionSelect = document.getElementById('deptFunc_positionTitle');
|
||||
|
||||
if (departmentSelect) departmentSelect.innerHTML = '<option value="">請選擇</option>';
|
||||
if (positionSelect) positionSelect.innerHTML = '<option value="">請先選擇部級單位</option>';
|
||||
|
||||
if (selectedDivision && divisionToDepartment[selectedDivision]) {
|
||||
const departments = divisionToDepartment[selectedDivision];
|
||||
departments.forEach(department => {
|
||||
const option = document.createElement('option');
|
||||
option.value = department;
|
||||
option.textContent = department;
|
||||
departmentSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onDeptFuncDepartmentChange(event) {
|
||||
const selectedDepartment = event.target.value;
|
||||
const positionSelect = document.getElementById('deptFunc_positionTitle');
|
||||
|
||||
if (positionSelect) positionSelect.innerHTML = '<option value="">請選擇</option>';
|
||||
|
||||
if (selectedDepartment && departmentToPosition[selectedDepartment]) {
|
||||
const positions = departmentToPosition[selectedDepartment];
|
||||
positions.forEach(position => {
|
||||
const option = document.createElement('option');
|
||||
option.value = position;
|
||||
option.textContent = position;
|
||||
positionSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 部門職責關聯功能 ====================
|
||||
|
||||
// 部門職責資料 (示範用)
|
||||
let deptFunctionData = [];
|
||||
|
||||
export function refreshDeptFunctionList(showMessage = false) {
|
||||
const select = document.getElementById('jd_deptFunction');
|
||||
if (!select) return;
|
||||
|
||||
select.innerHTML = '<option value="">-- 請選擇部門職責 --</option>';
|
||||
|
||||
if (deptFunctionData.length > 0) {
|
||||
deptFunctionData.forEach(df => {
|
||||
const option = document.createElement('option');
|
||||
option.value = df.deptFunctionCode;
|
||||
option.textContent = `${df.deptFunctionCode} - ${df.deptFunctionName} (${df.deptFunctionDept})`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
if (showMessage && typeof showToast === 'function') {
|
||||
showToast('已載入 ' + deptFunctionData.length + ' 筆部門職責資料');
|
||||
}
|
||||
} else {
|
||||
if (showMessage && typeof showToast === 'function') {
|
||||
showToast('尚無部門職責資料,請先建立部門職責');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function loadDeptFunctionInfo() {
|
||||
const select = document.getElementById('jd_deptFunction');
|
||||
const infoSection = document.getElementById('deptFunctionInfoSection');
|
||||
|
||||
if (!select) return;
|
||||
|
||||
const selectedCode = select.value;
|
||||
|
||||
if (!selectedCode) {
|
||||
if (infoSection) infoSection.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const deptFunc = deptFunctionData.find(d => d.deptFunctionCode === selectedCode);
|
||||
|
||||
if (deptFunc) {
|
||||
const codeEl = document.getElementById('jd_deptFunctionCode');
|
||||
const buEl = document.getElementById('jd_deptFunctionBU');
|
||||
const missionEl = document.getElementById('jd_deptMission');
|
||||
const functionsEl = document.getElementById('jd_deptCoreFunctions');
|
||||
const kpisEl = document.getElementById('jd_deptKPIs');
|
||||
|
||||
if (codeEl) codeEl.value = deptFunc.deptFunctionCode || '';
|
||||
if (buEl) buEl.value = deptFunc.deptFunctionBU || '';
|
||||
if (missionEl) missionEl.value = deptFunc.deptMission || '';
|
||||
if (functionsEl) functionsEl.value = deptFunc.deptCoreFunctions || '';
|
||||
if (kpisEl) kpisEl.value = deptFunc.deptKPIs || '';
|
||||
|
||||
const deptInput = document.getElementById('jd_department');
|
||||
if (deptInput && !deptInput.value) {
|
||||
deptInput.value = deptFunc.deptFunctionDept;
|
||||
}
|
||||
|
||||
if (infoSection) infoSection.style.display = 'block';
|
||||
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('已載入部門職責: ' + deptFunc.deptFunctionName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setDeptFunctionData(data) {
|
||||
deptFunctionData = data;
|
||||
}
|
||||
|
||||
// 初始化時載入階層資料
|
||||
export async function initializeHierarchyDropdowns() {
|
||||
await loadHierarchyData();
|
||||
initializeDropdowns();
|
||||
}
|
||||
559
js/error_handler.js
Normal file
559
js/error_handler.js
Normal file
@@ -0,0 +1,559 @@
|
||||
/**
|
||||
* Global Error Handler for HR Position System
|
||||
* Provides unified error handling and user-friendly error dialogs
|
||||
*/
|
||||
|
||||
class ErrorHandler {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Create error modal container if not exists
|
||||
if (!document.getElementById('error-modal-container')) {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'error-modal-container';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
// Add error handler CSS
|
||||
this.injectStyles();
|
||||
|
||||
// Setup global error handlers
|
||||
this.setupGlobalHandlers();
|
||||
}
|
||||
|
||||
injectStyles() {
|
||||
if (document.getElementById('error-handler-styles')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'error-handler-styles';
|
||||
style.textContent = `
|
||||
.error-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.error-modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 0;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
animation: slideDown 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.error-modal-header {
|
||||
padding: 20px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.error-modal-header.error {
|
||||
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
|
||||
color: white;
|
||||
border-bottom-color: #c0392b;
|
||||
}
|
||||
|
||||
.error-modal-header.warning {
|
||||
background: linear-gradient(135deg, #f39c12 0%, #e67e22 100%);
|
||||
color: white;
|
||||
border-bottom-color: #e67e22;
|
||||
}
|
||||
|
||||
.error-modal-header.info {
|
||||
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
|
||||
color: white;
|
||||
border-bottom-color: #2980b9;
|
||||
}
|
||||
|
||||
.error-modal-header.success {
|
||||
background: linear-gradient(135deg, #27ae60 0%, #229954 100%);
|
||||
color: white;
|
||||
border-bottom-color: #229954;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
flex: 1;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-modal-body {
|
||||
padding: 25px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 15px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.error-details {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
margin-top: 15px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.error-modal-footer {
|
||||
padding: 15px 25px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.error-btn {
|
||||
padding: 10px 25px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
font-family: 'Noto Sans TC', sans-serif;
|
||||
}
|
||||
|
||||
.error-btn-primary {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.error-btn-primary:hover {
|
||||
background: #2980b9;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
|
||||
}
|
||||
|
||||
.error-btn-secondary {
|
||||
background: #95a5a6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.error-btn-secondary:hover {
|
||||
background: #7f8c8d;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
transform: translateY(-50px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.error-toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 15px 20px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
z-index: 10001;
|
||||
animation: slideInRight 0.3s ease;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.error-toast.error {
|
||||
border-left: 4px solid #e74c3c;
|
||||
}
|
||||
|
||||
.error-toast.warning {
|
||||
border-left: 4px solid #f39c12;
|
||||
}
|
||||
|
||||
.error-toast.info {
|
||||
border-left: 4px solid #3498db;
|
||||
}
|
||||
|
||||
.error-toast.success {
|
||||
border-left: 4px solid #27ae60;
|
||||
}
|
||||
|
||||
.error-toast-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.error-toast-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.error-toast-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 5px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.error-toast-message {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error-toast-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.error-toast-close:hover {
|
||||
background: #f0f0f0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOutRight {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
setupGlobalHandlers() {
|
||||
// Handle uncaught errors
|
||||
window.addEventListener('error', (event) => {
|
||||
this.handleError({
|
||||
title: '執行錯誤',
|
||||
message: event.message,
|
||||
details: `檔案: ${event.filename}\n行號: ${event.lineno}:${event.colno}\n錯誤: ${event.error?.stack || event.message}`
|
||||
});
|
||||
});
|
||||
|
||||
// Handle unhandled promise rejections
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
this.handleError({
|
||||
title: 'Promise 錯誤',
|
||||
message: '發生未處理的 Promise 錯誤',
|
||||
details: event.reason?.stack || event.reason
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error modal
|
||||
* @param {Object} options - Error options
|
||||
* @param {string} options.title - Error title
|
||||
* @param {string} options.message - Error message
|
||||
* @param {string} options.details - Error details (optional)
|
||||
* @param {string} options.type - Error type: error, warning, info, success (default: error)
|
||||
* @param {Function} options.onClose - Callback when modal closes
|
||||
*/
|
||||
showError(options) {
|
||||
const {
|
||||
title = '錯誤',
|
||||
message = '發生未知錯誤',
|
||||
details = null,
|
||||
type = 'error',
|
||||
onClose = null
|
||||
} = options;
|
||||
|
||||
const icons = {
|
||||
error: '❌',
|
||||
warning: '⚠️',
|
||||
info: 'ℹ️',
|
||||
success: '✅'
|
||||
};
|
||||
|
||||
const container = document.getElementById('error-modal-container');
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'error-modal-overlay';
|
||||
modal.innerHTML = `
|
||||
<div class="error-modal">
|
||||
<div class="error-modal-header ${type}">
|
||||
<div class="error-icon">${icons[type]}</div>
|
||||
<h3 class="error-title">${this.escapeHtml(title)}</h3>
|
||||
</div>
|
||||
<div class="error-modal-body">
|
||||
<div class="error-message">${this.escapeHtml(message)}</div>
|
||||
${details ? `<div class="error-details">${this.escapeHtml(details)}</div>` : ''}
|
||||
</div>
|
||||
<div class="error-modal-footer">
|
||||
<button class="error-btn error-btn-primary" onclick="window.errorHandler.closeModal(this)">
|
||||
確定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(modal);
|
||||
|
||||
// Store close callback
|
||||
modal._onClose = onClose;
|
||||
|
||||
// Close on overlay click
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
this.closeModal(modal.querySelector('.error-btn'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show toast notification
|
||||
* @param {Object} options - Toast options
|
||||
* @param {string} options.title - Toast title
|
||||
* @param {string} options.message - Toast message
|
||||
* @param {string} options.type - Toast type: error, warning, info, success (default: info)
|
||||
* @param {number} options.duration - Duration in ms (default: 3000)
|
||||
*/
|
||||
showToast(options) {
|
||||
const {
|
||||
title = '',
|
||||
message = '',
|
||||
type = 'info',
|
||||
duration = 3000
|
||||
} = options;
|
||||
|
||||
const icons = {
|
||||
error: '❌',
|
||||
warning: '⚠️',
|
||||
info: 'ℹ️',
|
||||
success: '✅'
|
||||
};
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `error-toast ${type}`;
|
||||
toast.innerHTML = `
|
||||
<div class="error-toast-icon">${icons[type]}</div>
|
||||
<div class="error-toast-content">
|
||||
${title ? `<div class="error-toast-title">${this.escapeHtml(title)}</div>` : ''}
|
||||
<div class="error-toast-message">${this.escapeHtml(message)}</div>
|
||||
</div>
|
||||
<button class="error-toast-close" onclick="this.parentElement.remove()">×</button>
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// Auto remove after duration
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'slideOutRight 0.3s ease';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, duration);
|
||||
}
|
||||
}
|
||||
|
||||
closeModal(button) {
|
||||
const modal = button.closest('.error-modal-overlay');
|
||||
if (modal) {
|
||||
const onClose = modal._onClose;
|
||||
|
||||
modal.style.animation = 'fadeOut 0.3s ease';
|
||||
setTimeout(() => {
|
||||
modal.remove();
|
||||
if (onClose && typeof onClose === 'function') {
|
||||
onClose();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API errors
|
||||
* @param {Error} error - Error object
|
||||
* @param {Object} options - Additional options
|
||||
*/
|
||||
async handleAPIError(error, options = {}) {
|
||||
const { showModal = true, showToast = false } = options;
|
||||
|
||||
let message = '發生 API 錯誤';
|
||||
let details = null;
|
||||
|
||||
if (error.response) {
|
||||
// Server responded with error
|
||||
const data = error.response.data;
|
||||
message = data.error || data.message || `HTTP ${error.response.status} 錯誤`;
|
||||
details = JSON.stringify(data, null, 2);
|
||||
} else if (error.request) {
|
||||
// No response received
|
||||
message = '無法連接到伺服器,請檢查網路連線';
|
||||
details = error.message;
|
||||
} else {
|
||||
// Request setup error
|
||||
message = error.message;
|
||||
details = error.stack;
|
||||
}
|
||||
|
||||
if (showModal) {
|
||||
this.showError({
|
||||
title: 'API 錯誤',
|
||||
message,
|
||||
details,
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
|
||||
if (showToast) {
|
||||
this.showToast({
|
||||
title: 'API 錯誤',
|
||||
message,
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle general errors
|
||||
* @param {Object} options - Error options
|
||||
*/
|
||||
handleError(options) {
|
||||
console.error('[ErrorHandler]', options);
|
||||
this.showError(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
* @param {string} text - Text to escape
|
||||
* @returns {string} - Escaped text
|
||||
*/
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm dialog
|
||||
* @param {Object} options - Confirm options
|
||||
* @param {string} options.title - Title
|
||||
* @param {string} options.message - Message
|
||||
* @param {string} options.confirmText - Confirm button text
|
||||
* @param {string} options.cancelText - Cancel button text
|
||||
* @param {Function} options.onConfirm - Confirm callback
|
||||
* @param {Function} options.onCancel - Cancel callback
|
||||
*/
|
||||
confirm(options) {
|
||||
const {
|
||||
title = '確認',
|
||||
message = '確定要執行此操作嗎?',
|
||||
confirmText = '確定',
|
||||
cancelText = '取消',
|
||||
onConfirm = null,
|
||||
onCancel = null
|
||||
} = options;
|
||||
|
||||
const container = document.getElementById('error-modal-container');
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'error-modal-overlay';
|
||||
modal.innerHTML = `
|
||||
<div class="error-modal">
|
||||
<div class="error-modal-header info">
|
||||
<div class="error-icon">❓</div>
|
||||
<h3 class="error-title">${this.escapeHtml(title)}</h3>
|
||||
</div>
|
||||
<div class="error-modal-body">
|
||||
<div class="error-message">${this.escapeHtml(message)}</div>
|
||||
</div>
|
||||
<div class="error-modal-footer">
|
||||
<button class="error-btn error-btn-secondary" data-action="cancel">
|
||||
${this.escapeHtml(cancelText)}
|
||||
</button>
|
||||
<button class="error-btn error-btn-primary" data-action="confirm">
|
||||
${this.escapeHtml(confirmText)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(modal);
|
||||
|
||||
// Handle button clicks
|
||||
modal.querySelectorAll('[data-action]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const action = btn.getAttribute('data-action');
|
||||
modal.remove();
|
||||
|
||||
if (action === 'confirm' && onConfirm) {
|
||||
onConfirm();
|
||||
} else if (action === 'cancel' && onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize global error handler
|
||||
window.errorHandler = new ErrorHandler();
|
||||
|
||||
// Export for module usage
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = ErrorHandler;
|
||||
}
|
||||
155
js/fix_cors.js
Normal file
155
js/fix_cors.js
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* CORS 錯誤修正
|
||||
* 將直接調用 Claude API 改為通過後端 Flask API 調用
|
||||
*
|
||||
* 使用方法:
|
||||
* 1. 在 index.html 中找到 callClaudeAPI 函數
|
||||
* 2. 將其替換為下面的新版本
|
||||
*/
|
||||
|
||||
// ==================== 修正後的 AI Generation Functions ====================
|
||||
|
||||
/**
|
||||
* 調用後端 LLM API 生成文字
|
||||
* @param {string} prompt - 提示詞
|
||||
* @param {string} api - API 名稱 (gemini, deepseek, openai)
|
||||
* @returns {Promise<Object>} - 生成的 JSON 數據
|
||||
*/
|
||||
async function callClaudeAPI(prompt, api = 'gemini') {
|
||||
try {
|
||||
// 調用後端 Flask API,而不是直接調用 Claude API
|
||||
const response = await fetch("/api/llm/generate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
api: api, // 使用 Gemini 作為默認
|
||||
prompt: prompt,
|
||||
max_tokens: 2000
|
||||
})
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
// 移除可能的 markdown 代碼塊標記
|
||||
responseText = responseText.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim();
|
||||
|
||||
// 解析 JSON
|
||||
return JSON.parse(responseText);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error calling LLM API:", error);
|
||||
|
||||
// 使用全局錯誤處理器顯示錯誤
|
||||
if (window.errorHandler) {
|
||||
window.errorHandler.showError({
|
||||
title: 'AI 生成錯誤',
|
||||
message: error.message || '調用 AI API 時發生錯誤',
|
||||
type: 'error',
|
||||
details: error.stack
|
||||
});
|
||||
} else {
|
||||
alert(`AI 生成錯誤: ${error.message}`);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 設置按鈕載入狀態
|
||||
* @param {HTMLElement} btn - 按鈕元素
|
||||
* @param {boolean} loading - 是否載入中
|
||||
*/
|
||||
function setButtonLoading(btn, loading) {
|
||||
if (loading) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<div class="spinner"></div><span>AI 生成中...</span>';
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg><span>✨ I\'m feeling lucky</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 選擇 LLM API 提供者 ====================
|
||||
|
||||
/**
|
||||
* 讓用戶選擇使用哪個 LLM API
|
||||
* @returns {Promise<string>} - 選擇的 API 名稱
|
||||
*/
|
||||
async function selectLLMProvider() {
|
||||
// 獲取可用的 API 列表
|
||||
try {
|
||||
const response = await fetch('/api/llm/config');
|
||||
const config = await response.json();
|
||||
|
||||
const enabledAPIs = [];
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (value.enabled) {
|
||||
enabledAPIs.push({
|
||||
key: key,
|
||||
name: value.name
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (enabledAPIs.length === 0) {
|
||||
throw new Error('沒有可用的 LLM API,請先配置 API Key');
|
||||
}
|
||||
|
||||
// 如果只有一個 API,直接使用
|
||||
if (enabledAPIs.length === 1) {
|
||||
return enabledAPIs[0].key;
|
||||
}
|
||||
|
||||
// 多個 API 時,使用第一個(默認 Gemini)
|
||||
return enabledAPIs[0].key;
|
||||
|
||||
} catch (error) {
|
||||
console.error('無法獲取 LLM 配置:', error);
|
||||
// 默認使用 Gemini
|
||||
return 'gemini';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 增強版的 callClaudeAPI - 自動選擇最佳 API
|
||||
* @param {string} prompt - 提示詞
|
||||
* @returns {Promise<Object>} - 生成的 JSON 數據
|
||||
*/
|
||||
async function callAIAPI(prompt) {
|
||||
const api = await selectLLMProvider();
|
||||
return callClaudeAPI(prompt, api);
|
||||
}
|
||||
|
||||
// ==================== 使用示例 ====================
|
||||
|
||||
/*
|
||||
// 原來的調用方式(會導致 CORS 錯誤):
|
||||
const result = await callClaudeAPI(prompt);
|
||||
|
||||
// 修正後的調用方式 1(使用默認 Gemini):
|
||||
const result = await callClaudeAPI(prompt, 'gemini');
|
||||
|
||||
// 修正後的調用方式 2(使用 DeepSeek):
|
||||
const result = await callClaudeAPI(prompt, 'deepseek');
|
||||
|
||||
// 修正後的調用方式 3(使用 OpenAI):
|
||||
const result = await callClaudeAPI(prompt, 'openai');
|
||||
|
||||
// 修正後的調用方式 4(自動選擇最佳 API):
|
||||
const result = await callAIAPI(prompt);
|
||||
*/
|
||||
571
js/forms.js
Normal file
571
js/forms.js
Normal file
@@ -0,0 +1,571 @@
|
||||
/**
|
||||
* 表單邏輯模組
|
||||
* 處理各表單的資料操作、驗證和提交
|
||||
*/
|
||||
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
// ==================== 常數映射 ====================
|
||||
|
||||
export const categoryMap = { '01': '技術職', '02': '管理職', '03': '業務職', '04': '行政職' };
|
||||
export const natureMap = { 'FT': '全職', 'PT': '兼職', 'CT': '約聘', 'IN': '實習' };
|
||||
export const jobCategoryMap = { 'MGR': '管理職', 'TECH': '技術職', 'SALE': '業務職', 'ADMIN': '行政職', 'RD': '研發職', 'PROD': '生產職' };
|
||||
|
||||
// ==================== 崗位清單全域變數 ====================
|
||||
export let positionListData = [];
|
||||
export let currentSortColumn = '';
|
||||
export let currentSortDirection = 'asc';
|
||||
|
||||
// ==================== 崗位基礎資料表單 ====================
|
||||
|
||||
export function updateCategoryName() {
|
||||
const category = document.getElementById('positionCategory').value;
|
||||
document.getElementById('positionCategoryName').value = categoryMap[category] || '';
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
export function updateNatureName() {
|
||||
const nature = document.getElementById('positionNature').value;
|
||||
document.getElementById('positionNatureName').value = natureMap[nature] || '';
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
export function changePositionCode() {
|
||||
const currentCode = document.getElementById('positionCode').value;
|
||||
const newCode = prompt('請輸入新的崗位編號:', currentCode);
|
||||
if (newCode && newCode !== currentCode) {
|
||||
document.getElementById('positionCode').value = newCode;
|
||||
showToast('崗位編號已更改!');
|
||||
updatePreview();
|
||||
}
|
||||
}
|
||||
|
||||
export function getPositionFormData() {
|
||||
const form = document.getElementById('positionForm');
|
||||
const formData = new FormData(form);
|
||||
const data = { basicInfo: {}, recruitInfo: {} };
|
||||
const basicFields = ['positionCode', 'positionName', 'positionCategory', 'positionCategoryName', 'positionNature', 'positionNatureName', 'headcount', 'positionLevel', 'effectiveDate', 'positionDesc', 'positionRemark'];
|
||||
const recruitFields = ['minEducation', 'requiredGender', 'salaryRange', 'workExperience', 'minAge', 'maxAge', 'jobType', 'recruitPosition', 'jobTitle', 'jobDesc', 'positionReq', 'titleReq', 'majorReq', 'skillReq', 'langReq', 'otherReq', 'superiorPosition', 'recruitRemark'];
|
||||
basicFields.forEach(field => { const value = formData.get(field); if (value) data.basicInfo[field] = value; });
|
||||
recruitFields.forEach(field => { const value = formData.get(field); if (value) data.recruitInfo[field] = value; });
|
||||
return data;
|
||||
}
|
||||
|
||||
export function savePositionAndExit() {
|
||||
const form = document.getElementById('positionForm');
|
||||
if (!form.checkValidity()) { form.reportValidity(); return; }
|
||||
console.log('Save Position:', getPositionFormData());
|
||||
showToast('崗位資料已保存!');
|
||||
}
|
||||
|
||||
export function savePositionAndNew() {
|
||||
const form = document.getElementById('positionForm');
|
||||
if (!form.checkValidity()) { form.reportValidity(); return; }
|
||||
console.log('Save Position:', getPositionFormData());
|
||||
showToast('崗位資料已保存,請繼續新增!');
|
||||
form.reset();
|
||||
document.getElementById('effectiveDate').value = new Date().toISOString().split('T')[0];
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
export async function saveToPositionList() {
|
||||
const form = document.getElementById('positionForm');
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = getPositionFormData();
|
||||
|
||||
if (!formData.basicInfo.positionCode) {
|
||||
alert('請輸入崗位編號');
|
||||
return;
|
||||
}
|
||||
if (!formData.basicInfo.positionName) {
|
||||
alert('請輸入崗位名稱');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(API_BASE_URL + '/positions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast(result.message || '崗位已成功儲存至崗位清單!');
|
||||
setTimeout(() => {
|
||||
switchModule('positionlist');
|
||||
}, 1500);
|
||||
} else {
|
||||
alert('儲存失敗: ' + (result.error || '未知錯誤'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('儲存錯誤:', error);
|
||||
alert('儲存失敗: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelPositionForm() {
|
||||
if (confirm('確定要取消嗎?未保存的資料將會遺失。')) {
|
||||
document.getElementById('positionForm').reset();
|
||||
updatePreview();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 職務基礎資料表單 ====================
|
||||
|
||||
export function updateJobCategoryName() {
|
||||
const category = document.getElementById('jobCategoryCode').value;
|
||||
document.getElementById('jobCategoryName').value = jobCategoryMap[category] || '';
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
export function changeJobCode() {
|
||||
const currentCode = document.getElementById('jobCode').value;
|
||||
const newCode = prompt('請輸入新的職務編號:', currentCode);
|
||||
if (newCode && newCode !== currentCode) {
|
||||
document.getElementById('jobCode').value = newCode;
|
||||
showToast('職務編號已更改!');
|
||||
updatePreview();
|
||||
}
|
||||
}
|
||||
|
||||
export function getJobFormData() {
|
||||
const form = document.getElementById('jobForm');
|
||||
const formData = new FormData(form);
|
||||
const data = {};
|
||||
const fields = ['jobCategoryCode', 'jobCategoryName', 'jobCode', 'jobName', 'jobNameEn', 'jobEffectiveDate', 'jobHeadcount', 'jobSortOrder', 'jobRemark', 'jobLevel'];
|
||||
fields.forEach(field => { const value = formData.get(field); if (value) data[field] = value; });
|
||||
data.hasAttendanceBonus = document.getElementById('job_hasAttBonus')?.checked || false;
|
||||
data.hasHousingAllowance = document.getElementById('job_hasHouseAllow')?.checked || false;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function saveJobToPositionList() {
|
||||
const form = document.getElementById('jobForm');
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = getJobFormData();
|
||||
|
||||
if (!formData.jobCode) {
|
||||
alert('請輸入職務代碼');
|
||||
return;
|
||||
}
|
||||
if (!formData.jobName) {
|
||||
alert('請輸入職務名稱');
|
||||
return;
|
||||
}
|
||||
|
||||
const positionData = {
|
||||
basicInfo: {
|
||||
positionCode: formData.jobCode,
|
||||
positionName: formData.jobName,
|
||||
positionCategory: formData.jobCategoryCode || '',
|
||||
effectiveDate: formData.jobEffectiveDate || new Date().toISOString().split('T')[0],
|
||||
headcount: formData.jobHeadcount || 1,
|
||||
positionLevel: formData.jobLevel || '',
|
||||
positionRemark: formData.jobRemark || ''
|
||||
},
|
||||
recruitInfo: {}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(API_BASE_URL + '/positions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(positionData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast(result.message || '職務已成功儲存至崗位清單!');
|
||||
setTimeout(() => {
|
||||
switchModule('positionlist');
|
||||
}, 1500);
|
||||
} else {
|
||||
alert('儲存失敗: ' + (result.error || '未知錯誤'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('儲存錯誤:', error);
|
||||
alert('儲存失敗: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
export function saveJobAndExit() {
|
||||
const form = document.getElementById('jobForm');
|
||||
if (!form.checkValidity()) { form.reportValidity(); return; }
|
||||
console.log('Save Job:', getJobFormData());
|
||||
showToast('職務資料已保存!');
|
||||
}
|
||||
|
||||
export function saveJobAndNew() {
|
||||
const form = document.getElementById('jobForm');
|
||||
if (!form.checkValidity()) { form.reportValidity(); return; }
|
||||
console.log('Save Job:', getJobFormData());
|
||||
showToast('職務資料已保存,請繼續新增!');
|
||||
form.reset();
|
||||
document.getElementById('attendanceLabel').textContent = '否';
|
||||
document.getElementById('housingLabel').textContent = '否';
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
export function cancelJobForm() {
|
||||
if (confirm('確定要取消嗎?未保存的資料將會遺失。')) {
|
||||
document.getElementById('jobForm').reset();
|
||||
document.getElementById('attendanceLabel').textContent = '否';
|
||||
document.getElementById('housingLabel').textContent = '否';
|
||||
updatePreview();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 崗位描述表單 ====================
|
||||
|
||||
export function getJobDescFormData() {
|
||||
const form = document.getElementById('jobDescForm');
|
||||
const formData = new FormData(form);
|
||||
const data = { basicInfo: {}, positionInfo: {}, responsibilities: {}, requirements: {} };
|
||||
|
||||
['empNo', 'empName', 'positionCode', 'versionDate'].forEach(field => {
|
||||
const el = document.getElementById('jd_' + field);
|
||||
if (el && el.value) data.basicInfo[field] = el.value;
|
||||
});
|
||||
|
||||
['positionName', 'department', 'positionEffectiveDate', 'directSupervisor', 'positionGradeJob', 'reportTo', 'directReports', 'workLocation', 'empAttribute'].forEach(field => {
|
||||
const el = document.getElementById('jd_' + field);
|
||||
if (el && el.value) data.positionInfo[field] = el.value;
|
||||
});
|
||||
|
||||
const purpose = document.getElementById('jd_positionPurpose');
|
||||
if (purpose && purpose.value) data.responsibilities.positionPurpose = purpose.value;
|
||||
const mainResp = document.getElementById('jd_mainResponsibilities');
|
||||
if (mainResp && mainResp.value) data.responsibilities.mainResponsibilities = mainResp.value;
|
||||
|
||||
['education', 'basicSkills', 'professionalKnowledge', 'workExperienceReq', 'otherRequirements'].forEach(field => {
|
||||
const el = document.getElementById('jd_' + field);
|
||||
if (el && el.value) data.requirements[field] = el.value;
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function saveJobDescAndExit() {
|
||||
const formData = getJobDescFormData();
|
||||
console.log('Save JobDesc:', formData);
|
||||
|
||||
if (!formData.basicInfo.positionCode) {
|
||||
alert('請輸入崗位代碼');
|
||||
return;
|
||||
}
|
||||
|
||||
const requestData = {
|
||||
positionCode: formData.basicInfo.positionCode,
|
||||
positionName: formData.positionInfo.positionName || '',
|
||||
effectiveDate: formData.positionInfo.positionEffectiveDate || new Date().toISOString().split('T')[0],
|
||||
jobDuties: formData.responsibilities.mainResponsibilities || '',
|
||||
requiredSkills: formData.requirements.basicSkills || '',
|
||||
workEnvironment: formData.positionInfo.workLocation || ''
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(API_BASE_URL + '/position-descriptions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast(result.message || '崗位描述已保存!');
|
||||
setTimeout(() => {
|
||||
switchModule('positionlist');
|
||||
}, 1000);
|
||||
} else {
|
||||
alert('保存失敗: ' + (result.error || '未知錯誤'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存錯誤:', error);
|
||||
alert('保存失敗: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveJobDescAndNew() {
|
||||
const formData = getJobDescFormData();
|
||||
console.log('Save JobDesc:', formData);
|
||||
|
||||
if (!formData.basicInfo.positionCode) {
|
||||
alert('請輸入崗位代碼');
|
||||
return;
|
||||
}
|
||||
|
||||
const requestData = {
|
||||
positionCode: formData.basicInfo.positionCode,
|
||||
positionName: formData.positionInfo.positionName || '',
|
||||
effectiveDate: formData.positionInfo.positionEffectiveDate || new Date().toISOString().split('T')[0],
|
||||
jobDuties: formData.responsibilities.mainResponsibilities || '',
|
||||
requiredSkills: formData.requirements.basicSkills || '',
|
||||
workEnvironment: formData.positionInfo.workLocation || ''
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(API_BASE_URL + '/position-descriptions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast(result.message || '崗位描述已保存,請繼續新增!');
|
||||
document.getElementById('jobDescForm').reset();
|
||||
document.getElementById('jd_mainResponsibilities').value = '1、\n2、\n3、\n4、';
|
||||
updatePreview();
|
||||
} else {
|
||||
alert('保存失敗: ' + (result.error || '未知錯誤'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存錯誤:', error);
|
||||
alert('保存失敗: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveJobDescToPositionList() {
|
||||
const formData = getJobDescFormData();
|
||||
|
||||
if (!formData.basicInfo.positionCode) {
|
||||
alert('請輸入崗位代碼');
|
||||
return;
|
||||
}
|
||||
|
||||
const positionData = {
|
||||
basicInfo: {
|
||||
positionCode: formData.basicInfo.positionCode,
|
||||
positionName: formData.positionInfo.positionName || '',
|
||||
effectiveDate: formData.positionInfo.positionEffectiveDate || new Date().toISOString().split('T')[0],
|
||||
positionDesc: formData.responsibilities.mainResponsibilities || '',
|
||||
positionRemark: formData.responsibilities.positionPurpose || ''
|
||||
},
|
||||
recruitInfo: {
|
||||
minEducation: formData.requirements.education || '',
|
||||
skillReq: formData.requirements.basicSkills || '',
|
||||
workExperience: formData.requirements.workExperienceReq || '',
|
||||
otherReq: formData.requirements.otherRequirements || ''
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(API_BASE_URL + '/positions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(positionData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast(result.message || '崗位描述已成功儲存至崗位清單!');
|
||||
setTimeout(() => {
|
||||
switchModule('positionlist');
|
||||
}, 1500);
|
||||
} else {
|
||||
alert('儲存失敗: ' + (result.error || '未知錯誤'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('儲存錯誤:', error);
|
||||
alert('儲存失敗: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelJobDescForm() {
|
||||
if (confirm('確定要取消嗎?未保存的資料將會遺失。')) {
|
||||
document.getElementById('jobDescForm').reset();
|
||||
document.getElementById('jd_mainResponsibilities').value = '1、\n2、\n3、\n4、';
|
||||
updatePreview();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 預覽更新 ====================
|
||||
|
||||
export function updatePreview() {
|
||||
const activeModuleBtn = document.querySelector('.module-btn.active');
|
||||
if (!activeModuleBtn) return;
|
||||
|
||||
const activeModule = activeModuleBtn.dataset.module;
|
||||
let data;
|
||||
if (activeModule === 'position') {
|
||||
data = { module: '崗位基礎資料', ...getPositionFormData() };
|
||||
} else if (activeModule === 'job') {
|
||||
data = { module: '職務基礎資料', ...getJobFormData() };
|
||||
} else {
|
||||
data = { module: '崗位描述', ...getJobDescFormData() };
|
||||
}
|
||||
const previewEl = document.getElementById('jsonPreview');
|
||||
if (previewEl) {
|
||||
previewEl.textContent = JSON.stringify(data, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Toast 訊息 ====================
|
||||
|
||||
export function showToast(message) {
|
||||
const toast = document.getElementById('toast');
|
||||
const toastMessage = document.getElementById('toastMessage');
|
||||
if (!toast || !toastMessage) {
|
||||
console.warn('Toast elements not found, creating dynamic toast');
|
||||
const existingToast = document.querySelector('.toast.dynamic-toast');
|
||||
if (existingToast) existingToast.remove();
|
||||
|
||||
const dynamicToast = document.createElement('div');
|
||||
dynamicToast.className = 'toast dynamic-toast show';
|
||||
dynamicToast.innerHTML = `
|
||||
<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
|
||||
<span>${message}</span>
|
||||
`;
|
||||
document.body.appendChild(dynamicToast);
|
||||
setTimeout(() => {
|
||||
dynamicToast.classList.remove('show');
|
||||
setTimeout(() => dynamicToast.remove(), 300);
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
toastMessage.textContent = message;
|
||||
toast.classList.add('show');
|
||||
setTimeout(() => toast.classList.remove('show'), 3000);
|
||||
}
|
||||
|
||||
// ==================== 模組切換 ====================
|
||||
|
||||
export function switchModule(moduleName) {
|
||||
document.querySelectorAll('.module-btn').forEach(b => {
|
||||
b.classList.remove('active', 'job-active', 'desc-active');
|
||||
});
|
||||
document.querySelectorAll('.module-content').forEach(c => c.classList.remove('active'));
|
||||
|
||||
const targetBtn = document.querySelector(`.module-btn[data-module="${moduleName}"]`);
|
||||
if (targetBtn) {
|
||||
targetBtn.classList.add('active');
|
||||
if (moduleName === 'job') targetBtn.classList.add('job-active');
|
||||
if (moduleName === 'jobdesc') targetBtn.classList.add('desc-active');
|
||||
}
|
||||
|
||||
const targetModule = document.getElementById('module-' + moduleName);
|
||||
if (targetModule) {
|
||||
targetModule.classList.add('active');
|
||||
}
|
||||
|
||||
if (moduleName === 'positionlist') {
|
||||
loadPositionList();
|
||||
}
|
||||
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
// ==================== 崗位清單功能 ====================
|
||||
|
||||
export async function loadPositionList() {
|
||||
try {
|
||||
showToast('正在載入崗位清單...');
|
||||
|
||||
const response = await fetch(API_BASE_URL + '/position-list');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
positionListData = result.data;
|
||||
renderPositionList();
|
||||
showToast('已載入 ' + positionListData.length + ' 筆崗位資料');
|
||||
} else {
|
||||
alert('載入失敗: ' + (result.error || '未知錯誤'));
|
||||
positionListData = [];
|
||||
renderPositionList();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('載入錯誤:', error);
|
||||
alert('載入失敗: ' + error.message);
|
||||
positionListData = [];
|
||||
renderPositionList();
|
||||
}
|
||||
}
|
||||
|
||||
export function renderPositionList() {
|
||||
const tbody = document.getElementById('positionListBody');
|
||||
if (!tbody) return;
|
||||
|
||||
if (positionListData.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="padding: 40px; text-align: center; color: var(--text-secondary);">沒有資料,請先建立崗位或點擊「載入清單」</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = positionListData.map(item => `
|
||||
<tr style="border-bottom: 1px solid #eee;">
|
||||
<td style="padding: 12px;">${sanitizeHTML(item.positionCode)}</td>
|
||||
<td style="padding: 12px;">${sanitizeHTML(item.positionName)}</td>
|
||||
<td style="padding: 12px;">${sanitizeHTML(item.positionCategory || '')}</td>
|
||||
<td style="padding: 12px;">${sanitizeHTML(item.positionNature || '')}</td>
|
||||
<td style="padding: 12px;">${sanitizeHTML(String(item.headcount || ''))}</td>
|
||||
<td style="padding: 12px;">${sanitizeHTML(item.positionLevel || '')}</td>
|
||||
<td style="padding: 12px;">${sanitizeHTML(item.effectiveDate || '')}</td>
|
||||
<td style="padding: 12px; text-align: center;">
|
||||
<button class="btn btn-secondary" style="padding: 4px 8px; font-size: 12px;" onclick="viewPositionDesc('${sanitizeHTML(item.positionCode)}')">檢視</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
export function sortPositionList(column) {
|
||||
if (currentSortColumn === column) {
|
||||
currentSortDirection = currentSortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
currentSortColumn = column;
|
||||
currentSortDirection = 'asc';
|
||||
}
|
||||
|
||||
positionListData.sort((a, b) => {
|
||||
let valA = a[column];
|
||||
let valB = b[column];
|
||||
|
||||
if (typeof valA === 'string') {
|
||||
valA = valA.toLowerCase();
|
||||
valB = valB.toLowerCase();
|
||||
}
|
||||
|
||||
if (valA < valB) return currentSortDirection === 'asc' ? -1 : 1;
|
||||
if (valA > valB) return currentSortDirection === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
document.querySelectorAll('.sort-icon').forEach(icon => icon.textContent = '');
|
||||
const currentHeader = document.querySelector(`th[data-sort="${column}"] .sort-icon`);
|
||||
if (currentHeader) {
|
||||
currentHeader.textContent = currentSortDirection === 'asc' ? ' ^' : ' v';
|
||||
}
|
||||
|
||||
renderPositionList();
|
||||
}
|
||||
|
||||
// ==================== 工具函數 ====================
|
||||
|
||||
function sanitizeHTML(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
const temp = document.createElement('div');
|
||||
temp.textContent = str;
|
||||
return temp.innerHTML;
|
||||
}
|
||||
|
||||
// 暴露全域函數
|
||||
if (typeof window !== 'undefined') {
|
||||
window.showToast = showToast;
|
||||
window.updatePreview = updatePreview;
|
||||
window.switchModule = switchModule;
|
||||
window.updateCategoryName = updateCategoryName;
|
||||
window.updateNatureName = updateNatureName;
|
||||
window.updateJobCategoryName = updateJobCategoryName;
|
||||
}
|
||||
19
js/main.js
19
js/main.js
@@ -89,26 +89,26 @@ function setupFormListeners() {
|
||||
field.addEventListener('input', updatePreview);
|
||||
});
|
||||
|
||||
// 崗位類別變更
|
||||
const positionCategory = document.getElementById('positionCategory');
|
||||
// 崗位類別變更 (pos_category)
|
||||
const positionCategory = document.getElementById('pos_category');
|
||||
if (positionCategory) {
|
||||
positionCategory.addEventListener('change', updateCategoryName);
|
||||
}
|
||||
|
||||
// 崗位性質變更
|
||||
const positionNature = document.getElementById('positionNature');
|
||||
// 崗位性質變更 (pos_type)
|
||||
const positionNature = document.getElementById('pos_type');
|
||||
if (positionNature) {
|
||||
positionNature.addEventListener('change', updateNatureName);
|
||||
}
|
||||
|
||||
// 職務類別變更
|
||||
const jobCategoryCode = document.getElementById('jobCategoryCode');
|
||||
// 職務類別變更 (job_category)
|
||||
const jobCategoryCode = document.getElementById('job_category');
|
||||
if (jobCategoryCode) {
|
||||
jobCategoryCode.addEventListener('change', updateJobCategoryName);
|
||||
}
|
||||
|
||||
// Toggle 開關變更
|
||||
const hasAttendanceBonus = document.getElementById('hasAttendanceBonus');
|
||||
// Toggle 開關變更 (job_hasAttBonus)
|
||||
const hasAttendanceBonus = document.getElementById('job_hasAttBonus');
|
||||
if (hasAttendanceBonus) {
|
||||
hasAttendanceBonus.addEventListener('change', function() {
|
||||
const label = document.getElementById('attendanceLabel');
|
||||
@@ -119,7 +119,8 @@ function setupFormListeners() {
|
||||
});
|
||||
}
|
||||
|
||||
const hasHousingAllowance = document.getElementById('hasHousingAllowance');
|
||||
// Toggle 開關變更 (job_hasHouseAllow)
|
||||
const hasHousingAllowance = document.getElementById('job_hasHouseAllow');
|
||||
if (hasHousingAllowance) {
|
||||
hasHousingAllowance.addEventListener('change', function() {
|
||||
const label = document.getElementById('housingLabel');
|
||||
|
||||
88
js/prompts.js
Normal file
88
js/prompts.js
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* AI Prompt 模板模組
|
||||
* 定義各表單的 AI 生成提示詞
|
||||
*/
|
||||
|
||||
// ==================== AI 幫我想 - 預設 Prompt 模板 ====================
|
||||
export const DEFAULT_AI_PROMPTS = {
|
||||
positionBasic: `你是專業人資顧問,熟悉半導體製造業。請生成崗位基礎資料。
|
||||
|
||||
請生成以下欄位:positionCode, positionName, positionCategory, positionNature, headcount, positionLevel, positionDesc, positionRemark
|
||||
|
||||
欄位說明:
|
||||
- positionCode: 崗位編號(格式如 ENG-001, MGR-002)
|
||||
- positionName: 崗位名稱
|
||||
- positionCategory: 崗位類別代碼(01=技術職, 02=管理職, 03=業務職, 04=行政職)
|
||||
- positionNature: 崗位性質代碼(FT=全職, PT=兼職, CT=約聘, IN=實習)
|
||||
- headcount: 編制人數(1-10)
|
||||
- positionLevel: 崗位級別(L1-L7)
|
||||
- positionDesc: 崗位描述(條列式)
|
||||
- positionRemark: 崗位備注
|
||||
|
||||
請用繁體中文,只返回純 JSON 格式,不要有任何其他文字。`,
|
||||
|
||||
positionRecruit: `你是專業人資顧問,熟悉半導體製造業。請生成招聘要求資料。
|
||||
|
||||
請生成以下欄位:minEducation, salaryRange, workExperience, jobType, recruitPosition, jobTitle, positionReq, skillReq, langReq
|
||||
|
||||
欄位說明:
|
||||
- minEducation: 最低學歷代碼(HS=高中職, JC=專科, BA=大學, MA=碩士)
|
||||
- salaryRange: 薪酬范圍代碼(A=30000以下, B=30000-50000, C=50000-80000, D=80000以上, N=面議)
|
||||
- workExperience: 工作經驗年數(0, 1, 3, 5, 10)
|
||||
- jobType: 工作性質代碼(FT=全職, PT=兼職, CT=約聘)
|
||||
- recruitPosition: 招聘職位代碼(ENG=工程師, MGR=經理, AST=助理)
|
||||
- jobTitle: 職位名稱
|
||||
- positionReq: 崗位要求(條列式)
|
||||
- skillReq: 技能要求(條列式)
|
||||
- langReq: 語言要求(條列式)
|
||||
|
||||
請用繁體中文,只返回純 JSON 格式,不要有任何其他文字。`,
|
||||
|
||||
jobBasic: `你是專業人資顧問,熟悉半導體製造業。請生成職務基礎資料。
|
||||
|
||||
請生成以下欄位:jobCode, jobName, jobNameEn, jobCategoryCode, jobLevel, jobHeadcount, jobRemark
|
||||
|
||||
欄位說明:
|
||||
- jobCode: 職務編號(格式如 J001)
|
||||
- jobName: 職務名稱(繁體中文)
|
||||
- jobNameEn: 職務名稱英文
|
||||
- jobCategoryCode: 職務類別代碼(MGR=管理職, TECH=技術職, ADMIN=行政職, SALE=業務職)
|
||||
- jobLevel: 職務級別(J1-J7)
|
||||
- jobHeadcount: 職務人數(1-100)
|
||||
- jobRemark: 職務備註
|
||||
|
||||
請用繁體中文,只返回純 JSON 格式,不要有任何其他文字。`,
|
||||
|
||||
deptFunction: `你是專業人資顧問,熟悉半導體製造業。請生成部門職責資料。
|
||||
|
||||
請生成以下欄位:deptFunctionCode, deptFunctionName, deptFunctionBU, deptFunctionDept, deptManager, deptMission, deptVision, deptCoreFunctions, deptKPIs
|
||||
|
||||
欄位說明:
|
||||
- deptFunctionCode: 部門職責編號(格式如 DF001)
|
||||
- deptFunctionName: 部門職責名稱
|
||||
- deptFunctionBU: 事業單位
|
||||
- deptFunctionDept: 部門名稱
|
||||
- deptManager: 部門主管
|
||||
- deptMission: 部門使命
|
||||
- deptVision: 部門願景
|
||||
- deptCoreFunctions: 核心職能(條列式)
|
||||
- deptKPIs: KPI 指標(條列式)
|
||||
|
||||
請用繁體中文,只返回純 JSON 格式,不要有任何其他文字。`,
|
||||
|
||||
jobDesc: `你是專業人資顧問,熟悉半導體製造業。請生成崗位描述資料。
|
||||
|
||||
請生成以下欄位:positionName, department, directSupervisor, positionPurpose, mainResponsibilities, education, basicSkills, professionalKnowledge
|
||||
|
||||
欄位說明:
|
||||
- positionName: 崗位名稱
|
||||
- department: 所屬部門
|
||||
- directSupervisor: 直屬主管
|
||||
- positionPurpose: 崗位目的
|
||||
- mainResponsibilities: 主要職責(條列式)
|
||||
- education: 學歷要求
|
||||
- basicSkills: 基本技能(條列式)
|
||||
- professionalKnowledge: 專業知識(條列式)
|
||||
|
||||
請用繁體中文,只返回純 JSON 格式,不要有任何其他文字。`
|
||||
};
|
||||
212
js/ui.js
212
js/ui.js
@@ -49,22 +49,51 @@ export function getPositionFormData() {
|
||||
const formData = new FormData(form);
|
||||
const data = { basicInfo: {}, recruitInfo: {} };
|
||||
|
||||
const basicFields = ['positionCode', 'positionName', 'positionCategory', 'positionCategoryName',
|
||||
'positionNature', 'positionNatureName', 'headcount', 'positionLevel',
|
||||
'effectiveDate', 'positionDesc', 'positionRemark'];
|
||||
const recruitFields = ['minEducation', 'requiredGender', 'salaryRange', 'workExperience',
|
||||
'minAge', 'maxAge', 'jobType', 'recruitPosition', 'jobTitle', 'jobDesc',
|
||||
'positionReq', 'titleReq', 'majorReq', 'skillReq', 'langReq', 'otherReq',
|
||||
'superiorPosition', 'recruitRemark'];
|
||||
// 使用新的 pos_ prefix 欄位
|
||||
const basicFieldMapping = {
|
||||
'pos_code': 'positionCode',
|
||||
'pos_name': 'positionName',
|
||||
'pos_category': 'positionCategory',
|
||||
'pos_categoryName': 'positionCategoryName',
|
||||
'pos_type': 'positionType',
|
||||
'pos_typeName': 'positionTypeName',
|
||||
'pos_headcount': 'headcount',
|
||||
'pos_level': 'positionLevel',
|
||||
'pos_effectiveDate': 'effectiveDate',
|
||||
'pos_desc': 'positionDesc',
|
||||
'pos_remark': 'positionRemark'
|
||||
};
|
||||
|
||||
basicFields.forEach(field => {
|
||||
const value = formData.get(field);
|
||||
if (value) data.basicInfo[field] = value;
|
||||
// 使用新的 rec_ prefix 欄位
|
||||
const recruitFieldMapping = {
|
||||
'rec_eduLevel': 'minEducation',
|
||||
'rec_gender': 'requiredGender',
|
||||
'rec_salaryRange': 'salaryRange',
|
||||
'rec_expYears': 'workExperience',
|
||||
'rec_minAge': 'minAge',
|
||||
'rec_maxAge': 'maxAge',
|
||||
'rec_jobType': 'jobType',
|
||||
'rec_position': 'recruitPosition',
|
||||
'rec_jobTitle': 'jobTitle',
|
||||
'rec_jobDesc': 'jobDesc',
|
||||
'rec_positionReq': 'positionReq',
|
||||
'rec_certReq': 'certReq',
|
||||
'rec_majorReq': 'majorReq',
|
||||
'rec_skillReq': 'skillReq',
|
||||
'rec_langReq': 'langReq',
|
||||
'rec_otherReq': 'otherReq',
|
||||
'rec_superiorCode': 'superiorPosition',
|
||||
'rec_remark': 'recruitRemark'
|
||||
};
|
||||
|
||||
Object.entries(basicFieldMapping).forEach(([htmlId, dataKey]) => {
|
||||
const el = document.getElementById(htmlId);
|
||||
if (el && el.value) data.basicInfo[dataKey] = el.value;
|
||||
});
|
||||
|
||||
recruitFields.forEach(field => {
|
||||
const value = formData.get(field);
|
||||
if (value) data.recruitInfo[field] = value;
|
||||
Object.entries(recruitFieldMapping).forEach(([htmlId, dataKey]) => {
|
||||
const el = document.getElementById(htmlId);
|
||||
if (el && el.value) data.recruitInfo[dataKey] = el.value;
|
||||
});
|
||||
|
||||
return data;
|
||||
@@ -76,19 +105,31 @@ export function getPositionFormData() {
|
||||
*/
|
||||
export function getJobFormData() {
|
||||
const form = document.getElementById('jobForm');
|
||||
const formData = new FormData(form);
|
||||
const data = {};
|
||||
|
||||
const fields = ['jobCategoryCode', 'jobCategoryName', 'jobCode', 'jobName', 'jobNameEn',
|
||||
'jobEffectiveDate', 'jobHeadcount', 'jobSortOrder', 'jobRemark', 'jobLevel'];
|
||||
// 使用新的 job_ prefix 欄位
|
||||
const fieldMapping = {
|
||||
'job_category': 'jobCategoryCode',
|
||||
'job_categoryName': 'jobCategoryName',
|
||||
'job_code': 'jobCode',
|
||||
'job_name': 'jobName',
|
||||
'job_nameEn': 'jobNameEn',
|
||||
'job_level': 'jobLevel',
|
||||
'job_effectiveDate': 'jobEffectiveDate',
|
||||
'job_sortOrder': 'jobSortOrder',
|
||||
'job_headcount': 'jobHeadcount',
|
||||
'job_remark': 'jobRemark'
|
||||
};
|
||||
|
||||
fields.forEach(field => {
|
||||
const value = formData.get(field);
|
||||
if (value) data[field] = value;
|
||||
Object.entries(fieldMapping).forEach(([htmlId, dataKey]) => {
|
||||
const el = document.getElementById(htmlId);
|
||||
if (el && el.value) data[dataKey] = el.value;
|
||||
});
|
||||
|
||||
data.hasAttendanceBonus = document.getElementById('hasAttendanceBonus').checked;
|
||||
data.hasHousingAllowance = document.getElementById('hasHousingAllowance').checked;
|
||||
const hasAttBonus = document.getElementById('job_hasAttBonus');
|
||||
const hasHouseAllow = document.getElementById('job_hasHouseAllow');
|
||||
data.hasAttendanceBonus = hasAttBonus ? hasAttBonus.checked : false;
|
||||
data.hasHousingAllowance = hasHouseAllow ? hasHouseAllow.checked : false;
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -101,33 +142,59 @@ export function getJobDescFormData() {
|
||||
const form = document.getElementById('jobDescForm');
|
||||
if (!form) return {};
|
||||
|
||||
const formData = new FormData(form);
|
||||
const data = { basicInfo: {}, positionInfo: {}, responsibilities: {}, requirements: {} };
|
||||
|
||||
// Basic Info
|
||||
['empNo', 'empName', 'positionCode', 'versionDate'].forEach(field => {
|
||||
const el = document.getElementById('jd_' + field);
|
||||
if (el && el.value) data.basicInfo[field] = el.value;
|
||||
// Basic Info - 使用新的 jd_ prefix
|
||||
const basicMapping = {
|
||||
'jd_empNo': 'empNo',
|
||||
'jd_empName': 'empName',
|
||||
'jd_posCode': 'positionCode',
|
||||
'jd_versionDate': 'versionDate'
|
||||
};
|
||||
|
||||
Object.entries(basicMapping).forEach(([htmlId, dataKey]) => {
|
||||
const el = document.getElementById(htmlId);
|
||||
if (el && el.value) data.basicInfo[dataKey] = el.value;
|
||||
});
|
||||
|
||||
// Position Info
|
||||
['positionName', 'department', 'positionEffectiveDate', 'directSupervisor',
|
||||
'positionGradeJob', 'reportTo', 'directReports', 'workLocation', 'empAttribute'].forEach(field => {
|
||||
const el = document.getElementById('jd_' + field);
|
||||
if (el && el.value) data.positionInfo[field] = el.value;
|
||||
// Position Info - 使用新的 jd_ prefix
|
||||
const posInfoMapping = {
|
||||
'jd_posName': 'positionName',
|
||||
'jd_department': 'department',
|
||||
'jd_posLevel': 'positionLevel',
|
||||
'jd_posEffDate': 'positionEffectiveDate',
|
||||
'jd_supervisor': 'directSupervisor',
|
||||
'jd_gradeJob': 'positionGradeJob',
|
||||
'jd_reportTo': 'reportTo',
|
||||
'jd_directReports': 'directReports',
|
||||
'jd_location': 'workLocation',
|
||||
'jd_empAttr': 'empAttribute'
|
||||
};
|
||||
|
||||
Object.entries(posInfoMapping).forEach(([htmlId, dataKey]) => {
|
||||
const el = document.getElementById(htmlId);
|
||||
if (el && el.value) data.positionInfo[dataKey] = el.value;
|
||||
});
|
||||
|
||||
// Purpose & Responsibilities
|
||||
const purpose = document.getElementById('jd_positionPurpose');
|
||||
// Purpose & Responsibilities - 使用新的 jd_ prefix
|
||||
const purpose = document.getElementById('jd_purpose');
|
||||
if (purpose && purpose.value) data.responsibilities.positionPurpose = purpose.value;
|
||||
|
||||
const mainResp = document.getElementById('jd_mainResponsibilities');
|
||||
const mainResp = document.getElementById('jd_mainResp');
|
||||
if (mainResp && mainResp.value) data.responsibilities.mainResponsibilities = mainResp.value;
|
||||
|
||||
// Requirements
|
||||
['education', 'basicSkills', 'professionalKnowledge', 'workExperienceReq', 'otherRequirements'].forEach(field => {
|
||||
const el = document.getElementById('jd_' + field);
|
||||
if (el && el.value) data.requirements[field] = el.value;
|
||||
// Requirements - 使用新的 jd_ prefix
|
||||
const reqMapping = {
|
||||
'jd_eduLevel': 'education',
|
||||
'jd_basicSkills': 'basicSkills',
|
||||
'jd_proKnowledge': 'professionalKnowledge',
|
||||
'jd_expReq': 'workExperienceReq',
|
||||
'jd_otherReq': 'otherRequirements'
|
||||
};
|
||||
|
||||
Object.entries(reqMapping).forEach(([htmlId, dataKey]) => {
|
||||
const el = document.getElementById(htmlId);
|
||||
if (el && el.value) data.requirements[dataKey] = el.value;
|
||||
});
|
||||
|
||||
return data;
|
||||
@@ -141,16 +208,33 @@ export function getDeptFunctionFormData() {
|
||||
const form = document.getElementById('deptFunctionForm');
|
||||
if (!form) return {};
|
||||
|
||||
const formData = new FormData(form);
|
||||
const data = {};
|
||||
|
||||
const fields = ['deptFunctionCode', 'deptFunctionName', 'deptFunctionBU',
|
||||
'deptFunctionDept', 'deptManager', 'deptMission', 'deptVision',
|
||||
'deptCoreFunctions', 'deptKPIs'];
|
||||
// 使用新的 df_ prefix 欄位
|
||||
const fieldMapping = {
|
||||
'df_code': 'dfCode',
|
||||
'df_name': 'dfName',
|
||||
'df_businessUnit': 'businessUnit',
|
||||
'df_division': 'division',
|
||||
'df_department': 'department',
|
||||
'df_section': 'section',
|
||||
'df_posTitle': 'positionTitle',
|
||||
'df_posLevel': 'positionLevel',
|
||||
'df_managerTitle': 'managerTitle',
|
||||
'df_effectiveDate': 'effectiveDate',
|
||||
'df_headcountLimit': 'headcountLimit',
|
||||
'df_status': 'status',
|
||||
'df_mission': 'mission',
|
||||
'df_vision': 'vision',
|
||||
'df_coreFunc': 'coreFunctions',
|
||||
'df_kpis': 'kpis',
|
||||
'df_collab': 'collaboration',
|
||||
'df_remark': 'remark'
|
||||
};
|
||||
|
||||
fields.forEach(field => {
|
||||
const value = formData.get(field);
|
||||
if (value) data[field] = value;
|
||||
Object.entries(fieldMapping).forEach(([htmlId, dataKey]) => {
|
||||
const el = document.getElementById(htmlId);
|
||||
if (el && el.value) data[dataKey] = el.value;
|
||||
});
|
||||
|
||||
return data;
|
||||
@@ -192,8 +276,11 @@ export function updatePreview() {
|
||||
* 更新崗位類別中文名稱
|
||||
*/
|
||||
export function updateCategoryName() {
|
||||
const category = document.getElementById('positionCategory').value;
|
||||
document.getElementById('positionCategoryName').value = categoryMap[category] || '';
|
||||
const categoryEl = document.getElementById('pos_category');
|
||||
const categoryNameEl = document.getElementById('pos_categoryName');
|
||||
if (categoryEl && categoryNameEl) {
|
||||
categoryNameEl.value = categoryMap[categoryEl.value] || '';
|
||||
}
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
@@ -201,8 +288,11 @@ export function updateCategoryName() {
|
||||
* 更新崗位性質中文名稱
|
||||
*/
|
||||
export function updateNatureName() {
|
||||
const nature = document.getElementById('positionNature').value;
|
||||
document.getElementById('positionNatureName').value = natureMap[nature] || '';
|
||||
const typeEl = document.getElementById('pos_type');
|
||||
const typeNameEl = document.getElementById('pos_typeName');
|
||||
if (typeEl && typeNameEl) {
|
||||
typeNameEl.value = natureMap[typeEl.value] || '';
|
||||
}
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
@@ -210,8 +300,11 @@ export function updateNatureName() {
|
||||
* 更新職務類別中文名稱
|
||||
*/
|
||||
export function updateJobCategoryName() {
|
||||
const category = document.getElementById('jobCategoryCode').value;
|
||||
document.getElementById('jobCategoryName').value = jobCategoryMap[category] || '';
|
||||
const categoryEl = document.getElementById('job_category');
|
||||
const categoryNameEl = document.getElementById('job_categoryName');
|
||||
if (categoryEl && categoryNameEl) {
|
||||
categoryNameEl.value = jobCategoryMap[categoryEl.value] || '';
|
||||
}
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
@@ -219,10 +312,12 @@ export function updateJobCategoryName() {
|
||||
* 修改崗位編號
|
||||
*/
|
||||
export function changePositionCode() {
|
||||
const currentCode = document.getElementById('positionCode').value;
|
||||
const codeEl = document.getElementById('pos_code');
|
||||
if (!codeEl) return;
|
||||
const currentCode = codeEl.value;
|
||||
const newCode = prompt('請輸入新的崗位編號:', currentCode);
|
||||
if (newCode && newCode !== currentCode) {
|
||||
document.getElementById('positionCode').value = newCode;
|
||||
codeEl.value = newCode;
|
||||
showToast('崗位編號已更改!');
|
||||
updatePreview();
|
||||
}
|
||||
@@ -232,10 +327,12 @@ export function changePositionCode() {
|
||||
* 修改職務編號
|
||||
*/
|
||||
export function changeJobCode() {
|
||||
const currentCode = document.getElementById('jobCode').value;
|
||||
const codeEl = document.getElementById('job_code');
|
||||
if (!codeEl) return;
|
||||
const currentCode = codeEl.value;
|
||||
const newCode = prompt('請輸入新的職務編號:', currentCode);
|
||||
if (newCode && newCode !== currentCode) {
|
||||
document.getElementById('jobCode').value = newCode;
|
||||
codeEl.value = newCode;
|
||||
showToast('職務編號已更改!');
|
||||
updatePreview();
|
||||
}
|
||||
@@ -271,7 +368,10 @@ export function confirmMajor() {
|
||||
document.querySelectorAll('#majorModal input[type="checkbox"]:checked').forEach(cb => {
|
||||
selected.push(cb.value);
|
||||
});
|
||||
document.getElementById('majorReq').value = selected.join(', ');
|
||||
const majorReqEl = document.getElementById('rec_majorReq');
|
||||
if (majorReqEl) {
|
||||
majorReqEl.value = selected.join(', ');
|
||||
}
|
||||
closeMajorModal();
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
36
js/utils.js
36
js/utils.js
@@ -132,39 +132,39 @@ export function showCopyableError(options) {
|
||||
|
||||
const modalHTML = `
|
||||
<div id="errorModal" class="modal-overlay show">
|
||||
<div class="modal" style="max-width: 600px;">
|
||||
<div class="modal-header" style="background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%); color: white;">
|
||||
<h3>🚨 ${sanitizeHTML(title)}</h3>
|
||||
<div class="modal" style="max-width: min(90vw, 500px); max-height: 85vh; display: flex; flex-direction: column;">
|
||||
<div class="modal-header" style="background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%); color: white; flex-shrink: 0;">
|
||||
<h3 style="font-size: 1rem;">🚨 ${sanitizeHTML(title)}</h3>
|
||||
<button class="modal-close" onclick="closeErrorModal(this)" style="color: white;">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<strong>錯誤訊息:</strong>
|
||||
<p style="color: #e74c3c; font-weight: 500;">${sanitizeHTML(message)}</p>
|
||||
<div class="modal-body" style="overflow-y: auto; flex: 1; padding: 16px;">
|
||||
<div style="margin-bottom: 12px;">
|
||||
<strong style="font-size: 0.9rem;">錯誤訊息:</strong>
|
||||
<p style="color: #e74c3c; font-weight: 500; font-size: 0.85rem; word-break: break-word;">${sanitizeHTML(message)}</p>
|
||||
</div>
|
||||
${details ? `
|
||||
<div style="margin-bottom: 16px;">
|
||||
<strong>詳細資訊:</strong>
|
||||
<pre style="background: #f8f9fa; padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 0.85rem; max-height: 200px;">${sanitizeHTML(details)}</pre>
|
||||
<div style="margin-bottom: 12px;">
|
||||
<strong style="font-size: 0.9rem;">詳細資訊:</strong>
|
||||
<pre style="background: #f8f9fa; padding: 10px; border-radius: 6px; overflow-x: auto; font-size: 0.75rem; max-height: 120px; white-space: pre-wrap; word-break: break-all;">${sanitizeHTML(details)}</pre>
|
||||
</div>
|
||||
` : ''}
|
||||
${suggestions && suggestions.length > 0 ? `
|
||||
<div>
|
||||
<strong>請檢查以下項目:</strong>
|
||||
<ul style="margin: 8px 0; padding-left: 20px;">
|
||||
${suggestions.map(s => `<li style="margin: 4px 0;">${sanitizeHTML(s)}</li>`).join('')}
|
||||
<strong style="font-size: 0.9rem;">請檢查以下項目:</strong>
|
||||
<ul style="margin: 6px 0; padding-left: 18px; font-size: 0.85rem;">
|
||||
${suggestions.map(s => `<li style="margin: 3px 0;">${sanitizeHTML(s)}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="copyErrorDetails()">
|
||||
<svg viewBox="0 0 24 24" style="width: 16px; height: 16px;">
|
||||
<div class="modal-footer" style="flex-shrink: 0; padding: 12px 16px;">
|
||||
<button class="btn btn-secondary" onclick="copyErrorDetails()" style="font-size: 0.85rem; padding: 8px 12px;">
|
||||
<svg viewBox="0 0 24 24" style="width: 14px; height: 14px;">
|
||||
<path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
|
||||
</svg>
|
||||
複製錯誤詳情
|
||||
複製
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="closeErrorModal(this)">關閉</button>
|
||||
<button class="btn btn-primary" onclick="closeErrorModal(this)" style="font-size: 0.85rem; padding: 8px 16px;">關閉</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user