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:
2025-12-09 12:05:20 +08:00
parent a068ef9704
commit a6af297623
82 changed files with 8685 additions and 4933 deletions

406
js/admin.js Normal file
View 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
View 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);
}
}

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}

View File

@@ -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
View 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
View File

@@ -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();
}

View File

@@ -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>