feat: 手动上传绕防爬、下载错误诊断与健康检查工具;模块化重构 API 与批量同步
后端:
- 将 handlers.rs (1338行) 拆分为 helpers/papers/notes/sync 四模块
- 将 batch_sync.rs 拆分为 batch/{mod,meta,asset} 三模块
- 新增 POST /api/upload 多部件文件上传接口
- 新增 POST /api/no_resource 标记文献"无全文资源"
- 新增 GET/POST /api/active_bibcode 追踪活跃文献
- StandardPaper 结构体扩展 pdf_error / html_error 错误诊断字段
- download.rs 记录下载失败详情至数据库
- 新增 health_check 二进制工具,支持只读扫描与 --fix 自动修复
- 移除 scratch/ 目录、recovered_handlers.rs 及调试日志
前端:
- 新建 CustomSelect 可复用组件,替换全部原生 select
- LibraryPanel:同步按钮反馈动画、下载失败/无资源状态筛选与计数、
文献类型筛选、状态优先排序、搜索一键清空
- 详情弹窗:错误诊断展示、手动 PDF/HTML 上传区、无资源标记/恢复
- SearchPanel:扩展文献类型徽章、下载失败状态提示
- SyncPanel:同步启动乐观 UI 更新、日志容器内自动滚动
- Tab 状态 localStorage 持久化、弹窗 z-index 修复
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
export interface SelectOption {
|
||||
value: string | number;
|
||||
label: React.ReactNode;
|
||||
}
|
||||
|
||||
interface CustomSelectProps {
|
||||
value: string | number;
|
||||
onChange: (value: any) => void;
|
||||
options: SelectOption[];
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function CustomSelect({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
className = '',
|
||||
disabled = false,
|
||||
}: CustomSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const selectedOption = options.find(opt => opt.value === value) || options[0];
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative inline-block ${className}`}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`w-full flex items-center justify-between gap-2 bg-white border border-slate-250 hover:border-slate-350 rounded-lg px-2.5 py-2 text-xs font-semibold text-slate-700 transition-all text-left outline-none cursor-pointer ${
|
||||
isOpen ? 'border-sky-500 ring-1 ring-sky-500 bg-white' : ''
|
||||
} ${disabled ? 'bg-slate-50 text-slate-450 cursor-not-allowed border-slate-200' : ''}`}
|
||||
>
|
||||
<span className="truncate">{selectedOption?.label}</span>
|
||||
<ChevronDown
|
||||
className={`w-3.5 h-3.5 text-slate-400 transition-transform duration-200 shrink-0 ${
|
||||
isOpen ? 'rotate-180 text-sky-500' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && !disabled && (
|
||||
<div className="absolute left-0 mt-1.5 w-full min-w-[150px] bg-white border border-slate-200 rounded-lg shadow-lg py-1 z-50 max-h-60 overflow-y-auto scrollbar-thin">
|
||||
{options.map(option => {
|
||||
const isSelected = option.value === value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-xs transition-colors block cursor-pointer outline-none ${
|
||||
isSelected
|
||||
? 'bg-sky-50 text-sky-700 font-bold'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 font-medium'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user