Merge pull request #220 from rarnu/feat/editor-settings

Opt: 优化设置页面
This commit is contained in:
skyler 2026-05-12 00:08:26 +08:00 committed by GitHub
commit ab1b4ae227
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 222 additions and 191 deletions

1
.gitignore vendored
View File

@ -26,6 +26,7 @@ Thumbs.db
*.swo
*~
.vscode/
.idea/
# GitHub (keep workflows)
.github/*

View File

@ -16,7 +16,6 @@ import {
MessageSquarePlus,
Replace,
Server,
Settings,
Play,
Square,
Trash2,
@ -27,7 +26,6 @@ import {
TestTube,
} from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
DropdownMenu,
@ -36,10 +34,8 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useSettingsStore, type AiProvider, type AiApiStyle } from "@/stores/settingsStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIconType } from "@/lib/connectionPresentation";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
@ -49,7 +45,6 @@ import { buildAiContext, runAiStream, type AiAction } from "@/lib/ai";
import { extractFirstSqlCodeBlock, shouldAttemptAiAutoExecute } from "@/lib/aiSqlExecutionPolicy";
import { Marked } from "marked";
import {
aiTestConnection,
aiCancelStream,
saveAiConversation,
loadAiConversations,
@ -89,7 +84,6 @@ const emit = defineEmits<{
const prompt = ref("");
const messages = ref<ChatMessage[]>([]);
const isGenerating = ref(false);
const showSettings = ref(false);
const scrollRef = ref<InstanceType<typeof ScrollArea> | null>(null);
const activeAction = ref<AiAction>("generate");
const currentSessionId = ref("");
@ -168,20 +162,6 @@ function changeDatabase(database: string) {
queryStore.updateDatabase(tab.id, database);
}
const tempProvider = ref<AiProvider>(settings.aiConfig.provider);
const tempApiKey = ref(settings.aiConfig.apiKey);
const tempEndpoint = ref(settings.aiConfig.endpoint);
const tempModel = ref(settings.aiConfig.model);
const tempApiStyle = ref<AiApiStyle>(settings.aiConfig.apiStyle || "completions");
const tempProxyEnabled = ref(!!settings.aiConfig.proxyEnabled);
const tempProxyUrl = ref(settings.aiConfig.proxyUrl || "");
const providerDefaults: Record<AiProvider, { endpoint: string; model: string }> = {
claude: { endpoint: "https://api.anthropic.com/v1/messages", model: "claude-sonnet-4-20250514" },
openai: { endpoint: "https://api.openai.com/v1/chat/completions", model: "gpt-4o" },
custom: { endpoint: "", model: "" },
};
function appendAssistantDelta(assistantIdx: number, delta: string) {
const msg = messages.value[assistantIdx];
if (msg.isThinking) msg.isThinking = false;
@ -209,63 +189,6 @@ function toggleReasoning(index: number) {
expandedReasoning.value = next;
}
function openSettings() {
tempProvider.value = settings.aiConfig.provider;
tempApiKey.value = settings.aiConfig.apiKey;
tempEndpoint.value = settings.aiConfig.endpoint;
tempModel.value = settings.aiConfig.model;
tempApiStyle.value = settings.aiConfig.apiStyle || "completions";
tempProxyEnabled.value = !!settings.aiConfig.proxyEnabled;
tempProxyUrl.value = settings.aiConfig.proxyUrl || "";
showSettings.value = true;
}
function saveSettings() {
settings.updateAiConfig({
provider: tempProvider.value,
apiKey: tempApiKey.value,
endpoint: tempEndpoint.value,
model: tempModel.value,
apiStyle: tempApiStyle.value,
proxyEnabled: tempProxyEnabled.value,
proxyUrl: tempProxyUrl.value,
});
showSettings.value = false;
}
const testingAi = ref(false);
const testResult = ref<"" | "success" | "error">("");
const testError = ref("");
async function testAiConnection() {
testingAi.value = true;
testResult.value = "";
testError.value = "";
try {
await aiTestConnection({
provider: tempProvider.value,
apiKey: tempApiKey.value,
endpoint: tempEndpoint.value,
model: tempModel.value,
apiStyle: tempApiStyle.value,
proxyEnabled: tempProxyEnabled.value,
proxyUrl: tempProxyUrl.value,
});
testResult.value = "success";
} catch (e: any) {
testResult.value = "error";
testError.value = e?.message || String(e);
} finally {
testingAi.value = false;
}
}
function selectProvider(provider: AiProvider) {
tempProvider.value = provider;
tempEndpoint.value = providerDefaults[provider].endpoint;
tempModel.value = providerDefaults[provider].model;
}
function scrollToBottom() {
nextTick(() => {
const root = scrollRef.value?.$el as HTMLElement | undefined;
@ -283,7 +206,7 @@ async function send() {
if (!props.connection || !props.tab) return;
if (!settings.isConfigured()) {
openSettings();
toast(t("ai.noConfig"));
return;
}
@ -531,9 +454,6 @@ function formatInlineText(text: string): string {
<Button variant="ghost" size="icon" class="h-6 w-6" @click="clearMessages" :title="t('ai.clear')">
<Trash2 class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-6 w-6" @click="openSettings">
<Settings class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-6 w-6" @click="emit('close')">
<X class="h-3.5 w-3.5" />
</Button>
@ -747,107 +667,6 @@ function formatInlineText(text: string): string {
</div>
</div>
</div>
<Dialog v-model:open="showSettings">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>{{ t("ai.settings") }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3 py-2">
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">{{ t("ai.provider") }}</Label>
<Select :model-value="tempProvider" @update:model-value="(v: any) => selectProvider(v)">
<SelectTrigger class="col-span-2 h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="claude">Claude</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">API Key</Label>
<Input v-model="tempApiKey" type="password" autocomplete="off" class="col-span-2 h-8 text-xs" />
</div>
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">Endpoint</Label>
<Input
v-model="tempEndpoint"
placeholder="https://api.openai.com/v1"
autocomplete="off"
class="col-span-2 h-8 text-xs"
/>
</div>
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">Model</Label>
<Input v-model="tempModel" autocomplete="off" class="col-span-2 h-8 text-xs" />
</div>
<div v-if="tempProvider !== 'claude'" class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">API</Label>
<div class="col-span-2 flex gap-2">
<Button
size="sm"
variant="outline"
class="h-8 flex-1 text-xs"
:class="{
'border-blue-300 border-2 ring-2 ring-blue-300/50': tempApiStyle === 'completions',
}"
@click="tempApiStyle = 'completions'"
>/chat/completions</Button
>
<Button
size="sm"
variant="outline"
class="h-8 flex-1 text-xs"
:class="{
'border-blue-300 border-2 ring-2 ring-blue-300/50': tempApiStyle === 'responses',
}"
@click="tempApiStyle = 'responses'"
>/responses</Button
>
</div>
</div>
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">{{ t("ai.proxy") }}</Label>
<label class="col-span-2 flex items-center gap-2 text-xs text-muted-foreground">
<input v-model="tempProxyEnabled" type="checkbox" class="h-4 w-4 shrink-0 accent-primary" />
{{ t("ai.proxyEnable") }}
</label>
</div>
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">{{ t("ai.proxyUrl") }}</Label>
<Input
v-model="tempProxyUrl"
autocomplete="off"
class="col-span-2 h-8 text-xs"
placeholder="socks5://127.0.0.1:7890"
:disabled="!tempProxyEnabled"
/>
</div>
</div>
<DialogFooter class="flex items-center gap-2">
<div class="flex-1 flex items-center gap-2">
<Button
size="sm"
variant="outline"
:disabled="testingAi || !tempApiKey?.trim() || !tempEndpoint?.trim() || !tempModel?.trim()"
@click="testAiConnection"
>
<Loader2 v-if="testingAi" class="h-3 w-3 animate-spin mr-1" />
{{ t("connection.test") }}
</Button>
<span v-if="testResult === 'success'" class="text-xs text-green-500">{{ t("connection.testSuccess") }}</span>
<span
v-else-if="testResult === 'error'"
class="text-xs text-destructive truncate max-w-[200px]"
:title="testError"
>{{ testError }}</span
>
</div>
<Button size="sm" @click="saveSettings">{{ t("grid.save") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<style scoped>

View File

@ -2,19 +2,27 @@
import { ref, watch, shallowRef, computed } from "vue";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { useI18n } from "vue-i18n";
import { FolderOpen, Settings, Trash2 } from "lucide-vue-next";
import { FolderOpen, Loader2, Settings, Trash2 } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useSettingsStore, EDITOR_THEMES, FONT_FAMILIES, DEFAULT_EDITOR_SETTINGS } from "@/stores/settingsStore";
import {
useSettingsStore,
EDITOR_THEMES,
FONT_FAMILIES,
DEFAULT_EDITOR_SETTINGS,
type AiProvider,
type AiApiStyle,
} from "@/stores/settingsStore";
import { loadEditorTheme, editorFontTheme } from "@/lib/editorThemes";
import { isTauriRuntime } from "@/lib/tauriRuntime";
import type { JdbcDriverInfo, JdbcPluginStatus } from "@/types/database";
import * as api from "@/lib/api";
import { aiTestConnection } from "@/lib/api";
import { useToast } from "@/composables/useToast";
const { t } = useI18n();
@ -23,6 +31,7 @@ const { toast } = useToast();
const props = defineProps<{
open: boolean;
initialTab?: string;
}>();
const emit = defineEmits<{
@ -120,13 +129,15 @@ const jdbcDriverPathInput = ref("");
watch(
() => props.open,
(open) => {
async (open) => {
if (open) {
activeSettingsTab.value = "editor";
activeSettingsTab.value = props.initialTab || "editor";
passwordMessage.value = "";
oldPassword.value = "";
newPassword.value = "";
confirmNewPassword.value = "";
await settingsStore.initAiConfig();
syncAiEditState();
void loadJdbcDrivers();
void loadJdbcPluginStatus();
}
@ -273,6 +284,91 @@ async function deleteJdbcDriver(path: string) {
}
}
// ---------- AI Settings ----------
const aiProviderDefaults: Record<AiProvider, { endpoint: string; model: string }> = {
claude: { endpoint: "https://api.anthropic.com/v1/messages", model: "claude-sonnet-4-20250514" },
openai: { endpoint: "https://api.openai.com/v1/chat/completions", model: "gpt-4o" },
custom: { endpoint: "", model: "" },
};
const aiEditProvider = ref<AiProvider>(settingsStore.aiConfig.provider);
const aiEditApiKey = ref(settingsStore.aiConfig.apiKey);
const aiEditEndpoint = ref(settingsStore.aiConfig.endpoint);
const aiEditModel = ref(settingsStore.aiConfig.model);
const aiEditApiStyle = ref<AiApiStyle>(settingsStore.aiConfig.apiStyle || "completions");
const aiEditProxyEnabled = ref(!!settingsStore.aiConfig.proxyEnabled);
const aiEditProxyUrl = ref(settingsStore.aiConfig.proxyUrl || "");
const aiTesting = ref(false);
const aiTestResult = ref<"" | "success" | "error">("");
const aiTestError = ref("");
function syncAiEditState() {
aiEditProvider.value = settingsStore.aiConfig.provider;
aiEditApiKey.value = settingsStore.aiConfig.apiKey;
aiEditEndpoint.value = settingsStore.aiConfig.endpoint;
aiEditModel.value = settingsStore.aiConfig.model;
aiEditApiStyle.value = settingsStore.aiConfig.apiStyle || "completions";
aiEditProxyEnabled.value = !!settingsStore.aiConfig.proxyEnabled;
aiEditProxyUrl.value = settingsStore.aiConfig.proxyUrl || "";
aiTestResult.value = "";
aiTestError.value = "";
}
function aiSelectProvider(provider: AiProvider) {
aiEditProvider.value = provider;
aiEditEndpoint.value = aiProviderDefaults[provider].endpoint;
aiEditModel.value = aiProviderDefaults[provider].model;
}
function aiHasChanges(): boolean {
return (
aiEditProvider.value !== settingsStore.aiConfig.provider ||
aiEditApiKey.value !== settingsStore.aiConfig.apiKey ||
aiEditEndpoint.value !== settingsStore.aiConfig.endpoint ||
aiEditModel.value !== settingsStore.aiConfig.model ||
aiEditApiStyle.value !== (settingsStore.aiConfig.apiStyle || "completions") ||
aiEditProxyEnabled.value !== !!settingsStore.aiConfig.proxyEnabled ||
aiEditProxyUrl.value !== (settingsStore.aiConfig.proxyUrl || "")
);
}
function aiApplySettings() {
settingsStore.updateAiConfig({
provider: aiEditProvider.value,
apiKey: aiEditApiKey.value,
endpoint: aiEditEndpoint.value,
model: aiEditModel.value,
apiStyle: aiEditApiStyle.value,
proxyEnabled: aiEditProxyEnabled.value,
proxyUrl: aiEditProxyUrl.value,
});
}
async function aiTestConn() {
if (!aiEditApiKey.value.trim() || !aiEditEndpoint.value.trim() || !aiEditModel.value.trim()) return;
aiTesting.value = true;
aiTestResult.value = "";
aiTestError.value = "";
try {
await aiTestConnection({
provider: aiEditProvider.value,
apiKey: aiEditApiKey.value,
endpoint: aiEditEndpoint.value,
model: aiEditModel.value,
apiStyle: aiEditApiStyle.value,
proxyEnabled: aiEditProxyEnabled.value,
proxyUrl: aiEditProxyUrl.value,
});
aiTestResult.value = "success";
} catch (e: any) {
aiTestResult.value = "error";
aiTestError.value = e?.message || String(e);
} finally {
aiTesting.value = false;
}
}
// ---------- CodeMirror preview ----------
const previewRef = ref<HTMLDivElement>();
const previewView = shallowRef<EditorViewType | null>(null);
@ -381,6 +477,7 @@ watch(
<TabsList class="w-full">
<TabsTrigger value="editor" class="flex-1">{{ t("settings.editorTab") }}</TabsTrigger>
<TabsTrigger value="appearance" class="flex-1">{{ t("settings.appearanceTab") }}</TabsTrigger>
<TabsTrigger value="ai" class="flex-1">{{ t("settings.aiTab") }}</TabsTrigger>
<TabsTrigger v-if="!isWeb" value="jdbc" class="flex-1">{{ t("settings.jdbcTab") }}</TabsTrigger>
<TabsTrigger v-if="isWeb" value="security" class="flex-1">{{ t("settings.securityTab") }}</TabsTrigger>
<TabsTrigger value="about" class="flex-1">{{ t("settings.aboutTab") }}</TabsTrigger>
@ -525,7 +622,7 @@ watch(
type="button"
variant="outline"
class="h-auto justify-start p-3"
:class="editAppLayout === 'separated' ? 'border-primary bg-primary/10' : ''"
:class="editAppLayout === 'separated' ? 'border-blue-300 border-2 ring-2 ring-blue-300/50' : ''"
@click="setAppLayout('separated')"
>
<div class="text-left">
@ -537,7 +634,7 @@ watch(
type="button"
variant="outline"
class="h-auto justify-start p-3"
:class="editAppLayout === 'classic' ? 'border-primary bg-primary/10' : ''"
:class="editAppLayout === 'classic' ? 'border-blue-300 border-2 ring-2 ring-blue-300/50' : ''"
@click="setAppLayout('classic')"
>
<div class="text-left">
@ -562,6 +659,111 @@ watch(
</DialogFooter>
</TabsContent>
<!-- AI Settings Tab -->
<TabsContent value="ai" class="space-y-5 py-2">
<p class="text-xs text-muted-foreground">{{ t("ai.settingsHint") }}</p>
<div class="space-y-3">
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">{{ t("ai.provider") }}</Label>
<Select :model-value="aiEditProvider" @update:model-value="(v: any) => aiSelectProvider(v)">
<SelectTrigger class="col-span-2 h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="claude">Claude</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">API Key</Label>
<Input v-model="aiEditApiKey" type="password" autocomplete="off" class="col-span-2 h-8 text-xs" />
</div>
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">Endpoint</Label>
<Input
v-model="aiEditEndpoint"
placeholder="https://api.openai.com/v1"
autocomplete="off"
class="col-span-2 h-8 text-xs"
/>
</div>
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">Model</Label>
<Input v-model="aiEditModel" autocomplete="off" class="col-span-2 h-8 text-xs" />
</div>
<div v-if="aiEditProvider !== 'claude'" class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">API</Label>
<div class="col-span-2 flex gap-2">
<Button
size="sm"
variant="outline"
class="h-8 flex-1 text-xs"
:class="{ 'border-blue-300 border-2 ring-2 ring-blue-300/50': aiEditApiStyle === 'completions' }"
@click="aiEditApiStyle = 'completions'"
>/chat/completions</Button
>
<Button
size="sm"
variant="outline"
class="h-8 flex-1 text-xs"
:class="{ 'border-blue-300 border-2 ring-2 ring-blue-300/50': aiEditApiStyle === 'responses' }"
@click="aiEditApiStyle = 'responses'"
>/responses</Button
>
</div>
</div>
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">{{ t("ai.proxy") }}</Label>
<label class="col-span-2 flex items-center gap-2 text-xs text-muted-foreground">
<input v-model="aiEditProxyEnabled" type="checkbox" class="h-4 w-4 shrink-0 accent-primary" />
{{ t("ai.proxyEnable") }}
</label>
</div>
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">{{ t("ai.proxyUrl") }}</Label>
<Input
v-model="aiEditProxyUrl"
autocomplete="off"
class="col-span-2 h-8 text-xs"
placeholder="socks5://127.0.0.1:7890"
:disabled="!aiEditProxyEnabled"
/>
</div>
</div>
<DialogFooter class="flex items-center gap-2">
<div class="flex-1 flex items-center gap-2">
<Button
size="sm"
variant="outline"
:disabled="aiTesting || !aiEditApiKey?.trim() || !aiEditEndpoint?.trim() || !aiEditModel?.trim()"
@click="aiTestConn"
>
<Loader2 v-if="aiTesting" class="h-3 w-3 animate-spin mr-1" />
{{ t("connection.test") }}
</Button>
<span v-if="aiTestResult === 'success'" class="text-xs text-green-500">{{
t("connection.testSuccess")
}}</span>
<span
v-else-if="aiTestResult === 'error'"
class="text-xs text-destructive truncate max-w-[200px]"
:title="aiTestError"
>{{ aiTestError }}</span
>
</div>
<Button variant="outline" @click="emit('update:open', false)">{{ t("common.close") }}</Button>
<Button :disabled="!aiHasChanges()" @click="aiApplySettings">{{ t("settings.apply") }}</Button>
</DialogFooter>
</TabsContent>
<TabsContent v-if="!isWeb" value="jdbc" class="space-y-5 py-2">
<div class="rounded-md border bg-muted/20 p-4">
<div class="flex min-h-12 items-center justify-between gap-3">

View File

@ -24,6 +24,7 @@ import { useDialogSources } from "@/composables/useDialogSources";
const props = defineProps<{
showConnectionDialog: boolean;
showSettingsDialog: boolean;
settingsInitialTab?: string;
showDangerDialog: boolean;
dangerSql: string;
}>();
@ -98,7 +99,11 @@ watch(
@connect-succeeded="emit('connectSucceeded', $event)"
@connect-failed="emit('connectFailed', $event)"
/>
<EditorSettingsDialog :open="showSettingsDialog" @update:open="emit('update:showSettingsDialog', $event)" />
<EditorSettingsDialog
:open="showSettingsDialog"
:initial-tab="settingsInitialTab || 'editor'"
@update:open="emit('update:showSettingsDialog', $event)"
/>
<DangerConfirmDialog
:open="showDangerDialog"
:sql="dangerSql"

View File

@ -452,6 +452,7 @@ export default {
ai: {
placeholder: "Describe your query in natural language...",
settings: "AI Settings",
noConfig: "AI is not configured. Please set up your AI provider in Settings > AI.",
provider: "Provider",
run: "Run",
readingSchema: "Reading schema",
@ -896,6 +897,7 @@ export default {
title: "Settings",
editorTab: "Editor",
appearanceTab: "Appearance",
aiTab: "AI",
jdbcTab: "JDBC Drivers",
securityTab: "Security",
aboutTab: "About",

View File

@ -443,6 +443,7 @@ export default {
ai: {
placeholder: "描述你想查什么...",
settings: "AI 设置",
noConfig: "AI 尚未配置。请在 设置 > AI 中配置你的 AI 服务。",
provider: "提供商",
run: "执行",
readingSchema: "读取结构",
@ -881,6 +882,7 @@ export default {
title: "设置",
editorTab: "编辑器",
appearanceTab: "外观",
aiTab: "AI",
jdbcTab: "JDBC 驱动",
securityTab: "安全",
aboutTab: "关于我们",