feat: 大量优化和bug修复

This commit is contained in:
2026-05-23 22:23:29 +08:00
parent 92b063c4c4
commit fc71e924e7
39 changed files with 1381 additions and 302 deletions
+34 -22
View File
@@ -10,6 +10,7 @@ import {
Sun
} from "lucide-vue-next";
import SettingsDialog from "@/components/layout/SettingsDialog.vue";
import { Button } from "@/components/ui/button";
import { useAuthStore } from "@/stores/auth";
import { useGlobalStore } from "@/stores/global";
@@ -96,28 +97,39 @@ const handleSignOut = async () => {
<Moon v-else class="size-4" />
</Button>
<!-- 已登录显示邮箱和退出按钮未登录显示登录链接 -->
<template v-if="authStore.isOnline">
<span class="max-sm:hidden text-xs text-muted-foreground">
{{ authStore.user!.email }}
</span>
<Button
variant="ghost"
size="icon"
aria-label="退出登录"
@click="handleSignOut"
>
<LogOut class="size-4" />
</Button>
</template>
<template v-else>
<Button as-child variant="ghost" size="sm">
<NuxtLink to="/login" class="gap-2">
<LogIn class="size-4" />
<span>登录</span>
</NuxtLink>
</Button>
</template>
<!--
已登录/未登录状态由客户端 auth 插件决定.client.ts
SSR 阶段 isOnline 始终为 false
整个认证区块必须放在 ClientOnly 内避免水合不匹配
-->
<ClientOnly>
<template v-if="authStore.isOnline">
<span class="max-sm:hidden text-xs text-muted-foreground">
{{ authStore.user!.email }}
</span>
<SettingsDialog />
<Button
variant="ghost"
size="icon"
aria-label="退出登录"
@click="handleSignOut"
>
<LogOut class="size-4" />
</Button>
</template>
<template v-else>
<Button as-child variant="ghost" size="sm">
<NuxtLink to="/login" class="gap-2">
<LogIn class="size-4" />
<span>登录</span>
</NuxtLink>
</Button>
</template>
<!-- SSR 阶段占位避免布局闪烁 -->
<template #fallback>
<div class="size-8 rounded-md" />
</template>
</ClientOnly>
</nav>
</div>
</header>
+361
View File
@@ -0,0 +1,361 @@
<!-- app/components/layout/SettingsDialog.vue - 右上角设置弹窗个人 API 配置 + superadmin 系统配置 -->
<script lang="ts" setup>
import { Settings } from "lucide-vue-next";
import { storeToRefs } from "pinia";
import { ref, watch } from "vue";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useAuthStore } from "@/stores/auth";
import { useSettingsStore } from "@/stores/settings";
const authStore = useAuthStore();
const settingsStore = useSettingsStore();
const { userSettings, systemConfig, userLoading, userFetching, sysLoading } =
storeToRefs(settingsStore);
const open = ref(false);
const isSuperAdmin = computed(() => authStore.user?.role === "superadmin");
const userFormBusy = computed(() => userLoading.value || userFetching.value);
// ─────────────────────────────────────
// 个人配置表单状态
// ─────────────────────────────────────
const userForm = reactive({
useCustomConfig: false,
customApiBase: "",
customApiKey: "", // 空 = 不修改
customModel: "",
customMaxTokens: "" as string,
customTemperature: "" as string
});
// ─────────────────────────────────────
// 系统配置表单状态
// ─────────────────────────────────────
const sysForm = reactive({
openAiApiBase: "",
openAiApiKey: "", // 空 = 不修改
openAiModel: "",
maxTokens: "" as string,
temperature: "" as string,
maxSseLineBytes: "" as string
});
/** 打开时拉取最新配置,并回填表单 */
watch(open, async (val) => {
if (!val) return;
await settingsStore.fetchUserSettings();
if (isSuperAdmin.value) {
await settingsStore.fetchSystemConfig();
}
});
/** 服务端数据加载后同步到表单 */
watch(
userSettings,
(s) => {
if (!s) return;
userForm.useCustomConfig = s.useCustomConfig;
userForm.customApiBase = s.customApiBase ?? "";
userForm.customApiKey = ""; // key 不回填,placeholder 展示遮蔽串
userForm.customModel = s.customModel ?? "";
userForm.customMaxTokens =
s.customMaxTokens !== null ? String(s.customMaxTokens) : "";
userForm.customTemperature =
s.customTemperature !== null ? String(s.customTemperature) : "";
},
{ immediate: true }
);
watch(
systemConfig,
(s) => {
if (!s) return;
sysForm.openAiApiBase = s.openAiApiBase;
sysForm.openAiApiKey = ""; // key 不回填
sysForm.openAiModel = s.openAiModel;
sysForm.maxTokens = String(s.maxTokens);
sysForm.temperature = String(s.temperature);
sysForm.maxSseLineBytes = String(s.maxSseLineBytes);
},
{ immediate: true }
);
/** 保存个人配置 */
const saveUserSettings = async () => {
const maxTokens = userForm.customMaxTokens
? Number(userForm.customMaxTokens)
: null;
const temperature = userForm.customTemperature
? Number(userForm.customTemperature)
: null;
await settingsStore.updateUserSettings({
useCustomConfig: userForm.useCustomConfig,
customApiBase: userForm.customApiBase,
customApiKey: userForm.customApiKey, // 空字符串 = 服务端跳过
customModel: userForm.customModel,
customMaxTokens: maxTokens,
customTemperature: temperature
});
};
/** 保存系统配置(superadmin only */
const saveSystemConfig = async () => {
await settingsStore.updateSystemConfig({
openAiApiBase: sysForm.openAiApiBase || undefined,
openAiApiKey: sysForm.openAiApiKey || undefined, // 空 = 不更新
openAiModel: sysForm.openAiModel || undefined,
maxTokens: sysForm.maxTokens ? Number(sysForm.maxTokens) : undefined,
temperature: sysForm.temperature ? Number(sysForm.temperature) : undefined,
maxSseLineBytes: sysForm.maxSseLineBytes
? Number(sysForm.maxSseLineBytes)
: undefined
});
};
</script>
<template>
<Dialog v-model:open="open">
<DialogTrigger as-child>
<Button variant="ghost" size="icon" aria-label="设置">
<Settings class="size-4" />
</Button>
</DialogTrigger>
<DialogContent class="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
设置
<!-- 弹窗打开时拉取配置的加载指示与保存操作无关 -->
<span
v-if="userFetching"
class="size-3.5 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground"
/>
</DialogTitle>
<!-- 为无障碍语义提供描述视觉上隐藏 -->
<DialogDescription class="sr-only">
管理您的自定义 API 配置
</DialogDescription>
</DialogHeader>
<!-- 个人 API 配置区 -->
<div class="space-y-4">
<p class="text-sm font-medium text-foreground">个人 API 配置</p>
<p class="text-xs text-muted-foreground leading-relaxed">
启用后将使用您的自定义 API 地址和 Key 进行答题API Key
和地址必须同时填写才会生效
</p>
<!-- 启用/禁用开关 -->
<div class="flex items-center gap-3">
<Switch
:id="'use-custom-config'"
v-model="userForm.useCustomConfig"
:disabled="userFormBusy"
/>
<Label for="use-custom-config" class="cursor-pointer select-none">
使用自定义 API
</Label>
</div>
<!-- 自定义字段关闭时视觉置灰但仍可编辑 -->
<div
class="space-y-3 transition-opacity"
:class="{ 'opacity-40': !userForm.useCustomConfig }"
>
<div class="space-y-1.5">
<Label for="custom-api-base">API 地址</Label>
<Input
id="custom-api-base"
v-model="userForm.customApiBase"
placeholder="https://api.openai.com/v1"
autocomplete="off"
:disabled="userFormBusy"
/>
</div>
<div class="space-y-1.5">
<Label for="custom-api-key">API Key</Label>
<Input
id="custom-api-key"
v-model="userForm.customApiKey"
type="password"
:placeholder="
userSettings?.customApiKeyPreview
? `已设置(${userSettings.customApiKeyPreview}),留空不修改`
: '请输入 API Key'
"
autocomplete="new-password"
:disabled="userFormBusy"
/>
</div>
<div class="space-y-1.5">
<Label for="custom-model">
模型名称
<span class="text-muted-foreground font-normal"
>可选留空跟随系统配置</span
>
</Label>
<Input
id="custom-model"
v-model="userForm.customModel"
placeholder="gpt-5.2"
:disabled="userFormBusy"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<Label for="custom-max-tokens">
最大 Token
<span class="text-muted-foreground font-normal">可选</span>
</Label>
<Input
id="custom-max-tokens"
v-model="userForm.customMaxTokens"
type="number"
min="1"
max="32768"
placeholder="跟随系统配置"
:disabled="userFormBusy"
/>
</div>
<div class="space-y-1.5">
<Label for="custom-temperature">
温度
<span class="text-muted-foreground font-normal">可选</span>
</Label>
<Input
id="custom-temperature"
v-model="userForm.customTemperature"
type="number"
step="0.1"
min="0"
max="2"
placeholder="跟随系统配置"
:disabled="userFormBusy"
/>
</div>
</div>
</div>
<Button
class="w-full"
:disabled="
userFormBusy ||
!userForm.customApiBase ||
(!userForm.customApiKey && !userSettings?.customApiKeyPreview)
"
@click="saveUserSettings"
>
{{ userLoading ? "保存中..." : "保存个人配置" }}
</Button>
</div>
<!-- 系统配置区 superadmin -->
<template v-if="isSuperAdmin">
<hr class="my-2 border-border" />
<div class="space-y-4">
<div>
<p class="text-sm font-medium text-foreground">系统配置</p>
<p class="text-xs text-muted-foreground mt-1">
修改后立即对所有用户未启用自定义配置的生效
</p>
</div>
<div class="space-y-3">
<div class="space-y-1.5">
<Label for="sys-api-base">API 地址</Label>
<Input
id="sys-api-base"
v-model="sysForm.openAiApiBase"
placeholder="https://api.openai.com/v1"
autocomplete="off"
/>
</div>
<div class="space-y-1.5">
<Label for="sys-api-key">API Key</Label>
<Input
id="sys-api-key"
v-model="sysForm.openAiApiKey"
type="password"
:placeholder="
systemConfig?.openAiApiKeyPreview
? `已设置(${systemConfig.openAiApiKeyPreview}),留空不修改`
: '请输入 API Key'
"
autocomplete="new-password"
/>
</div>
<div class="space-y-1.5">
<Label for="sys-model">模型名称</Label>
<Input
id="sys-model"
v-model="sysForm.openAiModel"
placeholder="gpt-5.2"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<Label for="sys-max-tokens">最大 Token </Label>
<Input
id="sys-max-tokens"
v-model="sysForm.maxTokens"
type="number"
min="1"
max="32768"
/>
</div>
<div class="space-y-1.5">
<Label for="sys-temperature">温度</Label>
<Input
id="sys-temperature"
v-model="sysForm.temperature"
type="number"
step="0.1"
min="0"
max="2"
/>
</div>
</div>
<div class="space-y-1.5">
<Label for="sys-max-sse">SSE 单行最大字节数</Label>
<Input
id="sys-max-sse"
v-model="sysForm.maxSseLineBytes"
type="number"
min="1024"
/>
</div>
</div>
<Button
class="w-full"
variant="secondary"
:disabled="sysLoading"
@click="saveSystemConfig"
>
{{ sysLoading ? "更新中..." : "更新系统配置" }}
</Button>
</div>
</template>
</DialogContent>
</Dialog>
</template>