feat: add NovelPage component with bookshelf, open book, and reading views

This commit is contained in:
2026-06-02 08:19:38 +08:00
parent 54e17c9795
commit 81b0d0ea1f
39 changed files with 8057 additions and 64 deletions

View File

@@ -1,14 +1,16 @@
// frontend-react/src/App.jsx
import React, { useCallback, useEffect, useRef } from 'react'; // ✅ 移除 useState
import React, { useCallback, useEffect, useRef, useState } from 'react';
import TopBar from './components/TopBar';
import { ChatBox } from './components/Mid';
import SideBarLeft from './components/SideBarLeft';
import SideBarRight from './components/SideBarRight';
import PlaceholderPage from './components/PlaceholderPage';
import NovelPage from './components/Novel/NovelPage';
import StudioEditPage from './components/Studio/StudioEditPage';
import StudioRunPage from './components/Studio/StudioRunPage';
import useAppLayoutStore from './Store/AppLayoutSlice'; // ✅ 新增
import useStudioStore from './Store/Studio/StudioSlice';
import useNovelStore from './Store/Novel/NovelSlice';
import useApiConfigStore from './Store/SideBarLeft/ApiConfigSlice'; // ✅ 引入 API 配置 Store
import usePresetStore from './Store/SideBarLeft/PresetSlice'; // ✅ 引入预设 Store
import useCharacterStore from './Store/SideBarLeft/CharacterSlice'; // ✅ 引入角色卡 Store
@@ -194,22 +196,41 @@ function App() {
const initStudio = useStudioStore((s) => s.initStudio);
const initStudioRun = useStudioStore((s) => s.initStudioRun);
const initNovel = useNovelStore((s) => s.initNovel);
const novelView = useNovelStore((s) => s.view);
const readingChromeVisible = useNovelStore((s) => s.readingChromeVisible);
const isStudioEditPage = activePage === 'studio_edit';
const isStudioRunPage = activePage === 'studio_run';
const isNovelPage = activePage === 'novel';
const isStudioPage = isStudioEditPage || isStudioRunPage;
const [isMobileViewport, setIsMobileViewport] = useState(() =>
typeof window !== 'undefined' && window.matchMedia('(max-width: 768px)').matches
);
useEffect(() => {
const mq = window.matchMedia('(max-width: 768px)');
const onChange = (e) => setIsMobileViewport(e.matches);
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);
const hideTopBar =
isNovelPage && novelView === 'reading' && isMobileViewport && !readingChromeVisible;
useEffect(() => {
if (isStudioEditPage) {
initStudio();
} else if (isStudioRunPage) {
initStudioRun();
} else if (isNovelPage) {
initNovel();
}
}, [isStudioEditPage, isStudioRunPage, initStudio, initStudioRun]);
}, [isStudioEditPage, isStudioRunPage, isNovelPage, initStudio, initStudioRun, initNovel]);
return (
<div className={`app ${layoutMode}-mode`}>
{/* ✅ TopBar 不再需要 props直接从 Store 读取状态 */}
<TopBar />
<div className={`app ${layoutMode}-mode${hideTopBar ? ' novel-reading-immersive' : ''}`}>
{!hideTopBar && <TopBar />}
{/* 主内容容器 */}
{activePage === 'chat' ? (
@@ -243,6 +264,10 @@ function App() {
<div className="main-container studio-container">
<StudioRunPage />
</div>
) : activePage === 'novel' ? (
<div className="main-container novel-container">
<NovelPage />
</div>
) : (
<div className="main-container placeholder-container">
<PlaceholderPage page={activePage} />

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -17,8 +17,21 @@
padding: 0 var(--spacing-lg);
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: nowrap;
gap: var(--spacing-md);
min-width: 0;
overflow-x: auto;
scrollbar-width: thin;
scrollbar-color: var(--color-scrollbar) transparent;
}
.top-bar-content::-webkit-scrollbar {
height: 6px;
}
.top-bar-content::-webkit-scrollbar-thumb {
background: var(--color-scrollbar);
border-radius: var(--radius-full);
}
/* Status Section - Left side */
@@ -26,10 +39,7 @@
display: flex;
align-items: center;
gap: var(--spacing-md);
flex: 1;
justify-content: flex-start;
min-width: 0;
overflow-x: auto;
flex-shrink: 0;
}
/* Actions Section - Right side */
@@ -38,6 +48,7 @@
align-items: center;
gap: var(--spacing-sm);
flex-shrink: 0;
margin-left: auto;
}
/* Status Badge Styles - Minimalist */
@@ -87,21 +98,25 @@
white-space: nowrap;
}
/* Action Button Styles - Badge-like (Similar to status-badge) */
/* Action Button Styles — compact square, matches theme toggle */
.action-btn {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-sm) var(--spacing-md);
width: 32px;
height: 32px;
min-width: 32px;
min-height: 32px;
padding: 0;
background-color: transparent;
color: var(--color-text-secondary);
border: none;
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
cursor: pointer;
font-size: 1.4rem; /* 更大的图标 */
font-size: 1rem;
line-height: 1;
min-height: 36px; /* 与 status-badge 一致 */
flex-shrink: 0;
}
.action-btn:hover {
@@ -665,3 +680,16 @@
border-color: var(--color-text-muted);
color: var(--color-text-primary);
}
/* Mobile: hide TopBar horizontal scrollbar (keep swipe scroll) */
@media (max-width: 768px) {
.top-bar-content {
scrollbar-width: none;
-ms-overflow-style: none;
}
.top-bar-content::-webkit-scrollbar {
display: none;
height: 0;
}
}

