- 前端 React+TypeScript (源码+构建产物) - 后端 Python FastAPI - 数据库迁移 001-015 - Docker部署配置 - 微信ClawBot API频道绑定 - 项目文档 README.md
187 lines
8.2 KiB
TypeScript
187 lines
8.2 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import type { Asset, NovelChapter } from '@/types';
|
|
import { mockAssets } from '@/mock/data';
|
|
import './NovelPreviewPage.css';
|
|
|
|
const NovelPreviewPage: React.FC = () => {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const [activeChapter, setActiveChapter] = useState<string>('');
|
|
const [rightTab, setRightTab] = useState<'worldview' | 'characters' | 'hooks'>('worldview');
|
|
|
|
const novel: Asset | undefined = mockAssets.find(a => a.id === id && a.category === 'novel');
|
|
const chapters = novel?.chapters || [];
|
|
const meta = novel?.novelMeta;
|
|
const currentChapter: NovelChapter | undefined = chapters.find(c => c.id === (activeChapter || chapters[0]?.id));
|
|
|
|
if (!novel) {
|
|
return (
|
|
<div className="novel-preview-page">
|
|
<div className="novel-preview-empty">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--color-text-disabled)' }}><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
|
<p>未找到该小说</p>
|
|
<button className="novel-back-btn" onClick={() => navigate('/assets')}>返回资产</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="novel-preview-page">
|
|
{/* Top: Novel Intro */}
|
|
<div className="novel-topbar">
|
|
<button className="novel-back-btn" onClick={() => navigate('/assets')}>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
|
</button>
|
|
<div className="novel-intro">
|
|
<h1 className="novel-intro-title">{novel.name}</h1>
|
|
<p className="novel-intro-desc">{novel.description}</p>
|
|
<div className="novel-intro-stats">
|
|
<span>{chapters.length} 章</span>
|
|
<span>·</span>
|
|
<span>{chapters.reduce((s, c) => s + c.wordCount, 0).toLocaleString()} 字</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="novel-body">
|
|
{/* Left: Chapter List */}
|
|
<aside className="novel-sidebar-left">
|
|
<h3 className="novel-sidebar-title">章节目录</h3>
|
|
<div className="novel-chapter-list">
|
|
{chapters.map(ch => (
|
|
<button
|
|
key={ch.id}
|
|
className={`novel-chapter-item ${(activeChapter || chapters[0]?.id) === ch.id ? 'active' : ''}`}
|
|
onClick={() => setActiveChapter(ch.id)}
|
|
>
|
|
<span className="chapter-item-num">{String(ch.sortOrder).padStart(2, '0')}</span>
|
|
<div className="chapter-item-info">
|
|
<span className="chapter-item-title">{ch.title}</span>
|
|
<span className="chapter-item-count">{ch.wordCount.toLocaleString()} 字</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Center: Content */}
|
|
<main className="novel-content">
|
|
{currentChapter ? (
|
|
<>
|
|
<div className="novel-content-header">
|
|
<h2 className="novel-chapter-title">{currentChapter.title}</h2>
|
|
</div>
|
|
<div className="novel-content-body">
|
|
{currentChapter.content.split('\n\n').map((para, i) => (
|
|
<p key={i} className="novel-paragraph">{para}</p>
|
|
))}
|
|
</div>
|
|
<div className="novel-content-footer">
|
|
<span>本章字数:{currentChapter.wordCount.toLocaleString()} 字</span>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="novel-content-empty">
|
|
<p>暂无章节内容</p>
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
{/* Right: World View, Characters, Hooks & Anchors */}
|
|
<aside className="novel-sidebar-right">
|
|
<div className="novel-right-tabs">
|
|
<button
|
|
className={`novel-right-tab ${rightTab === 'worldview' ? 'active' : ''}`}
|
|
onClick={() => setRightTab('worldview')}
|
|
>世界观</button>
|
|
<button
|
|
className={`novel-right-tab ${rightTab === 'characters' ? 'active' : ''}`}
|
|
onClick={() => setRightTab('characters')}
|
|
>人物</button>
|
|
<button
|
|
className={`novel-right-tab ${rightTab === 'hooks' ? 'active' : ''}`}
|
|
onClick={() => setRightTab('hooks')}
|
|
>钩子&锚点</button>
|
|
</div>
|
|
|
|
<div className="novel-right-content">
|
|
{rightTab === 'worldview' && meta?.worldView && (
|
|
<div className="novel-worldview">
|
|
<p className="worldview-summary">{meta.worldView.summary}</p>
|
|
<div className="worldview-section">
|
|
<h4>世界规则</h4>
|
|
<ul>
|
|
{meta.worldView.rules.map((r, i) => <li key={i}>{r}</li>)}
|
|
</ul>
|
|
</div>
|
|
<div className="worldview-section">
|
|
<h4>时间线</h4>
|
|
<div className="worldview-timeline">
|
|
{meta.worldView.timeline.map((t, i) => (
|
|
<div key={i} className="timeline-item">
|
|
<span className="timeline-era">{t.era}</span>
|
|
<span className="timeline-event">{t.events}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{rightTab === 'characters' && meta?.characters && (
|
|
<div className="novel-characters">
|
|
{meta.characters.map(char => (
|
|
<div key={char.id} className="character-card">
|
|
<div className="character-avatar">{char.name.charAt(0)}</div>
|
|
<div className="character-info">
|
|
<div className="character-name-row">
|
|
<span className="character-name">{char.name}</span>
|
|
<span className={`character-role ${char.role === '主角' ? 'main' : char.role === '反派' ? 'villain' : ''}`}>{char.role}</span>
|
|
</div>
|
|
<p className="character-desc">{char.description}</p>
|
|
{char.relationships.length > 0 && (
|
|
<div className="character-rels">
|
|
{char.relationships.map((r, i) => (
|
|
<span key={i} className="character-rel-tag">{r}</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{rightTab === 'hooks' && meta && (
|
|
<div className="novel-hooks">
|
|
<div className="hooks-section">
|
|
<h4>故事钩子</h4>
|
|
{meta.hooks.map((h, i) => (
|
|
<div key={i} className="hook-item">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
|
|
<span>{h}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="hooks-section">
|
|
<h4>故事锚点</h4>
|
|
{meta.anchors.map((a, i) => (
|
|
<div key={i} className="anchor-item">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>
|
|
<span>{a}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default NovelPreviewPage;
|