feat: 增强搜索详情与索引稳定性

This commit is contained in:
chuan
2026-08-07 02:07:02 +08:00
parent d992d305b9
commit b898cfb01b
25 changed files with 607 additions and 203 deletions
+2
View File
@@ -24,3 +24,5 @@ opencodes/ 不参与构建的参考项目
Web 开发和构建方式见 [`web/README.md`](web/README.md)
开发环境可以直接运行 `scripts\run.bat` 在当前窗口同时启动 Rust 后端和 Web 前端 按一次 `Ctrl+C` 即可统一停止
任一服务异常退出时启动脚本会清理 Cargo Bun 及其子进程树 避免遗留 Vite 或后端进程
+10 -2
View File
@@ -168,12 +168,18 @@
- [x] 实现 `/contents/{content_key}` 内容变体接口
- [x] 使用 Bun Vue TypeScript Vite Tailwind CSS 和 shadcn-vue 建立 Web 基础环境
- [x] 实现简单现代并适配移动端的单页搜索界面
- [x] 接入搜索过滤排序分页详情内容变体和磁力链接复制
- [x] 接入搜索排序分页详情内容变体和磁力链接复制
- [x] 实现名称别名和文件路径的有限状态自动机正则搜索
- [x] 实现种子详情文件列表后端分页并限制浏览器单页节点数量
- [x] 保留种子详情顶部结构并使用 reka-ui 数字分页重构文件条目
- [x] 支持用户选择并持久化文件列表每页数量
- [x] 统一搜索结果数字分页并支持用户选择每页数量
- [x] 接入采集索引持久化和验证运行状态展示
- [x] 实现运行状态定时刷新和浏览器持久化明暗主题
- [x] 实现运行状态每秒刷新点击外部关闭和浏览器持久化明暗主题
- [x] 为加载空结果接口错误和失败重试提供明确界面状态
- [x] 将生产静态资源交给 Axum 提供并支持单页回退
- [x] 提供 Windows 一键启动后端和 Web 开发服务的脚本
- [x] 修复一键启动脚本只停止父进程导致 Vite 子进程残留的问题
- [x] 定义统一错误响应
- [x] 限制查询长度分页大小和最大 offset
- [ ] 增加请求延迟错误率和并发指标
@@ -240,6 +246,8 @@
- [x] 为 Metadata TCP 建连增加独立每秒速率限制
- [x] 将桌面默认配置调整为保守网络预算
- [x] 根据本机首次验证将主动 UDP 从 `40/s` 下调至 `10/s` 并将 Metadata 建连从 `5/s` 下调至 `2/s`
- [x] 对照 Bitmagnet 默认并发将运行模板调整为受全局预算约束的高吞吐配置
- [x] 修复 Windows 临时索引文件占用导致整个服务退出的问题
- [x] 验证极保守配置运行三分钟不影响同机代理网络并安全退出
- [x] 将 BEP-51 采样准入压力反向传递到采样查询调度
- [x] 实现样本来源节点单点 `get_peers` 优先和失败后有限递归降级
+16 -16
View File
@@ -1,27 +1,27 @@
# 定义 dht-search 的推荐起始配置并作为用户配置模板
data_dir = "data"
persistence_queue_capacity = 4096
persistence_queue_capacity = 8192
stats_interval_secs = 10
index_batch_size = 512
index_batch_size = 1024
index_interval_millis = 5000
[dht]
port = 12313
netmode = "ipv4-only"
hash_queue_capacity = 10000
max_outbound_queries_per_second = 10
outbound_query_burst = 2
metadata_timeout_secs = 4
metadata_queue_capacity = 10000
metadata_workers = 8
metadata_connects_per_second = 2
sample_queries_per_second = 1
peer_lookups_per_second = 1
peer_lookup_max_active = 4
find_node_queries_per_second = 6
find_node_max_in_flight = 12
new_destinations_per_minute = 60
hash_queue_capacity = 20000
max_outbound_queries_per_second = 50
outbound_query_burst = 10
metadata_timeout_secs = 6
metadata_queue_capacity = 20000
metadata_workers = 64
metadata_connects_per_second = 16
sample_queries_per_second = 10
peer_lookups_per_second = 8
peer_lookup_max_active = 32
find_node_queries_per_second = 30
find_node_max_in_flight = 64
new_destinations_per_minute = 1200
[http]
listen = "127.0.0.1:8080"
@@ -30,7 +30,7 @@ web_dir = "web/dist"
[verification]
enabled = true
queue_capacity = 10000
max_active = 2
max_active = 8
max_peer_attempts = 3
lease_secs = 60
poll_interval_millis = 250
+22 -15
View File
@@ -36,22 +36,22 @@ cargo run -p dht-search -- --data-dir D:\data\dht-search --run-duration-secs 360
生产 Web 页面需要先在 `web` 目录执行 `bun run build` Axum 会从 `http.web_dir` 提供构建结果
### 网络保护配置
### 当前运行模板网络配置
主动 DHT 查询共享 `max_outbound_queries_per_second` 总预算因此 `find_node` `get_peers``sample_infohashes` 的总发送速率不会各自叠加后失控
`dht-search.example.toml` 使用低于 Bitmagnet 默认并发的高吞吐配置 主动 DHT 查询共享 `max_outbound_queries_per_second` 总预算 因此 `find_node` `get_peers``sample_infohashes` 的总发送速率不会各自叠加后失控
| 配置项 | 保守默认值 | 作用 |
| 配置项 | 运行模板值 | 作用 |
|---|---:|---|
| `max_outbound_queries_per_second` | `10` | 三类主动 DHT UDP 查询的合计每秒速率 |
| `outbound_query_burst` | `2` | 空闲后允许立即消费的 UDP 查询数 |
| `find_node_queries_per_second` | `6` | `find_node` 自身速率上限 |
| `find_node_max_in_flight` | `12` | 同时等待响应的 `find_node` 数量 |
| `new_destinations_per_minute` | `60` | 每分钟首次探测的新 UDP 目标数量 |
| `peer_lookups_per_second` | `1` | 每秒启动的 infohash Peer 查找数量 |
| `peer_lookup_max_active` | `4` | 同时运行的 Peer 查找数量 |
| `sample_queries_per_second` | `1` | BEP-51 采样查询速率 |
| `metadata_workers` | `8` | 同时处理的 Metadata 任务数量 |
| `metadata_connects_per_second` | `2` | 每秒真正开始的 Peer TCP 连接数量 |
| `max_outbound_queries_per_second` | `50` | 三类主动 DHT UDP 查询的合计每秒速率 |
| `outbound_query_burst` | `10` | 空闲后允许立即消费的 UDP 查询数 |
| `find_node_queries_per_second` | `30` | `find_node` 自身速率上限 |
| `find_node_max_in_flight` | `64` | 同时等待响应的 `find_node` 数量 |
| `new_destinations_per_minute` | `1200` | 每分钟首次探测的新 UDP 目标数量 |
| `peer_lookups_per_second` | `8` | 每秒启动的 infohash Peer 查找数量 |
| `peer_lookup_max_active` | `32` | 同时运行的 Peer 查找数量 |
| `sample_queries_per_second` | `10` | BEP-51 采样查询速率 |
| `metadata_workers` | `64` | 同时处理的 Metadata 任务数量 |
| `metadata_connects_per_second` | `16` | 每秒真正开始的 Peer TCP 连接数量 |
Metadata 下载和可用性握手共用 `metadata_connects_per_second` 预算不会各自叠加
@@ -76,7 +76,9 @@ Metadata 下载和可用性握手共用 `metadata_connects_per_second` 预算不
热度是近期 DHT 发现强度最近出现时间和可连接 Peer 数的综合活跃度分数 不代表全球下载量
桌面网络不要在不了解路由器 NAT 和代理容量时大幅提高这些值
这组参数仍保留全局速率限制 如果同机代理或路由器再次出现不稳定应优先降低总 UDP 预算和 Metadata 建连速率
Windows 下索引每五秒批量提交 临时文件占用会自动指数退避重试且不会停止采集 HTTP 服务或丢失 RocksDB 待索引状态
### 采样去重和 Peer 查找
@@ -119,14 +121,17 @@ GET /stats
GET /search?q=ubuntu&offset=0&limit=20
GET /search?q=&min_size=1048576&max_size=10737418240&extension=mkv
GET /search?q=流浪地球&min_files=1&availability=active&heat=hot&sort=heat
GET /search?q=%5ES%5Cd%7B2%7DE%5Cd%7B2%7D&regex=true
GET /contents/{content_key}?offset=0&limit=20
GET /torrents/{infohash}
GET /torrents/{infohash}?file_offset=0&file_limit=100
```
`limit` 被限制在 1 到 100 之间且 `offset` 最大为 10000
搜索支持中文英文数字和文件名片段匹配
设置 `regex=true` 后查询文本作为不区分大小写的正则表达式匹配名称 别名和文件路径 正则最长 256 字节并由 Tantivy 有限状态自动机执行
过滤参数还包括 `min_files` `max_files` `first_seen_after` `first_seen_before` `last_seen_after` `last_seen_before` `availability``heat`
排序支持 `relevance` `latest` `oldest` `heat` `size_desc` `size_asc``discoveries`
@@ -137,6 +142,8 @@ GET /torrents/{infohash}
搜索响应包含 `heat``availability` 摘要 详情响应包含完整验证时间 Peer 数和连续失败次数
详情文件列表默认返回 100 条且单次最多 200 条 使用 `file_offset` 翻页避免超大种子一次向浏览器返回全部文件
## 数据恢复
RocksDB 是权威数据源而 Tantivy 是可重建索引
+23 -3
View File
@@ -16,7 +16,7 @@ use dht_search::{
use super::{
ApiState,
request::{ContentVariantsRequest, SearchRequest},
request::{ContentVariantsRequest, SearchRequest, TorrentRequest},
response::{
ContentVariantsResponse, ErrorResponse, StatsResponse, StatusResponse, TorrentResponse,
TorrentVariantResponse,
@@ -83,6 +83,9 @@ pub(crate) async fn search(
if request.q.len() > 512 {
return Err(ApiError::bad_request("查询文本不能超过 512 字节"));
}
if request.regex && request.q.len() > 256 {
return Err(ApiError::bad_request("正则表达式不能超过 256 字节"));
}
validate_range(request.min_size, request.max_size, "min_size", "max_size")?;
validate_range(
request.min_files,
@@ -103,7 +106,12 @@ pub(crate) async fn search(
"last_seen_before",
)?;
let mut query = request.q;
let content_key = if let Ok(info_hash) = InfoHash::from_str(query.trim()) {
let content_key = if !request.regex {
InfoHash::from_str(query.trim()).ok()
} else {
None
};
let content_key = if let Some(info_hash) = content_key {
let repository = state.repository.clone();
let record = tokio::task::spawn_blocking(move || repository.get(info_hash))
.await
@@ -126,6 +134,7 @@ pub(crate) async fn search(
let page = tokio::task::spawn_blocking(move || {
state.search.search_with(SearchOptions {
query,
regex: request.regex,
offset: request.offset,
limit: request.limit,
min_size: request.min_size,
@@ -203,7 +212,14 @@ pub(crate) async fn content_variants(
pub(crate) async fn torrent(
State(state): State<ApiState>,
Path(info_hash): Path<String>,
Query(request): Query<TorrentRequest>,
) -> Result<Json<TorrentResponse>, ApiError> {
if request.file_limit == 0 || request.file_limit > 200 {
return Err(ApiError::bad_request("file_limit 必须在 1 到 200 之间"));
}
if request.file_offset > 1_000_000 {
return Err(ApiError::bad_request("file_offset 不能超过 1000000"));
}
let verification = state.verification.clone();
let info_hash =
InfoHash::from_str(&info_hash).map_err(|error| ApiError::bad_request(error.to_string()))?;
@@ -217,7 +233,11 @@ pub(crate) async fn torrent(
.enqueue(vec![info_hash], VerificationPriority::High)
.await;
}
Ok(Json(record.into()))
Ok(Json(TorrentResponse::from_record(
record,
request.file_offset,
request.file_limit,
)))
}
pub(crate) struct ApiError {
+79 -5
View File
@@ -80,11 +80,17 @@ mod tests {
info_hash: "0101010101010101010101010101010101010101".into(),
magnet_link: String::new(),
name: "Example Movie".into(),
total_size: 42,
files: vec![FileInfo {
path: "movie.mkv".into(),
size: 42,
}],
total_size: 205,
files: (0..205)
.map(|index| FileInfo {
path: if index == 0 {
"movie.mkv".into()
} else {
format!("extras/{index}.txt")
},
size: 1,
})
.collect(),
piece_length: 16_384,
peers: vec!["127.0.0.1:6881".into()],
timestamp: 10,
@@ -144,6 +150,34 @@ mod tests {
assert_eq!(json["hits"][0]["variant_count"], 2);
assert_eq!(repository.verification_queue_len().unwrap(), 1);
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/search?q=example.%2Amovie&regex=true")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let json: serde_json::Value =
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
.unwrap();
assert_eq!(json["total"], 1);
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/search?q=%5B&regex=true")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let response = app
.clone()
.oneshot(
@@ -202,6 +236,7 @@ mod tests {
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let response = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/torrents/{}", InfoHash::from_bytes([1; 20])))
@@ -222,8 +257,47 @@ mod tests {
.starts_with("magnet:?xt=")
);
assert_eq!(json["files"][0]["path"], "movie.mkv");
assert_eq!(json["files"].as_array().unwrap().len(), 100);
assert_eq!(json["file_count"], 205);
assert_eq!(json["file_offset"], 0);
assert_eq!(json["file_limit"], 100);
assert_eq!(repository.verification_queue_len().unwrap(), 1);
let response = app
.clone()
.oneshot(
Request::builder()
.uri(format!(
"/torrents/{}?file_limit=0",
InfoHash::from_bytes([1; 20])
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let response = app
.clone()
.oneshot(
Request::builder()
.uri(format!(
"/torrents/{}?file_offset=200&file_limit=100",
InfoHash::from_bytes([1; 20])
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let json: serde_json::Value =
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
.unwrap();
assert_eq!(json["files"].as_array().unwrap().len(), 5);
assert_eq!(json["file_offset"], 200);
persistence.close_and_join().await.unwrap();
}
}
+14
View File
@@ -15,6 +15,8 @@ pub(crate) struct SearchRequest {
#[serde(default)]
pub(crate) q: String,
#[serde(default)]
pub(crate) regex: bool,
#[serde(default)]
pub(crate) offset: usize,
#[serde(default = "default_limit")]
pub(crate) limit: usize,
@@ -39,3 +41,15 @@ pub(crate) struct ContentVariantsRequest {
#[serde(default = "default_limit")]
pub(crate) limit: usize,
}
#[derive(Debug, Deserialize)]
pub(crate) struct TorrentRequest {
#[serde(default)]
pub(crate) file_offset: usize,
#[serde(default = "default_file_limit")]
pub(crate) file_limit: usize,
}
fn default_file_limit() -> usize {
100
}
+21 -3
View File
@@ -52,6 +52,9 @@ pub(crate) struct TorrentResponse {
pub(crate) magnet_link: String,
pub(crate) name: String,
pub(crate) total_size: u64,
pub(crate) file_count: usize,
pub(crate) file_offset: usize,
pub(crate) file_limit: usize,
pub(crate) files: Vec<TorrentFile>,
pub(crate) piece_length: u64,
pub(crate) content_key: String,
@@ -104,16 +107,31 @@ impl From<TorrentRecord> for TorrentVariantResponse {
}
}
impl From<TorrentRecord> for TorrentResponse {
fn from(record: TorrentRecord) -> Self {
impl TorrentResponse {
pub(crate) fn from_record(
record: TorrentRecord,
file_offset: usize,
file_limit: usize,
) -> Self {
let info_hash = record.info_hash.to_string();
let heat = record.heat(unix_timestamp());
let file_count = record.files.len();
let file_offset = file_offset.min(file_count);
let files = record
.files
.into_iter()
.skip(file_offset)
.take(file_limit)
.collect();
Self {
magnet_link: format!("magnet:?xt=urn:btih:{info_hash}"),
info_hash,
name: record.name,
total_size: record.total_size,
files: record.files,
file_count,
file_offset,
file_limit,
files,
piece_length: record.piece_length,
content_key: hex::encode(record.content_key),
first_seen: record.first_seen,
+46 -9
View File
@@ -254,6 +254,7 @@ async fn run_indexer(
) -> Result<(), String> {
let mut ticker = tokio::time::interval(interval);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut consecutive_retries = 0_u32;
loop {
tokio::select! {
_ = cancel.cancelled() => break,
@@ -261,11 +262,21 @@ async fn run_indexer(
let indexed = index_one_batch(repository.clone(), search.clone(), batch_size).await;
match indexed {
Ok(count) => {
consecutive_retries = 0;
if count > 0 {
tracing::debug!(count, "搜索索引已提交");
}
}
Err(error) => {
Err(IndexBatchError::Retryable(error)) => {
consecutive_retries = consecutive_retries.saturating_add(1);
let delay = index_retry_delay(consecutive_retries);
tracing::warn!(%error, retry = consecutive_retries, delay_ms = delay.as_millis(), "搜索索引遇到临时 I/O 错误");
tokio::select! {
_ = cancel.cancelled() => break,
_ = tokio::time::sleep(delay) => {}
}
}
Err(IndexBatchError::Fatal(error)) => {
let _ = fatal.send(error.clone());
return Err(error);
}
@@ -274,27 +285,53 @@ async fn run_indexer(
}
}
let mut shutdown_retries = 0_u32;
loop {
let count = index_one_batch(repository.clone(), search.clone(), batch_size).await?;
if count == 0 {
break;
match index_one_batch(repository.clone(), search.clone(), batch_size).await {
Ok(0) => break,
Ok(_) => shutdown_retries = 0,
Err(IndexBatchError::Retryable(error)) if shutdown_retries < 3 => {
shutdown_retries += 1;
let delay = index_retry_delay(shutdown_retries);
tracing::warn!(%error, retry = shutdown_retries, delay_ms = delay.as_millis(), "关闭前提交搜索索引时遇到临时 I/O 错误");
tokio::time::sleep(delay).await;
}
Err(IndexBatchError::Retryable(error)) => {
tracing::warn!(%error, "关闭前搜索索引仍被占用 待索引状态将在下次启动恢复");
break;
}
Err(IndexBatchError::Fatal(error)) => return Err(error),
}
}
Ok(())
}
enum IndexBatchError {
Retryable(String),
Fatal(String),
}
async fn index_one_batch(
repository: Arc<RocksTorrentRepository>,
search: SearchEngine,
batch_size: usize,
) -> Result<usize, String> {
) -> Result<usize, IndexBatchError> {
tokio::task::spawn_blocking(move || {
search
.index_pending(repository.as_ref(), batch_size, unix_timestamp())
.map_err(|error| error.to_string())
match search.index_pending(repository.as_ref(), batch_size, unix_timestamp()) {
Ok(count) => Ok(count),
Err(error) if error.is_retryable_io() => {
Err(IndexBatchError::Retryable(error.to_string()))
}
Err(error) => Err(IndexBatchError::Fatal(error.to_string())),
}
})
.await
.map_err(|error| error.to_string())?
.map_err(|error| IndexBatchError::Fatal(error.to_string()))?
}
fn index_retry_delay(attempt: u32) -> Duration {
let shift = attempt.saturating_sub(1).min(6);
Duration::from_millis((250_u64 << shift).min(10_000))
}
async fn monitor(
+74 -3
View File
@@ -12,7 +12,7 @@ use tantivy::{
Term,
collector::{Count, TopDocs},
directory::MmapDirectory,
query::{AllQuery, BooleanQuery, BoostQuery, Occur, Query, RangeQuery, TermQuery},
query::{AllQuery, BooleanQuery, BoostQuery, Occur, Query, RangeQuery, RegexQuery, TermQuery},
schema::{IndexRecordOption, Value},
tokenizer::{LowerCaser, NgramTokenizer, TextAnalyzer},
};
@@ -164,6 +164,8 @@ impl SearchEngine {
let query_text = options.query.trim();
if query_text.is_empty() || query_text == "*" {
clauses.push(Box::new(AllQuery));
} else if options.regex {
clauses.push(regex_query(query_text, fields)?);
} else {
clauses.push(text_query(query_text, fields));
}
@@ -351,9 +353,12 @@ fn document(group: &ContentGroup, fields: SearchFields) -> TantivyDocument {
let mut document = TantivyDocument::default();
document.add_text(fields.info_hash, record.info_hash.to_string());
document.add_text(fields.name, normalize_bounded(&record.name, 512));
document.add_text(fields.regex_text, normalize_bounded(&record.name, 512));
document.add_text(fields.display_name, &record.name);
for alias in &group.aliases {
document.add_text(fields.aliases, normalize_bounded(alias, 512));
let alias = normalize_bounded(alias, 512);
document.add_text(fields.aliases, &alias);
document.add_text(fields.regex_text, alias);
}
let mut indexed_path_bytes = 0_usize;
for file in record.files.iter().take(MAX_INDEXED_FILES) {
@@ -362,7 +367,8 @@ fn document(group: &ContentGroup, fields: SearchFields) -> TantivyDocument {
break;
}
indexed_path_bytes += path.len();
document.add_text(fields.files_text, path);
document.add_text(fields.files_text, &path);
document.add_text(fields.regex_text, path);
}
for extension in extensions(record) {
document.add_text(fields.extensions, extension);
@@ -390,6 +396,31 @@ fn document(group: &ContentGroup, fields: SearchFields) -> TantivyDocument {
document
}
fn regex_query(pattern: &str, fields: SearchFields) -> Result<Box<dyn Query>, SearchError> {
let mut pattern = pattern.to_lowercase();
let anchored_start = pattern.starts_with('^');
if anchored_start {
pattern.remove(0);
}
let anchored_end = pattern.ends_with('$')
&& pattern[..pattern.len() - 1]
.chars()
.rev()
.take_while(|character| *character == '\\')
.count()
.is_multiple_of(2);
if anchored_end {
pattern.pop();
}
let prefix = if anchored_start { "" } else { ".*" };
let suffix = if anchored_end { "" } else { ".*" };
let contains_pattern = format!("{prefix}({pattern}){suffix}");
Ok(Box::new(RegexQuery::from_pattern(
&contains_pattern,
fields.regex_text,
)?))
}
fn text_query(query: &str, fields: SearchFields) -> Box<dyn Query> {
let terms = query_terms(query);
if terms.is_empty() {
@@ -661,6 +692,46 @@ mod tests {
assert_eq!(engine.search("1080p", 0, 10).unwrap().total, 1);
}
#[test]
fn regex_matches_names_and_file_paths_case_insensitively() {
let directory = TempDir::new().unwrap();
let engine = SearchEngine::open(directory.path()).unwrap();
index_records(&engine, &[record()]);
let name = engine
.search_with(SearchOptions {
query: r"ubuntu\s+linux\s+24\.0[0-9]".into(),
regex: true,
limit: 10,
..SearchOptions::default()
})
.unwrap();
assert_eq!(name.total, 1);
let path = engine
.search_with(SearchOptions {
query: r"ubuntu\.(iso|img)$".into(),
regex: true,
limit: 10,
..SearchOptions::default()
})
.unwrap();
assert_eq!(path.total, 1);
}
#[test]
fn invalid_regex_is_rejected() {
let directory = TempDir::new().unwrap();
let engine = SearchEngine::open(directory.path()).unwrap();
index_records(&engine, &[record()]);
let result = engine.search_with(SearchOptions {
query: "[".into(),
regex: true,
limit: 10,
..SearchOptions::default()
});
assert!(result.is_err());
}
#[test]
fn equal_content_is_collapsed_and_aliases_remain_searchable() {
let directory = TempDir::new().unwrap();
+45
View File
@@ -17,6 +17,22 @@ pub enum SearchError {
MissingField(&'static str),
}
impl SearchError {
pub fn is_retryable_io(&self) -> bool {
matches!(
self,
Self::Tantivy(tantivy::TantivyError::IoError(error))
if matches!(
error.kind(),
std::io::ErrorKind::PermissionDenied
| std::io::ErrorKind::WouldBlock
| std::io::ErrorKind::Interrupted
| std::io::ErrorKind::TimedOut
)
)
}
}
#[derive(Debug, thiserror::Error)]
pub enum IndexingError {
#[error(transparent)]
@@ -24,3 +40,32 @@ pub enum IndexingError {
#[error(transparent)]
Storage(#[from] crate::storage::StorageError),
}
impl IndexingError {
pub fn is_retryable_io(&self) -> bool {
matches!(self, Self::Search(error) if error.is_retryable_io())
}
}
#[cfg(test)]
mod tests {
use std::{io, sync::Arc};
use super::{IndexingError, SearchError};
#[test]
fn permission_denied_index_error_is_retryable() {
let error = IndexingError::Search(SearchError::Tantivy(tantivy::TantivyError::IoError(
Arc::new(io::Error::from(io::ErrorKind::PermissionDenied)),
)));
assert!(error.is_retryable_io());
}
#[test]
fn invalid_query_is_not_retryable() {
let error = IndexingError::Search(SearchError::Tantivy(
tantivy::TantivyError::InvalidArgument("invalid".into()),
));
assert!(!error.is_retryable_io());
}
}
+1
View File
@@ -20,6 +20,7 @@ pub enum SearchSort {
#[derive(Debug, Clone, Default)]
pub struct SearchOptions {
pub query: String,
pub regex: bool,
pub offset: usize,
pub limit: usize,
pub min_size: Option<u64>,
+3
View File
@@ -13,6 +13,7 @@ pub(crate) struct SearchFields {
pub(crate) display_name: Field,
pub(crate) aliases: Field,
pub(crate) files_text: Field,
pub(crate) regex_text: Field,
pub(crate) extensions: Field,
pub(crate) total_size: Field,
pub(crate) file_count: Field,
@@ -39,6 +40,7 @@ pub(crate) fn build_schema() -> (Schema, SearchFields) {
let display_name = builder.add_text_field("display_name", STORED);
let aliases = builder.add_text_field("aliases", indexed_text.clone());
let files_text = builder.add_text_field("files_text", indexed_text);
let regex_text = builder.add_text_field("regex_text", STRING);
let extensions = builder.add_text_field("extensions", STRING);
let total_size = builder.add_u64_field("total_size", FAST | STORED);
let file_count = builder.add_u64_field("file_count", FAST | STORED);
@@ -60,6 +62,7 @@ pub(crate) fn build_schema() -> (Schema, SearchFields) {
display_name,
aliases,
files_text,
regex_text,
extensions,
total_size,
file_count,
+12 -3
View File
@@ -53,14 +53,23 @@ echo.
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
"$backend = $null; $web = $null;" ^
"function Stop-Tree($process) {" ^
" if ($null -ne $process) {" ^
" $process.Refresh();" ^
" if (-not $process.HasExited) { Start-Process -FilePath 'taskkill.exe' -ArgumentList @('/PID', [string]$process.Id, '/T', '/F') -NoNewWindow -Wait | Out-Null }" ^
" }" ^
"}" ^
"try {" ^
" $backend = Start-Process -FilePath 'cargo' -ArgumentList @('run','-p','dht-search','--','--config','dht-search.example.toml') -WorkingDirectory $env:PROJECT_ROOT -NoNewWindow -PassThru;" ^
" $web = Start-Process -FilePath 'bun' -ArgumentList @('run','dev','--','--host','127.0.0.1') -WorkingDirectory $env:WEB_DIR -NoNewWindow -PassThru;" ^
" $web = Start-Process -FilePath 'bun' -ArgumentList @('--bun','run','dev','--','--host','127.0.0.1') -WorkingDirectory $env:WEB_DIR -NoNewWindow -PassThru;" ^
" while (-not $backend.HasExited -and -not $web.HasExited) { Start-Sleep -Milliseconds 250 }" ^
"} finally {" ^
" foreach ($process in @($backend, $web)) {" ^
" if ($null -ne $process -and -not $process.HasExited) { Stop-Process -Id $process.Id }" ^
" for ($i = 0; $i -lt 20; $i++) {" ^
" if (($null -eq $backend -or $backend.HasExited) -and ($null -eq $web -or $web.HasExited)) { break }" ^
" Start-Sleep -Milliseconds 250" ^
" }" ^
" Stop-Tree $backend;" ^
" Stop-Tree $web;" ^
"}"
endlocal
+4 -1
View File
@@ -50,13 +50,16 @@ Axum 根据配置中的 `http.web_dir` 提供静态资源和单页回退 不需
## 已实现功能
- 关键词 文件名片段和精确 infohash 搜索
- 大小 文件数 扩展名 时间 热度和可用性筛选
- 普通关键词和名称 文件路径正则搜索
- 相关度 时间 热度 大小和发现次数排序
- 有上限的结果分页和 URL 查询恢复
- 基于 reka-ui 的搜索结果数字分页和浏览器持久化每页数量选择
- 文件列表 热度 可用性和收录时间详情
- 基于 reka-ui 的数字分页文件列表和浏览器持久化每页数量选择
- 相同内容的不同 infohash 变体展示
- 磁力链接打开和复制
- DHT 采集 索引 持久化和验证运行状态
- 每秒自动刷新的运行状态和点击外部关闭
- 加载 空结果 接口错误 重试和移动端适配
## 添加组件
+96 -26
View File
@@ -1,23 +1,24 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Activity, ChevronLeft, ChevronRight, Database, Filter, LoaderCircle, Moon, Search, Server, Sun, X } from '@lucide/vue'
import { Activity, Database, LoaderCircle, Moon, Search, Server, Sun, X } from '@lucide/vue'
import SearchFiltersPanel from '@/components/SearchFilters.vue'
import SearchResultCard from '@/components/SearchResultCard.vue'
import TorrentDetailDialog from '@/components/TorrentDetailDialog.vue'
import { Button } from '@/components/ui/button'
import { AppPagination } from '@/components/ui/pagination'
import { AppSelect } from '@/components/ui/select'
import { getStats, getTorrent, getVariants, search } from '@/lib/api'
import type { SearchFilters, SearchHit, SearchPage, SearchSort, ServiceStats, TorrentDetail, ContentVariants } from '@/types/api'
import type { SearchHit, SearchPage, SearchSort, ServiceStats, TorrentDetail, ContentVariants } from '@/types/api'
const emptyFilters = (): SearchFilters => ({ minSizeMiB: '', maxSizeMiB: '', extension: '', minFiles: '', maxFiles: '', firstSeenAfter: '', firstSeenBefore: '', lastSeenAfter: '', lastSeenBefore: '', availability: '', heat: '' })
const query = ref('')
const submittedQuery = ref('')
const filters = ref(emptyFilters())
const filtersOpen = ref(false)
const regexMode = ref(false)
const sort = ref<SearchSort>('latest')
const page = ref<SearchPage | null>(null)
const offset = ref(0)
const limit = 20
const supportedResultPageSizes = [10, 20, 50, 100]
const savedResultPageSize = Number(localStorage.getItem('dht-search-result-page-size'))
const limit = ref(supportedResultPageSizes.includes(savedResultPageSize) ? savedResultPageSize : 20)
const loading = ref(true)
const error = ref('')
const stats = ref<ServiceStats | null>(null)
@@ -25,22 +26,44 @@ const statsOpen = ref(false)
const darkMode = ref(false)
const detailOpen = ref(false)
const detailLoading = ref(false)
const detailFilesLoading = ref(false)
const detailFilesError = ref('')
const detailError = ref('')
const selectedHash = ref('')
const detail = ref<TorrentDetail | null>(null)
const variants = ref<ContentVariants | null>(null)
const supportedFilePageSizes = [25, 50, 100, 200]
const savedFilePageSize = Number(localStorage.getItem('dht-search-file-page-size'))
const detailFilePageSize = ref(supportedFilePageSizes.includes(savedFilePageSize) ? savedFilePageSize : 100)
let searchController: AbortController | null = null
let detailController: AbortController | null = null
let filesController: AbortController | null = null
let statsTimer: number | null = null
let statsRequestActive = false
const pageNumber = computed(() => Math.floor((page.value?.offset ?? 0) / limit) + 1)
const pageCount = computed(() => Math.max(1, Math.ceil((page.value?.total ?? 0) / limit)))
const filterCount = computed(() => Object.values(filters.value).filter(Boolean).length)
const pageNumber = computed(() => Math.floor((page.value?.offset ?? 0) / limit.value) + 1)
const paginationTotal = computed(() => Math.min(page.value?.total ?? 0, 10_000 + limit.value))
const resultPageSizeOptions = [
{ value: '10', label: '10 条/页' },
{ value: '20', label: '20 条/页' },
{ value: '50', label: '50 条/页' },
{ value: '100', label: '100 条/页' },
]
const sortOptions: ReadonlyArray<{ value: SearchSort; label: string }> = [
{ value: 'relevance', label: '相关度' },
{ value: 'latest', label: '最近收录' },
{ value: 'oldest', label: '最早收录' },
{ value: 'heat', label: '热度' },
{ value: 'discoveries', label: '发现次数' },
{ value: 'size_desc', label: '大小降序' },
{ value: 'size_asc', label: '大小升序' },
]
function updateBrowserUrl() {
const params = new URLSearchParams()
if (submittedQuery.value) params.set('q', submittedQuery.value)
if (sort.value !== 'latest') params.set('sort', sort.value)
if (regexMode.value) params.set('regex', 'true')
if (offset.value) params.set('offset', String(offset.value))
history.replaceState(null, '', params.size ? `?${params}` : location.pathname)
}
@@ -52,7 +75,7 @@ async function loadSearch() {
error.value = ''
updateBrowserUrl()
try {
page.value = await search({ query: submittedQuery.value, offset: offset.value, limit, sort: sort.value, filters: filters.value }, searchController.signal)
page.value = await search({ query: submittedQuery.value, offset: offset.value, limit: limit.value, sort: sort.value, regex: regexMode.value }, searchController.signal)
} catch (cause) {
if ((cause as Error).name !== 'AbortError') error.value = cause instanceof Error ? cause.message : '搜索失败'
} finally {
@@ -67,9 +90,17 @@ function submitSearch() {
offset.value = 0
void loadSearch()
}
function changePage(nextOffset: number) { offset.value = Math.max(0, nextOffset); void loadSearch(); window.scrollTo({ top: 0, behavior: 'smooth' }) }
function changeSort(event: Event) { sort.value = (event.target as HTMLSelectElement).value as SearchSort; offset.value = 0; void loadSearch() }
function resetFilters() { filters.value = emptyFilters(); offset.value = 0; void loadSearch() }
function changePage(pageNumber: number) { offset.value = Math.max(0, (pageNumber - 1) * limit.value); void loadSearch(); window.scrollTo({ top: 0, behavior: 'smooth' }) }
function changeResultPageSize(value: string) {
const pageSize = Number(value)
if (!supportedResultPageSizes.includes(pageSize) || pageSize === limit.value) return
limit.value = pageSize
localStorage.setItem('dht-search-result-page-size', String(pageSize))
offset.value = 0
void loadSearch()
}
function changeSort(value: string) { sort.value = value as SearchSort; offset.value = 0; void loadSearch() }
function toggleRegex() { regexMode.value = !regexMode.value; offset.value = 0 }
function toggleTheme() {
darkMode.value = !darkMode.value
@@ -77,17 +108,24 @@ function toggleTheme() {
localStorage.setItem('dht-search-theme', darkMode.value ? 'dark' : 'light')
}
function closeStatsOnOutsideClick(event: PointerEvent) {
if (statsOpen.value && event.target instanceof Element && !event.target.closest('[data-stats-panel]')) {
statsOpen.value = false
}
}
async function openDetail(hit: SearchHit) {
selectedHash.value = hit.info_hash
detailOpen.value = true
detailLoading.value = true
detailError.value = ''
detailFilesError.value = ''
detail.value = null
variants.value = null
detailController?.abort()
detailController = new AbortController()
try {
const [torrent, contentVariants] = await Promise.all([getTorrent(hit.info_hash, detailController.signal), getVariants(hit.content_key, detailController.signal)])
const [torrent, contentVariants] = await Promise.all([getTorrent(hit.info_hash, 0, detailFilePageSize.value, detailController.signal), getVariants(hit.content_key, detailController.signal)])
detail.value = torrent
variants.value = contentVariants
} catch (cause) {
@@ -97,11 +135,37 @@ async function openDetail(hit: SearchHit) {
}
}
function closeDetail() { detailController?.abort(); detailOpen.value = false }
async function loadFilePage(fileOffset: number) {
if (!detail.value || detailFilesLoading.value) return
filesController?.abort()
filesController = new AbortController()
detailFilesLoading.value = true
detailFilesError.value = ''
const infoHash = detail.value.info_hash
try {
const next = await getTorrent(infoHash, fileOffset, detailFilePageSize.value, filesController.signal)
if (selectedHash.value === infoHash) detail.value = next
} catch (cause) {
if ((cause as Error).name !== 'AbortError') detailFilesError.value = cause instanceof Error ? cause.message : '文件列表加载失败'
} finally {
if (!filesController.signal.aborted) detailFilesLoading.value = false
}
}
function changeFilePageSize(fileLimit: number) {
if (!supportedFilePageSizes.includes(fileLimit) || fileLimit === detailFilePageSize.value) return
detailFilePageSize.value = fileLimit
localStorage.setItem('dht-search-file-page-size', String(fileLimit))
void loadFilePage(0)
}
function closeDetail() { detailController?.abort(); filesController?.abort(); detailOpen.value = false }
function retryDetail() { const hit = page.value?.hits.find((item) => item.info_hash === selectedHash.value); if (hit) void openDetail(hit) }
async function loadStats() {
try { stats.value = await getStats() } catch { stats.value = null }
if (statsRequestActive) return
statsRequestActive = true
try { stats.value = await getStats() } catch { stats.value = null } finally { statsRequestActive = false }
}
watch(statsOpen, (open) => {
@@ -111,15 +175,17 @@ watch(statsOpen, (open) => {
}
if (open) {
void loadStats()
statsTimer = window.setInterval(() => void loadStats(), 5_000)
statsTimer = window.setInterval(() => void loadStats(), 1_000)
}
})
onMounted(() => {
document.addEventListener('pointerdown', closeStatsOnOutsideClick)
darkMode.value = document.documentElement.classList.contains('dark')
const params = new URLSearchParams(location.search)
query.value = params.get('q') ?? ''
submittedQuery.value = query.value
regexMode.value = params.get('regex') === 'true'
const initialSort = params.get('sort') as SearchSort | null
if (initialSort && ['relevance', 'latest', 'oldest', 'heat', 'size_desc', 'size_asc', 'discoveries'].includes(initialSort)) sort.value = initialSort
else if (query.value.trim()) sort.value = 'relevance'
@@ -127,8 +193,10 @@ onMounted(() => {
void loadSearch()
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', closeStatsOnOutsideClick)
searchController?.abort()
detailController?.abort()
filesController?.abort()
if (statsTimer !== null) window.clearInterval(statsTimer)
})
</script>
@@ -140,7 +208,7 @@ onBeforeUnmount(() => {
<div class="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground"><Database class="size-4" aria-hidden="true" /></div>
<p class="text-sm font-semibold tracking-tight">DHT Search</p>
<Button class="ml-auto" size="icon" variant="ghost" :aria-label="darkMode ? '切换到浅色主题' : '切换到深色主题'" :title="darkMode ? '浅色主题' : '深色主题'" @click="toggleTheme"><Sun v-if="darkMode" /><Moon v-else /></Button>
<div class="relative">
<div class="relative" data-stats-panel>
<Button size="icon" variant="ghost" aria-label="运行状态" title="运行状态" @click="statsOpen = !statsOpen"><span class="relative"><Activity class="size-4" /><i class="absolute -right-0.5 -top-0.5 size-1.5 rounded-full" :class="stats ? 'bg-emerald-500' : 'bg-muted-foreground'" /></span></Button>
<div v-if="statsOpen" class="absolute right-0 top-11 w-72 rounded-xl border bg-popover p-4 text-popover-foreground shadow-xl">
<div class="mb-3 flex items-center"><p class="text-sm font-semibold">服务运行状态</p><button class="ml-auto" aria-label="关闭状态面板" @click="statsOpen = false"><X class="size-4" /></button></div>
@@ -157,23 +225,25 @@ onBeforeUnmount(() => {
<div class="flex flex-wrap items-center gap-2 p-2">
<Search class="ml-2.5 size-4 shrink-0 text-muted-foreground" />
<label class="sr-only" for="search-query">搜索关键词</label>
<input id="search-query" v-model="query" class="h-9 min-w-48 flex-1 bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground" maxlength="512" placeholder="输入名称、文件名或 40 位 infohash" type="search" />
<Button size="sm" type="button" :variant="filtersOpen ? 'secondary' : 'ghost'" @click="filtersOpen = !filtersOpen"><Filter />筛选<span v-if="filterCount" class="rounded-full bg-primary px-1.5 text-[10px] text-primary-foreground">{{ filterCount }}</span></Button>
<select aria-label="结果排序" class="field-input h-9 w-auto py-0" :value="sort" @change="changeSort"><option value="relevance">相关度</option><option value="latest">最近收录</option><option value="oldest">最早收录</option><option value="heat">热度</option><option value="discoveries">发现次数</option><option value="size_desc">大小降序</option><option value="size_asc">大小升序</option></select>
<input id="search-query" v-model="query" class="h-9 min-w-48 flex-1 bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground" :maxlength="regexMode ? 256 : 512" :placeholder="regexMode ? '输入正则表达式' : '输入名称、文件名或 40 位 infohash'" type="search" />
<Button size="sm" type="button" :variant="regexMode ? 'secondary' : 'ghost'" :aria-pressed="regexMode" title="正则搜索" @click="toggleRegex"><span class="font-mono">.*</span><span class="hidden sm:inline">正则</span></Button>
<AppSelect :model-value="sort" :options="sortOptions" label="结果排序" @update:model-value="changeSort" />
<Button type="submit">搜索</Button>
</div>
<SearchFiltersPanel v-if="filtersOpen" v-model="filters" @reset="resetFilters" />
</form>
<div v-if="loading" class="flex min-h-72 items-center justify-center text-sm text-muted-foreground"><LoaderCircle class="mr-2 size-5 animate-spin" />正在搜索</div>
<div v-else-if="error" class="mt-4 flex min-h-72 flex-col items-center justify-center gap-4 rounded-xl border border-dashed text-center"><Server class="size-8 text-muted-foreground" /><div><p class="font-medium">无法完成搜索</p><p class="mt-1 text-sm text-muted-foreground">{{ error }}</p></div><Button variant="outline" @click="loadSearch">重试</Button></div>
<div v-else-if="page?.hits.length" class="mt-4 space-y-3"><SearchResultCard v-for="hit in page.hits" :key="hit.content_key" :hit="hit" @select="openDetail" /></div>
<div v-else class="mt-4 flex min-h-72 flex-col items-center justify-center rounded-xl border border-dashed text-center"><Search class="mb-3 size-8 text-muted-foreground" /><p class="font-medium">没有找到匹配内容</p><p class="mt-1 text-sm text-muted-foreground">尝试其他关键词或减少筛选条件</p></div>
<div v-else class="mt-4 flex min-h-72 flex-col items-center justify-center rounded-xl border border-dashed text-center"><Search class="mb-3 size-8 text-muted-foreground" /><p class="font-medium">没有找到匹配内容</p><p class="mt-1 text-sm text-muted-foreground">尝试其他关键词或正则表达式</p></div>
<nav v-if="page && page.total > limit" aria-label="搜索结果分页" class="mt-6 flex items-center justify-center gap-3"><Button size="sm" variant="outline" :disabled="offset === 0" @click="changePage(offset - limit)"><ChevronLeft />上一页</Button><span class="min-w-24 text-center text-xs text-muted-foreground">第 {{ pageNumber }} / {{ pageCount }} 页</span><Button size="sm" variant="outline" :disabled="offset + limit >= page.total || offset + limit > 10_000" @click="changePage(offset + limit)">下一页<ChevronRight /></Button></nav>
<div v-if="page && page.total > 0" class="mt-6 flex flex-wrap items-center justify-center gap-2">
<AppSelect :model-value="String(limit)" :options="resultPageSizeOptions" label="每页搜索结果数量" @update:model-value="changeResultPageSize" />
<AppPagination v-if="paginationTotal > limit" :page="pageNumber" :page-size="limit" :total="paginationTotal" @update:page="changePage" />
</div>
</section>
</main>
<TorrentDetailDialog :detail="detail" :error="detailError" :loading="detailLoading" :open="detailOpen" :variants="variants" @close="closeDetail" @retry="retryDetail" />
<TorrentDetailDialog :detail="detail" :error="detailError" :files-error="detailFilesError" :files-loading="detailFilesLoading" :loading="detailLoading" :open="detailOpen" :variants="variants" @close="closeDetail" @file-page="loadFilePage" @file-page-size="changeFilePageSize" @retry="retryDetail" />
</div>
</template>
-64
View File
@@ -1,64 +0,0 @@
<script setup lang="ts">
import { RotateCcw } from '@lucide/vue'
import { Button } from '@/components/ui/button'
import type { SearchFilters } from '@/types/api'
const props = defineProps<{ modelValue: SearchFilters }>()
const emit = defineEmits<{
'update:modelValue': [value: SearchFilters]
reset: []
}>()
function update<K extends keyof SearchFilters>(key: K, value: SearchFilters[K]) {
emit('update:modelValue', { ...props.modelValue, [key]: value })
}
</script>
<template>
<div class="grid gap-5 border-t px-5 py-5 sm:grid-cols-2 lg:grid-cols-4">
<label class="field-label">
<span>大小范围 MiB</span>
<span class="flex items-center gap-2">
<input class="field-input" min="0" placeholder="最小" type="number" :value="modelValue.minSizeMiB" @input="update('minSizeMiB', ($event.target as HTMLInputElement).value)" />
<span class="text-muted-foreground"></span>
<input class="field-input" min="0" placeholder="最大" type="number" :value="modelValue.maxSizeMiB" @input="update('maxSizeMiB', ($event.target as HTMLInputElement).value)" />
</span>
</label>
<label class="field-label">
<span>文件数量</span>
<span class="flex items-center gap-2">
<input class="field-input" min="0" placeholder="最少" type="number" :value="modelValue.minFiles" @input="update('minFiles', ($event.target as HTMLInputElement).value)" />
<span class="text-muted-foreground"></span>
<input class="field-input" min="0" placeholder="最多" type="number" :value="modelValue.maxFiles" @input="update('maxFiles', ($event.target as HTMLInputElement).value)" />
</span>
</label>
<label class="field-label">
<span>文件扩展名</span>
<input class="field-input" placeholder="例如 mkv" :value="modelValue.extension" @input="update('extension', ($event.target as HTMLInputElement).value)" />
</label>
<label class="field-label">
<span>可用性</span>
<select class="field-input" :value="modelValue.availability" @change="update('availability', ($event.target as HTMLSelectElement).value as SearchFilters['availability'])">
<option value="">全部</option><option value="active">可用</option><option value="unknown">未验证</option><option value="possibly_stale">可能失效</option>
</select>
</label>
<label class="field-label">
<span>热度</span>
<select class="field-input" :value="modelValue.heat" @change="update('heat', ($event.target as HTMLSelectElement).value as SearchFilters['heat'])">
<option value="">全部</option><option value="hot">热门</option><option value="active">活跃</option><option value="normal">一般</option><option value="cold">冷门</option>
</select>
</label>
<label class="field-label">
<span>首次收录起止</span>
<span class="flex gap-2"><input class="field-input min-w-0" type="date" :value="modelValue.firstSeenAfter" @input="update('firstSeenAfter', ($event.target as HTMLInputElement).value)" /><input class="field-input min-w-0" type="date" :value="modelValue.firstSeenBefore" @input="update('firstSeenBefore', ($event.target as HTMLInputElement).value)" /></span>
</label>
<label class="field-label">
<span>最近发现起止</span>
<span class="flex gap-2"><input class="field-input min-w-0" type="date" :value="modelValue.lastSeenAfter" @input="update('lastSeenAfter', ($event.target as HTMLInputElement).value)" /><input class="field-input min-w-0" type="date" :value="modelValue.lastSeenBefore" @input="update('lastSeenBefore', ($event.target as HTMLInputElement).value)" /></span>
</label>
<div class="flex items-end">
<Button class="w-full" size="sm" type="button" variant="outline" @click="$emit('reset')"><RotateCcw />重置筛选</Button>
</div>
</div>
</template>
+52 -8
View File
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Check, Copy, Download, File, LoaderCircle, X } from '@lucide/vue'
import { Check, Copy, Download, LoaderCircle, X } from '@lucide/vue'
import { Button } from '@/components/ui/button'
import { AppPagination } from '@/components/ui/pagination'
import { AppSelect } from '@/components/ui/select'
import { availabilityLabel, formatBytes, formatDate, heatLabel } from '@/lib/format'
import type { ContentVariants, TorrentDetail } from '@/types/api'
@@ -11,11 +13,30 @@ const props = defineProps<{
detail: TorrentDetail | null
variants: ContentVariants | null
loading: boolean
filesLoading: boolean
filesError: string
error: string
}>()
const emit = defineEmits<{ close: []; retry: [] }>()
const emit = defineEmits<{ close: []; retry: []; 'file-page': [offset: number]; 'file-page-size': [size: number] }>()
const copied = ref('')
const totalFileSize = computed(() => props.detail?.files.reduce((sum, file) => sum + file.size, 0) ?? 0)
const filePage = computed(() => props.detail ? Math.floor(props.detail.file_offset / props.detail.file_limit) + 1 : 1)
const filePageSizeOptions = [
{ value: '25', label: '25 条/页' },
{ value: '50', label: '50 条/页' },
{ value: '100', label: '100 条/页' },
{ value: '200', label: '200 条/页' },
]
function fileName(path: string) {
const normalized = path.replaceAll('\\', '/')
return normalized.slice(normalized.lastIndexOf('/') + 1) || normalized
}
function fileDirectory(path: string) {
const normalized = path.replaceAll('\\', '/')
const separator = normalized.lastIndexOf('/')
return separator > 0 ? normalized.slice(0, separator) : '根目录'
}
async function copy(value: string, type: string) {
if (navigator.clipboard && window.isSecureContext) {
@@ -34,6 +55,14 @@ async function copy(value: string, type: string) {
window.setTimeout(() => { if (copied.value === type) copied.value = '' }, 1600)
}
function changeFilePage(page: number) {
if (props.detail) emit('file-page', (page - 1) * props.detail.file_limit)
}
function changeFilePageSize(value: string) {
emit('file-page-size', Number(value))
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && props.open) emit('close')
}
@@ -61,14 +90,14 @@ onBeforeUnmount(() => {
<div v-else-if="error" class="flex flex-1 flex-col items-center justify-center gap-4 px-6 text-center"><p class="text-sm text-destructive">{{ error }}</p><Button variant="outline" @click="$emit('retry')">重新加载</Button></div>
<div v-else-if="detail" class="flex-1 overflow-y-auto">
<div class="space-y-5 border-b p-5 sm:p-7">
<div><h2 class="break-words text-xl font-semibold tracking-tight">{{ detail.name }}</h2><p class="mt-2 break-all font-mono text-[11px] leading-5 text-muted-foreground">{{ detail.info_hash }}</p></div>
<div><h2 class="break-words text-xl font-semibold tracking-tight">{{ detail.name }}</h2><button class="mt-2 flex max-w-full items-center gap-1.5 text-left font-mono text-[11px] leading-5 text-muted-foreground hover:text-foreground" title="复制 infohash" @click="copy(detail.info_hash, 'hash')"><span class="truncate">{{ detail.info_hash }}</span><Check v-if="copied === 'hash'" class="size-3 shrink-0 text-emerald-600" /><Copy v-else class="size-3 shrink-0" /></button></div>
<div class="flex flex-wrap gap-2">
<Button as-child><a :href="detail.magnet_link"><Download />打开磁力链接</a></Button>
<Button variant="outline" @click="copy(detail.magnet_link, 'magnet')"><Check v-if="copied === 'magnet'" /><Copy v-else />{{ copied === 'magnet' ? '已复制' : '复制磁力链接' }}</Button>
</div>
<dl class="grid grid-cols-2 gap-x-5 gap-y-4 rounded-xl bg-muted/55 p-4 text-sm sm:grid-cols-3">
<div><dt>总大小</dt><dd>{{ formatBytes(detail.total_size) }}</dd></div>
<div><dt>文件数量</dt><dd>{{ detail.files.length.toLocaleString() }}</dd></div>
<div><dt>文件数量</dt><dd>{{ detail.file_count.toLocaleString() }}</dd></div>
<div><dt>分片大小</dt><dd>{{ formatBytes(detail.piece_length) }}</dd></div>
<div><dt>热度</dt><dd>{{ heatLabel[detail.heat.level] }} · {{ detail.heat.score }}</dd></div>
<div><dt>可用性</dt><dd>{{ availabilityLabel[detail.availability.status] }}<template v-if="detail.availability.reachable_peers"> · {{ detail.availability.reachable_peers }} Peer</template></dd></div>
@@ -80,9 +109,24 @@ onBeforeUnmount(() => {
</div>
<div class="border-b p-5 sm:p-7">
<div class="mb-3 flex items-center"><h3 class="font-semibold">文件详情</h3><span class="ml-auto text-xs text-muted-foreground">{{ formatBytes(totalFileSize) }}</span></div>
<div class="divide-y rounded-xl border">
<div v-for="file in detail.files" :key="`${file.path}-${file.size}`" class="flex items-center gap-3 px-4 py-3 text-sm"><File class="size-4 shrink-0 text-muted-foreground" /><span class="min-w-0 flex-1 break-all">{{ file.path }}</span><span class="shrink-0 text-xs text-muted-foreground">{{ formatBytes(file.size) }}</span></div>
<div class="mb-4 flex flex-wrap items-center gap-3">
<div><h3 class="font-semibold">文件详情</h3><p class="mt-1 text-xs text-muted-foreground"> {{ detail.file_count.toLocaleString() }} 个文件</p></div>
<div class="ml-auto flex flex-wrap items-center justify-end gap-2">
<AppSelect :model-value="String(detail.file_limit)" :options="filePageSizeOptions" label="每页文件数量" @update:model-value="changeFilePageSize" />
<AppPagination v-if="detail.file_count > detail.file_limit" :disabled="filesLoading" :page="filePage" :page-size="detail.file_limit" :total="detail.file_count" @update:page="changeFilePage" />
</div>
</div>
<div class="relative overflow-hidden rounded-xl border bg-card">
<div v-if="filesLoading" class="absolute inset-0 z-10 flex items-center justify-center bg-background/75 backdrop-blur-sm"><LoaderCircle class="size-5 animate-spin text-muted-foreground" /></div>
<div v-if="filesError" class="border-b bg-destructive/10 px-4 py-2 text-xs text-destructive">{{ filesError }}</div>
<div class="divide-y">
<div v-for="(file, index) in detail.files" :key="`${detail.file_offset}-${file.path}-${file.size}`" class="group flex items-center gap-3 px-3 py-2 transition-colors hover:bg-muted/45 sm:px-4">
<span class="w-7 shrink-0 text-left font-mono text-[10px] tabular-nums text-muted-foreground/70">{{ detail.file_offset + index + 1 }}</span>
<div class="min-w-0 flex-1"><p class="truncate text-sm font-medium leading-4" :title="file.path">{{ fileName(file.path) }}</p><p class="truncate text-[10px] leading-4 text-muted-foreground" :title="fileDirectory(file.path)">{{ fileDirectory(file.path) }}</p></div>
<span class="w-20 shrink-0 text-right text-xs tabular-nums text-muted-foreground">{{ formatBytes(file.size) }}</span>
</div>
<p v-if="!detail.files.length" class="px-4 py-8 text-center text-sm text-muted-foreground">没有文件信息</p>
</div>
</div>
</div>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { ChevronLeft, ChevronRight, MoreHorizontal } from '@lucide/vue'
import {
PaginationEllipsis,
PaginationList,
PaginationListItem,
PaginationNext,
PaginationPrev,
PaginationRoot,
} from 'reka-ui'
defineProps<{
page: number
pageSize: number
total: number
disabled?: boolean
}>()
const emit = defineEmits<{ 'update:page': [page: number] }>()
</script>
<template>
<PaginationRoot :page="page" :items-per-page="pageSize" :total="total" :disabled="disabled" :sibling-count="1" show-edges @update:page="emit('update:page', $event)">
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
<PaginationPrev class="pagination-button" title="上一页"><ChevronLeft class="size-4" /></PaginationPrev>
<template v-for="(item, index) in items" :key="item.type === 'page' ? item.value : `ellipsis-${index}`">
<PaginationListItem v-if="item.type === 'page'" class="pagination-button data-[selected]:bg-primary data-[selected]:text-primary-foreground" :value="item.value">{{ item.value }}</PaginationListItem>
<PaginationEllipsis v-else class="flex size-8 items-center justify-center text-muted-foreground"><MoreHorizontal class="size-4" /></PaginationEllipsis>
</template>
<PaginationNext class="pagination-button" title="下一页"><ChevronRight class="size-4" /></PaginationNext>
</PaginationList>
</PaginationRoot>
</template>
@@ -0,0 +1 @@
export { default as AppPagination } from './AppPagination.vue'
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { Check, ChevronDown } from '@lucide/vue'
import {
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectValue,
SelectViewport,
} from 'reka-ui'
defineProps<{
modelValue: string
options: ReadonlyArray<{ value: string; label: string }>
label: string
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<SelectRoot :model-value="modelValue" @update:model-value="(value) => typeof value === 'string' && emit('update:modelValue', value)">
<SelectTrigger :aria-label="label" class="flex h-9 min-w-28 items-center justify-between gap-2 rounded-md border bg-background px-3 text-sm outline-none transition hover:bg-accent focus:ring-3 focus:ring-ring/20 data-[placeholder]:text-muted-foreground">
<SelectValue />
<ChevronDown class="size-4 text-muted-foreground" />
</SelectTrigger>
<SelectPortal>
<SelectContent class="z-50 min-w-[var(--reka-select-trigger-width)] overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-lg" position="popper" :side-offset="5">
<SelectViewport class="p-1">
<SelectItem v-for="option in options" :key="option.value" :value="option.value" class="relative flex cursor-default select-none items-center rounded-md py-2 pl-8 pr-3 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50">
<SelectItemIndicator class="absolute left-2 flex size-4 items-center justify-center"><Check class="size-4" /></SelectItemIndicator>
<SelectItemText>{{ option.label }}</SelectItemText>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as AppSelect } from './AppSelect.vue'
+5 -31
View File
@@ -1,6 +1,5 @@
import type {
ContentVariants,
SearchFilters,
SearchPage,
SearchSort,
ServiceStats,
@@ -12,22 +11,7 @@ interface SearchInput {
offset: number
limit: number
sort: SearchSort
filters: SearchFilters
}
function optionalNumber(params: URLSearchParams, key: string, value: string, scale = 1) {
if (value.trim() === '') return
const parsed = Number(value)
if (Number.isFinite(parsed) && parsed >= 0) params.set(key, String(Math.round(parsed * scale)))
}
function optionalDate(params: URLSearchParams, key: string, value: string) {
if (!value) return
const timestamp = new Date(value).getTime()
if (Number.isFinite(timestamp)) {
const endOfDay = key.endsWith('_before') ? 86_399 : 0
params.set(key, String(Math.floor(timestamp / 1000) + endOfDay))
}
regex: boolean
}
async function request<T>(path: string, signal?: AbortSignal): Promise<T> {
@@ -52,23 +36,13 @@ export function search(input: SearchInput, signal?: AbortSignal): Promise<Search
limit: String(input.limit),
sort: input.sort,
})
const { filters } = input
optionalNumber(params, 'min_size', filters.minSizeMiB, 1024 * 1024)
optionalNumber(params, 'max_size', filters.maxSizeMiB, 1024 * 1024)
optionalNumber(params, 'min_files', filters.minFiles)
optionalNumber(params, 'max_files', filters.maxFiles)
optionalDate(params, 'first_seen_after', filters.firstSeenAfter)
optionalDate(params, 'first_seen_before', filters.firstSeenBefore)
optionalDate(params, 'last_seen_after', filters.lastSeenAfter)
optionalDate(params, 'last_seen_before', filters.lastSeenBefore)
if (filters.extension.trim()) params.set('extension', filters.extension.trim().replace(/^\./, ''))
if (filters.availability) params.set('availability', filters.availability)
if (filters.heat) params.set('heat', filters.heat)
if (input.regex) params.set('regex', 'true')
return request<SearchPage>(`/search?${params}`, signal)
}
export function getTorrent(infoHash: string, signal?: AbortSignal): Promise<TorrentDetail> {
return request<TorrentDetail>(`/torrents/${encodeURIComponent(infoHash)}`, signal)
export function getTorrent(infoHash: string, fileOffset = 0, fileLimit = 100, signal?: AbortSignal): Promise<TorrentDetail> {
const params = new URLSearchParams({ file_offset: String(fileOffset), file_limit: String(fileLimit) })
return request<TorrentDetail>(`/torrents/${encodeURIComponent(infoHash)}?${params}`, signal)
}
export function getVariants(contentKey: string, signal?: AbortSignal): Promise<ContentVariants> {
+3
View File
@@ -149,6 +149,9 @@
.status-cell b {
@apply text-sm font-semibold;
}
.pagination-button {
@apply flex size-8 items-center justify-center rounded-md text-xs font-medium transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-40;
}
dl dt {
@apply text-xs text-muted-foreground;
}
+3 -14
View File
@@ -60,6 +60,9 @@ export interface TorrentDetail {
magnet_link: string
name: string
total_size: number
file_count: number
file_offset: number
file_limit: number
files: TorrentFile[]
piece_length: number
content_key: string
@@ -104,17 +107,3 @@ export interface ServiceStats {
verification_succeeded: number
verification_failed: number
}
export interface SearchFilters {
minSizeMiB: string
maxSizeMiB: string
extension: string
minFiles: string
maxFiles: string
firstSeenAfter: string
firstSeenBefore: string
lastSeenAfter: string
lastSeenBefore: string
availability: '' | AvailabilityStatus
heat: '' | HeatLevel
}