362 lines
12 KiB
Vue
362 lines
12 KiB
Vue
<!-- 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>
|