diff --git a/HANDOFF.md b/HANDOFF.md
index e798485..044d2c7 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -117,7 +117,7 @@ stash-ui/src/components/ SearchField / Input / EngineIcon 等
"search": {
"default": "internal",
"engines": [
- { "id": "google", "name": "Google", "url": "https://www.google.com/search?q={query}", "enabled": true },
+ { "id": "google", "name": "Google", "url": "https://www.google.com/search?q={query}", "icon": "", "enabled": true },
{ "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 },
@@ -134,6 +134,8 @@ stash-ui/src/components/ SearchField / Input / EngineIcon 等
- 外部引擎 URL 必须以 `http(s)://` 开头并包含 `{query}`,否则后端 400。
- 引擎 id 只能用小写字母 / 数字 / `-` / `_`(1–30 位,`internal` 保留);内置五个用品牌图标,
后台新加的引擎 id 自动生成为 `custom-xxx`、图标用通用地球(组件库 EngineIcon 自动回落)。
+- 每个引擎可带 `icon`(png / svg 地址,`/` 开头的托管路径或 http(s) 链接):给了就用 `` 渲染自己
+ 的图标,留空回落到品牌图标 / 地球;SVG 走 img 是静态的,不执行脚本。
- `background.light/dark` 为空时用内置 `stash-web/src/assets/site-bg.jpg` / `site-bg-dark.jpg`;否则填托管目录 `public/` 路径或完整 URL。
- 旧 settings JSON 缺失字段会自动回落默认值;多余旧字段在下次保存时被丢弃。
diff --git a/src/db/settings.rs b/src/db/settings.rs
index c92af3c..c74f5df 100644
--- a/src/db/settings.rs
+++ b/src/db/settings.rs
@@ -162,6 +162,8 @@ pub struct SearchEngineSettings {
pub name: String,
/// 搜索地址模板,用 `{query}` 表示关键词
pub url: String,
+ /// 自定义图标地址(png / svg);空表示用内置品牌图标或通用地球
+ pub icon: String,
pub enabled: bool,
}
@@ -182,30 +184,35 @@ impl Default for SearchSettings {
id: "google".into(),
name: "Google".into(),
url: "https://www.google.com/search?q={query}".into(),
+ icon: String::new(),
enabled: true,
},
SearchEngineSettings {
id: "bing".into(),
name: "Bing".into(),
url: "https://www.bing.com/search?q={query}".into(),
+ icon: String::new(),
enabled: true,
},
SearchEngineSettings {
id: "baidu".into(),
name: "百度".into(),
url: "https://www.baidu.com/s?wd={query}".into(),
+ icon: String::new(),
enabled: true,
},
SearchEngineSettings {
id: "sogou".into(),
name: "搜狗".into(),
url: "https://www.sogou.com/web?query={query}".into(),
+ icon: String::new(),
enabled: true,
},
SearchEngineSettings {
id: "360".into(),
name: "360".into(),
url: "https://www.so.com/s?q={query}".into(),
+ icon: String::new(),
enabled: true,
},
],
@@ -469,6 +476,19 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> {
"搜索地址需以 http(s):// 开头并包含 {query}".into(),
));
}
+ let icon = engine.icon.trim();
+ if icon.chars().count() > 500 {
+ return Err(StashError::InvalidDirectory("引擎图标地址最多 500 个字符".into()));
+ }
+ if !icon.is_empty()
+ && !(icon.starts_with("http://")
+ || icon.starts_with("https://")
+ || icon.starts_with('/'))
+ {
+ return Err(StashError::InvalidDirectory(
+ "引擎图标需为空、/ 开头的托管路径或 http(s):// 链接".into(),
+ ));
+ }
}
let default = settings.search.default.trim();
if default != "internal" {
@@ -637,6 +657,7 @@ mod tests {
id: "my-engine_1".into(),
name: "自定义".into(),
url: "https://example.com/search?q={query}".into(),
+ icon: "/favicon.svg".into(),
enabled: true,
});
assert!(save(&mut conn, settings, 0).is_ok(), "合法自定义 id 应能保存");
@@ -647,10 +668,21 @@ mod tests {
id: bad_id.into(),
name: "坏".into(),
url: "https://example.com/?q={query}".into(),
+ icon: String::new(),
enabled: true,
});
assert!(save(&mut conn, bad, 0).is_err(), "{bad_id} 应被拒绝");
}
+
+ let mut bad_icon = Settings::default();
+ bad_icon.search.engines.push(SearchEngineSettings {
+ id: "custom-x".into(),
+ name: "坏图标".into(),
+ url: "https://example.com/?q={query}".into(),
+ icon: "javascript:alert(1)".into(),
+ enabled: true,
+ });
+ assert!(save(&mut conn, bad_icon, 0).is_err(), "非法图标地址应被拒绝");
}
#[test]
diff --git a/stash-web/src/AppNext.tsx b/stash-web/src/AppNext.tsx
index a963e3a..aab45c2 100644
--- a/stash-web/src/AppNext.tsx
+++ b/stash-web/src/AppNext.tsx
@@ -69,7 +69,7 @@ type Route =
| { kind: 'item'; key: string; itemId: string }
| { kind: 'api' }
-const INTERNAL_SEARCH = { id: 'internal', name: '站内搜索', url: '' } as const
+const INTERNAL_SEARCH = { id: 'internal', name: '站内搜索', url: '', icon: '' } as const
const SEARCH_ENGINE_KEY = 'stash-search-engine'
@@ -362,7 +362,12 @@ export default function AppNext() {
INTERNAL_SEARCH,
...site.search.engines
.filter((engine) => engine.enabled)
- .map((engine) => ({ id: engine.id, name: engine.name, url: engine.url })),
+ .map((engine) => ({
+ id: engine.id,
+ name: engine.name,
+ url: engine.url,
+ icon: engine.icon ?? '',
+ })),
]
const searchEngine =
searchEngines.find((engine) => engine.id === searchEngineId) ??
@@ -930,7 +935,7 @@ export default function AppNext() {
}
onClick={() => setEngineOpen((open) => !open)}
>
-