Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7d0630fe6 | ||
|
|
733c1552ce | ||
|
|
fdfd17657c | ||
|
|
281f04b060 |
+10
-5
@@ -117,11 +117,12 @@ stash-ui/src/components/ SearchField / Input / EngineIcon 等
|
|||||||
"search": {
|
"search": {
|
||||||
"default": "internal",
|
"default": "internal",
|
||||||
"engines": [
|
"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": "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,11 @@ 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` 保留);内置六个(含 DuckDuckGo)用品牌图标,
|
||||||
|
且**不能删除**(只能改名 / 改地址 / 停用)。不做老库兼容:库里的设置缺内置引擎时,保存会 400;
|
||||||
|
后台新加的引擎 id 自动生成为 `custom-xxx`、图标用通用地球(组件库 EngineIcon 自动回落)。
|
||||||
|
- 每个引擎可带 `icon`(png / svg 地址,`/` 开头的托管路径或 http(s) 链接):给了就用 `<img>` 渲染自己
|
||||||
|
的图标,留空回落到品牌图标 / 地球;SVG 走 img 是静态的,不执行脚本。
|
||||||
- `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 +150,7 @@ stash-ui/src/components/ SearchField / Input / EngineIcon 等
|
|||||||
- **首页管理**
|
- **首页管理**
|
||||||
- 页头:开关、速度、滚动条目、品牌 / 图标 / 配色;
|
- 页头:开关、速度、滚动条目、品牌 / 图标 / 配色;
|
||||||
- 背景管理:亮色 / 暗色背景 URL + 预览 + 恢复内置;
|
- 背景管理:亮色 / 暗色背景 URL + 预览 + 恢复内置;
|
||||||
- 搜索框:默认搜索项、五个外部引擎的名称 / URL / 启停、恢复默认;
|
- 搜索框:默认搜索项、外部引擎的名称 / URL / 启停、添加 / 删除自定义引擎、恢复默认;
|
||||||
- 侧边栏:资料卡 / 本地服务 / 分组导航显隐;
|
- 侧边栏:资料卡 / 本地服务 / 分组导航显隐;
|
||||||
- 内容卡片:卡片 + 勾选分组 + 预览条数 + 排序;
|
- 内容卡片:卡片 + 勾选分组 + 预览条数 + 排序;
|
||||||
- **资料管理**:头像、名称、角色、简介、位置;
|
- **资料管理**:头像、名称、角色、简介、位置;
|
||||||
@@ -211,7 +216,7 @@ PATCH /api/auth/password
|
|||||||
| 项 | 说明 |
|
| 项 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 推送 | 本地 `develop` 领先远端 20 个提交,尚未推送;Gitea HTTPS 凭据需准备 |
|
| 推送 | 本地 `develop` 领先远端 20 个提交,尚未推送;Gitea HTTPS 凭据需准备 |
|
||||||
| 搜索引擎 | id 固定五个;新增需同时补 EngineIcon、后端校验、前端默认值、后台面板 |
|
| 搜索引擎 | 内置六个(含 DuckDuckGo,有品牌图标,不能删只能停用)+ 后台添加的自定义引擎(最多 10 个,通用地球图标) |
|
||||||
| 搜索选择记忆 | 存本机 localStorage(`stash-search-engine`);换设备 / 清站点数据后回到后台默认 |
|
| 搜索选择记忆 | 存本机 localStorage(`stash-search-engine`);换设备 / 清站点数据后回到后台默认 |
|
||||||
| 图片上传 | 背景 / 头像 / Logo 只支持 URL,不支持上传 |
|
| 图片上传 | 背景 / 头像 / Logo 只支持 URL,不支持上传 |
|
||||||
| 卡片/分组拖拽 | 排序用数值 / 现有 SortableList;分组拖拽排序未做 |
|
| 卡片/分组拖拽 | 排序用数值 / 现有 SortableList;分组拖拽排序未做 |
|
||||||
|
|||||||
+103
-6
@@ -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")]
|
||||||
@@ -162,6 +165,8 @@ pub struct SearchEngineSettings {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
/// 搜索地址模板,用 `{query}` 表示关键词
|
/// 搜索地址模板,用 `{query}` 表示关键词
|
||||||
pub url: String,
|
pub url: String,
|
||||||
|
/// 自定义图标地址(png / svg);空表示用内置品牌图标或通用地球
|
||||||
|
pub icon: String,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,30 +187,42 @@ impl Default for SearchSettings {
|
|||||||
id: "google".into(),
|
id: "google".into(),
|
||||||
name: "Google".into(),
|
name: "Google".into(),
|
||||||
url: "https://www.google.com/search?q={query}".into(),
|
url: "https://www.google.com/search?q={query}".into(),
|
||||||
|
icon: String::new(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
SearchEngineSettings {
|
SearchEngineSettings {
|
||||||
id: "bing".into(),
|
id: "bing".into(),
|
||||||
name: "Bing".into(),
|
name: "Bing".into(),
|
||||||
url: "https://www.bing.com/search?q={query}".into(),
|
url: "https://www.bing.com/search?q={query}".into(),
|
||||||
|
icon: String::new(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
SearchEngineSettings {
|
SearchEngineSettings {
|
||||||
id: "baidu".into(),
|
id: "baidu".into(),
|
||||||
name: "百度".into(),
|
name: "百度".into(),
|
||||||
url: "https://www.baidu.com/s?wd={query}".into(),
|
url: "https://www.baidu.com/s?wd={query}".into(),
|
||||||
|
icon: String::new(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
SearchEngineSettings {
|
SearchEngineSettings {
|
||||||
id: "sogou".into(),
|
id: "sogou".into(),
|
||||||
name: "搜狗".into(),
|
name: "搜狗".into(),
|
||||||
url: "https://www.sogou.com/web?query={query}".into(),
|
url: "https://www.sogou.com/web?query={query}".into(),
|
||||||
|
icon: String::new(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
SearchEngineSettings {
|
SearchEngineSettings {
|
||||||
id: "360".into(),
|
id: "360".into(),
|
||||||
name: "360".into(),
|
name: "360".into(),
|
||||||
url: "https://www.so.com/s?q={query}".into(),
|
url: "https://www.so.com/s?q={query}".into(),
|
||||||
|
icon: String::new(),
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
SearchEngineSettings {
|
||||||
|
id: "duckduckgo".into(),
|
||||||
|
name: "DuckDuckGo".into(),
|
||||||
|
url: "https://duckduckgo.com/?q={query}".into(),
|
||||||
|
icon: String::new(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -337,6 +354,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 +464,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();
|
||||||
@@ -459,6 +486,26 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> {
|
|||||||
"搜索地址需以 http(s):// 开头并包含 {query}".into(),
|
"搜索地址需以 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(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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" {
|
||||||
@@ -619,6 +666,56 @@ 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(),
|
||||||
|
icon: "/favicon.svg".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(),
|
||||||
|
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]
|
||||||
|
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]
|
#[test]
|
||||||
fn invalid_settings_are_rejected() {
|
fn invalid_settings_are_rejected() {
|
||||||
let mut conn = db();
|
let mut conn = db();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -70,7 +69,7 @@ type Route =
|
|||||||
| { kind: 'item'; key: string; itemId: string }
|
| { kind: 'item'; key: string; itemId: string }
|
||||||
| { kind: 'api' }
|
| { 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'
|
const SEARCH_ENGINE_KEY = 'stash-search-engine'
|
||||||
|
|
||||||
@@ -363,7 +362,12 @@ export default function AppNext() {
|
|||||||
INTERNAL_SEARCH,
|
INTERNAL_SEARCH,
|
||||||
...site.search.engines
|
...site.search.engines
|
||||||
.filter((engine) => engine.enabled)
|
.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 =
|
const searchEngine =
|
||||||
searchEngines.find((engine) => engine.id === searchEngineId) ??
|
searchEngines.find((engine) => engine.id === searchEngineId) ??
|
||||||
@@ -931,7 +935,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} src={searchEngine.icon} size={14} />
|
||||||
<span>{searchEngine.name}</span>
|
<span>{searchEngine.name}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -978,7 +982,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} src={engine.icon} size={18} />
|
||||||
<span>{engine.name}</span>
|
<span>{engine.name}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -38,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,
|
||||||
@@ -2092,6 +2092,35 @@ 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: '',
|
||||||
|
icon: '',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
engines,
|
||||||
|
default: value.default === id ? 'internal' : value.default,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const defaultOptions = [
|
const defaultOptions = [
|
||||||
{ value: 'internal', label: '站内搜索' },
|
{ value: 'internal', label: '站内搜索' },
|
||||||
...value.engines
|
...value.engines
|
||||||
@@ -2103,8 +2132,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 +2151,18 @@ 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} src={engine.icon} size={18} />
|
||||||
<Text strong>{engine.name || engine.id}</Text>
|
<Text strong>{engine.name || engine.id}</Text>
|
||||||
|
<div className="search-engine-actions">
|
||||||
<Switch
|
<Switch
|
||||||
checked={engine.enabled}
|
checked={engine.enabled}
|
||||||
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>
|
||||||
|
)}
|
||||||
|
</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}`}>
|
||||||
@@ -2150,11 +2184,27 @@ function SearchPanel({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
<FormField
|
||||||
|
label="图标地址"
|
||||||
|
htmlFor={`search-icon-${engine.id}`}
|
||||||
|
hint="png / svg;留空用默认图标(内置品牌或通用地球),站内图片放 public/ 后填 /xxx.svg"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id={`search-icon-${engine.id}`}
|
||||||
|
value={engine.icon ?? ''}
|
||||||
|
placeholder="/icons/xxx.svg 或 https://…"
|
||||||
|
onChange={(event) => updateEngine(engine.id, { icon: event.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
<Space gap="sm" wrap>
|
||||||
|
<Button variant="secondary" onClick={addEngine} disabled={value.engines.length >= 10}>
|
||||||
|
添加搜索引擎
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -2166,6 +2216,7 @@ function SearchPanel({
|
|||||||
>
|
>
|
||||||
恢复默认引擎
|
恢复默认引擎
|
||||||
</Button>
|
</Button>
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -78,13 +78,14 @@ 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
|
||||||
|
/** 自定义图标地址(png / svg);空表示用品牌图标或通用地球 */
|
||||||
|
icon: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,34 +101,56 @@ export const DEFAULT_SEARCH_ENGINES: SearchEngineSettings[] = [
|
|||||||
id: 'google',
|
id: 'google',
|
||||||
name: 'Google',
|
name: 'Google',
|
||||||
url: 'https://www.google.com/search?q={query}',
|
url: 'https://www.google.com/search?q={query}',
|
||||||
|
icon: '',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bing',
|
id: 'bing',
|
||||||
name: 'Bing',
|
name: 'Bing',
|
||||||
url: 'https://www.bing.com/search?q={query}',
|
url: 'https://www.bing.com/search?q={query}',
|
||||||
|
icon: '',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'baidu',
|
id: 'baidu',
|
||||||
name: '百度',
|
name: '百度',
|
||||||
url: 'https://www.baidu.com/s?wd={query}',
|
url: 'https://www.baidu.com/s?wd={query}',
|
||||||
|
icon: '',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'sogou',
|
id: 'sogou',
|
||||||
name: '搜狗',
|
name: '搜狗',
|
||||||
url: 'https://www.sogou.com/web?query={query}',
|
url: 'https://www.sogou.com/web?query={query}',
|
||||||
|
icon: '',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '360',
|
id: '360',
|
||||||
name: '360',
|
name: '360',
|
||||||
url: 'https://www.so.com/s?q={query}',
|
url: 'https://www.so.com/s?q={query}',
|
||||||
|
icon: '',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'duckduckgo',
|
||||||
|
name: 'DuckDuckGo',
|
||||||
|
url: 'https://duckduckgo.com/?q={query}',
|
||||||
|
icon: '',
|
||||||
enabled: true,
|
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