AstroResearch/dashboard/src/pages/ReaderPanel.tsx
Asfmq 26d3b2fc02 feat: PWA 移动端适配、响应式三档布局与书签认证
前端:
  - 新增 PWA manifest、透传 Service Worker、iOS 全屏 meta 标签
  - 响应式断点细化为手机(<768px)/平板(<1024px)/桌面三档
  - ReaderToolbar 操作按钮在窄屏折叠为下拉菜单,按钮文案响应式精简
  - BilingualViewer 手机端强制单列堆叠,100dvh + safe-area-inset-top 适配
  - Bookmarklet 去硬编码 localhost,动态注入 origin + bookmarklet_key 完成认证
  - 修复 useAutoScroll 改用容器级 scrollTo(scrollHeight)
  - PaperCard 操作区 flex-wrap / SearchPanel 移动端垂直布局

  后端:
  - 登录返回 bookmarklet_key(SHA-1 哈希),auth_middleware 对书签导入路径额外放行 Bearer token
  - CORS 白名单扩展到全部私有网段,SameSite cookie 改为 Lax 兼容局域网访问
  - 新增 pdf_inline_middleware,PDF 文件强制内联显示防移动端下载
  - 修复 meta.rs datetime 列别名缺失 / 登录错误兼容 {error} 对象格式
2026-07-01 18:57:21 +08:00

234 lines
7.8 KiB
TypeScript

