From 66cdcacce9998ca4de48161af01023c497b601e2 Mon Sep 17 00:00:00 2001 From: donald Date: Mon, 8 Dec 2025 19:29:28 +0800 Subject: [PATCH] feat: Implement role-based access control (RBAC) with 3-tier authorization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 3 user roles: user, admin, super_admin - Restrict LLM config management to super_admin only - Restrict audit logs and statistics to super_admin only - Update AdminPage with role-based tab visibility - Add complete 5 Why prompt from 5why-analyzer.jsx - Add system documentation and authorization guide - Add ErrorModal component and seed test users script 🀖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- routes/admin.js | 4 +- routes/analyze.js | 151 ++++++- routes/llmConfig.js | 64 +-- scripts/seed-test-users.js | 108 +++++ src/components/ErrorModal.jsx | 146 +++++++ src/contexts/AuthContext.jsx | 2 +- src/pages/AdminPage.jsx | 731 +++++++++++++++++++++++++++++++--- src/pages/AnalyzePage.jsx | 115 ++++-- src/services/api.js | 5 +- 分局授權.md | 279 +++++++++++++ 系統䜿甚說明曞.md | 344 ++++++++++++++++ 11 files changed, 1791 insertions(+), 158 deletions(-) create mode 100644 scripts/seed-test-users.js create mode 100644 src/components/ErrorModal.jsx create mode 100644 分局授權.md create mode 100644 系統䜿甚說明曞.md 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