Initial commit: HR Performance System
- Database schema with 31 tables for 4-card system - LLM API integration (Gemini, DeepSeek, OpenAI) - Error handling system with modal component - Connection test UI for LLM services - Environment configuration files - Complete database documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
257
components/ErrorModal.css
Normal file
257
components/ErrorModal.css
Normal file
@@ -0,0 +1,257 @@
|
||||
/* 錯誤彈窗樣式 */
|
||||
|
||||
.error-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.error-modal-overlay.visible {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.error-modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.error-modal.visible {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.error-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.error-modal-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.error-modal-title h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.error-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.error-modal-close:hover {
|
||||
background-color: #f3f4f6;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.error-modal-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 15px;
|
||||
color: #374151;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error-metadata {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
padding: 4px 8px;
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.error-time {
|
||||
padding: 4px 8px;
|
||||
background-color: #f3f4f6;
|
||||
color: #4b5563;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.error-details {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background-color: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.error-details summary {
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: #4b5563;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.error-details summary:hover {
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.error-details pre {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background-color: #1f2937;
|
||||
color: #f9fafb;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.error-path {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.error-path small {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.error-modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.error-countdown {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.error-modal-button {
|
||||
padding: 8px 20px;
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.error-modal-button:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
.error-modal-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Severity Colors */
|
||||
.error-modal.error .error-modal-title h3 {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.error-modal.warning .error-modal-title h3 {
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.error-modal.warning .error-modal-button {
|
||||
background-color: #ea580c;
|
||||
}
|
||||
|
||||
.error-modal.warning .error-modal-button:hover {
|
||||
background-color: #c2410c;
|
||||
}
|
||||
|
||||
.error-modal.info .error-modal-title h3 {
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
.error-modal.info .error-modal-button {
|
||||
background-color: #0284c7;
|
||||
}
|
||||
|
||||
.error-modal.info .error-modal-button:hover {
|
||||
background-color: #0369a1;
|
||||
}
|
||||
|
||||
/* 響應式設計 */
|
||||
@media (max-width: 768px) {
|
||||
.error-modal {
|
||||
width: 95%;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.error-modal-header {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.error-modal-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.error-modal-footer {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.error-countdown {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-modal-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
127
components/ErrorModal.jsx
Normal file
127
components/ErrorModal.jsx
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 錯誤彈窗元件
|
||||
* 統一顯示應用程式中的錯誤訊息
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import './ErrorModal.css';
|
||||
|
||||
const ErrorModal = ({ error, onClose, autoClose = true, duration = 5000 }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [countdown, setCountdown] = useState(duration / 1000);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setIsVisible(true);
|
||||
setCountdown(duration / 1000);
|
||||
|
||||
if (autoClose) {
|
||||
const timer = setTimeout(() => {
|
||||
handleClose();
|
||||
}, duration);
|
||||
|
||||
const countdownInterval = setInterval(() => {
|
||||
setCountdown((prev) => Math.max(0, prev - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
clearInterval(countdownInterval);
|
||||
};
|
||||
}
|
||||
}
|
||||
}, [error, autoClose, duration]);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
if (onClose) onClose();
|
||||
}, 300);
|
||||
};
|
||||
|
||||
if (!error) return null;
|
||||
|
||||
const getSeverityClass = () => {
|
||||
if (!error.statusCode) return 'error';
|
||||
if (error.statusCode >= 500) return 'error';
|
||||
if (error.statusCode >= 400) return 'warning';
|
||||
return 'info';
|
||||
};
|
||||
|
||||
const getSeverityIcon = () => {
|
||||
const severity = getSeverityClass();
|
||||
switch (severity) {
|
||||
case 'error':
|
||||
return '❌';
|
||||
case 'warning':
|
||||
return '⚠️';
|
||||
case 'info':
|
||||
return 'ℹ️';
|
||||
default:
|
||||
return '❌';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`error-modal-overlay ${isVisible ? 'visible' : ''}`} onClick={handleClose}>
|
||||
<div
|
||||
className={`error-modal ${getSeverityClass()} ${isVisible ? 'visible' : ''}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="error-modal-header">
|
||||
<div className="error-modal-title">
|
||||
<span className="error-icon">{getSeverityIcon()}</span>
|
||||
<h3>{error.title || '錯誤'}</h3>
|
||||
</div>
|
||||
<button className="error-modal-close" onClick={handleClose} aria-label="關閉">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="error-modal-body">
|
||||
<p className="error-message">{error.message}</p>
|
||||
|
||||
{error.statusCode && (
|
||||
<div className="error-metadata">
|
||||
<span className="error-code">錯誤代碼: {error.statusCode}</span>
|
||||
{error.timestamp && (
|
||||
<span className="error-time">
|
||||
時間: {new Date(error.timestamp).toLocaleString('zh-TW')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error.details && (
|
||||
<details className="error-details">
|
||||
<summary>詳細資訊</summary>
|
||||
<pre>{JSON.stringify(error.details, null, 2)}</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{error.path && (
|
||||
<div className="error-path">
|
||||
<small>路徑: {error.path}</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="error-modal-footer">
|
||||
{autoClose && (
|
||||
<span className="error-countdown">
|
||||
{countdown > 0 ? `${countdown} 秒後自動關閉` : '關閉中...'}
|
||||
</span>
|
||||
)}
|
||||
<button className="error-modal-button" onClick={handleClose}>
|
||||
確定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorModal;
|
||||
404
components/LLMConnectionTest.css
Normal file
404
components/LLMConnectionTest.css
Normal file
@@ -0,0 +1,404 @@
|
||||
/* LLM 連線測試樣式 */
|
||||
|
||||
.llm-connection-test {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.test-header {
|
||||
margin-bottom: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.test-header h2 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.test-description {
|
||||
font-size: 15px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.test-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.test-all-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.test-all-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.test-all-button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.test-all-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Providers Grid */
|
||||
.providers-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.provider-card {
|
||||
background: white;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.provider-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.provider-card.success {
|
||||
border-color: #10b981;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%);
|
||||
}
|
||||
|
||||
.provider-card.failure {
|
||||
border-color: #ef4444;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #fef2f2 100%);
|
||||
}
|
||||
|
||||
/* Provider Header */
|
||||
.provider-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.provider-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.provider-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.provider-info h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
/* Provider Body */
|
||||
.provider-body {
|
||||
min-height: 120px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.result-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.status-badge.success {
|
||||
background-color: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.status-badge.failure {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.result-message {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #4b5563;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
background-color: #f9fafb;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.error-details {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.error-details summary {
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #ef4444;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.error-details summary:hover {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.error-details pre {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background-color: #1f2937;
|
||||
color: #f9fafb;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.no-result {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Provider Footer */
|
||||
.provider-footer {
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.test-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background: white;
|
||||
border: 2px solid;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.test-button:hover:not(:disabled) {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.test-button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.test-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.spinner.small {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.test-footer {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.info-content strong {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #1e40af;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.info-content p {
|
||||
margin: 0 0 12px 0;
|
||||
color: #1e3a8a;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-content ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.info-content li {
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* 響應式設計 */
|
||||
@media (max-width: 768px) {
|
||||
.llm-connection-test {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.test-header h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.providers-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.provider-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 深色模式支援 (可選) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.llm-connection-test {
|
||||
background-color: #111827;
|
||||
}
|
||||
|
||||
.test-header h2 {
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.test-description {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.provider-card {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.provider-info h3 {
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.result-message {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
background-color: #111827;
|
||||
}
|
||||
|
||||
.test-button {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
.test-button:hover:not(:disabled) {
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 100%);
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.info-content strong,
|
||||
.info-content p,
|
||||
.info-content li {
|
||||
color: #dbeafe;
|
||||
}
|
||||
}
|
||||
228
components/LLMConnectionTest.jsx
Normal file
228
components/LLMConnectionTest.jsx
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* LLM 連線測試元件
|
||||
* 測試 Gemini, DeepSeek, OpenAI 三種 LLM API 的連線狀態
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import './LLMConnectionTest.css';
|
||||
|
||||
const LLMConnectionTest = () => {
|
||||
const [testResults, setTestResults] = useState({
|
||||
gemini: null,
|
||||
deepseek: null,
|
||||
openai: null,
|
||||
});
|
||||
|
||||
const [testing, setTesting] = useState({
|
||||
gemini: false,
|
||||
deepseek: false,
|
||||
openai: false,
|
||||
});
|
||||
|
||||
const [testingAll, setTestingAll] = useState(false);
|
||||
|
||||
const providers = [
|
||||
{
|
||||
id: 'gemini',
|
||||
name: 'Google Gemini',
|
||||
icon: '🤖',
|
||||
color: '#4285f4',
|
||||
},
|
||||
{
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
icon: '🧠',
|
||||
color: '#7c3aed',
|
||||
},
|
||||
{
|
||||
id: 'openai',
|
||||
name: 'OpenAI',
|
||||
icon: '✨',
|
||||
color: '#10a37f',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 測試單一 LLM 連線
|
||||
*/
|
||||
const testConnection = async (provider) => {
|
||||
setTesting((prev) => ({ ...prev, [provider]: true }));
|
||||
setTestResults((prev) => ({ ...prev, [provider]: null }));
|
||||
|
||||
try {
|
||||
const response = await axios.post(`/api/llm/test/${provider}`);
|
||||
setTestResults((prev) => ({ ...prev, [provider]: response.data }));
|
||||
} catch (error) {
|
||||
const errorData = error.response?.data || {
|
||||
success: false,
|
||||
message: error.message || '連線測試失敗',
|
||||
provider,
|
||||
};
|
||||
setTestResults((prev) => ({ ...prev, [provider]: errorData }));
|
||||
} finally {
|
||||
setTesting((prev) => ({ ...prev, [provider]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 測試所有 LLM 連線
|
||||
*/
|
||||
const testAllConnections = async () => {
|
||||
setTestingAll(true);
|
||||
setTestResults({
|
||||
gemini: null,
|
||||
deepseek: null,
|
||||
openai: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/llm/test/all');
|
||||
setTestResults(response.data);
|
||||
} catch (error) {
|
||||
console.error('測試所有連線失敗:', error);
|
||||
} finally {
|
||||
setTestingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 取得測試結果樣式
|
||||
*/
|
||||
const getResultClass = (result) => {
|
||||
if (!result) return '';
|
||||
return result.success ? 'success' : 'failure';
|
||||
};
|
||||
|
||||
/**
|
||||
* 取得測試結果圖示
|
||||
*/
|
||||
const getResultIcon = (result) => {
|
||||
if (!result) return '⏳';
|
||||
return result.success ? '✅' : '❌';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="llm-connection-test">
|
||||
<div className="test-header">
|
||||
<h2>LLM API 連線測試</h2>
|
||||
<p className="test-description">
|
||||
測試與外部 LLM 服務的連線狀態,確保 API 金鑰配置正確
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="test-actions">
|
||||
<button
|
||||
className="test-all-button"
|
||||
onClick={testAllConnections}
|
||||
disabled={testingAll || Object.values(testing).some(Boolean)}
|
||||
>
|
||||
{testingAll ? (
|
||||
<>
|
||||
<span className="spinner"></span>
|
||||
測試中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>🔄</span>
|
||||
測試所有連線
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="providers-grid">
|
||||
{providers.map((provider) => {
|
||||
const result = testResults[provider.id];
|
||||
const isTesting = testing[provider.id];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider.id}
|
||||
className={`provider-card ${getResultClass(result)}`}
|
||||
style={{ borderColor: provider.color }}
|
||||
>
|
||||
<div className="provider-header">
|
||||
<div className="provider-info">
|
||||
<span className="provider-icon" style={{ color: provider.color }}>
|
||||
{provider.icon}
|
||||
</span>
|
||||
<h3>{provider.name}</h3>
|
||||
</div>
|
||||
<span className="result-icon">{getResultIcon(result)}</span>
|
||||
</div>
|
||||
|
||||
<div className="provider-body">
|
||||
{result && (
|
||||
<div className="result-details">
|
||||
<div className={`status-badge ${result.success ? 'success' : 'failure'}`}>
|
||||
{result.success ? '連線成功' : '連線失敗'}
|
||||
</div>
|
||||
|
||||
<p className="result-message">{result.message}</p>
|
||||
|
||||
{result.model && (
|
||||
<div className="result-meta">
|
||||
<span className="meta-label">模型:</span>
|
||||
<span className="meta-value">{result.model}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.error && (
|
||||
<details className="error-details">
|
||||
<summary>錯誤詳情</summary>
|
||||
<pre>{result.error}</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!result && !isTesting && (
|
||||
<p className="no-result">尚未測試</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="provider-footer">
|
||||
<button
|
||||
className="test-button"
|
||||
onClick={() => testConnection(provider.id)}
|
||||
disabled={isTesting || testingAll}
|
||||
style={{ borderColor: provider.color, color: provider.color }}
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<span className="spinner small"></span>
|
||||
測試中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>🔌</span>
|
||||
測試連線
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="test-footer">
|
||||
<div className="info-box">
|
||||
<span className="info-icon">ℹ️</span>
|
||||
<div className="info-content">
|
||||
<strong>提示:</strong>
|
||||
<p>請確保在 .env 文件中正確配置了對應的 API 金鑰</p>
|
||||
<ul>
|
||||
<li>GEMINI_API_KEY - Google Gemini API 金鑰</li>
|
||||
<li>DEEPSEEK_API_KEY - DeepSeek API 金鑰</li>
|
||||
<li>OPENAI_API_KEY - OpenAI API 金鑰</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LLMConnectionTest;
|
||||
Reference in New Issue
Block a user