Files
readflow/src/ui/mod.rs
大麦 be5aac7d56 chore: 整理构建文件
- 移除备份文件
- 清理未跟踪文件
2026-03-10 22:21:17 +08:00

647 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! UI 模块
//!
//! ReadFlow Dioxus GUI 界面
#![allow(non_snake_case)]
use dioxus::prelude::*;
use crate::config::{ThemeMode, load};
use crate::library::{Library, LibraryItem};
use crate::core::document::DocumentEngine;
use std::path::PathBuf;
/// 选中的文件类型
#[derive(Debug, Clone, Copy, PartialEq, Default)]
enum FilterType {
#[default]
All,
Recent,
Pdf,
Epub,
Mobi,
Text,
}
/// 主应用组件
#[component]
fn App() -> Element {
// 加载配置
let config = load();
// 书库路径
let library_path = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("ReadFlow");
// 初始化书库(创建时自动扫描)
let mut library = use_signal(|| Library::new(library_path));
// 选中状态
let selected = use_signal(|| None::<String>);
// 过滤器
let mut filter = use_signal(|| FilterType::All);
// 搜索关键词
let mut query = use_signal(|| String::new());
// 设置面板显示
let mut show_settings = use_signal(|| false);
// 阅读器视图:显示打开的文档
let opened_document = use_signal(|| None::<String>);
// 主题
let is_dark = config.theme.mode == ThemeMode::Dark;
let theme_class = if is_dark { "dark" } else { "light" };
rsx! {
div {
class: "app-container {theme_class}",
// 侧边栏
div { class: "sidebar",
div { class: "sidebar-header",
h1 { "ReadFlow" }
div { class: "header-actions",
button {
class: "open-file-btn",
onclick: move |_| {
// 打开文件选择对话框
spawn(async move {
open_local_file(library, opened_document).await;
});
},
"📂 打开文件"
}
button {
class: "settings-btn",
onclick: move |_| show_settings.set(!show_settings()),
"⚙️"
}
}
}
// 搜索框
div { class: "search-box",
input {
r#type: "text",
placeholder: "搜索文件...",
value: "{query}",
oninput: move |e| query.set(e.value().to_string()),
}
}
// 过滤器
div { class: "filter-buttons",
button {
class: if filter() == FilterType::All { "active" },
onclick: move |_| filter.set(FilterType::All),
"全部"
}
button {
class: if filter() == FilterType::Recent { "active" },
onclick: move |_| filter.set(FilterType::Recent),
"最近"
}
button {
class: if filter() == FilterType::Pdf { "active" },
onclick: move |_| filter.set(FilterType::Pdf),
"PDF"
}
button {
class: if filter() == FilterType::Epub { "active" },
onclick: move |_| filter.set(FilterType::Epub),
"EPUB"
}
button {
class: if filter() == FilterType::Mobi { "active" },
onclick: move |_| filter.set(FilterType::Mobi),
"MOBI"
}
}
// 文件列表
div { class: "file-list",
// 获取并过滤文件列表(在渲染前准备好数据)
FileList {
items: get_filtered_items(library, filter(), query()),
selected: selected
}
}
}
// 主内容区
div { class: "main-content",
if let Some(doc_path) = opened_document() {
div { class: "reader",
h2 { "正在阅读:{doc_path}" }
DocumentViewer { path: doc_path }
}
} else if let Some(path) = selected() {
div { class: "reader",
h2 { "正在阅读: {path}" }
p { "阅读器功能开发中..." }
}
} else {
div { class: "welcome",
h2 { "欢迎使用 ReadFlow" }
p { "从左侧选择一个文件,或点击「📂 打开文件」按钮" }
p { "支持格式PDF, EPUB, MOBI, TXT, Markdown, 代码文件" }
}
}
}
// 设置面板
if show_settings() {
div { class: "modal-overlay",
div { class: "settings-panel",
h2 { "设置" }
button { class: "close-btn", onclick: move |_| show_settings.set(false), "×" }
div { class: "setting-item",
label { "主题" }
select {
value: "{config.theme.mode}",
option { value: "light", "浅色" }
option { value: "dark", "深色" }
}
}
}
}
}
}
}
}
/// 文件列表组件
#[component]
fn FileList(items: Vec<LibraryItem>, selected: Signal<Option<String>>) -> Element {
rsx! {
if items.is_empty() {
p { class: "empty", "暂无文件" }
} else {
for item in items {
FileItem { item: item, selected: selected }
}
}
}
}
/// 单个文件项组件
#[component]
fn FileItem(item: crate::library::LibraryItem, selected: Signal<Option<String>>) -> Element {
let is_selected = selected().as_ref().map(|s| s == &item.path).unwrap_or(false);
let item_path = item.path.clone();
rsx! {
div {
class: if is_selected { "file-item selected" } else { "file-item" },
onclick: move |_| {
selected.set(Some(item_path.clone()));
},
div { class: "file-icon", "{get_file_icon(&item.format)}" }
div { class: "file-info",
div { class: "file-title", "{item.title}" }
div { class: "file-meta",
span { class: "file-format", "{item.format}" }
span { "{item.format_file_size()}" }
}
}
}
}
}
/// 获取过滤后的文件列表
fn get_filtered_items(library: Signal<Library>, filter: FilterType, query: String) -> Vec<LibraryItem> {
let library_guard = library.read();
let all_items: Vec<LibraryItem> = library_guard.get_all_items().into_iter().cloned().collect();
// 搜索过滤
let filtered: Vec<_> = if query.is_empty() {
all_items
} else {
let q = query.to_lowercase();
all_items.into_iter()
.filter(|item| item.title.to_lowercase().contains(&q) || item.path.to_lowercase().contains(&q))
.collect()
};
// 类型过滤
match filter {
FilterType::All => filtered,
FilterType::Recent => {
let recent: Vec<&LibraryItem> = library_guard.get_recent(20);
let recent_paths: Vec<&str> = recent.iter().map(|i| i.path.as_str()).collect();
filtered.into_iter().filter(|item| recent_paths.contains(&item.path.as_str())).collect()
}
FilterType::Pdf => filtered.into_iter().filter(|item| item.format == "PDF").collect(),
FilterType::Epub => filtered.into_iter().filter(|item| item.format == "EPUB").collect(),
FilterType::Mobi => filtered.into_iter().filter(|item| item.format == "MOBI" || item.format == "AZW3").collect(),
FilterType::Text => filtered.into_iter().filter(|item| item.format == "TXT" || item.format == "Markdown").collect(),
}
}
/// 获取文件图标
fn get_file_icon(format: &str) -> &'static str {
match format {
"PDF" => "📕",
"EPUB" => "📙",
"MOBI" | "AZW3" => "📘",
"TXT" | "Markdown" => "📄",
_ => "📁",
}
}
/// 获取应用 CSS
fn get_app_css() -> &'static str {
r#"
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.app-container {
display: flex;
height: 100vh;
overflow: hidden;
}
.app-container.light {
background: #f7fafc;
color: #1a202c;
}
.app-container.dark {
background: #0f172a;
color: #f1f5f9;
}
.sidebar {
width: 280px;
background: #1e293b;
color: #ffffff;
display: flex;
flex-direction: column;
border-right: 1px solid #475569;
}
.app-container.light .sidebar {
background: #ffffff;
color: #1a202c;
border-right: #e2e8f0;
}
.sidebar-header {
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #475569;
}
.settings-btn {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
padding: 5px;
}
.search-box {
padding: 10px 20px;
}
.search-box input {
width: 100%;
padding: 8px 12px;
border: none;
border-radius: 6px;
background: #475569;
color: #ffffff;
}
.app-container.light .search-box input {
background: #f0f0f0;
color: #1a202c;
}
.filter-buttons {
display: flex;
flex-wrap: wrap;
gap: 5px;
padding: 0 10px 10px;
}
.filter-buttons button {
padding: 5px 10px;
border: none;
border-radius: 4px;
background: #475569;
color: #ffffff;
cursor: pointer;
font-size: 12px;
}
.app-container.light .filter-buttons button {
background: #e0e0e0;
color: #1a202c;
}
.filter-buttons button.active {
background: #e94560;
}
.file-list {
flex: 1;
overflow-y: auto;
padding: 10px;
}
.file-item {
display: flex;
align-items: center;
padding: 10px;
margin-bottom: 5px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.file-item:hover {
background: #475569;
}
.app-container.light .file-item:hover {
background: #f0f0f0;
}
.file-item.selected {
background: #e94560;
}
.file-icon {
font-size: 24px;
margin-right: 10px;
}
.file-info {
flex: 1;
overflow: hidden;
}
.file-title {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-meta {
font-size: 12px;
opacity: 0.7;
display: flex;
gap: 10px;
}
.main-content {
flex: 1;
padding: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.welcome, .reader {
text-align: center;
}
.welcome h2, .reader h2 {
margin-bottom: 20px;
}
.empty {
text-align: center;
opacity: 0.5;
padding: 20px;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
}
.settings-panel {
background: #1e293b;
color: #ffffff;
padding: 30px;
border-radius: 12px;
min-width: 300px;
position: relative;
}
.app-container.light .settings-panel {
background: #ffffff;
color: #1a202c;
}
.settings-panel h2 {
margin-bottom: 20px;
}
.close-btn {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
color: #ffffff;
font-size: 24px;
cursor: pointer;
}
.setting-item {
margin-bottom: 15px;
}
.setting-item label {
display: block;
margin-bottom: 5px;
}
.setting-item select {
width: 100%;
padding: 8px;
border-radius: 4px;
border: 1px solid #475569;
background: #475569;
color: #ffffff;
}
.app-container.light .setting-item select {
background: #f0f0f0;
color: #1a202c;
border-color: #e2e8f0;
}
.header-actions {
display: flex;
gap: 10px;
align-items: center;
}
.open-file-btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
background: #e94560;
color: #ffffff;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}
.open-file-btn:hover {
background: #ff6b6b;
}
.document-viewer {
width: 100%;
max-width: 900px;
text-align: left;
}
.doc-header {
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #e94560;
}
.doc-header h3 {
font-size: 24px;
margin-bottom: 10px;
}
.doc-meta {
font-size: 14px;
opacity: 0.7;
}
.doc-content {
background: var(--bg-secondary);
border-radius: 8px;
padding: 30px;
min-height: 400px;
}
.error-viewer {
text-align: center;
padding: 40px;
background: rgba(233, 69, 96, 0.1);
border-radius: 8px;
border: 1px solid #e94560;
}
.error-viewer h3 {
color: #e94560;
margin-bottom: 10px;
}
"#
}
/// 打开本地文件选择对话框并加载文件
async fn open_local_file(mut library: Signal<Library>, mut opened_document: Signal<Option<String>>) {
use rfd::FileDialog;
// 使用 rfd (Rust File Dialog) 打开文件选择器
let file = FileDialog::new()
.add_filter("文档", &["pdf", "epub", "mobi", "azw3", "txt", "md"])
.add_filter("代码文件", &["rs", "py", "js", "ts", "go", "java", "c", "cpp", "h", "css", "html", "json", "xml", "yaml", "yml", "toml", "sql", "sh", "bash", "zsh"])
.set_title("选择要打开的文件")
.pick_file();
if let Some(path) = file {
let path_str = path.to_string_lossy().to_string();
// 添加到书库(如果尚未存在)
let path_obj = std::path::Path::new(&path_str);
let _ = library.write().add_file(path_obj);
// 打开文档
opened_document.set(Some(path_str.clone()));
}
}
/// 文档查看器组件
#[component]
fn DocumentViewer(path: String) -> Element {
// 尝试加载文档
let engine = DocumentEngine::new();
match engine.open(&path) {
Ok(doc) => {
let format_str = format!("{:?}", doc.format);
let page_count = doc.metadata.page_count;
let size_str = format_size(doc.metadata.file_size);
rsx! {
div { class: "document-viewer",
div { class: "doc-header",
h3 { "{doc.title}" }
p { class: "doc-meta",
"格式:{format_str} | 页数:{page_count} | 大小:{size_str}"
}
}
div { class: "doc-content",
p { "文档已加载,阅读器渲染功能开发中..." }
p { "文件路径:{path}" }
}
}
}
}
Err(e) => {
let error_msg = e.to_string();
rsx! {
div { class: "error-viewer",
h3 { "❌ 打开文件失败" }
p { "{error_msg}" }
}
}
}
}
}
/// 格式化文件大小
fn format_size(size: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if size >= GB {
format!("{:.2} GB", size as f64 / GB as f64)
} else if size >= MB {
format!("{:.2} MB", size as f64 / MB as f64)
} else if size >= KB {
format!("{:.2} KB", size as f64 / KB as f64)
} else {
format!("{} B", size)
}
}
/// 启动 GUI
pub fn run() {
// 使用 Dioxus 启动
dioxus::launch(App);
}