feat(search): 按参考稿重做搜索框并支持后台配置
- 顶部悬浮引擎标签 + 下拉选项,使用 stash-ui 新增的 EngineIcon
- 站内搜索保持现有本地过滤与回车打开首个匹配项
- 外部引擎使用配置的 {query} 模板新标签页搜索
- settings 新增 search.default / search.engines,后台新增『搜索框』配置默认项、名称、地址与启停
- 默认默认搜索项为站内搜索,地址校验必须含 {query}
This commit is contained in:
+1
-1
@@ -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 首页卡片与勾选分组
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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={
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: '管理员',
|
||||||
|
|||||||
Reference in New Issue
Block a user