diff --git a/internal-plugins/setting/src/env.d.ts b/internal-plugins/setting/src/env.d.ts index 14152b3a..25e568dd 100644 --- a/internal-plugins/setting/src/env.d.ts +++ b/internal-plugins/setting/src/env.d.ts @@ -16,6 +16,18 @@ interface Services { writeImageFile: (base64Url: string) => string | undefined } +type WebSearchEngineType = 'search' | 'webpage' + +interface WebSearchEngine { + id: string + name: string + url: string + icon: string + enabled: boolean + type: WebSearchEngineType + keyword?: string +} + declare global { interface Window { services: Services @@ -404,9 +416,13 @@ declare global { // 网页快开 webSearch: { - getAll: () => Promise<{ success: boolean; data?: any[]; error?: string }> - add: (engine: any) => Promise<{ success: boolean; error?: string }> - update: (engine: any) => Promise<{ success: boolean; error?: string }> + getAll: () => Promise<{ + success: boolean + data?: WebSearchEngine[] + error?: string + }> + add: (engine: WebSearchEngine) => Promise<{ success: boolean; error?: string }> + update: (engine: WebSearchEngine) => Promise<{ success: boolean; error?: string }> delete: (id: string) => Promise<{ success: boolean; error?: string }> fetchFavicon: ( url: string diff --git a/internal-plugins/setting/src/views/WebSearchSetting/WebSearchSetting.vue b/internal-plugins/setting/src/views/WebSearchSetting/WebSearchSetting.vue index d850a4a7..f1777188 100644 --- a/internal-plugins/setting/src/views/WebSearchSetting/WebSearchSetting.vue +++ b/internal-plugins/setting/src/views/WebSearchSetting/WebSearchSetting.vue @@ -16,6 +16,8 @@ interface WebSearchEngine { url: string icon: string enabled: boolean + type: 'search' | 'webpage' + keyword?: string } // 搜索引擎列表 @@ -89,6 +91,10 @@ async function handleSave(engine: WebSearchEngine): Promise { error('请填写名称和 URL') return } + if (engine.type === 'webpage' && !engine.keyword?.trim()) { + error('请填写匹配关键字') + return + } try { const engineData = { @@ -96,7 +102,9 @@ async function handleSave(engine: WebSearchEngine): Promise { name: engine.name, url: engine.url, icon: engine.icon || '', - enabled: engine.enabled !== undefined ? engine.enabled : true + enabled: engine.enabled !== undefined ? engine.enabled : true, + type: engine.type || 'search', + keyword: engine.keyword || '' } if (editingEngine.value) { @@ -161,7 +169,7 @@ onMounted(() => {
- +
@@ -182,9 +190,18 @@ onMounted(() => {

{{ engine.name }}

{{ engine.url }}
+
+ 匹配关键字:{{ engine.keyword }} +
+ + {{ engine.type === 'webpage' ? '网页' : '搜索引擎' }} +
@@ -385,8 +402,28 @@ onMounted(() => { } .toggle-sm { - transform: scale(0.8); - transform-origin: center; + display: flex; + align-items: center; + width: 36px; + height: 20px; + margin: 0 7px; +} + +.toggle-sm .toggle-slider { + border-radius: 20px; +} + +.toggle-sm .toggle-slider::before { + width: 12px; + height: 12px; +} + +.toggle-sm input:checked + .toggle-slider::before { + transform: translateX(16px); +} + +.toggle-sm input:checked + .toggle-slider:hover::before { + transform: translateX(16px) scale(1.15); } .engine-url { @@ -396,13 +433,55 @@ onMounted(() => { line-height: 1.4; } +.engine-type-badge { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + height: 22px; + padding: 0 6px; + margin-right: 8px; + border-radius: 4px; + font-size: 11px; + line-height: 1; + color: var(--primary-color); + background: var(--primary-light-bg); +} + +.engine-type-badge-webpage { + color: #d97706; + background: rgba(245, 158, 11, 0.14); +} + +:global([data-theme='dark']) .engine-type-badge-webpage { + color: #fbbf24; + background: rgba(251, 191, 36, 0.16); +} + +.engine-keyword { + margin-top: 4px; + font-size: 12px; + color: var(--text-secondary); +} + .engine-actions { display: flex; - gap: 8px; + align-items: center; + justify-content: flex-end; + gap: 6px; margin-left: 16px; flex-shrink: 0; } +.engine-actions .icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; +} + /* 图标按钮颜色样式 */ .edit-btn { color: var(--primary-color); diff --git a/internal-plugins/setting/src/views/WebSearchSetting/components/WebSearchEditor/WebSearchEditor.vue b/internal-plugins/setting/src/views/WebSearchSetting/components/WebSearchEditor/WebSearchEditor.vue index 938b99c8..a9a2550a 100644 --- a/internal-plugins/setting/src/views/WebSearchSetting/components/WebSearchEditor/WebSearchEditor.vue +++ b/internal-plugins/setting/src/views/WebSearchSetting/components/WebSearchEditor/WebSearchEditor.vue @@ -8,6 +8,8 @@ interface WebSearchEngine { url: string icon: string enabled: boolean + type: 'search' | 'webpage' + keyword?: string } interface Props { @@ -23,14 +25,27 @@ const emit = defineEmits<{ const { error } = useToast() const isEditing = computed(() => props.editingEngine !== null) +const isWebpage = computed(() => formData.value.type === 'webpage') +const urlLabel = computed(() => (isWebpage.value ? '网页 URL *' : 'URL 模板 *')) +const urlPlaceholder = computed(() => + isWebpage.value ? '例如:https://www.example.com' : '例如:https://www.google.com/search?q={q}' +) +const urlHint = computed(() => + isWebpage.value + ? '支持 http/https,未填写协议时会自动补充 https://' + : '使用 {q} 作为搜索关键词的占位符;未填写协议时会自动补充 https://' +) const isFetchingIcon = ref(false) +const iconFileInputRef = ref(null) const formData = ref({ id: '', name: '', url: '', icon: '', - enabled: true + enabled: true, + type: 'webpage', + keyword: '' }) // 监听 editingEngine 变化 @@ -38,14 +53,20 @@ watch( () => props.editingEngine, (newEngine) => { if (newEngine) { - formData.value = { ...newEngine } + formData.value = { + ...newEngine, + type: newEngine.type || 'search', + keyword: newEngine.keyword || '' + } } else { formData.value = { id: '', name: '', url: '', icon: '', - enabled: true + enabled: true, + type: 'webpage', + keyword: '' } } }, @@ -75,41 +96,140 @@ async function handleFetchFavicon(): Promise { } } +function handleSelectIconFile(): void { + iconFileInputRef.value?.click() +} + +function handleIconFileChange(event: Event): void { + const input = event.target as HTMLInputElement + const file = input.files?.[0] + input.value = '' + + if (!file) return + if (!file.type.startsWith('image/')) { + error('请选择图片文件') + return + } + + const reader = new FileReader() + reader.onload = () => { + if (typeof reader.result === 'string') { + formData.value.icon = reader.result + } else { + error('读取图标失败') + } + } + reader.onerror = () => { + error('读取图标失败') + } + reader.readAsDataURL(file) +} + function handleSave(): void { if (!formData.value.name || !formData.value.url) { - error('请填写名称和 URL 模板') + error('请填写名称和 URL') return } - if (!formData.value.url.includes('{q}')) { - error('URL 模板必须包含 {q} 占位符') - return + + const nextData = { + ...formData.value, + name: formData.value.name.trim(), + url: formData.value.url.trim(), + keyword: formData.value.keyword?.trim() || '' + } + + if (nextData.type === 'webpage') { + if (!nextData.keyword) { + error('请填写匹配关键字') + return + } + if (nextData.url.includes('{q}')) { + error('网页 URL 不能包含 {q} 占位符') + return + } + nextData.url = ensureUrlProtocol(nextData.url) + if (!isHttpUrl(nextData.url)) { + error('网页 URL 必须是有效的 http/https 地址') + return + } + } else { + if (!nextData.url.includes('{q}')) { + error('URL 模板必须包含 {q} 占位符') + return + } + nextData.url = ensureUrlProtocol(nextData.url) + if (!isHttpUrl(nextData.url.replace('{q}', 'test'))) { + error('URL 模板必须是有效的 http/https 地址') + return + } + nextData.keyword = '' + } + + emit('save', nextData) +} + +function ensureUrlProtocol(url: string): string { + if (/^https?:\/\//i.test(url)) { + return url + } + return `https://${url}` +} + +function isHttpUrl(url: string): boolean { + try { + const parsed = new URL(url) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + } catch { + return false } - emit('save', { ...formData.value }) }