完成大量美化
This commit is contained in:
242
frontend/src/Store/SideBarLeft/CharacterSlice.jsx
Normal file
242
frontend/src/Store/SideBarLeft/CharacterSlice.jsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
const useCharacterStore = create(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// 状态
|
||||
characters: [], // 所有角色卡列表
|
||||
selectedCharacter: null, // 当前选中的角色
|
||||
isLoading: false,
|
||||
error: null,
|
||||
currentPage: 1, // 当前页码
|
||||
pageSize: 12, // 每页显示数量
|
||||
characterChats: {}, // 存储每个角色的聊天记录 {characterName: [chats]}
|
||||
|
||||
// 获取所有角色卡
|
||||
fetchCharacters: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await fetch('/api/characters/');
|
||||
if (!response.ok) throw new Error('Failed to fetch characters');
|
||||
|
||||
const data = await response.json();
|
||||
set({
|
||||
characters: data,
|
||||
isLoading: false,
|
||||
currentPage: 1 // 重置到第一页
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error.message,
|
||||
isLoading: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 设置当前页码
|
||||
setCurrentPage: (page) => {
|
||||
set({ currentPage: page });
|
||||
},
|
||||
|
||||
// 设置每页显示数量
|
||||
setPageSize: (size) => {
|
||||
set({ pageSize: size, currentPage: 1 }); // 重置到第一页
|
||||
},
|
||||
|
||||
// 获取当前页的角色数据
|
||||
getCurrentPageCharacters: () => {
|
||||
const { characters, currentPage, pageSize } = get();
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
return characters.slice(startIndex, endIndex);
|
||||
},
|
||||
|
||||
// 获取总页数
|
||||
getTotalPages: () => {
|
||||
const { characters, pageSize } = get();
|
||||
return Math.ceil(characters.length / pageSize);
|
||||
},
|
||||
|
||||
// 获取角色的聊天记录
|
||||
fetchCharacterChats: async (characterName) => {
|
||||
const { characterChats } = get();
|
||||
|
||||
// 如果已经缓存了,直接返回
|
||||
if (characterChats[characterName]) {
|
||||
return characterChats[characterName];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/characters/${encodeURIComponent(characterName)}/chats`);
|
||||
if (!response.ok) throw new Error('Failed to fetch chats');
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 更新缓存
|
||||
set((state) => ({
|
||||
characterChats: {
|
||||
...state.characterChats,
|
||||
[characterName]: data.chats || []
|
||||
}
|
||||
}));
|
||||
|
||||
return data.chats || [];
|
||||
} catch (error) {
|
||||
console.error('获取聊天记录失败:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// 选择角色
|
||||
selectCharacter: (character) => {
|
||||
// 使用函数式更新避免不必要的重渲染
|
||||
set((state) => {
|
||||
// 如果选中的是同一个角色,不更新状态
|
||||
if (state.selectedCharacter?.id === character.id) {
|
||||
return state;
|
||||
}
|
||||
return { selectedCharacter: character };
|
||||
});
|
||||
},
|
||||
|
||||
// 创建角色
|
||||
createCharacter: async (characterData) => {
|
||||
try {
|
||||
const response = await fetch('/api/characters/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(characterData)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create character');
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 刷新列表
|
||||
await get().fetchCharacters();
|
||||
|
||||
return data.character;
|
||||
} catch (error) {
|
||||
set({ error: error.message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 更新角色
|
||||
updateCharacter: async (name, updates) => {
|
||||
try {
|
||||
const response = await fetch(`/api/characters/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update character');
|
||||
|
||||
// 刷新列表
|
||||
await get().fetchCharacters();
|
||||
} catch (error) {
|
||||
set({ error: error.message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 删除角色
|
||||
deleteCharacter: async (name) => {
|
||||
try {
|
||||
const response = await fetch(`/api/characters/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete character');
|
||||
|
||||
// 刷新列表
|
||||
await get().fetchCharacters();
|
||||
} catch (error) {
|
||||
set({ error: error.message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 上传头像
|
||||
uploadAvatar: async (name, file) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(`/api/characters/${encodeURIComponent(name)}/avatar`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to upload avatar');
|
||||
|
||||
// 刷新列表以更新头像路径
|
||||
await get().fetchCharacters();
|
||||
} catch (error) {
|
||||
set({ error: error.message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 导入角色卡
|
||||
importCharacter: async (file) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch('/api/characters/import', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to import character');
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 刷新列表
|
||||
await get().fetchCharacters();
|
||||
|
||||
return data.character;
|
||||
} catch (error) {
|
||||
set({ error: error.message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 导出角色卡为 PNG
|
||||
exportCharacterAsPng: async (name) => {
|
||||
try {
|
||||
const response = await fetch(`/api/characters/${encodeURIComponent(name)}/export/png`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to export character');
|
||||
|
||||
// 下载文件
|
||||
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);
|
||||
} catch (error) {
|
||||
set({ error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}),
|
||||
{
|
||||
name: 'character-storage',
|
||||
partialize: (state) => ({
|
||||
selectedCharacter: state.selectedCharacter
|
||||
})
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export default useCharacterStore;
|
||||
@@ -53,6 +53,12 @@ const useWorldBookStore = create((set, get) => ({
|
||||
currentWorldBook: null, // 当前选中的世界书
|
||||
currentEntries: [], // 当前世界书的条目列表
|
||||
currentEntry: null, // 当前选中的条目
|
||||
entriesPagination: { // 条目分页信息
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total_pages: 0
|
||||
},
|
||||
loading: false, // 加载状态
|
||||
error: null, // 错误信息
|
||||
success: false, // 操作成功状态
|
||||
@@ -116,8 +122,7 @@ const useWorldBookStore = create((set, get) => ({
|
||||
return {
|
||||
loading: false,
|
||||
globalWorldBooks: updatedGlobalBooks,
|
||||
success: true,
|
||||
message: isGlobal ? `已将 "${name}" 设为全局世界书` : `已取消 "${name}" 的全局状态`
|
||||
error: null
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -130,7 +135,18 @@ const useWorldBookStore = create((set, get) => ({
|
||||
const data = await handleResponse(response);
|
||||
|
||||
// 从 LocalStorage 获取全局世界书列表
|
||||
const globalBooks = loadGlobalWorldBooks();
|
||||
let globalBooks = loadGlobalWorldBooks();
|
||||
|
||||
// 清理 LocalStorage 中已不存在的世界书
|
||||
const existingWorldBookNames = new Set(data.map(wb => wb.name));
|
||||
const cleanedGlobalBooks = globalBooks.filter(wb => existingWorldBookNames.has(wb.name));
|
||||
|
||||
// 如果有被清理的项,更新 LocalStorage
|
||||
if (cleanedGlobalBooks.length !== globalBooks.length) {
|
||||
console.log(`清理了 ${globalBooks.length - cleanedGlobalBooks.length} 个不存在的全局世界书`);
|
||||
saveGlobalWorldBooks(cleanedGlobalBooks);
|
||||
globalBooks = cleanedGlobalBooks;
|
||||
}
|
||||
|
||||
set({
|
||||
loading: false,
|
||||
@@ -333,18 +349,24 @@ const useWorldBookStore = create((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// 异步操作:获取世界书的所有条目
|
||||
fetchWorldBookEntries: async (name) => {
|
||||
// 异步操作:获取世界书的所有条目(支持分页)
|
||||
fetchWorldBookEntries: async (name, page = 1, page_size = 20) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await fetch(`/api/worldbooks/${name}/entries`);
|
||||
const response = await fetch(`/api/worldbooks/${name}/entries?page=${page}&page_size=${page_size}`);
|
||||
const data = await handleResponse(response);
|
||||
|
||||
set(state => {
|
||||
if (state.currentWorldBook?.name === name) {
|
||||
return {
|
||||
loading: false,
|
||||
currentEntries: data,
|
||||
currentEntries: data.entries || [],
|
||||
entriesPagination: {
|
||||
total: data.total || 0,
|
||||
page: data.page || 1,
|
||||
page_size: data.page_size || 20,
|
||||
total_pages: data.total_pages || 0
|
||||
},
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -754,3 +754,65 @@
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ==================== 分页控件样式 ==================== */
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pagination-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-pagination {
|
||||
padding: 6px 12px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.15s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-pagination:hover:not(:disabled) {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-pagination:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-size-select {
|
||||
padding: 6px 10px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.page-size-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px var(--color-accent-light);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const WorldBook = () => {
|
||||
currentWorldBook,
|
||||
currentEntries,
|
||||
currentEntry,
|
||||
entriesPagination,
|
||||
loading,
|
||||
error,
|
||||
success,
|
||||
@@ -87,6 +88,13 @@ const WorldBook = () => {
|
||||
prevActiveTabRef.current = activeTab;
|
||||
}, [activeTab, fetchWorldBooks]);
|
||||
|
||||
// 组件挂载时立即加载世界书列表
|
||||
useEffect(() => {
|
||||
if (worldBooks.length === 0) {
|
||||
fetchWorldBooks();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (success && message) {
|
||||
alert(message);
|
||||
@@ -134,8 +142,10 @@ const WorldBook = () => {
|
||||
const handleSelectWorldBook = async (book) => {
|
||||
setCurrentWorldBook(book);
|
||||
setShowWorldBookDropdown(false);
|
||||
// 重置分页到第一页
|
||||
setCurrentPage(1);
|
||||
try {
|
||||
await fetchWorldBookEntries(book.name);
|
||||
await fetchWorldBookEntries(book.name, 1, pageSize);
|
||||
} catch (err) {
|
||||
console.error('加载世界书条目失败:', err);
|
||||
}
|
||||
@@ -192,6 +202,8 @@ const WorldBook = () => {
|
||||
|
||||
try {
|
||||
await createWorldBookEntry(currentWorldBook.name, entryData);
|
||||
// 添加成功后刷新当前页
|
||||
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
|
||||
setNewEntry({
|
||||
uid: 0,
|
||||
content: '',
|
||||
@@ -234,6 +246,29 @@ const WorldBook = () => {
|
||||
setShowEditPanel(true);
|
||||
};
|
||||
|
||||
// 处理页码变化
|
||||
const handlePageChange = async (newPage) => {
|
||||
if (!currentWorldBook) return;
|
||||
setCurrentPage(newPage);
|
||||
try {
|
||||
await fetchWorldBookEntries(currentWorldBook.name, newPage, pageSize);
|
||||
} catch (err) {
|
||||
console.error('加载世界书条目失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理每页数量变化
|
||||
const handlePageSizeChange = async (newPageSize) => {
|
||||
if (!currentWorldBook) return;
|
||||
setPageSize(newPageSize);
|
||||
setCurrentPage(1); // 重置到第一页
|
||||
try {
|
||||
await fetchWorldBookEntries(currentWorldBook.name, 1, newPageSize);
|
||||
} catch (err) {
|
||||
console.error('加载世界书条目失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEntryUpdate = async (field, value) => {
|
||||
if (!currentEntry || !currentWorldBook) return;
|
||||
|
||||
@@ -241,6 +276,8 @@ const WorldBook = () => {
|
||||
try {
|
||||
await updateWorldBookEntry(currentWorldBook.name, currentEntry.uid, updatedEntry);
|
||||
setCurrentEntry(updatedEntry);
|
||||
// 更新成功后刷新当前页
|
||||
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
|
||||
} catch (err) {
|
||||
console.error('更新条目失败:', err);
|
||||
}
|
||||
@@ -301,27 +338,33 @@ const WorldBook = () => {
|
||||
const handleDeleteEntry = async () => {
|
||||
if (!currentEntry || !currentWorldBook) return;
|
||||
|
||||
if (confirm('确定要删除此条目吗?')) {
|
||||
try {
|
||||
await deleteWorldBookEntry(currentWorldBook.name, currentEntry.uid);
|
||||
setShowEditPanel(false);
|
||||
setCurrentEntry(null);
|
||||
} catch (err) {
|
||||
console.error('删除条目失败:', err);
|
||||
}
|
||||
if (!window.confirm('确定要删除此条目吗?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteWorldBookEntry(currentWorldBook.name, currentEntry.uid);
|
||||
setShowEditPanel(false);
|
||||
setCurrentEntry(null);
|
||||
// 删除成功后刷新当前页
|
||||
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
|
||||
} catch (err) {
|
||||
console.error('删除条目失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteWorldBook = async () => {
|
||||
if (!currentWorldBook) return;
|
||||
|
||||
if (confirm(`确定要删除世界书 "${currentWorldBook.name}" 吗?`)) {
|
||||
try {
|
||||
await deleteWorldBook(currentWorldBook.name);
|
||||
resetCurrentWorldBook();
|
||||
} catch (err) {
|
||||
console.error('删除世界书失败:', err);
|
||||
}
|
||||
if (!window.confirm(`确定要删除世界书 "${currentWorldBook.name}" 吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteWorldBook(currentWorldBook.name);
|
||||
resetCurrentWorldBook();
|
||||
} catch (err) {
|
||||
console.error('删除世界书失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -393,7 +436,7 @@ const WorldBook = () => {
|
||||
if (importedBook) {
|
||||
handleSelectWorldBook(importedBook);
|
||||
// 导入成功后自动加载条目
|
||||
await fetchWorldBookEntries(name);
|
||||
await fetchWorldBookEntries(name, 1, pageSize);
|
||||
console.log('✅ 世界书导入成功,已加载条目');
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -623,6 +666,42 @@ const WorldBook = () => {
|
||||
) : (
|
||||
<div className="loading">暂无条目</div>
|
||||
)}
|
||||
|
||||
{/* 分页控件 */}
|
||||
{entriesPagination.total > 0 && (
|
||||
<div className="pagination-controls">
|
||||
<div className="pagination-info">
|
||||
共 {entriesPagination.total} 条,第 {entriesPagination.page}/{entriesPagination.total_pages} 页
|
||||
</div>
|
||||
<div className="pagination-buttons">
|
||||
<button
|
||||
className="btn-pagination"
|
||||
disabled={entriesPagination.page <= 1}
|
||||
onClick={() => handlePageChange(entriesPagination.page - 1)}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
className="btn-pagination"
|
||||
disabled={entriesPagination.page >= entriesPagination.total_pages}
|
||||
onClick={() => handlePageChange(entriesPagination.page + 1)}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
<select
|
||||
className="page-size-select"
|
||||
value={pageSize}
|
||||
onChange={(e) => handlePageSizeChange(parseInt(e.target.value))}
|
||||
>
|
||||
<option value={10}>10条/页</option>
|
||||
<option value={20}>20条/页</option>
|
||||
<option value={50}>50条/页</option>
|
||||
<option value={100}>100条/页</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="btn btn-primary" onClick={handleAddEntry}>
|
||||
+ 添加条目
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/* Markdown 渲染样式 - 参考 Discord/Telegram 风格 */
|
||||
|
||||
.markdown-content {
|
||||
font-size: 1rem; /* 增大基础字体 */
|
||||
line-height: 1.8; /* 增加行高,提高可读性 */
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* 段落 */
|
||||
.md-paragraph {
|
||||
margin: 0.6em 0;
|
||||
line-height: 1.8;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.md-paragraph:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.md-paragraph:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 标题 */
|
||||
.md-heading {
|
||||
margin: 1.2em 0 0.6em;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-ui); /* Markdown 标题使用微软雅黑 UI */
|
||||
}
|
||||
|
||||
.md-h1 {
|
||||
font-size: 1.6rem;
|
||||
border-bottom: 2px solid var(--color-border-light);
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.md-h2 {
|
||||
font-size: 1.4rem;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.md-h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.md-h4 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* 行内代码 */
|
||||
.inline-code {
|
||||
background-color: rgba(0, 0, 0, 0.06);
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 3px;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 0.85em;
|
||||
color: #e83e8c;
|
||||
}
|
||||
|
||||
.message.user .inline-code {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 代码块 */
|
||||
.code-block-wrapper {
|
||||
margin: 0.8em 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: #282c34;
|
||||
}
|
||||
|
||||
.code-block-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5em 1em;
|
||||
background-color: #21252b;
|
||||
border-bottom: 1px solid #181a1f;
|
||||
}
|
||||
|
||||
.code-language {
|
||||
font-size: 0.75rem;
|
||||
color: #abb2bf;
|
||||
font-weight: 500;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0.3em 0.6em;
|
||||
background-color: transparent;
|
||||
border: 1px solid #3e4451;
|
||||
border-radius: 4px;
|
||||
color: #abb2bf;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
background-color: #3e4451;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 引用块 */
|
||||
.md-blockquote {
|
||||
margin: 0.8em 0;
|
||||
padding: 0.6em 1.2em;
|
||||
border-left: 4px solid var(--color-accent);
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: 0 4px 4px 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.message.user .md-blockquote {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-left-color: rgba(255, 255, 255, 0.5);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* 列表 */
|
||||
.md-list {
|
||||
margin: 0.6em 0;
|
||||
padding-left: 1.8em;
|
||||
}
|
||||
|
||||
.md-list-item {
|
||||
margin: 0.4em 0;
|
||||
line-height: 1.7;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.md-list-ordered {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
/* 链接 */
|
||||
.md-link {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.md-link:hover {
|
||||
border-bottom-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.message.user .md-link {
|
||||
color: #fff;
|
||||
border-bottom-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.message.user .md-link:hover {
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
|
||||
/* 图片 */
|
||||
.md-image {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
/* 分隔线 */
|
||||
.md-divider {
|
||||
margin: 1.5em 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.message.user .md-divider {
|
||||
border-top-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* 表格 */
|
||||
.md-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0.8em 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.md-table-head {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.message.user .md-table-head {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.md-table-row {
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.md-table-cell {
|
||||
padding: 0.6em 0.8em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.md-table-header {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* KaTeX 数学公式优化 */
|
||||
.katex {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
.katex-display {
|
||||
margin: 0.8em 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 768px) {
|
||||
.markdown-content {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.md-h1 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.md-h2 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.md-h3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import remarkMath from "remark-math";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||
import "katex/dist/katex.min.css";
|
||||
|
||||
// 复制图标
|
||||
const CopyIcon = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// 代码块组件
|
||||
const CodeBlock = ({ node, inline, className, children, ...props }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
const codeContent = String(children).replace(/\n$/, "");
|
||||
|
||||
// 行内代码
|
||||
if (inline) {
|
||||
return (
|
||||
<code className="inline-code" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
const handleCopy = () => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="code-block-wrapper">
|
||||
<div className="code-block-header">
|
||||
<span className="code-language">{match?.[1] || "text"}</span>
|
||||
<CopyToClipboard text={codeContent} onCopy={handleCopy}>
|
||||
<button className="copy-button" title="复制代码">
|
||||
{copied ? "✓ 已复制" : <><CopyIcon /> 复制</>}
|
||||
</button>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
style={oneDark}
|
||||
language={match?.[1] || "text"}
|
||||
PreTag="div"
|
||||
showLineNumbers
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: "0 0 8px 8px",
|
||||
fontSize: "0.85rem",
|
||||
lineHeight: "1.5"
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{codeContent}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Markdown 渲染组件
|
||||
const MarkdownRenderer = ({ content, className = "" }) => {
|
||||
if (!content) return null;
|
||||
|
||||
return (
|
||||
<div className={`markdown-content ${className}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
code: CodeBlock,
|
||||
// 自定义段落样式
|
||||
p: ({ children }) => <p className="md-paragraph">{children}</p>,
|
||||
// 自定义列表样式
|
||||
ul: ({ children }) => <ul className="md-list">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="md-list md-list-ordered">{children}</ol>,
|
||||
li: ({ children }) => <li className="md-list-item">{children}</li>,
|
||||
// 自定义标题样式
|
||||
h1: ({ children }) => <h1 className="md-heading md-h1">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="md-heading md-h2">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="md-heading md-h3">{children}</h3>,
|
||||
h4: ({ children }) => <h4 className="md-heading md-h4">{children}</h4>,
|
||||
// 自定义引用块
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="md-blockquote">{children}</blockquote>
|
||||
),
|
||||
// 自定义表格
|
||||
table: ({ children }) => <table className="md-table">{children}</table>,
|
||||
thead: ({ children }) => <thead className="md-table-head">{children}</thead>,
|
||||
tbody: ({ children }) => <tbody className="md-table-body">{children}</tbody>,
|
||||
tr: ({ children }) => <tr className="md-table-row">{children}</tr>,
|
||||
th: ({ children }) => <th className="md-table-cell md-table-header">{children}</th>,
|
||||
td: ({ children }) => <td className="md-table-cell">{children}</td>,
|
||||
// 自定义链接
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="md-link">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
// 自定义图片
|
||||
img: ({ src, alt }) => (
|
||||
<img src={src} alt={alt} className="md-image" />
|
||||
),
|
||||
// 自定义分隔线
|
||||
hr: () => <hr className="md-divider" />,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownRenderer;
|
||||
4
frontend/src/components/shared/MarkdownRenderer/index.js
Normal file
4
frontend/src/components/shared/MarkdownRenderer/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import MarkdownRenderer from './MarkdownRenderer';
|
||||
import './MarkdownRenderer.css';
|
||||
|
||||
export default MarkdownRenderer;
|
||||
Reference in New Issue
Block a user