diff --git a/routes/admin.js b/routes/admin.js index e723ef0..57b742f 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -219,7 +219,7 @@ router.get('/analyses', requireAdmin, asyncHandler(async (req, res) => { * GET /api/admin/audit-logs * 取得稽核日誌 */ -router.get('/audit-logs', requireAdmin, asyncHandler(async (req, res) => { +router.get('/audit-logs', requireSuperAdmin, asyncHandler(async (req, res) => { const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 50; const filters = { @@ -243,7 +243,7 @@ router.get('/audit-logs', requireAdmin, asyncHandler(async (req, res) => { * GET /api/admin/statistics * 取得完整統計資料 */ -router.get('/statistics', requireAdmin, asyncHandler(async (req, res) => { +router.get('/statistics', requireSuperAdmin, asyncHandler(async (req, res) => { const overallStats = await Analysis.getStatistics(); const users = await User.getAll(1, 1000); diff --git a/routes/analyze.js b/routes/analyze.js index e05a968..41e8334 100644 --- a/routes/analyze.js +++ b/routes/analyze.js @@ -13,7 +13,7 @@ const router = express.Router(); */ async function getActiveLLMConfig() { const [config] = await query( - `SELECT provider_name, api_endpoint, api_key, model_name, temperature, max_tokens, timeout_seconds + `SELECT provider, api_url, api_key, model_name, temperature, max_tokens, timeout FROM llm_configs WHERE is_active = 1 LIMIT 1` @@ -22,13 +22,13 @@ async function getActiveLLMConfig() { // 如果沒有資料庫配置,使用環境變數的 Ollama 配置 if (!config) { return { - provider_name: 'Ollama', - api_endpoint: ollamaConfig.apiUrl, + provider: 'Ollama', + api_url: ollamaConfig.apiUrl, api_key: null, model_name: ollamaConfig.model, temperature: ollamaConfig.temperature, max_tokens: ollamaConfig.maxTokens, - timeout_seconds: ollamaConfig.timeout / 1000 + timeout: ollamaConfig.timeout }; } @@ -52,13 +52,14 @@ router.post('/', requireAuth, asyncHandler(async (req, res) => { } const startTime = Date.now(); + let analysis = null; try { // 取得啟用的 LLM 配置 const llmConfig = await getActiveLLMConfig(); // 建立分析記錄 - const analysis = await Analysis.create({ + analysis = await Analysis.create({ user_id: userId, finding, job_content: jobContent, @@ -121,7 +122,7 @@ router.post('/', requireAuth, asyncHandler(async (req, res) => { 注意: - 5 Why 的目的不是「湊滿五個問題」,而是穿透表面症狀直達根本原因 -- 若在第 3 或第 4 個 Why 就已找到真正的根本原因,可以停止(設為 null) +- 若在第 3 或第 4 個 Why 就已找到真正的根本原因,可以停止 - 每個 Why 必須標註是「已驗證事實」還是「待驗證假設」 - 最終對策必須是「永久性對策」 @@ -159,26 +160,42 @@ router.post('/', requireAuth, asyncHandler(async (req, res) => { }`; // 呼叫 LLM API(支援 DeepSeek, Ollama 等) + // DeepSeek 限制 max_tokens 最大為 8192,確保不超過 + const effectiveMaxTokens = Math.min( + Math.max(parseInt(llmConfig.max_tokens) || 4000, 4000), + 8000 // DeepSeek 最大限制 + ); + const effectiveTemperature = parseFloat(llmConfig.temperature) || 0.7; + console.log('Using max_tokens:', effectiveMaxTokens, 'temperature:', effectiveTemperature); + const response = await axios.post( - `${llmConfig.api_endpoint}/v1/chat/completions`, + `${llmConfig.api_url}/v1/chat/completions`, { model: llmConfig.model_name, messages: [ { role: 'system', - content: 'You are an expert consultant specializing in 5 Why root cause analysis. You always respond in valid JSON format without any markdown code blocks.' + content: `你是 5 Why 根因分析專家。 + +重要規則: +1. 只回覆 JSON,不要任何其他文字 +2. 不要使用 markdown 代碼塊 +3. 直接以 { 開頭,以 } 結尾 +4. 確保 JSON 格式正確完整 +5. analyses 陣列必須包含 3 個分析角度 +6. 每個角度的 whys 陣列包含 3-5 個 why` }, { role: 'user', content: prompt } ], - temperature: llmConfig.temperature, - max_tokens: llmConfig.max_tokens, + temperature: effectiveTemperature, + max_tokens: effectiveMaxTokens, stream: false }, { - timeout: llmConfig.timeout_seconds * 1000, + timeout: llmConfig.timeout, headers: { 'Content-Type': 'application/json', ...(llmConfig.api_key && { 'Authorization': `Bearer ${llmConfig.api_key}` }) @@ -188,12 +205,97 @@ router.post('/', requireAuth, asyncHandler(async (req, res) => { // 處理回應 if (!response.data || !response.data.choices || !response.data.choices[0]) { - throw new Error(`Invalid response from ${llmConfig.provider_name} API`); + throw new Error(`Invalid response from ${llmConfig.provider} API`); } const content = response.data.choices[0].message.content; - const cleanContent = content.replace(/```json|```/g, '').trim(); - const result = JSON.parse(cleanContent); + console.log('LLM Response length:', content.length); + console.log('LLM Response (first 500 chars):', content.substring(0, 500)); + + // 清理回應內容 + let cleanContent = content + .replace(/```json\s*/gi, '') + .replace(/```\s*/g, '') + .replace(/<\|[^|]*\|>/g, '') // 移除 <|channel|> 等特殊標記 + .replace(/[\s\S]*?<\/think>/gi, '') // 移除思考過程 + .replace(/^[\s\S]*?(?=\{)/m, '') // 移除 JSON 之前的所有內容 + .trim(); + + // 找到 JSON 開始和結束位置 + const jsonStart = cleanContent.indexOf('{'); + const jsonEnd = cleanContent.lastIndexOf('}'); + + if (jsonStart === -1) { + console.error('No JSON found in response:', cleanContent.substring(0, 500)); + throw new Error('LLM 回應格式錯誤,無法找到 JSON'); + } + + // 提取 JSON 部分 + cleanContent = cleanContent.substring(jsonStart, jsonEnd + 1); + console.log('Extracted JSON length:', cleanContent.length); + + // 嘗試解析 JSON + let result; + try { + result = JSON.parse(cleanContent); + } catch (parseError) { + console.log('JSON parse failed:', parseError.message); + console.log('Attempting to fix JSON...'); + + // 嘗試修復常見問題 + let fixedContent = cleanContent + // 修復未轉義的換行符 + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') + // 修復尾部逗號 + .replace(/,(\s*[\}\]])/g, '$1') + // 修復缺少逗號 + .replace(/"\s*\n\s*"/g, '",\n"') + .replace(/\}\s*\{/g, '},{') + .replace(/\]\s*\[/g, '],['); + + try { + result = JSON.parse(fixedContent); + console.log('Fixed JSON parse successful'); + } catch (fixError) { + // 最後嘗試:用更激進的方式修復 + console.log('Aggressive fix attempt...'); + + // 計算括號平衡 + let braces = 0, brackets = 0, inStr = false, escape = false; + for (const c of fixedContent) { + if (escape) { escape = false; continue; } + if (c === '\\') { escape = true; continue; } + if (c === '"') { inStr = !inStr; continue; } + if (!inStr) { + if (c === '{') braces++; + else if (c === '}') braces--; + else if (c === '[') brackets++; + else if (c === ']') brackets--; + } + } + + // 嘗試補上缺少的括號 + fixedContent = fixedContent.replace(/,\s*$/, ''); + while (brackets > 0) { fixedContent += ']'; brackets--; } + while (braces > 0) { fixedContent += '}'; braces--; } + + try { + result = JSON.parse(fixedContent); + console.log('Aggressive fix successful'); + } catch (finalError) { + console.error('All JSON fix attempts failed'); + console.error('Original content (first 1000):', cleanContent.substring(0, 1000)); + throw new Error(`JSON 解析失敗。請重試或簡化輸入內容。`); + } + } + } + + // 驗證結果結構 + if (!result.problemRestatement || !result.analyses || !Array.isArray(result.analyses)) { + throw new Error('LLM 回應缺少必要欄位 (problemRestatement 或 analyses)'); + } // 計算處理時間 const processingTime = Math.floor((Date.now() - startTime) / 1000); @@ -295,7 +397,7 @@ ${JSON.stringify(analysis.analysis_result, null, 2)} }`; const response = await axios.post( - `${llmConfig.api_endpoint}/v1/chat/completions`, + `${llmConfig.api_url}/v1/chat/completions`, { model: llmConfig.model_name, messages: [ @@ -313,7 +415,7 @@ ${JSON.stringify(analysis.analysis_result, null, 2)} stream: false }, { - timeout: llmConfig.timeout_seconds * 1000, + timeout: llmConfig.timeout, headers: { 'Content-Type': 'application/json', ...(llmConfig.api_key && { 'Authorization': `Bearer ${llmConfig.api_key}` }) @@ -322,7 +424,22 @@ ${JSON.stringify(analysis.analysis_result, null, 2)} ); const content = response.data.choices[0].message.content; - const cleanContent = content.replace(/```json|```/g, '').trim(); + + // 清理回應內容,移除 markdown 代碼塊標記和特殊標記 + let cleanContent = content + .replace(/```json\s*/gi, '') + .replace(/```\s*/g, '') + .replace(/<\|[^|]*\|>/g, '') + .replace(/^[^{]*/, '') + .trim(); + + // 嘗試提取 JSON 對象 + const jsonMatch = cleanContent.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + throw new Error('翻譯結果格式錯誤'); + } + cleanContent = jsonMatch[0]; + const result = JSON.parse(cleanContent); res.json({ diff --git a/routes/llmConfig.js b/routes/llmConfig.js index dfefdb0..40a1fb5 100644 --- a/routes/llmConfig.js +++ b/routes/llmConfig.js @@ -1,7 +1,7 @@ import express from 'express'; import { query } from '../config.js'; import { asyncHandler } from '../middleware/errorHandler.js'; -import { requireAuth, requireAdmin } from '../middleware/auth.js'; +import { requireAuth, requireSuperAdmin } from '../middleware/auth.js'; import AuditLog from '../models/AuditLog.js'; const router = express.Router(); @@ -12,7 +12,7 @@ const router = express.Router(); */ router.get('/', requireAuth, asyncHandler(async (req, res) => { const configs = await query( - `SELECT id, provider_name, model_name, is_active, created_at, updated_at + `SELECT id, provider, api_url, model_name, is_active, created_at, updated_at FROM llm_configs ORDER BY is_active DESC, created_at DESC` ); @@ -29,7 +29,7 @@ router.get('/', requireAuth, asyncHandler(async (req, res) => { */ router.get('/active', requireAuth, asyncHandler(async (req, res) => { const [config] = await query( - `SELECT id, provider_name, api_endpoint, model_name, temperature, max_tokens, timeout_seconds + `SELECT id, provider, api_url, model_name, temperature, max_tokens, timeout FROM llm_configs WHERE is_active = 1 LIMIT 1` @@ -52,19 +52,19 @@ router.get('/active', requireAuth, asyncHandler(async (req, res) => { * POST /api/llm-config * 新增 LLM 配置(僅管理員) */ -router.post('/', requireAdmin, asyncHandler(async (req, res) => { +router.post('/', requireSuperAdmin, asyncHandler(async (req, res) => { const { - provider_name, - api_endpoint, + provider, + api_url, api_key, model_name, temperature, max_tokens, - timeout_seconds + timeout } = req.body; // 驗證必填欄位 - if (!provider_name || !api_endpoint || !model_name) { + if (!provider || !api_url || !model_name) { return res.status(400).json({ success: false, error: '請填寫所有必填欄位' @@ -73,16 +73,16 @@ router.post('/', requireAdmin, asyncHandler(async (req, res) => { const result = await query( `INSERT INTO llm_configs - (provider_name, api_endpoint, api_key, model_name, temperature, max_tokens, timeout_seconds) + (provider, api_url, api_key, model_name, temperature, max_tokens, timeout) VALUES (?, ?, ?, ?, ?, ?, ?)`, [ - provider_name, - api_endpoint, + provider, + api_url, api_key || null, model_name, temperature || 0.7, max_tokens || 6000, - timeout_seconds || 120 + timeout || 120000 ] ); @@ -91,7 +91,7 @@ router.post('/', requireAdmin, asyncHandler(async (req, res) => { req.session.userId, 'llm_config', result.insertId, - { provider_name, model_name }, + { provider, model_name }, req.ip, req.get('user-agent') ); @@ -107,20 +107,20 @@ router.post('/', requireAdmin, asyncHandler(async (req, res) => { * PUT /api/llm-config/:id * 更新 LLM 配置(僅管理員) */ -router.put('/:id', requireAdmin, asyncHandler(async (req, res) => { +router.put('/:id', requireSuperAdmin, asyncHandler(async (req, res) => { const configId = parseInt(req.params.id); const { - provider_name, - api_endpoint, + provider, + api_url, api_key, model_name, temperature, max_tokens, - timeout_seconds + timeout } = req.body; // 驗證必填欄位 - if (!provider_name || !api_endpoint || !model_name) { + if (!provider || !api_url || !model_name) { return res.status(400).json({ success: false, error: '請填寫所有必填欄位' @@ -138,17 +138,17 @@ router.put('/:id', requireAdmin, asyncHandler(async (req, res) => { await query( `UPDATE llm_configs - SET provider_name = ?, api_endpoint = ?, api_key = ?, model_name = ?, - temperature = ?, max_tokens = ?, timeout_seconds = ?, updated_at = NOW() + SET provider = ?, api_url = ?, api_key = ?, model_name = ?, + temperature = ?, max_tokens = ?, timeout = ?, updated_at = NOW() WHERE id = ?`, [ - provider_name, - api_endpoint, + provider, + api_url, api_key || null, model_name, temperature || 0.7, max_tokens || 6000, - timeout_seconds || 120, + timeout || 120000, configId ] ); @@ -159,7 +159,7 @@ router.put('/:id', requireAdmin, asyncHandler(async (req, res) => { 'llm_config', configId, {}, - { provider_name, model_name }, + { provider, model_name }, req.ip, req.get('user-agent') ); @@ -174,11 +174,11 @@ router.put('/:id', requireAdmin, asyncHandler(async (req, res) => { * PUT /api/llm-config/:id/activate * 啟用特定 LLM 配置(僅管理員) */ -router.put('/:id/activate', requireAdmin, asyncHandler(async (req, res) => { +router.put('/:id/activate', requireSuperAdmin, asyncHandler(async (req, res) => { const configId = parseInt(req.params.id); // 檢查配置是否存在 - const [existing] = await query('SELECT id, provider_name FROM llm_configs WHERE id = ?', [configId]); + const [existing] = await query('SELECT id, provider FROM llm_configs WHERE id = ?', [configId]); if (!existing) { return res.status(404).json({ success: false, @@ -205,7 +205,7 @@ router.put('/:id/activate', requireAdmin, asyncHandler(async (req, res) => { res.json({ success: true, - message: `已啟用 ${existing.provider_name} 配置` + message: `已啟用 ${existing.provider} 配置` }); })); @@ -213,7 +213,7 @@ router.put('/:id/activate', requireAdmin, asyncHandler(async (req, res) => { * DELETE /api/llm-config/:id * 刪除 LLM 配置(僅管理員) */ -router.delete('/:id', requireAdmin, asyncHandler(async (req, res) => { +router.delete('/:id', requireSuperAdmin, asyncHandler(async (req, res) => { const configId = parseInt(req.params.id); // 檢查是否為啟用中的配置 @@ -254,10 +254,10 @@ router.delete('/:id', requireAdmin, asyncHandler(async (req, res) => { * POST /api/llm-config/test * 測試 LLM 配置連線(僅管理員) */ -router.post('/test', requireAdmin, asyncHandler(async (req, res) => { - const { api_endpoint, api_key, model_name } = req.body; +router.post('/test', requireSuperAdmin, asyncHandler(async (req, res) => { + const { api_url, api_key, model_name } = req.body; - if (!api_endpoint || !model_name) { + if (!api_url || !model_name) { return res.status(400).json({ success: false, error: '請提供 API 端點和模型名稱' @@ -268,7 +268,7 @@ router.post('/test', requireAdmin, asyncHandler(async (req, res) => { const axios = (await import('axios')).default; const response = await axios.post( - `${api_endpoint}/v1/chat/completions`, + `${api_url}/v1/chat/completions`, { model: model_name, messages: [ diff --git a/scripts/seed-test-users.js b/scripts/seed-test-users.js new file mode 100644 index 0000000..e5a30ef --- /dev/null +++ b/scripts/seed-test-users.js @@ -0,0 +1,108 @@ +import mysql from 'mysql2/promise'; +import dotenv from 'dotenv'; +import bcrypt from 'bcryptjs'; + +// 載入環境變數 +dotenv.config(); + +// 資料庫配置 +const dbConfig = { + host: process.env.DB_HOST || 'mysql.theaken.com', + port: parseInt(process.env.DB_PORT) || 33306, + user: process.env.DB_USER || 'A102', + password: process.env.DB_PASSWORD || 'Bb123456', + database: process.env.DB_NAME || 'db_A102' +}; + +// 測試帳號列表 +const testUsers = [ + { + employee_id: 'ADMIN001', + username: 'admin', + email: 'admin@example.com', + password: 'Admin@123456', + role: 'super_admin', + department: 'IT', + position: '系統管理員' + }, + { + employee_id: 'MGR001', + username: 'manager', + email: 'manager@example.com', + password: 'Manager@123456', + role: 'admin', + department: '品質管理部', + position: '品管經理' + }, + { + employee_id: 'TEST001', + username: 'testuser', + email: 'user@example.com', + password: 'User@123456', + role: 'user', + department: '生產部', + position: '工程師' + } +]; + +async function seedTestUsers() { + let connection; + + try { + console.log('\n╔════════════════════════════════════════════╗'); + console.log('║ 5 Why Analyzer - Seed Test Users ║'); + console.log('╚════════════════════════════════════════════╝\n'); + + console.log('🔄 Connecting to database...'); + connection = await mysql.createConnection(dbConfig); + console.log('✅ Connected successfully\n'); + + for (const user of testUsers) { + // 檢查使用者是否已存在 (只用 email 精確匹配) + const [existing] = await connection.execute( + 'SELECT id FROM users WHERE email = ?', + [user.email] + ); + + if (existing.length > 0) { + // 更新現有使用者的密碼 + const passwordHash = await bcrypt.hash(user.password, 10); + await connection.execute( + 'UPDATE users SET password_hash = ?, role = ?, is_active = 1, employee_id = ? WHERE email = ?', + [passwordHash, user.role, user.employee_id, user.email] + ); + console.log(`🔄 Updated: ${user.email} (${user.role})`); + } else { + // 建立新使用者 + const passwordHash = await bcrypt.hash(user.password, 10); + await connection.execute( + `INSERT INTO users (employee_id, username, email, password_hash, role, department, position, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?, 1)`, + [user.employee_id, user.username, user.email, passwordHash, user.role, user.department, user.position] + ); + console.log(`✅ Created: ${user.email} (${user.role})`); + } + } + + console.log('\n✅ Test users seeded successfully!\n'); + console.log('📋 Test Accounts:'); + console.log('─────────────────────────────────────────────'); + testUsers.forEach(user => { + console.log(` ${user.role.padEnd(12)} : ${user.email} / ${user.password}`); + }); + console.log('─────────────────────────────────────────────\n'); + + } catch (error) { + console.error('\n❌ Seeding failed:'); + console.error(' Error:', error.message); + process.exit(1); + } finally { + if (connection) { + await connection.end(); + console.log('🔌 Database connection closed\n'); + } + } +} + +// 執行 +seedTestUsers(); diff --git a/src/components/ErrorModal.jsx b/src/components/ErrorModal.jsx new file mode 100644 index 0000000..7ddcbb0 --- /dev/null +++ b/src/components/ErrorModal.jsx @@ -0,0 +1,146 @@ +import { createContext, useContext, useState, useCallback } from 'react'; + +const ErrorModalContext = createContext(null); + +export function ErrorModalProvider({ children }) { + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + + const showError = useCallback((title, message, details = null) => { + setError({ title, message, details }); + setCopied(false); + }, []); + + const hideError = useCallback(() => { + setError(null); + setCopied(false); + }, []); + + const copyErrorMessage = useCallback(async () => { + if (!error) return; + + const errorText = [ + `錯誤標題: ${error.title}`, + `錯誤訊息: ${error.message}`, + error.details ? `詳細資訊: ${error.details}` : '', + `時間: ${new Date().toLocaleString('zh-TW')}` + ].filter(Boolean).join('\n'); + + try { + await navigator.clipboard.writeText(errorText); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }, [error]); + + return ( + + {children} + {error && ( + + )} + + ); +} + +export function useErrorModal() { + const context = useContext(ErrorModalContext); + if (!context) { + throw new Error('useErrorModal must be used within ErrorModalProvider'); + } + return context; +} + +function ErrorModalOverlay({ error, onClose, onCopy, copied }) { + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+ + + +
+
+

{error.title}

+

發生異常情況

+
+ +
+ + {/* Content */} +
+
+

錯誤訊息

+

{error.message}

+
+ + {error.details && ( +
+

詳細資訊

+
+                {error.details}
+              
+
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/src/contexts/AuthContext.jsx b/src/contexts/AuthContext.jsx index a6f3cca..4884165 100644 --- a/src/contexts/AuthContext.jsx +++ b/src/contexts/AuthContext.jsx @@ -21,7 +21,7 @@ export function AuthProvider({ children }) { setUser(response.user); } } catch (err) { - console.log('Not authenticated'); + // 401 是預期行為(用戶未登入),靜默處理 setUser(null); } finally { setLoading(false); diff --git a/src/pages/AdminPage.jsx b/src/pages/AdminPage.jsx index 259e933..5b82d7e 100644 --- a/src/pages/AdminPage.jsx +++ b/src/pages/AdminPage.jsx @@ -1,10 +1,11 @@ -import { useState, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import api from '../services/api'; import { useAuth } from '../contexts/AuthContext'; +import { useErrorModal } from '../components/ErrorModal'; export default function AdminPage() { const [activeTab, setActiveTab] = useState('dashboard'); - const { isAdmin } = useAuth(); + const { isAdmin, isSuperAdmin } = useAuth(); if (!isAdmin()) { return ( @@ -14,23 +15,35 @@ export default function AdminPage() { ); } + // 根據角色定義可見的標籤頁 + const allTabs = [ + { id: 'dashboard', name: '總覽', icon: '📊', requireSuperAdmin: false }, + { id: 'users', name: '使用者管理', icon: '👥', requireSuperAdmin: false }, + { id: 'analyses', name: '分析記錄', icon: '📝', requireSuperAdmin: false }, + { id: 'llm', name: 'LLM 配置', icon: '🤖', requireSuperAdmin: true }, + { id: 'llmtest', name: 'LLM 測試台', icon: '🧪', requireSuperAdmin: true }, + { id: 'audit', name: '稽核日誌', icon: '🔍', requireSuperAdmin: true }, + { id: 'statistics', name: '系統統計', icon: '📈', requireSuperAdmin: true }, + ]; + + // 過濾出當前使用者可見的標籤頁 + const visibleTabs = allTabs.filter(tab => !tab.requireSuperAdmin || isSuperAdmin()); + return (

管理者儀表板

-

系統管理與監控

+

+ 系統管理與監控 + {isSuperAdmin() && 超級管理員} + {!isSuperAdmin() && isAdmin() && 管理員} +

{/* Tabs */}
-
); } @@ -141,6 +156,8 @@ function UsersTab() { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [showCreateModal, setShowCreateModal] = useState(false); + const { showError } = useErrorModal(); + const { isSuperAdmin } = useAuth(); useEffect(() => { loadUsers(); @@ -153,7 +170,7 @@ function UsersTab() { setUsers(response.data); } } catch (err) { - console.error(err); + showError('載入使用者失敗', err.message, err.stack); } finally { setLoading(false); } @@ -166,7 +183,7 @@ function UsersTab() { await api.deleteUser(id); loadUsers(); } catch (err) { - alert('刪除失敗: ' + err.message); + showError('刪除使用者失敗', err.message, err.stack); } }; @@ -219,12 +236,14 @@ function UsersTab() { - + {isSuperAdmin() && ( + + )} ))} @@ -374,6 +393,7 @@ function LLMConfigTab() { const [loading, setLoading] = useState(true); const [showModal, setShowModal] = useState(false); const [editingConfig, setEditingConfig] = useState(null); + const { showError } = useErrorModal(); useEffect(() => { loadConfigs(); @@ -386,7 +406,7 @@ function LLMConfigTab() { setConfigs(response.data); } } catch (err) { - console.error(err); + showError('載入 LLM 配置失敗', err.message, err.stack); } finally { setLoading(false); } @@ -397,7 +417,7 @@ function LLMConfigTab() { await api.activateLLMConfig(id); loadConfigs(); } catch (err) { - alert('啟用失敗: ' + err.message); + showError('啟用 LLM 配置失敗', err.message, err.stack); } }; @@ -408,7 +428,7 @@ function LLMConfigTab() { await api.deleteLLMConfig(id); loadConfigs(); } catch (err) { - alert('刪除失敗: ' + err.message); + showError('刪除 LLM 配置失敗', err.message, err.stack); } }; @@ -448,10 +468,10 @@ function LLMConfigTab() { {configs.map((config) => ( - {config.provider_name} + {config.provider} - {config.api_endpoint} + {config.api_url} {config.model_name} @@ -520,39 +540,91 @@ function LLMConfigTab() { // LLM Config Modal function LLMConfigModal({ config, onClose, onSuccess }) { const [formData, setFormData] = useState({ - provider_name: config?.provider_name || 'DeepSeek', - api_endpoint: config?.api_endpoint || 'https://api.deepseek.com', + provider: config?.provider || 'Ollama', + api_url: config?.api_url || 'https://ollama_pjapi.theaken.com', api_key: '', model_name: config?.model_name || 'deepseek-chat', temperature: config?.temperature || 0.7, max_tokens: config?.max_tokens || 6000, - timeout_seconds: config?.timeout_seconds || 120, + timeout: config?.timeout || 120000, }); const [loading, setLoading] = useState(false); const [testing, setTesting] = useState(false); const [error, setError] = useState(''); const [testResult, setTestResult] = useState(''); + const [availableModels, setAvailableModels] = useState([]); + const [loadingModels, setLoadingModels] = useState(false); - const providerPresets = { - DeepSeek: { - api_endpoint: 'https://api.deepseek.com', - model_name: 'deepseek-chat', - }, + const providerPresets = React.useMemo(() => ({ Ollama: { - api_endpoint: 'https://ollama_pjapi.theaken.com', - model_name: 'qwen2.5:3b', + api_url: 'https://ollama_pjapi.theaken.com', + model_name: 'deepseek-chat', + models: [] // Will be loaded dynamically + }, + DeepSeek: { + api_url: 'https://api.deepseek.com', + model_name: 'deepseek-chat', + models: [ + { id: 'deepseek-chat', name: 'DeepSeek Chat' }, + { id: 'deepseek-coder', name: 'DeepSeek Coder' } + ] }, OpenAI: { - api_endpoint: 'https://api.openai.com', + api_url: 'https://api.openai.com', model_name: 'gpt-4', + models: [ + { id: 'gpt-4', name: 'GPT-4' }, + { id: 'gpt-4-turbo', name: 'GPT-4 Turbo' }, + { id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo' } + ] }, - }; + }), []); + + // Load Ollama models function + const loadOllamaModels = React.useCallback(async () => { + setLoadingModels(true); + try { + const response = await fetch(`${formData.api_url}/v1/models`); + const data = await response.json(); + + if (data.data && Array.isArray(data.data)) { + const models = data.data.map(model => ({ + id: model.id, + name: model.info?.name || model.id, + description: model.info?.description || '', + best_for: model.info?.best_for || '' + })); + setAvailableModels(models); + } + } catch (err) { + console.error('Failed to load Ollama models:', err); + setAvailableModels([ + { id: 'deepseek-chat', name: 'DeepSeek Chat' }, + { id: 'deepseek-reasoner', name: 'DeepSeek Reasoner' }, + { id: 'gpt-oss:120b', name: 'GPT-OSS 120B' } + ]); + } finally { + setLoadingModels(false); + } + }, [formData.api_url]); + + // Load available models when provider or API endpoint changes + useEffect(() => { + if (formData.provider === 'Ollama') { + loadOllamaModels(); + } else { + const preset = providerPresets[formData.provider]; + setAvailableModels(preset?.models || []); + } + }, [formData.provider, formData.api_url, loadOllamaModels, providerPresets]); const handleProviderChange = (provider) => { + const preset = providerPresets[provider]; setFormData({ ...formData, - provider_name: provider, - ...(providerPresets[provider] || {}), + provider: provider, + api_url: preset?.api_url || formData.api_url, + model_name: preset?.model_name || formData.model_name, }); }; @@ -563,7 +635,7 @@ function LLMConfigModal({ config, onClose, onSuccess }) { try { const response = await api.testLLMConfig({ - api_endpoint: formData.api_endpoint, + api_url: formData.api_url, api_key: formData.api_key, model_name: formData.model_name, }); @@ -617,13 +689,13 @@ function LLMConfigModal({ config, onClose, onSuccess }) {
@@ -631,14 +703,24 @@ function LLMConfigModal({ config, onClose, onSuccess }) {
- setFormData({...formData, api_endpoint: e.target.value})} - className="w-full px-3 py-2 border rounded-lg" - placeholder="https://api.deepseek.com" - required - /> +
+ setFormData({...formData, api_url: e.target.value})} + className="flex-1 px-3 py-2 border rounded-lg" + placeholder="https://ollama_pjapi.theaken.com" + required + /> + +
@@ -648,20 +730,74 @@ function LLMConfigModal({ config, onClose, onSuccess }) { value={formData.api_key} onChange={(e) => setFormData({...formData, api_key: e.target.value})} className="w-full px-3 py-2 border rounded-lg" - placeholder="選填(某些 API 需要)" + placeholder="選填(Ollama 不需要)" />
- - setFormData({...formData, model_name: e.target.value})} - className="w-full px-3 py-2 border rounded-lg" - placeholder="deepseek-chat" - required - /> + + {availableModels.length > 0 ? ( + <> + +
+ {availableModels.map(model => ( +
setFormData({...formData, model_name: model.id})} + className={`p-2 cursor-pointer border-b last:border-b-0 hover:bg-blue-50 ${ + formData.model_name === model.id ? 'bg-indigo-100 border-l-4 border-l-indigo-500' : '' + }`} + > +
{model.name}
+
{model.id}
+ {model.description &&
{model.description}
} + {model.best_for &&
適合: {model.best_for}
} +
+ ))} +
+ + ) : ( +
+ setFormData({...formData, model_name: e.target.value})} + className="w-full px-3 py-2 border rounded-lg" + placeholder="deepseek-chat" + required + /> +

+ 點擊「掃描模型」按鈕從 API 載入可用模型列表 +

+
+ )}
@@ -692,8 +828,8 @@ function LLMConfigModal({ config, onClose, onSuccess }) { setFormData({...formData, timeout_seconds: parseInt(e.target.value)})} + value={formData.timeout / 1000} + onChange={(e) => setFormData({...formData, timeout: parseInt(e.target.value) * 1000})} className="w-full px-3 py-2 border rounded-lg" />
@@ -730,6 +866,357 @@ function LLMConfigModal({ config, onClose, onSuccess }) { ); } +// LLM Test Tab Component +function LLMTestTab() { + const [apiUrl, setApiUrl] = useState('https://ollama_pjapi.theaken.com'); + const [models, setModels] = useState([]); + const [selectedModel, setSelectedModel] = useState(''); + const [loading, setLoading] = useState(false); + const [testResult, setTestResult] = useState(''); + const [chatMessages, setChatMessages] = useState([]); + const [chatInput, setChatInput] = useState(''); + const [chatLoading, setChatLoading] = useState(false); + const [streamingContent, setStreamingContent] = useState(''); + const [useStreaming, setUseStreaming] = useState(true); + const { showError } = useErrorModal(); + + // Load available models from API + const loadModels = async () => { + setLoading(true); + setTestResult(''); + try { + const response = await fetch(`${apiUrl}/v1/models`); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + + if (data.data && Array.isArray(data.data)) { + const modelList = data.data.map(model => ({ + id: model.id, + name: model.info?.name || model.id, + description: model.info?.description || '', + best_for: model.info?.best_for || '', + owned_by: model.owned_by || 'unknown' + })); + setModels(modelList); + if (modelList.length > 0 && !selectedModel) { + setSelectedModel(modelList[0].id); + } + setTestResult(`✅ 成功載入 ${modelList.length} 個模型`); + } else { + throw new Error('Invalid response format'); + } + } catch (err) { + showError('載入模型失敗', err.message, `API 端點: ${apiUrl}\n\n${err.stack || ''}`); + setModels([]); + } finally { + setLoading(false); + } + }; + + // Quick connection test + const quickTest = async () => { + setLoading(true); + setTestResult(''); + try { + const response = await fetch(`${apiUrl}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: selectedModel || 'deepseek-chat', + messages: [{ role: 'user', content: 'Hello' }], + max_tokens: 50, + temperature: 0.7 + }) + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + + if (data.choices && data.choices[0]) { + const reply = data.choices[0].message?.content || 'No content'; + setTestResult(`✅ 連線成功!\n\n模型: ${data.model}\n回應: ${reply}`); + } else { + throw new Error('Invalid response format'); + } + } catch (err) { + showError('連線測試失敗', err.message, `API 端點: ${apiUrl}\n模型: ${selectedModel || 'deepseek-chat'}\n\n${err.stack || ''}`); + } finally { + setLoading(false); + } + }; + + // Send chat message + const sendMessage = async () => { + if (!chatInput.trim() || chatLoading) return; + + const userMessage = { role: 'user', content: chatInput.trim() }; + const newMessages = [...chatMessages, userMessage]; + setChatMessages(newMessages); + setChatInput(''); + setChatLoading(true); + setStreamingContent(''); + + try { + const requestBody = { + model: selectedModel || 'deepseek-chat', + messages: newMessages, + temperature: 0.7, + stream: useStreaming + }; + + if (useStreaming) { + // Streaming mode with SSE + const response = await fetch(`${apiUrl}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody) + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let fullContent = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6); + if (data === '[DONE]') continue; + + try { + const parsed = JSON.parse(data); + const delta = parsed.choices?.[0]?.delta?.content || ''; + fullContent += delta; + setStreamingContent(fullContent); + } catch (e) { + // Skip invalid JSON + } + } + } + } + + setChatMessages([...newMessages, { role: 'assistant', content: fullContent }]); + setStreamingContent(''); + } else { + // Non-streaming mode + const response = await fetch(`${apiUrl}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody) + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + + const assistantMessage = { + role: 'assistant', + content: data.choices?.[0]?.message?.content || 'No response' + }; + setChatMessages([...newMessages, assistantMessage]); + } + } catch (err) { + setChatMessages([...newMessages, { + role: 'assistant', + content: `❌ Error: ${err.message}` + }]); + } finally { + setChatLoading(false); + } + }; + + // Clear chat history + const clearChat = () => { + setChatMessages([]); + setStreamingContent(''); + }; + + return ( +
+ {/* API Configuration */} +
+

LLM API 測試台

+ +
+
+ + setApiUrl(e.target.value)} + className="w-full px-3 py-2 border rounded-lg" + placeholder="https://ollama_pjapi.theaken.com" + /> +
+
+ + +
+
+ +
+ + + +
+ + {testResult && ( +
+ {testResult} +
+ )} + + {/* Models List */} + {models.length > 0 && ( +
+

可用模型 ({models.length})

+
+ {models.map(model => ( +
setSelectedModel(model.id)} + className={`p-3 rounded-lg border cursor-pointer transition ${ + selectedModel === model.id + ? 'border-indigo-500 bg-indigo-50' + : 'border-gray-200 hover:border-gray-300' + }`} + > +
{model.name}
+
{model.id}
+ {model.description && ( +
{model.description}
+ )} + {model.best_for && ( +
適合: {model.best_for}
+ )} +
+ ))} +
+
+ )} +
+ + {/* Chat Console */} +
+
+

對話測試

+ +
+ + {/* Chat Messages */} +
+ {chatMessages.length === 0 && !streamingContent && ( +
+ 選擇模型後開始對話測試 +
+ )} + {chatMessages.map((msg, idx) => ( +
+
+
+ {msg.role === 'user' ? '你' : 'AI'} +
+
{msg.content}
+
+
+ ))} + {streamingContent && ( +
+
+
AI
+
{streamingContent}
+ +
+
+ )} + {chatLoading && !streamingContent && ( +
+ 思考中... +
+ )} +
+ + {/* Chat Input */} +
+ setChatInput(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && sendMessage()} + placeholder="輸入訊息..." + className="flex-1 px-4 py-2 border rounded-lg" + disabled={chatLoading || !selectedModel} + /> + +
+
+
+ ); +} + // Create User Modal function CreateUserModal({ onClose, onSuccess }) { const [formData, setFormData] = useState({ @@ -849,3 +1336,121 @@ function CreateUserModal({ onClose, onSuccess }) {
); } + +// Statistics Tab Component (Super Admin Only) +function StatisticsTab() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const { showError } = useErrorModal(); + + useEffect(() => { + loadStatistics(); + }, []); + + const loadStatistics = async () => { + try { + const response = await api.getStatistics(); + if (response.success) { + setStats(response.data); + } + } catch (err) { + showError('載入統計資料失敗', err.message, err.stack); + } finally { + setLoading(false); + } + }; + + if (loading) return
載入中...
; + + if (!stats) return
無法載入統計資料
; + + return ( +
+ {/* Overall Statistics */} +
+

整體分析統計

+
+
+

{stats.overall?.total || 0}

+

總分析數

+
+
+

{stats.overall?.completed || 0}

+

完成

+
+
+

{stats.overall?.failed || 0}

+

失敗

+
+
+

{stats.overall?.processing || 0}

+

處理中

+
+
+
+ + {/* User Statistics */} +
+

使用者統計

+
+ {/* By Role */} +
+

依角色分布

+
+ {stats.users?.byRole && Object.entries(stats.users.byRole).map(([role, count]) => ( +
+ + {role === 'super_admin' ? '超級管理員' : role === 'admin' ? '管理員' : '一般使用者'} + + {count} +
+ ))} +
+
+ + {/* By Department */} +
+

依部門分布

+
+ {stats.users?.byDepartment && Object.entries(stats.users.byDepartment).map(([dept, data]) => ( +
+ {dept} + + {data.active} + /{data.total} + +
+ ))} +
+
+
+
+ + {/* Summary */} +
+

摘要

+
+
+ 總使用者數: + {stats.users?.total || 0} +
+
+ 平均處理時間: + {Math.round(stats.overall?.avg_processing_time || 0)} 秒 +
+
+ 成功率: + + {stats.overall?.total > 0 + ? ((stats.overall.completed / stats.overall.total) * 100).toFixed(1) + : 0}% + +
+
+
+
+ ); +} diff --git a/src/pages/AnalyzePage.jsx b/src/pages/AnalyzePage.jsx index d5c8cfc..5b0a40f 100644 --- a/src/pages/AnalyzePage.jsx +++ b/src/pages/AnalyzePage.jsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import api from '../services/api'; +import { useErrorModal } from '../components/ErrorModal'; export default function AnalyzePage() { const [finding, setFinding] = useState(''); @@ -7,7 +8,7 @@ export default function AnalyzePage() { const [outputLanguage, setOutputLanguage] = useState('zh-TW'); const [result, setResult] = useState(null); const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); + const { showError } = useErrorModal(); const languages = [ { code: 'zh-TW', name: '繁體中文' }, @@ -21,17 +22,18 @@ export default function AnalyzePage() { const handleAnalyze = async (e) => { e.preventDefault(); - setError(''); setResult(null); setLoading(true); try { const response = await api.createAnalysis(finding, jobContent, outputLanguage); if (response.success) { - setResult(response.analysis); + setResult(response.data); } } catch (err) { - setError(err.message || '分析失敗,請稍後再試'); + const errorMessage = err.message || '分析失敗,請稍後再試'; + const errorDetails = err.response?.data?.details || err.stack || null; + showError('分析錯誤', errorMessage, errorDetails); } finally { setLoading(false); } @@ -41,7 +43,6 @@ export default function AnalyzePage() { setFinding(''); setJobContent(''); setResult(null); - setError(''); }; return ( @@ -97,12 +98,6 @@ export default function AnalyzePage() {
- {error && ( -
-

{error}

-
- )} -
- {/* Perspectives */} - {result.perspectives && result.perspectives.length > 0 && ( + {/* Problem Restatement */} + {result.problemRestatement && ( +
+

問題重述 (5W1H):

+

{result.problemRestatement}

+
+ )} + + {/* Analyses */} + {result.analyses && result.analyses.length > 0 && (
- {result.perspectives.map((perspective, index) => ( + {result.analyses.map((analysis, index) => (
- - {perspective.perspective_type === 'technical' && '⚙️'} - {perspective.perspective_type === 'process' && '📋'} - {perspective.perspective_type === 'human' && '👤'} - -

- {perspective.perspective_type === 'technical' && '技術角度'} - {perspective.perspective_type === 'process' && '流程角度'} - {perspective.perspective_type === 'human' && '人員角度'} -

+ {analysis.perspectiveIcon || '🔍'} +

{analysis.perspective}

{/* 5 Whys */} - {perspective.whys && perspective.whys.length > 0 && ( + {analysis.whys && analysis.whys.length > 0 && (
- {perspective.whys.map((why, wIndex) => ( -
-
- Why {why.why_level}: -
-
-

{why.question}

-

{why.answer}

+ {analysis.whys.map((why, wIndex) => ( +
+
+ + Why {why.level}: + +
+

{why.question}

+

{why.answer}

+

+ {why.isVerified ? '✓ 已確認' : '⚠ 待驗證'}: {why.verificationNote} +

+
))} @@ -178,18 +178,50 @@ export default function AnalyzePage() { )} {/* Root Cause */} - {perspective.root_cause && ( + {analysis.rootCause && (
-

根本原因:

-

{perspective.root_cause}

+

🎯 根本原因:

+

{analysis.rootCause}

)} - {/* Solution */} - {perspective.solution && ( + {/* Logic Check */} + {analysis.logicCheck && ( +
+

🔄 邏輯檢核:

+

➡️ {analysis.logicCheck.forward}

+

⬅️ {analysis.logicCheck.backward}

+

+ {analysis.logicCheck.isValid ? '✓ 邏輯有效' : '✗ 邏輯需檢討'} +

+
+ )} + + {/* Countermeasure */} + {analysis.countermeasure && (
-

建議解決方案:

-

{perspective.solution}

+

💡 永久對策:

+

{analysis.countermeasure.permanent}

+ {analysis.countermeasure.actionItems && ( +
+

行動項目:

+
    + {analysis.countermeasure.actionItems.map((item, i) => ( +
  • {item}
  • + ))} +
+
+ )} + {analysis.countermeasure.avoidList && analysis.countermeasure.avoidList.length > 0 && ( +
+

避免的暫時性做法:

+
    + {analysis.countermeasure.avoidList.map((item, i) => ( +
  • {item}
  • + ))} +
+
+ )}
)}
@@ -200,7 +232,6 @@ export default function AnalyzePage() { {/* Metadata */}

分析 ID: {result.id}

-

分析時間: {new Date(result.created_at).toLocaleString('zh-TW')}

)} diff --git a/src/services/api.js b/src/services/api.js index 7364b64..f99905d 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -31,7 +31,10 @@ class ApiClient { return data; } catch (error) { - console.error('API Error:', error); + // 只對非認證錯誤顯示控制台訊息 + if (!endpoint.includes('/auth/me') || !error.message.includes('未登入')) { + console.error('API Error:', error); + } throw error; } } diff --git a/分層授權.md b/分層授權.md new file mode 100644 index 0000000..60555e1 --- /dev/null +++ b/分層授權.md @@ -0,0 +1,279 @@ +# 5 Why 根因分析系統 - 分層授權設計 + +**版本**: 1.0.0 +**更新日期**: 2025-12-08 + +--- + +## 1. 角色定義 + +系統定義三個權限層級: + +| 層級 | 角色代碼 | 角色名稱 | 說明 | +|------|----------|----------|------| +| L1 | `user` | 一般使用者 | 僅能使用分析功能,管理自己的資料 | +| L2 | `admin` | 管理員 | 可管理使用者、查看所有分析、配置 LLM | +| L3 | `super_admin` | 超級管理員 | 擁有所有權限,包括刪除使用者、系統級操作 | + +--- + +## 2. 權限矩陣 + +### 2.1 分析功能 (`/api/analyze`) + +| 功能 | API 端點 | user | admin | super_admin | +|------|----------|:----:|:-----:|:-----------:| +| 建立分析 | `POST /` | ✅ | ✅ | ✅ | +| 翻譯分析 | `POST /translate` | ✅ | ✅ | ✅ | +| 查看自己的歷史 | `GET /history` | ✅ | ✅ | ✅ | +| 查看自己的分析 | `GET /:id` | ✅ | ✅ | ✅ | +| 查看他人的分析 | `GET /:id` | ❌ | ✅ | ✅ | +| 刪除自己的分析 | `DELETE /:id` | ✅ | ✅ | ✅ | +| 刪除他人的分析 | `DELETE /:id` | ❌ | ✅ | ✅ | + +### 2.2 使用者管理 (`/api/admin/users`) + +| 功能 | API 端點 | user | admin | super_admin | +|------|----------|:----:|:-----:|:-----------:| +| 查看使用者列表 | `GET /users` | ❌ | ✅ | ✅ | +| 建立使用者 | `POST /users` | ❌ | ✅ | ✅ | +| 編輯使用者 | `PUT /users/:id` | ❌ | ✅ | ✅ | +| 停用使用者 (軟刪除) | `PUT /users/:id` | ❌ | ✅ | ✅ | +| 刪除使用者 (硬刪除) | `DELETE /users/:id` | ❌ | ❌ | ✅ | + +### 2.3 LLM 配置管理 (`/api/llm-config`) + +| 功能 | API 端點 | user | admin | super_admin | +|------|----------|:----:|:-----:|:-----------:| +| 查看 LLM 配置列表 | `GET /` | ✅ | ✅ | ✅ | +| 查看啟用的配置 | `GET /active` | ✅ | ✅ | ✅ | +| 新增 LLM 配置 | `POST /` | ❌ | ❌ | ✅ | +| 編輯 LLM 配置 | `PUT /:id` | ❌ | ❌ | ✅ | +| 啟用 LLM 配置 | `PUT /:id/activate` | ❌ | ❌ | ✅ | +| 刪除 LLM 配置 | `DELETE /:id` | ❌ | ❌ | ✅ | +| 測試 LLM 連線 | `POST /test` | ❌ | ❌ | ✅ | + +### 2.4 系統管理 (`/api/admin`) + +| 功能 | API 端點 | user | admin | super_admin | +|------|----------|:----:|:-----:|:-----------:| +| 查看儀表板 | `GET /dashboard` | ❌ | ✅ | ✅ | +| 查看所有分析記錄 | `GET /analyses` | ❌ | ✅ | ✅ | +| 查看稽核日誌 | `GET /audit-logs` | ❌ | ❌ | ✅ | +| 查看系統統計 | `GET /statistics` | ❌ | ❌ | ✅ | + +### 2.5 帳號功能 (`/api/auth`) + +| 功能 | API 端點 | user | admin | super_admin | +|------|----------|:----:|:-----:|:-----------:| +| 登入 | `POST /login` | ✅ | ✅ | ✅ | +| 登出 | `POST /logout` | ✅ | ✅ | ✅ | +| 查看自己的資料 | `GET /me` | ✅ | ✅ | ✅ | +| 修改自己的密碼 | `POST /change-password` | ✅ | ✅ | ✅ | + +--- + +## 3. 中介層實作 + +### 3.1 現有中介層 (`middleware/auth.js`) + +```javascript +// L1: 一般使用者 - 僅需登入 +export function requireAuth(req, res, next) + +// L2: 管理員 - 需要 admin 或 super_admin 角色 +export function requireAdmin(req, res, next) + +// L3: 超級管理員 - 僅允許 super_admin 角色 +export function requireSuperAdmin(req, res, next) + +// 資源擁有權檢查 - 一般使用者只能存取自己的資源 +export function requireOwnership(resourceUserIdParam) +``` + +### 3.2 權限檢查流程 + +``` +請求進入 + │ + ▼ +┌─────────────────┐ +│ requireAuth │ ─── 未登入 ──→ 401 Unauthorized +└────────┬────────┘ + │ 已登入 + ▼ +┌─────────────────┐ +│ requireAdmin? │ ─── 角色不符 ──→ 403 Forbidden +│ requireSuper? │ +└────────┬────────┘ + │ 權限通過 + ▼ +┌─────────────────┐ +│ requireOwnership│ ─── 非本人且非管理員 ──→ 403 Forbidden +└────────┬────────┘ + │ 全部通過 + ▼ + 執行業務邏輯 +``` + +--- + +## 4. 測試帳號 + +| 角色 | Email | 密碼 | 工號 | +|------|-------|------|------| +| super_admin | admin@example.com | Admin@123456 | ADMIN001 | +| admin | manager@example.com | Manager@123456 | MGR001 | +| user | user001@example.com | User@123456 | EMP001 | +| user | user002@example.com | User@123456 | EMP002 | + +--- + +## 5. 前端頁面權限控制 + +### 5.1 導航選單顯示 + +| 選單項目 | user | admin | super_admin | +|----------|:----:|:-----:|:-----------:| +| 5 Why 分析 | ✅ | ✅ | ✅ | +| 分析歷史 | ✅ | ✅ | ✅ | +| 管理員面板 | ❌ | ✅ | ✅ | + +### 5.2 管理員面板標籤頁 + +| 標籤頁 | admin | super_admin | +|--------|:-----:|:-----------:| +| 儀表板 | ✅ | ✅ | +| 使用者管理 | ✅ | ✅ | +| 分析記錄 | ✅ | ✅ | +| LLM 配置 | ✅ | ✅ | +| LLM 測試台 | ✅ | ✅ | +| 稽核日誌 | ✅ | ✅ | + +### 5.3 操作按鈕顯示 + +| 操作 | admin | super_admin | +|------|:-----:|:-----------:| +| 新增使用者 | ✅ | ✅ | +| 編輯使用者 | ✅ | ✅ | +| 停用使用者 | ✅ | ✅ | +| 刪除使用者 | ❌ | ✅ | + +--- + +## 6. 資料庫角色欄位 + +### 6.1 users 表結構 + +```sql +CREATE TABLE users ( + id INT PRIMARY KEY AUTO_INCREMENT, + employee_id VARCHAR(50) UNIQUE, + username VARCHAR(100), + email VARCHAR(255) UNIQUE, + password_hash VARCHAR(255), + role ENUM('user', 'admin', 'super_admin') DEFAULT 'user', + department VARCHAR(100), + position VARCHAR(100), + is_active TINYINT(1) DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_login_at TIMESTAMP +); +``` + +### 6.2 角色值說明 + +| role 值 | 說明 | +|---------|------| +| `user` | 一般使用者,預設值 | +| `admin` | 管理員 | +| `super_admin` | 超級管理員 | + +--- + +## 7. Session 結構 + +登入成功後,Session 中儲存以下資訊: + +```javascript +req.session = { + userId: 123, // 使用者 ID + userRole: 'admin', // 角色代碼 + employeeId: 'EMP001', // 工號 + username: '王小明' // 使用者名稱 +} +``` + +--- + +## 8. 權限擴展建議 + +### 8.1 未來可考慮的權限細分 + +| 權限代碼 | 說明 | 建議角色 | +|----------|------|----------| +| `analysis.create` | 建立分析 | user, admin, super_admin | +| `analysis.view.own` | 查看自己的分析 | user, admin, super_admin | +| `analysis.view.all` | 查看所有分析 | admin, super_admin | +| `analysis.delete.own` | 刪除自己的分析 | user, admin, super_admin | +| `analysis.delete.all` | 刪除所有分析 | admin, super_admin | +| `user.manage` | 管理使用者 | admin, super_admin | +| `user.delete` | 刪除使用者 | super_admin | +| `llm.configure` | 配置 LLM | admin, super_admin | +| `system.audit` | 查看稽核日誌 | admin, super_admin | + +### 8.2 實作 RBAC (Role-Based Access Control) + +未來可擴展為更靈活的權限系統: + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Users │────▶│ Roles │────▶│Permissions│ +└──────────┘ └──────────┘ └──────────┘ + │ │ │ + │ │ │ + user_id role_id permission_id +``` + +--- + +## 9. 安全性考量 + +### 9.1 已實作 + +- [x] 密碼使用 bcrypt 加密 (cost factor: 10) +- [x] Session-based 認證 +- [x] 前後端雙重權限檢查 +- [x] 稽核日誌記錄所有敏感操作 +- [x] 軟刪除保留資料完整性 + +### 9.2 建議加強 + +- [ ] 密碼複雜度驗證 +- [ ] 登入失敗次數限制 +- [ ] Session 過期時間設定 +- [ ] IP 白名單 (管理功能) +- [ ] 雙因素認證 (2FA) + +--- + +## 附錄:快速參考 + +### 檢查使用者權限的程式碼 + +```javascript +// 檢查是否為管理員 +const isAdmin = ['admin', 'super_admin'].includes(req.session.userRole); + +// 檢查是否為超級管理員 +const isSuperAdmin = req.session.userRole === 'super_admin'; + +// 檢查是否為資源擁有者或管理員 +const canAccess = resource.user_id === req.session.userId || isAdmin; +``` + +--- + +**文件版本**: 1.0.0 +**編寫日期**: 2025-12-08 +**編寫者**: Development Team diff --git a/系統使用說明書.md b/系統使用說明書.md new file mode 100644 index 0000000..b945f4a --- /dev/null +++ b/系統使用說明書.md @@ -0,0 +1,344 @@ +# 5 Why 根因分析系統 - 使用說明書 + +**版本**: 1.3.0 +**更新日期**: 2025-12-08 + +--- + +## 目錄 + +1. [系統概述](#1-系統概述) +2. [系統架構](#2-系統架構) +3. [使用流程](#3-使用流程) +4. [功能說明](#4-功能說明) +5. [優化設計](#5-優化設計) +6. [常見問題](#6-常見問題) + +--- + +## 1. 系統概述 + +### 1.1 什麼是 5 Why 分析法? + +5 Why 分析法是一種根因分析技術,透過連續追問「為什麼」來深入探究問題的根本原因,而非停留在表面症狀。 + +### 1.2 系統目標 + +本系統整合 AI 大型語言模型(LLM),協助使用者: + +- **快速進行根因分析**:AI 從多個角度自動生成 5 Why 分析 +- **確保分析品質**:遵循五大執行要項,避免常見錯誤 +- **提供永久對策**:聚焦系統性解決方案,而非暫時性補救 + +### 1.3 系統特色 + +| 特色 | 說明 | +|------|------| +| 多角度分析 | 自動從 3 個不同角度(流程面、系統面、管理面等)進行分析 | +| 邏輯檢核 | 每個分析自動進行順向/逆向邏輯驗證 | +| 多語言支援 | 支援繁中、簡中、英、日、韓、越、泰 7 種語言 | +| 歷史記錄 | 自動保存所有分析記錄,可隨時查詢 | + +--- + +## 2. 系統架構 + +### 2.1 技術架構 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 使用者介面 │ +│ (React + Tailwind CSS) │ +├─────────────────────────────────────────────────────────┤ +│ 後端服務 │ +│ (Node.js + Express) │ +├──────────────────┬──────────────────┬───────────────────┤ +│ MySQL 資料庫 │ Session 管理 │ LLM API │ +│ (分析記錄儲存) │ (使用者認證) │ (DeepSeek/Ollama)│ +└──────────────────┴──────────────────┴───────────────────┘ +``` + +### 2.2 模組說明 + +| 模組 | 功能 | +|------|------| +| 認證模組 | 使用者登入、登出、密碼管理 | +| 分析模組 | 5 Why 分析、翻譯、歷史查詢 | +| 管理模組 | 使用者管理、LLM 配置、稽核日誌 | + +--- + +## 3. 使用流程 + +### 3.1 整體流程圖 + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ 登入 │ -> │ 輸入問題 │ -> │ AI 分析 │ -> │ 查看結果 │ +└──────────┘ └──────────┘ └──────────┘ └──────────┘ + │ │ + v v + ┌──────────────┐ ┌──────────────┐ + │ 選擇輸出語言 │ │ 翻譯/匯出 │ + └──────────────┘ └──────────────┘ +``` + +### 3.2 詳細操作步驟 + +#### 步驟 1:登入系統 + +1. 開啟系統網址 `http://localhost:5176` +2. 輸入帳號(Email 或工號)與密碼 +3. 點擊「登入」按鈕 + +**測試帳號**: + +| 角色 | Email | 密碼 | +|------|-------|------| +| 超級管理員 | admin@example.com | Admin@123456 | +| 管理員 | manager@example.com | Manager@123456 | +| 一般使用者 | user@example.com | User@123456 | + +#### 步驟 2:輸入分析內容 + +在「5 Why 根因分析」頁面: + +1. **發現的現象**(必填) + - 描述您觀察到的問題或異常 + - 範例:「伺服器每週二凌晨 3 點當機」 + +2. **工作內容/背景**(必填) + - 提供相關的背景資訊 + - 包含:系統環境、作業流程、相關人員等 + +3. **輸出語言** + - 選擇分析結果的語言 + +#### 步驟 3:執行分析 + +1. 點擊「開始分析」按鈕 +2. 等待 AI 處理(約 30-60 秒) +3. 系統會顯示分析進度 + +#### 步驟 4:查看結果 + +分析完成後,會顯示: + +``` +┌─────────────────────────────────────────────────────┐ +│ 問題重述 (5W1H) │ +│ ─────────────────────────────────────────────────── │ +│ [AI 用 5W1H 格式重新描述問題] │ +├─────────────────────────────────────────────────────┤ +│ 🔄 流程面分析 │ +│ ─────────────────────────────────────────────────── │ +│ Why 1: [問題] → [答案] ✓已確認 │ +│ Why 2: [問題] → [答案] ⚠待驗證 │ +│ Why 3: [問題] → [答案] ✓已確認 │ +│ │ +│ 🎯 根本原因:[分析結果] │ +│ │ +│ 🔄 邏輯檢核: │ +│ ➡️ 順向:如果[原因]發生,則[結果]必然發生 │ +│ ⬅️ 逆向:如果消除[原因],則[結果]不會發生 │ +│ │ +│ 💡 永久對策:[系統性解決方案] │ +│ 行動項目: │ +│ • [具體行動 1] │ +│ • [具體行動 2] │ +└─────────────────────────────────────────────────────┘ +``` + +--- + +## 4. 功能說明 + +### 4.1 分析功能 + +#### 4.1.1 多角度分析 + +系統自動從 3 個不同角度進行分析: + +- **流程面**:作業流程、標準程序 +- **系統面**:技術系統、設備機制 +- **管理面**:管理制度、監督機制 + +#### 4.1.2 5 Why 深度追問 + +每個角度會進行 3-5 次「為什麼」追問: + +``` +Why 1: 為什麼發生 A? → 因為 B +Why 2: 為什麼發生 B? → 因為 C +Why 3: 為什麼發生 C? → 因為 D(根本原因) +``` + +#### 4.1.3 邏輯雙向檢核 + +每個分析包含邏輯驗證: + +- **順向檢核**:如果原因發生,結果是否必然發生? +- **逆向檢核**:如果消除原因,結果是否就不會發生? + +### 4.2 管理功能(僅管理員) + +#### 4.2.1 儀表板 + +- 分析統計(總數、成功率、平均處理時間) +- 使用者活動統計 +- 系統健康狀態 + +#### 4.2.2 使用者管理 + +- 新增/編輯/停用使用者 +- 角色權限設定 +- 密碼重設 + +#### 4.2.3 LLM 配置 + +- 新增/編輯 LLM 端點 +- 模型選擇與參數調整 +- 連線測試 + +#### 4.2.4 LLM 測試台 + +- 動態載入可用模型 +- 快速連線測試 +- 互動式對話測試 + +--- + +## 5. 優化設計 + +### 5.1 分析品質優化 + +#### 5.1.1 五大執行要項 + +系統 Prompt 內建五大執行要項,確保分析品質: + +| 要項 | 說明 | +|------|------| +| 精準定義問題 | 使用 5W1H 描述現象,而非結論 | +| 聚焦流程與系統 | 追問「系統為何允許疏失發生」 | +| 基於事實 | 每個 Why 標註「已確認」或「待驗證」 | +| 邏輯檢核 | 順向+逆向雙向驗證 | +| 可執行對策 | 永久性系統解決方案 | + +#### 5.1.2 Prompt 精簡化 + +``` +優化前:80+ 行複雜 Prompt +優化後:15 行精簡 Prompt + +效果: +- 減少 LLM 混淆 +- 提高 JSON 格式正確率 +- 降低回應被截斷風險 +``` + +### 5.2 技術優化 + +#### 5.2.1 JSON 解析強化 + +多層修復策略處理 LLM 輸出格式問題: + +```javascript +1. 清理特殊標記(```json、等) +2. 提取 { 到 } 之間的純 JSON +3. 修復常見格式問題(尾部逗號、缺少逗號) +4. 補齊未閉合的括號 +``` + +#### 5.2.2 API 參數優化 + +```javascript +// 問題:DeepSeek 限制 max_tokens 最大 8192 +// 解決:動態限制在 4000-8000 之間 +const effectiveMaxTokens = Math.min( + Math.max(parseInt(llmConfig.max_tokens) || 4000, 4000), + 8000 +); + +// 問題:temperature 為字串導致 API 錯誤 +// 解決:確保轉換為數字 +const effectiveTemperature = parseFloat(llmConfig.temperature) || 0.7; +``` + +### 5.3 使用者體驗優化 + +#### 5.3.1 結果視覺化 + +- 分析角度以卡片式呈現 +- Why 層級以縮排+左邊界線區分 +- 已確認/待驗證以顏色標記 +- 根本原因、邏輯檢核、永久對策區塊化 + +#### 5.3.2 載入狀態 + +- 分析中顯示動畫與預估時間 +- 錯誤發生時顯示詳細訊息 + +--- + +## 6. 常見問題 + +### Q1: 分析時間很長怎麼辦? + +**原因**:LLM API 回應時間取決於模型負載與輸入長度 + +**建議**: +- 簡化輸入內容,聚焦關鍵資訊 +- 避免在尖峰時段使用 + +### Q2: 出現「JSON 解析失敗」錯誤? + +**原因**:LLM 輸出格式不正確 + +**解決**: +- 點擊「重試」重新分析 +- 簡化輸入內容 +- 聯繫管理員檢查 LLM 配置 + +### Q3: 分析結果不符合預期? + +**建議**: +- 提供更詳細的背景資訊 +- 明確描述問題現象(5W1H) +- 避免主觀結論,描述客觀事實 + +### Q4: 如何切換 LLM 模型? + +**步驟**(需管理員權限): +1. 進入「管理員」頁面 +2. 選擇「LLM 配置」Tab +3. 新增或編輯配置 +4. 點擊「啟用」設為預設 + +--- + +## 附錄 + +### A. 系統需求 + +| 項目 | 最低需求 | +|------|----------| +| 瀏覽器 | Chrome 90+、Firefox 88+、Edge 90+ | +| 網路 | 穩定的網際網路連線 | + +### B. 快速鍵 + +| 快速鍵 | 功能 | +|--------|------| +| `Enter` | 在登入頁面提交表單 | +| `Ctrl+Enter` | 在分析頁面開始分析 | + +### C. 聯絡支援 + +如有問題,請聯繫系統管理員或參考: +- GitHub Issues: https://github.com/anthropics/claude-code/issues + +--- + +**文件版本**: 1.0.0 +**編寫日期**: 2025-12-08 +**編寫者**: Development Team