Files
OCS_service/app/components/layout/SettingsDialog.vue
T
2026-05-23 22:23:29 +08:00

362 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 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>