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} 对象格式
This commit is contained in:
parent
f885c0a4a8
commit
26d3b2fc02
@ -3,8 +3,15 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<!-- iOS PWA 独立全屏支持 -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Astro科研" />
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AstroResearch 天文科研辅助系统</title>
|
||||
<title>Astro科研</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
BIN
dashboard/public/logo.png
Normal file
BIN
dashboard/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 416 KiB |
17
dashboard/public/manifest.json
Normal file
17
dashboard/public/manifest.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"short_name": "Astro科研",
|
||||
"name": "Astro科研",
|
||||
"icons": [
|
||||
{
|
||||
"src": "logo.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
15
dashboard/public/sw.js
Normal file
15
dashboard/public/sw.js
Normal file
@ -0,0 +1,15 @@
|
||||
// dashboard/public/sw.js
|
||||
// 极简 PWA 通用 Service Worker,仅作请求透传,不作本地缓存以防破坏 SSE 及 RAG 的动态交互。
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// 直接透传,不拦截或缓存任何请求
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
@ -69,6 +69,7 @@ export default function App() {
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('astro_active_tab', activeTab);
|
||||
window.scrollTo(0, 0);
|
||||
}, [activeTab]);
|
||||
|
||||
// 3. 调用拆分后的业务 Hook
|
||||
@ -312,7 +313,7 @@ export default function App() {
|
||||
|
||||
// 5. 正常工作面板布局装配
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden text-slate-800 bg-slate-100 select-text">
|
||||
<div className="flex h-[100dvh] overflow-hidden text-slate-800 bg-slate-100 select-text">
|
||||
|
||||
{/* 导航左侧栏 */}
|
||||
<Sidebar
|
||||
@ -328,7 +329,7 @@ export default function App() {
|
||||
{/* 主工作区 */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden relative">
|
||||
{/* 移动端顶部 Header */}
|
||||
<header className="lg:hidden flex items-center justify-between px-4 py-3 bg-white border-b border-slate-200 select-none shrink-0 z-20">
|
||||
<header className="lg:hidden flex items-center justify-between px-4 pb-3 bg-white border-b border-slate-200 select-none shrink-0 z-20 pt-[calc(env(safe-area-inset-top,0px)+12px)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@ -355,7 +356,11 @@ export default function App() {
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 sm:p-6 md:p-8 relative w-full flex flex-col">
|
||||
<div className={`flex-1 relative w-full flex flex-col ${
|
||||
(activeTab === 'reader' || activeTab === 'citation' || activeTab === 'agent')
|
||||
? 'overflow-hidden p-0 sm:p-4 md:p-6 lg:p-8'
|
||||
: 'overflow-y-auto p-4 sm:p-6 md:p-8'
|
||||
}`}>
|
||||
<div className={`w-full flex-1 flex flex-col min-h-0 ${
|
||||
(activeTab === 'reader' || activeTab === 'citation' || activeTab === 'agent') ? 'max-w-none' : 'max-w-7xl mx-auto'
|
||||
}`}>
|
||||
|
||||
@ -88,7 +88,7 @@ export function PaperCard({
|
||||
</div>
|
||||
|
||||
{headerActions && (
|
||||
<div className="flex gap-2 shrink-0 items-center">
|
||||
<div className="flex flex-wrap gap-2 shrink-0 items-center">
|
||||
{headerActions}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -17,7 +17,13 @@ export function useAutoScroll(deps: React.DependencyList): UseAutoScrollReturn {
|
||||
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
|
||||
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
|
||||
chatEndRef.current?.scrollIntoView({ behavior });
|
||||
const el = scrollContainerRef.current;
|
||||
if (el) {
|
||||
el.scrollTo({
|
||||
top: el.scrollHeight,
|
||||
behavior,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -129,27 +129,47 @@ export function BilingualViewer({
|
||||
hoverCardPos,
|
||||
children,
|
||||
}: BilingualViewerProps) {
|
||||
// 监测屏幕宽度以判断是否在移动端/平板设备上(宽度 < 1024px)
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(() => {
|
||||
// 监测屏幕宽度以判断是否在平板设备上(宽度 < 1024px)
|
||||
const [isTabletViewport, setIsTabletViewport] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.innerWidth < 1024;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// 检测是否在手机端(宽度 < 768px)
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.innerWidth < 768;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobileViewport(window.innerWidth < 1024);
|
||||
setIsTabletViewport(window.innerWidth < 1024);
|
||||
setIsMobileViewport(window.innerWidth < 768);
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
const gridTemplateColumns = isMobileViewport
|
||||
? '1fr' // 手机端一律垂直单列堆叠
|
||||
: (isTabletViewport
|
||||
? (viewMode === 'bilingual' ? '1fr 1fr' : '1fr')
|
||||
: (viewMode === 'bilingual'
|
||||
? (showNotesPanel ? '1fr 1fr 380px' : '1fr 1fr')
|
||||
: (showNotesPanel ? '1fr 380px' : '1fr'));
|
||||
: (showNotesPanel ? '1fr 380px' : '1fr')));
|
||||
|
||||
const style = isMobileViewport
|
||||
? {
|
||||
gridTemplateColumns: '1fr',
|
||||
gridTemplateRows: viewMode === 'bilingual'
|
||||
? (showNotesPanel ? '1fr 1fr 1fr' : '1fr 1fr')
|
||||
: (showNotesPanel ? '1fr 1fr' : '1fr')
|
||||
}
|
||||
: { gridTemplateColumns };
|
||||
|
||||
// 解析英文和中文段落的 Front Matter 头部元数据
|
||||
const { metadata: engMeta, pureMarkdown: engPure } = parseMarkdownFrontMatter(englishText);
|
||||
@ -173,7 +193,7 @@ export function BilingualViewer({
|
||||
return (
|
||||
<div
|
||||
className="flex-1 grid gap-6 overflow-hidden w-full min-h-0"
|
||||
style={{ gridTemplateColumns }}
|
||||
style={style}
|
||||
>
|
||||
{/* 英文正文视窗 (或双语对照) */}
|
||||
{(viewMode === 'english' || viewMode === 'bilingual') && (
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
// dashboard/src/components/reader/ReaderToolbar.tsx
|
||||
import { FileText, Loader, Languages, RotateCw, BookOpen, Sparkles, ChevronDown, History } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { FileText, Loader, Languages, RotateCw, BookOpen, Sparkles, ChevronDown, History, SlidersHorizontal } from 'lucide-react';
|
||||
import type { StandardPaper } from '../../types';
|
||||
|
||||
interface ReaderToolbarProps {
|
||||
@ -56,6 +57,8 @@ export function ReaderToolbar({
|
||||
setSyncScroll,
|
||||
showPdf,
|
||||
}: ReaderToolbarProps) {
|
||||
const [showActionsMenu, setShowActionsMenu] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col xl:flex-row xl:items-center xl:justify-between border-b border-slate-200 pb-3 shrink-0 gap-3">
|
||||
<div className="min-w-0 w-full xl:flex-1 pr-0 xl:pr-4">
|
||||
@ -68,23 +71,24 @@ export function ReaderToolbar({
|
||||
<span>文献编码: {selectedPaper.bibcode}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 items-center relative w-full xl:w-auto">
|
||||
<div className="flex flex-row items-center justify-between sm:justify-start gap-1.5 sm:gap-2 relative w-full xl:w-auto shrink-0 flex-nowrap">
|
||||
{/* 快速切换文献菜单 */}
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSwitchMenu(!showSwitchMenu)}
|
||||
className="btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2 transition-all cursor-pointer shrink-0"
|
||||
className="btn-console btn-console-secondary px-2 sm:px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1 sm:gap-2 transition-all cursor-pointer shrink-0"
|
||||
title="快速切换文献"
|
||||
>
|
||||
<BookOpen className="w-3.5 h-3.5 text-sky-600" />
|
||||
<span>快速切换</span>
|
||||
<span className="hidden sm:inline">快速切换</span><span className="sm:hidden">最近</span>
|
||||
<ChevronDown className={`w-3 h-3 transition-transform ${showSwitchMenu ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{showSwitchMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-45" onClick={() => setShowSwitchMenu(false)} />
|
||||
<div className="absolute right-0 mt-1.5 w-72 rounded-md bg-white border border-slate-200 shadow-md py-1.5 z-50 text-xs max-h-96 overflow-y-auto scrollbar-thin">
|
||||
<div className="absolute left-0 xl:right-0 xl:left-auto mt-1.5 w-72 rounded-md bg-white border border-slate-200 shadow-md py-1 z-50 text-xs max-h-96 overflow-y-auto scrollbar-thin">
|
||||
{/* 最近阅读部分 */}
|
||||
{recentlySelected.length > 0 && (
|
||||
<div className="border-b border-slate-100 pb-1.5 mb-1.5">
|
||||
@ -149,11 +153,11 @@ export function ReaderToolbar({
|
||||
|
||||
{/* 原文/中文/对照视图切换按钮 */}
|
||||
{(englishText || chineseText) && (
|
||||
<div className="flex gap-1 bg-slate-100 p-0.5 rounded-lg border border-slate-200">
|
||||
<div className="flex gap-0.5 sm:gap-1 bg-slate-100 p-0.5 rounded-lg border border-slate-200 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('english')}
|
||||
className={`px-2.5 py-1 rounded-md text-[10px] sm:text-xs font-bold transition-all cursor-pointer ${
|
||||
className={`px-1.5 sm:px-2.5 py-1 rounded-md text-[10px] sm:text-xs font-bold transition-all cursor-pointer ${
|
||||
viewMode === 'english'
|
||||
? 'bg-white text-slate-800 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
@ -164,7 +168,7 @@ export function ReaderToolbar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('chinese')}
|
||||
className={`px-2.5 py-1 rounded-md text-[10px] sm:text-xs font-bold transition-all cursor-pointer ${
|
||||
className={`px-1.5 sm:px-2.5 py-1 rounded-md text-[10px] sm:text-xs font-bold transition-all cursor-pointer ${
|
||||
viewMode === 'chinese'
|
||||
? 'bg-white text-slate-800 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
@ -175,7 +179,7 @@ export function ReaderToolbar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('bilingual')}
|
||||
className={`hidden lg:block px-2.5 py-1 rounded-md text-[10px] sm:text-xs font-bold transition-all cursor-pointer ${
|
||||
className={`px-1.5 sm:px-2.5 py-1 rounded-md text-[10px] sm:text-xs font-bold transition-all cursor-pointer ${
|
||||
viewMode === 'bilingual'
|
||||
? 'bg-white text-slate-800 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
@ -197,12 +201,12 @@ export function ReaderToolbar({
|
||||
}
|
||||
setSyncScroll(!syncScroll);
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-1.5 border transition-all cursor-pointer ${
|
||||
className={`hidden xl:flex px-3 py-1.5 rounded-lg text-xs font-bold items-center gap-1.5 border transition-all cursor-pointer ${
|
||||
showPdf
|
||||
? 'bg-slate-50 text-slate-400 border-slate-200 cursor-not-allowed opacity-70'
|
||||
: syncScroll
|
||||
? 'bg-sky-50 text-sky-700 border-sky-200 shadow-sm'
|
||||
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'
|
||||
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-55'
|
||||
}`}
|
||||
title={showPdf ? 'PDF预览模式下不支持同步滚动,请点击左侧“显示正文”切换到正文模式' : '开启或关闭中英段落双轨同步滚动'}
|
||||
>
|
||||
@ -216,7 +220,7 @@ export function ReaderToolbar({
|
||||
<button
|
||||
onClick={() => handleParse(selectedPaper.bibcode)}
|
||||
disabled={parsing}
|
||||
className="btn-console btn-console-primary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
|
||||
className="hidden xl:flex btn-console btn-console-primary px-4 py-2 rounded-lg text-xs font-bold items-center gap-2"
|
||||
>
|
||||
{parsing ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <FileText className="w-3.5 h-3.5" />}
|
||||
{parsing ? '正文结构解析中...' : '解析源文正文'}
|
||||
@ -229,7 +233,7 @@ export function ReaderToolbar({
|
||||
}, '确认重新解析');
|
||||
}}
|
||||
disabled={parsing}
|
||||
className="btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
|
||||
className="hidden xl:flex btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold items-center gap-2"
|
||||
title="覆盖本地解析结果,重新从 HTML/PDF 转换为 Markdown"
|
||||
>
|
||||
{parsing ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RotateCw className="w-3.5 h-3.5" />}
|
||||
@ -242,7 +246,7 @@ export function ReaderToolbar({
|
||||
<button
|
||||
onClick={() => handleTranslate(selectedPaper.bibcode)}
|
||||
disabled={translating}
|
||||
className="btn-console btn-console-primary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
|
||||
className="hidden xl:flex btn-console btn-console-primary px-4 py-2 rounded-lg text-xs font-bold items-center gap-2"
|
||||
>
|
||||
{translating ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <Languages className="w-3.5 h-3.5" />}
|
||||
{translating ? '天文学术语对比翻译中...' : '生成智能翻译'}
|
||||
@ -253,7 +257,7 @@ export function ReaderToolbar({
|
||||
<button
|
||||
onClick={() => handleTranslate(selectedPaper.bibcode, true)}
|
||||
disabled={translating}
|
||||
className="btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
|
||||
className="hidden xl:flex btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold items-center gap-2"
|
||||
title="清除翻译缓存并重新生成大模型对照翻译"
|
||||
>
|
||||
{translating ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RotateCw className="w-3.5 h-3.5" />}
|
||||
@ -266,7 +270,7 @@ export function ReaderToolbar({
|
||||
<button
|
||||
onClick={() => handleVectorize(selectedPaper.bibcode)}
|
||||
disabled={vectorizing}
|
||||
className="btn-console btn-console-primary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
|
||||
className="hidden xl:flex btn-console btn-console-primary px-4 py-2 rounded-lg text-xs font-bold items-center gap-2"
|
||||
title="对文献进行知识入库,以开启学术 AI 研讨"
|
||||
>
|
||||
{vectorizing ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />}
|
||||
@ -282,7 +286,7 @@ export function ReaderToolbar({
|
||||
}, '确认重新知识入库');
|
||||
}}
|
||||
disabled={vectorizing}
|
||||
className="btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
|
||||
className="hidden xl:flex btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold items-center gap-2"
|
||||
title="重新为该文献解析知识点并入库"
|
||||
>
|
||||
{vectorizing ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RotateCw className="w-3.5 h-3.5" />}
|
||||
@ -290,18 +294,155 @@ export function ReaderToolbar({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 移动端/平板端 折叠后的“文献操作”菜单 */}
|
||||
<div className="relative xl:hidden shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowActionsMenu(!showActionsMenu)}
|
||||
className="btn-console btn-console-secondary px-2 sm:px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1 sm:gap-2 transition-all cursor-pointer shrink-0"
|
||||
title="更多文献操作"
|
||||
>
|
||||
<SlidersHorizontal className="w-3.5 h-3.5 text-sky-600" />
|
||||
<span className="hidden sm:inline">文献操作</span><span className="sm:hidden">操作</span>
|
||||
<ChevronDown className={`w-3 h-3 transition-transform ${showActionsMenu ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{showActionsMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-45" onClick={() => setShowActionsMenu(false)} />
|
||||
<div className="absolute right-0 mt-1.5 w-max min-w-[145px] rounded-md bg-white border border-slate-200 shadow-md py-1 z-50 text-xs">
|
||||
|
||||
{/* 同步滚动 */}
|
||||
{(englishText || chineseText) && viewMode === 'bilingual' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (showPdf) {
|
||||
alert("PDF预览模式下不支持同步滚动");
|
||||
return;
|
||||
}
|
||||
setSyncScroll(!syncScroll);
|
||||
setShowActionsMenu(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-xs transition-colors flex items-center gap-2 font-medium cursor-pointer outline-none ${
|
||||
syncScroll
|
||||
? 'bg-blueprint/5 text-blueprint font-bold border-l-2 border-blueprint'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${(!showPdf && syncScroll) ? 'bg-sky-500 animate-pulse' : 'bg-slate-355'}`} />
|
||||
<span>同步滚动:{syncScroll ? '已开启' : '已关闭'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 解析正文 */}
|
||||
{!selectedPaper.has_markdown ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
handleParse(selectedPaper.bibcode);
|
||||
setShowActionsMenu(false);
|
||||
}}
|
||||
disabled={parsing}
|
||||
className="w-full text-left px-4 py-2 text-xs transition-colors flex items-center gap-2 text-slate-600 hover:bg-slate-50 hover:text-slate-900 font-medium cursor-pointer outline-none"
|
||||
>
|
||||
<FileText className="w-4 h-4 text-slate-400" />
|
||||
<span>{parsing ? '正文解析中...' : '解析源文正文'}</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowActionsMenu(false);
|
||||
showConfirm('确定要重新解析正文吗?这会覆盖本地已解析的 Markdown。', () => {
|
||||
handleParse(selectedPaper.bibcode, true);
|
||||
}, '确认重新解析');
|
||||
}}
|
||||
disabled={parsing}
|
||||
className="w-full text-left px-4 py-2 text-xs transition-colors flex items-center gap-2 text-slate-600 hover:bg-slate-50 hover:text-slate-900 font-medium cursor-pointer outline-none"
|
||||
>
|
||||
<RotateCw className="w-4 h-4 text-slate-400" />
|
||||
<span>重新解析正文</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 智能翻译 */}
|
||||
{selectedPaper.has_markdown && !selectedPaper.has_translation && (
|
||||
<button
|
||||
onClick={() => {
|
||||
handleTranslate(selectedPaper.bibcode);
|
||||
setShowActionsMenu(false);
|
||||
}}
|
||||
disabled={translating}
|
||||
className="w-full text-left px-4 py-2 text-xs transition-colors flex items-center gap-2 text-slate-600 hover:bg-slate-50 hover:text-slate-900 font-medium cursor-pointer outline-none"
|
||||
>
|
||||
<Languages className="w-4 h-4 text-slate-400" />
|
||||
<span>{translating ? '翻译中...' : '生成智能翻译'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{selectedPaper.has_translation && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowActionsMenu(false);
|
||||
showConfirm('确定要重新进行智能翻译吗?这会覆盖本地已缓存的翻译结果。', () => {
|
||||
handleTranslate(selectedPaper.bibcode, true);
|
||||
}, '确认重新翻译');
|
||||
}}
|
||||
disabled={translating}
|
||||
className="w-full text-left px-4 py-2 text-xs transition-colors flex items-center gap-2 text-slate-600 hover:bg-slate-50 hover:text-slate-900 font-medium cursor-pointer outline-none"
|
||||
>
|
||||
<RotateCw className="w-4 h-4 text-slate-400" />
|
||||
<span>重新生成翻译</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 知识入库 */}
|
||||
{selectedPaper.has_markdown && !selectedPaper.has_vector && (
|
||||
<button
|
||||
onClick={() => {
|
||||
handleVectorize(selectedPaper.bibcode);
|
||||
setShowActionsMenu(false);
|
||||
}}
|
||||
disabled={vectorizing}
|
||||
className="w-full text-left px-4 py-2 text-xs transition-colors flex items-center gap-2 text-slate-600 hover:bg-slate-50 hover:text-slate-900 font-medium cursor-pointer outline-none"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 text-amber-500" />
|
||||
<span>{vectorizing ? '知识入库中...' : '知识入库'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{selectedPaper.has_markdown && selectedPaper.has_vector && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowActionsMenu(false);
|
||||
showConfirm('确定要重新进行知识入库吗?这会清除先前该文献已有的知识点记录并重新写入。', () => {
|
||||
handleVectorize(selectedPaper.bibcode);
|
||||
}, '确认重新知识入库');
|
||||
}}
|
||||
disabled={vectorizing}
|
||||
className="w-full text-left px-4 py-2 text-xs transition-colors flex items-center gap-2 text-slate-600 hover:bg-slate-50 hover:text-slate-900 font-medium cursor-pointer outline-none"
|
||||
>
|
||||
<RotateCw className="w-4 h-4 text-slate-400" />
|
||||
<span>重新知识入库</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 4. 学术助手按钮 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowNotesPanel(!showNotesPanel);
|
||||
}}
|
||||
className={`btn-console px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2 transition-all cursor-pointer ${
|
||||
className={`btn-console px-2 sm:px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1 sm:gap-2 transition-all cursor-pointer shrink-0 ${
|
||||
showNotesPanel ? 'bg-sky-50 text-sky-700 border-sky-200 shadow-xs' : 'btn-console-secondary'
|
||||
}`}
|
||||
title="开启或关闭侧边学术助手(包含阅读手札与 AI 问答)"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-amber-500" />
|
||||
<span>学术助手</span>
|
||||
<span className="hidden sm:inline">学术助手</span><span className="sm:hidden">助手</span>
|
||||
{notesCount > 0 && (
|
||||
<span className="px-1.5 py-0.2 text-[9px] rounded-full bg-sky-100 text-sky-850 font-extrabold select-none shrink-0">
|
||||
{notesCount}
|
||||
|
||||
@ -1,20 +1,23 @@
|
||||
// dashboard/src/components/sync/BookmarkletToolCard.tsx
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
// 书签代码常量
|
||||
const BOOKMARKLET_CODE = `javascript:(async function(){try{let defaultBib='';try{const res=await fetch('http://localhost:8000/api/active_bibcode',{credentials:'include'});if(res.ok){const data=await res.json();if(data&&data.bibcode)defaultBib=data.bibcode;}}catch(e){}const b=prompt('请输入文献的 Bibcode / doi / arxiv_id :',defaultBib);if(!b||!b.trim())return;const bib=b.trim();if(window.location.protocol==='file:'){alert('[ERR] 浏览器安全策略限制:书签脚本无法直接读取本地磁盘 file://。\\n\\n提示:对于本地 PDF/HTML 文件,请直接在 AstroResearch 的文献详情页点击“上传 PDF/HTML”按钮导入。');return;}let blob,type='html',ext='.html';const isPDF=document.contentType==='application/pdf'||window.location.pathname.toLowerCase().endsWith('.pdf')||document.title.toLowerCase().endsWith('.pdf');if(isPDF){try{const res=await fetch(window.location.href);if(!res.ok)throw new Error('HTTP '+res.status);blob=await res.blob();type='pdf';ext='.pdf';}catch(err){alert('[ERR] 无法读取该 PDF 数据。\\n(错误: '+err.message+')');return;}}else{blob=new Blob([document.documentElement.outerHTML],{type:'text/html'});}const fd=new FormData();fd.append('bibcode',bib);fd.append('type',type);fd.append('file',blob,bib+ext);const r=await fetch('http://localhost:8000/api/upload',{method:'POST',body:fd,credentials:'include'});if(r.ok){const d=await r.json();alert('[OK] '+(d.title||bib));}else{const t=await r.text();alert('[ERR '+r.status+'] '+t);}}catch(e){alert('[FAIL] '+e.message);}})();void(0);`;
|
||||
|
||||
export function BookmarkletToolCard() {
|
||||
const linkRef = useRef<HTMLAnchorElement | null>(null);
|
||||
|
||||
const getBookmarkletCode = () => {
|
||||
const origin = window.location.origin;
|
||||
const key = localStorage.getItem('bookmarklet_key') || '';
|
||||
return `javascript:(async function(){try{let defaultBib='';try{const res=await fetch('${origin}/api/active_bibcode',{headers:{'Authorization':'Bearer ${key}'}});if(res.ok){const data=await res.json();if(data&&data.bibcode)defaultBib=data.bibcode;}}catch(e){}const b=prompt('请输入文献的 Bibcode / doi / arxiv_id :',defaultBib);if(!b||!b.trim())return;const bib=b.trim();if(window.location.protocol==='file:'){alert('[ERR] 浏览器安全策略限制:书签脚本无法直接读取本地磁盘 file://。\\n\\n提示:对于本地 PDF/HTML 文件,请直接在 AstroResearch 的文献详情页点击“上传 PDF/HTML”按钮导入。');return;}let blob,type='html',ext='.html';const isPDF=document.contentType==='application/pdf'||window.location.pathname.toLowerCase().endsWith('.pdf')||document.title.toLowerCase().endsWith('.pdf');if(isPDF){try{const res=await fetch(window.location.href);if(!res.ok)throw new Error('HTTP '+res.status);blob=await res.blob();type='pdf';ext='.pdf';}catch(err){alert('[ERR] 无法读取该 PDF 数据。\\n(错误: '+err.message+')');return;}}else{blob=new Blob([document.documentElement.outerHTML],{type:'text/html'});}const fd=new FormData();fd.append('bibcode',bib);fd.append('type',type);fd.append('file',blob,bib+ext);const r=await fetch('${origin}/api/upload',{method:'POST',body:fd,headers:{'Authorization':'Bearer ${key}'}});if(r.ok){const d=await r.json();alert('[OK] '+(d.title||bib));}else{const t=await r.text();alert('[ERR '+r.status+'] '+t);}}catch(e){alert('[FAIL] '+e.message);}})();void(0);`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (linkRef.current) {
|
||||
linkRef.current.setAttribute('href', BOOKMARKLET_CODE);
|
||||
linkRef.current.setAttribute('href', getBookmarkletCode());
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCopyCode = () => {
|
||||
navigator.clipboard.writeText(BOOKMARKLET_CODE);
|
||||
navigator.clipboard.writeText(getBookmarkletCode());
|
||||
alert('书签代码已成功复制到剪贴板!');
|
||||
};
|
||||
|
||||
|
||||
@ -33,13 +33,24 @@ export function useAuth() {
|
||||
setLoggingIn(true);
|
||||
setLoginError(null);
|
||||
try {
|
||||
await axios.post('/api/auth/login', { password });
|
||||
const res = await axios.post('/api/auth/login', { password });
|
||||
if (res.data && res.data.bookmarklet_key) {
|
||||
localStorage.setItem('bookmarklet_key', res.data.bookmarklet_key);
|
||||
}
|
||||
setIsAuthenticated(true);
|
||||
setLoginError(null);
|
||||
} catch (err: unknown) {
|
||||
console.error('登录校验失败:', err);
|
||||
const axiosError = err as { response?: { data?: string } };
|
||||
const errMsg = axiosError.response?.data || '密码错误,请重试。';
|
||||
const axiosError = err as { response?: { data?: string | { error?: string } } };
|
||||
let errMsg = '密码错误,请重试。';
|
||||
if (axiosError.response?.data) {
|
||||
const data = axiosError.response.data;
|
||||
if (typeof data === 'string') {
|
||||
errMsg = data;
|
||||
} else if (typeof data === 'object' && data !== null && 'error' in data) {
|
||||
errMsg = data.error || errMsg;
|
||||
}
|
||||
}
|
||||
setLoginError(errMsg);
|
||||
} finally {
|
||||
setLoggingIn(false);
|
||||
@ -52,6 +63,7 @@ export function useAuth() {
|
||||
} catch (e) {
|
||||
console.error('登出失败:', e);
|
||||
}
|
||||
localStorage.removeItem('bookmarklet_key');
|
||||
setIsAuthenticated(false);
|
||||
setPassword('');
|
||||
};
|
||||
|
||||
@ -3,6 +3,15 @@ import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
// 注册 PWA Service Worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(reg => console.log('Service Worker registered with scope:', reg.scope))
|
||||
.catch(err => console.error('Service Worker registration failed:', err));
|
||||
});
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
@ -71,8 +71,8 @@ export function ReaderPanel(props: ReaderPanelProps) {
|
||||
|
||||
// 局部管理阅读视角模式(原文/中文/对照)
|
||||
const [viewMode, setViewMode] = useState<'bilingual' | 'english' | 'chinese'>(() => {
|
||||
if (typeof window !== 'undefined' && window.innerWidth < 1024) {
|
||||
return 'english';
|
||||
if (typeof window !== 'undefined' && window.innerWidth < 768) {
|
||||
return chineseText ? 'chinese' : 'english';
|
||||
}
|
||||
if (!chineseText) {
|
||||
return 'english';
|
||||
@ -90,15 +90,15 @@ export function ReaderPanel(props: ReaderPanelProps) {
|
||||
|
||||
const handleResize = () => {
|
||||
const currentWidth = window.innerWidth;
|
||||
const wasMobile = prevWidth < 1024;
|
||||
const isMobile = currentWidth < 1024;
|
||||
const wasMobile = prevWidth < 768;
|
||||
const isMobile = currentWidth < 768;
|
||||
|
||||
if (wasMobile !== isMobile) {
|
||||
if (isMobile) {
|
||||
// 从桌面缩放到移动端:切换显示英文原文
|
||||
setViewMode('english');
|
||||
// 从宽屏缩放到手机端:若有翻译则默认显示中文译文,否则原文
|
||||
setViewMode(chineseText ? 'chinese' : 'english');
|
||||
} else {
|
||||
// 从移动端拉宽到桌面端:若有翻译则显示双语对照,否则显示原文
|
||||
// 从手机端拉宽到桌面端/平板:若有翻译则显示双语对照,否则显示原文
|
||||
setViewMode(chineseText ? 'bilingual' : 'english');
|
||||
}
|
||||
}
|
||||
@ -112,9 +112,9 @@ export function ReaderPanel(props: ReaderPanelProps) {
|
||||
// 无翻译时强制回退到英文模式,有翻译时且在桌面端时自动切换到双语对照
|
||||
useEffect(() => {
|
||||
if (userOverrode) return;
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 1024;
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
if (isMobile) {
|
||||
if (viewMode !== 'english') {
|
||||
if (!chineseText && viewMode !== 'english') {
|
||||
queueMicrotask(() => setViewMode('english'));
|
||||
}
|
||||
return;
|
||||
|
||||
@ -12,7 +12,7 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
|
||||
const state = useResearchAgent({ showConfirm, showAlert });
|
||||
|
||||
return (
|
||||
<div className="w-full flex-1 flex overflow-hidden bg-slate-100 rounded-lg border border-slate-200 h-[calc(100vh-130px)] shadow-sm">
|
||||
<div className="w-full flex-1 flex overflow-hidden bg-slate-100 rounded-lg border border-slate-200 h-full lg:h-[calc(100vh-130px)] shadow-sm">
|
||||
{/* 左侧会话列表侧栏 */}
|
||||
<AgentSessionSidebar
|
||||
sessions={state.sessions}
|
||||
|
||||
@ -172,14 +172,14 @@ export function SearchPanel({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<div className="space-y-3">
|
||||
{rules.map((rule, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<div key={idx} className="flex flex-col sm:flex-row sm:items-center gap-2 pb-3.5 sm:pb-0 border-b border-slate-200/60 sm:border-0 last:border-0 last:pb-0">
|
||||
{idx > 0 ? (
|
||||
<CustomSelect
|
||||
value={rule.op}
|
||||
onChange={val => handleRuleChange(idx, 'op', String(val))}
|
||||
className="w-24"
|
||||
className="w-full sm:w-24"
|
||||
options={[
|
||||
{ value: 'AND', label: '并且 (AND)' },
|
||||
{ value: 'OR', label: '或者 (OR)' },
|
||||
@ -187,13 +187,13 @@ export function SearchPanel({
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 text-center text-xs text-slate-500 font-semibold">条件过滤</div>
|
||||
<div className="w-full sm:w-24 text-left sm:text-center text-[10px] sm:text-xs text-slate-400 sm:text-slate-500 font-bold sm:font-semibold py-1 uppercase tracking-wider select-none">条件过滤</div>
|
||||
)}
|
||||
|
||||
<CustomSelect
|
||||
value={rule.field}
|
||||
onChange={val => handleRuleChange(idx, 'field', String(val))}
|
||||
className="w-32"
|
||||
className="w-full sm:w-32"
|
||||
options={[
|
||||
{ value: 'all', label: '任意字段' },
|
||||
{ value: 'title', label: '标题名称' },
|
||||
@ -214,16 +214,16 @@ export function SearchPanel({
|
||||
? '例如: Althaus'
|
||||
: '请输入检索词...'
|
||||
}
|
||||
className="flex-1 px-3 py-1.5 rounded-md bg-white border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-slate-500 text-xs font-medium"
|
||||
className="w-full sm:flex-1 px-3 py-1.5 rounded-md bg-white border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-slate-500 text-xs font-medium"
|
||||
/>
|
||||
|
||||
{rules.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveRule(idx)}
|
||||
className="text-slate-600 hover:text-slate-800 text-xs font-bold px-2 py-1.5"
|
||||
className="text-rose-600 hover:text-rose-800 text-xs font-bold px-3 py-1.5 self-end sm:self-auto border border-rose-100 rounded-md sm:border-0 bg-rose-50/20 sm:bg-transparent mt-1 sm:mt-0 transition-all cursor-pointer"
|
||||
>
|
||||
删除
|
||||
删除条件
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -20,6 +20,7 @@ pub struct LoginRequest {
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub status: String,
|
||||
pub bookmarklet_key: String,
|
||||
}
|
||||
|
||||
// 辅助函数:从 Cookie 字符串中解析出 session_id 的值
|
||||
@ -93,6 +94,15 @@ fn prune_expired_sessions(
|
||||
});
|
||||
}
|
||||
|
||||
/// 计算浏览器书签专用的永久 API 密钥(基于 admin_password + salt 的 SHA-1 哈希)
|
||||
fn get_bookmarklet_api_key(password: &str) -> String {
|
||||
use sha1::{Digest, Sha1};
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(password.as_bytes());
|
||||
hasher.update(b"_bookmarklet_salt_key");
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
/// 最大会话数上限,超过后拒绝新登录以防止内存耗尽
|
||||
const MAX_SESSIONS: usize = 1000;
|
||||
|
||||
@ -154,10 +164,9 @@ pub async fn login(
|
||||
}
|
||||
|
||||
// 构建 Set-Cookie 报头
|
||||
// 书签脚本等跨站第三方页面调用要求 SameSite=None 和 Secure
|
||||
// localhost 在现代浏览器中即使使用 HTTP 协议,其 Secure Cookie 也会被正常放行并读取
|
||||
// 全局统一使用 SameSite=Lax 且不带 Secure,以完美支持 HTTP、HTTPS 以及局域网 IP 访问
|
||||
let cookie_value = format!(
|
||||
"session_id={}; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=86400",
|
||||
"session_id={}; HttpOnly; SameSite=Lax; Path=/; Max-Age=86400",
|
||||
token
|
||||
);
|
||||
let mut headers = HeaderMap::new();
|
||||
@ -165,10 +174,13 @@ pub async fn login(
|
||||
headers.insert(header::SET_COOKIE, val);
|
||||
}
|
||||
|
||||
let bookmarklet_key = get_bookmarklet_api_key(&state.config.admin_password);
|
||||
|
||||
Ok((
|
||||
headers,
|
||||
Json(LoginResponse {
|
||||
status: "ok".to_string(),
|
||||
bookmarklet_key,
|
||||
}),
|
||||
))
|
||||
} else {
|
||||
@ -195,7 +207,7 @@ pub async fn logout(
|
||||
|
||||
// 通过 Max-Age=0 清除浏览器端的 Cookie
|
||||
let mut headers = HeaderMap::new();
|
||||
let delete_cookie = "session_id=; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=0";
|
||||
let delete_cookie = "session_id=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0";
|
||||
if let Ok(val) = HeaderValue::from_str(delete_cookie) {
|
||||
headers.insert(header::SET_COOKIE, val);
|
||||
}
|
||||
@ -221,6 +233,7 @@ pub async fn auth_middleware(
|
||||
|
||||
let mut is_valid = false;
|
||||
if let Some(tok) = token {
|
||||
// 1. 优先校验内存中的动态 Session
|
||||
let mut sessions = state.sessions.lock().await;
|
||||
// 顺便执行过期会话自动清理垃圾收集
|
||||
prune_expired_sessions(&mut sessions);
|
||||
@ -230,6 +243,17 @@ pub async fn auth_middleware(
|
||||
*last_active = chrono::Utc::now();
|
||||
is_valid = true;
|
||||
}
|
||||
|
||||
// 2. 校验书签专属永久 API 密钥(严格隔离限制:仅允许用于特定的书签导入和同步路径)
|
||||
if !is_valid {
|
||||
let path = req.uri().path();
|
||||
if path == "/api/upload" || path == "/api/active_bibcode" {
|
||||
let expected_key = get_bookmarklet_api_key(&state.config.admin_password);
|
||||
if tok == expected_key {
|
||||
is_valid = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if is_valid {
|
||||
|
||||
68
src/main.rs
68
src/main.rs
@ -330,14 +330,36 @@ async fn main() -> anyhow::Result<()> {
|
||||
.allow_origin(tower_http::cors::AllowOrigin::predicate(
|
||||
|origin, _parts| {
|
||||
if let Ok(origin_str) = origin.to_str() {
|
||||
// 仅允许本地开发或回环地址(允许任意端口,支持 Vite dev 端口 :5173)
|
||||
if origin_str.starts_with("http://localhost:")
|
||||
|| origin_str.starts_with("http://127.0.0.1:")
|
||||
|| origin_str == "http://localhost"
|
||||
|| origin_str == "http://127.0.0.1"
|
||||
// 支持 http:// 或 https:// 协议下的本地回环及局域网私有网段 IP 来源(允许任意端口,如 Vite dev 端口 :5173)
|
||||
let host_part = if let Some(stripped) = origin_str.strip_prefix("http://") {
|
||||
Some(stripped)
|
||||
} else {
|
||||
origin_str.strip_prefix("https://")
|
||||
};
|
||||
|
||||
if let Some(host_and_port) = host_part {
|
||||
let host = host_and_port.split(':').next().unwrap_or("");
|
||||
if host == "localhost"
|
||||
|| host == "127.0.0.1"
|
||||
|| host == "[::1]"
|
||||
|| host.starts_with("192.168.")
|
||||
|| host.starts_with("10.")
|
||||
|| host.starts_with("169.254.")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if host.starts_with("172.") {
|
||||
if let Some(second_octet_str) =
|
||||
host.strip_prefix("172.").and_then(|s| s.split('.').next())
|
||||
{
|
||||
if let Ok(second_octet) = second_octet_str.parse::<u8>() {
|
||||
if (16..=31).contains(&second_octet) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
},
|
||||
@ -454,6 +476,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let protected_files = Router::new()
|
||||
.fallback_service(ServeDir::new(&config.library_dir))
|
||||
.layer(axum::middleware::from_fn(pdf_inline_middleware))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
astroresearch::api::auth::auth_middleware,
|
||||
@ -504,3 +527,38 @@ async fn main() -> anyhow::Result<()> {
|
||||
info!("服务已安全关闭。");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 针对 PDF 文件请求的中间件:强行设置 Content-Type 和 Content-Disposition 头,防止在移动端/手机浏览器上被强制下载。
|
||||
async fn pdf_inline_middleware(
|
||||
req: axum::http::Request<axum::body::Body>,
|
||||
next: axum::middleware::Next,
|
||||
) -> Result<axum::response::Response<axum::body::Body>, axum::http::StatusCode> {
|
||||
let path = req.uri().path().to_lowercase();
|
||||
let is_pdf = path.ends_with(".pdf");
|
||||
|
||||
let mut response = next.run(req).await;
|
||||
|
||||
if is_pdf && response.status().is_success() {
|
||||
// 获取文件名
|
||||
let filename = std::path::Path::new(&path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("document.pdf");
|
||||
|
||||
// 强行设置 Content-Type 为 application/pdf
|
||||
response.headers_mut().insert(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
axum::http::HeaderValue::from_static("application/pdf"),
|
||||
);
|
||||
|
||||
// 强行设置 Content-Disposition 为 inline
|
||||
let disposition_val = format!("inline; filename=\"{}\"", filename);
|
||||
if let Ok(hv) = axum::http::HeaderValue::from_str(&disposition_val) {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(axum::http::header::CONTENT_DISPOSITION, hv);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@ -317,7 +317,7 @@ impl MetaSync {
|
||||
|
||||
// 获取所有已存同步检索配置
|
||||
pub async fn list_queries(db: &SqlitePool) -> Result<Vec<SavedSyncQuery>, sqlx::Error> {
|
||||
let rows = sqlx::query("SELECT id, query, source, limit_count, datetime(last_run, 'localtime') FROM sync_queries ORDER BY last_run DESC")
|
||||
let rows = sqlx::query("SELECT id, query, source, limit_count, datetime(last_run, 'localtime') AS last_run FROM sync_queries ORDER BY last_run DESC")
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user