Initial commit: HR Position System

- Database schema with MySQL support
- LLM API integration (Gemini 2.5 Flash, DeepSeek, OpenAI)
- Error handling with copyable error messages
- CORS fix for API calls
- Complete setup documentation

🤖 Generated with Claude Code
https://claude.com/claude-code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-04 00:46:53 +08:00
commit 29c1633e49
13 changed files with 6184 additions and 0 deletions

82
.gitignore vendored Normal file
View File

@@ -0,0 +1,82 @@
# Environment variables
.env
.env.local
.env.*.local
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual Environment
venv/
env/
ENV/
env.bak/
venv.bak/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Flask
instance/
.webassets-cache
# Database
*.db
*.sqlite
*.sqlite3
# Logs
*.log
logs/
log/
# OS
Thumbs.db
desktop.ini
# Temporary files
*.tmp
*.temp
*.bak
# Node modules (if using any frontend build tools)
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
# Backup files
*.backup
backup/

315
CORS_FIX_GUIDE.md Normal file
View File

@@ -0,0 +1,315 @@
# CORS 錯誤修正指南
## 🔴 問題說明
您遇到的錯誤有兩個:
### 1. CORS (跨域資源共享) 錯誤
```
Access to fetch at 'https://api.anthropic.com/v1/messages' from origin 'http://127.0.0.1:5000'
has been blocked by CORS policy: Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
```
**原因**: 前端代碼直接從瀏覽器調用 Claude API但 Claude API 不允許來自瀏覽器的直接請求(出於安全考量)。
### 2. Storage 訪問錯誤
```
Uncaught (in promise) Error: Access to storage is not allowed from this context.
```
**原因**: 瀏覽器的本地存儲權限問題,通常在使用 `file://` 協議或某些安全限制下出現。
---
## ✅ 解決方案
### 方案 1: 修改 index.html 中的 callClaudeAPI 函數(推薦)
**步驟 1**: 打開 [index.html](./index.html)
**步驟 2**: 找到第 1264 行的 `callClaudeAPI` 函數
**步驟 3**: 將整個函數替換為以下代碼:
```javascript
// ==================== AI Generation Functions ====================
async function callClaudeAPI(prompt, api = 'gemini') {
try {
// 調用後端 Flask API避免 CORS 錯誤
const response = await fetch("/api/llm/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
api: api, // 使用指定的 LLM API (gemini, deepseek, openai)
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;
responseText = responseText.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim();
return JSON.parse(responseText);
} catch (error) {
console.error("Error calling LLM API:", error);
// 顯示友好的錯誤訊息
alert(`AI 生成錯誤: ${error.message}\n\n請確保\n1. Flask 後端已啟動\n2. 已在 .env 文件中配置 LLM API Key\n3. 網路連線正常`);
throw error;
}
}
```
**步驟 4**: 保存文件
**步驟 5**: 確保使用更新版的 Flask 後端
```bash
# 使用包含 LLM 端點的版本
python app_updated.py
```
**步驟 6**: 重新載入瀏覽器頁面
---
### 方案 2: 使用已修正的文件(快速方案)
我已經創建了包含完整修正的文件,您可以選擇以下方式之一:
#### 選項 A: 手動修改(最安全)
1. 打開 `index.html`
2. 搜尋 `async function callClaudeAPI(prompt)`
3. 用上面方案 1 的代碼替換
#### 選項 B: 使用腳本自動修正
參考 [fix_cors.js](./fix_cors.js) 文件中的完整說明。
---
## 📋 完整修正步驟
### 1. 確認後端已啟動並包含 LLM 端點
```bash
# 停止當前運行的 Flask如果有
# 按 Ctrl+C
# 啟動更新版後端
python app_updated.py
```
您應該看到:
```
✓ LLM 功能已啟用
已配置的 API: gemini, deepseek, openai
```
### 2. 配置 LLM API Key
編輯 [.env](./.env) 文件,添加至少一個 API Key
```env
# 至少配置其中一個
GEMINI_API_KEY=your_actual_gemini_api_key_here
DEEPSEEK_API_KEY=your_actual_deepseek_api_key_here
OPENAI_API_KEY=your_actual_openai_api_key_here
```
**獲取 API Key:**
- **Gemini**: https://makersuite.google.com/app/apikey
- **DeepSeek**: https://www.deepseek.com/
- **OpenAI**: https://platform.openai.com/api-keys
### 3. 測試 LLM API 連線
訪問測試頁面http://localhost:5000/api-test
點擊「測試連線」按鈕,確保至少一個 API 顯示「✓ 連線成功」。
### 4. 修改 index.html
按照方案 1 的步驟修改 `callClaudeAPI` 函數。
### 5. 重新載入頁面
在瀏覽器中按 `Ctrl+F5` 強制重新載入頁面(清除緩存)。
### 6. 測試 AI 功能
1. 在主頁面點擊「新增崗位」
2. 填寫部分欄位(例如崗位名稱)
3. 點擊「✨ I'm feeling lucky」按鈕
4. 應該會成功生成內容,不再出現 CORS 錯誤
---
## 🔍 驗證修正是否成功
打開瀏覽器的開發者工具F12切換到 Console 標籤:
### ✅ 成功的表現:
- 沒有 CORS 錯誤
- 看到 `POST http://localhost:5000/api/llm/generate` 請求成功 (200 OK)
- AI 自動填充正常工作
### ❌ 仍有問題:
#### 如果看到 `403 LLM 功能未啟用`
**原因**: 沒有使用 `app_updated.py`
**解決**:
```bash
python app_updated.py
```
#### 如果看到 `API Key 未設定`
**原因**: .env 文件中沒有配置 API Key
**解決**: 編輯 `.env` 文件,添加有效的 API Key
#### 如果看到 `連線逾時`
**原因**: 網路連線問題或 API 伺服器問題
**解決**:
1. 檢查網路連線
2. 嘗試使用不同的 LLM API修改 `callClaudeAPI(prompt, 'deepseek')`
#### 如果看到 `API Key 無效`
**原因**: API Key 錯誤或已過期
**解決**: 重新獲取有效的 API Key
---
## 🎯 架構變更說明
### 修正前(錯誤):
```
瀏覽器 → 直接調用 → Claude API (❌ CORS 錯誤)
```
### 修正後(正確):
```
瀏覽器 → Flask 後端 → Gemini/DeepSeek/OpenAI API (✅ 正常)
```
---
## 💡 為什麼這樣修改
### 1. 安全性
- **API Key 保護**: API Key 只存儲在服務器端(.env 文件),不暴露給前端
- **防止濫用**: 用戶無法看到或竊取 API Key
### 2. CORS 問題
- **同源請求**: 前端只調用同域的 Flask APIhttp://localhost:5000
- **服務器端請求**: Flask 服務器調用外部 API不受 CORS 限制
### 3. 靈活性
- **多 API 支持**: 可以輕鬆切換不同的 LLM API
- **統一接口**: 前端代碼無需關心使用哪個 LLM API
---
## 📝 其他建議
### 1. 添加錯誤處理 UI
`<head>` 標籤中添加錯誤處理腳本:
```html
<script src="error_handler.js"></script>
```
### 2. 選擇 LLM API
如果想使用特定的 LLM API可以修改調用
```javascript
// 使用 Gemini默認
const result = await callClaudeAPI(prompt, 'gemini');
// 使用 DeepSeek
const result = await callClaudeAPI(prompt, 'deepseek');
// 使用 OpenAI
const result = await callClaudeAPI(prompt, 'openai');
```
### 3. 添加 API 選擇器
可以在前端添加一個下拉選單,讓用戶選擇使用哪個 LLM API
```html
<select id="llm-selector">
<option value="gemini">Google Gemini</option>
<option value="deepseek">DeepSeek</option>
<option value="openai">OpenAI</option>
</select>
```
然後在調用時使用:
```javascript
const api = document.getElementById('llm-selector').value;
const result = await callClaudeAPI(prompt, api);
```
---
## 🆘 仍然遇到問題?
### 檢查清單
- [ ] Flask 後端是否正在運行?
- [ ] 是否使用 `app_updated.py` 而不是 `app.py`
- [ ] .env 文件中是否配置了至少一個 API Key
- [ ] API Key 是否有效?(可以在 http://localhost:5000/api-test 測試)
- [ ] 是否已修改 index.html 中的 `callClaudeAPI` 函數?
- [ ] 瀏覽器是否已重新載入頁面Ctrl+F5
- [ ] 瀏覽器控制台是否還有其他錯誤?
### 調試步驟
1. **測試後端 API**:
```bash
curl -X POST http://localhost:5000/api/llm/generate \
-H "Content-Type: application/json" \
-d '{"api":"gemini","prompt":"測試","max_tokens":100}'
```
2. **檢查 Flask 日誌**: 查看終端中 Flask 的輸出
3. **瀏覽器控制台**: 查看詳細的錯誤訊息
4. **測試頁面**: 訪問 http://localhost:5000/api-test 確認 API 配置
---
## 📚 相關文件
- [SETUP.md](./SETUP.md) - 完整安裝指南
- [fix_cors.js](./fix_cors.js) - 詳細修正代碼
- [error_handler.js](./error_handler.js) - 全局錯誤處理
- [app_updated.py](./app_updated.py) - 包含 LLM 端點的 Flask 後端
---
**文件版本**: 1.0
**最後更新**: 2024-12-04
**問題類型**: CORS 跨域請求錯誤
**解決狀態**: ✅ 已提供完整解決方案

236
GEMINI_API_FIX.md Normal file
View File

@@ -0,0 +1,236 @@
# Gemini API Referrer 錯誤解決方案
## 🔴 錯誤訊息
```json
{
"error": {
"code": 403,
"message": "Requests from referer \u003cempty\u003e are blocked.",
"status": "PERMISSION_DENIED",
"details": [
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"reason": "API_KEY_HTTP_REFERRER_BLOCKED"
}
]
}
}
```
## 📋 問題原因
Gemini API Key 有 **HTTP Referrer 限制**,這是 Google 的安全機制。當從服務器端調用時HTTP Referrer 為空,導致請求被阻擋。
---
## ✅ 解決方案
### 方案 1: 修改 Gemini API Key 設定(推薦)
1. **訪問 Google AI Studio**
https://makersuite.google.com/app/apikey
2. **找到您的 API Key**
3. **點擊「Edit API Key」或創建新的 API Key**
4. **設定 Application restrictions**
- 選擇 **"None"** 或 **"IP addresses"**
- **不要**選擇 "HTTP referrers (websites)"
5. **保存設定**
6. **更新 .env 文件**
```env
GEMINI_API_KEY=your_new_unrestricted_api_key
```
7. **重啟 Flask 服務器**
```bash
# 按 Ctrl+C 停止服務器
python start_server.py
```
---
### 方案 2: 使用其他 LLM API臨時解決
如果您有其他 LLM API Key可以暫時使用
#### 選項 A: 使用 DeepSeek
1. **獲取 DeepSeek API Key**
https://www.deepseek.com/
2. **添加到 .env**
```env
DEEPSEEK_API_KEY=your_deepseek_api_key
```
3. **修改默認 API**
編輯 `index.html`,找到 `callClaudeAPI` 調用,改為:
```javascript
const result = await callClaudeAPI(prompt, 'deepseek');
```
#### 選項 B: 使用 OpenAI
1. **獲取 OpenAI API Key**
https://platform.openai.com/api-keys
2. **添加到 .env**
```env
OPENAI_API_KEY=your_openai_api_key
```
3. **修改默認 API**
編輯 `index.html`,找到 `callClaudeAPI` 調用,改為:
```javascript
const result = await callClaudeAPI(prompt, 'openai');
```
---
### 方案 3: 創建 API 選擇器(最佳長期方案)
讓用戶在前端選擇要使用的 LLM API。
我可以幫您添加一個下拉選單,讓用戶可以在 Gemini、DeepSeek 和 OpenAI 之間切換。
---
## 🔍 驗證修正
### 測試 API 連線
訪問測試頁面http://127.0.0.1:5000/api-test
點擊每個 API 的「🧪 測試連線」按鈕,查看哪些 API 可用。
### 成功的表現
- ✅ API 測試顯示「✓ 連線成功」
- ✅ 瀏覽器不再顯示 403 錯誤
- ✅ AI 自動填充功能正常工作
---
## 📝 詳細錯誤訊息說明
您在截圖中看到的錯誤:
```
"reason": "API_KEY_HTTP_REFERRER_BLOCKED"
```
這表示:
- Gemini API Key 設定了 HTTP Referrer 限制
- 服務器端請求沒有 Referrer被 Google 阻擋
- 需要移除 Referrer 限制或使用其他 API
---
## 🎯 推薦操作順序
### 立即操作5 分鐘)
1. **使用 DeepSeek 或 OpenAI**(臨時解決)
```bash
# 編輯 .env 添加其他 API Key
# 重啟服務器
python start_server.py
```
2. **測試頁面重新載入**
- 按 Ctrl+F5 刷新
- 測試 AI 功能
### 長期解決10 分鐘)
1. **修改 Gemini API Key 設定**
- 訪問 Google AI Studio
- 移除 HTTP Referrer 限制
- 創建新的無限制 API Key
2. **更新配置**
- 更新 .env 文件
- 重啟服務器
3. **全面測試**
- 測試所有 API
- 確保都能正常工作
---
## 💡 補充說明
### 為什麼服務器端請求沒有 Referrer
當從 Python Flask 後端調用 API 時:
- HTTP 請求是由服務器發出的
- 沒有瀏覽器上下文
- Referer header 為空或不存在
- Google 的安全機制會阻擋這類請求
### 如何避免這個問題?
1. **使用無限制的 API Key**(推薦)
2. **使用 IP 地址限制**而非 Referrer 限制
3. **使用服務帳戶**(企業方案)
---
## 🆘 仍然有問題?
### 如果修改 API Key 後還是不行
1. **檢查 API Key 是否生效**
- 等待 1-2 分鐘
- Google 的設定更新需要時間
2. **確認 .env 文件正確**
```bash
# 查看 .env 內容
type .env
```
3. **重啟服務器**
```bash
# 完全停止後重新啟動
python start_server.py
```
4. **清除瀏覽器緩存**
- 按 Ctrl+Shift+Delete
- 清除緩存和 Cookie
- 重新載入頁面
---
## 📊 API 對比
| API | 優點 | 缺點 | 推薦度 |
|-----|------|------|--------|
| **Gemini** | 免費額度高,速度快 | Referrer 限制問題 | ⭐⭐⭐ |
| **DeepSeek** | 便宜,中文支持好 | 需要付費 | ⭐⭐⭐⭐ |
| **OpenAI** | 穩定,功能強大 | 價格較高 | ⭐⭐⭐⭐⭐ |
---
## ✅ 完成檢查清單
修正完成後,請檢查:
- [ ] 至少一個 LLM API 測試成功
- [ ] AI 自動填充功能正常
- [ ] 沒有 403 錯誤
- [ ] 錯誤訊息可以完整顯示和複製
- [ ] 瀏覽器控制台沒有錯誤
---
**文件版本**: 1.0
**最後更新**: 2024-12-04
**問題類型**: Gemini API HTTP Referrer 限制
**解決狀態**: ✅ 已提供完整解決方案

844
SDD.md Normal file
View File

@@ -0,0 +1,844 @@
# HR 基礎資料維護系統 - 軟體設計文件 (SDD)
**文件版本**1.0
**建立日期**2024-12-03
**文件狀態**Released
---
## 1. 文件概述
### 1.1 目的
本文件為 HR 基礎資料維護系統之軟體設計文件 (Software Design Document),詳細描述系統架構、模組設計、資料結構、介面規格與 AI 整合功能,供開發人員、測試人員及維護人員參考。
### 1.2 範圍
本系統涵蓋以下三大功能模組:
| 模組 | 功能說明 |
|------|----------|
| 崗位基礎資料 | 崗位主檔維護,含基礎資料與招聘要求 |
| 職務基礎資料 | 職務類別與屬性設定維護 |
| 崗位描述 | 職責描述、崗位要求與任職條件維護 |
### 1.3 參考文件
- 用戶需求規格書 (URD)
- 系統功能規格書 (FSD)
- API 設計規範
### 1.4 術語定義
| 術語 | 定義 |
|------|------|
| 崗位 (Position) | 組織架構中的職位單位,具有編號、級別、編制人數等屬性 |
| 職務 (Job Title) | 職務類別分類,如管理職、技術職、業務職等 |
| 崗位描述 (Job Description) | 詳細描述崗位職責、要求與任職條件的文件 |
| AI 自動填充 | 利用大型語言模型自動生成表單欄位內容的功能 |
---
## 2. 系統架構
### 2.1 整體架構圖
```
┌─────────────────────────────────────────────────────────────────┐
│ 使用者介面層 (UI Layer) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 崗位基礎資料 │ │ 職務基礎資料 │ │ 崗位描述 │ │
│ │ 模組 │ │ 模組 │ │ 模組 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ AI 自動填充服務 (Claude API) │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 應用服務層 (Application Layer) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Flask RESTful API │ │
│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │
│ │ │ Position │ │ Job │ │ Reference │ │ │
│ │ │ API │ │ API │ │ API │ │ │
│ │ └───────────┘ └───────────┘ └───────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 資料存取層 (Data Layer) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ In-Memory Database / Future DB │ │
│ │ (positions_db, jobs_db, job_desc_db) │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### 2.2 技術堆疊
| 層級 | 技術選型 | 說明 |
|------|----------|------|
| 前端 | HTML5 + CSS3 + JavaScript | 純前端實作,無框架依賴 |
| 樣式 | Custom CSS + Google Fonts | Noto Sans TC 字型、CSS Variables |
| 後端 | Python Flask | RESTful API 服務 |
| AI 服務 | Claude API (Anthropic) | 智能表單填充 |
| 資料庫 | In-Memory (Dict) | 可擴展至 MySQL/PostgreSQL |
### 2.3 部署架構
```
┌─────────────────────────────────────────┐
│ 使用者瀏覽器 │
│ ┌─────────────────────────────────┐ │
│ │ index.html (前端應用) │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘
│ │
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ Flask Server │ │ Claude API │
│ localhost:5000 │ │ api.anthropic │
└───────────────────┘ └───────────────────┘
```
---
## 3. 模組設計
### 3.1 崗位基礎資料模組
#### 3.1.1 模組概述
提供崗位主檔的 CRUD 操作,分為「基礎資料」與「招聘要求資料」兩個頁籤。
#### 3.1.2 基礎資料頁籤欄位規格
| 欄位名稱 | 欄位ID | 資料類型 | 必填 | 說明 |
|----------|--------|----------|------|------|
| 崗位編號 | positionCode | String(20) | ✓ | 唯一識別碼格式XXX-NNN |
| 崗位名稱 | positionName | String(100) | ✓ | 崗位中文名稱 |
| 崗位類別 | positionCategory | Enum | | 01/02/03/04 |
| 崗位類別名稱 | positionCategoryName | String(50) | | 自動帶出,唯讀 |
| 崗位性質 | positionNature | Enum | | FT/PT/CT/IN |
| 崗位性質名稱 | positionNatureName | String(50) | | 自動帶出,唯讀 |
| 編制人數 | headcount | Integer | | 0-9999 |
| 崗位級別 | positionLevel | Enum | | L1-L7 |
| 生效日期 | effectiveDate | Date | | 預設 2001-01-01 |
| 崗位描述 | positionDesc | Text | | 多行文字 |
| 崗位備注 | positionRemark | Text | | 多行文字 |
#### 3.1.3 招聘要求資料頁籤欄位規格
| 欄位名稱 | 欄位ID | 資料類型 | 說明 |
|----------|--------|----------|------|
| 最低學歷 | minEducation | Enum | HS/JC/BA/MA/PHD |
| 要求性別 | requiredGender | Enum | 空值=不限, M=男, F=女 |
| 薪酬范圍 | salaryRange | Enum | A/B/C/D/E/N |
| 工作經驗 | workExperience | Enum | 0/1/3/5/10 (年) |
| 最小年齡 | minAge | Integer | 18-65 |
| 最大年齡 | maxAge | Integer | 18-65 |
| 工作性質 | jobType | Enum | FT/PT/CT/DP |
| 招聘職位 | recruitPosition | Enum | ENG/MGR/AST/OP/SAL |
| 職位名稱 | jobTitle | String(100) | |
| 職位描述 | jobDesc | Text | |
| 崗位要求 | positionReq | Text | |
| 職稱要求 | titleReq | Enum | NONE/CERT/LIC |
| 專業要求 | majorReq | String(200) | 多選,逗號分隔 |
| 技能要求 | skillReq | String(200) | |
| 語言要求 | langReq | String(100) | |
| 其他要求 | otherReq | String(200) | |
| 上級崗位編號 | superiorPosition | String(20) | |
| 備注說明 | recruitRemark | Text | |
#### 3.1.4 類別代碼對照表
**崗位類別 (positionCategory)**
| 代碼 | 名稱 |
|------|------|
| 01 | 技術職 |
| 02 | 管理職 |
| 03 | 業務職 |
| 04 | 行政職 |
**崗位性質 (positionNature)**
| 代碼 | 名稱 |
|------|------|
| FT | 全職 |
| PT | 兼職 |
| CT | 約聘 |
| IN | 實習 |
**崗位級別 (positionLevel)**
| 代碼 | 名稱 |
|------|------|
| L1 | 基層員工 |
| L2 | 資深員工 |
| L3 | 主管 |
| L4 | 經理 |
| L5 | 總監 |
| L6 | 副總 |
| L7 | 總經理 |
---
### 3.2 職務基礎資料模組
#### 3.2.1 模組概述
提供職務類別主檔的維護功能,包含職務屬性設定。
#### 3.2.2 欄位規格
| 欄位名稱 | 欄位ID | 資料類型 | 必填 | 說明 |
|----------|--------|----------|------|------|
| 職務類別編號 | jobCategoryCode | Enum | ✓ | MGR/TECH/SALE/ADMIN/RD/PROD |
| 職務類別名稱 | jobCategoryName | String(50) | | 自動帶出,唯讀 |
| 職務編號 | jobCode | String(20) | ✓ | 唯一識別碼 |
| 職務名稱 | jobName | String(100) | ✓ | 職務中文名稱 |
| 職務英文 | jobNameEn | String(100) | | 職務英文名稱 |
| 生效日期 | jobEffectiveDate | Date | | |
| 編制人數 | jobHeadcount | Integer | | 0-9999 |
| 排列順序 | jobSortOrder | Integer | | 顯示排序 |
| 備注說明 | jobRemark | Text | | |
| 職務層級 | jobLevel | String(50) | | 可設為 *保密* |
| 是否有全勤 | hasAttendanceBonus | Boolean | | Toggle 開關 |
| 是否住房補貼 | hasHousingAllowance | Boolean | | Toggle 開關 |
#### 3.2.3 職務類別代碼對照表
| 代碼 | 名稱 |
|------|------|
| MGR | 管理職 |
| TECH | 技術職 |
| SALE | 業務職 |
| ADMIN | 行政職 |
| RD | 研發職 |
| PROD | 生產職 |
---
### 3.3 崗位描述模組
#### 3.3.1 模組概述
提供完整的崗位描述書 (Job Description) 維護功能,包含崗位基本信息、職責描述與崗位要求三大區塊。
#### 3.3.2 頂部區域欄位
| 欄位名稱 | 欄位ID | 資料類型 | 說明 |
|----------|--------|----------|------|
| 工號 | jd_empNo | String(20) | 員工編號 |
| 姓名 | jd_empName | String(50) | 自動帶出,唯讀 |
| 崗位代碼 | jd_positionCode | String(20) | |
| 版本更新日期 | jd_versionDate | Date | |
#### 3.3.3 崗位基本信息區塊
| 欄位名稱 | 欄位ID | 資料類型 | 說明 |
|----------|--------|----------|------|
| 崗位名稱 | jd_positionName | String(100) | |
| 所屬部門 | jd_department | String(100) | |
| 崗位生效日期 | jd_positionEffectiveDate | Date | |
| 直接領導職務 | jd_directSupervisor | String(100) | |
| 崗位職等&職務 | jd_positionGradeJob | String(100) | 選擇按鈕 |
| 匯報對象職務 | jd_reportTo | String(100) | 選擇按鈕 |
| 直接下級 | jd_directReports | String(200) | 格式:職位 x 人數 |
| 任職地點 | jd_workLocation | Enum | HQ/TPE/TYC/KHH/SH/SZ |
| 員工屬性 | jd_empAttribute | Enum | FT/CT/PT/IN/DP |
#### 3.3.4 職責描述區塊
| 欄位名稱 | 欄位ID | 資料類型 | 說明 |
|----------|--------|----------|------|
| 崗位設置目的 | jd_positionPurpose | String(500) | 單行文字 |
| 主要崗位職責 | jd_mainResponsibilities | Text | 編號格式 1、2、3、... |
#### 3.3.5 崗位要求區塊
| 欄位名稱 | 欄位ID | 資料類型 | 說明 |
|----------|--------|----------|------|
| 教育程度 | jd_education | String(200) | |
| 基本技能 | jd_basicSkills | Text | |
| 專業知識 | jd_professionalKnowledge | Text | |
| 工作經驗 | jd_workExperienceReq | Text | |
| 其他 | jd_otherRequirements | Text | |
#### 3.3.6 任職地點代碼對照表
| 代碼 | 名稱 |
|------|------|
| HQ | 總部 |
| TPE | 台北辦公室 |
| TYC | 桃園廠區 |
| KHH | 高雄廠區 |
| SH | 上海辦公室 |
| SZ | 深圳辦公室 |
#### 3.3.7 員工屬性代碼對照表
| 代碼 | 名稱 |
|------|------|
| FT | 正式員工 |
| CT | 約聘人員 |
| PT | 兼職人員 |
| IN | 實習生 |
| DP | 派遣人員 |
---
## 4. API 設計
### 4.1 API 端點總覽
#### 4.1.1 崗位資料 API
| 方法 | 端點 | 說明 |
|------|------|------|
| GET | `/api/positions` | 獲取所有崗位(支援分頁、搜尋) |
| GET | `/api/positions/{id}` | 獲取單一崗位 |
| POST | `/api/positions` | 新增崗位 |
| PUT | `/api/positions/{id}` | 更新崗位 |
| DELETE | `/api/positions/{id}` | 刪除崗位 |
| POST | `/api/positions/{id}/change-code` | 更改崗位編號 |
#### 4.1.2 職務資料 API
| 方法 | 端點 | 說明 |
|------|------|------|
| GET | `/api/jobs` | 獲取所有職務(支援分頁、搜尋、類別篩選) |
| GET | `/api/jobs/{id}` | 獲取單一職務 |
| POST | `/api/jobs` | 新增職務 |
| PUT | `/api/jobs/{id}` | 更新職務 |
| DELETE | `/api/jobs/{id}` | 刪除職務 |
| POST | `/api/jobs/{id}/change-code` | 更改職務編號 |
#### 4.1.3 參照資料 API
| 方法 | 端點 | 說明 |
|------|------|------|
| GET | `/api/reference/categories` | 崗位類別選項 |
| GET | `/api/reference/job-categories` | 職務類別選項 |
| GET | `/api/reference/natures` | 崗位性質選項 |
| GET | `/api/reference/education` | 學歷選項 |
| GET | `/api/reference/majors` | 專業選項 |
### 4.2 API 請求/回應範例
#### 4.2.1 新增崗位
**Request**
```http
POST /api/positions
Content-Type: application/json
{
"basicInfo": {
"positionCode": "ENG-001",
"positionName": "資深軟體工程師",
"positionCategory": "01",
"positionNature": "FT",
"headcount": "5",
"positionLevel": "L3",
"effectiveDate": "2024-01-01",
"positionDesc": "負責系統架構設計與核心模組開發"
},
"recruitInfo": {
"minEducation": "BA",
"salaryRange": "D",
"workExperience": "5",
"skillReq": "Python, JavaScript, SQL"
}
}
```
**Response (201 Created)**
```json
{
"success": true,
"message": "崗位資料新增成功",
"data": {
"id": "ENG-001",
"basicInfo": { ... },
"recruitInfo": { ... },
"createdAt": "2024-12-03T10:30:00",
"updatedAt": "2024-12-03T10:30:00"
}
}
```
#### 4.2.2 查詢崗位列表
**Request**
```http
GET /api/positions?page=1&size=20&search=工程師
```
**Response (200 OK)**
```json
{
"success": true,
"data": [
{
"id": "ENG-001",
"basicInfo": { ... },
"recruitInfo": { ... }
}
],
"pagination": {
"page": 1,
"size": 20,
"total": 1,
"totalPages": 1
}
}
```
### 4.3 錯誤處理
| HTTP 狀態碼 | 錯誤類型 | 說明 |
|-------------|----------|------|
| 400 | Bad Request | 請求參數錯誤或缺少必填欄位 |
| 404 | Not Found | 找不到指定資源 |
| 409 | Conflict | 資源衝突(如編號已存在) |
| 500 | Internal Server Error | 伺服器內部錯誤 |
**錯誤回應格式**
```json
{
"success": false,
"error": "錯誤訊息描述"
}
```
---
## 5. AI 自動填充功能設計
### 5.1 功能概述
系統整合 Claude API提供智能表單填充功能。用戶點擊「✨ I'm feeling lucky」按鈕後AI 會根據已填寫的內容,自動補充尚未填寫的欄位。
### 5.2 填充邏輯
```
┌─────────────────────────────────────────────────────────┐
│ AI 填充流程 │
├─────────────────────────────────────────────────────────┤
│ 1. 用戶點擊「I'm feeling lucky」按鈕 │
│ │ │
│ ▼ │
│ 2. 檢查表單欄位狀態 │
│ ├── 收集已填寫欄位 (作為上下文) │
│ └── 識別空白欄位 (待填充目標) │
│ │ │
│ ▼ │
│ 3. 若無空白欄位 → 顯示「所有欄位都已填寫完成!」 │
│ 若有空白欄位 → 繼續 │
│ │ │
│ ▼ │
│ 4. 組裝 Prompt │
│ ├── 包含已填寫資料作為參考 │
│ ├── 指定只生成空白欄位 │
│ └── 要求返回 JSON 格式 │
│ │ │
│ ▼ │
│ 5. 呼叫 Claude API (claude-sonnet-4-20250514) │
│ │ │
│ ▼ │
│ 6. 解析回應並填充空白欄位 │
│ └── 已有內容的欄位保持不變 │
│ │ │
│ ▼ │
│ 7. 顯示「✨ AI 已補充 N 個欄位!」 │
└─────────────────────────────────────────────────────────┘
```
### 5.3 核心函數
#### 5.3.1 fillIfEmpty
```javascript
/**
* 只在欄位為空時填入值
* @param {string} elementId - DOM 元素 ID
* @param {string} value - 要填入的值
* @returns {boolean} - 是否有填入值
*/
function fillIfEmpty(elementId, value) {
const el = document.getElementById(elementId);
if (el && !el.value.trim() && value) {
el.value = value;
return true;
}
return false;
}
```
#### 5.3.2 getEmptyFields
```javascript
/**
* 獲取空白欄位列表
* @param {string[]} fieldIds - 欄位 ID 陣列
* @returns {string[]} - 空白欄位 ID 陣列
*/
function getEmptyFields(fieldIds) {
return fieldIds.filter(id => !getFieldValue(id));
}
```
### 5.4 API 呼叫規格
**Endpoint**
```
POST https://api.anthropic.com/v1/messages
```
**Request Body**
```json
{
"model": "claude-sonnet-4-20250514",
"max_tokens": 2000,
"messages": [
{
"role": "user",
"content": "Prompt 內容..."
}
]
}
```
### 5.5 Prompt 設計範例
```
請為HR崗位管理系統生成崗位基礎資料。請用繁體中文回覆。
已填寫的資料(請參考這些內容來生成相關的資料):
{
"positionName": "資深前端工程師",
"positionLevel": "L4"
}
請「只生成」以下這些尚未填寫的欄位positionCode, positionCategory, positionNature, headcount, positionDesc
欄位說明:
- positionCode: 崗位編號(格式如 ENG-001
- positionCategory: 崗位類別代碼01=技術職, 02=管理職...
...
請直接返回JSON格式只包含需要生成的欄位
{
"positionCode": "...",
"positionCategory": "...",
...
}
```
---
## 6. 使用者介面設計
### 6.1 色彩規範
| 變數名稱 | 色碼 | 用途 |
|----------|------|------|
| --primary | #1a5276 | 主色(崗位模組) |
| --primary-light | #2980b9 | 主色亮色 |
| --primary-dark | #0e3a53 | 主色暗色 |
| --accent | #e67e22 | 強調色(職務模組) |
| --green | #27ae60 | 綠色(崗位描述模組) |
| --success | #27ae60 | 成功狀態 |
| --warning | #f39c12 | 警告狀態 |
| --danger | #e74c3c | 錯誤/必填標示 |
### 6.2 模組主題色
| 模組 | Header 背景 | 按鈕樣式 |
|------|-------------|----------|
| 崗位基礎資料 | 藍色漸層 | primary |
| 職務基礎資料 | 橘色漸層 | accent |
| 崗位描述 | 綠色漸層 | green |
### 6.3 元件規格
#### 6.3.1 輸入框
```css
input, select, textarea {
padding: 10px 14px;
border: 1.5px solid var(--border);
border-radius: 6px;
font-size: 0.9rem;
}
```
#### 6.3.2 按鈕
| 類型 | Class | 用途 |
|------|-------|------|
| 主要按鈕 | .btn-primary | 保存操作 |
| 次要按鈕 | .btn-secondary | 保存並新增 |
| 取消按鈕 | .btn-cancel | 取消操作 |
| AI 按鈕 | .ai-generate-btn | AI 自動填充 |
#### 6.3.3 Toggle 開關
```css
.toggle-switch {
width: 52px;
height: 28px;
}
```
### 6.4 響應式斷點
| 斷點 | 寬度 | 佈局調整 |
|------|------|----------|
| Desktop | > 768px | 雙欄表單佈局 |
| Mobile | ≤ 768px | 單欄表單佈局 |
---
## 7. 資料結構
### 7.1 崗位資料 (Position)
```typescript
interface Position {
id: string; // 崗位編號 (PK)
basicInfo: {
positionCode: string; // 崗位編號
positionName: string; // 崗位名稱
positionCategory: string; // 崗位類別
positionCategoryName: string;// 崗位類別名稱
positionNature: string; // 崗位性質
positionNatureName: string; // 崗位性質名稱
headcount: string; // 編制人數
positionLevel: string; // 崗位級別
effectiveDate: string; // 生效日期
positionDesc: string; // 崗位描述
positionRemark: string; // 崗位備注
};
recruitInfo: {
minEducation: string; // 最低學歷
requiredGender: string; // 要求性別
salaryRange: string; // 薪酬范圍
workExperience: string; // 工作經驗
minAge: string; // 最小年齡
maxAge: string; // 最大年齡
jobType: string; // 工作性質
recruitPosition: string; // 招聘職位
jobTitle: string; // 職位名稱
jobDesc: string; // 職位描述
positionReq: string; // 崗位要求
titleReq: string; // 職稱要求
majorReq: string; // 專業要求
skillReq: string; // 技能要求
langReq: string; // 語言要求
otherReq: string; // 其他要求
superiorPosition: string; // 上級崗位編號
recruitRemark: string; // 備注說明
};
createdAt: string; // 建立時間
updatedAt: string; // 更新時間
}
```
### 7.2 職務資料 (Job)
```typescript
interface Job {
id: string; // 職務編號 (PK)
jobCategoryCode: string; // 職務類別編號
jobCategoryName: string; // 職務類別名稱
jobCode: string; // 職務編號
jobName: string; // 職務名稱
jobNameEn: string; // 職務英文名稱
jobEffectiveDate: string; // 生效日期
jobHeadcount: number; // 編制人數
jobSortOrder: number; // 排列順序
jobRemark: string; // 備注說明
jobLevel: string; // 職務層級
hasAttendanceBonus: boolean; // 是否有全勤
hasHousingAllowance: boolean; // 是否住房補貼
createdAt: string; // 建立時間
updatedAt: string; // 更新時間
}
```
### 7.3 崗位描述資料 (JobDescription)
```typescript
interface JobDescription {
id: string; // ID (PK)
basicInfo: {
empNo: string; // 工號
empName: string; // 姓名
positionCode: string; // 崗位代碼
versionDate: string; // 版本更新日期
};
positionInfo: {
positionName: string; // 崗位名稱
department: string; // 所屬部門
positionEffectiveDate: string; // 崗位生效日期
directSupervisor: string; // 直接領導職務
positionGradeJob: string; // 崗位職等&職務
reportTo: string; // 匯報對象職務
directReports: string; // 直接下級
workLocation: string; // 任職地點
empAttribute: string; // 員工屬性
};
responsibilities: {
positionPurpose: string; // 崗位設置目的
mainResponsibilities: string;// 主要崗位職責
};
requirements: {
education: string; // 教育程度
basicSkills: string; // 基本技能
professionalKnowledge: string; // 專業知識
workExperienceReq: string; // 工作經驗
otherRequirements: string; // 其他要求
};
createdAt: string; // 建立時間
updatedAt: string; // 更新時間
}
```
---
## 8. 快捷鍵設計
| 快捷鍵 | 功能 | 適用範圍 |
|--------|------|----------|
| `Ctrl + S` | 保存并退出 | 所有模組 |
| `Ctrl + N` | 保存并新增 | 所有模組 |
---
## 9. 檔案結構
```
hr_position_form/
├── index.html # 前端應用主檔
├── app.py # Flask 後端 API
├── requirements.txt # Python 依賴套件
├── README.md # 使用說明文件
└── SDD.md # 軟體設計文件(本文件)
```
---
## 10. 未來擴展建議
### 10.1 資料庫整合
將 In-Memory 儲存替換為正式資料庫:
```python
# 建議使用 SQLAlchemy ORM
from flask_sqlalchemy import SQLAlchemy
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql://user:pass@host/db'
db = SQLAlchemy(app)
```
### 10.2 權限控制整合
```python
# 建議整合 Azure AD
from flask_azure_oauth import AzureOAuth
azure_oauth = AzureOAuth(app)
@app.route('/api/positions', methods=['POST'])
@azure_oauth.require_auth
def create_position():
...
```
### 10.3 審計日誌
```python
# 記錄所有資料變更
def log_audit(action, entity_type, entity_id, old_data, new_data, user_id):
audit_log = {
'action': action, # CREATE/UPDATE/DELETE
'entity_type': entity_type,
'entity_id': entity_id,
'old_data': old_data,
'new_data': new_data,
'user_id': user_id,
'timestamp': datetime.now().isoformat()
}
# 儲存至審計日誌表
```
### 10.4 多語言支援
```javascript
// i18n 國際化
const i18n = {
'zh-TW': {
'position.code': '崗位編號',
'position.name': '崗位名稱',
...
},
'en-US': {
'position.code': 'Position Code',
'position.name': 'Position Name',
...
}
};
```
---
## 11. 版本歷史
| 版本 | 日期 | 作者 | 變更說明 |
|------|------|------|----------|
| 1.0 | 2024-12-03 | System | 初始版本,包含三大模組設計與 AI 功能 |
---
## 12. 附錄
### 附錄 AJSON 資料預覽格式
系統提供即時 JSON 預覽功能,格式如下:
```json
{
"module": "崗位基礎資料",
"basicInfo": {
"positionCode": "ENG-001",
"positionName": "資深軟體工程師",
"positionCategory": "01",
"positionCategoryName": "技術職",
"positionNature": "FT",
"positionNatureName": "全職",
"headcount": "5",
"positionLevel": "L3"
},
"recruitInfo": {
"minEducation": "BA",
"salaryRange": "D",
"workExperience": "5"
}
}
```
### 附錄 BUI 截圖參考
(請參考系統實際畫面)
---
**文件結束**

438
SETUP.md Normal file
View File

@@ -0,0 +1,438 @@
# HR 基礎資料維護系統 - 安裝與配置指南
## 📋 目錄
1. [系統需求](#系統需求)
2. [環境配置](#環境配置)
3. [資料庫設置](#資料庫設置)
4. [Gitea 版本控制設置](#gitea-版本控制設置)
5. [LLM API 配置](#llm-api-配置)
6. [啟動系統](#啟動系統)
7. [功能測試](#功能測試)
8. [常見問題](#常見問題)
---
## 系統需求
### 軟體需求
- **Python**: 3.8 或更高版本
- **MySQL**: 5.7 或更高版本
- **Git**: 2.0 或更高版本 (用於版本控制)
- **現代瀏覽器**: Chrome, Firefox, Edge (最新版本)
### 硬體需求
- **記憶體**: 最少 2GB RAM
- **硬碟**: 最少 500MB 可用空間
- **網路**: 穩定的網際網路連線 (用於 LLM API)
---
## 環境配置
### 1. 複製或下載項目
```bash
cd d:/00001_Vibe_coding/1204剛為
```
### 2. 創建虛擬環境 (建議)
```bash
# Windows
python -m venv venv
venv\Scripts\activate
# Linux/Mac
python3 -m venv venv
source venv/bin/activate
```
### 3. 安裝依賴套件
```bash
pip install -r requirements_full.txt
```
如果出現錯誤,可以嘗試逐個安裝:
```bash
pip install flask flask-cors pymysql python-dotenv requests gitpython
```
### 4. 配置環境變數
項目根目錄下已有 [.env](./.env) 文件,請根據實際情況修改:
```env
# MySQL Database Configuration
DB_HOST=mysql.theaken.com
DB_PORT=33306
DB_NAME=db_A102
DB_USER=A102
DB_PASSWORD=Bb123456
# Gitea Version Control Configuration
GITEA_URL=https://gitea.theaken.com/
GITEA_USER=donald
GITEA_PASSWORD=!QAZ2wsx
GITEA_TOKEN=9e0a888d1a25bde9cf2ad5dff2bb7ee6d68d6ff0
# LLM API Keys (需要自行申請並填寫)
GEMINI_API_KEY=your_gemini_api_key_here
DEEPSEEK_API_KEY=your_deepseek_api_key_here
OPENAI_API_KEY=your_openai_api_key_here
```
⚠️ **重要**: `.env` 文件包含敏感資訊,已加入 `.gitignore`,請勿上傳到版本控制系統。
---
## 資料庫設置
### 1. 測試資料庫連線
```bash
python init_database.py
```
此腳本會:
- 測試資料庫連線
- 創建 `hr_position_system` 資料庫架構
- 建立所有必要的資料表
- 插入參照資料
### 2. 驗證資料表
成功執行後,應該會看到以下資料表:
- `positions` - 崗位基礎資料表
- `jobs` - 職務基礎資料表
- `job_descriptions` - 崗位描述表
- `reference_codes` - 參照資料代碼表
- `audit_logs` - 審計日誌表
### 3. 手動執行 SQL (可選)
如果自動腳本失敗,可以手動執行:
```bash
# 使用 MySQL 客戶端
mysql -h mysql.theaken.com -P 33306 -u A102 -p db_A102 < database_schema.sql
```
---
## Gitea 版本控制設置
### 1. 測試 Gitea 連線
```bash
python init_gitea.py
```
此腳本會:
- 測試 Gitea 伺服器連線
- 創建新的 Git 倉庫 (如果不存在)
- 配置本地 Git remote
- 創建初始提交
### 2. 推送到 Gitea
```bash
git push -u origin main
```
### 3. 訪問 Gitea 倉庫
在瀏覽器中訪問: [https://gitea.theaken.com/donald/hr-position-system](https://gitea.theaken.com/donald/hr-position-system)
---
## LLM API 配置
系統支援三種 LLM API:
### 1. Google Gemini
1. 訪問 [Google AI Studio](https://makersuite.google.com/app/apikey)
2. 創建 API Key
3. 將 API Key 填入 `.env` 文件的 `GEMINI_API_KEY`
### 2. DeepSeek
1. 訪問 [DeepSeek 官網](https://www.deepseek.com/)
2. 註冊並獲取 API Key
3. 將 API Key 填入 `.env` 文件的 `DEEPSEEK_API_KEY`
### 3. OpenAI
1. 訪問 [OpenAI Platform](https://platform.openai.com/api-keys)
2. 創建 API Key
3. 將 API Key 填入 `.env` 文件的 `OPENAI_API_KEY`
### 4. 測試 LLM API
```bash
python llm_config.py
```
此腳本會測試所有已配置的 API 連線狀態。
---
## 啟動系統
### 方法 1: 使用更新版的 app (包含 LLM 功能)
```bash
# 備份原有的 app.py
mv app.py app_original.py
# 使用新版 app
mv app_updated.py app.py
# 啟動系統
python app.py
```
### 方法 2: 直接使用更新版
```bash
python app_updated.py
```
### 啟動成功
您應該會看到類似以下的輸出:
```
╔══════════════════════════════════════════════════════════════╗
║ HR 基礎資料維護系統 - Flask Backend ║
╠══════════════════════════════════════════════════════════════╣
║ 伺服器啟動中... ║
║ 訪問網址: http://localhost:5000 ║
...
╚══════════════════════════════════════════════════════════════╝
✓ LLM 功能已啟用
已配置的 API: gemini, deepseek, openai
```
---
## 功能測試
### 1. 訪問主頁面
在瀏覽器中打開: [http://localhost:5000](http://localhost:5000)
### 2. 測試 LLM API 連線
訪問 API 測試頁面: [http://localhost:5000/api-test](http://localhost:5000/api-test)
在此頁面可以:
- 查看所有 LLM API 配置狀態
- 單獨測試每個 API 連線
- 一次測試所有 API
### 3. 測試 REST API
使用 Postman 或 curl 測試 API:
```bash
# 獲取崗位列表
curl http://localhost:5000/api/positions
# 獲取職務列表
curl http://localhost:5000/api/jobs
# 測試 LLM API 配置
curl http://localhost:5000/api/llm/config
# 測試 Gemini API
curl http://localhost:5000/api/llm/test/gemini
```
### 4. 測試崗位資料 CRUD
1. **新增崗位**: 點擊「新增崗位」按鈕
2. **填寫表單**: 輸入崗位基本資料
3. **AI 自動填充**: 點擊「✨ I'm feeling lucky」按鈕
4. **保存資料**: 點擊「保存並退出」
### 5. 測試錯誤處理
系統已集成全局錯誤處理:
- 所有 API 錯誤會自動顯示錯誤對話框
- 網路錯誤會有友好的提示訊息
- 可以在瀏覽器控制台查看詳細錯誤信息
---
## 常見問題
### Q1: 資料庫連線失敗
**問題**: 執行 `init_database.py` 時出現連線錯誤
**解決方案**:
1. 檢查 `.env` 文件中的資料庫配置是否正確
2. 確認資料庫伺服器是否可訪問:
```bash
ping mysql.theaken.com
```
3. 檢查防火牆是否阻擋了 33306 端口
4. 確認資料庫用戶名和密碼正確
### Q2: Gitea 推送失敗
**問題**: `git push` 時要求輸入密碼
**解決方案**:
1. 使用 Token 進行認證:
```bash
git remote set-url origin https://donald:9e0a888d1a25bde9cf2ad5dff2bb7ee6d68d6ff0@gitea.theaken.com/donald/hr-position-system.git
```
2. 或配置 Git credential helper
### Q3: LLM API 測試失敗
**問題**: LLM API 連線測試顯示失敗
**解決方案**:
1. 檢查 API Key 是否正確填寫在 `.env` 文件中
2. 確認 API Key 是否有效 (未過期)
3. 檢查網路連線是否正常
4. 查看是否有 API 配額限制
### Q4: 端口被占用
**問題**: 啟動 Flask 時提示 `Address already in use`
**解決方案**:
1. 更改端口:
```python
app.run(host='0.0.0.0', port=5001, debug=True)
```
2. 或關閉占用 5000 端口的進程:
```bash
# Windows
netstat -ano | findstr :5000
taskkill /PID <PID> /F
# Linux/Mac
lsof -i :5000
kill -9 <PID>
```
### Q5: 前端頁面無法訪問
**問題**: 瀏覽器顯示 404 Not Found
**解決方案**:
1. 確認 Flask 已正確啟動
2. 檢查 `index.html` 是否存在於項目根目錄
3. 清除瀏覽器緩存並重新載入
### Q6: LLM 功能未啟用
**問題**: 系統提示「LLM 功能未啟用」
**解決方案**:
1. 確認 `llm_config.py` 文件存在
2. 確認已安裝 `requests` 套件:
```bash
pip install requests
```
3. 使用 `app_updated.py` 而非原始的 `app.py`
---
## 系統架構
```
┌─────────────────────────────────────────┐
│ 使用者瀏覽器 │
│ ┌───────────────────────────────────┐ │
│ │ index.html (主應用) │ │
│ │ api_test.html (API測試頁) │ │
│ │ error_handler.js (錯誤處理) │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Flask Backend (app.py) │
│ ┌───────────────────────────────────┐ │
│ │ 崗位資料 API │ │
│ │ 職務資料 API │ │
│ │ 參照資料 API │ │
│ │ LLM API (llm_config.py) │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
┌─────┴─────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ MySQL │ │ LLM APIs │
│ Database │ │ (Gemini, │
│ │ │ DeepSeek,│
│ │ │ OpenAI) │
└──────────┘ └──────────┘
```
---
## 檔案說明
| 檔案 | 說明 |
|------|------|
| `.env` | 環境變數配置 |
| `.gitignore` | Git 忽略檔案配置 |
| `database_schema.sql` | MySQL 資料庫架構 |
| `init_database.py` | 資料庫初始化腳本 |
| `init_gitea.py` | Gitea 倉庫初始化腳本 |
| `llm_config.py` | LLM API 配置模組 |
| `app.py` | Flask 後端主程式 (原始版) |
| `app_updated.py` | Flask 後端主程式 (包含 LLM) |
| `error_handler.js` | 全局錯誤處理模組 |
| `api_test.html` | LLM API 測試頁面 |
| `index.html` | 主應用頁面 |
| `requirements_full.txt` | Python 依賴套件列表 |
| `SDD.md` | 軟體設計文件 |
| `SETUP.md` | 本安裝指南 |
---
## 技術支援
如遇到問題,請:
1. 查看瀏覽器控制台的錯誤訊息
2. 檢查 Flask 終端的錯誤日誌
3. 參考本文檔的[常見問題](#常見問題)章節
4. 查看系統設計文件 `SDD.md`
---
## 更新日誌
### Version 1.0 (2024-12-03)
- ✅ 初始版本發布
- ✅ 資料庫架構設計與初始化
- ✅ Gitea 版本控制整合
- ✅ LLM API 整合 (Gemini, DeepSeek, OpenAI)
- ✅ 全局錯誤處理機制
- ✅ API 測試頁面
- ✅ 環境配置自動化
---
**文件版本**: 1.0
**最後更新**: 2024-12-03
**維護者**: HR System Development Team

520
api_test.html Normal file
View File

@@ -0,0 +1,520 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LLM API 連線測試 - HR 系統</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--primary: #1a5276;
--primary-light: #2980b9;
--success: #27ae60;
--danger: #e74c3c;
--warning: #f39c12;
--border: #ddd;
--bg-light: #f8f9fa;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Noto Sans TC', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1000px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 2rem;
margin-bottom: 10px;
}
.header p {
opacity: 0.9;
font-size: 1rem;
}
.content {
padding: 30px;
}
.api-card {
background: var(--bg-light);
border: 2px solid var(--border);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
transition: all 0.3s;
}
.api-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.api-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.api-name {
font-size: 1.3rem;
font-weight: 600;
color: var(--primary);
}
.status-badge {
padding: 6px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.status-pending {
background: #e8e8e8;
color: #666;
}
.status-success {
background: #d4edda;
color: var(--success);
}
.status-error {
background: #f8d7da;
color: var(--danger);
}
.status-testing {
background: #fff3cd;
color: var(--warning);
}
.api-info {
display: grid;
grid-template-columns: 120px 1fr;
gap: 10px;
margin-bottom: 15px;
font-size: 0.9rem;
}
.api-info-label {
font-weight: 600;
color: #555;
}
.api-info-value {
color: #333;
word-break: break-all;
}
.api-key-display {
font-family: 'Courier New', monospace;
background: white;
padding: 8px;
border-radius: 4px;
border: 1px solid var(--border);
}
.api-key-masked {
color: #999;
}
.api-actions {
display: flex;
gap: 10px;
margin-top: 15px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
font-family: 'Noto Sans TC', sans-serif;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-light);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(26, 82, 118, 0.3);
}
.btn-success {
background: var(--success);
color: white;
}
.btn-success:hover:not(:disabled) {
background: #229954;
transform: translateY(-2px);
}
.result-message {
margin-top: 15px;
padding: 12px;
border-radius: 6px;
font-size: 0.9rem;
display: none;
}
.result-message.show {
display: block;
}
.result-success {
background: #d4edda;
color: var(--success);
border: 1px solid var(--success);
}
.result-error {
background: #f8d7da;
color: var(--danger);
border: 1px solid var(--danger);
}
.test-all-section {
text-align: center;
margin: 30px 0;
padding: 20px;
background: var(--bg-light);
border-radius: 8px;
}
.test-all-btn {
padding: 15px 40px;
font-size: 1.1rem;
}
.loading-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 3px solid rgba(255,255,255,.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s ease-in-out infinite;
margin-left: 8px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.summary {
background: white;
border: 2px solid var(--primary);
border-radius: 8px;
padding: 20px;
margin-bottom: 30px;
}
.summary h2 {
color: var(--primary);
margin-bottom: 15px;
font-size: 1.3rem;
}
.summary-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
}
.stat-box {
text-align: center;
padding: 15px;
border-radius: 6px;
background: var(--bg-light);
}
.stat-value {
font-size: 2rem;
font-weight: 700;
margin-bottom: 5px;
}
.stat-label {
font-size: 0.85rem;
color: #666;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔌 LLM API 連線測試</h1>
<p>HR 基礎資料維護系統 - API 配置檢測</p>
</div>
<div class="content">
<!-- Summary Section -->
<div class="summary">
<h2>📊 API 配置總覽</h2>
<div class="summary-stats">
<div class="stat-box">
<div class="stat-value" id="totalApis">3</div>
<div class="stat-label">API 總數</div>
</div>
<div class="stat-box">
<div class="stat-value" id="configuredApis" style="color: var(--success);">0</div>
<div class="stat-label">已配置</div>
</div>
<div class="stat-box">
<div class="stat-value" id="testedApis" style="color: var(--primary);">0</div>
<div class="stat-label">已測試</div>
</div>
<div class="stat-box">
<div class="stat-value" id="successApis" style="color: var(--success);">0</div>
<div class="stat-label">測試成功</div>
</div>
</div>
</div>
<!-- Test All Button -->
<div class="test-all-section">
<button class="btn btn-success test-all-btn" onclick="testAllAPIs()">
🚀 測試所有已配置的 API
</button>
</div>
<!-- Gemini API Card -->
<div class="api-card" id="gemini-card">
<div class="api-header">
<div class="api-name">🔷 Google Gemini</div>
<div class="status-badge status-pending" id="gemini-status">未測試</div>
</div>
<div class="api-info">
<div class="api-info-label">API Key:</div>
<div class="api-info-value">
<div class="api-key-display" id="gemini-key">
<span class="api-key-masked">••••••••••••••••</span>
</div>
</div>
<div class="api-info-label">Endpoint:</div>
<div class="api-info-value">https://generativelanguage.googleapis.com</div>
<div class="api-info-label">狀態:</div>
<div class="api-info-value" id="gemini-config-status">等待檢測...</div>
</div>
<div class="api-actions">
<button class="btn btn-primary" onclick="testAPI('gemini')" id="gemini-test-btn">
🧪 測試連線
</button>
</div>
<div class="result-message" id="gemini-result"></div>
</div>
<!-- DeepSeek API Card -->
<div class="api-card" id="deepseek-card">
<div class="api-header">
<div class="api-name">🔵 DeepSeek</div>
<div class="status-badge status-pending" id="deepseek-status">未測試</div>
</div>
<div class="api-info">
<div class="api-info-label">API Key:</div>
<div class="api-info-value">
<div class="api-key-display" id="deepseek-key">
<span class="api-key-masked">••••••••••••••••</span>
</div>
</div>
<div class="api-info-label">Endpoint:</div>
<div class="api-info-value">https://api.deepseek.com/v1</div>
<div class="api-info-label">狀態:</div>
<div class="api-info-value" id="deepseek-config-status">等待檢測...</div>
</div>
<div class="api-actions">
<button class="btn btn-primary" onclick="testAPI('deepseek')" id="deepseek-test-btn">
🧪 測試連線
</button>
</div>
<div class="result-message" id="deepseek-result"></div>
</div>
<!-- OpenAI API Card -->
<div class="api-card" id="openai-card">
<div class="api-header">
<div class="api-name">🟢 OpenAI</div>
<div class="status-badge status-pending" id="openai-status">未測試</div>
</div>
<div class="api-info">
<div class="api-info-label">API Key:</div>
<div class="api-info-value">
<div class="api-key-display" id="openai-key">
<span class="api-key-masked">••••••••••••••••</span>
</div>
</div>
<div class="api-info-label">Endpoint:</div>
<div class="api-info-value">https://api.openai.com/v1</div>
<div class="api-info-label">狀態:</div>
<div class="api-info-value" id="openai-config-status">等待檢測...</div>
</div>
<div class="api-actions">
<button class="btn btn-primary" onclick="testAPI('openai')" id="openai-test-btn">
🧪 測試連線
</button>
</div>
<div class="result-message" id="openai-result"></div>
</div>
</div>
</div>
<script>
// Check API configuration on page load
window.addEventListener('DOMContentLoaded', checkAPIConfiguration);
async function checkAPIConfiguration() {
try {
const response = await fetch('/api/llm/config');
const data = await response.json();
let configured = 0;
let tested = 0;
// Update UI for each API
['gemini', 'deepseek', 'openai'].forEach(api => {
const config = data[api];
const isConfigured = config && config.enabled;
if (isConfigured) {
configured++;
document.getElementById(`${api}-config-status`).textContent = '✓ 已配置';
document.getElementById(`${api}-config-status`).style.color = 'var(--success)';
// Show partial API key
if (config.api_key) {
const maskedKey = config.api_key.substring(0, 8) + '••••••••' + config.api_key.substring(config.api_key.length - 4);
document.getElementById(`${api}-key`).innerHTML = `<span>${maskedKey}</span>`;
}
} else {
document.getElementById(`${api}-config-status`).textContent = '✗ 未配置';
document.getElementById(`${api}-config-status`).style.color = 'var(--danger)';
document.getElementById(`${api}-test-btn`).disabled = true;
}
});
// Update summary
document.getElementById('configuredApis').textContent = configured;
} catch (error) {
showError('無法載入 API 配置');
}
}
async function testAPI(apiName) {
const statusBadge = document.getElementById(`${apiName}-status`);
const resultDiv = document.getElementById(`${apiName}-result`);
const testBtn = document.getElementById(`${apiName}-test-btn`);
// Update UI to testing state
statusBadge.className = 'status-badge status-testing';
statusBadge.innerHTML = '測試中<span class="loading-spinner"></span>';
testBtn.disabled = true;
resultDiv.className = 'result-message';
resultDiv.style.display = 'none';
try {
const response = await fetch(`/api/llm/test/${apiName}`);
const data = await response.json();
if (data.success) {
statusBadge.className = 'status-badge status-success';
statusBadge.textContent = '✓ 連線成功';
resultDiv.className = 'result-message result-success show';
resultDiv.textContent = `${data.message}`;
updateSummary('tested', true);
} else {
statusBadge.className = 'status-badge status-error';
statusBadge.textContent = '✗ 連線失敗';
resultDiv.className = 'result-message result-error show';
resultDiv.textContent = `${data.message}`;
updateSummary('tested', false);
}
} catch (error) {
statusBadge.className = 'status-badge status-error';
statusBadge.textContent = '✗ 錯誤';
resultDiv.className = 'result-message result-error show';
resultDiv.textContent = `✗ 發生錯誤: ${error.message}`;
updateSummary('tested', false);
} finally {
testBtn.disabled = false;
}
}
async function testAllAPIs() {
const apis = ['gemini', 'deepseek', 'openai'];
const testBtn = document.querySelector('.test-all-btn');
testBtn.disabled = true;
testBtn.innerHTML = '測試中<span class="loading-spinner"></span>';
for (const api of apis) {
const isEnabled = !document.getElementById(`${api}-test-btn`).disabled;
if (isEnabled) {
await testAPI(api);
// Small delay between tests
await new Promise(resolve => setTimeout(resolve, 500));
}
}
testBtn.disabled = false;
testBtn.innerHTML = '🚀 測試所有已配置的 API';
}
function updateSummary(type, success) {
const testedEl = document.getElementById('testedApis');
const successEl = document.getElementById('successApis');
if (type === 'tested') {
const currentTested = parseInt(testedEl.textContent) || 0;
testedEl.textContent = currentTested + 1;
if (success) {
const currentSuccess = parseInt(successEl.textContent) || 0;
successEl.textContent = currentSuccess + 1;
}
}
}
function showError(message) {
alert(message);
}
</script>
</body>
</html>

259
database_schema.sql Normal file
View File

@@ -0,0 +1,259 @@
-- HR Position System Database Schema
-- Database: db_A102
-- Schema: hr_position_system
-- Create schema if not exists
CREATE SCHEMA IF NOT EXISTS hr_position_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hr_position_system;
-- ============================================================
-- Table: positions (崗位基礎資料)
-- ============================================================
CREATE TABLE IF NOT EXISTS positions (
id VARCHAR(20) PRIMARY KEY COMMENT '崗位編號',
position_code VARCHAR(20) NOT NULL UNIQUE COMMENT '崗位編號',
position_name VARCHAR(100) NOT NULL COMMENT '崗位名稱',
position_category VARCHAR(2) COMMENT '崗位類別代碼',
position_category_name VARCHAR(50) COMMENT '崗位類別名稱',
position_nature VARCHAR(2) COMMENT '崗位性質代碼',
position_nature_name VARCHAR(50) COMMENT '崗位性質名稱',
headcount INT COMMENT '編制人數',
position_level VARCHAR(2) COMMENT '崗位級別',
effective_date DATE COMMENT '生效日期',
position_desc TEXT COMMENT '崗位描述',
position_remark TEXT COMMENT '崗位備注',
-- Recruitment Info
min_education VARCHAR(3) COMMENT '最低學歷',
required_gender VARCHAR(1) COMMENT '要求性別',
salary_range VARCHAR(1) COMMENT '薪酬范圍',
work_experience VARCHAR(2) COMMENT '工作經驗(年)',
min_age INT COMMENT '最小年齡',
max_age INT COMMENT '最大年齡',
job_type VARCHAR(2) COMMENT '工作性質',
recruit_position VARCHAR(3) COMMENT '招聘職位',
job_title VARCHAR(100) COMMENT '職位名稱',
job_desc TEXT COMMENT '職位描述',
position_req TEXT COMMENT '崗位要求',
title_req VARCHAR(4) COMMENT '職稱要求',
major_req VARCHAR(200) COMMENT '專業要求',
skill_req VARCHAR(200) COMMENT '技能要求',
lang_req VARCHAR(100) COMMENT '語言要求',
other_req VARCHAR(200) COMMENT '其他要求',
superior_position VARCHAR(20) COMMENT '上級崗位編號',
recruit_remark TEXT COMMENT '備注說明',
-- Metadata
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
created_by VARCHAR(50) COMMENT '建立人',
updated_by VARCHAR(50) COMMENT '更新人',
INDEX idx_position_code (position_code),
INDEX idx_position_name (position_name),
INDEX idx_position_category (position_category),
INDEX idx_position_level (position_level)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='崗位基礎資料表';
-- ============================================================
-- Table: jobs (職務基礎資料)
-- ============================================================
CREATE TABLE IF NOT EXISTS jobs (
id VARCHAR(20) PRIMARY KEY COMMENT '職務編號',
job_category_code VARCHAR(4) NOT NULL COMMENT '職務類別編號',
job_category_name VARCHAR(50) COMMENT '職務類別名稱',
job_code VARCHAR(20) NOT NULL UNIQUE COMMENT '職務編號',
job_name VARCHAR(100) NOT NULL COMMENT '職務名稱',
job_name_en VARCHAR(100) COMMENT '職務英文名稱',
job_effective_date DATE COMMENT '生效日期',
job_headcount INT COMMENT '編制人數',
job_sort_order INT COMMENT '排列順序',
job_remark TEXT COMMENT '備注說明',
job_level VARCHAR(50) COMMENT '職務層級',
has_attendance_bonus BOOLEAN DEFAULT FALSE COMMENT '是否有全勤',
has_housing_allowance BOOLEAN DEFAULT FALSE COMMENT '是否住房補貼',
-- Metadata
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
created_by VARCHAR(50) COMMENT '建立人',
updated_by VARCHAR(50) COMMENT '更新人',
INDEX idx_job_code (job_code),
INDEX idx_job_name (job_name),
INDEX idx_job_category (job_category_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='職務基礎資料表';
-- ============================================================
-- Table: job_descriptions (崗位描述)
-- ============================================================
CREATE TABLE IF NOT EXISTS job_descriptions (
id INT AUTO_INCREMENT PRIMARY KEY COMMENT '主鍵',
emp_no VARCHAR(20) COMMENT '工號',
emp_name VARCHAR(50) COMMENT '姓名',
position_code VARCHAR(20) COMMENT '崗位代碼',
version_date DATE COMMENT '版本更新日期',
-- Position Info
position_name VARCHAR(100) COMMENT '崗位名稱',
department VARCHAR(100) COMMENT '所屬部門',
position_effective_date DATE COMMENT '崗位生效日期',
direct_supervisor VARCHAR(100) COMMENT '直接領導職務',
position_grade_job VARCHAR(100) COMMENT '崗位職等&職務',
report_to VARCHAR(100) COMMENT '匯報對象職務',
direct_reports VARCHAR(200) COMMENT '直接下級',
work_location VARCHAR(3) COMMENT '任職地點',
emp_attribute VARCHAR(2) COMMENT '員工屬性',
-- Responsibilities
position_purpose VARCHAR(500) COMMENT '崗位設置目的',
main_responsibilities TEXT COMMENT '主要崗位職責',
-- Requirements
education VARCHAR(200) COMMENT '教育程度',
basic_skills TEXT COMMENT '基本技能',
professional_knowledge TEXT COMMENT '專業知識',
work_experience_req TEXT COMMENT '工作經驗',
other_requirements TEXT COMMENT '其他要求',
-- Metadata
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
created_by VARCHAR(50) COMMENT '建立人',
updated_by VARCHAR(50) COMMENT '更新人',
INDEX idx_emp_no (emp_no),
INDEX idx_position_code (position_code),
INDEX idx_version_date (version_date),
FOREIGN KEY (position_code) REFERENCES positions(position_code) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='崗位描述表';
-- ============================================================
-- Table: reference_codes (參照資料代碼表)
-- ============================================================
CREATE TABLE IF NOT EXISTS reference_codes (
id INT AUTO_INCREMENT PRIMARY KEY COMMENT '主鍵',
code_type VARCHAR(50) NOT NULL COMMENT '代碼類型',
code_value VARCHAR(10) NOT NULL COMMENT '代碼值',
code_name VARCHAR(100) NOT NULL COMMENT '代碼名稱',
code_name_en VARCHAR(100) COMMENT '英文名稱',
sort_order INT DEFAULT 0 COMMENT '排序',
is_active BOOLEAN DEFAULT TRUE COMMENT '是否啟用',
remark VARCHAR(200) COMMENT '備注',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
UNIQUE KEY uk_code_type_value (code_type, code_value),
INDEX idx_code_type (code_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='參照資料代碼表';
-- ============================================================
-- Table: audit_logs (審計日誌)
-- ============================================================
CREATE TABLE IF NOT EXISTS audit_logs (
id INT AUTO_INCREMENT PRIMARY KEY COMMENT '主鍵',
action VARCHAR(20) NOT NULL COMMENT '操作類型(CREATE/UPDATE/DELETE)',
entity_type VARCHAR(50) NOT NULL COMMENT '實體類型',
entity_id VARCHAR(50) NOT NULL COMMENT '實體ID',
old_data JSON COMMENT '變更前資料',
new_data JSON COMMENT '變更後資料',
user_id VARCHAR(50) COMMENT '操作用戶',
ip_address VARCHAR(50) COMMENT 'IP地址',
user_agent VARCHAR(200) COMMENT '用戶代理',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '操作時間',
INDEX idx_entity_type_id (entity_type, entity_id),
INDEX idx_user_id (user_id),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='審計日誌表';
-- ============================================================
-- Insert Reference Data (參照資料初始化)
-- ============================================================
-- 崗位類別 (Position Category)
INSERT INTO reference_codes (code_type, code_value, code_name, sort_order) VALUES
('POSITION_CATEGORY', '01', '技術職', 1),
('POSITION_CATEGORY', '02', '管理職', 2),
('POSITION_CATEGORY', '03', '業務職', 3),
('POSITION_CATEGORY', '04', '行政職', 4);
-- 崗位性質 (Position Nature)
INSERT INTO reference_codes (code_type, code_value, code_name, code_name_en, sort_order) VALUES
('POSITION_NATURE', 'FT', '全職', 'Full-time', 1),
('POSITION_NATURE', 'PT', '兼職', 'Part-time', 2),
('POSITION_NATURE', 'CT', '約聘', 'Contract', 3),
('POSITION_NATURE', 'IN', '實習', 'Intern', 4);
-- 崗位級別 (Position Level)
INSERT INTO reference_codes (code_type, code_value, code_name, sort_order) VALUES
('POSITION_LEVEL', 'L1', '基層員工', 1),
('POSITION_LEVEL', 'L2', '資深員工', 2),
('POSITION_LEVEL', 'L3', '主管', 3),
('POSITION_LEVEL', 'L4', '經理', 4),
('POSITION_LEVEL', 'L5', '總監', 5),
('POSITION_LEVEL', 'L6', '副總', 6),
('POSITION_LEVEL', 'L7', '總經理', 7);
-- 職務類別 (Job Category)
INSERT INTO reference_codes (code_type, code_value, code_name, sort_order) VALUES
('JOB_CATEGORY', 'MGR', '管理職', 1),
('JOB_CATEGORY', 'TECH', '技術職', 2),
('JOB_CATEGORY', 'SALE', '業務職', 3),
('JOB_CATEGORY', 'ADMIN', '行政職', 4),
('JOB_CATEGORY', 'RD', '研發職', 5),
('JOB_CATEGORY', 'PROD', '生產職', 6);
-- 學歷 (Education)
INSERT INTO reference_codes (code_type, code_value, code_name, code_name_en, sort_order) VALUES
('EDUCATION', 'HS', '高中', 'High School', 1),
('EDUCATION', 'JC', '專科', 'Junior College', 2),
('EDUCATION', 'BA', '大學', 'Bachelor', 3),
('EDUCATION', 'MA', '碩士', 'Master', 4),
('EDUCATION', 'PHD', '博士', 'PhD', 5);
-- 薪酬范圍 (Salary Range)
INSERT INTO reference_codes (code_type, code_value, code_name, sort_order) VALUES
('SALARY_RANGE', 'A', 'A級', 1),
('SALARY_RANGE', 'B', 'B級', 2),
('SALARY_RANGE', 'C', 'C級', 3),
('SALARY_RANGE', 'D', 'D級', 4),
('SALARY_RANGE', 'E', 'E級', 5),
('SALARY_RANGE', 'N', '面議', 6);
-- 任職地點 (Work Location)
INSERT INTO reference_codes (code_type, code_value, code_name, sort_order) VALUES
('WORK_LOCATION', 'HQ', '總部', 1),
('WORK_LOCATION', 'TPE', '台北辦公室', 2),
('WORK_LOCATION', 'TYC', '桃園廠區', 3),
('WORK_LOCATION', 'KHH', '高雄廠區', 4),
('WORK_LOCATION', 'SH', '上海辦公室', 5),
('WORK_LOCATION', 'SZ', '深圳辦公室', 6);
-- 員工屬性 (Employee Attribute)
INSERT INTO reference_codes (code_type, code_value, code_name, sort_order) VALUES
('EMP_ATTRIBUTE', 'FT', '正式員工', 1),
('EMP_ATTRIBUTE', 'CT', '約聘人員', 2),
('EMP_ATTRIBUTE', 'PT', '兼職人員', 3),
('EMP_ATTRIBUTE', 'IN', '實習生', 4),
('EMP_ATTRIBUTE', 'DP', '派遣人員', 5);
-- 招聘職位 (Recruit Position)
INSERT INTO reference_codes (code_type, code_value, code_name, sort_order) VALUES
('RECRUIT_POSITION', 'ENG', '工程師', 1),
('RECRUIT_POSITION', 'MGR', '經理', 2),
('RECRUIT_POSITION', 'AST', '助理', 3),
('RECRUIT_POSITION', 'OP', '操作員', 4),
('RECRUIT_POSITION', 'SAL', '業務', 5);
-- 職稱要求 (Title Requirement)
INSERT INTO reference_codes (code_type, code_value, code_name, sort_order) VALUES
('TITLE_REQ', 'NONE', '無要求', 1),
('TITLE_REQ', 'CERT', '需證書', 2),
('TITLE_REQ', 'LIC', '需執照', 3);
-- ============================================================
-- End of Schema
-- ============================================================

559
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;
}

2184
index.html Normal file

File diff suppressed because it is too large Load Diff

130
init_database.py Normal file
View File

@@ -0,0 +1,130 @@
"""
Database initialization script for HR Position System
Connects to MySQL and executes the schema creation script
"""
import os
import pymysql
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
def init_database():
"""Initialize the database schema"""
# Database connection parameters
db_config = {
'host': os.getenv('DB_HOST'),
'port': int(os.getenv('DB_PORT')),
'user': os.getenv('DB_USER'),
'password': os.getenv('DB_PASSWORD'),
'database': os.getenv('DB_NAME'),
'charset': 'utf8mb4',
'cursorclass': pymysql.cursors.DictCursor
}
print(f"Connecting to database: {db_config['host']}:{db_config['port']}")
print(f"Database: {db_config['database']}")
try:
# Connect to MySQL
connection = pymysql.connect(**db_config)
print("✓ Successfully connected to database")
# Read SQL schema file
schema_file = os.path.join(os.path.dirname(__file__), 'database_schema.sql')
with open(schema_file, 'r', encoding='utf-8') as f:
sql_script = f.read()
# Split SQL script by semicolon and execute each statement
cursor = connection.cursor()
# Split by semicolon and filter out empty statements
statements = [stmt.strip() for stmt in sql_script.split(';') if stmt.strip()]
print(f"\nExecuting {len(statements)} SQL statements...")
executed = 0
for i, statement in enumerate(statements, 1):
try:
# Skip comments and empty lines
if not statement or statement.startswith('--'):
continue
cursor.execute(statement)
executed += 1
# Show progress every 10 statements
if executed % 10 == 0:
print(f" Executed {executed} statements...")
except Exception as e:
print(f" Warning on statement {i}: {str(e)[:100]}")
continue
# Commit changes
connection.commit()
print(f"\n✓ Successfully executed {executed} SQL statements")
# Verify tables were created
cursor.execute("""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'hr_position_system'
ORDER BY table_name
""")
tables = cursor.fetchall()
print(f"\n✓ Created {len(tables)} tables:")
for table in tables:
print(f" - {table['table_name']}")
# Close connection
cursor.close()
connection.close()
print("\n✓ Database initialization completed successfully!")
return True
except Exception as e:
print(f"\n✗ Error initializing database: {str(e)}")
return False
def test_connection():
"""Test database connection"""
db_config = {
'host': os.getenv('DB_HOST'),
'port': int(os.getenv('DB_PORT')),
'user': os.getenv('DB_USER'),
'password': os.getenv('DB_PASSWORD'),
'database': os.getenv('DB_NAME')
}
try:
connection = pymysql.connect(**db_config)
print("✓ Database connection test successful")
connection.close()
return True
except Exception as e:
print(f"✗ Database connection test failed: {str(e)}")
return False
if __name__ == '__main__':
print("=" * 60)
print("HR Position System - Database Initialization")
print("=" * 60)
print()
# Test connection first
print("Step 1: Testing database connection...")
if not test_connection():
print("\nPlease check your database configuration in .env file")
exit(1)
print("\nStep 2: Initializing database schema...")
if init_database():
print("\nDatabase is ready to use!")
else:
print("\nDatabase initialization failed. Please check the errors above.")
exit(1)

308
llm_config.py Normal file
View File

@@ -0,0 +1,308 @@
"""
LLM API Configuration Module
Supports Gemini, DeepSeek, and OpenAI APIs with connection testing
"""
import os
import requests
from dotenv import load_dotenv
from typing import Dict, List, Tuple
# Load environment variables
load_dotenv()
class LLMConfig:
"""LLM API configuration and management"""
def __init__(self):
self.apis = {
'gemini': {
'name': 'Google Gemini',
'api_key': os.getenv('GEMINI_API_KEY', ''),
'endpoint': 'https://generativelanguage.googleapis.com/v1/models',
'enabled': bool(os.getenv('GEMINI_API_KEY'))
},
'deepseek': {
'name': 'DeepSeek',
'api_key': os.getenv('DEEPSEEK_API_KEY', ''),
'endpoint': os.getenv('DEEPSEEK_API_URL', 'https://api.deepseek.com/v1'),
'enabled': bool(os.getenv('DEEPSEEK_API_KEY'))
},
'openai': {
'name': 'OpenAI',
'api_key': os.getenv('OPENAI_API_KEY', ''),
'endpoint': os.getenv('OPENAI_API_URL', 'https://api.openai.com/v1'),
'enabled': bool(os.getenv('OPENAI_API_KEY'))
}
}
def get_enabled_apis(self) -> List[str]:
"""Get list of enabled APIs"""
return [key for key, config in self.apis.items() if config['enabled']]
def get_api_config(self, api_name: str) -> Dict:
"""Get configuration for specific API"""
return self.apis.get(api_name, {})
def test_gemini_connection(self) -> Tuple[bool, str]:
"""Test Gemini API connection"""
try:
api_key = self.apis['gemini']['api_key']
if not api_key:
return False, "API Key 未設定"
# Test endpoint - list models
url = f"https://generativelanguage.googleapis.com/v1beta/models?key={api_key}"
response = requests.get(url, timeout=10)
if response.status_code == 200:
data = response.json()
models = data.get('models', [])
model_count = len(models)
return True, f"連線成功!找到 {model_count} 個可用模型"
elif response.status_code == 400:
return False, "API Key 格式錯誤"
elif response.status_code == 403:
return False, "API Key 無效或權限不足"
else:
return False, f"連線失敗 (HTTP {response.status_code})"
except requests.exceptions.Timeout:
return False, "連線逾時"
except requests.exceptions.ConnectionError:
return False, "無法連接到伺服器"
except Exception as e:
return False, f"錯誤: {str(e)}"
def test_deepseek_connection(self) -> Tuple[bool, str]:
"""Test DeepSeek API connection"""
try:
api_key = self.apis['deepseek']['api_key']
endpoint = self.apis['deepseek']['endpoint']
if not api_key:
return False, "API Key 未設定"
# Test endpoint - list models
url = f"{endpoint}/models"
headers = {
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json'
}
response = requests.get(url, headers=headers, timeout=10)
if response.status_code == 200:
data = response.json()
models = data.get('data', [])
if models:
model_count = len(models)
return True, f"連線成功!找到 {model_count} 個可用模型"
else:
return True, "連線成功!"
elif response.status_code == 401:
return False, "API Key 無效"
elif response.status_code == 403:
return False, "權限不足"
else:
return False, f"連線失敗 (HTTP {response.status_code})"
except requests.exceptions.Timeout:
return False, "連線逾時"
except requests.exceptions.ConnectionError:
return False, "無法連接到伺服器"
except Exception as e:
return False, f"錯誤: {str(e)}"
def test_openai_connection(self) -> Tuple[bool, str]:
"""Test OpenAI API connection"""
try:
api_key = self.apis['openai']['api_key']
endpoint = self.apis['openai']['endpoint']
if not api_key:
return False, "API Key 未設定"
# Test endpoint - list models
url = f"{endpoint}/models"
headers = {
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json'
}
response = requests.get(url, headers=headers, timeout=10)
if response.status_code == 200:
data = response.json()
models = data.get('data', [])
model_count = len(models)
return True, f"連線成功!找到 {model_count} 個可用模型"
elif response.status_code == 401:
return False, "API Key 無效"
elif response.status_code == 403:
return False, "權限不足"
else:
return False, f"連線失敗 (HTTP {response.status_code})"
except requests.exceptions.Timeout:
return False, "連線逾時"
except requests.exceptions.ConnectionError:
return False, "無法連接到伺服器"
except Exception as e:
return False, f"錯誤: {str(e)}"
def test_all_connections(self) -> Dict[str, Tuple[bool, str]]:
"""Test all configured API connections"""
results = {}
if self.apis['gemini']['enabled']:
results['gemini'] = self.test_gemini_connection()
if self.apis['deepseek']['enabled']:
results['deepseek'] = self.test_deepseek_connection()
if self.apis['openai']['enabled']:
results['openai'] = self.test_openai_connection()
return results
def generate_text_gemini(self, prompt: str, max_tokens: int = 2000) -> Tuple[bool, str]:
"""Generate text using Gemini API"""
try:
api_key = self.apis['gemini']['api_key']
if not api_key:
return False, "API Key 未設定"
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={api_key}"
data = {
"contents": [
{
"parts": [
{"text": prompt}
]
}
],
"generationConfig": {
"maxOutputTokens": max_tokens,
"temperature": 0.7
}
}
response = requests.post(url, json=data, timeout=30)
if response.status_code == 200:
result = response.json()
text = result['candidates'][0]['content']['parts'][0]['text']
return True, text
else:
return False, f"生成失敗 (HTTP {response.status_code}): {response.text}"
except Exception as e:
return False, f"錯誤: {str(e)}"
def generate_text_deepseek(self, prompt: str, max_tokens: int = 2000) -> Tuple[bool, str]:
"""Generate text using DeepSeek API"""
try:
api_key = self.apis['deepseek']['api_key']
endpoint = self.apis['deepseek']['endpoint']
if not api_key:
return False, "API Key 未設定"
url = f"{endpoint}/chat/completions"
headers = {
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json'
}
data = {
"model": "deepseek-chat",
"messages": [
{"role": "user", "content": prompt}
],
"max_tokens": max_tokens,
"temperature": 0.7
}
response = requests.post(url, json=data, headers=headers, timeout=30)
if response.status_code == 200:
result = response.json()
text = result['choices'][0]['message']['content']
return True, text
else:
return False, f"生成失敗 (HTTP {response.status_code}): {response.text}"
except Exception as e:
return False, f"錯誤: {str(e)}"
def generate_text_openai(self, prompt: str, model: str = "gpt-3.5-turbo", max_tokens: int = 2000) -> Tuple[bool, str]:
"""Generate text using OpenAI API"""
try:
api_key = self.apis['openai']['api_key']
endpoint = self.apis['openai']['endpoint']
if not api_key:
return False, "API Key 未設定"
url = f"{endpoint}/chat/completions"
headers = {
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json'
}
data = {
"model": model,
"messages": [
{"role": "user", "content": prompt}
],
"max_tokens": max_tokens,
"temperature": 0.7
}
response = requests.post(url, json=data, headers=headers, timeout=30)
if response.status_code == 200:
result = response.json()
text = result['choices'][0]['message']['content']
return True, text
else:
return False, f"生成失敗 (HTTP {response.status_code}): {response.text}"
except Exception as e:
return False, f"錯誤: {str(e)}"
def main():
"""Test script"""
print("=" * 60)
print("LLM API Configuration Test")
print("=" * 60)
print()
config = LLMConfig()
# Show enabled APIs
enabled = config.get_enabled_apis()
print(f"已啟用的 API: {', '.join(enabled) if enabled else ''}")
print()
# Test all connections
print("測試 API 連線:")
print("-" * 60)
results = config.test_all_connections()
for api_name, (success, message) in results.items():
status = "" if success else ""
api_display_name = config.apis[api_name]['name']
print(f"{status} {api_display_name}: {message}")
print()
if __name__ == '__main__':
main()

23
requirements_full.txt Normal file
View File

@@ -0,0 +1,23 @@
# Flask and Web Framework
Flask==3.0.0
flask-cors==4.0.0
Werkzeug==3.0.1
# Database
pymysql==1.1.0
cryptography==41.0.7
# Environment Variables
python-dotenv==1.0.0
# HTTP Requests
requests==2.31.0
# Version Control
gitpython==3.1.40
# Development Tools
pytest==7.4.3
pytest-flask==1.3.0
black==23.12.1
flake8==7.0.0

286
start_server.py Normal file
View File

@@ -0,0 +1,286 @@
"""
啟動 Flask 服務器(解決 Windows 編碼問題)
"""
import sys
import os
# 設置 UTF-8 編碼
if sys.platform == 'win32':
import codecs
sys.stdout = codecs.getwriter('utf-8')(sys.stdout.buffer, 'strict')
sys.stderr = codecs.getwriter('utf-8')(sys.stderr.buffer, 'strict')
# 導入並啟動 app
from flask import Flask, request, jsonify, send_from_directory
from flask_cors import CORS
from datetime import datetime
import json
# Import LLM configuration
try:
from llm_config import LLMConfig
llm_config = LLMConfig()
LLM_ENABLED = True
except ImportError:
print("Warning: llm_config not found. LLM features will be disabled.")
LLM_ENABLED = False
app = Flask(__name__, static_folder='.')
CORS(app)
# 模擬資料庫
positions_db = {}
jobs_db = {}
# 預設崗位資料
default_positions = {
"MGR-001": {
"id": "MGR-001",
"basicInfo": {
"positionCode": "MGR-001",
"positionName": "管理職-資深班長",
"positionCategory": "02",
"positionCategoryName": "管理職",
"positionNature": "FT",
"positionNatureName": "全職",
"headcount": "5",
"positionLevel": "L3",
"effectiveDate": "2001-01-01",
"positionDesc": "負責生產線的日常管理與人員調度",
"positionRemark": ""
},
"recruitInfo": {
"minEducation": "JC",
"requiredGender": "",
"salaryRange": "C",
"workExperience": "3",
"minAge": "25",
"maxAge": "45",
"jobType": "FT",
"recruitPosition": "MGR",
"jobTitle": "資深班長",
"jobDesc": "",
"positionReq": "",
"titleReq": "",
"majorReq": "",
"skillReq": "",
"langReq": "",
"otherReq": "",
"superiorPosition": "",
"recruitRemark": ""
},
"createdAt": "2024-01-01T00:00:00",
"updatedAt": "2024-01-01T00:00:00"
}
}
positions_db.update(default_positions)
# 預設職務資料
default_jobs = {
"VP-001": {
"id": "VP-001",
"jobCategoryCode": "MGR",
"jobCategoryName": "管理職",
"jobCode": "VP-001",
"jobName": "副總",
"jobNameEn": "Vice President",
"jobEffectiveDate": "2001-01-01",
"jobHeadcount": 2,
"jobSortOrder": 10,
"jobRemark": "",
"jobLevel": "*保密*",
"hasAttendanceBonus": False,
"hasHousingAllowance": True,
"createdAt": "2024-01-01T00:00:00",
"updatedAt": "2024-01-01T00:00:00"
}
}
jobs_db.update(default_jobs)
# ==================== 靜態頁面 ====================
@app.route('/')
def index():
return send_from_directory('.', 'index.html')
@app.route('/api-test')
def api_test_page():
return send_from_directory('.', 'api_test.html')
# ==================== 崗位資料 API ====================
@app.route('/api/positions', methods=['GET'])
def get_positions():
page = request.args.get('page', 1, type=int)
size = request.args.get('size', 20, type=int)
search = request.args.get('search', '', type=str)
filtered = list(positions_db.values())
if search:
filtered = [p for p in filtered
if search.lower() in p['basicInfo'].get('positionCode', '').lower()
or search.lower() in p['basicInfo'].get('positionName', '').lower()]
total = len(filtered)
start = (page - 1) * size
end = start + size
paginated = filtered[start:end]
return jsonify({
'success': True,
'data': paginated,
'pagination': {
'page': page,
'size': size,
'total': total,
'totalPages': (total + size - 1) // size
}
})
@app.route('/api/positions/<position_id>', methods=['GET'])
def get_position(position_id):
if position_id not in positions_db:
return jsonify({'success': False, 'error': '找不到該崗位資料'}), 404
return jsonify({'success': True, 'data': positions_db[position_id]})
@app.route('/api/positions', methods=['POST'])
def create_position():
try:
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': '請提供有效的 JSON 資料'}), 400
basic_info = data.get('basicInfo', {})
if not basic_info.get('positionCode'):
return jsonify({'success': False, 'error': '崗位編號為必填欄位'}), 400
if not basic_info.get('positionName'):
return jsonify({'success': False, 'error': '崗位名稱為必填欄位'}), 400
position_code = basic_info['positionCode']
if position_code in positions_db:
return jsonify({'success': False, 'error': f'崗位編號 {position_code} 已存在'}), 409
now = datetime.now().isoformat()
new_position = {
'id': position_code,
'basicInfo': basic_info,
'recruitInfo': data.get('recruitInfo', {}),
'createdAt': now,
'updatedAt': now
}
positions_db[position_code] = new_position
return jsonify({'success': True, 'message': '崗位資料新增成功', 'data': new_position}), 201
except Exception as e:
return jsonify({'success': False, 'error': f'新增失敗: {str(e)}'}), 500
# ==================== LLM API ====================
@app.route('/api/llm/config', methods=['GET'])
def get_llm_config():
if not LLM_ENABLED:
return jsonify({'success': False, 'error': 'LLM 功能未啟用'}), 503
try:
config_data = {}
for api_name, api_config in llm_config.apis.items():
config_data[api_name] = {
'name': api_config['name'],
'enabled': api_config['enabled'],
'endpoint': api_config['endpoint'],
'api_key': api_config['api_key'][:8] + '...' if api_config['api_key'] else ''
}
return jsonify(config_data)
except Exception as e:
return jsonify({'success': False, 'error': f'獲取配置失敗: {str(e)}'}), 500
@app.route('/api/llm/test/<api_name>', methods=['GET'])
def test_llm_api(api_name):
if not LLM_ENABLED:
return jsonify({'success': False, 'message': 'LLM 功能未啟用'}), 503
try:
if api_name not in llm_config.apis:
return jsonify({'success': False, 'message': f'不支援的 API: {api_name}'}), 400
if api_name == 'gemini':
success, message = llm_config.test_gemini_connection()
elif api_name == 'deepseek':
success, message = llm_config.test_deepseek_connection()
elif api_name == 'openai':
success, message = llm_config.test_openai_connection()
else:
return jsonify({'success': False, 'message': f'未實作的 API: {api_name}'}), 400
return jsonify({'success': success, 'message': message})
except Exception as e:
return jsonify({'success': False, 'message': f'測試失敗: {str(e)}'}), 500
@app.route('/api/llm/generate', methods=['POST'])
def generate_llm_text():
if not LLM_ENABLED:
return jsonify({'success': False, 'error': 'LLM 功能未啟用'}), 503
try:
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': '請提供有效的 JSON 資料'}), 400
api_name = data.get('api', 'gemini')
prompt = data.get('prompt', '')
max_tokens = data.get('max_tokens', 2000)
if not prompt:
return jsonify({'success': False, 'error': '請提供提示詞'}), 400
if api_name == 'gemini':
success, result = llm_config.generate_text_gemini(prompt, max_tokens)
elif api_name == 'deepseek':
success, result = llm_config.generate_text_deepseek(prompt, max_tokens)
elif api_name == 'openai':
model = data.get('model', 'gpt-3.5-turbo')
success, result = llm_config.generate_text_openai(prompt, model, max_tokens)
else:
return jsonify({'success': False, 'error': f'不支援的 API: {api_name}'}), 400
if success:
return jsonify({'success': True, 'text': result})
else:
return jsonify({'success': False, 'error': result}), 500
except Exception as e:
return jsonify({'success': False, 'error': f'生成失敗: {str(e)}'}), 500
# ==================== 錯誤處理 ====================
@app.errorhandler(404)
def not_found(e):
return jsonify({'success': False, 'error': '找不到請求的資源'}), 404
@app.errorhandler(500)
def server_error(e):
return jsonify({'success': False, 'error': '伺服器內部錯誤'}), 500
# ==================== 主程式 ====================
if __name__ == '__main__':
print("=" * 60)
print("HR Position System - Flask Backend")
print("=" * 60)
print("\nServer starting...")
print("URL: http://localhost:5000")
print()
if LLM_ENABLED:
print("[OK] LLM features enabled")
enabled_apis = llm_config.get_enabled_apis()
if enabled_apis:
print(f"Configured APIs: {', '.join(enabled_apis)}")
else:
print("Warning: No LLM API Keys configured")
else:
print("[!] LLM features disabled (llm_config.py not found)")
print("\nPress Ctrl+C to stop")
print("=" * 60)
print()
app.run(host='0.0.0.0', port=5000, debug=True)