// dashboard/src/features/reader/ReaderPanel.tsx
import { useState, useEffect } from 'react';
import type { StandardPaper, NoteRecord } from '../types';
import { useReaderState } from '../hooks/useReaderState';
import { ReaderToolbar } from '../components/reader/ReaderToolbar';
import { BilingualViewer } from '../components/reader/BilingualViewer';
import { ReaderNotesSidebar } from '../components/reader/ReaderNotesSidebar';
interface ReaderPanelProps {
selectedPaper: StandardPaper;
library: StandardPaper[];
recentlySelected: StandardPaper[];
onSwitchPaper: (paper: StandardPaper) => void;
parsing: boolean;
handleParse: (bibcode: string, force?: boolean) => void;
translating: boolean;
handleTranslate: (bibcode: string, force?: boolean) => void;
vectorizing: boolean;
handleVectorize: (bibcode: string) => void;
showNotesPanel: boolean;
setShowNotesPanel: (show: boolean) => void;
notes: NoteRecord[];
englishText: string;
chineseText: string;
handleTextSelection: (paragraphIdx: number) => void;
selectedParagraphIdx: number | null;
setSelectedParagraphIdx: (idx: number | null) => void;
selectedText: string;
setSelectedText: (text: string) => void;
newNoteColor: string;
setNewNoteColor: (color: string) => void;
newNoteText: string;
setNewNoteText: (text: string) => void;
handleCreateNote: () => void;
handleDeleteNote: (id: number) => void;
showConfirm: (message: string, onConfirm: () => void, title?: string) => void;
onJumpToSource: (bibcode: string, paragraphIndex: number) => void;
}
export function ReaderPanel(props: ReaderPanelProps) {
const {
selectedPaper,
library,
recentlySelected,
onSwitchPaper,
parsing,
handleParse,
translating,
handleTranslate,
vectorizing,
handleVectorize,
showNotesPanel,
setShowNotesPanel,
notes,
englishText,
chineseText,
handleTextSelection,
selectedParagraphIdx,
setSelectedParagraphIdx,
selectedText,
setSelectedText,
newNoteColor,
setNewNoteColor,
newNoteText,
setNewNoteText,
handleCreateNote,
handleDeleteNote,
showConfirm,
onJumpToSource,
} = props;
// 局部管理阅读视角模式(原文/中文/对照)
const [viewMode, setViewMode] = useState<'bilingual' | 'english' | 'chinese'>(() => {
if (typeof window !== 'undefined' && window.innerWidth < 768) {
return chineseText ? 'chinese' : 'english';
}
if (!chineseText) {
return 'english';
}
return 'bilingual';
});
const [userOverrode, setUserOverrode] = useState(false);
// 监听窗口尺寸变化,处理移动端与桌面端的自适应视角切换
useEffect(() => {
if (typeof window === 'undefined') return;
let prevWidth = window.innerWidth;
const handleResize = () => {
const currentWidth = window.innerWidth;
const wasMobile = prevWidth < 768;
const isMobile = currentWidth < 768;
if (wasMobile !== isMobile) {
if (isMobile) {
// 从宽屏缩放到手机端:若有翻译则默认显示中文译文,否则原文
setViewMode(chineseText ? 'chinese' : 'english');
} else {
// 从手机端拉宽到桌面端/平板:若有翻译则显示双语对照,否则显示原文
setViewMode(chineseText ? 'bilingual' : 'english');
}
}
prevWidth = currentWidth;
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [chineseText]);
// 无翻译时强制回退到英文模式,有翻译时且在桌面端时自动切换到双语对照
useEffect(() => {
if (userOverrode) return;
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
if (isMobile) {
if (!chineseText && viewMode !== 'english') {
queueMicrotask(() => setViewMode('english'));
}
return;
}
if (!chineseText && viewMode === 'bilingual') {
queueMicrotask(() => setViewMode('english'));
} else if (chineseText && viewMode === 'english') {
queueMicrotask(() => setViewMode('bilingual'));
}
}, [chineseText, viewMode, userOverrode]);
const handleViewModeChange = (mode: 'bilingual' | 'english' | 'chinese') => {
setUserOverrode(true);
setViewMode(mode);
};
// 切换文献时重置用户手动覆盖标记
useEffect(() => {
queueMicrotask(() => setUserOverrode(false));
}, [selectedPaper?.bibcode]);
// 调用封装后的阅读器逻辑状态 Hook
const state = useReaderState({
selectedPaper,
englishText,
viewMode,
setViewMode: handleViewModeChange,
});
return (
<div className="w-full flex-1 flex flex-col space-y-4 min-h-0">
{/* 顶部控制栏 */}
<ReaderToolbar
selectedPaper={selectedPaper}
library={library}
recentlySelected={recentlySelected}
onSwitchPaper={onSwitchPaper}
parsing={parsing}
handleParse={handleParse}
translating={translating}
handleTranslate={handleTranslate}
vectorizing={vectorizing}
handleVectorize={handleVectorize}
showNotesPanel={showNotesPanel}
setShowNotesPanel={setShowNotesPanel}
notesCount={notes.length}
showConfirm={showConfirm}
englishText={englishText}
chineseText={chineseText}
showSwitchMenu={state.showSwitchMenu}
setShowSwitchMenu={state.setShowSwitchMenu}
viewMode={viewMode}
setViewMode={handleViewModeChange}
syncScroll={state.syncScroll}
setSyncScroll={state.setSyncScroll}
showPdf={state.showPdf}
/>
{/* 并排对比视窗与侧边栏 */}
<BilingualViewer
selectedPaper={selectedPaper}
parsing={parsing}
translating={translating}
englishText={englishText}
chineseText={chineseText}
notes={notes}
handleTextSelection={handleTextSelection}
showNotesPanel={showNotesPanel}
viewMode={viewMode}
showPdf={state.showPdf}
setShowPdf={state.setShowPdf}
englishRef={state.englishRef}
chineseRef={state.chineseRef}
handleEnglishScroll={state.handleEnglishScroll}
handleChineseScroll={state.handleChineseScroll}
handleMouseOver={state.handleMouseOver}
clearHoverTimeout={state.clearHoverTimeout}
handleContainerMouseLeave={state.handleContainerMouseLeave}
handleMouseLeaveTarget={state.handleMouseLeaveTarget}
highlightCandidates={state.highlightCandidates}
hoveredTarget={state.hoveredTarget}
hoverCardPos={state.hoverCardPos}
>
{/* 学术助手侧边栏 */}
<ReaderNotesSidebar
selectedPaper={selectedPaper}
showNotesPanel={showNotesPanel}
setShowNotesPanel={setShowNotesPanel}
notes={notes}
selectedParagraphIdx={selectedParagraphIdx}
setSelectedParagraphIdx={setSelectedParagraphIdx}
selectedText={selectedText}
setSelectedText={setSelectedText}
newNoteColor={newNoteColor}
setNewNoteColor={setNewNoteColor}
newNoteText={newNoteText}
setNewNoteText={setNewNoteText}
handleCreateNote={handleCreateNote}
handleDeleteNote={handleDeleteNote}
onJumpToSource={onJumpToSource}
sidebarTab={state.sidebarTab}
setSidebarTab={state.setSidebarTab}
targets={state.targets}
loadingTargets={state.loadingTargets}
identifyingTargets={state.identifyingTargets}
associatingTarget={state.associatingTarget}
manualTargetName={state.manualTargetName}
setManualTargetName={state.setManualTargetName}
handleIdentifyTargets={state.handleIdentifyTargets}
handleAssociateTarget={state.handleAssociateTarget}
handleJumpToTarget={state.handleJumpToTarget}
/>
</BilingualViewer>
</div>
);
}