完成大量美化

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