Files
dbx/apps/desktop/src/components/settings/McpDatabaseScopePicker.vue
T

364 lines
20 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { Check, ChevronLeft, ChevronRight, Loader2, Plus, RefreshCw, Search } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { listDatabases, mongoListDatabases, redisListDatabases } from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import type { McpConnectionPolicy } from "@/stores/settingsStore";
import type { ConnectionConfig } from "@/types/database";
type DatabaseScope = McpConnectionPolicy["databaseScope"];
type ExecutionMode = "read_only" | "safe_write" | "high_risk_write";
const { t } = useI18n();
const connectionStore = useConnectionStore();
const PAGE_SIZE_OPTIONS = [6, 10, 20] as const;
const DATABASE_SCOPES: DatabaseScope[] = ["all", "selected", "none"];
const props = withDefaults(
defineProps<{
connections: readonly ConnectionConfig[];
allowedConnectionIds: readonly string[] | null;
connectionPolicies: readonly McpConnectionPolicy[];
disabled?: boolean;
busy?: boolean;
}>(),
{ disabled: false, busy: false },
);
const emit = defineEmits<{
"update:connectionPolicies": [value: McpConnectionPolicy[]];
"set:database-policy": [connectionId: string, databaseName: string, mode: ExecutionMode | "inherit"];
}>();
const selectedConnectionId = ref("");
const databasesByConnection = ref<Record<string, string[]>>({});
const loadingConnectionId = ref("");
const loadError = ref("");
const search = ref("");
const manualDatabase = ref("");
const pageSize = ref<number>(PAGE_SIZE_OPTIONS[0]);
const connectionPage = ref(1);
const databasePage = ref(1);
const scopedConnections = computed(() => (props.allowedConnectionIds === null ? [...props.connections] : props.connections.filter((connection) => props.allowedConnectionIds?.includes(connection.id))));
const selectedConnection = computed(() => scopedConnections.value.find((connection) => connection.id === selectedConnectionId.value));
const selectedPolicy = computed(() => props.connectionPolicies.find((policy) => policy.connectionId === selectedConnectionId.value));
const selectedScope = computed<DatabaseScope>(() => selectedPolicy.value?.databaseScope ?? "all");
const selectedDatabases = computed(() => selectedPolicy.value?.allowedDatabases ?? []);
const selectedDatabasePolicies = computed(() => selectedPolicy.value?.databasePolicies ?? []);
const displayedDatabases = computed(() => {
const query = search.value.trim().toLocaleLowerCase();
// 加载缓存是会话级的:把已持久化的库名并入列表,重开对话框后授权项仍完整可见。
const loaded = databasesByConnection.value[selectedConnectionId.value] ?? [];
return [...new Set([...selectedDatabases.value, ...loaded])].sort((left, right) => left.localeCompare(right)).filter((database) => !query || database.toLocaleLowerCase().includes(query));
});
const connectionPageCount = computed(() => Math.max(1, Math.ceil(scopedConnections.value.length / pageSize.value)));
const databasePageCount = computed(() => Math.max(1, Math.ceil(displayedDatabases.value.length / pageSize.value)));
const pagedScopedConnections = computed(() => {
const start = (connectionPage.value - 1) * pageSize.value;
return scopedConnections.value.slice(start, start + pageSize.value);
});
const pagedDatabases = computed(() => {
const start = (databasePage.value - 1) * pageSize.value;
return displayedDatabases.value.slice(start, start + pageSize.value);
});
watch(
scopedConnections,
(connections) => {
if (!connections.some((connection) => connection.id === selectedConnectionId.value)) {
selectedConnectionId.value = connections[0]?.id ?? "";
}
},
{ immediate: true },
);
function policyFor(connectionId: string): McpConnectionPolicy {
return (
props.connectionPolicies.find((policy) => policy.connectionId === connectionId) ?? {
connectionId,
readOnly: false,
allowDangerousSql: false,
executionModeConfigured: false,
executionModePolicyVersion: null,
databaseScope: "all",
allowedDatabases: [],
databasePolicies: [],
}
);
}
function updateSelectedPolicy(patch: Partial<McpConnectionPolicy>) {
if (props.disabled || !selectedConnectionId.value) return;
const existing = policyFor(selectedConnectionId.value);
const next = { ...existing, ...patch };
const policies = props.connectionPolicies.filter((policy) => policy.connectionId !== selectedConnectionId.value);
emit("update:connectionPolicies", [...policies, next]);
}
function setScope(scope: DatabaseScope) {
if (scope !== "selected" && selectedDatabasePolicies.value.length > 0 && !window.confirm(t("settings.mcpDatabaseScopeClearsPermissionExceptions"))) return;
updateSelectedPolicy({
databaseScope: scope,
allowedDatabases: scope === "selected" ? selectedDatabases.value : [],
databasePolicies: scope === "selected" ? selectedDatabasePolicies.value.filter((policy) => selectedDatabases.value.includes(policy.databaseName)) : [],
});
}
function onScopeKeydown(event: KeyboardEvent, scope: DatabaseScope) {
if (props.disabled || props.busy || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const currentIndex = DATABASE_SCOPES.indexOf(scope);
const nextIndex = event.key === "Home" ? 0 : event.key === "End" ? DATABASE_SCOPES.length - 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? (currentIndex - 1 + DATABASE_SCOPES.length) % DATABASE_SCOPES.length : (currentIndex + 1) % DATABASE_SCOPES.length;
const nextScope = DATABASE_SCOPES[nextIndex];
const group = event.currentTarget instanceof HTMLElement ? event.currentTarget.closest<HTMLElement>('[role="radiogroup"]') : null;
group?.querySelector<HTMLElement>(`[data-database-scope="${nextScope}"]`)?.focus();
setScope(nextScope);
}
function setConnectionPage(page: number) {
connectionPage.value = Math.min(Math.max(1, page), connectionPageCount.value);
}
function setDatabasePage(page: number) {
databasePage.value = Math.min(Math.max(1, page), databasePageCount.value);
}
function selectConnection(connectionId: string) {
selectedConnectionId.value = connectionId;
loadError.value = "";
search.value = "";
databasePage.value = 1;
}
function toggleDatabase(database: string, checked: boolean) {
if (!checked && selectedDatabasePolicies.value.some((policy) => policy.databaseName === database) && !window.confirm(t("settings.mcpDatabaseScopeClearsPermissionExceptions"))) return;
const databases = checked ? [...new Set([...selectedDatabases.value, database])] : selectedDatabases.value.filter((item) => item !== database);
updateSelectedPolicy({
databaseScope: "selected",
allowedDatabases: databases,
databasePolicies: selectedDatabasePolicies.value.filter((policy) => databases.includes(policy.databaseName)),
});
}
function databasePolicyMode(database: string): ExecutionMode | "inherit" {
const policy = selectedDatabasePolicies.value.find((item) => item.databaseName === database);
if (!policy) return "inherit";
if (policy.readOnly) return "read_only";
return policy.allowDangerousSql ? "high_risk_write" : "safe_write";
}
function addManualDatabase() {
// busy 期间 updateSelectedPolicy 会被丢弃;提前拦截,避免库名进了本地列表却没进策略。
if (props.disabled || props.busy) return;
const database = manualDatabase.value.trim();
if (!database) return;
const loaded = databasesByConnection.value[selectedConnectionId.value] ?? [];
databasesByConnection.value = {
...databasesByConnection.value,
[selectedConnectionId.value]: [...new Set([...loaded, database])].sort((left, right) => left.localeCompare(right)),
};
toggleDatabase(database, true);
manualDatabase.value = "";
databasePage.value = 1;
}
async function loadConnectionDatabases() {
const connection = selectedConnection.value;
if (!connection || loadingConnectionId.value) return;
loadingConnectionId.value = connection.id;
loadError.value = "";
try {
// SQL 连接的 list_databases 只读后端现有连接池,从未在侧边栏连接过的连接
// 会报 "Connection not found";与侧边栏流程一致,先确保连接建立。
await connectionStore.ensureConnected(connection.id, { activate: false });
const databases = connection.db_type === "mongodb" ? await mongoListDatabases(connection.id) : connection.db_type === "redis" ? (await redisListDatabases(connection.id)).map((database) => String(database.db)) : (await listDatabases(connection.id)).map((database) => database.name);
databasesByConnection.value = {
...databasesByConnection.value,
[connection.id]: [...new Set(databases.map((database) => database.trim()).filter(Boolean))].sort((left, right) => left.localeCompare(right)),
};
} catch (error: unknown) {
loadError.value = error instanceof Error ? error.message : String(error);
} finally {
loadingConnectionId.value = "";
}
}
function scopeSummary(connection: ConnectionConfig): string {
const policy = props.connectionPolicies.find((item) => item.connectionId === connection.id);
if (!policy || policy.databaseScope === "all") return t("settings.mcpDatabaseScopeSummaryAll");
if (policy.databaseScope === "none") return t("settings.mcpDatabaseScopeSummaryNone");
return t("settings.mcpDatabaseScopeSummarySelected", { count: policy.allowedDatabases.length });
}
watch(search, () => {
databasePage.value = 1;
});
watch(pageSize, () => {
connectionPage.value = 1;
databasePage.value = 1;
});
watch(connectionPageCount, () => setConnectionPage(connectionPage.value));
watch(databasePageCount, () => setDatabasePage(databasePage.value));
</script>
<template>
<section class="space-y-3">
<div>
<p class="text-sm font-medium">{{ t("settings.mcpDatabaseScopeTitle") }}</p>
<p class="text-xs text-muted-foreground">{{ t("settings.mcpDatabaseScopeDescription") }}</p>
</div>
<div v-if="!scopedConnections.length" class="rounded border border-dashed bg-background px-3 py-4 text-center text-xs text-muted-foreground">{{ t("settings.mcpDatabaseScopeEmptyHint") }}</div>
<div v-else class="grid min-h-72 overflow-hidden rounded-md border bg-background md:grid-cols-[minmax(14rem,0.42fr)_minmax(0,1fr)]">
<div class="border-b p-2 md:border-b-0 md:border-r">
<div class="flex items-center justify-between gap-2 px-2 pb-2">
<p class="text-xs font-medium text-muted-foreground">{{ t("settings.mcpDatabaseScopeAllowedConnections") }}</p>
<label class="flex h-7 items-center gap-1 rounded border bg-background px-1.5 text-[11px] text-muted-foreground">
{{ t("settings.mcpPerPage") }}
<select v-model.number="pageSize" class="bg-transparent text-[11px] text-foreground outline-none" :disabled="disabled">
<option v-for="size in PAGE_SIZE_OPTIONS" :key="size" :value="size">{{ size }}</option>
</select>
</label>
</div>
<div class="space-y-1">
<button
v-for="connection in pagedScopedConnections"
:key="connection.id"
type="button"
class="w-full cursor-pointer rounded-md px-2 py-2 text-left transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
:class="selectedConnectionId === connection.id ? 'bg-muted' : ''"
:aria-current="selectedConnectionId === connection.id ? 'true' : undefined"
@pointerdown="selectConnection(connection.id)"
@click="selectConnection(connection.id)"
>
<div class="pointer-events-none flex items-center justify-between gap-2">
<span class="min-w-0 truncate text-sm font-medium">{{ connection.name }}</span>
<Badge variant="outline" class="shrink-0 text-[10px] font-normal">{{ scopeSummary(connection) }}</Badge>
</div>
<p class="pointer-events-none mt-0.5 truncate font-mono text-[10px] text-muted-foreground">{{ connection.db_type }} · {{ connection.host || connection.database || connection.id }}</p>
</button>
</div>
<div v-if="connectionPageCount > 1" class="mt-2 flex items-center justify-center gap-2 border-t pt-2 text-xs text-muted-foreground">
<Button type="button" size="icon-sm" variant="ghost" :disabled="connectionPage === 1" :title="t('settings.mcpPreviousPage')" :aria-label="t('settings.mcpPreviousPage')" @click="setConnectionPage(connectionPage - 1)"><ChevronLeft /></Button>
<span class="min-w-12 text-center tabular-nums">{{ connectionPage }} / {{ connectionPageCount }}</span>
<Button type="button" size="icon-sm" variant="ghost" :disabled="connectionPage === connectionPageCount" :title="t('settings.mcpNextPage')" :aria-label="t('settings.mcpNextPage')" @click="setConnectionPage(connectionPage + 1)"><ChevronRight /></Button>
</div>
</div>
<div v-if="selectedConnection" class="space-y-3 p-3">
<div class="flex flex-wrap items-start justify-between gap-2">
<div>
<p class="text-sm font-medium">{{ selectedConnection.name }}</p>
<p class="text-xs text-muted-foreground">{{ t("settings.mcpDatabaseScopeDescription") }}</p>
</div>
<Button v-if="selectedScope !== 'all'" type="button" variant="outline" size="sm" :disabled="disabled || Boolean(loadingConnectionId)" @click="loadConnectionDatabases">
<Loader2 v-if="loadingConnectionId === selectedConnection.id" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
<RefreshCw v-else class="mr-1.5 h-3.5 w-3.5" />
{{ t("settings.mcpDatabaseLoadButton") }}
</Button>
</div>
<div class="grid gap-2 sm:grid-cols-3" role="radiogroup" :aria-label="t('settings.mcpDatabaseScopeAriaLabel')">
<Button
type="button"
role="radio"
data-database-scope="all"
variant="outline"
class="h-auto justify-start px-3 py-2 text-left"
:class="selectedScope === 'all' ? 'dbx-choice-selected' : ''"
:aria-checked="selectedScope === 'all'"
:tabindex="selectedScope === 'all' ? 0 : -1"
:disabled="disabled || busy"
@click="setScope('all')"
@keydown="onScopeKeydown($event, 'all')"
>
<span
><span class="block text-sm">{{ t("settings.mcpDatabaseScopeAllTitle") }}</span
><span class="block text-[11px] font-normal text-muted-foreground">{{ t("settings.mcpDatabaseScopeAllDescription") }}</span></span
>
</Button>
<Button
type="button"
role="radio"
data-database-scope="selected"
variant="outline"
class="h-auto justify-start px-3 py-2 text-left"
:class="selectedScope === 'selected' ? 'dbx-choice-selected' : ''"
:aria-checked="selectedScope === 'selected'"
:tabindex="selectedScope === 'selected' ? 0 : -1"
:disabled="disabled || busy"
@click="setScope('selected')"
@keydown="onScopeKeydown($event, 'selected')"
>
<span
><span class="block text-sm">{{ t("settings.mcpDatabaseScopeSelectedTitle") }}</span
><span class="block text-[11px] font-normal text-muted-foreground">{{ t("settings.mcpDatabaseScopeSelectedDescription") }}</span></span
>
</Button>
<Button
type="button"
role="radio"
data-database-scope="none"
variant="outline"
class="h-auto justify-start px-3 py-2 text-left"
:class="selectedScope === 'none' ? 'dbx-choice-selected' : ''"
:aria-checked="selectedScope === 'none'"
:tabindex="selectedScope === 'none' ? 0 : -1"
:disabled="disabled || busy"
@click="setScope('none')"
@keydown="onScopeKeydown($event, 'none')"
>
<span
><span class="block text-sm">{{ t("settings.mcpDatabaseScopeNoneTitle") }}</span
><span class="block text-[11px] font-normal text-muted-foreground">{{ t("settings.mcpDatabaseScopeNoneDescription") }}</span></span
>
</Button>
</div>
<template v-if="selectedScope === 'selected'">
<p class="text-xs text-muted-foreground">{{ t("settings.mcpDatabaseScopeSelectedSummary", { count: selectedDatabases.length }) }}</p>
<div class="flex gap-2">
<div class="relative min-w-0 flex-1"><Search class="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" /><Input v-model="search" class="h-8 pl-8 text-xs" :placeholder="t('settings.mcpDatabaseSearchPlaceholder')" /></div>
<Input v-model="manualDatabase" class="h-8 w-40 text-xs" :placeholder="t('settings.mcpDatabaseManualPlaceholder')" :disabled="disabled || busy" @keydown.enter.prevent="addManualDatabase" />
<Button type="button" variant="outline" size="sm" :disabled="disabled || busy || !manualDatabase.trim()" @click="addManualDatabase"><Plus class="h-3.5 w-3.5" /></Button>
</div>
<p v-if="loadError" class="rounded border border-destructive/30 bg-destructive/5 px-2 py-1.5 text-xs text-destructive">{{ t("settings.mcpDatabaseLoadFailed", { error: loadError }) }}</p>
<div v-if="displayedDatabases.length" class="rounded border">
<label v-for="database in pagedDatabases" :key="database" class="flex cursor-pointer items-center gap-2 border-b px-3 py-2 text-xs last:border-b-0 hover:bg-muted/50">
<input type="checkbox" :checked="selectedDatabases.includes(database)" :disabled="disabled || busy" @change="toggleDatabase(database, ($event.target as HTMLInputElement).checked)" />
<span class="min-w-0 flex-1 truncate font-mono">{{ database }}</span>
<Check v-if="selectedDatabases.includes(database)" class="h-3.5 w-3.5 text-green-600" />
<select
v-if="selectedDatabases.includes(database)"
:value="databasePolicyMode(database)"
class="h-7 shrink-0 rounded border bg-background px-1.5 text-[11px]"
:disabled="disabled || busy"
@click.stop
@change="$emit('set:database-policy', selectedConnectionId, database, ($event.target as HTMLSelectElement).value as ExecutionMode | 'inherit')"
>
<option value="inherit">{{ t("settings.mcpConnectionPolicyInherit") }}</option>
<option value="read_only">{{ t("settings.mcpConnectionPolicyReadOnly") }}</option>
<option value="safe_write">{{ t("settings.mcpConnectionPolicySafeWrite") }}</option>
<option value="high_risk_write">{{ t("settings.mcpConnectionPolicyHighRiskWrite") }}</option>
</select>
</label>
<div v-if="databasePageCount > 1" class="flex items-center justify-center gap-2 border-t px-3 py-2 text-xs text-muted-foreground">
<Button type="button" size="icon-sm" variant="ghost" :disabled="databasePage === 1" :title="t('settings.mcpPreviousPage')" :aria-label="t('settings.mcpPreviousPage')" @click="setDatabasePage(databasePage - 1)"><ChevronLeft /></Button>
<span class="min-w-12 text-center tabular-nums">{{ databasePage }} / {{ databasePageCount }}</span>
<Button type="button" size="icon-sm" variant="ghost" :disabled="databasePage === databasePageCount" :title="t('settings.mcpNextPage')" :aria-label="t('settings.mcpNextPage')" @click="setDatabasePage(databasePage + 1)"><ChevronRight /></Button>
</div>
</div>
<p v-else class="rounded border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">{{ t("settings.mcpDatabaseEmptyHint") }}</p>
</template>
</div>
</div>
</section>
</template>