feat(search): 后台可以自己添加搜索引擎

- 搜索引擎 id 放宽:小写字母/数字/-/_,1–30 位,internal 保留;最多 10 个
- 后台搜索框面板加「添加搜索引擎」(id 自动生成 custom-xxx)与每行「删除」
- 自定义引擎用组件库 EngineIcon 的通用地球图标(@stash/ui 0.3.3)
- 搜索地址仍要求 http(s):// + {query},默认项被删/停用时回落到站内搜索
This commit is contained in:
2026-11-06 12:19:06 +08:00
parent 9f1a8863f4
commit 281f04b060
6 changed files with 108 additions and 35 deletions
+4 -3
View File
@@ -132,7 +132,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 固定为 `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。 - `background.light/dark` 为空时用内置 `stash-web/src/assets/site-bg.jpg` / `site-bg-dark.jpg`;否则填托管目录 `public/` 路径或完整 URL。
- 旧 settings JSON 缺失字段会自动回落默认值;多余旧字段在下次保存时被丢弃。 - 旧 settings JSON 缺失字段会自动回落默认值;多余旧字段在下次保存时被丢弃。
@@ -145,7 +146,7 @@ stash-ui/src/components/ SearchField / Input / EngineIcon 等
- **首页管理** - **首页管理**
- 页头:开关、速度、滚动条目、品牌 / 图标 / 配色; - 页头:开关、速度、滚动条目、品牌 / 图标 / 配色;
- 背景管理:亮色 / 暗色背景 URL + 预览 + 恢复内置; - 背景管理:亮色 / 暗色背景 URL + 预览 + 恢复内置;
- 搜索框:默认搜索项、五个外部引擎的名称 / URL / 启停、恢复默认; - 搜索框:默认搜索项、外部引擎的名称 / URL / 启停、添加 / 删除自定义引擎、恢复默认;
- 侧边栏:资料卡 / 本地服务 / 分组导航显隐; - 侧边栏:资料卡 / 本地服务 / 分组导航显隐;
- 内容卡片:卡片 + 勾选分组 + 预览条数 + 排序; - 内容卡片:卡片 + 勾选分组 + 预览条数 + 排序;
- **资料管理**:头像、名称、角色、简介、位置; - **资料管理**:头像、名称、角色、简介、位置;
@@ -211,7 +212,7 @@ PATCH /api/auth/password
| 项 | 说明 | | 项 | 说明 |
| --- | --- | | --- | --- |
| 推送 | 本地 `develop` 领先远端 20 个提交,尚未推送;Gitea HTTPS 凭据需准备 | | 推送 | 本地 `develop` 领先远端 20 个提交,尚未推送;Gitea HTTPS 凭据需准备 |
| 搜索引擎 | id 固定五个;新增需同时补 EngineIcon、后端校验、前端默认值、后台面板 | | 搜索引擎 | 内置五个(有品牌图标)+ 后台添加的自定义引擎(最多 10 个,通用地球图标) |
| 搜索选择记忆 | 存本机 localStorage(`stash-search-engine`);换设备 / 清站点数据后回到后台默认 | | 搜索选择记忆 | 存本机 localStorage(`stash-search-engine`);换设备 / 清站点数据后回到后台默认 |
| 图片上传 | 背景 / 头像 / Logo 只支持 URL,不支持上传 | | 图片上传 | 背景 / 头像 / Logo 只支持 URL,不支持上传 |
| 卡片/分组拖拽 | 排序用数值 / 现有 SortableList;分组拖拽排序未做 | | 卡片/分组拖拽 | 排序用数值 / 现有 SortableList;分组拖拽排序未做 |
+40 -6
View File
@@ -337,6 +337,16 @@ pub struct SettingsDocument {
pub settings: Settings, 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> { pub fn validate(settings: &Settings) -> Result<(), StashError> {
if settings.header.items.is_empty() { if settings.header.items.is_empty() {
@@ -437,13 +447,13 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> {
} }
let mut ids = std::collections::HashSet::new(); let mut ids = std::collections::HashSet::new();
for engine in &settings.search.engines { for engine in &settings.search.engines {
if !matches!( let id = engine.id.trim();
engine.id.as_str(), if !valid_engine_id(id) {
"google" | "bing" | "baidu" | "sogou" | "360" return Err(StashError::InvalidDirectory(
) { "搜索引擎标识只能用小写字母 / 数字 / - / _(1–30 位,internal 为保留字)".into(),
return Err(StashError::InvalidDirectory("搜索引擎标识不合法".into())); ));
} }
if !ids.insert(engine.id.as_str()) { if !ids.insert(id) {
return Err(StashError::InvalidDirectory("搜索引擎标识不能重复".into())); return Err(StashError::InvalidDirectory("搜索引擎标识不能重复".into()));
} }
let name = engine.name.trim(); let name = engine.name.trim();
@@ -619,6 +629,30 @@ mod tests {
assert!(matches!(error, StashError::Conflict(_))); 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] #[test]
fn invalid_settings_are_rejected() { fn invalid_settings_are_rejected() {
let mut conn = db(); let mut conn = db();
+7
View File
@@ -1606,6 +1606,13 @@ html.theme-transition .site-background {
margin-left: auto; margin-left: auto;
} }
/* 引擎卡片右上角:启用开关 + 删除 */
.search-engine-actions {
display: flex;
align-items: center;
gap: var(--su-space-2);
}
.search-engine-fields { .search-engine-fields {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
+2 -3
View File
@@ -10,7 +10,6 @@ import {
SearchField, SearchField,
Space, Space,
Toaster, Toaster,
type EngineIconName,
type MenuItem, type MenuItem,
} from '@stash/ui' } from '@stash/ui'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
@@ -931,7 +930,7 @@ export default function AppNext() {
} }
onClick={() => setEngineOpen((open) => !open)} onClick={() => setEngineOpen((open) => !open)}
> >
<EngineIcon name={searchEngine.id as EngineIconName} size={14} /> <EngineIcon name={searchEngine.id} size={14} />
<span>{searchEngine.name}</span> <span>{searchEngine.name}</span>
</button> </button>
@@ -978,7 +977,7 @@ export default function AppNext() {
className={`engine-option${engine.id === searchEngine.id ? ' is-active' : ''}`} className={`engine-option${engine.id === searchEngine.id ? ' is-active' : ''}`}
onClick={() => chooseEngine(engine.id)} onClick={() => chooseEngine(engine.id)}
> >
<EngineIcon name={engine.id as EngineIconName} size={18} /> <EngineIcon name={engine.id} size={18} />
<span>{engine.name}</span> <span>{engine.name}</span>
</button> </button>
))} ))}
+53 -20
View File
@@ -28,7 +28,6 @@ import {
ToggleRow, ToggleRow,
Toaster, Toaster,
toast, toast,
type EngineIconName,
type IconName, type IconName,
type MenuItem, type MenuItem,
} from '@stash/ui' } from '@stash/ui'
@@ -2092,6 +2091,32 @@ function SearchPanel({
onChange({ ...value, engines, default: nextDefault }) 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 = [ const defaultOptions = [
{ value: 'internal', label: '站内搜索' }, { value: 'internal', label: '站内搜索' },
...value.engines ...value.engines
@@ -2103,8 +2128,8 @@ function SearchPanel({
<Space direction="column" gap="lg" align="stretch" className="admin-form"> <Space direction="column" gap="lg" align="stretch" className="admin-form">
<Title level={4}>搜索框</Title> <Title level={4}>搜索框</Title>
<Text size="sm" tone="muted"> <Text size="sm" tone="muted">
说明:站内搜索始终可用;外部搜索引擎可以改名称、地址和启停,地址里的 {'{query}'}{' '} 说明:站内搜索始终可用;外部搜索引擎可以改名称、地址和启停,也可以自己加(最多 10 个),
会替换成关键词。默认项是刷新后搜索框选中的引擎。 地址里的 {'{query}'} 会替换成关键词。默认项是刷新后搜索框选中的引擎。
</Text> </Text>
<FormField label="默认搜索项"> <FormField label="默认搜索项">
@@ -2122,13 +2147,16 @@ function SearchPanel({
key={engine.id} key={engine.id}
> >
<div className="search-engine-head"> <div className="search-engine-head">
<EngineIcon name={engine.id as EngineIconName} size={18} /> <EngineIcon name={engine.id} size={18} />
<Text strong>{engine.name || engine.id}</Text> <Text strong>{engine.name || engine.id}</Text>
<Switch <div className="search-engine-actions">
checked={engine.enabled} <Switch
label={`启用 ${engine.name || engine.id}`} checked={engine.enabled}
onChange={(enabled) => updateEngine(engine.id, { enabled })} label={`启用 ${engine.name || engine.id}`}
/> onChange={(enabled) => updateEngine(engine.id, { enabled })}
/>
<TextButton onClick={() => removeEngine(engine.id)}>删除</TextButton>
</div>
</div> </div>
<div className="search-engine-fields"> <div className="search-engine-fields">
<FormField label="名称" htmlFor={`search-name-${engine.id}`}> <FormField label="名称" htmlFor={`search-name-${engine.id}`}>
@@ -2155,17 +2183,22 @@ function SearchPanel({
</div> </div>
<div> <div>
<Button <Space gap="sm" wrap>
variant="secondary" <Button variant="secondary" onClick={addEngine} disabled={value.engines.length >= 10}>
onClick={() => 添加搜索引擎
onChange({ </Button>
default: 'internal', <Button
engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })), variant="secondary"
}) onClick={() =>
} onChange({
> default: 'internal',
恢复默认引擎 engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })),
</Button> })
}
>
恢复默认引擎
</Button>
</Space>
</div> </div>
</Space> </Space>
) )
+2 -3
View File
@@ -78,10 +78,9 @@ export function writeCachedBackground(background: BackgroundSettings): void {
} }
} }
export type SearchEngineId = 'google' | 'bing' | 'baidu' | 'sogou' | '360'
export type SearchEngineSettings = { export type SearchEngineSettings = {
id: SearchEngineId /** 内置引擎用固定 id(对应品牌图标);自定义引擎由后台生成 custom-xxx */
id: string
name: string name: string
/** 搜索地址模板,用 {query} 表示关键词 */ /** 搜索地址模板,用 {query} 表示关键词 */
url: string url: string