完成大量美化

This commit is contained in:
2026-05-01 14:44:18 +08:00
parent 1d0f0ae0ef
commit 6b65b24b0f
49 changed files with 2487 additions and 9019 deletions

View File

@@ -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>
);

View File

@@ -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

View File

@@ -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}" 的全局状态`
};
});
},
// 异步操作:获取所有世界书

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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>
);
};

View File

@@ -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>&nbsp;</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;

View File

@@ -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;
}

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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">

View File

@@ -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);
}

View File

@@ -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>
);
};

View File

@@ -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;
}
}

View File

@@ -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%);
}