變更內容: - 所有資料表加上 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>
774 lines
29 KiB
JavaScript
774 lines
29 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|
||
}
|