feat(search): 按参考稿重做搜索框并支持后台配置

- 顶部悬浮引擎标签 + 下拉选项,使用 stash-ui 新增的 EngineIcon
- 站内搜索保持现有本地过滤与回车打开首个匹配项
- 外部引擎使用配置的 {query} 模板新标签页搜索
- settings 新增 search.default / search.engines,后台新增『搜索框』配置默认项、名称、地址与启停
- 默认默认搜索项为站内搜索,地址校验必须含 {query}
This commit is contained in:
2026-11-04 18:11:22 +08:00
parent 5ae2088826
commit aa6f341505
6 changed files with 447 additions and 127 deletions
+1 -1
View File
@@ -79,7 +79,7 @@ src/
mod.rs Db 门面(所有方法)+ 迁移测试 mod.rs Db 门面(所有方法)+ 迁移测试
schema.rs 建表、种子、版本迁移(0 / 15→…→23) schema.rs 建表、种子、版本迁移(0 / 15→…→23)
auth.rs 管理员、密码哈希、会话(token 只存 sha256) auth.rs 管理员、密码哈希、会话(token 只存 sha256)
settings.rs settings JSON(header/sidebar/background/profile/site) settings.rs settings JSON(header/sidebar/background/search/profile/site)
entries.rs 条目读取/编辑、浏览数、点赞、审计 entries.rs 条目读取/编辑、浏览数、点赞、审计
groups.rs 分组树新增/移动/排序/删除 groups.rs 分组树新增/移动/排序/删除
cards.rs 首页卡片与勾选分组 cards.rs 首页卡片与勾选分组
+99
View File
@@ -119,6 +119,64 @@ pub struct BackgroundSettings {
pub dark: String, pub dark: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct SearchEngineSettings {
pub id: String,
pub name: String,
/// 搜索地址模板,用 `{query}` 表示关键词
pub url: String,
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct SearchSettings {
/// `internal` 或某个引擎 id
pub default: String,
pub engines: Vec<SearchEngineSettings>,
}
impl Default for SearchSettings {
fn default() -> Self {
Self {
default: "internal".into(),
engines: vec![
SearchEngineSettings {
id: "google".into(),
name: "Google".into(),
url: "https://www.google.com/search?q={query}".into(),
enabled: true,
},
SearchEngineSettings {
id: "bing".into(),
name: "Bing".into(),
url: "https://www.bing.com/search?q={query}".into(),
enabled: true,
},
SearchEngineSettings {
id: "baidu".into(),
name: "百度".into(),
url: "https://www.baidu.com/s?wd={query}".into(),
enabled: true,
},
SearchEngineSettings {
id: "sogou".into(),
name: "搜狗".into(),
url: "https://www.sogou.com/web?query={query}".into(),
enabled: true,
},
SearchEngineSettings {
id: "360".into(),
name: "360".into(),
url: "https://www.so.com/s?q={query}".into(),
enabled: true,
},
],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)] #[serde(default)]
pub struct ProfileSettings { pub struct ProfileSettings {
@@ -230,6 +288,7 @@ pub struct Settings {
pub header: HeaderSettings, pub header: HeaderSettings,
pub sidebar: SidebarSettings, pub sidebar: SidebarSettings,
pub background: BackgroundSettings, pub background: BackgroundSettings,
pub search: SearchSettings,
pub profile: ProfileSettings, pub profile: ProfileSettings,
pub site: SiteMetaSettings, pub site: SiteMetaSettings,
} }
@@ -295,6 +354,46 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> {
"暗色背景地址最多 500 个字符".into(), "暗色背景地址最多 500 个字符".into(),
)); ));
} }
if settings.search.engines.len() > 10 {
return Err(StashError::InvalidDirectory("搜索引擎最多 10 个".into()));
}
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()));
}
if !ids.insert(engine.id.as_str()) {
return Err(StashError::InvalidDirectory("搜索引擎标识不能重复".into()));
}
let name = engine.name.trim();
if name.is_empty() || name.chars().count() > 20 {
return Err(StashError::InvalidDirectory("搜索引擎名称需为 1–20 个字".into()));
}
let url = engine.url.trim();
if url.chars().count() > 500
|| !(url.starts_with("http://") || url.starts_with("https://"))
|| !url.contains("{query}")
{
return Err(StashError::InvalidDirectory(
"搜索地址需以 http(s):// 开头并包含 {query}".into(),
));
}
}
let default = settings.search.default.trim();
if default != "internal" {
let engine = settings
.search
.engines
.iter()
.find(|engine| engine.id == default)
.ok_or_else(|| StashError::InvalidDirectory("默认搜索引擎不存在".into()))?;
if !engine.enabled {
return Err(StashError::InvalidDirectory("默认搜索引擎未启用".into()));
}
}
if settings.site.title.chars().count() > 120 { if settings.site.title.chars().count() > 120 {
return Err(StashError::InvalidDirectory("页面标题最多 120 个字".into())); return Err(StashError::InvalidDirectory("页面标题最多 120 个字".into()));
} }
+94 -47
View File
@@ -1437,96 +1437,143 @@ html.theme-transition .site-background {
} }
} }
/* ---------- 搜索框:搜索引擎切换(效果五) ---------- */ /* ---------- 搜索框:顶部引擎标签 + 下拉选项 ---------- */
.engine-switch { .search-shell {
position: relative; position: relative;
display: inline-flex; width: min(100%, 480px);
align-items: center;
} }
.engine-trigger { .engine-badge {
position: absolute;
top: -12px;
left: 16px;
z-index: 2;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; gap: 5px;
width: 26px; padding: 2px 8px;
height: 26px; border: 1px solid var(--su-border);
padding: 0; border-radius: 6px;
border: 0; background-color: var(--su-bg-card);
border-radius: var(--su-radius-xs); box-shadow: var(--su-shadow-card);
background: transparent; color: var(--su-text-main);
color: var(--su-text-muted); font: inherit;
font-size: var(--su-font-size-lg); font-size: 11px;
line-height: 1.4;
cursor: pointer; cursor: pointer;
transition: transition:
color var(--su-duration-fast) var(--su-ease-in-out), background-color var(--su-duration-fast) var(--su-ease-in-out),
background-color var(--su-duration-fast) var(--su-ease-in-out); border-color var(--su-duration-fast) var(--su-ease-in-out);
} }
.engine-trigger:hover, .engine-badge:hover,
.engine-trigger[aria-expanded='true'] { .engine-badge[aria-expanded='true'] {
border-color: var(--su-border-strong);
background-color: var(--su-bg-subtle); background-color: var(--su-bg-subtle);
color: var(--su-text-main);
} }
.engine-trigger svg { .engine-badge svg {
display: block; display: block;
flex-shrink: 0;
} }
.engine-menu { .engine-dropdown {
position: absolute; position: absolute;
top: 50%; top: calc(100% + 8px);
left: calc(100% + 6px); left: 10px;
z-index: var(--su-z-float); z-index: var(--su-z-float);
display: flex; display: flex;
flex-direction: row; flex-direction: column;
gap: 2px; gap: 2px;
min-width: 170px;
padding: var(--su-space-1); padding: var(--su-space-1);
border: 1px solid var(--su-border); border: 1px solid var(--su-border);
border-radius: var(--su-radius-sm); border-radius: var(--su-radius-sm);
background-color: var(--su-bg-card); background-color: var(--su-bg-card);
box-shadow: var(--su-shadow-pop); box-shadow: var(--su-shadow-pop);
transform: translateY(-50%); animation: engine-menu-in 160ms var(--su-ease-out);
animation: engine-bar-in 140ms var(--su-ease-out);
} }
@keyframes engine-bar-in { @keyframes engine-menu-in {
from { from {
opacity: 0; opacity: 0;
transform: translate(-6px, -50%); transform: translateY(-6px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translate(0, -50%); transform: translateY(0);
} }
} }
.engine-option { .engine-divider {
display: inline-flex; height: 1px;
align-items: center; margin: 4px 8px;
gap: 4px; background-color: var(--su-border);
padding: var(--su-space-1) var(--su-space-2);
border: 0;
border-radius: var(--su-radius-xs);
background: transparent;
color: var(--su-text-sub);
font: inherit;
font-size: var(--su-font-size-xs);
white-space: nowrap;
cursor: pointer;
} }
.engine-option-icon { .engine-option {
display: inline-flex; display: flex;
font-size: var(--su-font-size-lg); align-items: center;
gap: 10px;
width: 100%;
padding: 8px 12px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--su-text-main);
font: inherit;
font-size: 13px;
text-align: left;
white-space: nowrap;
cursor: pointer;
transition: background-color var(--su-duration-fast) var(--su-ease-in-out);
} }
.engine-option:hover { .engine-option:hover {
background-color: var(--su-bg-subtle); background-color: var(--su-bg-subtle);
color: var(--su-text-main);
} }
.engine-option.is-active { .engine-option.is-active {
background-color: var(--su-color-primary-soft); background-color: var(--su-color-primary-soft);
color: var(--su-color-primary); color: var(--su-color-primary);
font-weight: 600; }
/* 后台:搜索框引擎配置 */
.search-engine-list {
display: flex;
flex-direction: column;
gap: var(--su-space-3);
}
.search-engine-row {
padding: var(--su-space-4);
border: 1px solid var(--su-border);
border-radius: var(--su-radius-md);
}
.search-engine-row.is-disabled {
opacity: 0.55;
}
.search-engine-head {
display: flex;
align-items: center;
gap: var(--su-space-2);
margin-bottom: var(--su-space-3);
}
.search-engine-head > :last-child {
margin-left: auto;
}
.search-engine-fields {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
gap: var(--su-space-3);
}
@media (width <= 900px) {
.search-engine-fields {
grid-template-columns: minmax(0, 1fr);
}
} }
+85 -78
View File
@@ -1,12 +1,13 @@
import { import {
Empty, Empty,
Icon, EngineIcon,
Layout, Layout,
Menu, Menu,
Notice, Notice,
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'
@@ -27,6 +28,7 @@ import { ServicePanel } from './components/sidebar/ServicePanel'
import { useProjects } from './useProjects' import { useProjects } from './useProjects'
import { fetchSession, logout, type SessionState } from './auth' import { fetchSession, logout, type SessionState } from './auth'
import { import {
DEFAULT_SEARCH_ENGINES,
fetchSettings, fetchSettings,
type SettingsDocument, type SettingsDocument,
type SiteSettings, type SiteSettings,
@@ -63,27 +65,7 @@ type Route =
| { kind: 'item'; key: string; itemId: string } | { kind: 'item'; key: string; itemId: string }
| { kind: 'api' } | { kind: 'api' }
const SEARCH_ENGINES = [ const INTERNAL_SEARCH = { id: 'internal', name: '站内搜索', url: '' } as const
{ key: 'local', name: '本地', search: '', icon: 'search_3_line' },
{ key: 'google', name: 'Google', search: 'https://www.google.com/search?q=', icon: 'google_fill' },
{ key: 'bing', name: 'Bing', search: 'https://www.bing.com/search?q=', icon: 'edge_fill' },
{ key: 'baidu', name: '百度', search: 'https://www.baidu.com/s?wd=', icon: 'compass_line' },
{ key: 'sogou', name: '搜狗', search: 'https://www.sogou.com/web?query=', icon: 'search_2_line' },
{ key: '360', name: '360', search: 'https://www.so.com/s?q=', icon: 'safety_certificate_line' },
] as const
type SearchEngine = (typeof SEARCH_ENGINES)[number]
const SEARCH_ENGINE_KEY = 'stash-search-engine'
function readSearchEngine(): SearchEngine {
try {
const saved = window.localStorage.getItem(SEARCH_ENGINE_KEY)
return SEARCH_ENGINES.find((engine) => engine.key === saved) ?? SEARCH_ENGINES[0]
} catch {
return SEARCH_ENGINES[0]
}
}
function parseRoute(pathname: string): Route { function parseRoute(pathname: string): Route {
const segments = pathname.split('/').filter(Boolean) const segments = pathname.split('/').filter(Boolean)
@@ -234,7 +216,7 @@ export default function AppNext() {
const [editingKey, setEditingKey] = useState<string | null>(null) const [editingKey, setEditingKey] = useState<string | null>(null)
const [scrolled, setScrolled] = useState(false) const [scrolled, setScrolled] = useState(false)
const searchRef = useRef<HTMLInputElement>(null) const searchRef = useRef<HTMLInputElement>(null)
const [searchEngine, setSearchEngine] = useState<SearchEngine>(readSearchEngine) const [searchEngineId, setSearchEngineId] = useState<string | null>(null)
const [engineOpen, setEngineOpen] = useState(false) const [engineOpen, setEngineOpen] = useState(false)
const engineRef = useRef<HTMLDivElement>(null) const engineRef = useRef<HTMLDivElement>(null)
@@ -321,6 +303,10 @@ export default function AppNext() {
show_nav: true, show_nav: true,
}, },
background: { light: '', dark: '' }, background: { light: '', dark: '' },
search: {
default: 'internal',
engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })),
},
profile: { profile: {
name: '本地管理员', name: '本地管理员',
role: '管理员', role: '管理员',
@@ -339,6 +325,17 @@ export default function AppNext() {
const keywordLower = keyword.trim().toLowerCase() const keywordLower = keyword.trim().toLowerCase()
const searchEngines = [
INTERNAL_SEARCH,
...site.search.engines
.filter((engine) => engine.enabled)
.map((engine) => ({ id: engine.id, name: engine.name, url: engine.url })),
]
const searchEngine =
searchEngines.find((engine) => engine.id === searchEngineId) ??
searchEngines.find((engine) => engine.id === site.search.default) ??
INTERNAL_SEARCH
// 配色:本机偏好优先于站点默认,写在 <html data-scheme> 上, // 配色:本机偏好优先于站点默认,写在 <html data-scheme> 上,
// 深色由组件库的令牌块接管,页面与组件都不用改 // 深色由组件库的令牌块接管,页面与组件都不用改
const [themeOverride, setThemeOverride] = useState<ThemeChoice | null>(() => const [themeOverride, setThemeOverride] = useState<ThemeChoice | null>(() =>
@@ -410,7 +407,7 @@ export default function AppNext() {
event.preventDefault() event.preventDefault()
setEngineOpen(false) setEngineOpen(false)
if (searchEngine.key === 'local') { if (searchEngine.id === 'internal') {
const target = findLocalTarget(manage.groups, keywordLower) const target = findLocalTarget(manage.groups, keywordLower)
if (target !== undefined) { if (target !== undefined) {
navigate( navigate(
@@ -422,24 +419,19 @@ export default function AppNext() {
return return
} }
window.open( const searchUrl = searchEngine.url.replaceAll(
`${searchEngine.search}${encodeURIComponent(keyword.trim())}`, '{query}',
'_blank', encodeURIComponent(keyword.trim()),
'noopener,noreferrer',
) )
window.open(searchUrl, '_blank', 'noopener,noreferrer')
} }
input.addEventListener('keydown', handleKeyDown) input.addEventListener('keydown', handleKeyDown)
return () => input.removeEventListener('keydown', handleKeyDown) return () => input.removeEventListener('keydown', handleKeyDown)
}, [keyword, keywordLower, manage.groups, navigate, searchEngine]) }, [keyword, keywordLower, manage.groups, navigate, searchEngine])
const chooseEngine = (next: SearchEngine) => { const chooseEngine = (next: string) => {
setSearchEngine(next) setSearchEngineId(next)
setEngineOpen(false) setEngineOpen(false)
try {
window.localStorage.setItem(SEARCH_ENGINE_KEY, next.key)
} catch {
// 隐私模式下只影响本次选择,不阻断搜索
}
searchRef.current?.focus() searchRef.current?.focus()
} }
@@ -827,61 +819,76 @@ export default function AppNext() {
} }
toolbar={ toolbar={
<div className="search-toolbar"> <div className="search-toolbar">
<SearchField <div className="search-shell" ref={engineRef}>
value={keyword} <button
onChange={setKeyword} type="button"
inputRef={searchRef} className="engine-badge"
placeholder={ aria-haspopup="menu"
searchEngine.key === 'local' aria-expanded={engineOpen}
? '搜索本地项目…' aria-label={`搜索引擎:${searchEngine.name},点击切换`}
: `用 ${searchEngine.name} 搜索,回车打开…` title={
} searchEngine.id === 'internal'
label={ ? '当前:站内搜索;回车打开第一个匹配项'
searchEngine.key === 'local' : `当前:${searchEngine.name};回车用该引擎搜索`
? '搜索本地项目,回车打开第一个匹配项' }
: `用 ${searchEngine.name} 搜索,回车打开新标签页` onClick={() => setEngineOpen((open) => !open)}
} >
variant="card" <EngineIcon name={searchEngine.id as EngineIconName} size={14} />
hotkey="k" <span>{searchEngine.name}</span>
leading={ </button>
<div className="engine-switch" ref={engineRef}>
<SearchField
value={keyword}
onChange={setKeyword}
inputRef={searchRef}
maxWidth="100%"
placeholder={
searchEngine.id === 'internal'
? '搜索站内项目…'
: `使用 ${searchEngine.name} 搜索...`
}
label={
searchEngine.id === 'internal'
? '搜索站内项目,回车打开第一个匹配项'
: `使用 ${searchEngine.name} 搜索,回车打开新标签页`
}
variant="card"
hotkey="k"
/>
{engineOpen && (
<div className="engine-dropdown" role="menu" aria-label="选择搜索引擎">
<button <button
type="button" type="button"
className="engine-trigger" role="menuitemradio"
aria-haspopup="menu" aria-checked={searchEngine.id === 'internal'}
aria-expanded={engineOpen} className={`engine-option${searchEngine.id === 'internal' ? ' is-active' : ''}`}
aria-label={`搜索引擎:${searchEngine.name},点击切换`} onClick={() => chooseEngine('internal')}
title={
searchEngine.key === 'local'
? '当前:本地搜索;回车打开第一个匹配项'
: `当前:${searchEngine.name};回车用该引擎搜索`
}
onClick={() => setEngineOpen((open) => !open)}
> >
<Icon name={searchEngine.icon} /> <EngineIcon name="internal" size={18} />
<span>站内搜索</span>
</button> </button>
{engineOpen && ( {searchEngines.length > 1 && (
<div className="engine-menu" role="menu" aria-label="选择搜索引擎"> <>
{SEARCH_ENGINES.map((engine) => ( <div className="engine-divider" />
{searchEngines.slice(1).map((engine) => (
<button <button
key={engine.key} key={engine.id}
type="button" type="button"
role="menuitemradio" role="menuitemradio"
aria-checked={engine.key === searchEngine.key} aria-checked={engine.id === searchEngine.id}
className={`engine-option${engine.key === searchEngine.key ? ' is-active' : ''}`} className={`engine-option${engine.id === searchEngine.id ? ' is-active' : ''}`}
onClick={() => chooseEngine(engine)} onClick={() => chooseEngine(engine.id)}
> >
<span className="engine-option-icon"> <EngineIcon name={engine.id as EngineIconName} size={18} />
<Icon name={engine.icon} /> <span>{engine.name}</span>
</span>
{engine.name}
</button> </button>
))} ))}
</div> </>
)} )}
</div> </div>
} )}
/> </div>
</div> </div>
} }
sider={ sider={
+113 -1
View File
@@ -4,6 +4,7 @@ import {
Card, Card,
Caret, Caret,
Empty, Empty,
EngineIcon,
FormField, FormField,
Icon, Icon,
ICON_NAMES, ICON_NAMES,
@@ -27,6 +28,7 @@ import {
ToggleRow, ToggleRow,
Toaster, Toaster,
toast, toast,
type EngineIconName,
type IconName, type IconName,
type MenuItem, type MenuItem,
} from '@stash/ui' } from '@stash/ui'
@@ -36,11 +38,13 @@ 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 {
DEFAULT_SEARCH_ENGINES,
fetchSettings, fetchSettings,
saveSettings, saveSettings,
TONE_OPTIONS, TONE_OPTIONS,
type ProfileSettings,
type BackgroundSettings, type BackgroundSettings,
type ProfileSettings,
type SearchSettings,
type SettingsDocument, type SettingsDocument,
type SettingsTone, type SettingsTone,
type SiteSettings, type SiteSettings,
@@ -81,6 +85,7 @@ type TabKey =
| 'home-cards' | 'home-cards'
| 'home-header' | 'home-header'
| 'home-background' | 'home-background'
| 'home-search'
| 'profile' | 'profile'
| 'system' | 'system'
@@ -94,6 +99,7 @@ const ADMIN_NAV: MenuItem[] = [
children: [ children: [
{ key: 'home-header', label: '页头' }, { key: 'home-header', label: '页头' },
{ key: 'home-background', label: '背景管理' }, { key: 'home-background', label: '背景管理' },
{ key: 'home-search', label: '搜索框' },
{ key: 'home-sidebar', label: '侧边栏' }, { key: 'home-sidebar', label: '侧边栏' },
{ key: 'home-cards', label: '内容卡片' }, { key: 'home-cards', label: '内容卡片' },
], ],
@@ -391,6 +397,13 @@ export function AdminNext() {
/> />
)} )}
{tab === 'home-search' && (
<SearchPanel
value={draft.search}
onChange={(search) => setDraft({ ...draft, search })}
/>
)}
{tab === 'profile' && ( {tab === 'profile' && (
<ProfilePanel <ProfilePanel
value={draft.profile} value={draft.profile}
@@ -402,6 +415,7 @@ export function AdminNext() {
{(tab === 'home-header' || {(tab === 'home-header' ||
tab === 'home-background' || tab === 'home-background' ||
tab === 'home-search' ||
tab === 'profile') && ( tab === 'profile') && (
<Space gap="sm" justify="end" className="admin-actions"> <Space gap="sm" justify="end" className="admin-actions">
<Button <Button
@@ -2051,6 +2065,104 @@ function HeaderPanel({
) )
} }
function SearchPanel({
value,
onChange,
}: {
value: SearchSettings
onChange: (search: SearchSettings) => void
}) {
const updateEngine = (
id: string,
patch: Partial<SearchSettings['engines'][number]>,
) => {
const engines = value.engines.map((engine) =>
engine.id === id ? { ...engine, ...patch } : engine,
)
const nextDefault =
patch.enabled === false && value.default === id ? 'internal' : value.default
onChange({ ...value, engines, default: nextDefault })
}
const defaultOptions = [
{ value: 'internal', label: '站内搜索' },
...value.engines
.filter((engine) => engine.enabled)
.map((engine) => ({ value: engine.id, label: engine.name })),
]
return (
<Space direction="column" gap="lg" align="stretch" className="admin-form">
<Title level={4}>搜索框</Title>
<Text size="sm" tone="muted">
说明:站内搜索始终可用;外部搜索引擎可以改名称、地址和启停,地址里的 {'{query}'}{' '}
会替换成关键词。默认项是刷新后搜索框选中的引擎。
</Text>
<FormField label="默认搜索项">
<Select
options={defaultOptions}
value={value.default}
onChange={(next) => onChange({ ...value, default: next })}
/>
</FormField>
<div className="search-engine-list">
{value.engines.map((engine) => (
<div
className={`search-engine-row${engine.enabled ? '' : ' is-disabled'}`}
key={engine.id}
>
<div className="search-engine-head">
<EngineIcon name={engine.id as EngineIconName} size={18} />
<Text strong>{engine.name || engine.id}</Text>
<Switch
checked={engine.enabled}
label={`启用 ${engine.name || engine.id}`}
onChange={(enabled) => updateEngine(engine.id, { enabled })}
/>
</div>
<div className="search-engine-fields">
<FormField label="名称" htmlFor={`search-name-${engine.id}`}>
<Input
id={`search-name-${engine.id}`}
value={engine.name}
onChange={(event) => updateEngine(engine.id, { name: event.target.value })}
/>
</FormField>
<FormField
label="搜索地址"
htmlFor={`search-url-${engine.id}`}
hint="必须包含 {query}"
>
<Input
id={`search-url-${engine.id}`}
value={engine.url}
onChange={(event) => updateEngine(engine.id, { url: event.target.value })}
/>
</FormField>
</div>
</div>
))}
</div>
<div>
<Button
variant="secondary"
onClick={() =>
onChange({
default: 'internal',
engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })),
})
}
>
恢复默认引擎
</Button>
</div>
</Space>
)
}
function BackgroundPanel({ function BackgroundPanel({
value, value,
onChange, onChange,
+55
View File
@@ -30,6 +30,56 @@ export type BackgroundSettings = {
dark: string dark: string
} }
export type SearchEngineId = 'google' | 'bing' | 'baidu' | 'sogou' | '360'
export type SearchEngineSettings = {
id: SearchEngineId
name: string
/** 搜索地址模板,用 {query} 表示关键词 */
url: string
enabled: boolean
}
export type SearchSettings = {
/** `internal` 或某个引擎 id */
default: string
engines: SearchEngineSettings[]
}
/** 首次启动 / 恢复默认时的搜索引擎清单 */
export const DEFAULT_SEARCH_ENGINES: SearchEngineSettings[] = [
{
id: 'google',
name: 'Google',
url: 'https://www.google.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: '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,
},
]
export type SiteSettings = { export type SiteSettings = {
header: { header: {
enabled: boolean enabled: boolean
@@ -48,6 +98,7 @@ export type SiteSettings = {
show_nav: boolean show_nav: boolean
} }
background: BackgroundSettings background: BackgroundSettings
search: SearchSettings
profile: ProfileSettings profile: ProfileSettings
/** 站点级:标签标题 / favicon / 品牌 / 页头操作工具 */ /** 站点级:标签标题 / favicon / 品牌 / 页头操作工具 */
site: SiteMetaSettings site: SiteMetaSettings
@@ -116,6 +167,10 @@ export function defaultSettings(): SiteSettings {
show_nav: true, show_nav: true,
}, },
background: { light: '', dark: '' }, background: { light: '', dark: '' },
search: {
default: 'internal',
engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })),
},
profile: { profile: {
name: '本地管理员', name: '本地管理员',
role: '管理员', role: '管理员',