feat: 實作三個錦囊 AI 功能

- 新增 AI 錦囊 CSS 樣式到 components.css
- 創建 js/ai-bags.js 模組,包含:
  * 5個模組各3個錦囊的預設 prompt 模板
  * executeAIBag() - 執行 AI 生成並填充表單
  * editBagPrompt() - 編輯自定義 prompt
  * LocalStorage 管理自定義 prompt
- 更新 index.html:
  * 替換 5 處 AI 按鈕為三個錦囊(崗位基礎、招聘要求、職務、部門職責、崗位描述)
  * 新增 Prompt 編輯模態框
- 更新 main.js 引入 ai-bags.js 並初始化
- 新增設計文檔:三個錦囊設計.md
- 新增欄位對照文檔:表單欄位清單.md、更新欄位名稱.md、ID重命名對照表.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-06 01:19:54 +08:00
parent 12ceccc3d3
commit 8069f1b628
8 changed files with 1925 additions and 21 deletions

628
js/ai-bags.js Normal file
View File

@@ -0,0 +1,628 @@
/**
* AI Bags - 三個錦囊功能
* 提供可自定義 prompt 的 AI 生成按鈕
*/
import { callClaudeAPI } from './api.js';
import { showToast, fillIfEmpty } from './utils.js';
import { getPositionFormData, getJobFormData, getJobDescFormData, getDeptFunctionFormData } from './ui.js';
// ==================== 預設 Prompt 模板 ====================
const DEFAULT_PROMPTS = {
positionBasic: {
bag1: {
title: '簡化版',
subtitle: '僅必填欄位',
prompt: `你是專業人資顧問,熟悉半導體製造業。請生成崗位基礎資料(僅必填欄位)。
已填寫的資料:{existingData}
需要生成的欄位positionCode, positionName
請用繁體中文,返回 JSON 格式,不要有任何其他文字。`
},
bag2: {
title: '標準版',
subtitle: '常用欄位',
prompt: `你是專業人資顧問,熟悉半導體製造業。請生成崗位基礎資料(標準版)。
已填寫的資料:{existingData}
需要生成的欄位positionCode, positionName, positionCategory, positionLevel, headcount
欄位說明:
- positionCode: 崗位編號(格式如 ENG-001
- positionName: 崗位名稱
- positionCategory: 崗位類別代碼01=技術職, 02=管理職, 03=業務職, 04=行政職)
- positionLevel: 崗位級別L1-L7
- headcount: 編制人數1-10
請用繁體中文,返回 JSON 格式,不要有任何其他文字。`
},
bag3: {
title: '詳細版',
subtitle: '所有欄位',
prompt: `你是專業人資顧問,熟悉半導體製造業的人資所有流程。請生成完整的崗位基礎資料。
已填寫的資料:{existingData}
需要生成的欄位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: {
bag1: {
title: '基本需求',
subtitle: '核心要求',
prompt: `請生成「{positionName}」的基本招聘要求。
已填寫的資料:{existingData}
需要生成的欄位minEducation, workExperience, jobType, jobTitle
請用繁體中文,返回 JSON 格式,不要有任何其他文字。`
},
bag2: {
title: '標準需求',
subtitle: '完整資訊',
prompt: `請生成「{positionName}」的標準招聘要求。
已填寫的資料:{existingData}
需要生成的欄位minEducation, salaryRange, workExperience, jobType, recruitPosition, jobTitle, positionReq, skillReq
欄位說明:
- minEducation: 最低學歷代碼HS=高中職, JC=專科, BA=大學, MA=碩士, PHD=博士)
- salaryRange: 薪酬范圍代碼A=30000以下, B=30000-50000, C=50000-80000, D=80000-120000, E=120000以上, N=面議)
- workExperience: 工作經驗年數0=不限, 1, 3, 5, 10
- jobType: 工作性質代碼FT=全職, PT=兼職, CT=約聘, DP=派遣)
- recruitPosition: 招聘職位代碼ENG=工程師, MGR=經理, AST=助理, OP=作業員, SAL=業務)
- positionReq: 崗位要求(條列式,用換行分隔)
- skillReq: 技能要求(條列式,用換行分隔)
請用繁體中文,返回 JSON 格式,不要有任何其他文字。`
},
bag3: {
title: '完整需求',
subtitle: '18個欄位',
prompt: `請生成「{positionName}」的完整招聘要求資料。
已填寫的資料:{existingData}
需要生成所有空白欄位。
欄位說明:
- minEducation: 最低學歷代碼HS=高中職, JC=專科, BA=大學, MA=碩士, PHD=博士)
- requiredGender: 性別要求代碼M=限男性, F=限女性, N=不限)
- salaryRange: 薪酬范圍代碼A=30000以下, B=30000-50000, C=50000-80000, D=80000-120000, E=120000以上, N=面議)
- workExperience: 工作經驗年數0=不限, 1, 3, 5, 10
- minAge: 最低年齡18-65
- maxAge: 最高年齡18-65
- jobType: 工作性質代碼FT=全職, PT=兼職, CT=約聘, DP=派遣)
- recruitPosition: 招聘職位代碼ENG=工程師, MGR=經理, AST=助理, OP=作業員, SAL=業務)
- jobTitle: 職位名稱
- jobDesc: 工作內容(條列式,用換行分隔)
- positionReq: 崗位要求(條列式,用換行分隔)
- titleReq: 職稱要求(條列式,用換行分隔)
- majorReq: 科系要求(多個科系用逗號分隔)
- skillReq: 技能要求(條列式,用換行分隔)
- langReq: 語言要求(條列式,用換行分隔)
- otherReq: 其他要求(條列式,用換行分隔)
- superiorPosition: 直屬主管職位
- recruitRemark: 招聘備注
請用繁體中文,返回 JSON 格式,不要有任何其他文字。`
}
},
jobBasic: {
bag1: {
title: '簡化版',
subtitle: '核心欄位',
prompt: `請生成職務基礎資料(簡化版)。
已填寫的資料:{existingData}
需要生成的欄位jobCode, jobName, jobCategoryCode
請用繁體中文,返回 JSON 格式,不要有任何其他文字。`
},
bag2: {
title: '標準版',
subtitle: '常用欄位',
prompt: `請生成職務基礎資料(標準版)。
已填寫的資料:{existingData}
需要生成的欄位jobCode, jobName, jobNameEn, jobCategoryCode, jobLevel, jobHeadcount
欄位說明:
- jobCode: 職務編號(格式如 J001
- jobName: 職務名稱(繁體中文)
- jobNameEn: 職務名稱英文
- jobCategoryCode: 職務類別代碼01=技術類, 02=管理類, 03=業務類, 04=行政類)
- jobLevel: 職務級別J1-J7
- jobHeadcount: 職務人數1-100
請用繁體中文,返回 JSON 格式,不要有任何其他文字。`
},
bag3: {
title: '完整版',
subtitle: '所有欄位',
prompt: `請生成完整的職務基礎資料。
已填寫的資料:{existingData}
需要生成所有空白欄位。
欄位說明:
- jobCode: 職務編號(格式如 J001
- jobName: 職務名稱(繁體中文)
- jobNameEn: 職務名稱英文
- jobCategoryCode: 職務類別代碼01=技術類, 02=管理類, 03=業務類, 04=行政類)
- jobLevel: 職務級別J1-J7
- jobHeadcount: 職務人數1-100
- jobEffectiveDate: 生效日期YYYY-MM-DD
- jobSortOrder: 排序順序1-999
- hasAttendanceBonus: 是否有全勤獎金true/false
- hasHousingAllowance: 是否有住宿津貼true/false
- jobRemark: 職務備註
請用繁體中文,返回 JSON 格式,不要有任何其他文字。`
}
},
deptFunction: {
bag1: {
title: '基本版',
subtitle: '核心資訊',
prompt: `請生成部門職責資料(基本版)。
已填寫的資料:{existingData}
需要生成的欄位deptFunctionCode, deptFunctionName, deptFunctionBU, deptFunctionDept
請用繁體中文,返回 JSON 格式,不要有任何其他文字。`
},
bag2: {
title: '標準版',
subtitle: '含職責描述',
prompt: `請生成部門職責資料(標準版)。
已填寫的資料:{existingData}
需要生成的欄位deptFunctionCode, deptFunctionName, deptFunctionBU, deptFunctionDept, deptManager, deptMission, deptCoreFunctions
欄位說明:
- deptFunctionCode: 部門職責編號(格式如 DF001
- deptFunctionName: 部門職責名稱
- deptFunctionBU: 事業單位代碼BU1-BU5
- deptFunctionDept: 部門代碼DEPT1-DEPT20
- deptManager: 部門主管
- deptMission: 部門使命(簡短描述)
- deptCoreFunctions: 核心職能(條列式,用換行分隔)
請用繁體中文,返回 JSON 格式,不要有任何其他文字。`
},
bag3: {
title: '完整版',
subtitle: '含KPI指標',
prompt: `請生成完整的部門職責資料。
已填寫的資料:{existingData}
需要生成所有空白欄位。
欄位說明:
- deptFunctionCode: 部門職責編號(格式如 DF001
- deptFunctionName: 部門職責名稱
- deptFunctionBU: 事業單位代碼BU1-BU5
- deptFunctionDept: 部門代碼DEPT1-DEPT20
- deptManager: 部門主管
- deptMission: 部門使命(簡短描述)
- deptVision: 部門願景(條列式,用換行分隔)
- deptCoreFunctions: 核心職能(條列式,用換行分隔)
- deptKPIs: KPI 指標(條列式,用換行分隔)
請用繁體中文,返回 JSON 格式,不要有任何其他文字。`
}
},
jobDesc: {
bag1: {
title: '基本版',
subtitle: '核心資訊',
prompt: `請生成崗位描述資料(基本版)。
已填寫的資料:{existingData}
需要生成的欄位positionName, department, positionPurpose
請用繁體中文,返回 JSON 格式,不要有任何其他文字。`
},
bag2: {
title: '標準版',
subtitle: '含職責說明',
prompt: `請生成崗位描述資料(標準版)。
已填寫的資料:{existingData}
需要生成的欄位positionName, department, directSupervisor, positionPurpose, mainResponsibilities, education, basicSkills
欄位說明:
- positionName: 崗位名稱
- department: 所屬部門
- directSupervisor: 直屬主管
- positionPurpose: 崗位目的(簡短描述)
- mainResponsibilities: 主要職責(條列式,用換行分隔)
- education: 學歷要求
- basicSkills: 基本技能(條列式,用換行分隔)
請用繁體中文,返回 JSON 格式,不要有任何其他文字。`
},
bag3: {
title: '完整版',
subtitle: '33個欄位',
prompt: `請生成完整的崗位描述資料。
已填寫的資料:{existingData}
需要生成所有空白欄位。
包含以下區塊:
1. 基本資訊empNo, empName, positionCode, versionDate
2. 崗位資訊positionName, department, positionEffectiveDate, directSupervisor, positionGradeJob, reportTo, directReports, workLocation, empAttribute
3. 職責與目的positionPurpose, mainResponsibilities
4. 任職要求education, basicSkills, professionalKnowledge, workExperienceReq, otherRequirements
請用繁體中文,返回 JSON 格式,不要有任何其他文字。`
}
}
};
// ==================== LocalStorage 管理 ====================
/**
* 獲取模組的 prompts優先使用自定義否則使用預設
* @param {string} module - 模組名稱
* @returns {Object} - 包含三個 bag 的 prompt 物件
*/
function getModulePrompts(module) {
try {
const saved = localStorage.getItem('aiPrompts');
const prompts = saved ? JSON.parse(saved) : {};
// 如果沒有保存的 prompts使用預設值
if (!prompts[module]) {
return DEFAULT_PROMPTS[module] || {};
}
return prompts[module];
} catch (e) {
console.error('讀取 prompts 失敗:', e);
return DEFAULT_PROMPTS[module] || {};
}
}
/**
* 保存模組的 prompts
* @param {string} module - 模組名稱
* @param {number} bagNumber - 錦囊編號 (1-3)
* @param {Object} bagData - 包含 title, subtitle, prompt 的物件
*/
function saveModulePrompt(module, bagNumber, bagData) {
try {
const saved = localStorage.getItem('aiPrompts');
const prompts = saved ? JSON.parse(saved) : {};
if (!prompts[module]) {
prompts[module] = {};
}
prompts[module][`bag${bagNumber}`] = bagData;
localStorage.setItem('aiPrompts', JSON.stringify(prompts));
return true;
} catch (e) {
console.error('保存 prompt 失敗:', e);
return false;
}
}
/**
* 初始化所有模組的預設 prompts如果尚未設定
*/
function initializeDefaultPrompts() {
const saved = localStorage.getItem('aiPrompts');
if (!saved) {
localStorage.setItem('aiPrompts', JSON.stringify(DEFAULT_PROMPTS));
}
}
// ==================== 執行 AI 錦囊 ====================
/**
* 執行 AI 錦囊
* @param {string} module - 模組名稱
* @param {number} bagNumber - 錦囊編號 (1-3)
*/
export async function executeAIBag(module, bagNumber) {
const bagElement = document.querySelector(`.ai-bag[data-module="${module}"][data-bag="${bagNumber}"]`);
if (!bagElement) return;
// 防止重複點擊
if (bagElement.classList.contains('loading')) return;
try {
// 顯示載入狀態
bagElement.classList.add('loading');
const icon = bagElement.querySelector('.bag-icon');
const originalIcon = icon.textContent;
icon.innerHTML = '<div class="spinner"></div>';
// 獲取當前表單資料
let existingData = {};
if (module === 'positionBasic' || module === 'positionRecruit') {
const positionData = getPositionFormData();
existingData = module === 'positionBasic' ? positionData.basicInfo : positionData.recruitInfo;
} else if (module === 'jobBasic') {
existingData = getJobFormData();
} else if (module === 'jobDesc') {
existingData = getJobDescFormData();
} else if (module === 'deptFunction') {
existingData = getDeptFunctionFormData();
}
// 獲取 prompt 模板
const prompts = getModulePrompts(module);
const bagPrompt = prompts[`bag${bagNumber}`];
if (!bagPrompt || !bagPrompt.prompt) {
throw new Error('Prompt 模板不存在');
}
// 替換 prompt 中的變數
let finalPrompt = bagPrompt.prompt
.replace('{existingData}', JSON.stringify(existingData, null, 2))
.replace('{positionName}', existingData.positionName || '此崗位');
// 調用 AI API
const result = await callClaudeAPI(finalPrompt);
// 填充表單
fillFormWithAIResult(module, result);
showToast('✨ AI 生成成功!');
} catch (error) {
console.error('AI 錦囊執行失敗:', error);
showToast('❌ AI 生成失敗: ' + error.message);
} finally {
// 恢復正常狀態
bagElement.classList.remove('loading');
const icon = bagElement.querySelector('.bag-icon');
icon.textContent = '🎁';
}
}
/**
* 根據 AI 結果填充表單
* @param {string} module - 模組名稱
* @param {Object} result - AI 返回的 JSON 結果
*/
function fillFormWithAIResult(module, result) {
if (module === 'positionBasic') {
// 崗位基礎資料 - 基礎資料頁籤
Object.entries(result).forEach(([key, value]) => {
fillIfEmpty(key, value);
});
} else if (module === 'positionRecruit') {
// 崗位基礎資料 - 招聘要求頁籤
Object.entries(result).forEach(([key, value]) => {
fillIfEmpty(key, value);
});
} else if (module === 'jobBasic') {
// 職務基礎資料
Object.entries(result).forEach(([key, value]) => {
if (key === 'hasAttendanceBonus' || key === 'hasHousingAllowance') {
const checkbox = document.getElementById(key);
if (checkbox) checkbox.checked = value;
} else {
fillIfEmpty(key, value);
}
});
} else if (module === 'deptFunction') {
// 部門職責
Object.entries(result).forEach(([key, value]) => {
fillIfEmpty('df_' + key, value);
});
} else if (module === 'jobDesc') {
// 崗位描述
if (result.basicInfo) {
Object.entries(result.basicInfo).forEach(([key, value]) => {
fillIfEmpty('jd_' + key, value);
});
}
if (result.positionInfo) {
Object.entries(result.positionInfo).forEach(([key, value]) => {
fillIfEmpty('jd_' + key, value);
});
}
if (result.responsibilities) {
Object.entries(result.responsibilities).forEach(([key, value]) => {
fillIfEmpty('jd_' + key, value);
});
}
if (result.requirements) {
Object.entries(result.requirements).forEach(([key, value]) => {
fillIfEmpty('jd_' + key, value);
});
}
}
}
// ==================== 編輯 Prompt ====================
/**
* 編輯錦囊 Prompt
* @param {Event} event - 點擊事件
* @param {string} module - 模組名稱
* @param {number} bagNumber - 錦囊編號 (1-3)
*/
export function editBagPrompt(event, module, bagNumber) {
event.stopPropagation(); // 防止觸發父元素的 click 事件
const prompts = getModulePrompts(module);
const bagPrompt = prompts[`bag${bagNumber}`];
if (!bagPrompt) {
showToast('❌ Prompt 不存在');
return;
}
// 顯示編輯對話框
showPromptEditModal(module, bagNumber, bagPrompt);
}
/**
* 顯示 Prompt 編輯對話框
* @param {string} module - 模組名稱
* @param {number} bagNumber - 錦囊編號 (1-3)
* @param {Object} bagData - 包含 title, subtitle, prompt 的物件
*/
function showPromptEditModal(module, bagNumber, bagData) {
const modal = document.getElementById('promptEditModal');
if (!modal) {
console.error('找不到編輯對話框');
return;
}
// 填充對話框內容
document.getElementById('promptModalTitle').textContent = `編輯錦囊 ${bagNumber} - ${bagData.title}`;
document.getElementById('promptTitle').value = bagData.title || '';
document.getElementById('promptSubtitle').value = bagData.subtitle || '';
document.getElementById('promptContent').value = bagData.prompt || '';
// 保存當前編輯的模組和錦囊編號
modal.dataset.module = module;
modal.dataset.bagNumber = bagNumber;
// 顯示對話框
modal.classList.add('show');
}
/**
* 保存編輯的 Prompt
*/
export function savePromptEdit() {
const modal = document.getElementById('promptEditModal');
const module = modal.dataset.module;
const bagNumber = parseInt(modal.dataset.bagNumber);
const title = document.getElementById('promptTitle').value.trim();
const subtitle = document.getElementById('promptSubtitle').value.trim();
const prompt = document.getElementById('promptContent').value.trim();
if (!title || !prompt) {
showToast('⚠️ 標題和 Prompt 內容不能為空');
return;
}
// 保存到 LocalStorage
const success = saveModulePrompt(module, bagNumber, { title, subtitle, prompt });
if (success) {
// 更新頁面上的錦囊標題
const bagElement = document.querySelector(`.ai-bag[data-module="${module}"][data-bag="${bagNumber}"]`);
if (bagElement) {
const titleElement = bagElement.querySelector('.bag-title');
const subtitleElement = bagElement.querySelector('.bag-subtitle');
if (titleElement) titleElement.textContent = title;
if (subtitleElement) {
if (subtitle) {
if (!subtitleElement.classList) {
const newSubtitle = document.createElement('div');
newSubtitle.className = 'bag-subtitle';
newSubtitle.textContent = subtitle;
bagElement.querySelector('.bag-title').after(newSubtitle);
} else {
subtitleElement.textContent = subtitle;
}
}
}
}
closePromptEditModal();
showToast('✅ Prompt 已保存');
} else {
showToast('❌ 保存失敗');
}
}
/**
* 關閉編輯對話框
*/
export function closePromptEditModal() {
const modal = document.getElementById('promptEditModal');
if (modal) {
modal.classList.remove('show');
}
}
/**
* 重置為預設 Prompt
*/
export function resetToDefaultPrompt() {
const modal = document.getElementById('promptEditModal');
const module = modal.dataset.module;
const bagNumber = parseInt(modal.dataset.bagNumber);
const defaultPrompt = DEFAULT_PROMPTS[module]?.[`bag${bagNumber}`];
if (!defaultPrompt) {
showToast('❌ 找不到預設 Prompt');
return;
}
if (confirm('確定要重置為預設 Prompt 嗎?')) {
document.getElementById('promptTitle').value = defaultPrompt.title || '';
document.getElementById('promptSubtitle').value = defaultPrompt.subtitle || '';
document.getElementById('promptContent').value = defaultPrompt.prompt || '';
showToast('✅ 已重置為預設值');
}
}
// ==================== 初始化 ====================
/**
* 初始化錦囊標題
*/
export function initializeBagTitles() {
document.querySelectorAll('.ai-bag').forEach(bag => {
const module = bag.dataset.module;
const bagNumber = parseInt(bag.dataset.bag);
const prompts = getModulePrompts(module);
const bagPrompt = prompts[`bag${bagNumber}`];
if (bagPrompt) {
const titleElement = bag.querySelector('.bag-title');
const subtitleElement = bag.querySelector('.bag-subtitle');
if (titleElement) titleElement.textContent = bagPrompt.title || `錦囊${bagNumber}`;
if (subtitleElement && bagPrompt.subtitle) {
subtitleElement.textContent = bagPrompt.subtitle;
}
}
});
}
// 頁面載入時初始化
document.addEventListener('DOMContentLoaded', () => {
initializeDefaultPrompts();
initializeBagTitles();
});
// ==================== 掛載到 window ====================
if (typeof window !== 'undefined') {
window.executeAIBag = executeAIBag;
window.editBagPrompt = editBagPrompt;
window.savePromptEdit = savePromptEdit;
window.closePromptEditModal = closePromptEditModal;
window.resetToDefaultPrompt = resetToDefaultPrompt;
}

View File

@@ -5,6 +5,7 @@
import { showToast } from './utils.js';
import { switchModule, updatePreview, updateCategoryName, updateNatureName, updateJobCategoryName } from './ui.js';
import { initializeBagTitles } from './ai-bags.js';
// ==================== 初始化 ====================
@@ -187,6 +188,9 @@ document.addEventListener('DOMContentLoaded', () => {
setupFormListeners();
setupKeyboardShortcuts();
// 初始化 AI 錦囊標題
initializeBagTitles();
// 初始化預覽
updatePreview();