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:
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);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Not authenticated');
|
||||
// 401 是預期行為(用戶未登入),靜默處理
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useErrorModal } from '../components/ErrorModal';
|
||||
|
||||
export default function AdminPage() {
|
||||
const [activeTab, setActiveTab] = useState('dashboard');
|
||||
const { isAdmin } = useAuth();
|
||||
const { isAdmin, isSuperAdmin } = useAuth();
|
||||
|
||||
if (!isAdmin()) {
|
||||
return (
|
||||
@@ -14,23 +15,35 @@ export default function AdminPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// 根據角色定義可見的標籤頁
|
||||
const allTabs = [
|
||||
{ id: 'dashboard', name: '總覽', icon: '📊', requireSuperAdmin: false },
|
||||
{ id: 'users', name: '使用者管理', icon: '👥', requireSuperAdmin: false },
|
||||
{ id: 'analyses', name: '分析記錄', icon: '📝', requireSuperAdmin: false },
|
||||
{ id: 'llm', name: 'LLM 配置', icon: '🤖', requireSuperAdmin: true },
|
||||
{ id: 'llmtest', name: 'LLM 測試台', icon: '🧪', requireSuperAdmin: true },
|
||||
{ id: 'audit', name: '稽核日誌', icon: '🔍', requireSuperAdmin: true },
|
||||
{ id: 'statistics', name: '系統統計', icon: '📈', requireSuperAdmin: true },
|
||||
];
|
||||
|
||||
// 過濾出當前使用者可見的標籤頁
|
||||
const visibleTabs = allTabs.filter(tab => !tab.requireSuperAdmin || isSuperAdmin());
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<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>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<nav className="flex space-x-4">
|
||||
{[
|
||||
{ id: 'dashboard', name: '總覽', icon: '📊' },
|
||||
{ id: 'users', name: '使用者管理', icon: '👥' },
|
||||
{ id: 'analyses', name: '分析記錄', icon: '📝' },
|
||||
{ id: 'llm', name: 'LLM 配置', icon: '🤖' },
|
||||
{ id: 'audit', name: '稽核日誌', icon: '🔍' },
|
||||
].map(tab => (
|
||||
<nav className="flex space-x-4 flex-wrap">
|
||||
{visibleTabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
@@ -51,8 +64,10 @@ export default function AdminPage() {
|
||||
{activeTab === 'dashboard' && <DashboardTab />}
|
||||
{activeTab === 'users' && <UsersTab />}
|
||||
{activeTab === 'analyses' && <AnalysesTab />}
|
||||
{activeTab === 'llm' && <LLMConfigTab />}
|
||||
{activeTab === 'audit' && <AuditTab />}
|
||||
{activeTab === 'llm' && isSuperAdmin() && <LLMConfigTab />}
|
||||
{activeTab === 'llmtest' && isSuperAdmin() && <LLMTestTab />}
|
||||
{activeTab === 'audit' && isSuperAdmin() && <AuditTab />}
|
||||
{activeTab === 'statistics' && isSuperAdmin() && <StatisticsTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -141,6 +156,8 @@ function UsersTab() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const { showError } = useErrorModal();
|
||||
const { isSuperAdmin } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
@@ -153,7 +170,7 @@ function UsersTab() {
|
||||
setUsers(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showError('載入使用者失敗', err.message, err.stack);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -166,7 +183,7 @@ function UsersTab() {
|
||||
await api.deleteUser(id);
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
alert('刪除失敗: ' + err.message);
|
||||
showError('刪除使用者失敗', err.message, err.stack);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -219,12 +236,14 @@ function UsersTab() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm">
|
||||
<button
|
||||
onClick={() => deleteUser(user.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
刪除
|
||||
</button>
|
||||
{isSuperAdmin() && (
|
||||
<button
|
||||
onClick={() => deleteUser(user.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
刪除
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -374,6 +393,7 @@ function LLMConfigTab() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState(null);
|
||||
const { showError } = useErrorModal();
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigs();
|
||||
@@ -386,7 +406,7 @@ function LLMConfigTab() {
|
||||
setConfigs(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showError('載入 LLM 配置失敗', err.message, err.stack);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -397,7 +417,7 @@ function LLMConfigTab() {
|
||||
await api.activateLLMConfig(id);
|
||||
loadConfigs();
|
||||
} catch (err) {
|
||||
alert('啟用失敗: ' + err.message);
|
||||
showError('啟用 LLM 配置失敗', err.message, err.stack);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -408,7 +428,7 @@ function LLMConfigTab() {
|
||||
await api.deleteLLMConfig(id);
|
||||
loadConfigs();
|
||||
} catch (err) {
|
||||
alert('刪除失敗: ' + err.message);
|
||||
showError('刪除 LLM 配置失敗', err.message, err.stack);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -448,10 +468,10 @@ function LLMConfigTab() {
|
||||
{configs.map((config) => (
|
||||
<tr key={config.id} className="hover:bg-gray-50">
|
||||
<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 className="px-6 py-4 text-sm text-gray-600 max-w-xs truncate">
|
||||
{config.api_endpoint}
|
||||
{config.api_url}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{config.model_name}
|
||||
@@ -520,39 +540,91 @@ function LLMConfigTab() {
|
||||
// LLM Config Modal
|
||||
function LLMConfigModal({ config, onClose, onSuccess }) {
|
||||
const [formData, setFormData] = useState({
|
||||
provider_name: config?.provider_name || 'DeepSeek',
|
||||
api_endpoint: config?.api_endpoint || 'https://api.deepseek.com',
|
||||
provider: config?.provider || 'Ollama',
|
||||
api_url: config?.api_url || 'https://ollama_pjapi.theaken.com',
|
||||
api_key: '',
|
||||
model_name: config?.model_name || 'deepseek-chat',
|
||||
temperature: config?.temperature || 0.7,
|
||||
max_tokens: config?.max_tokens || 6000,
|
||||
timeout_seconds: config?.timeout_seconds || 120,
|
||||
timeout: config?.timeout || 120000,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [testResult, setTestResult] = useState('');
|
||||
const [availableModels, setAvailableModels] = useState([]);
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
|
||||
const providerPresets = {
|
||||
DeepSeek: {
|
||||
api_endpoint: 'https://api.deepseek.com',
|
||||
model_name: 'deepseek-chat',
|
||||
},
|
||||
const providerPresets = React.useMemo(() => ({
|
||||
Ollama: {
|
||||
api_endpoint: 'https://ollama_pjapi.theaken.com',
|
||||
model_name: 'qwen2.5:3b',
|
||||
api_url: 'https://ollama_pjapi.theaken.com',
|
||||
model_name: 'deepseek-chat',
|
||||
models: [] // Will be loaded dynamically
|
||||
},
|
||||
DeepSeek: {
|
||||
api_url: 'https://api.deepseek.com',
|
||||
model_name: 'deepseek-chat',
|
||||
models: [
|
||||
{ id: 'deepseek-chat', name: 'DeepSeek Chat' },
|
||||
{ id: 'deepseek-coder', name: 'DeepSeek Coder' }
|
||||
]
|
||||
},
|
||||
OpenAI: {
|
||||
api_endpoint: 'https://api.openai.com',
|
||||
api_url: 'https://api.openai.com',
|
||||
model_name: 'gpt-4',
|
||||
models: [
|
||||
{ id: 'gpt-4', name: 'GPT-4' },
|
||||
{ id: 'gpt-4-turbo', name: 'GPT-4 Turbo' },
|
||||
{ id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo' }
|
||||
]
|
||||
},
|
||||
};
|
||||
}), []);
|
||||
|
||||
// Load Ollama models function
|
||||
const loadOllamaModels = React.useCallback(async () => {
|
||||
setLoadingModels(true);
|
||||
try {
|
||||
const response = await fetch(`${formData.api_url}/v1/models`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.data && Array.isArray(data.data)) {
|
||||
const models = data.data.map(model => ({
|
||||
id: model.id,
|
||||
name: model.info?.name || model.id,
|
||||
description: model.info?.description || '',
|
||||
best_for: model.info?.best_for || ''
|
||||
}));
|
||||
setAvailableModels(models);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load Ollama models:', err);
|
||||
setAvailableModels([
|
||||
{ id: 'deepseek-chat', name: 'DeepSeek Chat' },
|
||||
{ id: 'deepseek-reasoner', name: 'DeepSeek Reasoner' },
|
||||
{ id: 'gpt-oss:120b', name: 'GPT-OSS 120B' }
|
||||
]);
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
}
|
||||
}, [formData.api_url]);
|
||||
|
||||
// Load available models when provider or API endpoint changes
|
||||
useEffect(() => {
|
||||
if (formData.provider === 'Ollama') {
|
||||
loadOllamaModels();
|
||||
} else {
|
||||
const preset = providerPresets[formData.provider];
|
||||
setAvailableModels(preset?.models || []);
|
||||
}
|
||||
}, [formData.provider, formData.api_url, loadOllamaModels, providerPresets]);
|
||||
|
||||
const handleProviderChange = (provider) => {
|
||||
const preset = providerPresets[provider];
|
||||
setFormData({
|
||||
...formData,
|
||||
provider_name: provider,
|
||||
...(providerPresets[provider] || {}),
|
||||
provider: provider,
|
||||
api_url: preset?.api_url || formData.api_url,
|
||||
model_name: preset?.model_name || formData.model_name,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -563,7 +635,7 @@ function LLMConfigModal({ config, onClose, onSuccess }) {
|
||||
|
||||
try {
|
||||
const response = await api.testLLMConfig({
|
||||
api_endpoint: formData.api_endpoint,
|
||||
api_url: formData.api_url,
|
||||
api_key: formData.api_key,
|
||||
model_name: formData.model_name,
|
||||
});
|
||||
@@ -617,13 +689,13 @@ function LLMConfigModal({ config, onClose, onSuccess }) {
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">提供商 *</label>
|
||||
<select
|
||||
value={formData.provider_name}
|
||||
value={formData.provider}
|
||||
onChange={(e) => handleProviderChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
required
|
||||
>
|
||||
<option value="Ollama">Ollama (本地部署)</option>
|
||||
<option value="DeepSeek">DeepSeek</option>
|
||||
<option value="Ollama">Ollama</option>
|
||||
<option value="OpenAI">OpenAI</option>
|
||||
<option value="Other">其他</option>
|
||||
</select>
|
||||
@@ -631,14 +703,24 @@ function LLMConfigModal({ config, onClose, onSuccess }) {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">API 端點 *</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.api_endpoint}
|
||||
onChange={(e) => setFormData({...formData, api_endpoint: e.target.value})}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
placeholder="https://api.deepseek.com"
|
||||
required
|
||||
/>
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="url"
|
||||
value={formData.api_url}
|
||||
onChange={(e) => setFormData({...formData, api_url: e.target.value})}
|
||||
className="flex-1 px-3 py-2 border rounded-lg"
|
||||
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>
|
||||
@@ -648,20 +730,74 @@ function LLMConfigModal({ config, onClose, onSuccess }) {
|
||||
value={formData.api_key}
|
||||
onChange={(e) => setFormData({...formData, api_key: e.target.value})}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
placeholder="選填(某些 API 需要)"
|
||||
placeholder="選填(Ollama 不需要)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">模型名稱 *</label>
|
||||
<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
|
||||
/>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
模型名稱 *
|
||||
{loadingModels && <span className="text-xs text-blue-600 ml-2 animate-pulse">掃描中...</span>}
|
||||
{!loadingModels && availableModels.length > 0 && (
|
||||
<span className="text-xs text-green-600 ml-2">已找到 {availableModels.length} 個模型</span>
|
||||
)}
|
||||
</label>
|
||||
{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 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>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.timeout_seconds}
|
||||
onChange={(e) => setFormData({...formData, timeout_seconds: parseInt(e.target.value)})}
|
||||
value={formData.timeout / 1000}
|
||||
onChange={(e) => setFormData({...formData, timeout: parseInt(e.target.value) * 1000})}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</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
|
||||
function CreateUserModal({ onClose, onSuccess }) {
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -849,3 +1336,121 @@ function CreateUserModal({ onClose, onSuccess }) {
|
||||
</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 api from '../services/api';
|
||||
import { useErrorModal } from '../components/ErrorModal';
|
||||
|
||||
export default function AnalyzePage() {
|
||||
const [finding, setFinding] = useState('');
|
||||
@@ -7,7 +8,7 @@ export default function AnalyzePage() {
|
||||
const [outputLanguage, setOutputLanguage] = useState('zh-TW');
|
||||
const [result, setResult] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const { showError } = useErrorModal();
|
||||
|
||||
const languages = [
|
||||
{ code: 'zh-TW', name: '繁體中文' },
|
||||
@@ -21,17 +22,18 @@ export default function AnalyzePage() {
|
||||
|
||||
const handleAnalyze = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setResult(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await api.createAnalysis(finding, jobContent, outputLanguage);
|
||||
if (response.success) {
|
||||
setResult(response.analysis);
|
||||
setResult(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || '分析失敗,請稍後再試');
|
||||
const errorMessage = err.message || '分析失敗,請稍後再試';
|
||||
const errorDetails = err.response?.data?.details || err.stack || null;
|
||||
showError('分析錯誤', errorMessage, errorDetails);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -41,7 +43,6 @@ export default function AnalyzePage() {
|
||||
setFinding('');
|
||||
setJobContent('');
|
||||
setResult(null);
|
||||
setError('');
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -97,12 +98,6 @@ export default function AnalyzePage() {
|
||||
</select>
|
||||
</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">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -138,39 +133,44 @@ export default function AnalyzePage() {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<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">
|
||||
✓ 完成
|
||||
✓ 完成 ({result.processingTime}秒)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Perspectives */}
|
||||
{result.perspectives && result.perspectives.length > 0 && (
|
||||
{/* Problem Restatement */}
|
||||
{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">
|
||||
{result.perspectives.map((perspective, index) => (
|
||||
{result.analyses.map((analysis, index) => (
|
||||
<div key={index} className="border border-gray-200 rounded-lg p-5">
|
||||
<div className="flex items-center mb-4">
|
||||
<span className="text-2xl mr-3">
|
||||
{perspective.perspective_type === 'technical' && '⚙️'}
|
||||
{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>
|
||||
<span className="text-2xl mr-3">{analysis.perspectiveIcon || '🔍'}</span>
|
||||
<h4 className="text-xl font-semibold text-gray-800">{analysis.perspective}</h4>
|
||||
</div>
|
||||
|
||||
{/* 5 Whys */}
|
||||
{perspective.whys && perspective.whys.length > 0 && (
|
||||
{analysis.whys && analysis.whys.length > 0 && (
|
||||
<div className="space-y-3 ml-10">
|
||||
{perspective.whys.map((why, wIndex) => (
|
||||
<div key={wIndex} className="flex">
|
||||
<div className="flex-shrink-0 w-24 font-medium text-indigo-600">
|
||||
Why {why.why_level}:
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-gray-700">{why.question}</p>
|
||||
<p className="text-gray-900 font-medium mt-1">{why.answer}</p>
|
||||
{analysis.whys.map((why, wIndex) => (
|
||||
<div key={wIndex} className="border-l-2 border-indigo-200 pl-4 py-2">
|
||||
<div className="flex items-start">
|
||||
<span className="flex-shrink-0 w-20 font-medium text-indigo-600">
|
||||
Why {why.level}:
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<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>
|
||||
))}
|
||||
@@ -178,18 +178,50 @@ export default function AnalyzePage() {
|
||||
)}
|
||||
|
||||
{/* Root Cause */}
|
||||
{perspective.root_cause && (
|
||||
{analysis.rootCause && (
|
||||
<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-red-900">{perspective.root_cause}</p>
|
||||
<p className="text-sm font-medium text-red-700 mb-1">🎯 根本原因:</p>
|
||||
<p className="text-red-900">{analysis.rootCause}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Solution */}
|
||||
{perspective.solution && (
|
||||
{/* Logic Check */}
|
||||
{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">
|
||||
<p className="text-sm font-medium text-green-700 mb-1">建議解決方案:</p>
|
||||
<p className="text-green-900">{perspective.solution}</p>
|
||||
<p className="text-sm font-medium text-green-700 mb-2">💡 永久對策:</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>
|
||||
@@ -200,7 +232,6 @@ export default function AnalyzePage() {
|
||||
{/* Metadata */}
|
||||
<div className="mt-6 pt-6 border-t border-gray-200 text-sm text-gray-500">
|
||||
<p>分析 ID: {result.id}</p>
|
||||
<p>分析時間: {new Date(result.created_at).toLocaleString('zh-TW')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -31,7 +31,10 @@ class ApiClient {
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
// 只對非認證錯誤顯示控制台訊息
|
||||
if (!endpoint.includes('/auth/me') || !error.message.includes('未登入')) {
|
||||
console.error('API Error:', error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user