- 恢复内容区内部滚动:页头/搜索框固定,滚动区域从搜索框下方开始;侧栏跟随内容滚动,侧栏滚轮驱动内容区 - v22:本地服务以 card 行 local-service 参与排序;首页按卡片顺序渲染,后台可拖动排序 - 本地服务卡片后端禁止删除/改名/配置分组 - 修复向下拖动时插到目标之后,倒数第二项可拖到最后 - 验证:搜索框位置固定、侧栏 scrollTop 跟随、本地服务与倒数第二拖末位均落库、首页顺序一致
238 lines
7.5 KiB
Rust
238 lines
7.5 KiB
Rust
//! 首页卡片:只存标题/顺序,内容由 `card_group` 勾选分组组成。
|
||
//!
|
||
//! 卡片与分组是多对多:一张卡可以放不同格式的分组,分组也可以被多张卡复用。
|
||
//! 卡片本身不拥有数据,删卡不影响内容。
|
||
|
||
use rusqlite::{Connection, OptionalExtension, params};
|
||
use serde::Deserialize;
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::{db::entries, db::map_error, error::StashError};
|
||
|
||
#[derive(Debug, Clone, Deserialize)]
|
||
pub struct NewCard {
|
||
pub title: String,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Default, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct UpdateCard {
|
||
pub title: Option<String>,
|
||
pub sort: Option<i64>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct CardGroupInput {
|
||
pub group_key: String,
|
||
#[serde(default)]
|
||
pub preview_limit: Option<i64>,
|
||
}
|
||
|
||
pub fn load(conn: &Connection) -> Result<Vec<Value>, StashError> {
|
||
let mut stmt = conn
|
||
.prepare("SELECT id, key, title, sort FROM card ORDER BY sort, id")
|
||
.map_err(map_error)?;
|
||
|
||
let cards = stmt
|
||
.query_map([], |row| {
|
||
Ok((
|
||
row.get::<_, i64>(0)?,
|
||
row.get::<_, String>(1)?,
|
||
row.get::<_, String>(2)?,
|
||
row.get::<_, i64>(3)?,
|
||
))
|
||
})
|
||
.map_err(map_error)?
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.map_err(map_error)?;
|
||
|
||
drop(stmt);
|
||
|
||
let mut result = Vec::with_capacity(cards.len());
|
||
for (id, key, title, sort) in cards {
|
||
let mut group_stmt = conn
|
||
.prepare(
|
||
"SELECT group_key, preview_limit FROM card_group
|
||
WHERE card_id = ?1 ORDER BY sort",
|
||
)
|
||
.map_err(map_error)?;
|
||
let groups = group_stmt
|
||
.query_map([id], |row| {
|
||
Ok((
|
||
row.get::<_, String>(0)?,
|
||
row.get::<_, Option<i64>>(1)?,
|
||
))
|
||
})
|
||
.map_err(map_error)?
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.map_err(map_error)?;
|
||
|
||
result.push(json!({
|
||
"id": id,
|
||
"key": key,
|
||
"title": title,
|
||
"sort": sort,
|
||
"groups": groups
|
||
.into_iter()
|
||
.map(|(group_key, preview_limit)| json!({
|
||
"groupKey": group_key,
|
||
"previewLimit": preview_limit,
|
||
}))
|
||
.collect::<Vec<_>>(),
|
||
}));
|
||
}
|
||
|
||
Ok(result)
|
||
}
|
||
|
||
pub fn create(conn: &mut Connection, new: NewCard) -> Result<i64, StashError> {
|
||
let title = new.title.trim();
|
||
if title.is_empty() || title.chars().count() > 40 {
|
||
return Err(StashError::InvalidDirectory("卡片名称需为 1–40 个字".into()));
|
||
}
|
||
|
||
let tx = conn.transaction().map_err(map_error)?;
|
||
let sort: i64 = tx
|
||
.query_row("SELECT COALESCE(MAX(sort), 0) + 1 FROM card", [], |row| {
|
||
row.get(0)
|
||
})
|
||
.map_err(map_error)?;
|
||
|
||
tx.execute(
|
||
"INSERT INTO card (key, title, sort) VALUES ('pending', ?1, ?2)",
|
||
params![title, sort],
|
||
)
|
||
.map_err(map_error)?;
|
||
let id = tx.last_insert_rowid();
|
||
tx.execute(
|
||
"UPDATE card SET key = ?1 WHERE id = ?2",
|
||
params![format!("card-{id}"), id],
|
||
)
|
||
.map_err(map_error)?;
|
||
|
||
entries::audit(&tx, "card.create", &format!("card-{id}"), title)?;
|
||
tx.commit().map_err(map_error)?;
|
||
Ok(id)
|
||
}
|
||
|
||
pub fn update(conn: &mut Connection, id: i64, patch: UpdateCard) -> Result<(), StashError> {
|
||
let current: Option<(String, String, i64)> = conn
|
||
.query_row(
|
||
"SELECT key, title, sort FROM card WHERE id = ?1",
|
||
[id],
|
||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||
)
|
||
.optional()
|
||
.map_err(map_error)?;
|
||
|
||
let Some((current_key, current_title, current_sort)) = current else {
|
||
return Err(StashError::NotFound(format!("卡片不存在: {id}")));
|
||
};
|
||
if current_key == "local-service" && patch.title.is_some() {
|
||
return Err(StashError::InvalidDirectory(
|
||
"本地服务卡片由系统生成,不能改名".into(),
|
||
));
|
||
}
|
||
|
||
let title = match patch.title {
|
||
Some(title) => {
|
||
let title = title.trim().to_string();
|
||
if title.is_empty() || title.chars().count() > 40 {
|
||
return Err(StashError::InvalidDirectory("卡片名称需为 1–40 个字".into()));
|
||
}
|
||
title
|
||
}
|
||
None => current_title,
|
||
};
|
||
let sort = patch.sort.unwrap_or(current_sort);
|
||
|
||
let tx = conn.transaction().map_err(map_error)?;
|
||
tx.execute(
|
||
"UPDATE card SET title = ?1, sort = ?2 WHERE id = ?3",
|
||
params![title, sort, id],
|
||
)
|
||
.map_err(map_error)?;
|
||
entries::audit(&tx, "card.update", &format!("card-{id}"), &title)?;
|
||
tx.commit().map_err(map_error)?;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn remove(conn: &Connection, id: i64) -> Result<(), StashError> {
|
||
let key: Option<String> = conn
|
||
.query_row("SELECT key FROM card WHERE id = ?1", [id], |row| row.get(0))
|
||
.optional()
|
||
.map_err(map_error)?;
|
||
if key.as_deref() == Some("local-service") {
|
||
return Err(StashError::InvalidDirectory(
|
||
"本地服务卡片由系统生成,不能删除".into(),
|
||
));
|
||
}
|
||
let removed = conn
|
||
.execute("DELETE FROM card WHERE id = ?1", [id])
|
||
.map_err(map_error)?;
|
||
if removed == 0 {
|
||
return Err(StashError::NotFound(format!("卡片不存在: {id}")));
|
||
}
|
||
entries::audit(conn, "card.delete", &format!("card-{id}"), "")?;
|
||
Ok(())
|
||
}
|
||
|
||
/// 整组替换卡片勾选的分组(顺序即数组顺序)。
|
||
pub fn set_groups(
|
||
conn: &mut Connection,
|
||
id: i64,
|
||
groups: Vec<CardGroupInput>,
|
||
) -> Result<(), StashError> {
|
||
let key: Option<String> = conn
|
||
.query_row("SELECT key FROM card WHERE id = ?1", [id], |row| row.get(0))
|
||
.optional()
|
||
.map_err(map_error)?;
|
||
if key.as_deref() == Some("local-service") {
|
||
return Err(StashError::InvalidDirectory(
|
||
"本地服务卡片由系统生成,不能配置分组".into(),
|
||
));
|
||
}
|
||
|
||
let exists: i64 = conn
|
||
.query_row("SELECT COUNT(*) FROM card WHERE id = ?1", [id], |row| row.get(0))
|
||
.map_err(map_error)?;
|
||
if exists == 0 {
|
||
return Err(StashError::NotFound(format!("卡片不存在: {id}")));
|
||
}
|
||
|
||
for group in &groups {
|
||
let node_exists: i64 = conn
|
||
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [&group.group_key], |row| {
|
||
row.get(0)
|
||
})
|
||
.map_err(map_error)?;
|
||
if node_exists == 0 {
|
||
return Err(StashError::NotFound(format!(
|
||
"分组不存在: {}",
|
||
group.group_key
|
||
)));
|
||
}
|
||
if let Some(limit) = group.preview_limit {
|
||
if !(1..=60).contains(&limit) {
|
||
return Err(StashError::InvalidDirectory("预览条数需在 1–60 之间".into()));
|
||
}
|
||
}
|
||
}
|
||
|
||
let tx = conn.transaction().map_err(map_error)?;
|
||
tx.execute("DELETE FROM card_group WHERE card_id = ?1", [id])
|
||
.map_err(map_error)?;
|
||
for (sort, group) in groups.iter().enumerate() {
|
||
tx.execute(
|
||
"INSERT INTO card_group (card_id, group_key, sort, preview_limit)
|
||
VALUES (?1, ?2, ?3, ?4)",
|
||
params![id, group.group_key, sort as i64, group.preview_limit],
|
||
)
|
||
.map_err(map_error)?;
|
||
}
|
||
entries::audit(&tx, "card.groups", &format!("card-{id}"), "")?;
|
||
tx.commit().map_err(map_error)?;
|
||
Ok(())
|
||
}
|