fix(home): polish intro page details and settings highlight

This commit is contained in:
zipg
2026-10-06 22:44:18 +08:00
committed by GitHub
parent f9ee05fd7d
commit ae1ee25a76
4 changed files with 219 additions and 106 deletions
@@ -334,6 +334,7 @@ import {
type SettingsSearchEntry,
type ToolbarVisibilityItem,
} from "@/lib/settings/settingsSearch";
import { findSettingsSearchHighlightTarget } from "@/lib/settings/settingsSearchHighlight";
import { LOCALE_OPTIONS } from "@/lib/app/localeOptions";
import { DEFAULT_WEB_DAV_AUTO_UPLOAD_INTERVAL_MINUTES, DEFAULT_WEB_DAV_REMOTE_PATH, normalizedWebDavAutoUploadInterval, readSyncMethod, writeWebDavAutoUploadFields, writeWebDavBackupSelection, writeSyncMethod, type SyncMethod } from "@/lib/webdav/webdavAutoUploadConfig";
import { apiUrl, webPath } from "@/lib/common/webPath";
@@ -3407,23 +3408,6 @@ function applySettingsSearchRoute(result: SettingsSearchEntry) {
if (result.route?.syncMethodTab) syncMethodTab.value = result.route.syncMethodTab;
}
function normalizeSettingsSearchText(value: string | null | undefined): string {
return value?.replace(/\s+/g, " ").trim() ?? "";
}
function findSettingsSearchHighlightTarget(searchRoot: HTMLElement, title: string): HTMLElement {
const titleElement = Array.from(searchRoot.querySelectorAll<HTMLElement>("label, h3, h4")).find((element) => normalizeSettingsSearchText(element.textContent) === title);
if (!titleElement) return searchRoot;
let candidate = titleElement.parentElement;
while (candidate && candidate !== searchRoot) {
if (candidate.classList.contains("rounded-md") && candidate.classList.contains("border")) return candidate;
if (candidate.querySelector("input, button, [role='combobox'], textarea")) return candidate;
candidate = candidate.parentElement;
}
return titleElement;
}
async function revealSettingsSearchTarget(result: SettingsSearchEntry) {
await nextTick();
const searchRoot = settingsContentScrollRef.value?.querySelector<HTMLElement>(`[data-settings-search-id="${result.targetId}"]`);
@@ -7539,45 +7523,6 @@ onUnmounted(() => {
<Separator />
<div data-settings-search-id="welcome-page-settings" :class="['settings-appearance-group', settingsSearchTargetClass('welcome-page-settings')]">
<div class="flex items-center gap-1">
<Label>{{ t("settings.welcomePage") }}</Label>
<HelpTooltip :label="t('settings.welcomePage')" trigger-class="[&_svg]:h-3 [&_svg]:w-3" content-class="max-w-72">
<p>{{ t("settings.welcomePageDescription") }}</p>
</HelpTooltip>
</div>
<div class="settings-appearance-choice-grid">
<Button
type="button"
variant="outline"
class="settings-choice-card h-auto min-w-0 justify-start overflow-hidden whitespace-normal border p-3"
:class="editWelcomePageMode === 'intro' ? 'dbx-choice-selected' : ''"
:aria-pressed="editWelcomePageMode === 'intro'"
@click="editWelcomePageMode = 'intro'"
>
<div class="w-full min-w-0 text-left">
<div class="text-sm font-medium">{{ t("settings.welcomePageIntro") }}</div>
<div class="break-words whitespace-normal text-xs text-muted-foreground">{{ t("settings.welcomePageIntroDescription") }}</div>
</div>
</Button>
<Button
type="button"
variant="outline"
class="settings-choice-card h-auto min-w-0 justify-start overflow-hidden whitespace-normal border p-3"
:class="editWelcomePageMode === 'workspace' ? 'dbx-choice-selected' : ''"
:aria-pressed="editWelcomePageMode === 'workspace'"
@click="editWelcomePageMode = 'workspace'"
>
<div class="w-full min-w-0 text-left">
<div class="text-sm font-medium">{{ t("settings.welcomePageWorkspace") }}</div>
<div class="break-words whitespace-normal text-xs text-muted-foreground">{{ t("settings.welcomePageWorkspaceDescription") }}</div>
</div>
</Button>
</div>
</div>
<Separator />
<div class="settings-appearance-group">
<Label>{{ t("settings.appLayout") }}</Label>
<div class="settings-appearance-choice-grid">
@@ -7737,6 +7682,45 @@ onUnmounted(() => {
<Switch id="quit-on-close" v-model="editQuitOnClose" />
</div>
<Separator />
<div data-settings-search-id="welcome-page-settings" :class="['settings-appearance-group', settingsSearchTargetClass('welcome-page-settings')]">
<div class="flex items-center gap-1">
<Label>{{ t("settings.welcomePage") }}</Label>
<HelpTooltip :label="t('settings.welcomePage')" trigger-class="[&_svg]:h-3 [&_svg]:w-3" content-class="max-w-72">
<p>{{ t("settings.welcomePageDescription") }}</p>
</HelpTooltip>
</div>
<div class="settings-appearance-choice-grid">
<Button
type="button"
variant="outline"
class="settings-choice-card h-auto min-w-0 justify-start overflow-hidden whitespace-normal border p-3"
:class="editWelcomePageMode === 'intro' ? 'dbx-choice-selected' : ''"
:aria-pressed="editWelcomePageMode === 'intro'"
@click="editWelcomePageMode = 'intro'"
>
<div class="w-full min-w-0 text-left">
<div class="text-sm font-medium">{{ t("settings.welcomePageIntro") }}</div>
<div class="break-words whitespace-normal text-xs text-muted-foreground">{{ t("settings.welcomePageIntroDescription") }}</div>
</div>
</Button>
<Button
type="button"
variant="outline"
class="settings-choice-card h-auto min-w-0 justify-start overflow-hidden whitespace-normal border p-3"
:class="editWelcomePageMode === 'workspace' ? 'dbx-choice-selected' : ''"
:aria-pressed="editWelcomePageMode === 'workspace'"
@click="editWelcomePageMode = 'workspace'"
>
<div class="w-full min-w-0 text-left">
<div class="text-sm font-medium">{{ t("settings.welcomePageWorkspace") }}</div>
<div class="break-words whitespace-normal text-xs text-muted-foreground">{{ t("settings.welcomePageWorkspaceDescription") }}</div>
</div>
</Button>
</div>
</div>
<div class="settings-appearance-group" data-icon-theme-settings>
<Label>{{ t("settings.iconTheme") }}</Label>
<div class="settings-appearance-choice-grid settings-icon-theme-grid">
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { ArrowRight, Code2, Database, DatabaseZap, Download, FilePlus2, History, Plus, Search, Settings2, ShieldCheck, Sparkles, Table2 } from "@lucide/vue";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import AppLogo from "@/components/icons/AppLogo.vue";
import LightDropdown from "@/components/ui/LightDropdown.vue";
import TruncatedTextTooltip from "@/components/ui/TruncatedTextTooltip.vue";
import { connectionDriverLabel, connectionIconType, connectionRedactedNameLabel, connectionRedactedOptionSubtitle } from "@/lib/connection/connectionPresentation";
import type { WelcomePageMode } from "@/stores/settingsStore";
@@ -32,15 +34,28 @@ const emit = defineEmits<{
"new-connection": [];
"new-query": [];
"show-history": [];
"import-config": [];
"import-config": [source: ImportSource];
"open-github": [];
"open-mcp-guide": [];
"open-website": [];
"open-settings": [];
}>();
type ImportSource = "dbx" | "navicat" | "dbeaver" | "datagrip";
const { t } = useI18n();
const importSourceItems = computed(() => [
{ value: "dbx", label: t("sidebar.importDbx") },
{ value: "navicat", label: t("sidebar.importNavicat") },
{ value: "dbeaver", label: t("sidebar.importDbeaver") },
{ value: "datagrip", label: t("sidebar.importDatagrip") },
]);
function selectImportSource(source: string) {
emit("import-config", source as ImportSource);
}
function welcomeConnectionSubtitle(connection: ConnectionConfig): string {
return connectionRedactedOptionSubtitle(connection) || connectionDriverLabel(connection);
}
@@ -48,61 +63,80 @@ function welcomeConnectionSubtitle(connection: ConnectionConfig): string {
<template>
<div data-welcome-screen class="min-w-0 flex-1 overflow-x-hidden overflow-y-auto bg-background">
<div v-if="welcomePageMode !== 'workspace'" class="welcome-intro mx-auto flex min-h-full w-full min-w-0 max-w-5xl flex-col justify-center px-6 py-10 sm:px-8">
<div class="welcome-intro-hero relative overflow-hidden rounded-2xl border px-6 py-10 sm:px-10 sm:py-14">
<div class="welcome-intro-glow pointer-events-none absolute -right-24 -top-24 h-64 w-64 rounded-full bg-primary/15 blur-3xl" />
<div class="relative max-w-2xl">
<div class="flex items-center gap-3">
<div class="flex h-11 w-11 items-center justify-center rounded-xl border bg-background/80 p-2 shadow-sm"><AppLogo class="h-full w-full object-contain" /></div>
<div class="text-xs font-medium uppercase tracking-[0.24em] text-muted-foreground">{{ t("welcome.introEyebrow") }}</div>
</div>
<h1 class="mt-7 text-3xl font-semibold tracking-tight sm:text-4xl">{{ t("welcome.introTitle") }}</h1>
<p class="mt-3 max-w-xl text-sm leading-6 text-muted-foreground sm:text-base">{{ t("welcome.introDescription") }}</p>
<div class="mt-7 flex flex-wrap items-center gap-2">
<button type="button" class="inline-flex h-9 items-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90" @click="emit('new-connection')">
<Plus class="h-4 w-4" /> {{ t("welcome.introNewConnection") }} <ArrowRight class="h-3.5 w-3.5" />
</button>
<button type="button" class="inline-flex h-9 items-center gap-2 rounded-md border bg-background/70 px-4 text-sm font-medium transition-colors hover:bg-muted" @click="emit('import-config')"><Download class="h-4 w-4" /> {{ t("welcome.introImport") }}</button>
<div v-if="welcomePageMode !== 'workspace'" class="welcome-intro-shell flex min-h-full w-full min-w-0 flex-col @container">
<div class="welcome-intro mx-auto flex w-full min-w-0 max-w-5xl flex-1 flex-col justify-center px-4 py-8 @3xs:px-6 @2xl:px-8 @2xl:py-10">
<div class="welcome-intro-hero relative overflow-hidden rounded-2xl border px-4 py-7 @3xs:px-5 @2xl:px-10 @2xl:py-12 @4xl:py-14">
<div class="welcome-intro-glow pointer-events-none absolute -right-24 -top-24 h-64 w-64 rounded-full bg-primary/15 blur-3xl" />
<div class="relative max-w-2xl">
<div class="flex flex-col items-start gap-2 @3xs:flex-row @3xs:items-center @3xs:gap-3">
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border bg-background/80 p-0.5 shadow-sm"><AppLogo class="h-full w-full object-contain" /></div>
<div class="text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground @2xl:text-xs @2xl:tracking-[0.24em]">{{ t("welcome.introEyebrow") }}</div>
</div>
<h1 class="mt-5 text-xl font-semibold tracking-tight @3xs:mt-6 @2xl:text-3xl @4xl:text-4xl">{{ t("welcome.introTitle") }}</h1>
<p class="mt-3 max-w-xl text-xs leading-5 text-muted-foreground @2xl:text-sm @2xl:leading-6 @4xl:text-base">{{ t("welcome.introDescription") }}</p>
<div class="mt-5 flex flex-wrap items-center gap-2 @2xl:mt-7">
<button type="button" class="inline-flex h-9 items-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90" @click="emit('new-connection')">
<Plus class="h-4 w-4" /> {{ t("welcome.introNewConnection") }} <ArrowRight class="h-3.5 w-3.5" />
</button>
<LightDropdown
model-value=""
:items="importSourceItems"
:aria-label="t('welcome.introImport')"
:trigger-title="t('welcome.introImport')"
:trigger-icon="Download"
:trigger-label="t('welcome.introImport')"
trigger-class="inline-flex h-9 items-center gap-2 rounded-md border bg-background/70 px-4 text-sm font-medium transition-colors hover:bg-muted"
trigger-icon-class="h-4 w-4"
item-icon-class="h-4 w-4"
content-class="w-56"
:show-trigger-label="true"
:show-chevron="true"
:highlight-selected="false"
check-position="none"
align="start"
@update:model-value="selectImportSource"
/>
</div>
</div>
</div>
</div>
<div class="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-xl border bg-muted/20 p-4">
<DatabaseZap class="h-5 w-5 text-primary" />
<div class="mt-3 text-sm font-medium">{{ t("welcome.introFeatureConnections") }}</div>
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("welcome.introFeatureConnectionsDescription") }}</p>
<div class="mt-4 grid gap-3 @2xl:mt-5 @2xl:grid-cols-2 @4xl:grid-cols-4">
<div class="rounded-xl border bg-muted/20 p-4">
<DatabaseZap class="h-5 w-5 text-primary" />
<div class="mt-3 text-sm font-medium">{{ t("welcome.introFeatureConnections") }}</div>
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("welcome.introFeatureConnectionsDescription") }}</p>
</div>
<div class="rounded-xl border bg-muted/20 p-4">
<Code2 class="h-5 w-5 text-primary" />
<div class="mt-3 text-sm font-medium">{{ t("welcome.introFeatureSql") }}</div>
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("welcome.introFeatureSqlDescription") }}</p>
</div>
<div class="rounded-xl border bg-muted/20 p-4">
<Table2 class="h-5 w-5 text-primary" />
<div class="mt-3 text-sm font-medium">{{ t("welcome.introFeatureData") }}</div>
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("welcome.introFeatureDataDescription") }}</p>
</div>
<div class="rounded-xl border bg-muted/20 p-4">
<Sparkles class="h-5 w-5 text-primary" />
<div class="mt-3 text-sm font-medium">{{ t("welcome.introFeatureAi") }}</div>
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("welcome.introFeatureAiDescription") }}</p>
</div>
</div>
<div class="rounded-xl border bg-muted/20 p-4">
<Code2 class="h-5 w-5 text-primary" />
<div class="mt-3 text-sm font-medium">{{ t("welcome.introFeatureSql") }}</div>
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("welcome.introFeatureSqlDescription") }}</p>
</div>
<div class="rounded-xl border bg-muted/20 p-4">
<Table2 class="h-5 w-5 text-primary" />
<div class="mt-3 text-sm font-medium">{{ t("welcome.introFeatureData") }}</div>
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("welcome.introFeatureDataDescription") }}</p>
</div>
<div class="rounded-xl border bg-muted/20 p-4">
<Sparkles class="h-5 w-5 text-primary" />
<div class="mt-3 text-sm font-medium">{{ t("welcome.introFeatureAi") }}</div>
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("welcome.introFeatureAiDescription") }}</p>
</div>
</div>
<div class="mt-5 flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-muted/10 px-4 py-3 text-xs text-muted-foreground">
<div class="flex items-center gap-2"><ShieldCheck class="h-4 w-4 text-primary" /> {{ t("welcome.introPrivacyHint") }}</div>
<div class="flex flex-wrap items-center gap-3">
<button type="button" class="inline-flex items-center gap-1.5 hover:text-foreground" @click="emit('show-history')"><History class="h-3.5 w-3.5" /> {{ t("history.title") }}</button>
<button type="button" class="inline-flex items-center gap-1.5 hover:text-foreground" @click="emit('open-settings')"><Settings2 class="h-3.5 w-3.5" /> {{ t("welcome.introSettings") }}</button>
<div class="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-muted/10 px-3 py-3 text-[11px] text-muted-foreground @2xl:mt-5 @2xl:px-4 @2xl:text-xs">
<div class="flex items-center gap-2"><ShieldCheck class="h-4 w-4 text-primary" /> {{ t("welcome.introPrivacyHint") }}</div>
<div class="flex flex-wrap items-center gap-3">
<button type="button" class="inline-flex items-center gap-1.5 hover:text-foreground" @click="emit('show-history')"><History class="h-3.5 w-3.5" /> {{ t("history.title") }}</button>
<button type="button" class="inline-flex items-center gap-1.5 hover:text-foreground" @click="emit('open-settings')"><Settings2 class="h-3.5 w-3.5" /> {{ t("welcome.introSettings") }}</button>
</div>
</div>
</div>
<div class="mt-6 flex items-center justify-center gap-3 text-[11px] text-muted-foreground/60">
<span>DBX {{ appVersion ? "v" + appVersion : "" }}</span
><span>·</span>
<a href="#" class="hover:text-foreground" @click.prevent="emit('open-github')">GitHub</a>
<span>·</span><button type="button" class="hover:text-foreground" @click="emit('open-website')">{{ t("welcome.mcpLearnMore") }}</button>
<div class="mt-5 flex flex-wrap items-center justify-center gap-2 text-[10px] text-muted-foreground/60 @2xl:mt-6 @2xl:gap-3 @2xl:text-[11px]">
<span>DBX {{ appVersion ? "v" + appVersion : "" }}</span
><span>·</span>
<a href="#" class="hover:text-foreground" @click.prevent="emit('open-github')">GitHub</a>
<span>·</span><button type="button" class="hover:text-foreground" @click="emit('open-website')">{{ t("welcome.mcpLearnMore") }}</button>
</div>
</div>
</div>
@@ -163,7 +197,7 @@ function welcomeConnectionSubtitle(connection: ConnectionConfig): string {
<button class="flex min-w-0 items-center gap-2 overflow-hidden rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" @click="emit('show-history')">
<History class="h-4 w-4 shrink-0" /> <span class="min-w-0 truncate">{{ t("history.title") }}</span>
</button>
<button class="flex min-w-0 items-center gap-2 overflow-hidden rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" @click="emit('import-config')">
<button class="flex min-w-0 items-center gap-2 overflow-hidden rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" @click="emit('import-config', 'dbx')">
<Download class="h-4 w-4 shrink-0" /> <span class="min-w-0 truncate">{{ t("sidebar.import") }}</span>
</button>
<div class="mt-2 min-w-0 overflow-hidden rounded-md bg-muted/30 px-3 py-2 text-xs leading-5 text-muted-foreground">
@@ -0,0 +1,63 @@
// @vitest-environment happy-dom
// Guards the settings-search "reveal" contract: the highlight must cover the
// whole setting block, not just the title row. Searching 首页 used to land on
// the label next to the help icon, which read like a rendering glitch.
import { describe, expect, it } from "vitest";
import { findSettingsSearchHighlightTarget } from "@/lib/settings/settingsSearchHighlight";
function searchRoot(html: string): HTMLElement {
const root = document.createElement("div");
root.innerHTML = html;
return root;
}
describe("findSettingsSearchHighlightTarget", () => {
it("highlights the setting block instead of the title row of a help-tooltip setting", () => {
const root = searchRoot(`
<div class="flex items-center gap-1">
<label>首页</label>
<button type="button" class="cursor-help" aria-label="首页"></button>
</div>
<div class="settings-appearance-choice-grid">
<button type="button">介绍页</button>
<button type="button">工作区</button>
</div>
`);
expect(findSettingsSearchHighlightTarget(root, "首页")).toBe(root);
});
it("keeps highlighting a bordered setting row that owns the title", () => {
const root = searchRoot(`
<div class="settings-item rounded-md border">
<div class="space-y-1">
<label for="quit-on-close">退出时关闭</label>
<p>关闭窗口时退出应用</p>
</div>
<button id="quit-on-close" role="switch"></button>
</div>
`);
expect(findSettingsSearchHighlightTarget(root, "退出时关闭")).toBe(root.firstElementChild);
});
it("does not stop at a row that only wraps a label and its help icon", () => {
const root = searchRoot(`
<div class="rounded-md border">
<div class="flex items-center gap-1">
<label>首页</label>
<button type="button" class="cursor-help" aria-label="首页"></button>
</div>
<button type="button" role="switch"></button>
</div>
`);
expect(findSettingsSearchHighlightTarget(root, "首页")).toBe(root.firstElementChild);
});
it("falls back to the search root when the title cannot be found", () => {
const root = searchRoot(`<div><label>其它</label></div>`);
expect(findSettingsSearchHighlightTarget(root, "首页")).toBe(root);
});
});
@@ -0,0 +1,32 @@
export function normalizeSettingsSearchText(value: string | null | undefined): string {
return value?.replace(/\s+/g, " ").trim() ?? "";
}
// Help icons render inside the title row of a setting, so their trigger button
// must not be mistaken for the setting's own control.
function isSettingsSearchHelpAffordance(element: Element): boolean {
return element.matches("button.cursor-help");
}
/**
* Resolves the element that should be highlighted for a settings search result.
*
* The search root usually wraps a title row (label + optional help icon) and the
* control that belongs to the setting. Walking up from the title must stop at
* the block that owns a control; stopping at the title row only highlights the
* label, which reads like a rendering glitch.
*/
export function findSettingsSearchHighlightTarget(searchRoot: HTMLElement, title: string): HTMLElement {
const normalizedTitle = normalizeSettingsSearchText(title);
const titleElement = Array.from(searchRoot.querySelectorAll<HTMLElement>("label, h3, h4")).find((element) => normalizeSettingsSearchText(element.textContent) === normalizedTitle);
if (!titleElement) return searchRoot;
let candidate = titleElement.parentElement;
while (candidate && candidate !== searchRoot) {
if (candidate.classList.contains("rounded-md") && candidate.classList.contains("border")) return candidate;
const settingControls = Array.from(candidate.querySelectorAll<HTMLElement>("input, button, [role='combobox'], textarea")).filter((control) => !isSettingsSearchHelpAffordance(control));
if (settingControls.length > 0) return candidate;
candidate = candidate.parentElement;
}
return searchRoot;
}