feat(search): 内置 DuckDuckGo,内置引擎不允许删除
- 默认引擎清单加 duckduckgo(https://duckduckgo.com/?q={query}),图标用组件库 0.3.5 的 DuckDuckGo 品牌图标 - 后端:内置六个必须存在,缺了返回 400;老库读取时按 id 补齐缺的内置引擎(插在内置那一组里,不覆盖用户改过的值) - 后台:内置引擎不渲染「删除」按钮,removeEngine 也做了兜底;自定义引擎照常可删
This commit is contained in:
+5
-3
@@ -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": "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": "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": "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": "" },
|
"profile": { "name": "...", "role": "...", "description": "...", "location": "...", "avatar": "" },
|
||||||
@@ -132,7 +133,8 @@ stash-ui/src/components/ SearchField / Input / EngineIcon 等
|
|||||||
- `search.default` 可以是 `internal` 或某个引擎 id;默认默认值是 `internal`。
|
- `search.default` 可以是 `internal` 或某个引擎 id;默认默认值是 `internal`。
|
||||||
- 上面是字段结构示例;实际值以 `GET /api/settings` 为准(交接时 `search.default=internal`、`site.theme=light`,background 两项使用自定义 picsum 图)。
|
- 上面是字段结构示例;实际值以 `GET /api/settings` 为准(交接时 `search.default=internal`、`site.theme=light`,background 两项使用自定义 picsum 图)。
|
||||||
- 外部引擎 URL 必须以 `http(s)://` 开头并包含 `{query}`,否则后端 400。
|
- 外部引擎 URL 必须以 `http(s)://` 开头并包含 `{query}`,否则后端 400。
|
||||||
- 引擎 id 只能用小写字母 / 数字 / `-` / `_`(1–30 位,`internal` 保留);内置五个用品牌图标,
|
- 引擎 id 只能用小写字母 / 数字 / `-` / `_`(1–30 位,`internal` 保留);内置六个(含 DuckDuckGo)用品牌图标,
|
||||||
|
且**不能删除**(只能改名 / 改地址 / 停用;老库读取时自动补齐缺的内置引擎);
|
||||||
后台新加的引擎 id 自动生成为 `custom-xxx`、图标用通用地球(组件库 EngineIcon 自动回落)。
|
后台新加的引擎 id 自动生成为 `custom-xxx`、图标用通用地球(组件库 EngineIcon 自动回落)。
|
||||||
- 每个引擎可带 `icon`(png / svg 地址,`/` 开头的托管路径或 http(s) 链接):给了就用 `<img>` 渲染自己
|
- 每个引擎可带 `icon`(png / svg 地址,`/` 开头的托管路径或 http(s) 链接):给了就用 `<img>` 渲染自己
|
||||||
的图标,留空回落到品牌图标 / 地球;SVG 走 img 是静态的,不执行脚本。
|
的图标,留空回落到品牌图标 / 地球;SVG 走 img 是静态的,不执行脚本。
|
||||||
@@ -214,7 +216,7 @@ PATCH /api/auth/password
|
|||||||
| 项 | 说明 |
|
| 项 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 推送 | 本地 `develop` 领先远端 20 个提交,尚未推送;Gitea HTTPS 凭据需准备 |
|
| 推送 | 本地 `develop` 领先远端 20 个提交,尚未推送;Gitea HTTPS 凭据需准备 |
|
||||||
| 搜索引擎 | 内置五个(有品牌图标)+ 后台添加的自定义引擎(最多 10 个,通用地球图标) |
|
| 搜索引擎 | 内置六个(含 DuckDuckGo,有品牌图标,不能删只能停用)+ 后台添加的自定义引擎(最多 10 个,通用地球图标) |
|
||||||
| 搜索选择记忆 | 存本机 localStorage(`stash-search-engine`);换设备 / 清站点数据后回到后台默认 |
|
| 搜索选择记忆 | 存本机 localStorage(`stash-search-engine`);换设备 / 清站点数据后回到后台默认 |
|
||||||
| 图片上传 | 背景 / 头像 / Logo 只支持 URL,不支持上传 |
|
| 图片上传 | 背景 / 头像 / Logo 只支持 URL,不支持上传 |
|
||||||
| 卡片/分组拖拽 | 排序用数值 / 现有 SortableList;分组拖拽排序未做 |
|
| 卡片/分组拖拽 | 排序用数值 / 现有 SortableList;分组拖拽排序未做 |
|
||||||
|
|||||||
+84
-1
@@ -14,6 +14,9 @@ pub const MAX_HEADER_ITEMS: usize = 8;
|
|||||||
pub const MIN_SPEED: u32 = 8;
|
pub const MIN_SPEED: u32 = 8;
|
||||||
pub const MAX_SPEED: u32 = 120;
|
pub const MAX_SPEED: u32 = 120;
|
||||||
pub const MAX_FONT_LINKS: usize = 20;
|
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)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
@@ -215,6 +218,13 @@ impl Default for SearchSettings {
|
|||||||
icon: String::new(),
|
icon: String::new(),
|
||||||
enabled: true,
|
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();
|
let default = settings.search.default.trim();
|
||||||
if default != "internal" {
|
if default != "internal" {
|
||||||
let engine = settings
|
let engine = settings
|
||||||
@@ -550,7 +567,32 @@ pub fn load(conn: &Connection) -> Result<SettingsDocument, StashError> {
|
|||||||
Settings::default()
|
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` 与库里不一致时返回冲突。
|
/// 保存设置;`expected_version` 与库里不一致时返回冲突。
|
||||||
@@ -685,6 +727,47 @@ mod tests {
|
|||||||
assert!(save(&mut conn, bad_icon, 0).is_err(), "非法图标地址应被拒绝");
|
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]
|
#[test]
|
||||||
fn invalid_settings_are_rejected() {
|
fn invalid_settings_are_rejected() {
|
||||||
let mut conn = db();
|
let mut conn = db();
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { LoginDialog } from '../auth/LoginDialog'
|
|||||||
import { GroupMark as GroupGlyph } from '../common/GroupMark'
|
import { GroupMark as GroupGlyph } from '../common/GroupMark'
|
||||||
import { isDarkScheme, readThemeOverride, writeThemeOverride } from '../../theme'
|
import { isDarkScheme, readThemeOverride, writeThemeOverride } from '../../theme'
|
||||||
import {
|
import {
|
||||||
|
BUILTIN_SEARCH_ENGINE_IDS,
|
||||||
DEFAULT_SEARCH_ENGINES,
|
DEFAULT_SEARCH_ENGINES,
|
||||||
fetchSettings,
|
fetchSettings,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
@@ -2110,6 +2111,8 @@ function SearchPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removeEngine = (id: string) => {
|
const removeEngine = (id: string) => {
|
||||||
|
// 内置引擎只能停用,不能删
|
||||||
|
if (BUILTIN_SEARCH_ENGINE_IDS.some((builtin) => builtin === id)) return
|
||||||
const engines = value.engines.filter((engine) => engine.id !== id)
|
const engines = value.engines.filter((engine) => engine.id !== id)
|
||||||
onChange({
|
onChange({
|
||||||
...value,
|
...value,
|
||||||
@@ -2156,7 +2159,9 @@ function SearchPanel({
|
|||||||
label={`启用 ${engine.name || engine.id}`}
|
label={`启用 ${engine.name || engine.id}`}
|
||||||
onChange={(enabled) => updateEngine(engine.id, { enabled })}
|
onChange={(enabled) => updateEngine(engine.id, { enabled })}
|
||||||
/>
|
/>
|
||||||
|
{!BUILTIN_SEARCH_ENGINE_IDS.some((builtin) => builtin === engine.id) && (
|
||||||
<TextButton onClick={() => removeEngine(engine.id)}>删除</TextButton>
|
<TextButton onClick={() => removeEngine(engine.id)}>删除</TextButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="search-engine-fields">
|
<div className="search-engine-fields">
|
||||||
|
|||||||
@@ -132,8 +132,25 @@ export const DEFAULT_SEARCH_ENGINES: SearchEngineSettings[] = [
|
|||||||
icon: '',
|
icon: '',
|
||||||
enabled: true,
|
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 = {
|
export type SiteSettings = {
|
||||||
header: {
|
header: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user