完成大量美化
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// frontend-react/src/App.jsx
|
||||
import React from 'react';
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import TopBar from './components/TopBar';
|
||||
import { ChatBox } from './components/Mid';
|
||||
import SideBarLeft from './components/SideBarLeft';
|
||||
@@ -7,22 +7,129 @@ import SideBarRight from './components/SideBarRight';
|
||||
import './index.css';
|
||||
|
||||
function App() {
|
||||
// 布局模式:'chat'(聊天模式) | 'edit'(编辑模式)
|
||||
const [layoutMode, setLayoutMode] = useState('chat');
|
||||
|
||||
// 左侧栏模式:'fixed'(固定)| 'smart'(智能)| 'expanded'(扩展)
|
||||
const [sidebarMode, setSidebarMode] = useState(() => {
|
||||
return localStorage.getItem('sidebarMode') || 'smart';
|
||||
});
|
||||
|
||||
// 智能模式的悬停状态
|
||||
const [isSidebarHovered, setIsSidebarHovered] = useState(false);
|
||||
|
||||
// 防抖定时器引用
|
||||
const hoverTimeoutRef = useRef(null);
|
||||
const leaveTimeoutRef = useRef(null);
|
||||
|
||||
// 配色主题
|
||||
const [colorTheme, setColorTheme] = useState(() => {
|
||||
return localStorage.getItem('colorTheme') || 'default';
|
||||
});
|
||||
|
||||
// 保存左侧栏模式到 LocalStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sidebarMode', sidebarMode);
|
||||
}, [sidebarMode]);
|
||||
|
||||
// 保存配色主题到 LocalStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('colorTheme', colorTheme);
|
||||
// 应用主题到 document
|
||||
document.documentElement.setAttribute('data-color-theme', colorTheme);
|
||||
}, [colorTheme]);
|
||||
|
||||
// 处理鼠标进入左侧栏 - 使用 useCallback 优化
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (sidebarMode === 'smart') {
|
||||
// 清除之前的离开定时器
|
||||
if (leaveTimeoutRef.current) {
|
||||
clearTimeout(leaveTimeoutRef.current);
|
||||
}
|
||||
|
||||
// 设置防抖延迟后展开
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
setIsSidebarHovered(true);
|
||||
setLayoutMode('edit');
|
||||
}, 400); // 400ms 防抖
|
||||
}
|
||||
}, [sidebarMode]);
|
||||
|
||||
// 处理鼠标离开左侧栏 - 使用 useCallback 优化
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (sidebarMode === 'smart') {
|
||||
// 清除之前的进入定时器
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
|
||||
// 设置延迟收起,给用户反应时间
|
||||
leaveTimeoutRef.current = setTimeout(() => {
|
||||
setIsSidebarHovered(false);
|
||||
setLayoutMode('chat');
|
||||
}, 250); // 250ms 延迟
|
||||
}
|
||||
}, [sidebarMode]);
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current);
|
||||
if (leaveTimeoutRef.current) clearTimeout(leaveTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 更新左侧栏模式(从设置面板调用)
|
||||
const updateSidebarMode = useCallback((mode) => {
|
||||
setSidebarMode(mode);
|
||||
// 切换模式时重置悬停状态
|
||||
setIsSidebarHovered(false);
|
||||
if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current);
|
||||
if (leaveTimeoutRef.current) clearTimeout(leaveTimeoutRef.current);
|
||||
|
||||
if (mode === 'expanded') {
|
||||
setLayoutMode('edit');
|
||||
} else {
|
||||
setLayoutMode('chat');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 更新配色主题(从设置面板调用)
|
||||
const updateColorTheme = useCallback((theme) => {
|
||||
setColorTheme(theme);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<TopBar />
|
||||
<div className={`app ${layoutMode}-mode`}>
|
||||
<TopBar
|
||||
sidebarMode={sidebarMode}
|
||||
colorTheme={colorTheme}
|
||||
onSidebarModeChange={updateSidebarMode}
|
||||
onColorThemeChange={updateColorTheme}
|
||||
/>
|
||||
|
||||
{/* 主内容容器 */}
|
||||
<div className="main-container">
|
||||
{/* 左侧栏 - 预设面板 */}
|
||||
{/* 左侧栏 - 智能模式下悬停展开 */}
|
||||
<div
|
||||
className={`sidebar-left-wrapper sidebar-mode-${sidebarMode} ${sidebarMode === 'smart' && isSidebarHovered ? 'sidebar-expanded' : ''}`}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<SideBarLeft />
|
||||
</div>
|
||||
|
||||
{/* 中间栏:聊天框 */}
|
||||
<div className="chat-area">
|
||||
<ChatBox />
|
||||
<div className="chat-area-wrapper">
|
||||
<div className="chat-area">
|
||||
<ChatBox />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧栏 */}
|
||||
<div className="sidebar-right-wrapper">
|
||||
<SideBarRight />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -36,10 +36,11 @@ const useChatBoxStore = create(
|
||||
|
||||
// 选项状态
|
||||
options: {
|
||||
dynamicTable: false, // 动态表格
|
||||
streamOutput: false, // 流式输出
|
||||
imageWorkflow: false, // 生图工作流
|
||||
htmlRender: false, // HTML渲染
|
||||
dynamicTable: false, // 动态表格
|
||||
streamOutput: false, // 流式输出
|
||||
imageWorkflow: false, // 生图工作流
|
||||
htmlRender: false, // HTML渲染
|
||||
markdownRender: true, // Markdown渲染(默认开启)
|
||||
},
|
||||
|
||||
// 设置消息列表
|
||||
@@ -79,7 +80,8 @@ const useChatBoxStore = create(
|
||||
dynamicTable: false,
|
||||
streamOutput: false,
|
||||
imageWorkflow: false,
|
||||
htmlRender: false
|
||||
htmlRender: false,
|
||||
markdownRender: true
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -125,9 +127,11 @@ const useChatBoxStore = create(
|
||||
wsConnection: null, // 重置连接
|
||||
messages: [...messages, {
|
||||
id: Date.now(),
|
||||
floor: messages.length + 1,
|
||||
floor: nextFloor,
|
||||
mes: content,
|
||||
is_user: true
|
||||
is_user: true,
|
||||
name: userName || 'User',
|
||||
sendDate: new Date().toISOString()
|
||||
}]
|
||||
});
|
||||
|
||||
@@ -160,9 +164,11 @@ const useChatBoxStore = create(
|
||||
set((state) => ({
|
||||
messages: [...state.messages, {
|
||||
id: newMessageId,
|
||||
floor: state.messages.length + 1,
|
||||
floor: assistantFloor,
|
||||
mes: '',
|
||||
is_user: false
|
||||
is_user: false,
|
||||
name: characterName || 'Assistant',
|
||||
sendDate: new Date().toISOString()
|
||||
}]
|
||||
}));
|
||||
|
||||
@@ -292,16 +298,41 @@ const useChatBoxStore = create(
|
||||
fetchChatHistory: async (roleName, chatName) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
// 确保chatName是字符串
|
||||
// 确俚chatName是字符串
|
||||
const actualChatName = typeof chatName === 'object' && chatName !== null ? chatName.chat_name : chatName;
|
||||
const response = await fetch(`/api/chat/${encodeURIComponent(roleName)}/${encodeURIComponent(actualChatName)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch chat history');
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
let messages = data.messages || [];
|
||||
|
||||
// 如果消息为空,尝试获取角色的 first_mes 并显示为第一条消息(仅前端显示)
|
||||
if (messages.length === 0) {
|
||||
try {
|
||||
const characterResponse = await fetch(`/api/characters/${encodeURIComponent(roleName)}`);
|
||||
if (characterResponse.ok) {
|
||||
const characterData = await characterResponse.json();
|
||||
if (characterData.first_mes && characterData.first_mes.trim()) {
|
||||
// 创建临时的开场白消息(不保存到后端)
|
||||
messages = [{
|
||||
floor: 1,
|
||||
mes: characterData.first_mes,
|
||||
is_user: false,
|
||||
name: characterData.name || roleName,
|
||||
sendDate: new Date().toISOString()
|
||||
}];
|
||||
console.log(`[ChatBoxStore] 显示角色 ${roleName} 的开场白(临时消息)`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[ChatBoxStore] 获取角色信息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
messages: data.messages || [],
|
||||
messages: messages,
|
||||
userName: data.metadata?.user_name || 'User',
|
||||
characterName: data.metadata?.character_name || roleName || 'Assistant',
|
||||
isLoading: false
|
||||
|
||||
@@ -77,68 +77,49 @@ const useWorldBookStore = create((set, get) => ({
|
||||
currentEntry: null
|
||||
}),
|
||||
|
||||
// 异步操作:切换世界书的全局状态
|
||||
// 异步操作:切换世界书的全局状态(纯前端操作,使用 LocalStorage)
|
||||
toggleGlobalWorldBook: async (name, isGlobal) => {
|
||||
set({ loading: true, error: null, success: false });
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('is_global', isGlobal);
|
||||
|
||||
const response = await fetch(`/api/worldbooks/${name}`, {
|
||||
method: 'PUT',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await handleResponse(response);
|
||||
|
||||
set(state => {
|
||||
const updatedWorldBooks = state.worldBooks.map(wb =>
|
||||
wb.name === data.name ? data : wb
|
||||
);
|
||||
|
||||
// 更新全局世界书列表
|
||||
let updatedGlobalBooks = [...state.globalWorldBooks];
|
||||
const globalIndex = updatedGlobalBooks.findIndex(wb => wb.name === data.name);
|
||||
|
||||
if (isGlobal) {
|
||||
// 如果是世界书被标记为全局
|
||||
if (globalIndex === -1) {
|
||||
// 如果不在全局列表中,添加它
|
||||
updatedGlobalBooks = [...updatedGlobalBooks, data];
|
||||
} else {
|
||||
// 如果已经在全局列表中,更新它
|
||||
updatedGlobalBooks[globalIndex] = data;
|
||||
}
|
||||
} else {
|
||||
// 如果世界书不再全局,从全局列表中移除
|
||||
if (globalIndex !== -1) {
|
||||
updatedGlobalBooks = updatedGlobalBooks.filter(wb => wb.name !== data.name);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存到 LocalStorage
|
||||
saveGlobalWorldBooks(updatedGlobalBooks);
|
||||
|
||||
set(state => {
|
||||
// 查找世界书
|
||||
const worldBook = state.worldBooks.find(wb => wb.name === name);
|
||||
if (!worldBook) {
|
||||
return {
|
||||
loading: false,
|
||||
worldBooks: updatedWorldBooks,
|
||||
globalWorldBooks: updatedGlobalBooks,
|
||||
currentWorldBook: state.currentWorldBook?.name === data.name
|
||||
? data
|
||||
: state.currentWorldBook,
|
||||
success: true,
|
||||
error: `Worldbook '${name}' not found`,
|
||||
success: false
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
set({
|
||||
// 更新全局世界书列表
|
||||
let updatedGlobalBooks = [...state.globalWorldBooks];
|
||||
const globalIndex = updatedGlobalBooks.findIndex(wb => wb.name === name);
|
||||
|
||||
if (isGlobal) {
|
||||
// 如果世界书被标记为全局
|
||||
if (globalIndex === -1) {
|
||||
// 如果不在全局列表中,添加它
|
||||
updatedGlobalBooks = [...updatedGlobalBooks, worldBook];
|
||||
} else {
|
||||
// 如果已经在全局列表中,更新它
|
||||
updatedGlobalBooks[globalIndex] = worldBook;
|
||||
}
|
||||
} else {
|
||||
// 如果世界书不再全局,从全局列表中移除
|
||||
if (globalIndex !== -1) {
|
||||
updatedGlobalBooks = updatedGlobalBooks.filter(wb => wb.name !== name);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存到 LocalStorage
|
||||
saveGlobalWorldBooks(updatedGlobalBooks);
|
||||
|
||||
return {
|
||||
loading: false,
|
||||
error: error.message,
|
||||
success: false
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
globalWorldBooks: updatedGlobalBooks,
|
||||
success: true,
|
||||
message: isGlobal ? `已将 "${name}" 设为全局世界书` : `已取消 "${name}" 的全局状态`
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// 异步操作:获取所有世界书
|
||||
|
||||
@@ -3,3 +3,4 @@ export { default as useSideBarLeftStore } from './SideBarLeftSlice';
|
||||
export { default as useApiConfigStore } from './ApiConfigSlice';
|
||||
export { default as usePresetStore } from './PresetSlice';
|
||||
export { default as useWorldBookStore } from './WorldBookSlice';
|
||||
export { default as useCharacterStore } from './CharacterSlice';
|
||||
|
||||
@@ -11,41 +11,66 @@
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-xl);
|
||||
padding: var(--spacing-lg);
|
||||
padding-bottom: 180px; /* 为固定的输入框留出空间 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
gap: var(--spacing-sm);
|
||||
background-color: var(--color-bg-subtle); /* 跟随主题的背景色 */
|
||||
min-height: 0; /* 确保 flex 子元素可以收缩 */
|
||||
}
|
||||
|
||||
/* 滚动条美化 */
|
||||
.chat-messages::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.chat-messages::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-messages::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-messages::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 80%;
|
||||
max-width: 100%; /* SillyTavern 风格:占满宽度 */
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
line-height: 1.5;
|
||||
border-radius: 0; /* 去掉圆角 */
|
||||
line-height: 1.8;
|
||||
position: relative;
|
||||
transition: background-color 0.2s ease;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
font-family: var(--font-body); /* 正文使用微软雅黑 */
|
||||
}
|
||||
|
||||
/* 用户消息 - 右侧对齐,用色彩区分 */
|
||||
.message.user {
|
||||
align-self: flex-end;
|
||||
background: var(--gradient-primary);
|
||||
color: white;
|
||||
border-bottom-right-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-md);
|
||||
align-self: stretch;
|
||||
background: linear-gradient(to right, var(--color-accent-ultra-light), var(--color-accent-light));
|
||||
color: var(--color-text-primary);
|
||||
border-left: 3px solid var(--color-accent);
|
||||
}
|
||||
|
||||
/* AI 消息 - 左侧对齐,用色彩区分 */
|
||||
.message.ai {
|
||||
align-self: flex-start;
|
||||
background-color: var(--color-bg-secondary);
|
||||
align-self: stretch;
|
||||
background-color: var(--color-bg-elevated); /* 使用主题的浅色背景形成对比 */
|
||||
color: var(--color-text-primary);
|
||||
border-bottom-left-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.message-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
gap: var(--spacing-xs);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
@@ -53,21 +78,30 @@
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.message-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.85rem;
|
||||
color: inherit;
|
||||
font-family: var(--font-ui); /* 角色名使用微软雅黑 UI */
|
||||
}
|
||||
|
||||
.message-id {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.message-toolbar {
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.message:hover .message-toolbar {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toolbar-buttons {
|
||||
@@ -79,16 +113,18 @@
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-size: 0.9rem;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.toolbar-button:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
color: var(--color-accent);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
@@ -96,72 +132,94 @@
|
||||
}
|
||||
|
||||
.bubble {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-lg);
|
||||
line-height: 1.5;
|
||||
padding: var(--spacing-xs) 0;
|
||||
border-radius: 0;
|
||||
line-height: 1.7;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.message.user .bubble {
|
||||
background: transparent;
|
||||
color: white;
|
||||
border-bottom-right-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.message.ai .bubble {
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
border-bottom-left-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* 纯文本模式 */
|
||||
.plain-text {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
line-height: 1.8;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.edit-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.edit-textarea {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
min-height: auto; /* 自适应内容高度 */
|
||||
max-height: none; /* 不限制最大高度 */
|
||||
padding: var(--spacing-xs) 0; /* 与 bubble 保持一致 */
|
||||
border: none; /* 去掉边框 */
|
||||
border-radius: 0;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
background-color: transparent; /* 透明背景 */
|
||||
color: var(--color-text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.edit-textarea:focus {
|
||||
background-color: rgba(102, 126, 234, 0.05); /* 聚焦时轻微背景色 */
|
||||
}
|
||||
|
||||
.edit-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.cancel-button, .save-button {
|
||||
padding: 5px 15px;
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.cancel-button:hover {
|
||||
background-color: #e0e0e0;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.save-button {
|
||||
background-color: #007bff;
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.save-button:hover {
|
||||
background-color: #0056b3;
|
||||
background-color: #5568d3;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.swipe-controls {
|
||||
@@ -196,110 +254,117 @@
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* ==================== Input Area - Fixed at Bottom ==================== */
|
||||
|
||||
.chat-input-wrapper {
|
||||
flex-shrink: 0;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: var(--color-bg-primary); /* 跟随主题 */
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
z-index: 10;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
background-color: var(--color-bg-secondary);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
align-items: center; /* 垂直居中对齐 */
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background-color: var(--color-bg-elevated); /* 极浅色,跟随主题 */
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md); /* 适中的圆角 */
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); /* 轻量级阴影 */
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.input-container:focus-within {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.options-wrapper {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.options-toggle {
|
||||
width: 24px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
color: var(--color-text-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.3rem;
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast);
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.options-toggle:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.options-toggle.active {
|
||||
background-color: rgba(102, 126, 234, 0.08);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.options-toggle svg {
|
||||
display: none; /* Hide SVG, use text instead */
|
||||
.options-toggle.active {
|
||||
background-color: rgba(102, 126, 234, 0.12);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.chat-options {
|
||||
position: absolute;
|
||||
bottom: calc(100% + var(--spacing-sm));
|
||||
left: 0;
|
||||
background-color: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(102, 126, 234, 0.2);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-sm);
|
||||
box-shadow: var(--shadow-xl);
|
||||
z-index: var(--z-dropdown);
|
||||
min-width: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
padding: var(--spacing-md);
|
||||
min-width: 200px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.option-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.option-checkbox input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
height: 0;
|
||||
width: 0;
|
||||
.option-checkbox:hover {
|
||||
background-color: rgba(102, 126, 234, 0.05);
|
||||
}
|
||||
|
||||
.option-checkbox input[type="checkbox"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
background-color: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(102, 126, 234, 0.3);
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.option-checkbox:hover .checkmark {
|
||||
border-color: var(--color-accent);
|
||||
background-color: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.option-checkbox input:checked ~ .checkmark {
|
||||
.option-checkbox input:checked + .checkmark {
|
||||
background-color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.checkmark:after {
|
||||
content: "";
|
||||
.option-checkbox input:checked + .checkmark::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
display: none;
|
||||
left: 5px;
|
||||
top: 2px;
|
||||
width: 4px;
|
||||
@@ -309,50 +374,70 @@
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.option-checkbox input:checked ~ .checkmark:after {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-primary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.option-divider {
|
||||
height: 1px;
|
||||
background-color: var(--color-border-light);
|
||||
margin: var(--spacing-xs) 0;
|
||||
background: linear-gradient(to right,
|
||||
transparent,
|
||||
rgba(102, 126, 234, 0.15),
|
||||
transparent);
|
||||
margin: var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
.switch-chat-btn {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.switch-chat-btn:hover {
|
||||
background: linear-gradient(135deg, #5568d3 0%, #6a3f8f 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.message-input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border: 1px solid transparent;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background-color: transparent;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
resize: none; /* 禁用手动调整大小 */
|
||||
overflow-y: hidden; /* 隐藏滚动条 */
|
||||
min-height: 28px; /* 最小高度(一行) */
|
||||
max-height: 300px; /* 最大高度 */
|
||||
transition: height 0.15s ease; /* 平滑过渡 */
|
||||
font-size: 0.95rem;
|
||||
resize: none;
|
||||
overflow-y: hidden;
|
||||
min-height: 36px;
|
||||
max-height: 300px;
|
||||
transition: all 0.15s ease;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.message-input:focus {
|
||||
outline: none;
|
||||
background-color: var(--color-bg-primary);
|
||||
border-color: var(--color-border);
|
||||
box-shadow: 0 0 0 2px var(--color-accent-ultra-light);
|
||||
background-color: rgba(102, 126, 234, 0.03);
|
||||
}
|
||||
|
||||
.message-input::placeholder {
|
||||
@@ -361,31 +446,55 @@
|
||||
}
|
||||
|
||||
.send-button {
|
||||
width: 24px;
|
||||
height: 36px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
color: var(--color-text-muted);
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast);
|
||||
font-size: 1.2rem;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.2);
|
||||
align-self: center; /* 确保按钮垂直居中 */
|
||||
}
|
||||
|
||||
.send-button:hover {
|
||||
color: var(--color-accent);
|
||||
.send-button:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #5568d3 0%, #6a3f8f 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.send-button:active {
|
||||
color: var(--color-accent);
|
||||
.send-button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.send-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.send-button.stopping {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.send-button svg {
|
||||
display: none; /* Hide SVG, use text instead */
|
||||
display: none;
|
||||
}
|
||||
|
||||
.loading, .error {
|
||||
@@ -397,3 +506,123 @@
|
||||
.error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* 聊天选择器弹窗 */
|
||||
.chat-selector-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.chat-selector-modal {
|
||||
background: var(--color-bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-selector-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.chat-selector-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color var(--transition-fast);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--color-error);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.chat-selector-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.chat-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.chat-option {
|
||||
padding: var(--spacing-md);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.chat-option:hover {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.chat-option-content {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-option-name {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.chat-option-preview {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.empty-chats {
|
||||
text-align: center;
|
||||
padding: var(--spacing-xl) var(--spacing-lg);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.empty-chats p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// frontend-react/src/components/ChatBox/ChatBox.jsx
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import useChatBoxStore from '../../../Store/Mid/ChatBoxSlice';
|
||||
import MarkdownRenderer from '../../shared/MarkdownRenderer';
|
||||
import './ChatBox.css';
|
||||
|
||||
const ChatBox = () => {
|
||||
@@ -10,6 +11,11 @@ const ChatBox = () => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
const optionsRef = useRef(null);
|
||||
|
||||
// 聊天选择器相关状态
|
||||
const [showChatSelector, setShowChatSelector] = useState(false);
|
||||
const [characterChats, setCharacterChats] = useState([]);
|
||||
const chatSelectorRef = useRef(null);
|
||||
|
||||
// 新增:管理每条消息的当前显示的swipe版本
|
||||
const [currentSwipeId, setCurrentSwipeId] = useState({});
|
||||
@@ -48,6 +54,24 @@ const ChatBox = () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [showOptions]);
|
||||
|
||||
// 点击外部关闭聊天选择器
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (chatSelectorRef.current && !chatSelectorRef.current.contains(event.target) &&
|
||||
!event.target.closest('.chat-selector-button')) {
|
||||
setShowChatSelector(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showChatSelector) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [showChatSelector]);
|
||||
|
||||
const handleInputHeight = (e) => {
|
||||
const textarea = e.target;
|
||||
@@ -128,6 +152,53 @@ const ChatBox = () => {
|
||||
const toggleOptionsPanel = () => {
|
||||
setShowOptions(!showOptions);
|
||||
};
|
||||
|
||||
// 打开聊天选择器
|
||||
const handleOpenChatSelector = async () => {
|
||||
const { currentRole } = useChatBoxStore.getState();
|
||||
|
||||
// 如果没有当前角色,提示用户
|
||||
if (!currentRole) {
|
||||
alert('请先在左侧选择一个角色');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取当前角色的聊天列表
|
||||
const response = await fetch(`/api/chat/${encodeURIComponent(currentRole)}`);
|
||||
if (!response.ok) throw new Error('获取聊天列表失败');
|
||||
|
||||
const chats = await response.json();
|
||||
setCharacterChats(chats);
|
||||
|
||||
// 显示聊天选择器弹窗,让用户手动选择
|
||||
setShowChatSelector(true);
|
||||
} catch (error) {
|
||||
console.error('获取聊天列表失败:', error);
|
||||
alert('获取聊天列表失败: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 选择聊天并加载
|
||||
const handleSelectChat = async (chatName) => {
|
||||
try {
|
||||
const { setChatBoxRoleAndChat, fetchChatHistory, currentRole } = useChatBoxStore.getState();
|
||||
|
||||
// 设置当前角色和聊天
|
||||
setChatBoxRoleAndChat(currentRole, chatName);
|
||||
|
||||
// 加载聊天历史
|
||||
await fetchChatHistory(currentRole, chatName);
|
||||
|
||||
// 关闭选择器
|
||||
setShowChatSelector(false);
|
||||
|
||||
console.log(`已切换到聊天: ${chatName}`);
|
||||
} catch (error) {
|
||||
console.error('切换聊天失败:', error);
|
||||
alert('切换聊天失败: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染单条消息
|
||||
const renderMessage = (message) => {
|
||||
@@ -211,8 +282,10 @@ const ChatBox = () => {
|
||||
<div className="bubble">
|
||||
{options.htmlRender && !isUser ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: currentMes }} />
|
||||
) : options.markdownRender ? (
|
||||
<MarkdownRenderer content={currentMes} />
|
||||
) : (
|
||||
currentMes
|
||||
<div className="plain-text">{currentMes}</div>
|
||||
)}
|
||||
{hasSwipes && isLatestMessage && !isUser && (
|
||||
<div className="swipe-controls">
|
||||
@@ -269,6 +342,15 @@ const ChatBox = () => {
|
||||
</button>
|
||||
{showOptions && (
|
||||
<div className="chat-options">
|
||||
<label className="option-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.markdownRender}
|
||||
onChange={() => toggleOption('markdownRender')}
|
||||
/>
|
||||
<span className="checkmark"></span>
|
||||
<span className="option-label">Markdown渲染</span>
|
||||
</label>
|
||||
<label className="option-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -307,6 +389,18 @@ const ChatBox = () => {
|
||||
<span className="checkmark"></span>
|
||||
<span className="option-label">🎨 生图工作流</span>
|
||||
</label>
|
||||
|
||||
{/* 分隔线 - 区分多选框和按钮区域 */}
|
||||
<div className="option-divider"></div>
|
||||
|
||||
{/* 切换聊天按钮 */}
|
||||
<button
|
||||
className="switch-chat-btn"
|
||||
onClick={handleOpenChatSelector}
|
||||
title="切换到当前角色的最后聊天记录"
|
||||
>
|
||||
💬 切换聊天
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -336,6 +430,56 @@ const ChatBox = () => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 聊天选择器弹窗 */}
|
||||
{showChatSelector && (
|
||||
<div className="chat-selector-overlay" onClick={() => setShowChatSelector(false)}>
|
||||
<div
|
||||
className="chat-selector-modal"
|
||||
ref={chatSelectorRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="chat-selector-header">
|
||||
<h3>{characterName || '未选择角色'} - 选择聊天</h3>
|
||||
<button
|
||||
className="close-btn"
|
||||
onClick={() => setShowChatSelector(false)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="chat-selector-body">
|
||||
{characterChats.length > 0 ? (
|
||||
<div className="chat-list">
|
||||
{characterChats.map((chat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="chat-option"
|
||||
>
|
||||
<div
|
||||
className="chat-option-content"
|
||||
onClick={() => handleSelectChat(chat.chat_name)}
|
||||
>
|
||||
<div className="chat-option-name">{chat.chat_name}</div>
|
||||
{chat.last_message && (
|
||||
<div className="chat-option-preview">
|
||||
{chat.last_message.substring(0, 100)}{chat.last_message.length > 100 ? '...' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-chats">
|
||||
<p>暂无聊天记录</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,484 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,11 +1,11 @@
|
||||
/* ==================== 角色卡页面 - 简洁现代化风格 ==================== */
|
||||
/* ==================== 角色卡页面 - 卡片布局风格 ==================== */
|
||||
|
||||
.character-card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
padding: 6px;
|
||||
gap: 6px;
|
||||
background: var(--color-bg-primary);
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -14,14 +14,14 @@
|
||||
.tab-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
padding: 4px 6px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
@@ -29,18 +29,18 @@
|
||||
/* 操作按钮 */
|
||||
.tab-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
gap: 4px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 6px 12px;
|
||||
padding: 4px 10px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
transition: all 0.15s ease;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
@@ -52,64 +52,518 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 角色列表 */
|
||||
/* Tag 筛选器 */
|
||||
.tag-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.tag-btn {
|
||||
padding: 2px 6px;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tag-btn:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.tag-btn.active {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 错误和加载提示 */
|
||||
.error-message,
|
||||
.loading-message,
|
||||
.empty-message {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* 角色卡片列表 - 网格布局 */
|
||||
.character-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.character-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
flex-direction: column;
|
||||
width: 140px; /* 固定宽度 */
|
||||
height: 200px; /* 固定高度 */
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
transition: all 0.2s ease;
|
||||
overflow: hidden;
|
||||
will-change: transform, border-color; /* 性能优化 */
|
||||
contain: layout style paint; /* 性能优化 */
|
||||
user-select: none; /* 防止文本选择影响点击 */
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
.character-item:hover {
|
||||
border-color: var(--color-accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.character-item.selected {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.character-avatar {
|
||||
font-size: 24px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
/* 角色头像 */
|
||||
.character-avatar-wrapper {
|
||||
width: 100%;
|
||||
aspect-ratio: 3/4;
|
||||
position: relative;
|
||||
background: var(--color-bg-tertiary);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg-primary);
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.character-avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain; /* 不拉伸,完整显示图片 */
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.character-avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
color: var(--color-text-muted);
|
||||
background: linear-gradient(135deg, var(--color-bg-secondary), var(--color-bg-tertiary));
|
||||
}
|
||||
|
||||
/* 角色信息 */
|
||||
.character-info {
|
||||
padding: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 50px;
|
||||
/* 移除 user-select,因为已经在父元素设置了 */
|
||||
}
|
||||
|
||||
.character-name {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 2px;
|
||||
margin-bottom: 3px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.character-desc {
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.character-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 1px 5px;
|
||||
background: var(--color-bg-primary);
|
||||
border-radius: 8px;
|
||||
font-size: 9px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 分页控件 */
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 6px;
|
||||
margin-top: 6px;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
padding: 4px 10px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
transition: all 0.15s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(:disabled) {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
min-width: 70px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-size-selector {
|
||||
padding: 3px 6px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 编辑面板 */
|
||||
.character-edit-panel {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.edit-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.edit-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.export-format-selector {
|
||||
padding: 4px 8px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.export-format-selector:hover {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.export-format-selector:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.edit-btn.save {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.edit-btn.save:hover {
|
||||
background: var(--color-accent-dark);
|
||||
border-color: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
.edit-btn.export {
|
||||
background: #10b981;
|
||||
border-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.edit-btn.export:hover {
|
||||
background: #059669;
|
||||
border-color: #059669;
|
||||
}
|
||||
|
||||
.edit-btn.delete {
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.edit-btn.delete:hover {
|
||||
background: #dc2626;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
.edit-btn.cancel {
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.edit-btn.cancel:hover {
|
||||
background: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
padding: 6px 8px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
/* 聊天选择器弹窗 */
|
||||
.chat-selector-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.chat-selector-modal {
|
||||
background: var(--color-bg-primary);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-selector-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.chat-selector-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.chat-selector-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.chat-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-option {
|
||||
padding: 12px 16px;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chat-option:hover {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.chat-option.active {
|
||||
border-color: var(--color-accent);
|
||||
background: rgba(74, 108, 247, 0.1);
|
||||
}
|
||||
|
||||
.chat-option-content {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-option-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.chat-option-preview {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.chat-option-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.chat-option-delete:hover {
|
||||
opacity: 1;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.empty-chats {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.empty-chats p {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.create-chat-btn {
|
||||
padding: 10px 20px;
|
||||
background: var(--color-accent);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.create-chat-btn:hover {
|
||||
background: #3a5ce5;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,343 @@
|
||||
// frontend-react/src/components/SideBarLeft/tabs/CharacterCard/CharacterCard.jsx
|
||||
import React, { useState } from 'react';
|
||||
1// frontend-react/src/components/SideBarLeft/tabs/CharacterCard/CharacterCard.jsx
|
||||
import React, { useState, useEffect, useRef, useCallback, memo } from 'react';
|
||||
import { useCharacterStore } from '../../../../Store/SideBarLeft';
|
||||
import useChatBoxStore from '../../../../Store/Mid/ChatBoxSlice';
|
||||
import './CharacterCard.css';
|
||||
|
||||
// 优化的角色卡片项组件 - 使用 memo 和 useCallback
|
||||
const CharacterItem = memo(({ character, isSelected, onSelect }) => {
|
||||
const handleImageError = useCallback((e) => {
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'flex';
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect(character);
|
||||
}, [onSelect, character]);
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect(character);
|
||||
}
|
||||
}, [onSelect, character]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`character-item ${isSelected ? 'selected' : ''}`}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* 角色头像 */}
|
||||
<div className="character-avatar-wrapper">
|
||||
{character.avatarPath ? (
|
||||
<img
|
||||
src={character.avatarPath}
|
||||
alt={character.name}
|
||||
className="character-avatar-img"
|
||||
onError={handleImageError}
|
||||
loading="lazy" // 懒加载图片
|
||||
/>
|
||||
) : null}
|
||||
<div className="character-avatar-placeholder" style={{ display: character.avatarPath ? 'none' : 'flex' }}>
|
||||
{character.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 角色信息 */}
|
||||
<div className="character-info">
|
||||
<div className="character-name">{character.name}</div>
|
||||
{character.description && (
|
||||
<div className="character-desc">{character.description.substring(0, 50)}...</div>
|
||||
)}
|
||||
{character.tags && character.tags.length > 0 && (
|
||||
<div className="character-tags">
|
||||
{character.tags.slice(0, 3).map(tag => (
|
||||
<span key={tag} className="tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// 自定义比较函数,只在必要时重新渲染
|
||||
return prevProps.character.id === nextProps.character.id &&
|
||||
prevProps.isSelected === nextProps.isSelected;
|
||||
});
|
||||
|
||||
CharacterItem.displayName = 'CharacterItem';
|
||||
|
||||
const CharacterCard = () => {
|
||||
const [characters, setCharacters] = useState([
|
||||
{ id: 1, name: '冒险者', description: '勇敢的探险家', avatar: '🗡️' },
|
||||
{ id: 2, name: '魔法师', description: '精通奥术', avatar: '🔮' },
|
||||
{ id: 3, name: '商人', description: '精明的交易者', avatar: '💰' },
|
||||
]);
|
||||
const {
|
||||
characters,
|
||||
selectedCharacter,
|
||||
isLoading,
|
||||
error,
|
||||
currentPage,
|
||||
pageSize,
|
||||
characterChats,
|
||||
fetchCharacters,
|
||||
selectCharacter,
|
||||
deleteCharacter,
|
||||
exportCharacterAsPng,
|
||||
setCurrentPage,
|
||||
setPageSize,
|
||||
getCurrentPageCharacters,
|
||||
getTotalPages,
|
||||
fetchCharacterChats
|
||||
} = useCharacterStore();
|
||||
|
||||
const [filterTag, setFilterTag] = useState('');
|
||||
const [isEditing, setIsEditing] = useState(false); // 是否处于编辑模式
|
||||
const [editForm, setEditForm] = useState(null); // 编辑表单数据
|
||||
const [exportFormat, setExportFormat] = useState('png'); // 导出格式: 'png' 或 'json'
|
||||
const fileInputRef = useRef(null);
|
||||
const clickTimeoutRef = useRef(null);
|
||||
|
||||
// 加载角色列表
|
||||
useEffect(() => {
|
||||
fetchCharacters();
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (clickTimeoutRef.current) {
|
||||
clearTimeout(clickTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 获取所有唯一的 tags
|
||||
const allTags = [...new Set(characters.flatMap(char => char.tags || []))];
|
||||
|
||||
// 过滤角色
|
||||
const filteredCharacters = filterTag
|
||||
? characters.filter(char => (char.tags || []).includes(filterTag))
|
||||
: characters;
|
||||
|
||||
// 获取当前页的角色数据
|
||||
const currentPageCharacters = getCurrentPageCharacters();
|
||||
|
||||
// 获取总页数
|
||||
const totalPages = getTotalPages();
|
||||
|
||||
// 处理导入文件
|
||||
const handleImport = async (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const { importCharacter } = useCharacterStore.getState();
|
||||
await importCharacter(file);
|
||||
|
||||
// 清空文件输入
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('导入失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 触发文件选择
|
||||
const triggerImport = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
// 新建角色
|
||||
const handleCreate = async () => {
|
||||
const name = prompt('请输入角色名称:');
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
const { createCharacter } = useCharacterStore.getState();
|
||||
await createCharacter({
|
||||
name,
|
||||
description: '',
|
||||
personality: '',
|
||||
scenario: '',
|
||||
first_mes: '',
|
||||
mes_example: '',
|
||||
categories: [],
|
||||
tags: []
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('创建失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除角色
|
||||
const handleDelete = async (name) => {
|
||||
if (!confirm(`确定要删除角色 "${name}" 吗?这将删除所有聊天记录!`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteCharacter(name);
|
||||
} catch (err) {
|
||||
console.error('删除失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出角色
|
||||
const handleExport = async (name, format = 'png') => {
|
||||
try {
|
||||
if (format === 'json') {
|
||||
// JSON导出:获取角色数据并下载
|
||||
const response = await fetch(`/api/characters/${encodeURIComponent(name)}`);
|
||||
if (!response.ok) throw new Error('获取角色数据失败');
|
||||
|
||||
const characterData = await response.json();
|
||||
|
||||
// 创建JSON文件并下载
|
||||
const dataStr = JSON.stringify(characterData, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = window.URL.createObjectURL(dataBlob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${name}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
console.log('JSON导出成功');
|
||||
} else {
|
||||
// PNG导出:调用后端API
|
||||
const response = await fetch(`/api/characters/${encodeURIComponent(name)}/export/png`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('导出PNG失败');
|
||||
|
||||
// 下载文件
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${name}.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
console.log('PNG导出成功');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('导出失败:', err);
|
||||
alert('导出失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 选择角色并切换到该角色的最后聊天记录
|
||||
const handleSelectCharacter = useCallback(async (character) => {
|
||||
console.log('[CharacterCard] 点击角色:', character.name);
|
||||
|
||||
// 清除之前的定时器,防止重复点击
|
||||
if (clickTimeoutRef.current) {
|
||||
clearTimeout(clickTimeoutRef.current);
|
||||
}
|
||||
|
||||
// 设置防抖,避免快速连续点击
|
||||
clickTimeoutRef.current = setTimeout(async () => {
|
||||
// 使用 requestAnimationFrame 优化UI响应
|
||||
requestAnimationFrame(async () => {
|
||||
selectCharacter(character);
|
||||
|
||||
// 进入编辑模式
|
||||
setIsEditing(true);
|
||||
setEditForm({
|
||||
name: character.name,
|
||||
description: character.description || '',
|
||||
personality: character.personality || '',
|
||||
scenario: character.scenario || '',
|
||||
first_mes: character.first_mes || '',
|
||||
mes_example: character.mes_example || '',
|
||||
categories: character.categories || [],
|
||||
tags: character.tags || []
|
||||
});
|
||||
|
||||
// 获取该角色的聊天列表,并自动切换到最后一个聊天
|
||||
try {
|
||||
const response = await fetch(`/api/chat/${encodeURIComponent(character.name)}`);
|
||||
if (response.ok) {
|
||||
const chats = await response.json();
|
||||
if (chats.length > 0) {
|
||||
// 有聊天记录,切换到最后一个
|
||||
const lastChat = chats[chats.length - 1].chat_name;
|
||||
useChatBoxStore.getState().setChatBoxRoleAndChat(character.name, lastChat);
|
||||
|
||||
console.log(`[CharacterCard] 已切换到角色 ${character.name} 的聊天: ${lastChat}`);
|
||||
} else {
|
||||
// 没有聊天记录,自动创建一个默认聊天
|
||||
console.log(`[CharacterCard] 角色 ${character.name} 没有聊天,创建默认聊天...`);
|
||||
|
||||
const defaultChatName = '默认聊天';
|
||||
|
||||
try {
|
||||
const createResponse = await fetch(`/api/chat/${encodeURIComponent(character.name)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_name: defaultChatName,
|
||||
metadata: {
|
||||
user_name: 'User',
|
||||
character_name: character.name
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (createResponse.ok || createResponse.status === 400) {
|
||||
// 400 表示聊天已存在,也可以直接使用
|
||||
useChatBoxStore.getState().setChatBoxRoleAndChat(character.name, defaultChatName);
|
||||
console.log(`[CharacterCard] 已为角色 ${character.name} 设置聊天: ${defaultChatName}`);
|
||||
} else {
|
||||
console.error('[CharacterCard] 创建默认聊天失败:', await createResponse.text());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CharacterCard] 创建聊天异常:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CharacterCard] 获取聊天列表失败:', error);
|
||||
}
|
||||
|
||||
console.log('[CharacterCard] 已进入编辑模式');
|
||||
});
|
||||
}, 50); // 50ms 防抖延迟
|
||||
}, [selectCharacter]);
|
||||
|
||||
// 退出编辑模式
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditing(false);
|
||||
setEditForm(null);
|
||||
};
|
||||
|
||||
// 保存编辑
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editForm || !selectedCharacter) return;
|
||||
|
||||
try {
|
||||
const { updateCharacter } = useCharacterStore.getState();
|
||||
await updateCharacter(selectedCharacter.name, editForm);
|
||||
setIsEditing(false);
|
||||
setEditForm(null);
|
||||
} catch (err) {
|
||||
console.error('保存失败:', err);
|
||||
alert('保存失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理表单字段变化
|
||||
const handleFormChange = (field, value) => {
|
||||
setEditForm(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="character-card-content">
|
||||
@@ -18,22 +348,204 @@ const CharacterCard = () => {
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="tab-actions">
|
||||
<button className="action-btn">+ 新建</button>
|
||||
<button className="action-btn">📥 导入</button>
|
||||
<button className="action-btn" onClick={handleCreate}>+ 新建</button>
|
||||
<button className="action-btn" onClick={triggerImport}>📥 导入</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".png,.json"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleImport}
|
||||
/>
|
||||
</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>
|
||||
{/* Tag 筛选器 */}
|
||||
{allTags.length > 0 && (
|
||||
<div className="tag-filter">
|
||||
<button
|
||||
className={`tag-btn ${!filterTag ? 'active' : ''}`}
|
||||
onClick={() => setFilterTag('')}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{allTags.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
className={`tag-btn ${filterTag === tag ? 'active' : ''}`}
|
||||
onClick={() => setFilterTag(tag)}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{isLoading && (
|
||||
<div className="loading-message">
|
||||
加载中...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑模式 */}
|
||||
{isEditing && selectedCharacter && editForm && (
|
||||
<div className="character-edit-panel">
|
||||
<div className="edit-header">
|
||||
<span className="edit-title">编辑角色: {selectedCharacter.name}</span>
|
||||
<div className="edit-actions">
|
||||
<select
|
||||
className="export-format-selector"
|
||||
value={exportFormat}
|
||||
onChange={(e) => setExportFormat(e.target.value)}
|
||||
>
|
||||
<option value="png">🖼️ 图片</option>
|
||||
<option value="json">📄 JSON</option>
|
||||
</select>
|
||||
<button className="edit-btn export" onClick={() => handleExport(selectedCharacter.name, exportFormat)}>📤 导出</button>
|
||||
<button className="edit-btn delete" onClick={() => handleDelete(selectedCharacter.name)}>🗑️ 删除</button>
|
||||
<button className="edit-btn save" onClick={handleSaveEdit}>💾 保存</button>
|
||||
<button className="edit-btn cancel" onClick={handleCancelEdit}>❌ 取消</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="edit-form">
|
||||
<div className="form-group">
|
||||
<label>角色名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.name}
|
||||
onChange={(e) => handleFormChange('name', e.target.value)}
|
||||
placeholder="角色名称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>描述</label>
|
||||
<textarea
|
||||
value={editForm.description}
|
||||
onChange={(e) => handleFormChange('description', e.target.value)}
|
||||
placeholder="角色描述"
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>性格</label>
|
||||
<textarea
|
||||
value={editForm.personality}
|
||||
onChange={(e) => handleFormChange('personality', e.target.value)}
|
||||
placeholder="角色性格"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>场景</label>
|
||||
<textarea
|
||||
value={editForm.scenario}
|
||||
onChange={(e) => handleFormChange('scenario', e.target.value)}
|
||||
placeholder="场景设定"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>开场白</label>
|
||||
<textarea
|
||||
value={editForm.first_mes}
|
||||
onChange={(e) => handleFormChange('first_mes', e.target.value)}
|
||||
placeholder="第一条消息"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>对话示例</label>
|
||||
<textarea
|
||||
value={editForm.mes_example}
|
||||
onChange={(e) => handleFormChange('mes_example', e.target.value)}
|
||||
placeholder="对话示例"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>标签 (用逗号分隔)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.tags.join(', ')}
|
||||
onChange={(e) => handleFormChange('tags', e.target.value.split(',').map(t => t.trim()).filter(t => t))}
|
||||
placeholder="标签1, 标签2, 标签3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 角色卡片列表 */}
|
||||
{!isLoading && !isEditing && (
|
||||
<>
|
||||
<div className="character-list">
|
||||
{currentPageCharacters.length === 0 ? (
|
||||
<div className="empty-message">
|
||||
{filterTag ? '没有符合筛选的角色' : '暂无角色,请导入或创建'}
|
||||
</div>
|
||||
) : (
|
||||
currentPageCharacters.map(char => (
|
||||
<CharacterItem
|
||||
key={char.id}
|
||||
character={char}
|
||||
isSelected={selectedCharacter?.id === char.id}
|
||||
onSelect={handleSelectCharacter}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分页控件 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="pagination-controls">
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
|
||||
<span className="pagination-info">
|
||||
第 {currentPage} / {totalPages} 页
|
||||
</span>
|
||||
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
|
||||
<select
|
||||
className="page-size-selector"
|
||||
value={pageSize}
|
||||
onChange={(e) => setPageSize(Number(e.target.value))}
|
||||
>
|
||||
<option value={8}>8条/页</option>
|
||||
<option value={12}>12条/页</option>
|
||||
<option value={20}>20条/页</option>
|
||||
<option value={50}>50条/页</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
background: #f8f9fa;
|
||||
background: var(--color-bg-subtle);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
font-weight: 500;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translate(-50%, -100%);
|
||||
margin-top: -6px;
|
||||
}
|
||||
@@ -31,9 +31,9 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background: white;
|
||||
background: var(--color-bg-elevated);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.preset-select-container {
|
||||
@@ -46,31 +46,31 @@
|
||||
.preset-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
color: var(--color-text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preset-select {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #495057;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.preset-select:hover {
|
||||
border-color: #ced4da;
|
||||
background: white;
|
||||
border-color: var(--color-border-focus);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.preset-select:focus {
|
||||
outline: none;
|
||||
border-color: #4a6cf7;
|
||||
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px var(--color-accent-light);
|
||||
}
|
||||
|
||||
.preset-select:disabled {
|
||||
@@ -91,8 +91,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
border: 1px solid #e9ecef;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
@@ -100,10 +100,10 @@
|
||||
}
|
||||
|
||||
.preset-action-btn:hover {
|
||||
background: #f8f9fa;
|
||||
border-color: #ced4da;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-color: var(--color-border-focus);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.preset-action-btn:active {
|
||||
@@ -118,10 +118,10 @@
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: white;
|
||||
background: var(--color-bg-elevated);
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 1000;
|
||||
min-width: 280px;
|
||||
}
|
||||
@@ -131,8 +131,8 @@
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 12px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s ease;
|
||||
@@ -149,8 +149,8 @@
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 12px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
@@ -181,21 +181,21 @@
|
||||
}
|
||||
|
||||
.dialog-buttons button:first-child {
|
||||
background: #4a6cf7;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dialog-buttons button:first-child:hover {
|
||||
background: #3a5ce5;
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.dialog-buttons button:last-child {
|
||||
background: #f1f3f5;
|
||||
color: #495057;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.dialog-buttons button:last-child:hover {
|
||||
background: #e9ecef;
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
/* 组件编辑对话框 */
|
||||
@@ -204,9 +204,9 @@
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: white;
|
||||
background: var(--color-bg-elevated);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 1000;
|
||||
min-width: 450px;
|
||||
max-width: 80vw;
|
||||
@@ -220,7 +220,7 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.dialog-header h3 {
|
||||
@@ -258,8 +258,8 @@
|
||||
.component-textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
@@ -284,7 +284,7 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.token-count {
|
||||
@@ -295,9 +295,9 @@
|
||||
|
||||
/* 参数设置区域 */
|
||||
.preset-parameters-container {
|
||||
background: white;
|
||||
background: var(--color-bg-elevated);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: var(--shadow-xs);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: #f8f9fa;
|
||||
background: var(--color-bg-tertiary);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
@@ -398,8 +398,8 @@
|
||||
.parameter-number {
|
||||
width: 60px;
|
||||
padding: 4px 6px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
@@ -415,8 +415,8 @@
|
||||
.parameter-input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s ease;
|
||||
@@ -434,7 +434,7 @@
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
@@ -465,7 +465,7 @@
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: white;
|
||||
background: var(--color-bg-elevated);
|
||||
border-radius: 50%;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
@@ -486,9 +486,9 @@
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: white;
|
||||
background: var(--color-bg-elevated);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: var(--shadow-xs);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -497,8 +497,8 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: #f8f9fa;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.components-header h3 {
|
||||
@@ -550,17 +550,17 @@
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
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);
|
||||
background: var(--color-bg-elevated);
|
||||
border-color: var(--color-border-focus);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.prompt-component-item.disabled {
|
||||
@@ -609,8 +609,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
border: 1px solid #e9ecef;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
@@ -618,8 +618,8 @@
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
border-color: #ced4da;
|
||||
background: #f8f9fa;
|
||||
border-color: var(--color-border-focus);
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.toggle-btn.enabled {
|
||||
@@ -656,8 +656,8 @@
|
||||
|
||||
.edit-btn {
|
||||
padding: 3px 6px;
|
||||
background: white;
|
||||
border: 1px solid #e9ecef;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
@@ -667,14 +667,14 @@
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: #f8f9fa;
|
||||
border-color: #ced4da;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-color: var(--color-border-focus);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
padding: 3px 6px;
|
||||
background: white;
|
||||
border: 1px solid #e9ecef;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -144,11 +144,15 @@
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
/* Optimize backdrop filter for better performance */
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: var(--z-modal-backdrop);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: var(--spacing-xl);
|
||||
/* Optimize animation for better performance */
|
||||
will-change: opacity;
|
||||
animation: fadeIn var(--transition-normal);
|
||||
}
|
||||
|
||||
@@ -172,6 +176,8 @@
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Optimize animation for better performance */
|
||||
will-change: transform, opacity;
|
||||
animation: slideDown var(--transition-smooth);
|
||||
}
|
||||
|
||||
@@ -220,20 +226,20 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 14px;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);
|
||||
border: 1px solid rgba(102, 126, 234, 0.2);
|
||||
background: var(--gradient-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 16px;
|
||||
font-size: 13px;
|
||||
color: #495057;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.global-book-item-topbar:hover {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%);
|
||||
border-color: rgba(102, 126, 234, 0.3);
|
||||
background: linear-gradient(135deg, var(--color-accent-light) 0%, var(--color-accent-light) 100%);
|
||||
border-color: var(--color-accent);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 6px rgba(102, 126, 234, 0.15);
|
||||
box-shadow: 0 2px 6px var(--color-accent-light);
|
||||
}
|
||||
|
||||
.global-book-name-topbar {
|
||||
@@ -250,6 +256,73 @@
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* ==================== 设置面板样式 ==================== */
|
||||
|
||||
.setting-section {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.setting-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.setting-section h4 {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.setting-options {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.setting-option {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.setting-option input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.setting-option:hover {
|
||||
border-color: var(--color-accent);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.setting-option.active {
|
||||
border-color: var(--color-accent);
|
||||
background-color: var(--color-accent-light);
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.setting-description {
|
||||
margin: var(--spacing-xs) 0 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
.main-container {
|
||||
margin-top: 50px;
|
||||
|
||||
@@ -5,7 +5,7 @@ import ThemeToggle from './items/ThemeToggle';
|
||||
import useWorldBookStore from '../../Store/SideBarLeft/WorldBookSlice';
|
||||
import useApiConfigStore from '../../Store/SideBarLeft/ApiConfigSlice';
|
||||
|
||||
const Toolbar = () => {
|
||||
const Toolbar = ({ sidebarMode, colorTheme, onSidebarModeChange, onColorThemeChange }) => {
|
||||
const [activePanel, setActivePanel] = useState(null);
|
||||
const panelRef = useRef(null);
|
||||
const [currentUserRole, setCurrentUserRole] = useState({ name: '', description: '' });
|
||||
@@ -53,19 +53,19 @@ const Toolbar = () => {
|
||||
loadApiModels();
|
||||
}, [activeMap, fetchProfile]);
|
||||
|
||||
// 点击外部关闭面板
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (panelRef.current && !panelRef.current.contains(event.target)) {
|
||||
setActivePanel(null);
|
||||
}
|
||||
};
|
||||
// 点击外部关闭面板 - 使用 useCallback 优化
|
||||
const handleClickOutside = React.useCallback((event) => {
|
||||
if (panelRef.current && !panelRef.current.contains(event.target)) {
|
||||
setActivePanel(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
}, [handleClickOutside]);
|
||||
|
||||
// 加载当前用户角色
|
||||
useEffect(() => {
|
||||
@@ -170,7 +170,7 @@ const Toolbar = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 全局世界书面板 */}
|
||||
{/* 全局世界书面板 - 使用条件渲染优化 */}
|
||||
{activePanel === 'worldBook' && (
|
||||
<div className="panel-overlay" ref={panelRef}>
|
||||
<div className="panel-content">
|
||||
@@ -197,7 +197,7 @@ const Toolbar = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 设置面板 */}
|
||||
{/* 设置面板 - 使用条件渲染优化 */}
|
||||
{activePanel === 'settings' && (
|
||||
<div className="panel-overlay" ref={panelRef}>
|
||||
<div className="panel-content">
|
||||
@@ -208,13 +208,53 @@ const Toolbar = () => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<p>系统设置内容...</p>
|
||||
{/* 左侧栏模式 */}
|
||||
<div className="setting-section">
|
||||
<h4>左侧栏模式</h4>
|
||||
<div className="setting-options">
|
||||
<label className={`setting-option ${sidebarMode === 'fixed' ? 'active' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="sidebarMode"
|
||||
value="fixed"
|
||||
checked={sidebarMode === 'fixed'}
|
||||
onChange={(e) => onSidebarModeChange(e.target.value)}
|
||||
/>
|
||||
<span>固定</span>
|
||||
</label>
|
||||
<label className={`setting-option ${sidebarMode === 'smart' ? 'active' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="sidebarMode"
|
||||
value="smart"
|
||||
checked={sidebarMode === 'smart'}
|
||||
onChange={(e) => onSidebarModeChange(e.target.value)}
|
||||
/>
|
||||
<span>智能</span>
|
||||
</label>
|
||||
<label className={`setting-option ${sidebarMode === 'expanded' ? 'active' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="sidebarMode"
|
||||
value="expanded"
|
||||
checked={sidebarMode === 'expanded'}
|
||||
onChange={(e) => onSidebarModeChange(e.target.value)}
|
||||
/>
|
||||
<span>扩展</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="setting-description">
|
||||
{sidebarMode === 'fixed' && '左侧栏保持默认宽度,不响应交互'}
|
||||
{sidebarMode === 'smart' && '鼠标悬停时自动展开,移开后收起'}
|
||||
{sidebarMode === 'expanded' && '左侧栏保持最大宽度'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 拓展面板 */}
|
||||
{/* 拓展面板 - 使用条件渲染优化 */}
|
||||
{activePanel === 'extensions' && (
|
||||
<div className="panel-overlay" ref={panelRef}>
|
||||
<div className="panel-content">
|
||||
|
||||
@@ -2,3 +2,115 @@
|
||||
.theme-toggle {
|
||||
/* All styles inherited from .action-btn */
|
||||
}
|
||||
|
||||
.theme-toggle-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 主题选择面板 */
|
||||
.theme-selector-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
background-color: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
min-width: 280px;
|
||||
z-index: var(--z-popover);
|
||||
animation: fadeInScale var(--transition-fast);
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-selector-header {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.theme-selector-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.theme-list {
|
||||
padding: var(--spacing-xs);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.theme-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.theme-item:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.theme-item.active {
|
||||
background-color: var(--color-accent-light);
|
||||
border: 1px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.theme-item-icon {
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.theme-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.theme-item-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 2px;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.theme-item-desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.theme-color-preview {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.theme-color-bg,
|
||||
.theme-color-accent {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,137 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import './ThemeToggle.css';
|
||||
|
||||
const themes = [
|
||||
{
|
||||
id: 'paper',
|
||||
name: '仿纸暖黄',
|
||||
description: '经典护眼,像 Kindle',
|
||||
icon: '📖',
|
||||
colors: {
|
||||
bg: '#F5ECD7',
|
||||
text: '#3E3A35',
|
||||
accent: '#8B9A6B'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'modern',
|
||||
name: '现代清爽',
|
||||
description: '干净明快,适合网文',
|
||||
icon: '✨',
|
||||
colors: {
|
||||
bg: '#F8F8F8',
|
||||
text: '#2C2C2C',
|
||||
accent: '#4A9FB0'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'dark',
|
||||
name: '夜间暗色',
|
||||
description: '黑底灰字,夜间必备',
|
||||
icon: '🌙',
|
||||
colors: {
|
||||
bg: '#1E1E1E',
|
||||
text: '#BDBBB6',
|
||||
accent: '#D4A76A'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'green',
|
||||
name: '豆沙绿',
|
||||
description: '自然疗愈,绿色经典',
|
||||
icon: '🍃',
|
||||
colors: {
|
||||
bg: '#DCE5D9',
|
||||
text: '#2B3330',
|
||||
accent: '#7D9B7A'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const ThemeToggle = () => {
|
||||
const [theme, setTheme] = useState(() => {
|
||||
// 从 localStorage 读取主题,默认为 dark
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
return savedTheme || 'dark';
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentTheme, setCurrentTheme] = useState(() => {
|
||||
return localStorage.getItem('colorTheme') || 'dark';
|
||||
});
|
||||
const panelRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 应用主题到 document
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.documentElement.setAttribute('data-theme', currentTheme);
|
||||
document.documentElement.setAttribute('data-color-theme', currentTheme);
|
||||
// 保存到 localStorage
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
localStorage.setItem('theme', currentTheme);
|
||||
localStorage.setItem('colorTheme', currentTheme);
|
||||
}, [currentTheme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
|
||||
// 点击外部关闭面板
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (panelRef.current && !panelRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handleThemeSelect = (themeId) => {
|
||||
setCurrentTheme(themeId);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const currentThemeData = themes.find(t => t.id === currentTheme) || themes[2];
|
||||
|
||||
return (
|
||||
<button
|
||||
className="action-btn theme-toggle"
|
||||
onClick={toggleTheme}
|
||||
title={theme === 'light' ? '切换到夜间模式' : '切换到白天模式'}
|
||||
>
|
||||
{theme === 'light' ? '☾' : '☀'}
|
||||
</button>
|
||||
<div className="theme-toggle-wrapper" ref={panelRef}>
|
||||
<button
|
||||
className="action-btn theme-toggle"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
title="切换主题"
|
||||
>
|
||||
{currentThemeData.icon}
|
||||
</button>
|
||||
|
||||
{/* 主题选择面板 */}
|
||||
{isOpen && (
|
||||
<div className="theme-selector-panel">
|
||||
<div className="theme-selector-header">
|
||||
<span className="theme-selector-title">选择主题</span>
|
||||
</div>
|
||||
<div className="theme-list">
|
||||
{themes.map(theme => (
|
||||
<button
|
||||
key={theme.id}
|
||||
className={`theme-item ${currentTheme === theme.id ? 'active' : ''}`}
|
||||
onClick={() => handleThemeSelect(theme.id)}
|
||||
>
|
||||
<div className="theme-item-icon">{theme.icon}</div>
|
||||
<div className="theme-item-info">
|
||||
<div className="theme-item-name">{theme.name}</div>
|
||||
<div className="theme-item-desc">{theme.description}</div>
|
||||
</div>
|
||||
<div className="theme-color-preview">
|
||||
<div
|
||||
className="theme-color-bg"
|
||||
style={{ backgroundColor: theme.colors.bg }}
|
||||
/>
|
||||
<div
|
||||
className="theme-color-accent"
|
||||
style={{ backgroundColor: theme.colors.accent }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
height: 100vh;
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-family: var(--font-body); /* 全局使用微软雅黑 */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -25,18 +25,69 @@
|
||||
margin-top: 0; /* Ensure panels start from top */
|
||||
}
|
||||
|
||||
/* Smooth transitions for theme changes */
|
||||
.app,
|
||||
.app * {
|
||||
/* Smooth transitions for theme changes - only apply to specific properties */
|
||||
.app {
|
||||
transition: background-color var(--transition-normal),
|
||||
color var(--transition-normal),
|
||||
border-color var(--transition-normal),
|
||||
box-shadow var(--transition-normal);
|
||||
color var(--transition-normal);
|
||||
}
|
||||
|
||||
.app * {
|
||||
/* Remove global transition to improve performance */
|
||||
}
|
||||
|
||||
/* ==================== Panel Layout - Proportional Ratio ==================== */
|
||||
|
||||
/* CSS Variables for layout ratios */
|
||||
:root {
|
||||
/* Fixed mode: 1 : 3.5 : 1.25 (default) */
|
||||
--left-fixed: 1fr;
|
||||
--middle-fixed: 3.5fr;
|
||||
--right-fixed: 1.25fr;
|
||||
|
||||
/* Expanded mode: 1.8 : 2 : 1 */
|
||||
--left-expanded: 1.8fr;
|
||||
--middle-expanded: 2fr;
|
||||
--right-expanded: 1fr;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
gap: 0; /* Panels have their own borders */
|
||||
padding: 0;
|
||||
margin-top: 0; /* Ensure panels start from top */
|
||||
transition: grid-template-columns 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Wrapper containers for hover detection */
|
||||
.sidebar-left-wrapper,
|
||||
.chat-area-wrapper,
|
||||
.sidebar-right-wrapper {
|
||||
min-width: 0;
|
||||
min-height: 0; /* 允许 flex/grid 收缩 */
|
||||
overflow: hidden;
|
||||
display: flex; /* 确保子元素可以占满高度 */
|
||||
flex-direction: column;
|
||||
transition: flex-basis 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Fixed mode (default): 1 : 3.5 : 1.25 */
|
||||
.app.chat-mode .main-container,
|
||||
.sidebar-mode-fixed ~ .chat-area-wrapper {
|
||||
grid-template-columns: var(--left-fixed) var(--middle-fixed) var(--right-fixed);
|
||||
}
|
||||
|
||||
/* Expanded mode: 1.8 : 2 : 1 */
|
||||
.app.edit-mode .main-container,
|
||||
.sidebar-mode-expanded,
|
||||
.sidebar-mode-smart.sidebar-expanded {
|
||||
grid-template-columns: var(--left-expanded) var(--middle-expanded) var(--right-expanded) !important;
|
||||
}
|
||||
|
||||
/* ==================== Panel Layout - 1:2:1 Ratio ==================== */
|
||||
.sidebar-left {
|
||||
flex: 0 0 25%; /* 左侧 25% */
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
background-color: var(--color-bg-secondary);
|
||||
@@ -48,9 +99,15 @@
|
||||
transition: box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
/* Highlight left sidebar in edit mode */
|
||||
.app.edit-mode .sidebar-left {
|
||||
box-shadow: 2px 0 8px rgba(102, 126, 234, 0.15);
|
||||
}
|
||||
|
||||
.chat-area {
|
||||
flex: 0 0 50%; /* 中间 50% */
|
||||
flex: 1; /* 占据 wrapper 的全部高度 */
|
||||
min-width: 0;
|
||||
min-height: 0; /* 允许 flex 收缩 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--color-bg-primary);
|
||||
@@ -80,7 +137,6 @@
|
||||
}
|
||||
|
||||
.sidebar-right {
|
||||
flex: 0 0 25%; /* 右侧 25% */
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
background-color: var(--color-bg-secondary);
|
||||
@@ -92,6 +148,11 @@
|
||||
transition: box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
/* Slightly fade right sidebar in edit mode */
|
||||
.app.edit-mode .sidebar-right {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for webkit browsers */
|
||||
.sidebar-left::-webkit-scrollbar,
|
||||
.sidebar-right::-webkit-scrollbar {
|
||||
@@ -113,3 +174,59 @@
|
||||
.sidebar-right::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-scrollbar-hover);
|
||||
}
|
||||
|
||||
/* ==================== Responsive Breakpoints ==================== */
|
||||
|
||||
/* Large screens (>1920px) - Add minimum width constraints */
|
||||
@media (min-width: 1920px) {
|
||||
.app.chat-mode .main-container,
|
||||
.sidebar-mode-fixed ~ .chat-area-wrapper {
|
||||
grid-template-columns: minmax(280px, var(--left-fixed)) var(--middle-fixed) minmax(340px, var(--right-fixed));
|
||||
}
|
||||
|
||||
.app.edit-mode .main-container,
|
||||
.sidebar-mode-expanded,
|
||||
.sidebar-mode-smart.sidebar-expanded {
|
||||
grid-template-columns: minmax(350px, var(--left-expanded)) var(--middle-expanded) minmax(250px, var(--right-expanded)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Medium screens (1024px - 1439px) - Compress ratios */
|
||||
@media (min-width: 1024px) and (max-width: 1439px) {
|
||||
:root {
|
||||
--left-chat: 0.9fr;
|
||||
--middle-chat: 3.8fr;
|
||||
--right-chat: 1.1fr;
|
||||
|
||||
--left-edit: 1.3fr;
|
||||
--middle-edit: 2.7fr;
|
||||
--right-edit: 0.9fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small screens (<1024px) - Hide right sidebar */
|
||||
@media (max-width: 1023px) {
|
||||
.app.chat-mode .main-container,
|
||||
.app.edit-mode .main-container,
|
||||
.sidebar-mode-fixed ~ .chat-area-wrapper,
|
||||
.sidebar-mode-expanded,
|
||||
.sidebar-mode-smart.sidebar-expanded {
|
||||
grid-template-columns: 1fr 3fr;
|
||||
}
|
||||
|
||||
.sidebar-right {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile/Tablet (<768px) - Single column layout */
|
||||
@media (max-width: 767px) {
|
||||
.main-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar-left,
|
||||
.sidebar-right {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,11 @@
|
||||
--z-modal: 1050;
|
||||
--z-popover: 1060;
|
||||
--z-tooltip: 1070;
|
||||
|
||||
/* Font Families */
|
||||
--font-ui: "Microsoft YaHei UI", "微软雅黑 UI", "Segoe UI", sans-serif; /* UI 元素字体 */
|
||||
--font-body: "Microsoft YaHei", "微软雅黑", "Segoe UI", sans-serif; /* 正文字体 */
|
||||
--font-code: "Consolas", "Monaco", "Courier New", monospace; /* 代码字体 */
|
||||
}
|
||||
|
||||
/* Global theme transition */
|
||||
@@ -148,3 +153,125 @@
|
||||
--shadow-2xl: 0 20px 40px rgba(0, 0, 0, 0.08), 0 10px 20px rgba(0, 0, 0, 0.06);
|
||||
--shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
/* ==================== Color Themes ==================== */
|
||||
|
||||
/* 方案一:仿纸暖黄(最经典护眼)*/
|
||||
[data-color-theme='paper'] {
|
||||
--color-bg-primary: #F5ECD7;
|
||||
--color-bg-secondary: #F0EBE3;
|
||||
--color-bg-tertiary: #FBF8F2;
|
||||
--color-bg-elevated: #FFFFFF;
|
||||
--color-bg-subtle: #EDE4D0; /* 比主背景略深 */
|
||||
|
||||
--color-text-primary: #3E3A35;
|
||||
--color-text-secondary: #5A5650;
|
||||
--color-text-muted: #8B8580;
|
||||
--color-text-inverse: #F5ECD7;
|
||||
|
||||
--color-border: #D9D0C1;
|
||||
--color-border-light: #E5DDD0;
|
||||
--color-border-focus: #C9C0B1;
|
||||
|
||||
--color-accent: #8B9A6B;
|
||||
--color-accent-hover: #7A895A;
|
||||
--color-accent-active: #697849;
|
||||
--color-accent-light: rgba(139, 154, 107, 0.15);
|
||||
--color-accent-ultra-light: rgba(139, 154, 107, 0.08);
|
||||
|
||||
--color-scrollbar: #B8B0A0;
|
||||
--color-scrollbar-hover: #9A9282;
|
||||
|
||||
--gradient-primary: linear-gradient(135deg, #8B9A6B 0%, #A68B5B 100%);
|
||||
--gradient-subtle: linear-gradient(180deg, rgba(139, 154, 107, 0.08) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
/* 方案二:现代清爽(极浅灰白)*/
|
||||
[data-color-theme='modern'] {
|
||||
--color-bg-primary: #F8F8F8;
|
||||
--color-bg-secondary: #F2F2F2;
|
||||
--color-bg-tertiary: #FFFFFF;
|
||||
--color-bg-elevated: #FFFFFF;
|
||||
--color-bg-subtle: #F0F0F0; /* 比主背景略深 */
|
||||
|
||||
--color-text-primary: #2C2C2C;
|
||||
--color-text-secondary: #5A5A5A;
|
||||
--color-text-muted: #999999;
|
||||
--color-text-inverse: #F8F8F8;
|
||||
|
||||
--color-border: #E0E0E0;
|
||||
--color-border-light: #EBEBEB;
|
||||
--color-border-focus: #D0D0D0;
|
||||
|
||||
--color-accent: #4A9FB0;
|
||||
--color-accent-hover: #3A8FA0;
|
||||
--color-accent-active: #2A7F90;
|
||||
--color-accent-light: rgba(74, 159, 176, 0.12);
|
||||
--color-accent-ultra-light: rgba(74, 159, 176, 0.06);
|
||||
|
||||
--color-scrollbar: #BBBBBB;
|
||||
--color-scrollbar-hover: #999999;
|
||||
|
||||
--gradient-primary: linear-gradient(135deg, #4A9FB0 0%, #5B8FB0 100%);
|
||||
--gradient-subtle: linear-gradient(180deg, rgba(74, 159, 176, 0.05) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
/* 方案三:夜间暗色(黑底灰字)*/
|
||||
[data-color-theme='dark'] {
|
||||
--color-bg-primary: #1E1E1E;
|
||||
--color-bg-secondary: #252526;
|
||||
--color-bg-tertiary: #2D2D30;
|
||||
--color-bg-elevated: #333336;
|
||||
--color-bg-subtle: #252526; /* 比主背景略亮 */
|
||||
|
||||
--color-text-primary: #BDBBB6;
|
||||
--color-text-secondary: #9E9E9E;
|
||||
--color-text-muted: #757575;
|
||||
--color-text-inverse: #1E1E1E;
|
||||
|
||||
--color-border: #3E3E40;
|
||||
--color-border-light: #333335;
|
||||
--color-border-focus: #4E4E50;
|
||||
|
||||
--color-accent: #D4A76A;
|
||||
--color-accent-hover: #C4975A;
|
||||
--color-accent-active: #B4874A;
|
||||
--color-accent-light: rgba(212, 167, 106, 0.15);
|
||||
--color-accent-ultra-light: rgba(212, 167, 106, 0.08);
|
||||
|
||||
--color-scrollbar: #666666;
|
||||
--color-scrollbar-hover: #888888;
|
||||
|
||||
--gradient-primary: linear-gradient(135deg, #D4A76A 0%, #6A8CA8 100%);
|
||||
--gradient-subtle: linear-gradient(180deg, rgba(212, 167, 106, 0.05) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
/* 方案四:豆沙绿(自然疗愈)*/
|
||||
[data-color-theme='green'] {
|
||||
--color-bg-primary: #DCE5D9;
|
||||
--color-bg-secondary: #E8EFE6;
|
||||
--color-bg-tertiary: #F4F9F2;
|
||||
--color-bg-elevated: #FFFFFF;
|
||||
--color-bg-subtle: #D0D9CD; /* 比主背景略深 */
|
||||
|
||||
--color-text-primary: #2B3330;
|
||||
--color-text-secondary: #4A5550;
|
||||
--color-text-muted: #7A8580;
|
||||
--color-text-inverse: #DCE5D9;
|
||||
|
||||
--color-border: #C0CFBB;
|
||||
--color-border-light: #D0DFCB;
|
||||
--color-border-focus: #B0BFAB;
|
||||
|
||||
--color-accent: #7D9B7A;
|
||||
--color-accent-hover: #6D8B6A;
|
||||
--color-accent-active: #5D7B5A;
|
||||
--color-accent-light: rgba(125, 155, 122, 0.15);
|
||||
--color-accent-ultra-light: rgba(125, 155, 122, 0.08);
|
||||
|
||||
--color-scrollbar: #A0B09D;
|
||||
--color-scrollbar-hover: #889885;
|
||||
|
||||
--gradient-primary: linear-gradient(135deg, #7D9B7A 0%, #6E8B6B 100%);
|
||||
--gradient-subtle: linear-gradient(180deg, rgba(125, 155, 122, 0.08) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user