View File

@@ -7,7 +7,8 @@ import useApiConfigStore from '../../Store/SideBarLeft/ApiConfigSlice';
import PageModeToggle from './items/PageModeToggle';
import StudioRunControls from './items/StudioRunControls';
import ThemeToggle from './items/ThemeToggle';
import RegexPanel from '../SideBarLeft/tabs/Regex/RegexPanel'; // ✅ 新增:导入正则管理组件
import RegexPanel from '../SideBarLeft/tabs/Regex/RegexPanel';
import ApiConfig from '../SideBarLeft/tabs/ApiConfig/ApiConfig';
import './TopBar.css';
const Toolbar = () => {
@@ -24,6 +25,7 @@ const Toolbar = () => {
} = useAppLayoutStore();
const isStudioRunPage = activePage === 'studio_run';
const isNovelPage = activePage === 'novel';
// ✅ 从 UserStore 获取用户角色和方法
const { currentUserRole, userRoles, updateRoleName, updateRoleDescription, selectUserRole, addUserRole } = useUserStore();
@@ -32,9 +34,16 @@ const Toolbar = () => {
const { globalWorldBooks } = useWorldBookStore();
// 从 ApiConfigStore 获取核心和辅助 API 配置
const { activeMap, fetchProfile } = useApiConfigStore();
const { currentProfile } = useApiConfigStore();
const [coreModel, setCoreModel] = useState('未设置');
const [assistModel, setAssistModel] = useState('未设置');
useEffect(() => {
const main = currentProfile?.apis?.mainLLM;
const secondary = currentProfile?.apis?.secondaryLLM;
setCoreModel(main?.model || main?.name || '未设置');
setAssistModel(secondary?.model || secondary?.name || '未设置');
}, [currentProfile]);
// 系统设置状态
const [settings, setSettings] = useState({
@@ -155,64 +164,84 @@ const Toolbar = () => {
<div className="status-section">
{isStudioRunPage ? <StudioRunControls /> : null}
{/* 当前玩家角色 */}
<div
className="status-badge"
title="当前玩家角色"
onClick={() => handlePanelToggle('currentRole')}
>
<span className="status-icon">😊</span>
<span className="status-label">{truncateText(currentUserRole.name || '未设置', 15)}</span>
</div>
{!isNovelPage && (
<>
{/* 当前玩家角色 */}
<div
className="status-badge"
title="当前玩家角色"
onClick={() => handlePanelToggle('currentRole')}
>
<span className="status-icon">😊</span>
<span className="status-label">{truncateText(currentUserRole.name || '未设置', 15)}</span>
</div>
</>
)}
{/* 核心配置 */}
<div className="status-badge" title={`核心配置: ${coreModel}`}>
<div
className="status-badge"
title={`核心配置: ${coreModel}`}
onClick={isNovelPage ? () => handlePanelToggle('apiConfig') : undefined}
>
<span className="status-icon">🔌</span>
<span className="status-label">{truncateText(coreModel, 12)}</span>
</div>
{/* 辅助配置 */}
<div className="status-badge" title={`辅助配置: ${assistModel}`}>
<div
className="status-badge"
title={`辅助配置: ${assistModel}`}
onClick={isNovelPage ? () => handlePanelToggle('apiConfig') : undefined}
>
<span className="status-icon">🔗</span>
<span className="status-label">{truncateText(assistModel, 12)}</span>
</div>
{/* 当前预设 */}
<div className="status-badge" title="当前预设名">
<span className="status-icon"></span>
<span className="status-label">默认预设</span>
</div>
{/* 激活的世界书 */}
<div className="status-badge world-info-badge" title="已激活全局世界书" onClick={() => handlePanelToggle('worldBook')}>
<span className="status-icon">📚</span>
<span className="status-label">
{globalWorldBooks.length > 0
? globalWorldBooks.map(wb => wb.name).join(', ')
: '无'}
</span>
</div>
{!isNovelPage && (
<>
{/* 当前预设 */}
<div className="status-badge" title="当前预设名">
<span className="status-icon"></span>
<span className="status-label">默认预设</span>
</div>
{/* 激活的世界书 */}
<div className="status-badge world-info-badge" title="已激活全局世界书" onClick={() => handlePanelToggle('worldBook')}>
<span className="status-icon">📚</span>
<span className="status-label">
{globalWorldBooks.length > 0
? globalWorldBooks.map(wb => wb.name).join(', ')
: '无'}
</span>
</div>
</>
)}
</div>
{/* 右侧:操作按钮区域 */}
<div className="actions-section">
{/* 设置按钮 */}
<button
className="action-btn"
title="设置"
onClick={() => handlePanelToggle('settings')}
>
</button>
{/* 扩展按钮 */}
<button
className="action-btn"
title="扩展"
onClick={() => handlePanelToggle('extensions')}
>
</button>
{!isNovelPage && (
<>
{/* 设置按钮 */}
<button
className="action-btn"
title="设置"
onClick={() => handlePanelToggle('settings')}
>
</button>
{/* 扩展按钮 */}
<button
className="action-btn"
title="扩展"
onClick={() => handlePanelToggle('extensions')}
>
</button>
</>
)}
{/* 页面模式 */}
<PageModeToggle />
@@ -223,6 +252,23 @@ const Toolbar = () => {
</div>
</div>
{/* API 配置面板(爽文模式) */}
{activePanel === 'apiConfig' && (
<div className="panel-overlay" ref={panelRef}>
<div className="panel-content settings-panel">
<div className="panel-header">
<h3>API 配置</h3>
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
</button>
</div>
<div className="panel-body" style={{ padding: 0 }}>
<ApiConfig />
</div>
</div>
</div>
)}
{/* ✅ 用户角色设置面板 */}
{activePanel === 'currentRole' && (
<div className="panel-overlay" ref={panelRef}>

View File

@@ -28,15 +28,22 @@
}
.main-container.placeholder-container,
.main-container.studio-container {
.main-container.studio-container,
.main-container.novel-container {
flex-direction: column;
}
.main-container.studio-container {
.main-container.studio-container,
.main-container.novel-container {
display: flex;
min-height: 0;
}
.app.novel-reading-immersive .main-container.novel-container {
flex: 1;
min-height: 0;
}
/* Smooth transitions for theme changes - only apply to specific properties */
.app {
transition: background-color var(--transition-normal),