From 281f04b060ae0a3adef68c626234e083ff5880bc Mon Sep 17 00:00:00 2001 From: CNWei Date: Fri, 6 Nov 2026 12:19:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(search):=20=E5=90=8E=E5=8F=B0=E5=8F=AF?= =?UTF-8?q?=E4=BB=A5=E8=87=AA=E5=B7=B1=E6=B7=BB=E5=8A=A0=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E5=BC=95=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 搜索引擎 id 放宽:小写字母/数字/-/_,1–30 位,internal 保留;最多 10 个 - 后台搜索框面板加「添加搜索引擎」(id 自动生成 custom-xxx)与每行「删除」 - 自定义引擎用组件库 EngineIcon 的通用地球图标(@stash/ui 0.3.3) - 搜索地址仍要求 http(s):// + {query},默认项被删/停用时回落到站内搜索 --- HANDOFF.md | 7 +- src/db/settings.rs | 46 ++++++++++-- stash-web/src/App.css | 7 ++ stash-web/src/AppNext.tsx | 5 +- stash-web/src/components/admin/AdminNext.tsx | 73 ++++++++++++++------ stash-web/src/settings.ts | 5 +- 6 files changed, 108 insertions(+), 35 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index cbcd121..e798485 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -132,7 +132,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 固定为 `google / bing / baidu / sogou / 360`,因为图标与 id 对应;暂不支持新增自定义引擎。 +- 引擎 id 只能用小写字母 / 数字 / `-` / `_`(1–30 位,`internal` 保留);内置五个用品牌图标, + 后台新加的引擎 id 自动生成为 `custom-xxx`、图标用通用地球(组件库 EngineIcon 自动回落)。 - `background.light/dark` 为空时用内置 `stash-web/src/assets/site-bg.jpg` / `site-bg-dark.jpg`;否则填托管目录 `public/` 路径或完整 URL。 - 旧 settings JSON 缺失字段会自动回落默认值;多余旧字段在下次保存时被丢弃。 @@ -145,7 +146,7 @@ stash-ui/src/components/ SearchField / Input / EngineIcon 等 - **首页管理** - 页头:开关、速度、滚动条目、品牌 / 图标 / 配色; - 背景管理:亮色 / 暗色背景 URL + 预览 + 恢复内置; - - 搜索框:默认搜索项、五个外部引擎的名称 / URL / 启停、恢复默认; + - 搜索框:默认搜索项、外部引擎的名称 / URL / 启停、添加 / 删除自定义引擎、恢复默认; - 侧边栏:资料卡 / 本地服务 / 分组导航显隐; - 内容卡片:卡片 + 勾选分组 + 预览条数 + 排序; - **资料管理**:头像、名称、角色、简介、位置; @@ -211,7 +212,7 @@ PATCH /api/auth/password | 项 | 说明 | | --- | --- | | 推送 | 本地 `develop` 领先远端 20 个提交,尚未推送;Gitea HTTPS 凭据需准备 | -| 搜索引擎 | id 固定五个;新增需同时补 EngineIcon、后端校验、前端默认值、后台面板 | +| 搜索引擎 | 内置五个(有品牌图标)+ 后台添加的自定义引擎(最多 10 个,通用地球图标) | | 搜索选择记忆 | 存本机 localStorage(`stash-search-engine`);换设备 / 清站点数据后回到后台默认 | | 图片上传 | 背景 / 头像 / Logo 只支持 URL,不支持上传 | | 卡片/分组拖拽 | 排序用数值 / 现有 SortableList;分组拖拽排序未做 | diff --git a/src/db/settings.rs b/src/db/settings.rs index 7d8b4ba..c92af3c 100644 --- a/src/db/settings.rs +++ b/src/db/settings.rs @@ -337,6 +337,16 @@ pub struct SettingsDocument { pub settings: Settings, } +/// 引擎 id:小写字母 / 数字 / `-` / `_`,1–30 位;`internal` 是站内搜索的保留 id。 +fn valid_engine_id(id: &str) -> bool { + let count = id.chars().count(); + if count == 0 || count > 30 || id == "internal" { + return false; + } + id.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') +} + /// 校验:条数、长度、速度范围。 pub fn validate(settings: &Settings) -> Result<(), StashError> { if settings.header.items.is_empty() { @@ -437,13 +447,13 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> { } let mut ids = std::collections::HashSet::new(); for engine in &settings.search.engines { - if !matches!( - engine.id.as_str(), - "google" | "bing" | "baidu" | "sogou" | "360" - ) { - return Err(StashError::InvalidDirectory("搜索引擎标识不合法".into())); + let id = engine.id.trim(); + if !valid_engine_id(id) { + return Err(StashError::InvalidDirectory( + "搜索引擎标识只能用小写字母 / 数字 / - / _(1–30 位,internal 为保留字)".into(), + )); } - if !ids.insert(engine.id.as_str()) { + if !ids.insert(id) { return Err(StashError::InvalidDirectory("搜索引擎标识不能重复".into())); } let name = engine.name.trim(); @@ -619,6 +629,30 @@ mod tests { assert!(matches!(error, StashError::Conflict(_))); } + #[test] + fn custom_search_engines_use_safe_ids() { + let mut conn = db(); + let mut settings = Settings::default(); + settings.search.engines.push(SearchEngineSettings { + id: "my-engine_1".into(), + name: "自定义".into(), + url: "https://example.com/search?q={query}".into(), + enabled: true, + }); + assert!(save(&mut conn, settings, 0).is_ok(), "合法自定义 id 应能保存"); + + for bad_id in ["Bad Id", "internal", "", "引擎"] { + let mut bad = Settings::default(); + bad.search.engines.push(SearchEngineSettings { + id: bad_id.into(), + name: "坏".into(), + url: "https://example.com/?q={query}".into(), + enabled: true, + }); + assert!(save(&mut conn, bad, 0).is_err(), "{bad_id} 应被拒绝"); + } + } + #[test] fn invalid_settings_are_rejected() { let mut conn = db(); diff --git a/stash-web/src/App.css b/stash-web/src/App.css index 8d39d33..b4ebe67 100644 --- a/stash-web/src/App.css +++ b/stash-web/src/App.css @@ -1606,6 +1606,13 @@ html.theme-transition .site-background { margin-left: auto; } +/* 引擎卡片右上角:启用开关 + 删除 */ +.search-engine-actions { + display: flex; + align-items: center; + gap: var(--su-space-2); +} + .search-engine-fields { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); diff --git a/stash-web/src/AppNext.tsx b/stash-web/src/AppNext.tsx index 4b91261..a963e3a 100644 --- a/stash-web/src/AppNext.tsx +++ b/stash-web/src/AppNext.tsx @@ -10,7 +10,6 @@ import { SearchField, Space, Toaster, - type EngineIconName, type MenuItem, } from '@stash/ui' import { useEffect, useMemo, useRef, useState } from 'react' @@ -931,7 +930,7 @@ export default function AppNext() { } onClick={() => setEngineOpen((open) => !open)} > - + {searchEngine.name} @@ -978,7 +977,7 @@ export default function AppNext() { className={`engine-option${engine.id === searchEngine.id ? ' is-active' : ''}`} onClick={() => chooseEngine(engine.id)} > - + {engine.name} ))} diff --git a/stash-web/src/components/admin/AdminNext.tsx b/stash-web/src/components/admin/AdminNext.tsx index a7e198b..3a22717 100644 --- a/stash-web/src/components/admin/AdminNext.tsx +++ b/stash-web/src/components/admin/AdminNext.tsx @@ -28,7 +28,6 @@ import { ToggleRow, Toaster, toast, - type EngineIconName, type IconName, type MenuItem, } from '@stash/ui' @@ -2092,6 +2091,32 @@ function SearchPanel({ onChange({ ...value, engines, default: nextDefault }) } + /** 自定义引擎:id 只是内部键,点「添加」自动生成,后台不用手填 */ + const addEngine = () => { + if (value.engines.length >= 10) return + onChange({ + ...value, + engines: [ + ...value.engines, + { + id: `custom-${Date.now().toString(36)}`, + name: '新搜索引擎', + url: '', + enabled: true, + }, + ], + }) + } + + const removeEngine = (id: string) => { + const engines = value.engines.filter((engine) => engine.id !== id) + onChange({ + ...value, + engines, + default: value.default === id ? 'internal' : value.default, + }) + } + const defaultOptions = [ { value: 'internal', label: '站内搜索' }, ...value.engines @@ -2103,8 +2128,8 @@ function SearchPanel({ 搜索框 - 说明:站内搜索始终可用;外部搜索引擎可以改名称、地址和启停,地址里的 {'{query}'}{' '} - 会替换成关键词。默认项是刷新后搜索框选中的引擎。 + 说明:站内搜索始终可用;外部搜索引擎可以改名称、地址和启停,也可以自己加(最多 10 个), + 地址里的 {'{query}'} 会替换成关键词。默认项是刷新后搜索框选中的引擎。 @@ -2122,13 +2147,16 @@ function SearchPanel({ key={engine.id} >
- + {engine.name || engine.id} - updateEngine(engine.id, { enabled })} - /> +
+ updateEngine(engine.id, { enabled })} + /> + removeEngine(engine.id)}>删除 +
@@ -2155,17 +2183,22 @@ function SearchPanel({
- + + + +
) diff --git a/stash-web/src/settings.ts b/stash-web/src/settings.ts index efdd4ad..869a1dc 100644 --- a/stash-web/src/settings.ts +++ b/stash-web/src/settings.ts @@ -78,10 +78,9 @@ export function writeCachedBackground(background: BackgroundSettings): void { } } -export type SearchEngineId = 'google' | 'bing' | 'baidu' | 'sogou' | '360' - export type SearchEngineSettings = { - id: SearchEngineId + /** 内置引擎用固定 id(对应品牌图标);自定义引擎由后台生成 custom-xxx */ + id: string name: string /** 搜索地址模板,用 {query} 表示关键词 */ url: string