From 733c1552cea685f78a1f53a6b1bf87ac7e84288b Mon Sep 17 00:00:00 2001 From: CNWei Date: Fri, 6 Nov 2026 12:40:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(search):=20=E5=86=85=E7=BD=AE=20DuckDuckGo?= =?UTF-8?q?=EF=BC=8C=E5=86=85=E7=BD=AE=E5=BC=95=E6=93=8E=E4=B8=8D=E5=85=81?= =?UTF-8?q?=E8=AE=B8=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 默认引擎清单加 duckduckgo(https://duckduckgo.com/?q={query}),图标用组件库 0.3.5 的 DuckDuckGo 品牌图标 - 后端:内置六个必须存在,缺了返回 400;老库读取时按 id 补齐缺的内置引擎(插在内置那一组里,不覆盖用户改过的值) - 后台:内置引擎不渲染「删除」按钮,removeEngine 也做了兜底;自定义引擎照常可删 --- HANDOFF.md | 8 +- src/db/settings.rs | 85 +++++++++++++++++++- stash-web/src/components/admin/AdminNext.tsx | 7 +- stash-web/src/settings.ts | 17 ++++ 4 files changed, 112 insertions(+), 5 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 044d2c7..3642e75 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -121,7 +121,8 @@ stash-ui/src/components/ SearchField / Input / EngineIcon 等 { "id": "bing", "name": "Bing", "url": "https://www.bing.com/search?q={query}", "enabled": true }, { "id": "baidu", "name": "百度", "url": "https://www.baidu.com/s?wd={query}", "enabled": true }, { "id": "sogou", "name": "搜狗", "url": "https://www.sogou.com/web?query={query}", "enabled": true }, - { "id": "360", "name": "360", "url": "https://www.so.com/s?q={query}", "enabled": true } + { "id": "360", "name": "360", "url": "https://www.so.com/s?q={query}", "enabled": true }, + { "id": "duckduckgo", "name": "DuckDuckGo", "url": "https://duckduckgo.com/?q={query}", "enabled": true } ] }, "profile": { "name": "...", "role": "...", "description": "...", "location": "...", "avatar": "" }, @@ -132,7 +133,8 @@ stash-ui/src/components/ SearchField / Input / EngineIcon 等 - `search.default` 可以是 `internal` 或某个引擎 id;默认默认值是 `internal`。 - 上面是字段结构示例;实际值以 `GET /api/settings` 为准(交接时 `search.default=internal`、`site.theme=light`,background 两项使用自定义 picsum 图)。 - 外部引擎 URL 必须以 `http(s)://` 开头并包含 `{query}`,否则后端 400。 -- 引擎 id 只能用小写字母 / 数字 / `-` / `_`(1–30 位,`internal` 保留);内置五个用品牌图标, +- 引擎 id 只能用小写字母 / 数字 / `-` / `_`(1–30 位,`internal` 保留);内置六个(含 DuckDuckGo)用品牌图标, + 且**不能删除**(只能改名 / 改地址 / 停用;老库读取时自动补齐缺的内置引擎); 后台新加的引擎 id 自动生成为 `custom-xxx`、图标用通用地球(组件库 EngineIcon 自动回落)。 - 每个引擎可带 `icon`(png / svg 地址,`/` 开头的托管路径或 http(s) 链接):给了就用 `` 渲染自己 的图标,留空回落到品牌图标 / 地球;SVG 走 img 是静态的,不执行脚本。 @@ -214,7 +216,7 @@ PATCH /api/auth/password | 项 | 说明 | | --- | --- | | 推送 | 本地 `develop` 领先远端 20 个提交,尚未推送;Gitea HTTPS 凭据需准备 | -| 搜索引擎 | 内置五个(有品牌图标)+ 后台添加的自定义引擎(最多 10 个,通用地球图标) | +| 搜索引擎 | 内置六个(含 DuckDuckGo,有品牌图标,不能删只能停用)+ 后台添加的自定义引擎(最多 10 个,通用地球图标) | | 搜索选择记忆 | 存本机 localStorage(`stash-search-engine`);换设备 / 清站点数据后回到后台默认 | | 图片上传 | 背景 / 头像 / Logo 只支持 URL,不支持上传 | | 卡片/分组拖拽 | 排序用数值 / 现有 SortableList;分组拖拽排序未做 | diff --git a/src/db/settings.rs b/src/db/settings.rs index c74f5df..7c97307 100644 --- a/src/db/settings.rs +++ b/src/db/settings.rs @@ -14,6 +14,9 @@ pub const MAX_HEADER_ITEMS: usize = 8; pub const MIN_SPEED: u32 = 8; pub const MAX_SPEED: u32 = 120; pub const MAX_FONT_LINKS: usize = 20; +/// 内置搜索引擎:有品牌图标,后台只能改名称 / 地址 / 启停,不能删除。 +pub const BUILTIN_ENGINE_IDS: [&str; 6] = + ["google", "bing", "baidu", "sogou", "360", "duckduckgo"]; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] @@ -215,6 +218,13 @@ impl Default for SearchSettings { icon: String::new(), enabled: true, }, + SearchEngineSettings { + id: "duckduckgo".into(), + name: "DuckDuckGo".into(), + url: "https://duckduckgo.com/?q={query}".into(), + icon: String::new(), + enabled: true, + }, ], } } @@ -490,6 +500,13 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> { )); } } + for builtin in BUILTIN_ENGINE_IDS { + if !ids.contains(builtin) { + return Err(StashError::InvalidDirectory(format!( + "内置搜索引擎不能删除(缺少 {builtin})" + ))); + } + } let default = settings.search.default.trim(); if default != "internal" { let engine = settings @@ -550,7 +567,32 @@ pub fn load(conn: &Connection) -> Result { Settings::default() }); - Ok(SettingsDocument { version, settings }) + Ok(SettingsDocument { + version, + settings: with_builtin_engines(settings), + }) +} + +/// 老库里没有后来新增的内置引擎时补一份默认配置; +/// 用户已经改过的名称 / 地址 / 启停保持不动(按 id 判断),新增的插在内置引擎那一组里。 +fn with_builtin_engines(mut settings: Settings) -> Settings { + let builtin_ids: std::collections::HashSet<&str> = + BUILTIN_ENGINE_IDS.iter().copied().collect(); + + for engine in SearchSettings::default().engines { + if settings.search.engines.iter().any(|item| item.id == engine.id) { + continue; + } + let insert_at = settings + .search + .engines + .iter() + .rposition(|item| builtin_ids.contains(item.id.as_str())) + .map(|position| position + 1) + .unwrap_or(0); + settings.search.engines.insert(insert_at, engine); + } + settings } /// 保存设置;`expected_version` 与库里不一致时返回冲突。 @@ -685,6 +727,47 @@ mod tests { assert!(save(&mut conn, bad_icon, 0).is_err(), "非法图标地址应被拒绝"); } + #[test] + fn builtin_engines_cannot_be_removed() { + let mut conn = db(); + let mut missing = Settings::default(); + missing.search.engines.retain(|engine| engine.id != "duckduckgo"); + assert!(save(&mut conn, missing, 0).is_err(), "缺内置引擎应被拒绝"); + + // 改名 / 停用不算删除,允许 + let mut renamed = Settings::default(); + renamed.search.engines[0].name = "改过的名字".into(); + renamed.search.engines[0].enabled = false; + assert!(save(&mut conn, renamed, 0).is_ok()); + } + + #[test] + fn load_merges_new_builtin_engines() { + let conn = db(); + let mut old = Settings::default(); + old.search.engines.retain(|engine| engine.id != "duckduckgo"); + old.search.engines[0].name = "我改过的 Google".into(); + let raw = serde_json::to_string(&old).expect("应能序列化"); + conn.execute( + "INSERT INTO setting (key, value, version, updated_at) VALUES (?1, ?2, 5, 0)", + params![SETTINGS_KEY, raw], + ) + .expect("应能写入旧设置"); + + let loaded = load(&conn).expect("应能读取"); + assert_eq!(loaded.version, 5); + assert_eq!(loaded.settings.search.engines[0].name, "我改过的 Google"); + assert!( + loaded + .settings + .search + .engines + .iter() + .any(|engine| engine.id == "duckduckgo"), + "老设置里缺的 DuckDuckGo 应被补上" + ); + } + #[test] fn invalid_settings_are_rejected() { let mut conn = db(); diff --git a/stash-web/src/components/admin/AdminNext.tsx b/stash-web/src/components/admin/AdminNext.tsx index 29b2353..13084b2 100644 --- a/stash-web/src/components/admin/AdminNext.tsx +++ b/stash-web/src/components/admin/AdminNext.tsx @@ -37,6 +37,7 @@ import { LoginDialog } from '../auth/LoginDialog' import { GroupMark as GroupGlyph } from '../common/GroupMark' import { isDarkScheme, readThemeOverride, writeThemeOverride } from '../../theme' import { + BUILTIN_SEARCH_ENGINE_IDS, DEFAULT_SEARCH_ENGINES, fetchSettings, saveSettings, @@ -2110,6 +2111,8 @@ function SearchPanel({ } const removeEngine = (id: string) => { + // 内置引擎只能停用,不能删 + if (BUILTIN_SEARCH_ENGINE_IDS.some((builtin) => builtin === id)) return const engines = value.engines.filter((engine) => engine.id !== id) onChange({ ...value, @@ -2156,7 +2159,9 @@ function SearchPanel({ label={`启用 ${engine.name || engine.id}`} onChange={(enabled) => updateEngine(engine.id, { enabled })} /> - removeEngine(engine.id)}>删除 + {!BUILTIN_SEARCH_ENGINE_IDS.some((builtin) => builtin === engine.id) && ( + removeEngine(engine.id)}>删除 + )}
diff --git a/stash-web/src/settings.ts b/stash-web/src/settings.ts index f38544a..937619c 100644 --- a/stash-web/src/settings.ts +++ b/stash-web/src/settings.ts @@ -132,8 +132,25 @@ export const DEFAULT_SEARCH_ENGINES: SearchEngineSettings[] = [ icon: '', enabled: true, }, + { + id: 'duckduckgo', + name: 'DuckDuckGo', + url: 'https://duckduckgo.com/?q={query}', + icon: '', + enabled: true, + }, ] +/** 内置引擎:有品牌图标、后台不能删除(只能改名 / 改地址 / 停用) */ +export const BUILTIN_SEARCH_ENGINE_IDS = [ + 'google', + 'bing', + 'baidu', + 'sogou', + '360', + 'duckduckgo', +] as const + export type SiteSettings = { header: { enabled: boolean