feat: Implement role-based access control (RBAC) with 3-tier authorization
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -219,7 +219,7 @@ router.get('/analyses', requireAdmin, asyncHandler(async (req, res) => {
|
|||||||
* GET /api/admin/audit-logs
|
* 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 page = parseInt(req.query.page) || 1;
|
||||||
const limit = parseInt(req.query.limit) || 50;
|
const limit = parseInt(req.query.limit) || 50;
|
||||||
const filters = {
|
const filters = {
|
||||||
@@ -243,7 +243,7 @@ router.get('/audit-logs', requireAdmin, asyncHandler(async (req, res) => {
|
|||||||
* GET /api/admin/statistics
|
* 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 overallStats = await Analysis.getStatistics();
|
||||||
const users = await User.getAll(1, 1000);
|
const users = await User.getAll(1, 1000);
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const router = express.Router();
|
|||||||
*/
|
*/
|
||||||
async function getActiveLLMConfig() {
|
async function getActiveLLMConfig() {
|
||||||
const [config] = await query(
|
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
|
FROM llm_configs
|
||||||
WHERE is_active = 1
|
WHERE is_active = 1
|
||||||
LIMIT 1`
|
LIMIT 1`
|
||||||
@@ -22,13 +22,13 @@ async function getActiveLLMConfig() {
|
|||||||
// 如果沒有資料庫配置,使用環境變數的 Ollama 配置
|
// 如果沒有資料庫配置,使用環境變數的 Ollama 配置
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return {
|
return {
|
||||||
provider_name: 'Ollama',
|
provider: 'Ollama',
|
||||||
api_endpoint: ollamaConfig.apiUrl,
|
api_url: ollamaConfig.apiUrl,
|
||||||
api_key: null,
|
api_key: null,
|
||||||
model_name: ollamaConfig.model,
|
model_name: ollamaConfig.model,
|
||||||
temperature: ollamaConfig.temperature,
|
temperature: ollamaConfig.temperature,
|
||||||
max_tokens: ollamaConfig.maxTokens,
|
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();
|
const startTime = Date.now();
|
||||||
|
let analysis = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 取得啟用的 LLM 配置
|
// 取得啟用的 LLM 配置
|
||||||
const llmConfig = await getActiveLLMConfig();
|
const llmConfig = await getActiveLLMConfig();
|
||||||
|
|
||||||
// 建立分析記錄
|
// 建立分析記錄
|
||||||
const analysis = await Analysis.create({
|
analysis = await Analysis.create({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
finding,
|
finding,
|
||||||
job_content: jobContent,
|
job_content: jobContent,
|
||||||
@@ -121,7 +122,7 @@ router.post('/', requireAuth, asyncHandler(async (req, res) => {
|
|||||||
|
|
||||||
注意:
|
注意:
|
||||||
- 5 Why 的目的不是「湊滿五個問題」,而是穿透表面症狀直達根本原因
|
- 5 Why 的目的不是「湊滿五個問題」,而是穿透表面症狀直達根本原因
|
||||||
- 若在第 3 或第 4 個 Why 就已找到真正的根本原因,可以停止(設為 null)
|
- 若在第 3 或第 4 個 Why 就已找到真正的根本原因,可以停止
|
||||||
- 每個 Why 必須標註是「已驗證事實」還是「待驗證假設」
|
- 每個 Why 必須標註是「已驗證事實」還是「待驗證假設」
|
||||||
- 最終對策必須是「永久性對策」
|
- 最終對策必須是「永久性對策」
|
||||||
|
|
||||||
@@ -159,26 +160,42 @@ router.post('/', requireAuth, asyncHandler(async (req, res) => {
|
|||||||
}`;
|
}`;
|
||||||
|
|
||||||
// 呼叫 LLM API(支援 DeepSeek, Ollama 等)
|
// 呼叫 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(
|
const response = await axios.post(
|
||||||
`${llmConfig.api_endpoint}/v1/chat/completions`,
|
`${llmConfig.api_url}/v1/chat/completions`,
|
||||||
{
|
{
|
||||||
model: llmConfig.model_name,
|
model: llmConfig.model_name,
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: 'system',
|
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',
|
role: 'user',
|
||||||
content: prompt
|
content: prompt
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
temperature: llmConfig.temperature,
|
temperature: effectiveTemperature,
|
||||||
max_tokens: llmConfig.max_tokens,
|
max_tokens: effectiveMaxTokens,
|
||||||
stream: false
|
stream: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timeout: llmConfig.timeout_seconds * 1000,
|
timeout: llmConfig.timeout,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(llmConfig.api_key && { 'Authorization': `Bearer ${llmConfig.api_key}` })
|
...(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]) {
|
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 content = response.data.choices[0].message.content;
|
||||||
const cleanContent = content.replace(/```json|```/g, '').trim();
|
console.log('LLM Response length:', content.length);
|
||||||
const result = JSON.parse(cleanContent);
|
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(/<think>[\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);
|
const processingTime = Math.floor((Date.now() - startTime) / 1000);
|
||||||
@@ -295,7 +397,7 @@ ${JSON.stringify(analysis.analysis_result, null, 2)}
|
|||||||
}`;
|
}`;
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${llmConfig.api_endpoint}/v1/chat/completions`,
|
`${llmConfig.api_url}/v1/chat/completions`,
|
||||||
{
|
{
|
||||||
model: llmConfig.model_name,
|
model: llmConfig.model_name,
|
||||||
messages: [
|
messages: [
|
||||||
@@ -313,7 +415,7 @@ ${JSON.stringify(analysis.analysis_result, null, 2)}
|
|||||||
stream: false
|
stream: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timeout: llmConfig.timeout_seconds * 1000,
|
timeout: llmConfig.timeout,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(llmConfig.api_key && { 'Authorization': `Bearer ${llmConfig.api_key}` })
|
...(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 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);
|
const result = JSON.parse(cleanContent);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { query } from '../config.js';
|
import { query } from '../config.js';
|
||||||
import { asyncHandler } from '../middleware/errorHandler.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';
|
import AuditLog from '../models/AuditLog.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -12,7 +12,7 @@ const router = express.Router();
|
|||||||
*/
|
*/
|
||||||
router.get('/', requireAuth, asyncHandler(async (req, res) => {
|
router.get('/', requireAuth, asyncHandler(async (req, res) => {
|
||||||
const configs = await query(
|
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
|
FROM llm_configs
|
||||||
ORDER BY is_active DESC, created_at DESC`
|
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) => {
|
router.get('/active', requireAuth, asyncHandler(async (req, res) => {
|
||||||
const [config] = await query(
|
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
|
FROM llm_configs
|
||||||
WHERE is_active = 1
|
WHERE is_active = 1
|
||||||
LIMIT 1`
|
LIMIT 1`
|
||||||
@@ -52,19 +52,19 @@ router.get('/active', requireAuth, asyncHandler(async (req, res) => {
|
|||||||
* POST /api/llm-config
|
* POST /api/llm-config
|
||||||
* 新增 LLM 配置(僅管理員)
|
* 新增 LLM 配置(僅管理員)
|
||||||
*/
|
*/
|
||||||
router.post('/', requireAdmin, asyncHandler(async (req, res) => {
|
router.post('/', requireSuperAdmin, asyncHandler(async (req, res) => {
|
||||||
const {
|
const {
|
||||||
provider_name,
|
provider,
|
||||||
api_endpoint,
|
api_url,
|
||||||
api_key,
|
api_key,
|
||||||
model_name,
|
model_name,
|
||||||
temperature,
|
temperature,
|
||||||
max_tokens,
|
max_tokens,
|
||||||
timeout_seconds
|
timeout
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// 驗證必填欄位
|
// 驗證必填欄位
|
||||||
if (!provider_name || !api_endpoint || !model_name) {
|
if (!provider || !api_url || !model_name) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: '請填寫所有必填欄位'
|
error: '請填寫所有必填欄位'
|
||||||
@@ -73,16 +73,16 @@ router.post('/', requireAdmin, asyncHandler(async (req, res) => {
|
|||||||
|
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`INSERT INTO llm_configs
|
`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 (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
provider_name,
|
provider,
|
||||||
api_endpoint,
|
api_url,
|
||||||
api_key || null,
|
api_key || null,
|
||||||
model_name,
|
model_name,
|
||||||
temperature || 0.7,
|
temperature || 0.7,
|
||||||
max_tokens || 6000,
|
max_tokens || 6000,
|
||||||
timeout_seconds || 120
|
timeout || 120000
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ router.post('/', requireAdmin, asyncHandler(async (req, res) => {
|
|||||||
req.session.userId,
|
req.session.userId,
|
||||||
'llm_config',
|
'llm_config',
|
||||||
result.insertId,
|
result.insertId,
|
||||||
{ provider_name, model_name },
|
{ provider, model_name },
|
||||||
req.ip,
|
req.ip,
|
||||||
req.get('user-agent')
|
req.get('user-agent')
|
||||||
);
|
);
|
||||||
@@ -107,20 +107,20 @@ router.post('/', requireAdmin, asyncHandler(async (req, res) => {
|
|||||||
* PUT /api/llm-config/:id
|
* PUT /api/llm-config/:id
|
||||||
* 更新 LLM 配置(僅管理員)
|
* 更新 LLM 配置(僅管理員)
|
||||||
*/
|
*/
|
||||||
router.put('/:id', requireAdmin, asyncHandler(async (req, res) => {
|
router.put('/:id', requireSuperAdmin, asyncHandler(async (req, res) => {
|
||||||
const configId = parseInt(req.params.id);
|
const configId = parseInt(req.params.id);
|
||||||
const {
|
const {
|
||||||
provider_name,
|
provider,
|
||||||
api_endpoint,
|
api_url,
|
||||||
api_key,
|
api_key,
|
||||||
model_name,
|
model_name,
|
||||||
temperature,
|
temperature,
|
||||||
max_tokens,
|
max_tokens,
|
||||||
timeout_seconds
|
timeout
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// 驗證必填欄位
|
// 驗證必填欄位
|
||||||
if (!provider_name || !api_endpoint || !model_name) {
|
if (!provider || !api_url || !model_name) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: '請填寫所有必填欄位'
|
error: '請填寫所有必填欄位'
|
||||||
@@ -138,17 +138,17 @@ router.put('/:id', requireAdmin, asyncHandler(async (req, res) => {
|
|||||||
|
|
||||||
await query(
|
await query(
|
||||||
`UPDATE llm_configs
|
`UPDATE llm_configs
|
||||||
SET provider_name = ?, api_endpoint = ?, api_key = ?, model_name = ?,
|
SET provider = ?, api_url = ?, api_key = ?, model_name = ?,
|
||||||
temperature = ?, max_tokens = ?, timeout_seconds = ?, updated_at = NOW()
|
temperature = ?, max_tokens = ?, timeout = ?, updated_at = NOW()
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
[
|
[
|
||||||
provider_name,
|
provider,
|
||||||
api_endpoint,
|
api_url,
|
||||||
api_key || null,
|
api_key || null,
|
||||||
model_name,
|
model_name,
|
||||||
temperature || 0.7,
|
temperature || 0.7,
|
||||||
max_tokens || 6000,
|
max_tokens || 6000,
|
||||||
timeout_seconds || 120,
|
timeout || 120000,
|
||||||
configId
|
configId
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -159,7 +159,7 @@ router.put('/:id', requireAdmin, asyncHandler(async (req, res) => {
|
|||||||
'llm_config',
|
'llm_config',
|
||||||
configId,
|
configId,
|
||||||
{},
|
{},
|
||||||
{ provider_name, model_name },
|
{ provider, model_name },
|
||||||
req.ip,
|
req.ip,
|
||||||
req.get('user-agent')
|
req.get('user-agent')
|
||||||
);
|
);
|
||||||
@@ -174,11 +174,11 @@ router.put('/:id', requireAdmin, asyncHandler(async (req, res) => {
|
|||||||
* PUT /api/llm-config/:id/activate
|
* PUT /api/llm-config/:id/activate
|
||||||
* 啟用特定 LLM 配置(僅管理員)
|
* 啟用特定 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 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) {
|
if (!existing) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -205,7 +205,7 @@ router.put('/:id/activate', requireAdmin, asyncHandler(async (req, res) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
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
|
* DELETE /api/llm-config/:id
|
||||||
* 刪除 LLM 配置(僅管理員)
|
* 刪除 LLM 配置(僅管理員)
|
||||||
*/
|
*/
|
||||||
router.delete('/:id', requireAdmin, asyncHandler(async (req, res) => {
|
router.delete('/:id', requireSuperAdmin, asyncHandler(async (req, res) => {
|
||||||
const configId = parseInt(req.params.id);
|
const configId = parseInt(req.params.id);
|
||||||
|
|
||||||
// 檢查是否為啟用中的配置
|
// 檢查是否為啟用中的配置
|
||||||
@@ -254,10 +254,10 @@ router.delete('/:id', requireAdmin, asyncHandler(async (req, res) => {
|
|||||||
* POST /api/llm-config/test
|
* POST /api/llm-config/test
|
||||||
* 測試 LLM 配置連線(僅管理員)
|
* 測試 LLM 配置連線(僅管理員)
|
||||||
*/
|
*/
|
||||||
router.post('/test', requireAdmin, asyncHandler(async (req, res) => {
|
router.post('/test', requireSuperAdmin, asyncHandler(async (req, res) => {
|
||||||
const { api_endpoint, api_key, model_name } = req.body;
|
const { api_url, api_key, model_name } = req.body;
|
||||||
|
|
||||||
if (!api_endpoint || !model_name) {
|
if (!api_url || !model_name) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: '請提供 API 端點和模型名稱'
|
error: '請提供 API 端點和模型名稱'
|
||||||
@@ -268,7 +268,7 @@ router.post('/test', requireAdmin, asyncHandler(async (req, res) => {
|
|||||||
const axios = (await import('axios')).default;
|
const axios = (await import('axios')).default;
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${api_endpoint}/v1/chat/completions`,
|
`${api_url}/v1/chat/completions`,
|
||||||
{
|
{
|
||||||
model: model_name,
|
model: model_name,
|
||||||
messages: [
|
messages: [
|
||||||
|
|||||||
108
scripts/seed-test-users.js
Normal file
108
scripts/seed-test-users.js
Normal file
@@ -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();
|
||||||
146
src/components/ErrorModal.jsx
Normal file
146
src/components/ErrorModal.jsx
Normal file
@@ -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 (
|
||||||
|
<ErrorModalContext.Provider value={{ showError, hideError }}>
|
||||||
|
{children}
|
||||||
|
{error && (
|
||||||
|
<ErrorModalOverlay
|
||||||
|
error={error}
|
||||||
|
onClose={hideError}
|
||||||
|
onCopy={copyErrorMessage}
|
||||||
|
copied={copied}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ErrorModalContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-lg animate-modal-appear">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 p-6 border-b border-gray-100">
|
||||||
|
<div className="flex-shrink-0 w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
|
||||||
|
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">{error.title}</h3>
|
||||||
|
<p className="text-sm text-gray-500">發生異常情況</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
||||||
|
<p className="text-red-800 font-medium mb-2">錯誤訊息</p>
|
||||||
|
<p className="text-red-700 text-sm break-words">{error.message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error.details && (
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||||
|
<p className="text-gray-700 font-medium mb-2">詳細資訊</p>
|
||||||
|
<pre className="text-gray-600 text-xs whitespace-pre-wrap break-words max-h-40 overflow-y-auto font-mono">
|
||||||
|
{error.details}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-100 bg-gray-50 rounded-b-2xl">
|
||||||
|
<button
|
||||||
|
onClick={onCopy}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition ${
|
||||||
|
copied
|
||||||
|
? 'bg-green-100 text-green-700 border border-green-300'
|
||||||
|
: 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
已複製
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
複製異常訊息
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 transition"
|
||||||
|
>
|
||||||
|
確定
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ export function AuthProvider({ children }) {
|
|||||||
setUser(response.user);
|
setUser(response.user);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log('Not authenticated');
|
// 401 是預期行為(用戶未登入),靜默處理
|
||||||
setUser(null);
|
setUser(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { useErrorModal } from '../components/ErrorModal';
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const [activeTab, setActiveTab] = useState('dashboard');
|
const [activeTab, setActiveTab] = useState('dashboard');
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin, isSuperAdmin } = useAuth();
|
||||||
|
|
||||||
if (!isAdmin()) {
|
if (!isAdmin()) {
|
||||||
return (
|
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 (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h2 className="text-3xl font-bold text-gray-900">管理者儀表板</h2>
|
<h2 className="text-3xl font-bold text-gray-900">管理者儀表板</h2>
|
||||||
<p className="text-gray-600 mt-2">系統管理與監控</p>
|
<p className="text-gray-600 mt-2">
|
||||||
|
系統管理與監控
|
||||||
|
{isSuperAdmin() && <span className="ml-2 text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded">超級管理員</span>}
|
||||||
|
{!isSuperAdmin() && isAdmin() && <span className="ml-2 text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded">管理員</span>}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="border-b border-gray-200 mb-6">
|
<div className="border-b border-gray-200 mb-6">
|
||||||
<nav className="flex space-x-4">
|
<nav className="flex space-x-4 flex-wrap">
|
||||||
{[
|
{visibleTabs.map(tab => (
|
||||||
{ id: 'dashboard', name: '總覽', icon: '📊' },
|
|
||||||
{ id: 'users', name: '使用者管理', icon: '👥' },
|
|
||||||
{ id: 'analyses', name: '分析記錄', icon: '📝' },
|
|
||||||
{ id: 'llm', name: 'LLM 配置', icon: '🤖' },
|
|
||||||
{ id: 'audit', name: '稽核日誌', icon: '🔍' },
|
|
||||||
].map(tab => (
|
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
@@ -51,8 +64,10 @@ export default function AdminPage() {
|
|||||||
{activeTab === 'dashboard' && <DashboardTab />}
|
{activeTab === 'dashboard' && <DashboardTab />}
|
||||||
{activeTab === 'users' && <UsersTab />}
|
{activeTab === 'users' && <UsersTab />}
|
||||||
{activeTab === 'analyses' && <AnalysesTab />}
|
{activeTab === 'analyses' && <AnalysesTab />}
|
||||||
{activeTab === 'llm' && <LLMConfigTab />}
|
{activeTab === 'llm' && isSuperAdmin() && <LLMConfigTab />}
|
||||||
{activeTab === 'audit' && <AuditTab />}
|
{activeTab === 'llmtest' && isSuperAdmin() && <LLMTestTab />}
|
||||||
|
{activeTab === 'audit' && isSuperAdmin() && <AuditTab />}
|
||||||
|
{activeTab === 'statistics' && isSuperAdmin() && <StatisticsTab />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -141,6 +156,8 @@ function UsersTab() {
|
|||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const { showError } = useErrorModal();
|
||||||
|
const { isSuperAdmin } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
@@ -153,7 +170,7 @@ function UsersTab() {
|
|||||||
setUsers(response.data);
|
setUsers(response.data);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
showError('載入使用者失敗', err.message, err.stack);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -166,7 +183,7 @@ function UsersTab() {
|
|||||||
await api.deleteUser(id);
|
await api.deleteUser(id);
|
||||||
loadUsers();
|
loadUsers();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('刪除失敗: ' + err.message);
|
showError('刪除使用者失敗', err.message, err.stack);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -219,12 +236,14 @@ function UsersTab() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm">
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm">
|
||||||
<button
|
{isSuperAdmin() && (
|
||||||
onClick={() => deleteUser(user.id)}
|
<button
|
||||||
className="text-red-600 hover:text-red-900"
|
onClick={() => deleteUser(user.id)}
|
||||||
>
|
className="text-red-600 hover:text-red-900"
|
||||||
刪除
|
>
|
||||||
</button>
|
刪除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -374,6 +393,7 @@ function LLMConfigTab() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [editingConfig, setEditingConfig] = useState(null);
|
const [editingConfig, setEditingConfig] = useState(null);
|
||||||
|
const { showError } = useErrorModal();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConfigs();
|
loadConfigs();
|
||||||
@@ -386,7 +406,7 @@ function LLMConfigTab() {
|
|||||||
setConfigs(response.data);
|
setConfigs(response.data);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
showError('載入 LLM 配置失敗', err.message, err.stack);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -397,7 +417,7 @@ function LLMConfigTab() {
|
|||||||
await api.activateLLMConfig(id);
|
await api.activateLLMConfig(id);
|
||||||
loadConfigs();
|
loadConfigs();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('啟用失敗: ' + err.message);
|
showError('啟用 LLM 配置失敗', err.message, err.stack);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -408,7 +428,7 @@ function LLMConfigTab() {
|
|||||||
await api.deleteLLMConfig(id);
|
await api.deleteLLMConfig(id);
|
||||||
loadConfigs();
|
loadConfigs();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('刪除失敗: ' + err.message);
|
showError('刪除 LLM 配置失敗', err.message, err.stack);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -448,10 +468,10 @@ function LLMConfigTab() {
|
|||||||
{configs.map((config) => (
|
{configs.map((config) => (
|
||||||
<tr key={config.id} className="hover:bg-gray-50">
|
<tr key={config.id} className="hover:bg-gray-50">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className="font-medium text-gray-900">{config.provider_name}</span>
|
<span className="font-medium text-gray-900">{config.provider}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-gray-600 max-w-xs truncate">
|
<td className="px-6 py-4 text-sm text-gray-600 max-w-xs truncate">
|
||||||
{config.api_endpoint}
|
{config.api_url}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
{config.model_name}
|
{config.model_name}
|
||||||
@@ -520,39 +540,91 @@ function LLMConfigTab() {
|
|||||||
// LLM Config Modal
|
// LLM Config Modal
|
||||||
function LLMConfigModal({ config, onClose, onSuccess }) {
|
function LLMConfigModal({ config, onClose, onSuccess }) {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
provider_name: config?.provider_name || 'DeepSeek',
|
provider: config?.provider || 'Ollama',
|
||||||
api_endpoint: config?.api_endpoint || 'https://api.deepseek.com',
|
api_url: config?.api_url || 'https://ollama_pjapi.theaken.com',
|
||||||
api_key: '',
|
api_key: '',
|
||||||
model_name: config?.model_name || 'deepseek-chat',
|
model_name: config?.model_name || 'deepseek-chat',
|
||||||
temperature: config?.temperature || 0.7,
|
temperature: config?.temperature || 0.7,
|
||||||
max_tokens: config?.max_tokens || 6000,
|
max_tokens: config?.max_tokens || 6000,
|
||||||
timeout_seconds: config?.timeout_seconds || 120,
|
timeout: config?.timeout || 120000,
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [testResult, setTestResult] = useState('');
|
const [testResult, setTestResult] = useState('');
|
||||||
|
const [availableModels, setAvailableModels] = useState([]);
|
||||||
|
const [loadingModels, setLoadingModels] = useState(false);
|
||||||
|
|
||||||
const providerPresets = {
|
const providerPresets = React.useMemo(() => ({
|
||||||
DeepSeek: {
|
|
||||||
api_endpoint: 'https://api.deepseek.com',
|
|
||||||
model_name: 'deepseek-chat',
|
|
||||||
},
|
|
||||||
Ollama: {
|
Ollama: {
|
||||||
api_endpoint: 'https://ollama_pjapi.theaken.com',
|
api_url: 'https://ollama_pjapi.theaken.com',
|
||||||
model_name: 'qwen2.5:3b',
|
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: {
|
OpenAI: {
|
||||||
api_endpoint: 'https://api.openai.com',
|
api_url: 'https://api.openai.com',
|
||||||
model_name: 'gpt-4',
|
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 handleProviderChange = (provider) => {
|
||||||
|
const preset = providerPresets[provider];
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
provider_name: provider,
|
provider: provider,
|
||||||
...(providerPresets[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 {
|
try {
|
||||||
const response = await api.testLLMConfig({
|
const response = await api.testLLMConfig({
|
||||||
api_endpoint: formData.api_endpoint,
|
api_url: formData.api_url,
|
||||||
api_key: formData.api_key,
|
api_key: formData.api_key,
|
||||||
model_name: formData.model_name,
|
model_name: formData.model_name,
|
||||||
});
|
});
|
||||||
@@ -617,13 +689,13 @@ function LLMConfigModal({ config, onClose, onSuccess }) {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">提供商 *</label>
|
<label className="block text-sm font-medium mb-1">提供商 *</label>
|
||||||
<select
|
<select
|
||||||
value={formData.provider_name}
|
value={formData.provider}
|
||||||
onChange={(e) => handleProviderChange(e.target.value)}
|
onChange={(e) => handleProviderChange(e.target.value)}
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
|
<option value="Ollama">Ollama (本地部署)</option>
|
||||||
<option value="DeepSeek">DeepSeek</option>
|
<option value="DeepSeek">DeepSeek</option>
|
||||||
<option value="Ollama">Ollama</option>
|
|
||||||
<option value="OpenAI">OpenAI</option>
|
<option value="OpenAI">OpenAI</option>
|
||||||
<option value="Other">其他</option>
|
<option value="Other">其他</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -631,14 +703,24 @@ function LLMConfigModal({ config, onClose, onSuccess }) {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">API 端點 *</label>
|
<label className="block text-sm font-medium mb-1">API 端點 *</label>
|
||||||
<input
|
<div className="flex space-x-2">
|
||||||
type="url"
|
<input
|
||||||
value={formData.api_endpoint}
|
type="url"
|
||||||
onChange={(e) => setFormData({...formData, api_endpoint: e.target.value})}
|
value={formData.api_url}
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
onChange={(e) => setFormData({...formData, api_url: e.target.value})}
|
||||||
placeholder="https://api.deepseek.com"
|
className="flex-1 px-3 py-2 border rounded-lg"
|
||||||
required
|
placeholder="https://ollama_pjapi.theaken.com"
|
||||||
/>
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={loadOllamaModels}
|
||||||
|
disabled={loadingModels}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{loadingModels ? '掃描中...' : '🔍 掃描模型'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -648,20 +730,74 @@ function LLMConfigModal({ config, onClose, onSuccess }) {
|
|||||||
value={formData.api_key}
|
value={formData.api_key}
|
||||||
onChange={(e) => setFormData({...formData, api_key: e.target.value})}
|
onChange={(e) => setFormData({...formData, api_key: e.target.value})}
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
placeholder="選填(某些 API 需要)"
|
placeholder="選填(Ollama 不需要)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">模型名稱 *</label>
|
<label className="block text-sm font-medium mb-1">
|
||||||
<input
|
模型名稱 *
|
||||||
type="text"
|
{loadingModels && <span className="text-xs text-blue-600 ml-2 animate-pulse">掃描中...</span>}
|
||||||
value={formData.model_name}
|
{!loadingModels && availableModels.length > 0 && (
|
||||||
onChange={(e) => setFormData({...formData, model_name: e.target.value})}
|
<span className="text-xs text-green-600 ml-2">已找到 {availableModels.length} 個模型</span>
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
)}
|
||||||
placeholder="deepseek-chat"
|
</label>
|
||||||
required
|
{availableModels.length > 0 ? (
|
||||||
/>
|
<>
|
||||||
|
<select
|
||||||
|
value={formData.model_name}
|
||||||
|
onChange={(e) => {
|
||||||
|
const selectedModel = availableModels.find(m => m.id === e.target.value);
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
model_name: e.target.value
|
||||||
|
});
|
||||||
|
if (selectedModel?.description) {
|
||||||
|
setTestResult(`📋 ${selectedModel.description}${selectedModel.best_for ? `\n✨ 適合: ${selectedModel.best_for}` : ''}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 border border-green-300 rounded-lg bg-green-50"
|
||||||
|
required
|
||||||
|
disabled={loadingModels}
|
||||||
|
>
|
||||||
|
{availableModels.map(model => (
|
||||||
|
<option key={model.id} value={model.id}>
|
||||||
|
{model.name} ({model.id})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="mt-2 max-h-40 overflow-y-auto border rounded-lg bg-gray-50">
|
||||||
|
{availableModels.map(model => (
|
||||||
|
<div
|
||||||
|
key={model.id}
|
||||||
|
onClick={() => 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' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-sm">{model.name}</div>
|
||||||
|
<div className="text-xs text-gray-500">{model.id}</div>
|
||||||
|
{model.description && <div className="text-xs text-gray-600">{model.description}</div>}
|
||||||
|
{model.best_for && <div className="text-xs text-indigo-600">適合: {model.best_for}</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.model_name}
|
||||||
|
onChange={(e) => setFormData({...formData, model_name: e.target.value})}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
|
placeholder="deepseek-chat"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
點擊「掃描模型」按鈕從 API 載入可用模型列表
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
@@ -692,8 +828,8 @@ function LLMConfigModal({ config, onClose, onSuccess }) {
|
|||||||
<label className="block text-sm font-medium mb-1">Timeout (秒)</label>
|
<label className="block text-sm font-medium mb-1">Timeout (秒)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={formData.timeout_seconds}
|
value={formData.timeout / 1000}
|
||||||
onChange={(e) => setFormData({...formData, timeout_seconds: parseInt(e.target.value)})}
|
onChange={(e) => setFormData({...formData, timeout: parseInt(e.target.value) * 1000})}
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* API Configuration */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">LLM API 測試台</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">API 端點</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={apiUrl}
|
||||||
|
onChange={(e) => setApiUrl(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
|
placeholder="https://ollama_pjapi.theaken.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">選擇模型</label>
|
||||||
|
<select
|
||||||
|
value={selectedModel}
|
||||||
|
onChange={(e) => setSelectedModel(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
|
disabled={models.length === 0}
|
||||||
|
>
|
||||||
|
{models.length === 0 ? (
|
||||||
|
<option value="">請先載入模型列表</option>
|
||||||
|
) : (
|
||||||
|
models.map(model => (
|
||||||
|
<option key={model.id} value={model.id}>
|
||||||
|
{model.name} ({model.id})
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-3 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={loadModels}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? '載入中...' : '📋 載入模型列表'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={quickTest}
|
||||||
|
disabled={loading || !selectedModel}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? '測試中...' : '⚡ 快速測試'}
|
||||||
|
</button>
|
||||||
|
<label className="flex items-center space-x-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={useStreaming}
|
||||||
|
onChange={(e) => setUseStreaming(e.target.checked)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span>串流模式</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{testResult && (
|
||||||
|
<div className={`p-3 rounded-lg text-sm whitespace-pre-wrap ${
|
||||||
|
testResult.startsWith('✅')
|
||||||
|
? 'bg-green-50 border border-green-200 text-green-800'
|
||||||
|
: 'bg-red-50 border border-red-200 text-red-800'
|
||||||
|
}`}>
|
||||||
|
{testResult}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Models List */}
|
||||||
|
{models.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">可用模型 ({models.length})</h4>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||||
|
{models.map(model => (
|
||||||
|
<div
|
||||||
|
key={model.id}
|
||||||
|
onClick={() => 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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-sm">{model.name}</div>
|
||||||
|
<div className="text-xs text-gray-500">{model.id}</div>
|
||||||
|
{model.description && (
|
||||||
|
<div className="text-xs text-gray-600 mt-1">{model.description}</div>
|
||||||
|
)}
|
||||||
|
{model.best_for && (
|
||||||
|
<div className="text-xs text-indigo-600 mt-1">適合: {model.best_for}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat Console */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-semibold">對話測試</h3>
|
||||||
|
<button
|
||||||
|
onClick={clearChat}
|
||||||
|
className="text-sm text-gray-600 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
🗑️ 清除對話
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat Messages */}
|
||||||
|
<div className="border rounded-lg h-80 overflow-y-auto mb-4 p-4 bg-gray-50">
|
||||||
|
{chatMessages.length === 0 && !streamingContent && (
|
||||||
|
<div className="text-center text-gray-500 py-8">
|
||||||
|
選擇模型後開始對話測試
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{chatMessages.map((msg, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`mb-3 ${msg.role === 'user' ? 'text-right' : 'text-left'}`}
|
||||||
|
>
|
||||||
|
<div className={`inline-block max-w-[80%] p-3 rounded-lg ${
|
||||||
|
msg.role === 'user'
|
||||||
|
? 'bg-indigo-600 text-white'
|
||||||
|
: 'bg-white border border-gray-200 text-gray-800'
|
||||||
|
}`}>
|
||||||
|
<div className="text-xs opacity-70 mb-1">
|
||||||
|
{msg.role === 'user' ? '你' : 'AI'}
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-pre-wrap text-sm">{msg.content}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{streamingContent && (
|
||||||
|
<div className="mb-3 text-left">
|
||||||
|
<div className="inline-block max-w-[80%] p-3 rounded-lg bg-white border border-gray-200 text-gray-800">
|
||||||
|
<div className="text-xs opacity-70 mb-1">AI</div>
|
||||||
|
<div className="whitespace-pre-wrap text-sm">{streamingContent}</div>
|
||||||
|
<span className="animate-pulse">▊</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{chatLoading && !streamingContent && (
|
||||||
|
<div className="text-center text-gray-500">
|
||||||
|
<span className="animate-pulse">思考中...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat Input */}
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={chatInput}
|
||||||
|
onChange={(e) => setChatInput(e.target.value)}
|
||||||
|
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
|
||||||
|
placeholder="輸入訊息..."
|
||||||
|
className="flex-1 px-4 py-2 border rounded-lg"
|
||||||
|
disabled={chatLoading || !selectedModel}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={sendMessage}
|
||||||
|
disabled={chatLoading || !chatInput.trim() || !selectedModel}
|
||||||
|
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
發送
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Create User Modal
|
// Create User Modal
|
||||||
function CreateUserModal({ onClose, onSuccess }) {
|
function CreateUserModal({ onClose, onSuccess }) {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -849,3 +1336,121 @@ function CreateUserModal({ onClose, onSuccess }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 <div className="text-center py-12">載入中...</div>;
|
||||||
|
|
||||||
|
if (!stats) return <div className="text-center py-12 text-gray-500">無法載入統計資料</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Overall Statistics */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">整體分析統計</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="text-center p-4 bg-blue-50 rounded-lg">
|
||||||
|
<p className="text-3xl font-bold text-blue-600">{stats.overall?.total || 0}</p>
|
||||||
|
<p className="text-sm text-gray-600">總分析數</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-green-50 rounded-lg">
|
||||||
|
<p className="text-3xl font-bold text-green-600">{stats.overall?.completed || 0}</p>
|
||||||
|
<p className="text-sm text-gray-600">完成</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-red-50 rounded-lg">
|
||||||
|
<p className="text-3xl font-bold text-red-600">{stats.overall?.failed || 0}</p>
|
||||||
|
<p className="text-sm text-gray-600">失敗</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-yellow-50 rounded-lg">
|
||||||
|
<p className="text-3xl font-bold text-yellow-600">{stats.overall?.processing || 0}</p>
|
||||||
|
<p className="text-sm text-gray-600">處理中</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Statistics */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">使用者統計</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* By Role */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-3">依角色分布</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{stats.users?.byRole && Object.entries(stats.users.byRole).map(([role, count]) => (
|
||||||
|
<div key={role} className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||||
|
<span className={`text-sm font-medium ${
|
||||||
|
role === 'super_admin' ? 'text-red-600' :
|
||||||
|
role === 'admin' ? 'text-blue-600' : 'text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{role === 'super_admin' ? '超級管理員' : role === 'admin' ? '管理員' : '一般使用者'}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold">{count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* By Department */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-3">依部門分布</h4>
|
||||||
|
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||||
|
{stats.users?.byDepartment && Object.entries(stats.users.byDepartment).map(([dept, data]) => (
|
||||||
|
<div key={dept} className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||||
|
<span className="text-sm">{dept}</span>
|
||||||
|
<span className="text-sm">
|
||||||
|
<span className="font-bold">{data.active}</span>
|
||||||
|
<span className="text-gray-400">/{data.total}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">摘要</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600">總使用者數:</span>
|
||||||
|
<span className="font-bold ml-2">{stats.users?.total || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600">平均處理時間:</span>
|
||||||
|
<span className="font-bold ml-2">{Math.round(stats.overall?.avg_processing_time || 0)} 秒</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600">成功率:</span>
|
||||||
|
<span className="font-bold ml-2">
|
||||||
|
{stats.overall?.total > 0
|
||||||
|
? ((stats.overall.completed / stats.overall.total) * 100).toFixed(1)
|
||||||
|
: 0}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
|
import { useErrorModal } from '../components/ErrorModal';
|
||||||
|
|
||||||
export default function AnalyzePage() {
|
export default function AnalyzePage() {
|
||||||
const [finding, setFinding] = useState('');
|
const [finding, setFinding] = useState('');
|
||||||
@@ -7,7 +8,7 @@ export default function AnalyzePage() {
|
|||||||
const [outputLanguage, setOutputLanguage] = useState('zh-TW');
|
const [outputLanguage, setOutputLanguage] = useState('zh-TW');
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const { showError } = useErrorModal();
|
||||||
|
|
||||||
const languages = [
|
const languages = [
|
||||||
{ code: 'zh-TW', name: '繁體中文' },
|
{ code: 'zh-TW', name: '繁體中文' },
|
||||||
@@ -21,17 +22,18 @@ export default function AnalyzePage() {
|
|||||||
|
|
||||||
const handleAnalyze = async (e) => {
|
const handleAnalyze = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.createAnalysis(finding, jobContent, outputLanguage);
|
const response = await api.createAnalysis(finding, jobContent, outputLanguage);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setResult(response.analysis);
|
setResult(response.data);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || '分析失敗,請稍後再試');
|
const errorMessage = err.message || '分析失敗,請稍後再試';
|
||||||
|
const errorDetails = err.response?.data?.details || err.stack || null;
|
||||||
|
showError('分析錯誤', errorMessage, errorDetails);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -41,7 +43,6 @@ export default function AnalyzePage() {
|
|||||||
setFinding('');
|
setFinding('');
|
||||||
setJobContent('');
|
setJobContent('');
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setError('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,12 +98,6 @@ export default function AnalyzePage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
|
||||||
<p className="text-sm">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex space-x-4">
|
<div className="flex space-x-4">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -138,39 +133,44 @@ export default function AnalyzePage() {
|
|||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h3 className="text-2xl font-bold text-gray-900">分析結果</h3>
|
<h3 className="text-2xl font-bold text-gray-900">分析結果</h3>
|
||||||
<span className="px-3 py-1 bg-green-100 text-green-700 text-sm font-medium rounded-full">
|
<span className="px-3 py-1 bg-green-100 text-green-700 text-sm font-medium rounded-full">
|
||||||
✓ 完成
|
✓ 完成 ({result.processingTime}秒)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Perspectives */}
|
{/* Problem Restatement */}
|
||||||
{result.perspectives && result.perspectives.length > 0 && (
|
{result.problemRestatement && (
|
||||||
|
<div className="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg">
|
||||||
|
<p className="text-sm font-medium text-gray-600 mb-1">問題重述 (5W1H):</p>
|
||||||
|
<p className="text-gray-900">{result.problemRestatement}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Analyses */}
|
||||||
|
{result.analyses && result.analyses.length > 0 && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{result.perspectives.map((perspective, index) => (
|
{result.analyses.map((analysis, index) => (
|
||||||
<div key={index} className="border border-gray-200 rounded-lg p-5">
|
<div key={index} className="border border-gray-200 rounded-lg p-5">
|
||||||
<div className="flex items-center mb-4">
|
<div className="flex items-center mb-4">
|
||||||
<span className="text-2xl mr-3">
|
<span className="text-2xl mr-3">{analysis.perspectiveIcon || '🔍'}</span>
|
||||||
{perspective.perspective_type === 'technical' && '⚙️'}
|
<h4 className="text-xl font-semibold text-gray-800">{analysis.perspective}</h4>
|
||||||
{perspective.perspective_type === 'process' && '📋'}
|
|
||||||
{perspective.perspective_type === 'human' && '👤'}
|
|
||||||
</span>
|
|
||||||
<h4 className="text-xl font-semibold text-gray-800">
|
|
||||||
{perspective.perspective_type === 'technical' && '技術角度'}
|
|
||||||
{perspective.perspective_type === 'process' && '流程角度'}
|
|
||||||
{perspective.perspective_type === 'human' && '人員角度'}
|
|
||||||
</h4>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 5 Whys */}
|
{/* 5 Whys */}
|
||||||
{perspective.whys && perspective.whys.length > 0 && (
|
{analysis.whys && analysis.whys.length > 0 && (
|
||||||
<div className="space-y-3 ml-10">
|
<div className="space-y-3 ml-10">
|
||||||
{perspective.whys.map((why, wIndex) => (
|
{analysis.whys.map((why, wIndex) => (
|
||||||
<div key={wIndex} className="flex">
|
<div key={wIndex} className="border-l-2 border-indigo-200 pl-4 py-2">
|
||||||
<div className="flex-shrink-0 w-24 font-medium text-indigo-600">
|
<div className="flex items-start">
|
||||||
Why {why.why_level}:
|
<span className="flex-shrink-0 w-20 font-medium text-indigo-600">
|
||||||
</div>
|
Why {why.level}:
|
||||||
<div className="flex-1">
|
</span>
|
||||||
<p className="text-gray-700">{why.question}</p>
|
<div className="flex-1">
|
||||||
<p className="text-gray-900 font-medium mt-1">{why.answer}</p>
|
<p className="text-gray-700 font-medium">{why.question}</p>
|
||||||
|
<p className="text-gray-900 mt-1">{why.answer}</p>
|
||||||
|
<p className={`text-xs mt-1 ${why.isVerified ? 'text-green-600' : 'text-orange-600'}`}>
|
||||||
|
{why.isVerified ? '✓ 已確認' : '⚠ 待驗證'}: {why.verificationNote}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -178,18 +178,50 @@ export default function AnalyzePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Root Cause */}
|
{/* Root Cause */}
|
||||||
{perspective.root_cause && (
|
{analysis.rootCause && (
|
||||||
<div className="mt-4 ml-10 p-4 bg-red-50 border border-red-200 rounded-lg">
|
<div className="mt-4 ml-10 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
<p className="text-sm font-medium text-red-700 mb-1">根本原因:</p>
|
<p className="text-sm font-medium text-red-700 mb-1">🎯 根本原因:</p>
|
||||||
<p className="text-red-900">{perspective.root_cause}</p>
|
<p className="text-red-900">{analysis.rootCause}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Solution */}
|
{/* Logic Check */}
|
||||||
{perspective.solution && (
|
{analysis.logicCheck && (
|
||||||
|
<div className="mt-3 ml-10 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<p className="text-sm font-medium text-blue-700 mb-2">🔄 邏輯檢核:</p>
|
||||||
|
<p className="text-sm text-blue-800">➡️ {analysis.logicCheck.forward}</p>
|
||||||
|
<p className="text-sm text-blue-800">⬅️ {analysis.logicCheck.backward}</p>
|
||||||
|
<p className={`text-xs mt-2 ${analysis.logicCheck.isValid ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{analysis.logicCheck.isValid ? '✓ 邏輯有效' : '✗ 邏輯需檢討'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Countermeasure */}
|
||||||
|
{analysis.countermeasure && (
|
||||||
<div className="mt-3 ml-10 p-4 bg-green-50 border border-green-200 rounded-lg">
|
<div className="mt-3 ml-10 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||||
<p className="text-sm font-medium text-green-700 mb-1">建議解決方案:</p>
|
<p className="text-sm font-medium text-green-700 mb-2">💡 永久對策:</p>
|
||||||
<p className="text-green-900">{perspective.solution}</p>
|
<p className="text-green-900 font-medium">{analysis.countermeasure.permanent}</p>
|
||||||
|
{analysis.countermeasure.actionItems && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-xs text-green-700">行動項目:</p>
|
||||||
|
<ul className="list-disc list-inside text-sm text-green-800">
|
||||||
|
{analysis.countermeasure.actionItems.map((item, i) => (
|
||||||
|
<li key={i}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{analysis.countermeasure.avoidList && analysis.countermeasure.avoidList.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-xs text-orange-700">避免的暫時性做法:</p>
|
||||||
|
<ul className="list-disc list-inside text-sm text-orange-800">
|
||||||
|
{analysis.countermeasure.avoidList.map((item, i) => (
|
||||||
|
<li key={i}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -200,7 +232,6 @@ export default function AnalyzePage() {
|
|||||||
{/* Metadata */}
|
{/* Metadata */}
|
||||||
<div className="mt-6 pt-6 border-t border-gray-200 text-sm text-gray-500">
|
<div className="mt-6 pt-6 border-t border-gray-200 text-sm text-gray-500">
|
||||||
<p>分析 ID: {result.id}</p>
|
<p>分析 ID: {result.id}</p>
|
||||||
<p>分析時間: {new Date(result.created_at).toLocaleString('zh-TW')}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ class ApiClient {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API Error:', error);
|
// 只對非認證錯誤顯示控制台訊息
|
||||||
|
if (!endpoint.includes('/auth/me') || !error.message.includes('未登入')) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
279
分層授權.md
Normal file
279
分層授權.md
Normal file
@@ -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
|
||||||
344
系統使用說明書.md
Normal file
344
系統使用說明書.md
Normal file
@@ -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、<think>等)
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user