完成世界书、骰子、apiconfig页面处理
This commit is contained in:
77
frontend/src/components/SideBarLeft/SideBarLeft.css
Normal file
77
frontend/src/components/SideBarLeft/SideBarLeft.css
Normal file
@@ -0,0 +1,77 @@
|
||||
.sidebar-left {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
padding: 0 var(--spacing-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
flex: 1;
|
||||
padding: var(--spacing-md) var(--spacing-sm);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
background-color: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
border-bottom-color: var(--color-accent);
|
||||
background: var(--color-accent-ultra-light);
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-lg);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Tab placeholder for empty states */
|
||||
.tab-placeholder {
|
||||
padding: var(--spacing-lg);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.tab-placeholder h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.tab-placeholder p {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
41
frontend/src/components/SideBarLeft/SideBarLeft.jsx
Normal file
41
frontend/src/components/SideBarLeft/SideBarLeft.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
// frontend-react/src/components/SideBarLeft/SideBarLeft.jsx
|
||||
import React from 'react';
|
||||
import './SideBarLeft.css';
|
||||
import { useSideBarLeftStore } from '../../Store/indexStore';
|
||||
import useSideBarRightStore from '../../Store/SideBarLeft/SideBarLeftSlice';
|
||||
import Gallery from './tabs/Gallery';
|
||||
import CharacterCard from './tabs/CharacterCard';
|
||||
import ApiConfig from './tabs/ApiConfig';
|
||||
import Presets from './tabs/Presets';
|
||||
import WorldBook from './tabs/WorldBook';
|
||||
|
||||
const SideBarLeft = () => {
|
||||
const { activeTab, tabs, setActiveTab } = useSideBarLeftStore();
|
||||
|
||||
return (
|
||||
<div className="sidebar-left">
|
||||
<div className="sidebar-tabs">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`tab-button ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
title={tab.title}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="sidebar-content">
|
||||
{activeTab === 'gallery' && <Gallery />}
|
||||
{activeTab === 'character' && <CharacterCard />}
|
||||
{activeTab === 'api' && <ApiConfig />}
|
||||
{activeTab === 'presets' && <Presets />}
|
||||
{activeTab === 'worldbook' && <WorldBook />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SideBarLeft;
|
||||
1
frontend/src/components/SideBarLeft/index.js
Normal file
1
frontend/src/components/SideBarLeft/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './SideBarLeft';
|
||||
738
frontend/src/components/SideBarLeft/tabs/ApiConfig/ApiConfig.css
Normal file
738
frontend/src/components/SideBarLeft/tabs/ApiConfig/ApiConfig.css
Normal file
@@ -0,0 +1,738 @@
|
||||
/* ApiConfig - Compact Modern Design */
|
||||
.api-config-container {
|
||||
padding: var(--spacing-md);
|
||||
color: var(--color-text-primary);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.api-config-title {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
padding-bottom: var(--spacing-sm);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.api-config-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
/* 配置区域标签样式 - Compact Pill style */
|
||||
.config-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.config-tab {
|
||||
flex: 1;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-xs);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.config-tab:hover {
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.config-tab.active {
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-bg-secondary);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
/* 配置管理区域 - Integrated Layout */
|
||||
.profile-manager {
|
||||
padding: var(--spacing-sm);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.profile-header > label {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.profile-controls {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.profile-select-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-buttons {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.form-group.flex-1 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-group.flex-auto {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 6px var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-xs);
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-bg-primary);
|
||||
transition: all var(--transition-fast);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px var(--color-accent-ultra-light);
|
||||
}
|
||||
|
||||
.form-control::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
textarea.form-control {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-text {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding-top: var(--spacing-md);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 6px var(--spacing-md);
|
||||
border: none;
|
||||
border-radius: var(--radius-xs);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px var(--spacing-sm);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Primary Button - Save/Create */
|
||||
.btn-primary {
|
||||
background-color: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--color-accent-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Secondary Button - Connect/Reset */
|
||||
.btn-secondary {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Danger Button - Delete */
|
||||
.btn-danger {
|
||||
background-color: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #dc2626;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.btn-danger:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Text Button - Minimal */
|
||||
.btn-text {
|
||||
background-color: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 6px var(--spacing-sm);
|
||||
}
|
||||
|
||||
.btn-text:hover {
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
/* Notification styles */
|
||||
.notification {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: var(--radius-md);
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: var(--shadow-xl);
|
||||
animation: slideIn 0.3s ease-out;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.notification-success {
|
||||
background-color: var(--color-success);
|
||||
}
|
||||
|
||||
.notification-info {
|
||||
background-color: var(--color-info);
|
||||
}
|
||||
|
||||
.notification-error {
|
||||
background-color: var(--color-error);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Modified indicator on tabs */
|
||||
.modified-indicator {
|
||||
color: var(--color-error);
|
||||
font-size: 0.6rem;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Modal styles - Modern & Elegant */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
max-height: 85vh;
|
||||
overflow: hidden;
|
||||
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(30px) scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
padding: var(--spacing-lg) var(--spacing-lg) var(--spacing-md);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: linear-gradient(to right, var(--color-accent-ultra-light), transparent);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--spacing-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.checkbox-label:hover {
|
||||
background-color: var(--color-bg-elevated);
|
||||
border-color: var(--color-border);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--color-accent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.checkbox-text {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.modified-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 8px;
|
||||
background: linear-gradient(135deg, var(--color-accent-ultra-light), var(--color-accent));
|
||||
color: white;
|
||||
border-radius: var(--radius-xs);
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-top: 1px solid var(--color-border);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
/* Modal Hint Text - Enhanced */
|
||||
.modal-hint {
|
||||
margin: var(--spacing-md) 0 var(--spacing-sm) 0;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-left: 3px solid var(--color-accent);
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
.workflow-manager {
|
||||
margin-top: var(--spacing-lg);
|
||||
padding: var(--spacing-md);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.workflow-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.workflow-header h4 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.workflow-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.workflow-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.workflow-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.workflow-item:hover {
|
||||
border-color: var(--color-accent);
|
||||
background-color: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.workflow-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workflow-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.default-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 6px;
|
||||
background-color: var(--color-accent);
|
||||
color: white;
|
||||
border-radius: var(--radius-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.workflow-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.separator {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 1rem;
|
||||
opacity: 0.6;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--spacing-xl);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: var(--spacing-xs) 0;
|
||||
}
|
||||
|
||||
.empty-state .hint {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.workflow-hint {
|
||||
margin-top: var(--spacing-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-left: 3px solid var(--color-accent);
|
||||
border-radius: var(--radius-xs);
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.workflow-hint p {
|
||||
margin: 0 0 var(--spacing-xs) 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.workflow-hint ul {
|
||||
margin: 0;
|
||||
padding-left: var(--spacing-md);
|
||||
}
|
||||
|
||||
.workflow-hint li {
|
||||
margin: var(--spacing-xs) 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ==================== Compact Modern Design ==================== */
|
||||
|
||||
/* Mode Toggle - Pill Style (Linear/Vercel) */
|
||||
.mode-toggle {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-radius: 6px;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
padding: 4px 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mode-btn:hover {
|
||||
color: var(--color-text-primary);
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
background-color: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Compact Workflow Manager */
|
||||
.workflow-manager-compact {
|
||||
margin-top: var(--spacing-sm);
|
||||
padding: var(--spacing-xs);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.workflow-header-compact {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.workflow-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.workflow-actions-compact {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-icon:hover:not(:disabled) {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn-icon:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-icon-danger {
|
||||
composes: btn-icon;
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-icon-danger:hover {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.workflow-list-compact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.workflow-item-compact {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.workflow-item-compact:hover {
|
||||
background-color: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.workflow-name-compact {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.badge-default {
|
||||
font-size: 0.65rem;
|
||||
padding: 1px 4px;
|
||||
background-color: var(--color-accent);
|
||||
color: white;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Modal Hint Text */
|
||||
.modal-hint {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
828
frontend/src/components/SideBarLeft/tabs/ApiConfig/ApiConfig.jsx
Normal file
828
frontend/src/components/SideBarLeft/tabs/ApiConfig/ApiConfig.jsx
Normal file
@@ -0,0 +1,828 @@
|
||||
// frontend-react/src/components/SideBarLeft/tabs/ApiConfig/ApiConfig.jsx
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import useApiConfigStore from '../../../../Store/SideBarLeft/ApiConfigSlice';
|
||||
import useSideBarLeftStore from '../../../../Store/SideBarLeft/SideBarLeftSlice';
|
||||
import ComfyUIWorkflowManager from './ComfyUIWorkflowManager';
|
||||
import './ApiConfig.css';
|
||||
|
||||
const ApiConfig = () => {
|
||||
// 从store中获取状态和方法
|
||||
const {
|
||||
profiles,
|
||||
currentProfile,
|
||||
activeMap,
|
||||
loading,
|
||||
error,
|
||||
fetchProfiles,
|
||||
fetchProfile,
|
||||
saveProfile,
|
||||
deleteProfile,
|
||||
setActiveConfig,
|
||||
testConnection,
|
||||
notification
|
||||
} = useApiConfigStore();
|
||||
|
||||
// 本地表单状态 - 包含 4 个 API 配置
|
||||
const [formData, setFormData] = useState({
|
||||
mainLLM: { id: '', name: '', apiUrl: '', apiKey: '', model: '' },
|
||||
|
||||
// 生图配置 - 支持本地和云端两种模式
|
||||
imageModel: {
|
||||
mode: 'local', // 'local' | 'cloud'
|
||||
|
||||
// 本地 ComfyUI 配置
|
||||
local: {
|
||||
apiUrl: 'http://comfyui:8188',
|
||||
websocketEnabled: true,
|
||||
queueTimeout: 300,
|
||||
defaultWorkflow: 'default_txt2img.json'
|
||||
},
|
||||
|
||||
// 云端 API 配置
|
||||
cloud: {
|
||||
provider: 'dall-e',
|
||||
apiUrl: 'https://api.openai.com/v1/images/generations',
|
||||
apiKey: '',
|
||||
model: 'dall-e-3'
|
||||
}
|
||||
},
|
||||
|
||||
secondaryLLM: { id: '', name: '', apiUrl: '', apiKey: '', model: '' },
|
||||
ragEmbedding: { id: '', name: '', apiUrl: '', apiKey: '', model: '' }
|
||||
});
|
||||
|
||||
// 当前选中的配置文件 ID
|
||||
const [selectedProfileId, setSelectedProfileId] = useState('');
|
||||
|
||||
// 当前编辑的类别
|
||||
const [currentCategory, setCurrentCategory] = useState('mainLLM');
|
||||
|
||||
// 可用模型列表
|
||||
const [availableModels, setAvailableModels] = useState([]);
|
||||
|
||||
// 显示保存对话框
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
|
||||
// 新配置文件名称
|
||||
const [newProfileName, setNewProfileName] = useState('');
|
||||
|
||||
// 要保存的 API 类别(勾选状态)
|
||||
const [apisToSave, setApisToSave] = useState({
|
||||
mainLLM: false,
|
||||
imageModel: false,
|
||||
secondaryLLM: false,
|
||||
ragEmbedding: false
|
||||
});
|
||||
|
||||
// 跟踪哪些 API 有修改
|
||||
const [modifiedApis, setModifiedApis] = useState({});
|
||||
|
||||
// 获取当前激活的分页
|
||||
const { activeTab } = useSideBarLeftStore();
|
||||
|
||||
// 记录上一次的分页状态
|
||||
const prevActiveTabRef = React.useRef(activeTab);
|
||||
|
||||
// 组件加载时获取配置文件列表 - 只在切换到API分页时刷新
|
||||
useEffect(() => {
|
||||
// 检测是否从其他分页切换到API分页
|
||||
if (activeTab === 'api' && prevActiveTabRef.current !== 'api') {
|
||||
fetchProfiles();
|
||||
}
|
||||
// 更新上一次的分页状态
|
||||
prevActiveTabRef.current = activeTab;
|
||||
}, [activeTab, fetchProfiles]);
|
||||
|
||||
// 当选中配置文件时,加载该配置
|
||||
useEffect(() => {
|
||||
if (selectedProfileId) {
|
||||
loadProfile(selectedProfileId);
|
||||
}
|
||||
}, [selectedProfileId]);
|
||||
|
||||
// 加载配置文件
|
||||
const loadProfile = async (profileId) => {
|
||||
try {
|
||||
const profile = await fetchProfile(profileId);
|
||||
|
||||
// 合并到本地状态(服务器提供的覆盖,未提供的保留本地)
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
...profile.apis
|
||||
}));
|
||||
|
||||
// 清除修改标记
|
||||
setModifiedApis({});
|
||||
} catch (err) {
|
||||
console.error('加载配置文件失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理输入变化 - 支持嵌套路径
|
||||
const handleChange = (e, path = null) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
const newValue = type === 'checkbox' ? checked : value;
|
||||
|
||||
if (path) {
|
||||
// 嵌套路径更新,例如: ['imageModel', 'local', 'apiUrl']
|
||||
setFormData(prev => {
|
||||
const updated = { ...prev };
|
||||
let current = updated;
|
||||
|
||||
// 导航到倒数第二层
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
current = current[path[i]];
|
||||
}
|
||||
|
||||
// 更新最后一层
|
||||
current[path[path.length - 1]] = newValue;
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
// 标记 imageModel 为已修改
|
||||
setModifiedApis(prev => ({
|
||||
...prev,
|
||||
imageModel: true
|
||||
}));
|
||||
} else {
|
||||
// 扁平结构更新(用于其他 API)
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[currentCategory]: {
|
||||
...prev[currentCategory],
|
||||
[name]: newValue
|
||||
}
|
||||
}));
|
||||
|
||||
// 标记当前类别的 API 已被修改
|
||||
setModifiedApis(prev => ({
|
||||
...prev,
|
||||
[currentCategory]: true
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 切换生图模式
|
||||
const handleImageModeChange = (mode) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
imageModel: {
|
||||
...prev.imageModel,
|
||||
mode
|
||||
}
|
||||
}));
|
||||
|
||||
setModifiedApis(prev => ({
|
||||
...prev,
|
||||
imageModel: true
|
||||
}));
|
||||
};
|
||||
|
||||
// 处理类别切换
|
||||
const handleTabChange = (categoryId) => {
|
||||
setCurrentCategory(categoryId);
|
||||
setAvailableModels([]);
|
||||
};
|
||||
|
||||
// 打开保存/新建配置文件对话框
|
||||
const handleOpenSaveModal = () => {
|
||||
// 默认选中所有有修改的 API
|
||||
setApisToSave(modifiedApis);
|
||||
|
||||
// 如果是新建,清空名称;如果是更新,加载现有名称
|
||||
if (!selectedProfileId) {
|
||||
setNewProfileName('');
|
||||
} else {
|
||||
const currentProfile = profiles.find(p => p.id === selectedProfileId);
|
||||
setNewProfileName(currentProfile?.name || '');
|
||||
}
|
||||
|
||||
setShowSaveModal(true);
|
||||
};
|
||||
|
||||
// 处理保存配置文件
|
||||
const handleSave = async () => {
|
||||
// 收集要保存的 API 配置
|
||||
const apisToSaveData = {};
|
||||
Object.keys(apisToSave).forEach(category => {
|
||||
if (apisToSave[category]) {
|
||||
apisToSaveData[category] = formData[category];
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(apisToSaveData).length === 0) {
|
||||
alert('请至少选择一个 API 配置进行保存');
|
||||
return;
|
||||
}
|
||||
|
||||
// 判断是新建还是更新
|
||||
const isNewProfile = !selectedProfileId;
|
||||
const profileId = selectedProfileId || `profile-${Date.now()}`;
|
||||
const profileName = isNewProfile ? newProfileName : (profiles.find(p => p.id === selectedProfileId)?.name || profileId);
|
||||
|
||||
try {
|
||||
await saveProfile(profileId, profileName, apisToSaveData);
|
||||
setSelectedProfileId(profileId);
|
||||
setShowSaveModal(false);
|
||||
|
||||
// 清除修改标记
|
||||
setModifiedApis(prev => {
|
||||
const newModified = { ...prev };
|
||||
Object.keys(apisToSave).forEach(category => {
|
||||
if (apisToSave[category]) {
|
||||
delete newModified[category];
|
||||
}
|
||||
});
|
||||
return newModified;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('保存失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理重置
|
||||
const handleReset = () => {
|
||||
if (selectedProfileId) {
|
||||
loadProfile(selectedProfileId);
|
||||
} else {
|
||||
// 清空当前类别的配置
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[currentCategory]: { id: '', name: '', apiUrl: '', apiKey: '', model: '' }
|
||||
}));
|
||||
setModifiedApis(prev => {
|
||||
const newModified = { ...prev };
|
||||
delete newModified[currentCategory];
|
||||
return newModified;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 处理连接测试并获取模型列表
|
||||
const handleConnect = async () => {
|
||||
const currentApi = formData[currentCategory];
|
||||
if (!currentApi.apiUrl) {
|
||||
alert('请先填写 API 地址');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentApi.apiKey) {
|
||||
alert('请先填写 API 密钥');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/api-config/test-connection', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
apiUrl: currentApi.apiUrl,
|
||||
apiKey: currentApi.apiKey,
|
||||
category: currentCategory,
|
||||
model: currentApi.model || ''
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || '获取模型列表失败');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setAvailableModels(result.models);
|
||||
if (result.models.length === 0) {
|
||||
alert('未找到可用模型');
|
||||
} else {
|
||||
// 显示成功消息
|
||||
console.log(`成功获取 ${result.models.length} 个模型`);
|
||||
}
|
||||
} else {
|
||||
alert(`获取失败: ${result.message}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取模型列表失败:', err);
|
||||
alert('获取模型列表失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理模型选择
|
||||
const handleModelSelect = (selectedModel) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[currentCategory]: {
|
||||
...prev[currentCategory],
|
||||
model: selectedModel
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
// 测试 ComfyUI 连接
|
||||
const testComfyUIConnection = async (apiUrl) => {
|
||||
if (!apiUrl) {
|
||||
alert('请先填写 API 地址');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/api-config/test-comfyui-connection', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ apiUrl })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert(`连接成功!\n\nVRAM: ${(result.stats.vram_free / 1024 / 1024 / 1024).toFixed(2)} GB 可用\n设备: ${result.stats.device}`);
|
||||
} else {
|
||||
alert(`连接失败: ${result.message}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert('测试失败: ' + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 测试云端 API 连接
|
||||
const testCloudConnection = async (cloudConfig) => {
|
||||
if (!cloudConfig.apiKey) {
|
||||
alert('请先填写 API Key');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/api-config/test-cloud-connection', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cloudConfig)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('连接成功!');
|
||||
} else {
|
||||
alert(`连接失败: ${result.message}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert('测试失败: ' + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理从列表中选择配置文件
|
||||
const handleSelectProfile = (e) => {
|
||||
const profileId = e.target.value;
|
||||
setSelectedProfileId(profileId);
|
||||
};
|
||||
|
||||
// 处理删除配置文件
|
||||
const handleDelete = async () => {
|
||||
if (selectedProfileId && confirm('确定要删除此配置文件吗?')) {
|
||||
await deleteProfile(selectedProfileId);
|
||||
setSelectedProfileId('');
|
||||
handleCreateNew();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理设为默认配置
|
||||
const handleSetActive = async () => {
|
||||
if (!selectedProfileId) {
|
||||
alert('请先保存配置文件');
|
||||
return;
|
||||
}
|
||||
await setActiveConfig(currentCategory, selectedProfileId);
|
||||
};
|
||||
|
||||
// 配置区域标签(简短名称 + Tooltip)
|
||||
const configTabs = [
|
||||
{ id: 'mainLLM', label: '核心', tooltip: '主 LLM 模型 - 用于主要对话和推理' },
|
||||
{ id: 'imageModel', label: '生图', tooltip: '图像生成模型 - 本地 ComfyUI 或云端 API' },
|
||||
{ id: 'secondaryLLM', label: '辅助', tooltip: '副 LLM 模型 - 用于二次校验或特殊任务' },
|
||||
{ id: 'ragEmbedding', label: '向量', tooltip: 'RAG 嵌入模型 - 用于文本向量化和检索' }
|
||||
];
|
||||
|
||||
// 判断当前 API 密钥是否是脱敏的
|
||||
const isApiKeyMasked = formData[currentCategory].apiKey && formData[currentCategory].apiKey.endsWith('****');
|
||||
|
||||
// 计算有修改的 API 数量
|
||||
const modifiedCount = Object.keys(modifiedApis).filter(k => modifiedApis[k]).length;
|
||||
|
||||
return (
|
||||
<div className="api-config-container">
|
||||
{/* 移除标题,节省空间 */}
|
||||
|
||||
{error && <div className="notification notification-error">{error}</div>}
|
||||
{notification.show && (
|
||||
<div className={`notification notification-${notification.type}`}>
|
||||
{notification.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="api-config-form">
|
||||
{/* 配置管理区域 - 移到顶部 */}
|
||||
<div className="profile-manager">
|
||||
<div className="profile-header">
|
||||
<label htmlFor="profileSelect">配置文件</label>
|
||||
<div className="profile-controls">
|
||||
<select
|
||||
id="profileSelect"
|
||||
value={selectedProfileId}
|
||||
onChange={handleSelectProfile}
|
||||
className="form-control profile-select-input"
|
||||
>
|
||||
<option value="">新建配置文件...</option>
|
||||
{profiles.map(profile => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="profile-buttons">
|
||||
{!selectedProfileId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleOpenSaveModal}
|
||||
>
|
||||
+ 新建
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleSetActive}
|
||||
disabled={activeMap[currentCategory] === selectedProfileId}
|
||||
>
|
||||
设为默认
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 配置区域标签 */}
|
||||
<div className="config-tabs">
|
||||
{configTabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`config-tab ${currentCategory === tab.id ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
title={tab.tooltip}
|
||||
>
|
||||
{tab.label}
|
||||
{modifiedApis[tab.id] && <span className="modified-indicator">●</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 生图模式选择 - 紧凑版 */}
|
||||
{currentCategory === 'imageModel' && (
|
||||
<div className="mode-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`mode-btn ${formData.imageModel.mode === 'local' ? 'active' : ''}`}
|
||||
onClick={() => handleImageModeChange('local')}
|
||||
>
|
||||
🖥️ 本地
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`mode-btn ${formData.imageModel.mode === 'cloud' ? 'active' : ''}`}
|
||||
onClick={() => handleImageModeChange('cloud')}
|
||||
>
|
||||
☁️ 云端
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API配置表单 */}
|
||||
<div className="form-section">
|
||||
{/* 生图模型 - 本地 ComfyUI 模式 */}
|
||||
{currentCategory === 'imageModel' && formData.imageModel.mode === 'local' ? (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="local-apiUrl">API 地址</label>
|
||||
<input
|
||||
type="text"
|
||||
id="local-apiUrl"
|
||||
value={formData.imageModel.local.apiUrl}
|
||||
onChange={(e) => handleChange(e, ['imageModel', 'local', 'apiUrl'])}
|
||||
placeholder="http://comfyui:8188"
|
||||
className="form-control"
|
||||
/>
|
||||
<span className="form-hint">
|
||||
Docker 环境使用 http://comfyui:8188,本地运行使用 http://localhost:8188
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group half">
|
||||
<label htmlFor="local-websocket">启用 WebSocket</label>
|
||||
<label className="switch-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="local-websocket"
|
||||
checked={formData.imageModel.local.websocketEnabled}
|
||||
onChange={(e) => handleChange(e, ['imageModel', 'local', 'websocketEnabled'])}
|
||||
/>
|
||||
<span className="switch-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-group half">
|
||||
<label htmlFor="local-timeout">队列超时(秒)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="local-timeout"
|
||||
value={formData.imageModel.local.queueTimeout}
|
||||
onChange={(e) => handleChange(e, ['imageModel', 'local', 'queueTimeout'])}
|
||||
min="30"
|
||||
max="600"
|
||||
className="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="local-workflow">默认工作流</label>
|
||||
<select
|
||||
id="local-workflow"
|
||||
value={formData.imageModel.local.defaultWorkflow}
|
||||
onChange={(e) => handleChange(e, ['imageModel', 'local', 'defaultWorkflow'])}
|
||||
className="form-control"
|
||||
>
|
||||
<option value="default_txt2img.json">文生图(默认)</option>
|
||||
</select>
|
||||
<span className="form-hint">
|
||||
可上传自定义工作流文件
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => testComfyUIConnection(formData.imageModel.local.apiUrl)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '测试中...' : '测试连接'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : currentCategory === 'imageModel' && formData.imageModel.mode === 'cloud' ? (
|
||||
/* 生图模型 - 云端 API 模式 */
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="cloud-provider">服务提供商</label>
|
||||
<select
|
||||
id="cloud-provider"
|
||||
value={formData.imageModel.cloud.provider}
|
||||
onChange={(e) => handleChange(e, ['imageModel', 'cloud', 'provider'])}
|
||||
className="form-control"
|
||||
>
|
||||
<option value="dall-e">DALL-E 3 (OpenAI)</option>
|
||||
<option value="stability">Stable Diffusion (Stability AI)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="cloud-apiKey">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
id="cloud-apiKey"
|
||||
value={formData.imageModel.cloud.apiKey}
|
||||
onChange={(e) => handleChange(e, ['imageModel', 'cloud', 'apiKey'])}
|
||||
placeholder="sk-..."
|
||||
className="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="cloud-model">模型</label>
|
||||
<select
|
||||
id="cloud-model"
|
||||
value={formData.imageModel.cloud.model}
|
||||
onChange={(e) => handleChange(e, ['imageModel', 'cloud', 'model'])}
|
||||
className="form-control"
|
||||
>
|
||||
{formData.imageModel.cloud.provider === 'dall-e' && (
|
||||
<>
|
||||
<option value="dall-e-3">DALL-E 3</option>
|
||||
<option value="dall-e-2">DALL-E 2</option>
|
||||
</>
|
||||
)}
|
||||
{formData.imageModel.cloud.provider === 'stability' && (
|
||||
<>
|
||||
<option value="sd-xl-1024">SD-XL 1024</option>
|
||||
<option value="sd-2-1">SD 2.1</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => testCloudConnection(formData.imageModel.cloud)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '测试中...' : '测试连接'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* 其他 API 类型(主LLM、副LLM、嵌入模型)*/
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="apiUrl">API 地址</label>
|
||||
<input
|
||||
type="text"
|
||||
id="apiUrl"
|
||||
name="apiUrl"
|
||||
value={formData[currentCategory].apiUrl}
|
||||
onChange={handleChange}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
className="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="apiKey">密钥</label>
|
||||
<input
|
||||
type="password"
|
||||
id="apiKey"
|
||||
name="apiKey"
|
||||
value={isApiKeyMasked ? '' : formData[currentCategory].apiKey}
|
||||
onChange={handleChange}
|
||||
placeholder={isApiKeyMasked ? '已保存(留空保持不变)' : 'sk-...'}
|
||||
className="form-control"
|
||||
/>
|
||||
{isApiKeyMasked && (
|
||||
<span className="form-hint">当前密钥已加密存储</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group flex-1">
|
||||
<label htmlFor="model">模型</label>
|
||||
<input
|
||||
type="text"
|
||||
id="model"
|
||||
name="model"
|
||||
value={formData[currentCategory].model}
|
||||
onChange={handleChange}
|
||||
placeholder="gpt-3.5-turbo"
|
||||
className="form-control"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group flex-auto">
|
||||
<label> </label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-full"
|
||||
onClick={handleConnect}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '连接中...' : '获取模型列表'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{availableModels.length > 0 && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="modelSelect">选择模型</label>
|
||||
<select
|
||||
id="modelSelect"
|
||||
value={formData[currentCategory].model}
|
||||
onChange={(e) => handleModelSelect(e.target.value)}
|
||||
className="form-control"
|
||||
>
|
||||
<option value="">从列表中选择...</option>
|
||||
{availableModels.map((model, index) => (
|
||||
<option key={index} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ComfyUI 工作流管理器 - 仅在选择生图模型时显示 */}
|
||||
{currentCategory === 'imageModel' && formData.imageModel.mode === 'local' && (
|
||||
<ComfyUIWorkflowManager apiUrl={formData.imageModel.local?.apiUrl} />
|
||||
)}
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-text"
|
||||
onClick={handleReset}
|
||||
disabled={loading}
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleOpenSaveModal}
|
||||
disabled={loading || modifiedCount === 0}
|
||||
>
|
||||
{loading ? '保存中...' : modifiedCount > 0 ? `保存配置 (${modifiedCount})` : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 保存/更新配置文件对话框 */}
|
||||
{showSaveModal && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content">
|
||||
<h3 className="modal-title">
|
||||
{selectedProfileId ? '更新配置文件' : '新建配置文件'}
|
||||
</h3>
|
||||
|
||||
{/* 如果是新建,显示名称输入框 */}
|
||||
{!selectedProfileId && (
|
||||
<div className="form-group" style={{ padding: 'var(--spacing-lg) var(--spacing-lg) 0' }}>
|
||||
<label htmlFor="newProfileName">配置文件名称</label>
|
||||
<input
|
||||
type="text"
|
||||
id="newProfileName"
|
||||
value={newProfileName}
|
||||
onChange={(e) => setNewProfileName(e.target.value)}
|
||||
placeholder="例如:我的默认配置"
|
||||
className="form-control"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="modal-hint">
|
||||
{selectedProfileId
|
||||
? '选择要更新的 API 配置(未选中的将保持不变):'
|
||||
: '选择要包含在此配置文件中的 API 配置:'}
|
||||
</p>
|
||||
<div className="modal-body">
|
||||
{configTabs.map(tab => (
|
||||
<label key={tab.id} className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={apisToSave[tab.id] || false}
|
||||
onChange={(e) => setApisToSave(prev => ({
|
||||
...prev,
|
||||
[tab.id]: e.target.checked
|
||||
}))}
|
||||
/>
|
||||
<span className="checkbox-text">
|
||||
{tab.label}
|
||||
{modifiedApis[tab.id] && <span className="modified-badge">已修改</span>}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-text"
|
||||
onClick={() => setShowSaveModal(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleSave}
|
||||
>
|
||||
{selectedProfileId ? '保存更新' : '创建配置文件'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiConfig;
|
||||
@@ -0,0 +1,482 @@
|
||||
// frontend-react/src/components/SideBarLeft/tabs/ApiConfig/ApiConfig.jsx
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import useApiConfigStore from '../../../../Store/SideBarLeft/ApiConfigSlice';
|
||||
import './ApiConfig.css';
|
||||
|
||||
const ApiConfig = () => {
|
||||
// 从store中获取状态和方法
|
||||
const {
|
||||
profiles, // 所有配置文件列表
|
||||
currentProfile, // 当前加载的配置文件
|
||||
activeMap, // 激活配置映射 { category: profileId }
|
||||
loading,
|
||||
error,
|
||||
fetchProfiles, // 获取所有配置文件列表
|
||||
fetchProfile, // 获取单个配置文件
|
||||
saveProfile, // 保存配置文件(批量)
|
||||
deleteProfile, // 删除配置文件
|
||||
setActiveConfig, // 设置激活配置
|
||||
testConnection, // 测试连接
|
||||
notification
|
||||
} = useApiConfigStore();
|
||||
|
||||
// 本地表单状态 - 包含 4 个 API 配置
|
||||
const [formData, setFormData] = useState({
|
||||
mainLLM: {
|
||||
id: '',
|
||||
name: '',
|
||||
apiUrl: '',
|
||||
apiKey: '',
|
||||
model: '',
|
||||
},
|
||||
imageModel: {
|
||||
id: '',
|
||||
name: '',
|
||||
apiUrl: '',
|
||||
apiKey: '',
|
||||
model: '',
|
||||
},
|
||||
secondaryLLM: {
|
||||
id: '',
|
||||
name: '',
|
||||
apiUrl: '',
|
||||
apiKey: '',
|
||||
model: '',
|
||||
},
|
||||
ragEmbedding: {
|
||||
id: '',
|
||||
name: '',
|
||||
apiUrl: '',
|
||||
apiKey: '',
|
||||
model: '',
|
||||
}
|
||||
});
|
||||
|
||||
// 当前选中的配置文件 ID
|
||||
const [selectedProfileId, setSelectedProfileId] = useState('');
|
||||
|
||||
// 当前编辑的类别
|
||||
const [currentCategory, setCurrentCategory] = useState('mainLLM');
|
||||
|
||||
// 可用模型列表
|
||||
const [availableModels, setAvailableModels] = useState([]);
|
||||
|
||||
// 显示保存对话框
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
|
||||
// 要保存的 API 类别(勾选状态)
|
||||
const [apisToSave, setApisToSave] = useState({
|
||||
mainLLM: false,
|
||||
imageModel: false,
|
||||
secondaryLLM: false,
|
||||
ragEmbedding: false
|
||||
});
|
||||
|
||||
// 跟踪哪些 API 有修改
|
||||
const [modifiedApis, setModifiedApis] = useState({});
|
||||
|
||||
// 组件加载时获取配置文件列表
|
||||
useEffect(() => {
|
||||
fetchProfiles();
|
||||
}, [fetchProfiles]);
|
||||
|
||||
// 当选中配置文件时,加载该配置
|
||||
useEffect(() => {
|
||||
if (selectedProfileId) {
|
||||
loadProfile(selectedProfileId);
|
||||
}
|
||||
}, [selectedProfileId]);
|
||||
|
||||
// 加载配置文件
|
||||
const loadProfile = async (profileId) => {
|
||||
try {
|
||||
const profile = await fetchProfile(profileId);
|
||||
|
||||
// 合并到本地状态(服务器提供的覆盖,未提供的保留本地)
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
...profile.apis // 只覆盖服务器返回的 API
|
||||
}));
|
||||
|
||||
// 清除修改标记
|
||||
setModifiedApis({});
|
||||
} catch (err) {
|
||||
console.error('加载配置文件失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理输入变化
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[currentCategory]: {
|
||||
...prev[currentCategory],
|
||||
[name]: value
|
||||
}
|
||||
}));
|
||||
|
||||
// 标记当前类别的 API 已被修改
|
||||
setModifiedApis(prev => ({
|
||||
...prev,
|
||||
[currentCategory]: true
|
||||
}));
|
||||
};
|
||||
|
||||
// 处理类别切换
|
||||
const handleTabChange = (categoryId) => {
|
||||
setCurrentCategory(categoryId);
|
||||
setAvailableModels([]); // 清空模型列表
|
||||
};
|
||||
|
||||
// 打开保存对话框
|
||||
const handleOpenSaveModal = () => {
|
||||
// 默认选中所有有修改的 API
|
||||
setApisToSave(modifiedApis);
|
||||
setShowSaveModal(true);
|
||||
};
|
||||
|
||||
// 处理保存配置文件
|
||||
const handleSave = async () => {
|
||||
if (!selectedProfileId && !formData[currentCategory].name) {
|
||||
alert('请先输入配置文件名称');
|
||||
return;
|
||||
}
|
||||
|
||||
// 收集要保存的 API 配置
|
||||
const apisToSaveData = {};
|
||||
Object.keys(apisToSave).forEach(category => {
|
||||
if (apisToSave[category]) {
|
||||
apisToSaveData[category] = formData[category];
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(apisToSaveData).length === 0) {
|
||||
alert('请至少选择一个 API 配置进行保存');
|
||||
return;
|
||||
}
|
||||
|
||||
const profileId = selectedProfileId || `profile-${Date.now()}`;
|
||||
const profileName = formData[currentCategory].name || profileId;
|
||||
|
||||
try {
|
||||
await saveProfile(profileId, profileName, apisToSaveData);
|
||||
setSelectedProfileId(profileId);
|
||||
setShowSaveModal(false);
|
||||
|
||||
// 清除修改标记
|
||||
setModifiedApis(prev => {
|
||||
const newModified = { ...prev };
|
||||
Object.keys(apisToSave).forEach(category => {
|
||||
if (apisToSave[category]) {
|
||||
delete newModified[category];
|
||||
}
|
||||
});
|
||||
return newModified;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('保存失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理重置 (重置为当前激活配置)
|
||||
const handleReset = () => {
|
||||
loadActiveConfig();
|
||||
};
|
||||
|
||||
// 新增:处理连接测试并获取模型列表
|
||||
const handleConnect = async () => {
|
||||
if (!formData.apiUrl || !formData.apiKey) {
|
||||
alert('请先填写 API URL 和 API Key');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const models = await fetchModels(formData.apiUrl, formData.apiKey, formData.category);
|
||||
setAvailableModels(models);
|
||||
if (models.length === 0) {
|
||||
alert('未找到可用模型');
|
||||
} else {
|
||||
showNotification('success', `成功获取 ${models.length} 个模型`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert('连接失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 新增:处理模型选择
|
||||
const handleModelSelect = (selectedModel) => {
|
||||
setFormData(prev => ({ ...prev, model: selectedModel }));
|
||||
};
|
||||
|
||||
|
||||
// 处理从列表中选择配置
|
||||
const handleSelectProfile = async (e) => {
|
||||
const profileId = e.target.value;
|
||||
setSelectedProfileId(profileId);
|
||||
|
||||
if (profileId) {
|
||||
const profile = allApis.find(api => api.id === profileId);
|
||||
if (profile) {
|
||||
setFormData({ ...profile });
|
||||
// 自动设置为当前激活配置
|
||||
await setActiveConfig(formData.category, profileId);
|
||||
}
|
||||
} else {
|
||||
// 如果选择了空,清空表单但保留 category
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
id: '',
|
||||
name: '',
|
||||
apiUrl: '',
|
||||
apiKey: '',
|
||||
model: '',
|
||||
temperature: 0.7,
|
||||
maxTokens: 2048,
|
||||
systemPrompt: ''
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 处理新建配置
|
||||
const handleCreateNew = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
id: '',
|
||||
name: `新配置 ${new Date().toLocaleTimeString()}`,
|
||||
apiUrl: '',
|
||||
apiKey: '',
|
||||
model: ''
|
||||
}));
|
||||
setSelectedProfileId('');
|
||||
};
|
||||
|
||||
// 处理删除配置
|
||||
const handleDelete = async () => {
|
||||
if (selectedProfileId && confirm('确定要删除此配置吗?')) {
|
||||
await deleteApiConfig(selectedProfileId);
|
||||
// 删除后重置表单
|
||||
if (selectedProfileId === activeMap[formData.category]) {
|
||||
// 如果删除的是当前激活的配置,重置
|
||||
handleReset();
|
||||
} else {
|
||||
// 否则只清空选中状态
|
||||
setSelectedProfileId('');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理设为当前激活配置
|
||||
const handleSetActive = async () => {
|
||||
if (!formData.id) {
|
||||
alert('请先保存配置');
|
||||
return;
|
||||
}
|
||||
await setActiveConfig(formData.category, formData.id);
|
||||
};
|
||||
|
||||
// 过滤出当前类别的配置列表
|
||||
const currentCategoryProfiles = allApis.filter(api => api.category === formData.category);
|
||||
|
||||
// 配置区域标签
|
||||
const configTabs = [
|
||||
{ id: 'mainLLM', label: '主LLM模型' },
|
||||
{ id: 'imageModel', label: '生图模型' },
|
||||
{ id: 'secondaryLLM', label: '副LLM模型' },
|
||||
{ id: 'ragEmbedding', label: 'RAG嵌入模型' }
|
||||
];
|
||||
|
||||
// 判断API密钥是否是脱敏的
|
||||
const isApiKeyMasked = formData.apiKey && formData.apiKey.endsWith('****');
|
||||
|
||||
return (
|
||||
<div className="api-config-container">
|
||||
<h2 className="api-config-title">API配置</h2>
|
||||
|
||||
{error && <div className="notification notification-error">{error}</div>}
|
||||
{notification.show && (
|
||||
<div className={`notification notification-${notification.type}`}>
|
||||
{notification.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="api-config-form">
|
||||
{/* 配置区域标签 */}
|
||||
<div className="config-tabs">
|
||||
{configTabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`config-tab ${formData.category === tab.id ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 配置管理区域 - 整合选择和操作 */}
|
||||
<div className="profile-manager">
|
||||
<div className="profile-header">
|
||||
<label htmlFor="profileSelect">配置方案</label>
|
||||
<div className="profile-controls">
|
||||
<select
|
||||
id="profileSelect"
|
||||
value={selectedProfileId}
|
||||
onChange={handleSelectProfile}
|
||||
className="form-control profile-select-input"
|
||||
>
|
||||
<option value="">新建配置...</option>
|
||||
{currentCategoryProfiles.map(profile => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name} {activeMap[formData.category] === profile.id ? '●' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="profile-buttons">
|
||||
{!selectedProfileId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleCreateNew}
|
||||
>
|
||||
+ 新建
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleSetActive}
|
||||
disabled={activeMap[formData.category] === selectedProfileId}
|
||||
>
|
||||
设为默认
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API配置表单 */}
|
||||
<div className="form-section">
|
||||
<div className="form-group">
|
||||
<label htmlFor="name">名称</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="例如:GPT-4 生产环境"
|
||||
className="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="apiUrl">API 地址</label>
|
||||
<input
|
||||
type="text"
|
||||
id="apiUrl"
|
||||
name="apiUrl"
|
||||
value={formData.apiUrl}
|
||||
onChange={handleChange}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
className="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="apiKey">密钥</label>
|
||||
<input
|
||||
type="password"
|
||||
id="apiKey"
|
||||
name="apiKey"
|
||||
value={isApiKeyMasked ? '' : formData.apiKey}
|
||||
onChange={handleChange}
|
||||
placeholder={isApiKeyMasked ? '已保存(留空保持不变)' : 'sk-...'}
|
||||
className="form-control"
|
||||
/>
|
||||
{isApiKeyMasked && (
|
||||
<span className="form-hint">当前密钥已加密存储</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group flex-1">
|
||||
<label htmlFor="model">模型</label>
|
||||
<input
|
||||
type="text"
|
||||
id="model"
|
||||
name="model"
|
||||
value={formData.model}
|
||||
onChange={handleChange}
|
||||
placeholder="gpt-3.5-turbo"
|
||||
className="form-control"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group flex-auto">
|
||||
<label> </label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-full"
|
||||
onClick={handleConnect}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '连接中...' : '获取模型列表'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{availableModels.length > 0 && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="modelSelect">选择模型</label>
|
||||
<select
|
||||
id="modelSelect"
|
||||
value={formData.model}
|
||||
onChange={(e) => handleModelSelect(e.target.value)}
|
||||
className="form-control"
|
||||
>
|
||||
<option value="">从列表中选择...</option>
|
||||
{availableModels.map((model, index) => (
|
||||
<option key={index} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-text"
|
||||
onClick={handleReset}
|
||||
disabled={loading}
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '保存中...' : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiConfig;
|
||||
@@ -0,0 +1,484 @@
|
||||
// frontend-react/src/components/SideBarLeft/tabs/ApiConfig/ApiConfig.jsx
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import useApiConfigStore from '../../../../Store/SideBarLeft/ApiConfigSlice';
|
||||
import './ApiConfig.css';
|
||||
|
||||
const ApiConfig = () => {
|
||||
// 从store中获取状态和方法
|
||||
const {
|
||||
profiles,
|
||||
currentProfile,
|
||||
activeMap,
|
||||
loading,
|
||||
error,
|
||||
fetchProfiles,
|
||||
fetchProfile,
|
||||
saveProfile,
|
||||
deleteProfile,
|
||||
setActiveConfig,
|
||||
testConnection,
|
||||
notification
|
||||
} = useApiConfigStore();
|
||||
|
||||
// 本地表单状态 - 包含 4 个 API 配置
|
||||
const [formData, setFormData] = useState({
|
||||
mainLLM: { id: '', name: '', apiUrl: '', apiKey: '', model: '' },
|
||||
imageModel: { id: '', name: '', apiUrl: '', apiKey: '', model: '' },
|
||||
secondaryLLM: { id: '', name: '', apiUrl: '', apiKey: '', model: '' },
|
||||
ragEmbedding: { id: '', name: '', apiUrl: '', apiKey: '', model: '' }
|
||||
});
|
||||
|
||||
// 当前选中的配置文件 ID
|
||||
const [selectedProfileId, setSelectedProfileId] = useState('');
|
||||
|
||||
// 当前编辑的类别
|
||||
const [currentCategory, setCurrentCategory] = useState('mainLLM');
|
||||
|
||||
// 可用模型列表
|
||||
const [availableModels, setAvailableModels] = useState([]);
|
||||
|
||||
// 显示保存对话框
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
|
||||
// 要保存的 API 类别(勾选状态)
|
||||
const [apisToSave, setApisToSave] = useState({
|
||||
mainLLM: false,
|
||||
imageModel: false,
|
||||
secondaryLLM: false,
|
||||
ragEmbedding: false
|
||||
});
|
||||
|
||||
// 跟踪哪些 API 有修改
|
||||
const [modifiedApis, setModifiedApis] = useState({});
|
||||
|
||||
// 组件加载时获取配置文件列表
|
||||
useEffect(() => {
|
||||
fetchProfiles();
|
||||
}, [fetchProfiles]);
|
||||
|
||||
// 当选中配置文件时,加载该配置
|
||||
useEffect(() => {
|
||||
if (selectedProfileId) {
|
||||
loadProfile(selectedProfileId);
|
||||
}
|
||||
}, [selectedProfileId]);
|
||||
|
||||
// 加载配置文件
|
||||
const loadProfile = async (profileId) => {
|
||||
try {
|
||||
const profile = await fetchProfile(profileId);
|
||||
|
||||
// 合并到本地状态(服务器提供的覆盖,未提供的保留本地)
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
...profile.apis
|
||||
}));
|
||||
|
||||
// 清除修改标记
|
||||
setModifiedApis({});
|
||||
} catch (err) {
|
||||
console.error('加载配置文件失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理输入变化
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[currentCategory]: {
|
||||
...prev[currentCategory],
|
||||
[name]: value
|
||||
}
|
||||
}));
|
||||
|
||||
// 标记当前类别的 API 已被修改
|
||||
setModifiedApis(prev => ({
|
||||
...prev,
|
||||
[currentCategory]: true
|
||||
}));
|
||||
};
|
||||
|
||||
// 处理类别切换
|
||||
const handleTabChange = (categoryId) => {
|
||||
setCurrentCategory(categoryId);
|
||||
setAvailableModels([]);
|
||||
};
|
||||
|
||||
// 打开保存对话框
|
||||
const handleOpenSaveModal = () => {
|
||||
// 默认选中所有有修改的 API
|
||||
setApisToSave(modifiedApis);
|
||||
setShowSaveModal(true);
|
||||
};
|
||||
|
||||
// 处理保存配置文件
|
||||
const handleSave = async () => {
|
||||
// 收集要保存的 API 配置
|
||||
const apisToSaveData = {};
|
||||
Object.keys(apisToSave).forEach(category => {
|
||||
if (apisToSave[category]) {
|
||||
apisToSaveData[category] = formData[category];
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(apisToSaveData).length === 0) {
|
||||
alert('请至少选择一个 API 配置进行保存');
|
||||
return;
|
||||
}
|
||||
|
||||
const profileId = selectedProfileId || `profile-${Date.now()}`;
|
||||
const profileName = formData[currentCategory].name || profileId;
|
||||
|
||||
try {
|
||||
await saveProfile(profileId, profileName, apisToSaveData);
|
||||
setSelectedProfileId(profileId);
|
||||
setShowSaveModal(false);
|
||||
|
||||
// 清除修改标记
|
||||
setModifiedApis(prev => {
|
||||
const newModified = { ...prev };
|
||||
Object.keys(apisToSave).forEach(category => {
|
||||
if (apisToSave[category]) {
|
||||
delete newModified[category];
|
||||
}
|
||||
});
|
||||
return newModified;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('保存失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理重置
|
||||
const handleReset = () => {
|
||||
if (selectedProfileId) {
|
||||
loadProfile(selectedProfileId);
|
||||
} else {
|
||||
// 清空当前类别的配置
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[currentCategory]: { id: '', name: '', apiUrl: '', apiKey: '', model: '' }
|
||||
}));
|
||||
setModifiedApis(prev => {
|
||||
const newModified = { ...prev };
|
||||
delete newModified[currentCategory];
|
||||
return newModified;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 处理连接测试并获取模型列表
|
||||
const handleConnect = async () => {
|
||||
const currentApi = formData[currentCategory];
|
||||
if (!currentApi.apiUrl || !currentApi.apiKey) {
|
||||
alert('请先填写 API 地址和密钥');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const models = await testConnection(currentApi.apiUrl, currentApi.apiKey, currentCategory);
|
||||
setAvailableModels(models);
|
||||
if (models.length === 0) {
|
||||
alert('未找到可用模型');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('连接失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理模型选择
|
||||
const handleModelSelect = (selectedModel) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[currentCategory]: {
|
||||
...prev[currentCategory],
|
||||
model: selectedModel
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
// 处理从列表中选择配置文件
|
||||
const handleSelectProfile = (e) => {
|
||||
const profileId = e.target.value;
|
||||
setSelectedProfileId(profileId);
|
||||
};
|
||||
|
||||
// 处理新建配置文件
|
||||
const handleCreateNew = () => {
|
||||
setSelectedProfileId('');
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[currentCategory]: { id: '', name: '', apiUrl: '', apiKey: '', model: '' }
|
||||
}));
|
||||
setModifiedApis({});
|
||||
};
|
||||
|
||||
// 处理删除配置文件
|
||||
const handleDelete = async () => {
|
||||
if (selectedProfileId && confirm('确定要删除此配置文件吗?')) {
|
||||
await deleteProfile(selectedProfileId);
|
||||
setSelectedProfileId('');
|
||||
handleCreateNew();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理设为默认配置
|
||||
const handleSetActive = async () => {
|
||||
if (!selectedProfileId) {
|
||||
alert('请先保存配置文件');
|
||||
return;
|
||||
}
|
||||
await setActiveConfig(currentCategory, selectedProfileId);
|
||||
};
|
||||
|
||||
// 配置区域标签
|
||||
const configTabs = [
|
||||
{ id: 'mainLLM', label: '主LLM模型' },
|
||||
{ id: 'imageModel', label: '生图模型' },
|
||||
{ id: 'secondaryLLM', label: '副LLM模型' },
|
||||
{ id: 'ragEmbedding', label: 'RAG嵌入模型' }
|
||||
];
|
||||
|
||||
// 判断当前 API 密钥是否是脱敏的
|
||||
const isApiKeyMasked = formData[currentCategory].apiKey && formData[currentCategory].apiKey.endsWith('****');
|
||||
|
||||
// 计算有修改的 API 数量
|
||||
const modifiedCount = Object.keys(modifiedApis).filter(k => modifiedApis[k]).length;
|
||||
|
||||
return (
|
||||
<div className="api-config-container">
|
||||
<h2 className="api-config-title">API配置</h2>
|
||||
|
||||
{error && <div className="notification notification-error">{error}</div>}
|
||||
{notification.show && (
|
||||
<div className={`notification notification-${notification.type}`}>
|
||||
{notification.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="api-config-form">
|
||||
{/* 配置区域标签 */}
|
||||
<div className="config-tabs">
|
||||
{configTabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`config-tab ${currentCategory === tab.id ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
{modifiedApis[tab.id] && <span className="modified-indicator">●</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 配置管理区域 */}
|
||||
<div className="profile-manager">
|
||||
<div className="profile-header">
|
||||
<label htmlFor="profileSelect">配置文件</label>
|
||||
<div className="profile-controls">
|
||||
<select
|
||||
id="profileSelect"
|
||||
value={selectedProfileId}
|
||||
onChange={handleSelectProfile}
|
||||
className="form-control profile-select-input"
|
||||
>
|
||||
<option value="">新建配置文件...</option>
|
||||
{profiles.map(profile => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="profile-buttons">
|
||||
{!selectedProfileId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleCreateNew}
|
||||
>
|
||||
+ 新建
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleSetActive}
|
||||
disabled={activeMap[currentCategory] === selectedProfileId}
|
||||
>
|
||||
设为默认
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API配置表单 */}
|
||||
<div className="form-section">
|
||||
<div className="form-group">
|
||||
<label htmlFor="name">名称</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData[currentCategory].name}
|
||||
onChange={handleChange}
|
||||
placeholder="例如:GPT-4 生产环境"
|
||||
className="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="apiUrl">API 地址</label>
|
||||
<input
|
||||
type="text"
|
||||
id="apiUrl"
|
||||
name="apiUrl"
|
||||
value={formData[currentCategory].apiUrl}
|
||||
onChange={handleChange}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
className="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="apiKey">密钥</label>
|
||||
<input
|
||||
type="password"
|
||||
id="apiKey"
|
||||
name="apiKey"
|
||||
value={isApiKeyMasked ? '' : formData[currentCategory].apiKey}
|
||||
onChange={handleChange}
|
||||
placeholder={isApiKeyMasked ? '已保存(留空保持不变)' : 'sk-...'}
|
||||
className="form-control"
|
||||
/>
|
||||
{isApiKeyMasked && (
|
||||
<span className="form-hint">当前密钥已加密存储</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group flex-1">
|
||||
<label htmlFor="model">模型</label>
|
||||
<input
|
||||
type="text"
|
||||
id="model"
|
||||
name="model"
|
||||
value={formData[currentCategory].model}
|
||||
onChange={handleChange}
|
||||
placeholder="gpt-3.5-turbo"
|
||||
className="form-control"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group flex-auto">
|
||||
<label> </label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-full"
|
||||
onClick={handleConnect}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '连接中...' : '获取模型列表'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{availableModels.length > 0 && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="modelSelect">选择模型</label>
|
||||
<select
|
||||
id="modelSelect"
|
||||
value={formData[currentCategory].model}
|
||||
onChange={(e) => handleModelSelect(e.target.value)}
|
||||
className="form-control"
|
||||
>
|
||||
<option value="">从列表中选择...</option>
|
||||
{availableModels.map((model, index) => (
|
||||
<option key={index} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-text"
|
||||
onClick={handleReset}
|
||||
disabled={loading}
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleOpenSaveModal}
|
||||
disabled={loading || modifiedCount === 0}
|
||||
>
|
||||
{loading ? '保存中...' : modifiedCount > 0 ? `保存配置 (${modifiedCount})` : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 保存对话框 */}
|
||||
{showSaveModal && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content">
|
||||
<h3 className="modal-title">选择要保存的配置</h3>
|
||||
<div className="modal-body">
|
||||
{configTabs.map(tab => (
|
||||
<label key={tab.id} className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={apisToSave[tab.id] || false}
|
||||
onChange={(e) => setApisToSave(prev => ({
|
||||
...prev,
|
||||
[tab.id]: e.target.checked
|
||||
}))}
|
||||
/>
|
||||
<span className="checkbox-text">
|
||||
{tab.label}
|
||||
{modifiedApis[tab.id] && <span className="modified-badge">已修改</span>}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-text"
|
||||
onClick={() => setShowSaveModal(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleSave}
|
||||
>
|
||||
保存选中的配置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiConfig;
|
||||
@@ -0,0 +1,160 @@
|
||||
// frontend-react/src/components/SideBarLeft/tabs/ApiConfig/ComfyUIWorkflowManager.jsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import './ApiConfig.css';
|
||||
|
||||
const ComfyUIWorkflowManager = ({ apiUrl }) => {
|
||||
const [workflows, setWorkflows] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
// 加载工作流列表
|
||||
const loadWorkflows = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/api-config/comfyui/workflows');
|
||||
if (!response.ok) throw new Error('Failed to load workflows');
|
||||
const data = await response.json();
|
||||
setWorkflows(data);
|
||||
} catch (err) {
|
||||
console.error('加载工作流失败:', err);
|
||||
alert('加载工作流失败: ' + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadWorkflows();
|
||||
}, []);
|
||||
|
||||
// 上传工作流文件
|
||||
const handleUpload = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.name.endsWith('.json')) {
|
||||
alert('请上传 JSON 文件');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
const response = await fetch('/api/api-config/comfyui/workflows/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Upload failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
alert(`上传成功: ${result.filename}`);
|
||||
|
||||
// 重新加载列表
|
||||
await loadWorkflows();
|
||||
} catch (err) {
|
||||
console.error('上传失败:', err);
|
||||
alert('上传失败: ' + err.message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
// 清空文件输入
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// 删除工作流
|
||||
const handleDelete = async (filename) => {
|
||||
if (!confirm(`确定要删除工作流 "${filename}" 吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/api-config/comfyui/workflows/${filename}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Delete failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
alert(result.message);
|
||||
|
||||
// 重新加载列表
|
||||
await loadWorkflows();
|
||||
} catch (err) {
|
||||
console.error('删除失败:', err);
|
||||
alert('删除失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化文件大小
|
||||
const formatSize = (bytes) => {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="workflow-manager-compact">
|
||||
<div className="workflow-header-compact">
|
||||
<span className="workflow-label">工作流</span>
|
||||
<div className="workflow-actions-compact">
|
||||
<label className="btn-icon" title="导入工作流">
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleUpload}
|
||||
disabled={uploading}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
{uploading ? '⏳' : '📤'}
|
||||
</label>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={loadWorkflows}
|
||||
disabled={loading}
|
||||
title="刷新"
|
||||
>
|
||||
{loading ? '⏳' : '🔄'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="workflow-list-compact">
|
||||
{workflows.length === 0 ? (
|
||||
<div className="empty-hint">无工作流</div>
|
||||
) : (
|
||||
workflows.map((workflow) => (
|
||||
<div key={workflow.filename} className="workflow-item-compact">
|
||||
<span className="workflow-name-compact">
|
||||
{workflow.filename === 'default_txt2img.json' && (
|
||||
<span className="badge-default">默认</span>
|
||||
)}
|
||||
{workflow.name}
|
||||
</span>
|
||||
|
||||
{workflow.filename !== 'default_txt2img.json' && (
|
||||
<button
|
||||
className="btn-icon-danger"
|
||||
onClick={() => handleDelete(workflow.filename)}
|
||||
title="删除"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComfyUIWorkflowManager;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ApiConfig';
|
||||
@@ -0,0 +1,115 @@
|
||||
/* ==================== 角色卡页面 - 简洁现代化风格 ==================== */
|
||||
|
||||
.character-card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
background: var(--color-bg-primary);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 标题栏 */
|
||||
.tab-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.tab-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 6px 12px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.15s ease;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 角色列表 */
|
||||
.character-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.character-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.character-item:hover {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.character-avatar {
|
||||
font-size: 24px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg-primary);
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.character-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.character-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.character-desc {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// frontend-react/src/components/SideBarLeft/tabs/CharacterCard/CharacterCard.jsx
|
||||
import React, { useState } from 'react';
|
||||
import './CharacterCard.css';
|
||||
|
||||
const CharacterCard = () => {
|
||||
const [characters, setCharacters] = useState([
|
||||
{ id: 1, name: '冒险者', description: '勇敢的探险家', avatar: '🗡️' },
|
||||
{ id: 2, name: '魔法师', description: '精通奥术', avatar: '🔮' },
|
||||
{ id: 3, name: '商人', description: '精明的交易者', avatar: '💰' },
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="character-card-content">
|
||||
{/* 标题栏 */}
|
||||
<div className="tab-header">
|
||||
<span className="title-text">角色卡</span>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="tab-actions">
|
||||
<button className="action-btn">+ 新建</button>
|
||||
<button className="action-btn">📥 导入</button>
|
||||
</div>
|
||||
|
||||
{/* 角色列表 */}
|
||||
<div className="character-list">
|
||||
{characters.map(char => (
|
||||
<div key={char.id} className="character-item">
|
||||
<span className="character-avatar">{char.avatar}</span>
|
||||
<div className="character-info">
|
||||
<div className="character-name">{char.name}</div>
|
||||
<div className="character-desc">{char.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CharacterCard;
|
||||
@@ -0,0 +1,2 @@
|
||||
// frontend-react/src/components/SideBarLeft/tabs/CharacterCard/index.js
|
||||
export { default } from './CharacterCard';
|
||||
13
frontend/src/components/SideBarLeft/tabs/Gallery/Gallery.jsx
Normal file
13
frontend/src/components/SideBarLeft/tabs/Gallery/Gallery.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import './Gallery.css';
|
||||
|
||||
const Gallery = () => {
|
||||
return (
|
||||
<div className="gallery-content">
|
||||
<h2>画廊</h2>
|
||||
{/* 在这里实现画廊的具体内容 */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Gallery;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './Gallery';
|
||||
735
frontend/src/components/SideBarLeft/tabs/Presets/Presets.css
Normal file
735
frontend/src/components/SideBarLeft/tabs/Presets/Presets.css
Normal file
@@ -0,0 +1,735 @@
|
||||
/* 主容器 */
|
||||
.preset-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
background: #f8f9fa;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 工具提示 */
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
padding: 6px 10px;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
transform: translate(-50%, -100%);
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
/* 预设头部 */
|
||||
.preset-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.preset-select-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preset-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preset-select {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #495057;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.preset-select:hover {
|
||||
border-color: #ced4da;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.preset-select:focus {
|
||||
outline: none;
|
||||
border-color: #4a6cf7;
|
||||
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
|
||||
}
|
||||
|
||||
.preset-select:disabled {
|
||||
background: #f1f3f5;
|
||||
cursor: not-allowed;
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.preset-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preset-action-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.preset-action-btn:hover {
|
||||
background: #f8f9fa;
|
||||
border-color: #ced4da;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.preset-action-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 对话框 */
|
||||
.preset-save-dialog,
|
||||
.preset-edit-dialog,
|
||||
.preset-import-dialog {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: white;
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.preset-save-dialog input,
|
||||
.preset-edit-dialog input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 12px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.preset-save-dialog input:focus,
|
||||
.preset-edit-dialog input:focus {
|
||||
outline: none;
|
||||
border-color: #4a6cf7;
|
||||
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
|
||||
}
|
||||
|
||||
.preset-import-dialog textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 12px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.preset-import-dialog textarea:focus {
|
||||
outline: none;
|
||||
border-color: #4a6cf7;
|
||||
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
|
||||
}
|
||||
|
||||
.dialog-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dialog-buttons button {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dialog-buttons button:first-child {
|
||||
background: #4a6cf7;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dialog-buttons button:first-child:hover {
|
||||
background: #3a5ce5;
|
||||
}
|
||||
|
||||
.dialog-buttons button:last-child {
|
||||
background: #f1f3f5;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.dialog-buttons button:last-child:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
/* 组件编辑对话框 */
|
||||
.component-edit-dialog {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
min-width: 450px;
|
||||
max-width: 80vw;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.dialog-header h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.dialog-header .close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
color: #adb5bd;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.dialog-header .close-btn:hover {
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.component-textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.component-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #4a6cf7;
|
||||
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
|
||||
}
|
||||
|
||||
.component-textarea[readonly] {
|
||||
background: #f1f3f5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.token-count {
|
||||
font-size: 11px;
|
||||
color: #6c757d;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 参数设置区域 */
|
||||
.preset-parameters-container {
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.parameters-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: #f8f9fa;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.parameters-header:hover {
|
||||
background: #f1f3f5;
|
||||
}
|
||||
|
||||
.parameters-header span:first-child {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 10px;
|
||||
color: #6c757d;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.expand-icon.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.preset-parameters {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.parameter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.parameter-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.parameter-label {
|
||||
width: 100px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.parameter-slider {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
height: 4px;
|
||||
background: #e9ecef;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.parameter-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #4a6cf7;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.parameter-slider::-webkit-slider-thumb:hover {
|
||||
background: #3a5ce5;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.parameter-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #4a6cf7;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.parameter-slider::-moz-range-thumb:hover {
|
||||
background: #3a5ce5;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.parameter-number {
|
||||
width: 60px;
|
||||
padding: 4px 6px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.parameter-number:focus {
|
||||
outline: none;
|
||||
border-color: #4a6cf7;
|
||||
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
|
||||
}
|
||||
|
||||
.parameter-input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.parameter-input:focus {
|
||||
outline: none;
|
||||
border-color: #4a6cf7;
|
||||
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
|
||||
}
|
||||
|
||||
.parameter-toggles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.toggle-checkbox {
|
||||
width: 38px;
|
||||
height: 20px;
|
||||
-webkit-appearance: none;
|
||||
background: #e9ecef;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toggle-checkbox::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.toggle-checkbox:checked {
|
||||
background: #4a6cf7;
|
||||
}
|
||||
|
||||
.toggle-checkbox:checked::after {
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
/* 预设组件区域 */
|
||||
.preset-components-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.components-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: #f8f9fa;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.components-header h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.add-component-btn {
|
||||
padding: 4px 10px;
|
||||
background: #4a6cf7;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-component-btn:hover {
|
||||
background: #3a5ce5;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(74, 108, 247, 0.2);
|
||||
}
|
||||
|
||||
.components-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.drag-indicator {
|
||||
height: 3px;
|
||||
background: #4a6cf7;
|
||||
border-radius: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.drag-indicator.visible {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.prompt-component-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.prompt-component-item:hover {
|
||||
background: white;
|
||||
border-color: #ced4da;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.prompt-component-item.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.prompt-component-item.marker {
|
||||
background: rgba(74, 108, 247, 0.05);
|
||||
border-color: rgba(74, 108, 247, 0.2);
|
||||
}
|
||||
|
||||
.prompt-component-item.dragging {
|
||||
opacity: 0.5;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.component-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.component-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
color: #adb5bd;
|
||||
cursor: grab;
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
border-color: #ced4da;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.toggle-btn.enabled {
|
||||
background: #4a6cf7;
|
||||
color: white;
|
||||
border-color: #4a6cf7;
|
||||
}
|
||||
|
||||
.toggle-btn.enabled:hover {
|
||||
background: #3a5ce5;
|
||||
}
|
||||
|
||||
.component-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #2c3e50;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.component-marker-badge {
|
||||
padding: 1px 6px;
|
||||
background: rgba(74, 108, 247, 0.1);
|
||||
color: #4a6cf7;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.component-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
padding: 3px 6px;
|
||||
background: white;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: #f8f9fa;
|
||||
border-color: #ced4da;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
padding: 3px 6px;
|
||||
background: white;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: #dc3545;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #fff5f5;
|
||||
border-color: #ff6b6b;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-scrollbar);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-scrollbar-hover);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.preset-panel {
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.preset-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.preset-select-container {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.preset-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.component-edit-dialog {
|
||||
min-width: 85vw;
|
||||
}
|
||||
}
|
||||
801
frontend/src/components/SideBarLeft/tabs/Presets/Presets.jsx
Normal file
801
frontend/src/components/SideBarLeft/tabs/Presets/Presets.jsx
Normal file
@@ -0,0 +1,801 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import usePresetStore from '../../../../Store/SideBarLeft/PresetSlice';
|
||||
import useSideBarLeftStore from '../../../../Store/SideBarLeft/SideBarLeftSlice';
|
||||
import './Presets.css';
|
||||
|
||||
const PresetPanel = () => {
|
||||
const {
|
||||
selectedPreset,
|
||||
parameters,
|
||||
presets,
|
||||
isLoadingPresets,
|
||||
promptComponents,
|
||||
setSelectedPreset,
|
||||
updateParameter,
|
||||
saveCurrentAsPreset,
|
||||
editPresetName: updatePresetName,
|
||||
isParametersExpanded,
|
||||
toggleParametersExpanded,
|
||||
fetchPresets,
|
||||
setPromptComponents,
|
||||
toggleComponentEnabled,
|
||||
updateComponent,
|
||||
addComponent,
|
||||
removeComponent,
|
||||
moveComponent
|
||||
} = usePresetStore();
|
||||
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [showComponentEditDialog, setShowComponentEditDialog] = useState(false);
|
||||
const [newPresetName, setNewPresetName] = useState('');
|
||||
const [editPresetId, setEditPresetId] = useState('');
|
||||
const [editPresetName, setEditPresetName] = useState('');
|
||||
const [importPresetData, setImportPresetData] = useState('');
|
||||
const [tooltip, setTooltip] = useState({ visible: false, content: '', x: 0, y: 0 });
|
||||
|
||||
// 组件编辑状态
|
||||
const [editingComponentIndex, setEditingComponentIndex] = useState(-1);
|
||||
const [editComponentContent, setEditComponentContent] = useState('');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// 拖拽状态
|
||||
const [draggedItem, setDraggedItem] = useState(null);
|
||||
const [dragOverItem, setDragOverItem] = useState(null);
|
||||
|
||||
// 获取当前激活的分页
|
||||
const { activeTab } = useSideBarLeftStore();
|
||||
|
||||
// 记录上一次的分页状态
|
||||
const prevActiveTabRef = React.useRef(activeTab);
|
||||
|
||||
// 参数描述映射
|
||||
const parameterDescriptions = {
|
||||
temperature: "生成温度,控制随机性(0-2)",
|
||||
frequency_penalty: "频率惩罚,降低重复token概率",
|
||||
presence_penalty: "存在惩罚,鼓励谈论新话题",
|
||||
top_p: "核采样,控制词汇选择范围",
|
||||
top_k: "随机采样范围,从概率最高的K个词中选择",
|
||||
max_context: "上下文窗口大小(Token上限)",
|
||||
max_tokens: "单次回复的最大长度",
|
||||
max_context_unlocked: "是否允许超出限制的上下文",
|
||||
stream_openai: "是否使用流式输出",
|
||||
seed: "随机种子(-1为随机)",
|
||||
n: "生成回复的数量"
|
||||
};
|
||||
|
||||
// 显示工具提示
|
||||
const showTooltip = (event, content) => {
|
||||
setTooltip({
|
||||
visible: true,
|
||||
content,
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
});
|
||||
};
|
||||
|
||||
// 隐藏工具提示
|
||||
const hideTooltip = () => {
|
||||
setTooltip({ ...tooltip, visible: false });
|
||||
};
|
||||
|
||||
// 处理参数更新
|
||||
const handleParameterChange = (name, value) => {
|
||||
let convertedValue = value;
|
||||
if (name === 'temperature' || name === 'frequency_penalty' || name === 'presence_penalty' || name === 'top_p') {
|
||||
convertedValue = parseFloat(value);
|
||||
} else if (name === 'top_k' || name === 'max_context' || name === 'max_tokens' || name === 'seed' || name === 'n') {
|
||||
convertedValue = parseInt(value, 10);
|
||||
} else if (name === 'max_context_unlocked' || name === 'stream_openai') {
|
||||
convertedValue = value;
|
||||
}
|
||||
|
||||
updateParameter({ name, value: convertedValue });
|
||||
};
|
||||
|
||||
// 加载预设列表 - 只在切换到预设分页时刷新
|
||||
useEffect(() => {
|
||||
// 检测是否从其他分页切换到预设分页
|
||||
if (activeTab === 'presets' && prevActiveTabRef.current !== 'presets') {
|
||||
fetchPresets();
|
||||
}
|
||||
// 更新上一次的分页状态
|
||||
prevActiveTabRef.current = activeTab;
|
||||
}, [activeTab, fetchPresets]);
|
||||
|
||||
// 保存当前设置为预设
|
||||
const handleSavePreset = async () => {
|
||||
if (newPresetName.trim()) {
|
||||
try {
|
||||
await saveCurrentAsPreset({ name: newPresetName });
|
||||
setNewPresetName('');
|
||||
setShowSaveDialog(false);
|
||||
// 重新加载预设列表
|
||||
fetchPresets();
|
||||
} catch (error) {
|
||||
console.error('保存预设失败:', error);
|
||||
alert('保存预设失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 编辑预设名称
|
||||
const handleEditPreset = async () => {
|
||||
if (editPresetId && editPresetName.trim()) {
|
||||
try {
|
||||
await updatePresetName(editPresetId, editPresetName);
|
||||
setEditPresetId('');
|
||||
setEditPresetName('');
|
||||
setShowEditDialog(false);
|
||||
// 重新加载预设列表
|
||||
fetchPresets();
|
||||
} catch (error) {
|
||||
console.error('编辑预设名称失败:', error);
|
||||
alert('编辑预设名称失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 导入预设
|
||||
const handleImportPreset = async () => {
|
||||
try {
|
||||
const importedPreset = JSON.parse(importPresetData);
|
||||
if (importedPreset.name && importedPreset.parameters) {
|
||||
await saveCurrentAsPreset({ name: importedPreset.name });
|
||||
// 更新参数
|
||||
Object.keys(importedPreset.parameters).forEach(key => {
|
||||
updateParameter({ name: key, value: importedPreset.parameters[key] });
|
||||
});
|
||||
|
||||
// 更新组件列表
|
||||
if (importedPreset.promptComponents) {
|
||||
setPromptComponents(importedPreset.promptComponents);
|
||||
}
|
||||
|
||||
setImportPresetData('');
|
||||
setShowImportDialog(false);
|
||||
// 重新加载预设列表
|
||||
fetchPresets();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导入预设失败:', error);
|
||||
alert('导入预设失败: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出预设
|
||||
const handleExportPreset = () => {
|
||||
if (selectedPreset) {
|
||||
const preset = presets.find(p => p.id === selectedPreset);
|
||||
if (preset) {
|
||||
const exportData = {
|
||||
...preset,
|
||||
parameters,
|
||||
promptComponents
|
||||
};
|
||||
const dataStr = JSON.stringify(exportData, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${preset.name}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 添加新组件
|
||||
const handleAddNewComponent = () => {
|
||||
const newComponent = {
|
||||
identifier: `component_${Date.now()}`,
|
||||
name: '新组件',
|
||||
content: '',
|
||||
role: 0,
|
||||
system_prompt: false,
|
||||
marker: false,
|
||||
enabled: true
|
||||
};
|
||||
addComponent(newComponent);
|
||||
};
|
||||
|
||||
// 开始编辑组件
|
||||
const handleStartEditComponent = (index) => {
|
||||
setEditingComponentIndex(index);
|
||||
setEditComponentContent(promptComponents[index].content);
|
||||
setIsEditing(true);
|
||||
setShowComponentEditDialog(true);
|
||||
};
|
||||
|
||||
// 查看组件内容
|
||||
const handleViewComponent = (index) => {
|
||||
setEditingComponentIndex(index);
|
||||
setEditComponentContent(promptComponents[index].content);
|
||||
setIsEditing(false);
|
||||
setShowComponentEditDialog(true);
|
||||
};
|
||||
|
||||
// 保存组件编辑
|
||||
const handleSaveComponentEdit = () => {
|
||||
if (editingComponentIndex >= 0 && isEditing) {
|
||||
updateComponent(editingComponentIndex, { content: editComponentContent });
|
||||
}
|
||||
setEditingComponentIndex(-1);
|
||||
setEditComponentContent('');
|
||||
setShowComponentEditDialog(false);
|
||||
};
|
||||
|
||||
// 取消组件编辑
|
||||
const handleCancelComponentEdit = () => {
|
||||
setEditingComponentIndex(-1);
|
||||
setEditComponentContent('');
|
||||
setShowComponentEditDialog(false);
|
||||
};
|
||||
|
||||
// 关闭组件查看对话框
|
||||
const handleCloseComponentView = () => {
|
||||
setEditingComponentIndex(-1);
|
||||
setEditComponentContent('');
|
||||
setShowComponentEditDialog(false);
|
||||
};
|
||||
|
||||
// 切换组件启用状态
|
||||
const handleToggleComponentEnabled = (index) => {
|
||||
toggleComponentEnabled(index);
|
||||
};
|
||||
|
||||
// 删除组件
|
||||
const handleDeleteComponent = (index) => {
|
||||
if (window.confirm('确定要删除这个组件吗?')) {
|
||||
removeComponent(index);
|
||||
}
|
||||
};
|
||||
|
||||
// 拖拽开始
|
||||
const handleDragStart = (e, index) => {
|
||||
setDraggedItem(index);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', index.toString());
|
||||
setTimeout(() => {
|
||||
e.target.classList.add('dragging');
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// 拖拽结束
|
||||
const handleDragEnd = (e) => {
|
||||
setDraggedItem(null);
|
||||
setDragOverItem(null);
|
||||
e.target.classList.remove('dragging');
|
||||
};
|
||||
|
||||
// 拖拽经过
|
||||
const handleDragOver = (e, index) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (draggedItem === null || draggedItem === index) return;
|
||||
setDragOverItem(index);
|
||||
};
|
||||
|
||||
// 放置
|
||||
const handleDrop = (e, index) => {
|
||||
e.preventDefault();
|
||||
if (draggedItem === null || draggedItem === index) return;
|
||||
|
||||
moveComponent(draggedItem, index);
|
||||
|
||||
setDraggedItem(null);
|
||||
setDragOverItem(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="preset-panel">
|
||||
{/* 工具提示 */}
|
||||
{tooltip.visible && (
|
||||
<div
|
||||
className="tooltip"
|
||||
style={{ left: `${tooltip.x}px`, top: `${tooltip.y}px` }}
|
||||
>
|
||||
{tooltip.content}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 顶部:预设选择与操作 */}
|
||||
<div className="preset-header">
|
||||
{/* 预设选择下拉框 */}
|
||||
<div className="preset-select-container">
|
||||
<label
|
||||
className="preset-label"
|
||||
onMouseEnter={(e) => showTooltip(e, "选择预设配置")}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
预设:
|
||||
</label>
|
||||
<select
|
||||
className="preset-select"
|
||||
value={selectedPreset}
|
||||
onChange={(e) => setSelectedPreset(e.target.value)}
|
||||
disabled={isLoadingPresets}
|
||||
>
|
||||
<option value="">{isLoadingPresets ? "加载中..." : "选择预设..."}</option>
|
||||
{presets.map(preset => (
|
||||
<option key={preset.id} value={preset.id}>{preset.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="preset-actions">
|
||||
<button
|
||||
className="preset-action-btn"
|
||||
onClick={() => setShowSaveDialog(true)}
|
||||
onMouseEnter={(e) => showTooltip(e, "保存当前设置为新预设")}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
💾
|
||||
</button>
|
||||
<button
|
||||
className="preset-action-btn"
|
||||
onClick={() => {
|
||||
if (selectedPreset) {
|
||||
const preset = presets.find(p => p.id === selectedPreset);
|
||||
if (preset) {
|
||||
setEditPresetId(selectedPreset);
|
||||
setEditPresetName(preset.name);
|
||||
setShowEditDialog(true);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => showTooltip(e, "编辑当前预设")}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
className="preset-action-btn"
|
||||
onClick={() => setShowImportDialog(true)}
|
||||
onMouseEnter={(e) => showTooltip(e, "导入预设")}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
📥
|
||||
</button>
|
||||
<button
|
||||
className="preset-action-btn"
|
||||
onClick={handleExportPreset}
|
||||
onMouseEnter={(e) => showTooltip(e, "导出当前预设")}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
📤
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 保存预设对话框 */}
|
||||
{showSaveDialog && (
|
||||
<div className="preset-save-dialog">
|
||||
<input
|
||||
type="text"
|
||||
value={newPresetName}
|
||||
onChange={(e) => setNewPresetName(e.target.value)}
|
||||
placeholder="预设名称"
|
||||
/>
|
||||
<div className="dialog-buttons">
|
||||
<button onClick={handleSavePreset}>保存</button>
|
||||
<button onClick={() => setShowSaveDialog(false)}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑预设对话框 */}
|
||||
{showEditDialog && (
|
||||
<div className="preset-edit-dialog">
|
||||
<input
|
||||
type="text"
|
||||
value={editPresetName}
|
||||
onChange={(e) => setEditPresetName(e.target.value)}
|
||||
placeholder="预设名称"
|
||||
/>
|
||||
<div className="dialog-buttons">
|
||||
<button onClick={handleEditPreset}>保存</button>
|
||||
<button onClick={() => setShowEditDialog(false)}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 导入预设对话框 */}
|
||||
{showImportDialog && (
|
||||
<div className="preset-import-dialog">
|
||||
<textarea
|
||||
className="import-textarea"
|
||||
value={importPresetData}
|
||||
onChange={(e) => setImportPresetData(e.target.value)}
|
||||
placeholder="粘贴预设JSON数据"
|
||||
rows="5"
|
||||
/>
|
||||
<div className="dialog-buttons">
|
||||
<button onClick={handleImportPreset}>导入</button>
|
||||
<button onClick={() => setShowImportDialog(false)}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑/查看组件内容对话框 */}
|
||||
{showComponentEditDialog && (
|
||||
<div className="component-edit-dialog">
|
||||
<div className="dialog-header">
|
||||
<h3>{isEditing ? '编辑' : '查看'}组件: {editingComponentIndex >= 0 && promptComponents[editingComponentIndex].name}</h3>
|
||||
<button className="close-btn" onClick={handleCloseComponentView}>×</button>
|
||||
</div>
|
||||
<div className="dialog-content">
|
||||
<textarea
|
||||
value={editComponentContent}
|
||||
onChange={(e) => setEditComponentContent(e.target.value)}
|
||||
className="component-textarea"
|
||||
readOnly={!isEditing}
|
||||
rows={20}
|
||||
/>
|
||||
</div>
|
||||
<div className="dialog-footer">
|
||||
<span className="token-count">
|
||||
{editComponentContent ? editComponentContent.length : 0}
|
||||
</span>
|
||||
{isEditing && (
|
||||
<div className="dialog-buttons">
|
||||
<button onClick={handleSaveComponentEdit}>保存</button>
|
||||
<button onClick={handleCancelComponentEdit}>取消</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 参数设置区域 */}
|
||||
<div className="preset-parameters-container">
|
||||
<div
|
||||
className="parameters-header"
|
||||
onClick={toggleParametersExpanded}
|
||||
>
|
||||
<span>参数设置</span>
|
||||
<span className={`expand-icon ${isParametersExpanded ? 'expanded' : ''}`}>▼</span>
|
||||
</div>
|
||||
|
||||
{isParametersExpanded && (
|
||||
<div className="preset-parameters">
|
||||
{/* 温度滑块 */}
|
||||
<div className="parameter-row">
|
||||
<label
|
||||
className="parameter-label"
|
||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.temperature)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
Temperature
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={parameters.temperature}
|
||||
onChange={(e) => handleParameterChange('temperature', e.target.value)}
|
||||
className="parameter-slider"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={parameters.temperature}
|
||||
onChange={(e) => handleParameterChange('temperature', e.target.value)}
|
||||
className="parameter-number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 频率惩罚滑块 */}
|
||||
<div className="parameter-row">
|
||||
<label
|
||||
className="parameter-label"
|
||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.frequency_penalty)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
Frequency Penalty
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="-2"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={parameters.frequency_penalty}
|
||||
onChange={(e) => handleParameterChange('frequency_penalty', e.target.value)}
|
||||
className="parameter-slider"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="-2"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={parameters.frequency_penalty}
|
||||
onChange={(e) => handleParameterChange('frequency_penalty', e.target.value)}
|
||||
className="parameter-number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 存在惩罚滑块 */}
|
||||
<div className="parameter-row">
|
||||
<label
|
||||
className="parameter-label"
|
||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.presence_penalty)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
Presence Penalty
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="-2"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={parameters.presence_penalty}
|
||||
onChange={(e) => handleParameterChange('presence_penalty', e.target.value)}
|
||||
className="parameter-slider"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="-2"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={parameters.presence_penalty}
|
||||
onChange={(e) => handleParameterChange('presence_penalty', e.target.value)}
|
||||
className="parameter-number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top P 滑块 */}
|
||||
<div className="parameter-row">
|
||||
<label
|
||||
className="parameter-label"
|
||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.top_p)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
Top P
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={parameters.top_p}
|
||||
onChange={(e) => handleParameterChange('top_p', e.target.value)}
|
||||
className="parameter-slider"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={parameters.top_p}
|
||||
onChange={(e) => handleParameterChange('top_p', e.target.value)}
|
||||
className="parameter-number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top K 输入框 */}
|
||||
<div className="parameter-row">
|
||||
<label
|
||||
className="parameter-label"
|
||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.top_k)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
Top K
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={parameters.top_k}
|
||||
onChange={(e) => handleParameterChange('top_k', e.target.value)}
|
||||
className="parameter-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 最大上下文输入框 */}
|
||||
<div className="parameter-row">
|
||||
<label
|
||||
className="parameter-label"
|
||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.max_context)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
Max Context
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000000"
|
||||
value={parameters.max_context}
|
||||
onChange={(e) => handleParameterChange('max_context', e.target.value)}
|
||||
className="parameter-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 最大Token输入框 */}
|
||||
<div className="parameter-row">
|
||||
<label
|
||||
className="parameter-label"
|
||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.max_tokens)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
Max Tokens
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100000"
|
||||
value={parameters.max_tokens}
|
||||
onChange={(e) => handleParameterChange('max_tokens', e.target.value)}
|
||||
className="parameter-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 随机种子输入框 */}
|
||||
<div className="parameter-row">
|
||||
<label
|
||||
className="parameter-label"
|
||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.seed)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
Seed
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={parameters.seed}
|
||||
onChange={(e) => handleParameterChange('seed', e.target.value)}
|
||||
className="parameter-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 生成数量输入框 */}
|
||||
<div className="parameter-row">
|
||||
<label
|
||||
className="parameter-label"
|
||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.n)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
N (生成数量)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={parameters.n}
|
||||
onChange={(e) => handleParameterChange('n', e.target.value)}
|
||||
className="parameter-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 开关选项 */}
|
||||
<div className="parameter-toggles">
|
||||
<div className="toggle-row">
|
||||
<label
|
||||
className="toggle-label"
|
||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.max_context_unlocked)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
Max Context Unlocked
|
||||
</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={parameters.max_context_unlocked}
|
||||
onChange={(e) => handleParameterChange('max_context_unlocked', e.target.checked)}
|
||||
className="toggle-checkbox"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="toggle-row">
|
||||
<label
|
||||
className="toggle-label"
|
||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.stream_openai)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
Stream Output
|
||||
</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={parameters.stream_openai}
|
||||
onChange={(e) => handleParameterChange('stream_openai', e.target.checked)}
|
||||
className="toggle-checkbox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 预设组件列表 */}
|
||||
<div className="preset-components-section">
|
||||
<div className="components-header">
|
||||
<h3>预设组件</h3>
|
||||
<button
|
||||
onClick={handleAddNewComponent}
|
||||
className="add-component-btn"
|
||||
onMouseEnter={(e) => showTooltip(e, "添加新组件")}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
+ 添加组件
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="components-list draggable-container">
|
||||
{promptComponents.map((component, index) => (
|
||||
<React.Fragment key={component.identifier}>
|
||||
{/* 拖拽指示器 - 在组件上方 */}
|
||||
<div
|
||||
className={`drag-indicator ${dragOverItem === index ? 'visible' : ''}`}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
/>
|
||||
|
||||
{/* 组件项 */}
|
||||
<div
|
||||
className={`prompt-component-item ${!component.enabled ? 'disabled' : ''} ${component.marker ? 'marker' : ''} ${draggedItem === index ? 'dragging' : ''}`}
|
||||
draggable={!component.marker}
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="component-header">
|
||||
<div className="component-controls">
|
||||
<div className="drag-handle">⋮⋮</div>
|
||||
<button
|
||||
className={`toggle-btn ${component.enabled ? 'enabled' : 'disabled'}`}
|
||||
onClick={() => handleToggleComponentEnabled(index)}
|
||||
onMouseEnter={(e) => showTooltip(e, component.enabled ? "禁用组件" : "启用组件")}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
{component.enabled ? '✓' : '○'}
|
||||
</button>
|
||||
<span className="component-name">{component.name}</span>
|
||||
{component.marker && (
|
||||
<span className="component-marker-badge">🔒</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="component-actions">
|
||||
<button
|
||||
className="edit-btn"
|
||||
onClick={() => handleStartEditComponent(index)}
|
||||
onMouseEnter={(e) => showTooltip(e, "编辑/查看组件")}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<span className="token-count">
|
||||
{component.content ? component.content.length : 0}
|
||||
</span>
|
||||
{!component.marker && (
|
||||
<button
|
||||
className="delete-btn"
|
||||
onClick={() => handleDeleteComponent(index)}
|
||||
onMouseEnter={(e) => showTooltip(e, "删除组件")}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{/* 最后一个拖拽指示器 - 在列表末尾 */}
|
||||
<div
|
||||
className={`drag-indicator ${dragOverItem === promptComponents.length ? 'visible' : ''}`}
|
||||
onDragOver={(e) => handleDragOver(e, promptComponents.length)}
|
||||
onDrop={(e) => handleDrop(e, promptComponents.length)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PresetPanel;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './Presets';
|
||||
756
frontend/src/components/SideBarLeft/tabs/WorldBook/WorldBook.css
Normal file
756
frontend/src/components/SideBarLeft/tabs/WorldBook/WorldBook.css
Normal file
@@ -0,0 +1,756 @@
|
||||
/* ==================== 世界书页面 - 简洁现代化风格 ==================== */
|
||||
|
||||
.worldbook-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
background: var(--color-bg-primary);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ==================== 全局世界书区域 - SillyTavern 风格 ==================== */
|
||||
.global-books-display {
|
||||
padding: 8px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.global-books-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 6px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.global-books-header .title-text {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.global-books-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
/* 使用 line-height 控制行高,更灵活 */
|
||||
max-height: calc(2.4em + 6px); /* 两行 (1.2em × 2) + gap */
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
align-content: flex-start;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.global-books-list::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.global-books-list::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.global-books-list::-webkit-scrollbar-thumb {
|
||||
background: var(--color-scrollbar);
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.global-books-list::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-scrollbar-hover);
|
||||
}
|
||||
|
||||
.global-book-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
line-height: 1.2; /* 设置行高,用于父容器计算 */
|
||||
color: var(--color-text-primary);
|
||||
transition: all 0.15s ease;
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.global-book-item:hover {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.global-book-name {
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
border-radius: 2px;
|
||||
transition: all 0.15s ease;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.no-global-books {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ==================== 世界书管理区域 ==================== */
|
||||
.worldbook-management {
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
overflow: hidden;
|
||||
margin-top: 8px; /* 与全局世界书保持间距 */
|
||||
position: relative; /* 确保正常文档流 */
|
||||
z-index: 1; /* 降低层级,不遮挡其他元素 */
|
||||
}
|
||||
|
||||
.worldbook-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.worldbook-header .title-text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* 操作按钮组 */
|
||||
.worldbook-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 6px 12px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.15s ease;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* 世界书选择区域 */
|
||||
.worldbook-selector {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dropdown-btn {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
transition: all 0.15s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropdown-btn:hover {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.dropdown-btn:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px var(--color-accent-light);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
margin-top: 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.dropdown-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.dropdown-item.active {
|
||||
background: var(--color-accent-light);
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ==================== 条目列表区域 ==================== */
|
||||
.entries-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* 分页控制栏 */
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.page-size-select {
|
||||
padding: 3px 6px;
|
||||
font-size: 11px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.page-size-select:hover {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.page-size-select:focus {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px var(--color-accent-light);
|
||||
}
|
||||
|
||||
/* 新版紧凑条目样式 */
|
||||
.entry-item-compact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
min-height: 65px;
|
||||
}
|
||||
|
||||
.entry-item-compact:hover {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.entry-item-compact.active {
|
||||
background: var(--color-accent-light);
|
||||
border-color: var(--color-accent);
|
||||
border-left: 2px solid var(--color-accent);
|
||||
}
|
||||
|
||||
/* 第一行:名称 + Token + 开关 */
|
||||
.entry-row-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.entry-name-compact {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entry-tokens {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
padding: 2px 6px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 8px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 第二行:插入位置下拉框 */
|
||||
.entry-row-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.position-select {
|
||||
width: 100%;
|
||||
padding: 3px 6px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.position-select:hover {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.position-select:focus {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px var(--color-accent-light);
|
||||
}
|
||||
|
||||
/* 开关组件 */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--color-border);
|
||||
border-radius: 20px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider:before {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
/* 分页导航 */
|
||||
.pagination-nav {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
margin-top: 6px;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 5px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--color-accent);
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.page-btn:hover:not(:disabled) {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-indicator {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
padding: 3px 10px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* 加载和错误状态 */
|
||||
.loading, .error {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* 添加条目按钮 */
|
||||
.entries-container > .btn.btn-primary {
|
||||
margin-top: 6px;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
background: var(--color-accent);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.entries-container > .btn.btn-primary:hover {
|
||||
background: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
.entries-container > .btn.btn-primary:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* 删除按钮 */
|
||||
.btn.btn-danger {
|
||||
padding: 6px 12px;
|
||||
background: var(--color-danger);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn.btn-danger:hover {
|
||||
background: var(--color-danger-dark);
|
||||
}
|
||||
|
||||
.btn.btn-danger:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* ==================== 响应式设计 ==================== */
|
||||
|
||||
/* 小屏幕 (< 768px) */
|
||||
@media (max-width: 768px) {
|
||||
.worldbook-content {
|
||||
padding: 6px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.worldbook-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
min-width: calc(50% - 3px);
|
||||
}
|
||||
|
||||
.global-book-name-compact {
|
||||
max-width: 80px;
|
||||
}
|
||||
|
||||
.entry-name-compact {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 中等屏幕 (768px - 1024px) */
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.worldbook-content {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.global-book-name-compact {
|
||||
max-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 大屏幕 (> 1024px) */
|
||||
@media (min-width: 1025px) {
|
||||
.worldbook-content {
|
||||
padding: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.global-book-name-compact {
|
||||
max-width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 编辑面板样式 (保持不变) ==================== */
|
||||
.edit-panel-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 9998;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.edit-panel-overlay.open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.edit-panel {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0.95);
|
||||
width: 90vw;
|
||||
max-width: 1200px;
|
||||
height: 85vh;
|
||||
max-height: 900px;
|
||||
background: var(--color-bg-primary);
|
||||
z-index: 9999;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.edit-panel.open {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.edit-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.edit-panel-header h2 {
|
||||
font-size: 16px;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--color-danger-light);
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.edit-panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-group.compact {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px var(--color-accent-light);
|
||||
}
|
||||
|
||||
.content-textarea {
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
resize: vertical;
|
||||
line-height: 1.6;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.trigger-config-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.trigger-selector {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.trigger-config-panel {
|
||||
flex: 2;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.keyword-config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
1070
frontend/src/components/SideBarLeft/tabs/WorldBook/WorldBook.jsx
Normal file
1070
frontend/src/components/SideBarLeft/tabs/WorldBook/WorldBook.jsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
export { default } from './WorldBook';
|
||||
Reference in New Issue
Block a user