diff --git a/HANDOFF.md b/HANDOFF.md index 3027260..a7800b1 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -79,7 +79,7 @@ src/ mod.rs Db 门面(所有方法)+ 迁移测试 schema.rs 建表、种子、版本迁移(0 / 15→…→23) 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 条目读取/编辑、浏览数、点赞、审计 groups.rs 分组树新增/移动/排序/删除 cards.rs 首页卡片与勾选分组 diff --git a/src/db/settings.rs b/src/db/settings.rs index 093e014..b9b942c 100644 --- a/src/db/settings.rs +++ b/src/db/settings.rs @@ -119,6 +119,64 @@ pub struct BackgroundSettings { 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, +} + +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)] #[serde(default)] pub struct ProfileSettings { @@ -230,6 +288,7 @@ pub struct Settings { pub header: HeaderSettings, pub sidebar: SidebarSettings, pub background: BackgroundSettings, + pub search: SearchSettings, pub profile: ProfileSettings, pub site: SiteMetaSettings, } @@ -295,6 +354,46 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> { "暗色背景地址最多 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 { return Err(StashError::InvalidDirectory("页面标题最多 120 个字".into())); } diff --git a/stash-web/src/App.css b/stash-web/src/App.css index ad18cc3..4969031 100644 --- a/stash-web/src/App.css +++ b/stash-web/src/App.css @@ -1437,96 +1437,143 @@ html.theme-transition .site-background { } } -/* ---------- 搜索框:搜索引擎切换(效果五) ---------- */ -.engine-switch { +/* ---------- 搜索框:顶部引擎标签 + 下拉选项 ---------- */ +.search-shell { position: relative; - display: inline-flex; - align-items: center; + width: min(100%, 480px); } -.engine-trigger { +.engine-badge { + position: absolute; + top: -12px; + left: 16px; + z-index: 2; display: inline-flex; align-items: center; - justify-content: center; - width: 26px; - height: 26px; - padding: 0; - border: 0; - border-radius: var(--su-radius-xs); - background: transparent; - color: var(--su-text-muted); - font-size: var(--su-font-size-lg); + gap: 5px; + padding: 2px 8px; + border: 1px solid var(--su-border); + border-radius: 6px; + background-color: var(--su-bg-card); + box-shadow: var(--su-shadow-card); + color: var(--su-text-main); + font: inherit; + font-size: 11px; + line-height: 1.4; cursor: pointer; 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-trigger[aria-expanded='true'] { +.engine-badge:hover, +.engine-badge[aria-expanded='true'] { + border-color: var(--su-border-strong); background-color: var(--su-bg-subtle); - color: var(--su-text-main); } -.engine-trigger svg { +.engine-badge svg { display: block; + flex-shrink: 0; } -.engine-menu { +.engine-dropdown { position: absolute; - top: 50%; - left: calc(100% + 6px); + top: calc(100% + 8px); + left: 10px; z-index: var(--su-z-float); display: flex; - flex-direction: row; + flex-direction: column; gap: 2px; + min-width: 170px; padding: var(--su-space-1); border: 1px solid var(--su-border); border-radius: var(--su-radius-sm); background-color: var(--su-bg-card); box-shadow: var(--su-shadow-pop); - transform: translateY(-50%); - animation: engine-bar-in 140ms var(--su-ease-out); + animation: engine-menu-in 160ms var(--su-ease-out); } -@keyframes engine-bar-in { +@keyframes engine-menu-in { from { opacity: 0; - transform: translate(-6px, -50%); + transform: translateY(-6px); } to { opacity: 1; - transform: translate(0, -50%); + transform: translateY(0); } } -.engine-option { - display: inline-flex; - align-items: center; - gap: 4px; - 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-divider { + height: 1px; + margin: 4px 8px; + background-color: var(--su-border); } -.engine-option-icon { - display: inline-flex; - font-size: var(--su-font-size-lg); +.engine-option { + display: flex; + 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 { background-color: var(--su-bg-subtle); - color: var(--su-text-main); } .engine-option.is-active { background-color: var(--su-color-primary-soft); 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); + } } diff --git a/stash-web/src/AppNext.tsx b/stash-web/src/AppNext.tsx index 34962d8..6c3e185 100644 --- a/stash-web/src/AppNext.tsx +++ b/stash-web/src/AppNext.tsx @@ -1,12 +1,13 @@ import { Empty, - Icon, + EngineIcon, Layout, Menu, Notice, SearchField, Space, Toaster, + type EngineIconName, type MenuItem, } from '@stash/ui' import { useEffect, useMemo, useRef, useState } from 'react' @@ -27,6 +28,7 @@ import { ServicePanel } from './components/sidebar/ServicePanel' import { useProjects } from './useProjects' import { fetchSession, logout, type SessionState } from './auth' import { + DEFAULT_SEARCH_ENGINES, fetchSettings, type SettingsDocument, type SiteSettings, @@ -63,27 +65,7 @@ type Route = | { kind: 'item'; key: string; itemId: string } | { kind: 'api' } -const SEARCH_ENGINES = [ - { 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] - } -} +const INTERNAL_SEARCH = { id: 'internal', name: '站内搜索', url: '' } as const function parseRoute(pathname: string): Route { const segments = pathname.split('/').filter(Boolean) @@ -234,7 +216,7 @@ export default function AppNext() { const [editingKey, setEditingKey] = useState(null) const [scrolled, setScrolled] = useState(false) const searchRef = useRef(null) - const [searchEngine, setSearchEngine] = useState(readSearchEngine) + const [searchEngineId, setSearchEngineId] = useState(null) const [engineOpen, setEngineOpen] = useState(false) const engineRef = useRef(null) @@ -321,6 +303,10 @@ export default function AppNext() { show_nav: true, }, background: { light: '', dark: '' }, + search: { + default: 'internal', + engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })), + }, profile: { name: '本地管理员', role: '管理员', @@ -339,6 +325,17 @@ export default function AppNext() { 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 + // 配色:本机偏好优先于站点默认,写在 上, // 深色由组件库的令牌块接管,页面与组件都不用改 const [themeOverride, setThemeOverride] = useState(() => @@ -410,7 +407,7 @@ export default function AppNext() { event.preventDefault() setEngineOpen(false) - if (searchEngine.key === 'local') { + if (searchEngine.id === 'internal') { const target = findLocalTarget(manage.groups, keywordLower) if (target !== undefined) { navigate( @@ -422,24 +419,19 @@ export default function AppNext() { return } - window.open( - `${searchEngine.search}${encodeURIComponent(keyword.trim())}`, - '_blank', - 'noopener,noreferrer', + const searchUrl = searchEngine.url.replaceAll( + '{query}', + encodeURIComponent(keyword.trim()), ) + window.open(searchUrl, '_blank', 'noopener,noreferrer') } input.addEventListener('keydown', handleKeyDown) return () => input.removeEventListener('keydown', handleKeyDown) }, [keyword, keywordLower, manage.groups, navigate, searchEngine]) - const chooseEngine = (next: SearchEngine) => { - setSearchEngine(next) + const chooseEngine = (next: string) => { + setSearchEngineId(next) setEngineOpen(false) - try { - window.localStorage.setItem(SEARCH_ENGINE_KEY, next.key) - } catch { - // 隐私模式下只影响本次选择,不阻断搜索 - } searchRef.current?.focus() } @@ -827,61 +819,76 @@ export default function AppNext() { } toolbar={
- +
+ + + + + {engineOpen && ( +
- {engineOpen && ( -
- {SEARCH_ENGINES.map((engine) => ( + {searchEngines.length > 1 && ( + <> +
+ {searchEngines.slice(1).map((engine) => ( ))} -
+ )}
- } - /> + )} +
} sider={ diff --git a/stash-web/src/components/admin/AdminNext.tsx b/stash-web/src/components/admin/AdminNext.tsx index 5447477..a521af2 100644 --- a/stash-web/src/components/admin/AdminNext.tsx +++ b/stash-web/src/components/admin/AdminNext.tsx @@ -4,6 +4,7 @@ import { Card, Caret, Empty, + EngineIcon, FormField, Icon, ICON_NAMES, @@ -27,6 +28,7 @@ import { ToggleRow, Toaster, toast, + type EngineIconName, type IconName, type MenuItem, } from '@stash/ui' @@ -36,11 +38,13 @@ import { LoginDialog } from '../auth/LoginDialog' import { GroupMark as GroupGlyph } from '../common/GroupMark' import { isDarkScheme, readThemeOverride, writeThemeOverride } from '../../theme' import { + DEFAULT_SEARCH_ENGINES, fetchSettings, saveSettings, TONE_OPTIONS, - type ProfileSettings, type BackgroundSettings, + type ProfileSettings, + type SearchSettings, type SettingsDocument, type SettingsTone, type SiteSettings, @@ -81,6 +85,7 @@ type TabKey = | 'home-cards' | 'home-header' | 'home-background' + | 'home-search' | 'profile' | 'system' @@ -94,6 +99,7 @@ const ADMIN_NAV: MenuItem[] = [ children: [ { key: 'home-header', label: '页头' }, { key: 'home-background', label: '背景管理' }, + { key: 'home-search', label: '搜索框' }, { key: 'home-sidebar', label: '侧边栏' }, { key: 'home-cards', label: '内容卡片' }, ], @@ -391,6 +397,13 @@ export function AdminNext() { /> )} + {tab === 'home-search' && ( + setDraft({ ...draft, search })} + /> + )} + {tab === 'profile' && (
+ + ))} + + +
+ +
+ + ) +} + function BackgroundPanel({ value, onChange, diff --git a/stash-web/src/settings.ts b/stash-web/src/settings.ts index 49bfe7d..9fd493b 100644 --- a/stash-web/src/settings.ts +++ b/stash-web/src/settings.ts @@ -30,6 +30,56 @@ export type BackgroundSettings = { 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 = { header: { enabled: boolean @@ -48,6 +98,7 @@ export type SiteSettings = { show_nav: boolean } background: BackgroundSettings + search: SearchSettings profile: ProfileSettings /** 站点级:标签标题 / favicon / 品牌 / 页头操作工具 */ site: SiteMetaSettings @@ -116,6 +167,10 @@ export function defaultSettings(): SiteSettings { show_nav: true, }, background: { light: '', dark: '' }, + search: { + default: 'internal', + engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })), + }, profile: { name: '本地管理员', role: '管理员',