feat: 大量优化和bug修复
This commit is contained in:
@@ -29,7 +29,7 @@ OPENAI_API_KEY=your-api-key-here
|
||||
|
||||
```env
|
||||
OPENAI_API_BASE=https://api.openai.com/v1
|
||||
OPENAI_MODEL=gpt-3.5-turbo
|
||||
OPENAI_MODEL=gpt-5.2
|
||||
MAX_TOKENS=500
|
||||
TEMPERATURE=0.7
|
||||
```
|
||||
|
||||
@@ -6,6 +6,14 @@ import { Toaster } from "vue-sonner";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
|
||||
const themeStore = useGlobalStore();
|
||||
|
||||
// 将深色模式 class 反映到 <html>;useHead 在 SSR 和客户端都能执行,
|
||||
// 与 cookie 配合实现服务端 / 客户端一致渲染,避免水合不匹配
|
||||
useHead({
|
||||
htmlAttrs: {
|
||||
class: computed(() => (themeStore.isDark ? "dark" : ""))
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -146,7 +146,7 @@ const getQuestionTypeLabel = (type: string) => {
|
||||
<template #options="{ row }">
|
||||
<TooltipCom side="top" align="start">
|
||||
<template #trigger>
|
||||
<div class="max-w-md cursor-default truncate">
|
||||
<div class="max-w-md cursor-pointer truncate">
|
||||
<template v-if="row.options">
|
||||
<template
|
||||
v-for="(line, i) in getOptionLines(
|
||||
@@ -155,17 +155,18 @@ const getQuestionTypeLabel = (type: string) => {
|
||||
)"
|
||||
:key="i"
|
||||
>
|
||||
<span v-if="i > 0" class="text-muted-foreground"
|
||||
> </span
|
||||
>
|
||||
<span v-if="i > 0" class="text-muted-foreground">
|
||||
|
||||
</span>
|
||||
<span
|
||||
:class="
|
||||
line.isCorrect
|
||||
? 'text-green-500 font-medium'
|
||||
: 'text-muted-foreground'
|
||||
"
|
||||
>{{ line.text }}</span
|
||||
>
|
||||
{{ line.text }}
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
<span v-else class="text-muted-foreground">-</span>
|
||||
@@ -181,6 +182,7 @@ const getQuestionTypeLabel = (type: string) => {
|
||||
row.answer ?? ''
|
||||
)"
|
||||
:key="i"
|
||||
class="whitespace-pre-wrap mb-2 border rounded-lg px-2 py-1 last:mb-0 min-w-16"
|
||||
:class="line.isCorrect ? 'text-green-500 font-medium' : ''"
|
||||
>
|
||||
{{ line.text }}
|
||||
|
||||
@@ -5,47 +5,11 @@ import { storeToRefs } from "pinia";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAnswerStore } from "@/stores/answer";
|
||||
|
||||
const answerStore = useAnswerStore();
|
||||
const {
|
||||
health,
|
||||
stats,
|
||||
uptimeText,
|
||||
dashboardErrorMessage,
|
||||
statsLoading,
|
||||
cacheClearing
|
||||
} = storeToRefs(answerStore);
|
||||
|
||||
/** 顶部指标由 store 中的健康状态和统计信息组合而来 */
|
||||
const overviewItems = computed(() => [
|
||||
{
|
||||
label: "服务状态",
|
||||
value: health.value?.status === "ok" ? "运行中" : "-",
|
||||
detail: health.value?.version ? `v${health.value.version}` : "-",
|
||||
loading: statsLoading.value && !health.value
|
||||
},
|
||||
{
|
||||
label: "模型",
|
||||
value: stats.value?.model || health.value?.model || "-",
|
||||
detail: "AI 模型",
|
||||
loading: statsLoading.value && !stats.value
|
||||
},
|
||||
{
|
||||
label: "问答记录",
|
||||
value: stats.value?.qa_records_count?.toString() ?? "0",
|
||||
detail: uptimeText.value,
|
||||
loading: statsLoading.value && !stats.value
|
||||
}
|
||||
]);
|
||||
const { dashboardErrorMessage, recordsLoading, cacheClearing } =
|
||||
storeToRefs(answerStore);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -65,10 +29,13 @@ const overviewItems = computed(() => [
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="statsLoading"
|
||||
:disabled="recordsLoading"
|
||||
@click="answerStore.loadDashboard"
|
||||
>
|
||||
<RefreshCw class="size-4" :class="{ 'animate-spin': statsLoading }" />
|
||||
<RefreshCw
|
||||
class="size-4"
|
||||
:class="{ 'animate-spin': recordsLoading }"
|
||||
/>
|
||||
<span>刷新</span>
|
||||
</Button>
|
||||
<Button
|
||||
@@ -87,27 +54,5 @@ const overviewItems = computed(() => [
|
||||
<Alert v-if="dashboardErrorMessage" variant="destructive">
|
||||
<AlertDescription>{{ dashboardErrorMessage }}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Card v-for="item in overviewItems" :key="item.label" size="sm">
|
||||
<CardHeader>
|
||||
<CardDescription>{{ item.label }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<template v-if="item.loading">
|
||||
<Skeleton class="mb-2 h-8 w-24" />
|
||||
<Skeleton class="h-4 w-32" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<CardTitle class="truncate text-2xl font-semibold">
|
||||
{{ item.value }}
|
||||
</CardTitle>
|
||||
<div class="mt-1 truncate text-xs text-muted-foreground">
|
||||
{{ item.detail }}
|
||||
</div>
|
||||
</template>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
@@ -61,31 +62,46 @@ const configText = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>OCS 配置</CardTitle>
|
||||
<CardAction>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="authStore.user?.apiToken && authStore.isOnline"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="refreshing"
|
||||
@click="handleRefreshToken"
|
||||
>
|
||||
<RefreshCw :class="['size-4', refreshing ? 'animate-spin' : '']" />
|
||||
刷新 Token
|
||||
</Button>
|
||||
<CopyCom :text="configText" :timeout="1200" :disabled="refreshing" />
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<!-- auth 插件为 client-only,apiToken / isOnline 在 SSR 时不可用;
|
||||
整张卡用 ClientOnly 包裹,避免 token 文本和条件渲染的水合不匹配 -->
|
||||
<ClientOnly>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>OCS 配置</CardTitle>
|
||||
<CardAction>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="authStore.user?.apiToken && authStore.isOnline"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="refreshing"
|
||||
@click="handleRefreshToken"
|
||||
>
|
||||
<RefreshCw
|
||||
:class="['size-4', refreshing ? 'animate-spin' : '']"
|
||||
/>
|
||||
刷新 Token
|
||||
</Button>
|
||||
<CopyCom
|
||||
:text="configText"
|
||||
:timeout="1200"
|
||||
:disabled="refreshing"
|
||||
/>
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<pre
|
||||
class="h-89.5 overflow-x-auto overflow-y-hidden rounded-2xl bg-muted p-4 text-xs leading-5 text-muted-foreground"
|
||||
><code>{{ configText }}</code>
|
||||
<CardContent>
|
||||
<pre
|
||||
class="h-89.5 overflow-x-auto overflow-y-hidden rounded-2xl bg-muted p-4 text-xs leading-5 text-muted-foreground"
|
||||
><code>{{ configText }}</code>
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<template #fallback>
|
||||
<!-- SSR 占位,维持右侧列布局,高度与真实卡片近似 -->
|
||||
<Skeleton class="h-115.5 rounded-4xl" />
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import type { SwitchRootEmits, SwitchRootProps } from 'reka-ui'
|
||||
import {
|
||||
SwitchRoot,
|
||||
SwitchThumb,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(defineProps<SwitchRootProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
size?: 'sm' | 'default'
|
||||
}>(), {
|
||||
class: undefined,
|
||||
size: 'default',
|
||||
})
|
||||
|
||||
const emits = defineEmits<SwitchRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'size')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="switch"
|
||||
:data-size="size"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'data-[state=checked]:bg-primary data-[state=unchecked]:bg-input/90 data-[state=checked]:border-primary border-2 data-[state=unchecked]:border-transparent focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 shrink-0 rounded-full focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-5 data-[size=default]:w-11 data-[size=sm]:h-4 data-[size=sm]:w-7 peer group/switch relative inline-flex items-center transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<SwitchThumb
|
||||
data-slot="switch-thumb"
|
||||
class="bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground rounded-full shadow-sm group-data-[size=default]/switch:h-4 group-data-[size=default]/switch:w-6 group-data-[size=sm]/switch:h-3 group-data-[size=sm]/switch:w-4 data-[state=checked]:translate-x-[calc(100%-8px)] data-[state=unchecked]:translate-x-0 not-dark:bg-clip-padding pointer-events-none block ring-0 transition-transform"
|
||||
>
|
||||
<slot name="thumb" v-bind="slotProps" />
|
||||
</SwitchThumb>
|
||||
</SwitchRoot>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Switch } from './Switch.vue'
|
||||
@@ -2,7 +2,6 @@
|
||||
export type {
|
||||
IApiResponse,
|
||||
IClearCacheResponse,
|
||||
IHealthResponse,
|
||||
IQaRecord,
|
||||
IRecordsData,
|
||||
IRecordsRequest,
|
||||
@@ -11,6 +10,5 @@ export type {
|
||||
ISearchRequest,
|
||||
ISearchResponse,
|
||||
ISearchSuccessResponse,
|
||||
IStatsResponse,
|
||||
QuestionType
|
||||
} from "~~/shared/types/answer";
|
||||
|
||||
@@ -1,2 +1,10 @@
|
||||
// app/interfaces/index.ts - 前端共享类型统一出口
|
||||
export * from "./answer";
|
||||
export type {
|
||||
ISystemConfigPublic,
|
||||
ISystemConfigResponse,
|
||||
ISystemConfigUpdateRequest,
|
||||
IUserSettings,
|
||||
IUserSettingsResponse,
|
||||
IUserSettingsUpdateRequest
|
||||
} from "~~/shared/types/settings";
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
import type {
|
||||
IApiResponse,
|
||||
IClearCacheResponse,
|
||||
IHealthResponse,
|
||||
IRecordsRequest,
|
||||
IRecordsResponse,
|
||||
ISearchRequest,
|
||||
ISearchResponse,
|
||||
IStatsResponse
|
||||
ISearchResponse
|
||||
} from "@/interfaces";
|
||||
import BaseClientService from "@/services/base_service";
|
||||
|
||||
@@ -26,20 +24,6 @@ export class AnswerService {
|
||||
);
|
||||
}
|
||||
|
||||
/** 读取健康状态;无需 token */
|
||||
public static getHealth() {
|
||||
return BaseClientService.get<IApiResponse<IHealthResponse>>(
|
||||
`${AnswerService.basePath}/health`
|
||||
);
|
||||
}
|
||||
|
||||
/** 读取运行统计(需登录) */
|
||||
public static getStats() {
|
||||
return BaseClientService.get<IApiResponse<IStatsResponse>>(
|
||||
`${AnswerService.basePath}/stats`
|
||||
);
|
||||
}
|
||||
|
||||
/** 读取最近问答记录,供 Dashboard 表格展示 */
|
||||
public static getRecords(request: IRecordsRequest) {
|
||||
return BaseClientService.get<IRecordsResponse>(
|
||||
|
||||
@@ -46,4 +46,17 @@ export default class BaseClientService {
|
||||
|
||||
return { data };
|
||||
}
|
||||
|
||||
/** 发起 PUT 请求 */
|
||||
public static async put<T, B extends object | undefined = undefined>(
|
||||
url: string,
|
||||
body?: B
|
||||
) {
|
||||
const data = await $fetch<T>(url, {
|
||||
method: "PUT",
|
||||
body
|
||||
});
|
||||
|
||||
return { data };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// app/services/index.ts - 前端 service 统一出口
|
||||
export * from "./answer_service";
|
||||
export * from "./settings_service";
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// app/services/settings_service.ts - 用户设置和系统配置 API 封装
|
||||
import type {
|
||||
IApiResponse,
|
||||
ISystemConfigResponse,
|
||||
ISystemConfigUpdateRequest,
|
||||
IUserSettingsResponse,
|
||||
IUserSettingsUpdateRequest
|
||||
} from "@/interfaces";
|
||||
import BaseClientService from "@/services/base_service";
|
||||
|
||||
export class SettingsService {
|
||||
private static readonly basePath = "/api";
|
||||
|
||||
/** 读取当前用户的自定义 API 配置 */
|
||||
public static getUserSettings() {
|
||||
return BaseClientService.get<IUserSettingsResponse>(
|
||||
`${SettingsService.basePath}/user/settings`
|
||||
);
|
||||
}
|
||||
|
||||
/** 更新当前用户的自定义 API 配置 */
|
||||
public static updateUserSettings(req: IUserSettingsUpdateRequest) {
|
||||
return BaseClientService.post<IApiResponse<null>, IUserSettingsUpdateRequest>(
|
||||
`${SettingsService.basePath}/user/settings`,
|
||||
req
|
||||
);
|
||||
}
|
||||
|
||||
/** 读取系统配置(仅 superadmin) */
|
||||
public static getSystemConfig() {
|
||||
return BaseClientService.get<ISystemConfigResponse>(
|
||||
`${SettingsService.basePath}/admin/system-config`
|
||||
);
|
||||
}
|
||||
|
||||
/** 更新系统配置(仅 superadmin) */
|
||||
public static updateSystemConfig(req: ISystemConfigUpdateRequest) {
|
||||
return BaseClientService.put<IApiResponse<null>, ISystemConfigUpdateRequest>(
|
||||
`${SettingsService.basePath}/admin/system-config`,
|
||||
req
|
||||
);
|
||||
}
|
||||
}
|
||||
+5
-55
@@ -3,14 +3,12 @@ import { defineStore } from "pinia";
|
||||
import { toast } from "vue-sonner";
|
||||
|
||||
import type {
|
||||
IHealthResponse,
|
||||
IQaRecord,
|
||||
ISearchSuccessResponse,
|
||||
IStatsResponse,
|
||||
QuestionType
|
||||
} from "@/interfaces";
|
||||
import { AnswerService } from "@/services";
|
||||
import { formatUptime, getClientErrorMessage } from "~/utils";
|
||||
import { getClientErrorMessage } from "~/utils";
|
||||
|
||||
/** Dashboard 最近记录表格每页数量,组件直接复用,避免多个地方写死 */
|
||||
export const DASHBOARD_RECORD_PAGE_SIZE = 10;
|
||||
@@ -56,16 +54,10 @@ export const useAnswerStore = defineStore("answer", () => {
|
||||
const searchErrorMessage = ref("");
|
||||
|
||||
// ---- Dashboard 状态 ----
|
||||
/** 健康检查信息 */
|
||||
const health = ref<IHealthResponse>();
|
||||
/** 运行统计信息 */
|
||||
const stats = ref<IStatsResponse>();
|
||||
/** 最近问答记录 */
|
||||
const records = ref<IQaRecord[]>([]);
|
||||
/** Dashboard 错误文案 */
|
||||
const dashboardErrorMessage = ref("");
|
||||
/** 统计信息加载状态 */
|
||||
const statsLoading = ref(false);
|
||||
/** 最近问答记录加载状态 */
|
||||
const recordsLoading = ref(false);
|
||||
/** 缓存清理状态 */
|
||||
@@ -82,11 +74,6 @@ export const useAnswerStore = defineStore("answer", () => {
|
||||
/** Dashboard 表格当前页数据,页码切换会重新请求服务端分页接口 */
|
||||
const pagedRecords = computed(() => records.value);
|
||||
|
||||
/** Dashboard 顶部运行时长展示 */
|
||||
const uptimeText = computed(() => {
|
||||
return stats.value ? formatUptime(stats.value.uptime) : "-";
|
||||
});
|
||||
|
||||
/** 选择表格记录,交给详情弹窗展示完整题目、选项和答案 */
|
||||
const selectRecord = (record?: IQaRecord) => {
|
||||
selectedRecord.value = record;
|
||||
@@ -129,7 +116,7 @@ export const useAnswerStore = defineStore("answer", () => {
|
||||
if (res.data.code === 1) {
|
||||
answerResult.value = res.data;
|
||||
recordsPage.value = 1;
|
||||
await Promise.allSettled([getStats(), getRecords()]);
|
||||
await getRecords();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -143,37 +130,6 @@ export const useAnswerStore = defineStore("answer", () => {
|
||||
}
|
||||
};
|
||||
|
||||
/** 读取健康检查,失败时只写本地错误文案 */
|
||||
const getHealth = async () => {
|
||||
try {
|
||||
const res = await AnswerService.getHealth();
|
||||
health.value = res.data.data;
|
||||
} catch (error) {
|
||||
dashboardErrorMessage.value = getClientErrorMessage(
|
||||
error,
|
||||
"健康检查失败"
|
||||
);
|
||||
toast.error(dashboardErrorMessage.value);
|
||||
}
|
||||
};
|
||||
|
||||
/** 读取运行统计 */
|
||||
const getStats = async () => {
|
||||
statsLoading.value = true;
|
||||
try {
|
||||
const res = await AnswerService.getStats();
|
||||
stats.value = res.data.data;
|
||||
} catch (error) {
|
||||
dashboardErrorMessage.value = getClientErrorMessage(
|
||||
error,
|
||||
"统计信息读取失败"
|
||||
);
|
||||
toast.error(dashboardErrorMessage.value);
|
||||
} finally {
|
||||
statsLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 读取最近问答记录;服务端按 page/size 返回当前页数据 */
|
||||
const getRecords = async (page = recordsPage.value) => {
|
||||
recordsLoading.value = true;
|
||||
@@ -197,10 +153,10 @@ export const useAnswerStore = defineStore("answer", () => {
|
||||
}
|
||||
};
|
||||
|
||||
/** Dashboard 首次加载时并行拉取运行状态、统计和最近记录 */
|
||||
/** Dashboard 首次加载时拉取最近记录 */
|
||||
const loadDashboard = async () => {
|
||||
dashboardErrorMessage.value = "";
|
||||
await Promise.allSettled([getHealth(), getStats(), getRecords()]);
|
||||
await getRecords();
|
||||
};
|
||||
|
||||
/** 清空缓存后刷新统计与表格;$fetch 非 2xx 时会抛出,错误由 catch 处理 */
|
||||
@@ -211,7 +167,7 @@ export const useAnswerStore = defineStore("answer", () => {
|
||||
try {
|
||||
await AnswerService.clearCache();
|
||||
toast.success("缓存已清空");
|
||||
await Promise.allSettled([getStats(), getRecords(1)]);
|
||||
await getRecords(1);
|
||||
} catch (error) {
|
||||
dashboardErrorMessage.value = getClientErrorMessage(
|
||||
error,
|
||||
@@ -230,11 +186,8 @@ export const useAnswerStore = defineStore("answer", () => {
|
||||
searchLoading,
|
||||
answerResult,
|
||||
searchErrorMessage,
|
||||
health,
|
||||
stats,
|
||||
records,
|
||||
dashboardErrorMessage,
|
||||
statsLoading,
|
||||
recordsLoading,
|
||||
cacheClearing,
|
||||
recordsPage,
|
||||
@@ -242,10 +195,7 @@ export const useAnswerStore = defineStore("answer", () => {
|
||||
selectedRecord,
|
||||
pagedRecords,
|
||||
recordCount,
|
||||
uptimeText,
|
||||
searchAnswer,
|
||||
getHealth,
|
||||
getStats,
|
||||
getRecords,
|
||||
loadDashboard,
|
||||
clearCache,
|
||||
|
||||
+7
-2
@@ -11,6 +11,8 @@ interface AuthUser {
|
||||
email: string;
|
||||
/** 油猴脚本使用此 token 在 body 中传递身份 */
|
||||
apiToken?: string | null;
|
||||
/** 用户角色:user / admin / superadmin */
|
||||
role?: string | null;
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore("auth", () => {
|
||||
@@ -41,7 +43,8 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
name: session.data.user.name,
|
||||
email: session.data.user.email,
|
||||
apiToken: (session.data.user as { apiToken?: string | null })
|
||||
.apiToken
|
||||
.apiToken,
|
||||
role: (session.data.user as { role?: string | null }).role
|
||||
}
|
||||
: null;
|
||||
} catch {
|
||||
@@ -62,12 +65,14 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
name: string;
|
||||
email: string;
|
||||
apiToken?: string | null;
|
||||
role?: string | null;
|
||||
}) => {
|
||||
user.value = {
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
apiToken: u.apiToken ?? null
|
||||
apiToken: u.apiToken ?? null,
|
||||
role: u.role ?? null
|
||||
};
|
||||
initialized.value = true;
|
||||
};
|
||||
|
||||
+11
-5
@@ -1,14 +1,20 @@
|
||||
// app/stores/global.ts - 全局通用状态(主题等),持久化由 @nuxtjs/color-mode 统一管理(localStorage/cookie)
|
||||
// app/stores/global.ts - 全局通用状态(主题等)
|
||||
//
|
||||
// 颜色模式改用 useCookie 持久化,这样 SSR 和客户端都能读到同一份值,
|
||||
// 避免因 localStorage 在 SSR 阶段不可用而产生水合不匹配(hydration mismatch)。
|
||||
export const useGlobalStore = defineStore("theme", () => {
|
||||
// useColorMode 会自动读写持久化存储,class 也由其挂到 <html>
|
||||
const colorMode = useColorMode();
|
||||
// 用 Nuxt 的 useCookie:服务端从 request cookie 读取,客户端和正常 cookie 一致
|
||||
const _pref = useCookie<"light" | "dark">("color-mode", {
|
||||
default: () => "light",
|
||||
sameSite: "lax"
|
||||
});
|
||||
|
||||
/** 当前是否深色模式 */
|
||||
const isDark = computed(() => colorMode.value === "dark");
|
||||
const isDark = computed(() => _pref.value === "dark");
|
||||
|
||||
/** 切换深色 / 浅色 */
|
||||
const toggle = () => {
|
||||
colorMode.value = isDark.value ? "light" : "dark";
|
||||
_pref.value = isDark.value ? "light" : "dark";
|
||||
};
|
||||
|
||||
/** vue-sonner Toaster 的 theme prop 值,跟随当前模式 */
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// app/stores/settings.ts - 用户自定义配置和系统配置状态管理
|
||||
import { defineStore } from "pinia";
|
||||
import { toast } from "vue-sonner";
|
||||
|
||||
import type {
|
||||
ISystemConfigPublic,
|
||||
ISystemConfigUpdateRequest,
|
||||
IUserSettings,
|
||||
IUserSettingsUpdateRequest
|
||||
} from "@/interfaces";
|
||||
import { SettingsService } from "@/services/settings_service";
|
||||
|
||||
const getApiErrorMessage = (error: unknown, fallback: string) => {
|
||||
const data = (error as { data?: { msg?: string } })?.data;
|
||||
return data?.msg || fallback;
|
||||
};
|
||||
|
||||
export const useSettingsStore = defineStore("settings", () => {
|
||||
/** 用户自定义配置,null 表示尚未加载 */
|
||||
const userSettings = ref<IUserSettings | null>(null);
|
||||
/** 系统配置(仅 superadmin),null 表示尚未加载或无权限 */
|
||||
const systemConfig = ref<ISystemConfigPublic | null>(null);
|
||||
/** 加载/保存中状态 */
|
||||
const userLoading = ref(false); // 仅用于保存
|
||||
const userFetching = ref(false); // 仅用于 GET 拉取
|
||||
const sysLoading = ref(false);
|
||||
const sysFetching = ref(false);
|
||||
|
||||
/** 拉取当前用户的自定义配置 */
|
||||
const fetchUserSettings = async () => {
|
||||
userFetching.value = true;
|
||||
try {
|
||||
const res = await SettingsService.getUserSettings();
|
||||
if (res.data.code === 0 && res.data.data) {
|
||||
userSettings.value = res.data.data;
|
||||
}
|
||||
} catch {
|
||||
// 静默失败,不影响主功能
|
||||
} finally {
|
||||
userFetching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 保存用户自定义配置 */
|
||||
const updateUserSettings = async (
|
||||
req: IUserSettingsUpdateRequest
|
||||
): Promise<boolean> => {
|
||||
userLoading.value = true;
|
||||
try {
|
||||
const res = await SettingsService.updateUserSettings(req);
|
||||
const data = res.data;
|
||||
if (data.code === 0) {
|
||||
await fetchUserSettings();
|
||||
toast.success("配置已保存");
|
||||
return true;
|
||||
}
|
||||
toast.error(data.msg || "保存失败");
|
||||
return false;
|
||||
} catch (error) {
|
||||
toast.error(getApiErrorMessage(error, "保存失败,请重试"));
|
||||
return false;
|
||||
} finally {
|
||||
userLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 拉取系统配置(仅 superadmin 调用此方法) */
|
||||
const fetchSystemConfig = async () => {
|
||||
sysFetching.value = true;
|
||||
try {
|
||||
const res = await SettingsService.getSystemConfig();
|
||||
if (res.data.code === 0 && res.data.data) {
|
||||
systemConfig.value = res.data.data;
|
||||
}
|
||||
} catch {
|
||||
// 无权限或其他错误,静默处理
|
||||
} finally {
|
||||
sysFetching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 保存系统配置(仅 superadmin 调用) */
|
||||
const updateSystemConfig = async (
|
||||
req: ISystemConfigUpdateRequest
|
||||
): Promise<boolean> => {
|
||||
sysLoading.value = true;
|
||||
try {
|
||||
const res = await SettingsService.updateSystemConfig(req);
|
||||
if (res.data.code === 0) {
|
||||
toast.success("系统配置已更新");
|
||||
await fetchSystemConfig();
|
||||
return true;
|
||||
}
|
||||
toast.error(res.data.msg || "更新失败");
|
||||
return false;
|
||||
} catch (error) {
|
||||
toast.error(getApiErrorMessage(error, "更新失败,请重试"));
|
||||
return false;
|
||||
} finally {
|
||||
sysLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
userSettings,
|
||||
systemConfig,
|
||||
userLoading,
|
||||
userFetching,
|
||||
sysLoading,
|
||||
sysFetching,
|
||||
fetchUserSettings,
|
||||
updateUserSettings,
|
||||
fetchSystemConfig,
|
||||
updateSystemConfig
|
||||
};
|
||||
});
|
||||
+1
-1
@@ -20,7 +20,7 @@ export default defineNuxtConfig({
|
||||
cors: true,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
|
||||
"Access-Control-Allow-Methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type,Authorization"
|
||||
}
|
||||
}
|
||||
|
||||
+9
-11
@@ -13,22 +13,22 @@ datasource db {
|
||||
// Better Auth 标准用户表
|
||||
// apiToken 由服务端注册 hook 生成,供 OCS 油猴脚本跨域身份验证
|
||||
model User {
|
||||
id String @id
|
||||
id String @id
|
||||
name String
|
||||
email String @unique
|
||||
email String @unique
|
||||
emailVerified Boolean
|
||||
image String?
|
||||
createdAt DateTime
|
||||
updatedAt DateTime
|
||||
apiToken String? @unique
|
||||
apiToken String? @unique
|
||||
/// 用户最近一次清缓存的时间;只有在此时间之后写入的记录才算缓存命中
|
||||
cacheClearedAt DateTime?
|
||||
/// 用户角色:user(普通用户)、admin(管理员)、superadmin(超级管理员)
|
||||
role String @default("user")
|
||||
role String @default("user")
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
qaRecords QaRecord[]
|
||||
userConfigs UserConfig[]
|
||||
userConfig UserConfig?
|
||||
|
||||
@@map("user")
|
||||
}
|
||||
@@ -92,17 +92,15 @@ model SystemConfig {
|
||||
@@map("system_config")
|
||||
}
|
||||
|
||||
// 用户自定义配置(与系统配置独立),后续支持用户覆盖 API Key 等
|
||||
// 用户自定义配置(与系统配置独立),每个用户一行,value 存完整配置对象
|
||||
model UserConfig {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
userId String @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
/// 配置键,与 system_config 的 key 语义相同
|
||||
key String
|
||||
value String @db.Text
|
||||
/// 完整用户配置对象,包含 useCustomConfig/customApiBase/customApiKey 等字段
|
||||
value Json
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([userId, key])
|
||||
@@map("user_config")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// server/api/admin/system-config.get.ts - 读取系统配置(仅 superadmin 可访问)
|
||||
//
|
||||
// 流程:鉴权 → 二次校验 superadmin → 读 system_config 表 → 遮蔽 key → 返回
|
||||
// 安全边界:
|
||||
// - handler 内独立校验 role === 'superadmin',不依赖 middleware
|
||||
// - API Key 只返回遮蔽预览(前4...后4),不返回明文
|
||||
// - 此接口不返回任何用户数据
|
||||
|
||||
import { setResponseStatus } from "h3";
|
||||
import { getSysConfig, maskApiKey } from "~~/server/utils/sysConfig";
|
||||
import { apiErr, apiOk } from "~~/server/utils/response";
|
||||
import type { ISystemConfigResponse } from "~~/shared/types/settings";
|
||||
|
||||
export default defineEventHandler(async (event): Promise<ISystemConfigResponse> => {
|
||||
// 二次校验:必须是 superadmin 才能访问系统配置
|
||||
const role = (event.context.auth?.user as { role?: string } | undefined)
|
||||
?.role;
|
||||
if (role !== "superadmin") {
|
||||
setResponseStatus(event, 403);
|
||||
return apiErr(403, "权限不足") as ISystemConfigResponse;
|
||||
}
|
||||
|
||||
const cfg = await getSysConfig();
|
||||
|
||||
return apiOk({
|
||||
openAiApiBase: cfg.openAiApiBase,
|
||||
openAiApiKeyPreview: maskApiKey(cfg.openAiApiKey),
|
||||
openAiModel: cfg.openAiModel,
|
||||
maxTokens: cfg.maxTokens,
|
||||
temperature: cfg.temperature,
|
||||
maxSseLineBytes: cfg.maxSseLineBytes
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// server/api/admin/system-config.put.ts - 更新系统配置(仅 superadmin 可访问)
|
||||
//
|
||||
// 流程:鉴权 → 二次校验 superadmin → 白名单校验 → upsert system_config → 失效缓存 → 返回
|
||||
// 安全边界:
|
||||
// - handler 内独立校验 role === 'superadmin'
|
||||
// - openAiApiKey 为空字符串时跳过,不清空已有 key
|
||||
// - 更新成功后立即失效系统配置缓存,下次请求即生效
|
||||
|
||||
import { readBody, setResponseStatus } from "h3";
|
||||
import { prisma } from "~~/server/utils/db";
|
||||
import { createApiLogger, toSafeLogError } from "~~/server/utils/logging";
|
||||
import { apiErr, apiOk } from "~~/server/utils/response";
|
||||
import {
|
||||
invalidateSysConfigCache
|
||||
} from "~~/server/utils/sysConfig";
|
||||
import type { ISystemConfigUpdateRequest } from "~~/shared/types/settings";
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
|
||||
/** system_config 表 key 的常量映射 */
|
||||
const SYS_KEY = {
|
||||
openAiApiKey: "openai_api_key",
|
||||
openAiApiBase: "openai_api_base",
|
||||
openAiModel: "openai_model",
|
||||
maxTokens: "max_tokens",
|
||||
temperature: "temperature",
|
||||
maxSseLineBytes: "openai_stream_max_sse_line_bytes"
|
||||
} as const;
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const logger = createApiLogger(event, "admin.system-config.put");
|
||||
|
||||
const role = (event.context.auth?.user as { role?: string } | undefined)
|
||||
?.role;
|
||||
if (role !== "superadmin") {
|
||||
setResponseStatus(event, 403);
|
||||
return apiErr(403, "权限不足");
|
||||
}
|
||||
|
||||
const body = await readBody<unknown>(event).catch(() => null);
|
||||
if (!isRecord(body)) {
|
||||
setResponseStatus(event, 400);
|
||||
return apiErr(400, "请求体必须是 JSON 对象");
|
||||
}
|
||||
|
||||
const req: ISystemConfigUpdateRequest = {};
|
||||
if (typeof body.openAiApiBase === "string") {
|
||||
req.openAiApiBase = body.openAiApiBase.trim();
|
||||
}
|
||||
if (typeof body.openAiApiKey === "string") {
|
||||
req.openAiApiKey = body.openAiApiKey; // 空字符串 = 跳过,见下方逻辑
|
||||
}
|
||||
if (typeof body.openAiModel === "string") {
|
||||
req.openAiModel = body.openAiModel.trim();
|
||||
}
|
||||
if (typeof body.maxTokens === "number") {
|
||||
req.maxTokens = body.maxTokens;
|
||||
}
|
||||
if (typeof body.temperature === "number") {
|
||||
req.temperature = body.temperature;
|
||||
}
|
||||
if (typeof body.maxSseLineBytes === "number") {
|
||||
req.maxSseLineBytes = body.maxSseLineBytes;
|
||||
}
|
||||
|
||||
// 构建需要写入的 KV 对(key 为空时跳过)
|
||||
type SysKv = { key: string; value: string };
|
||||
const upserts: SysKv[] = [];
|
||||
|
||||
if (req.openAiApiBase) {
|
||||
upserts.push({ key: SYS_KEY.openAiApiBase, value: req.openAiApiBase });
|
||||
}
|
||||
// key 为空字符串时跳过,不清空已有 key
|
||||
if (req.openAiApiKey) {
|
||||
upserts.push({ key: SYS_KEY.openAiApiKey, value: req.openAiApiKey });
|
||||
}
|
||||
if (req.openAiModel) {
|
||||
upserts.push({ key: SYS_KEY.openAiModel, value: req.openAiModel });
|
||||
}
|
||||
if (req.maxTokens !== undefined) {
|
||||
upserts.push({ key: SYS_KEY.maxTokens, value: String(req.maxTokens) });
|
||||
}
|
||||
if (req.temperature !== undefined) {
|
||||
upserts.push({ key: SYS_KEY.temperature, value: String(req.temperature) });
|
||||
}
|
||||
if (req.maxSseLineBytes !== undefined) {
|
||||
upserts.push({
|
||||
key: SYS_KEY.maxSseLineBytes,
|
||||
value: String(req.maxSseLineBytes)
|
||||
});
|
||||
}
|
||||
|
||||
if (upserts.length === 0) {
|
||||
return apiOk(null);
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.$transaction(
|
||||
upserts.map((item) =>
|
||||
prisma.systemConfig.upsert({
|
||||
where: { key: item.key },
|
||||
create: { key: item.key, value: item.value },
|
||||
update: { value: item.value }
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// 立即失效缓存,确保下次请求读到最新配置
|
||||
invalidateSysConfigCache();
|
||||
|
||||
logger.info("system_config_updated", {
|
||||
updatedKeys: upserts.map((u) => u.key)
|
||||
});
|
||||
|
||||
return apiOk(null);
|
||||
} catch (error) {
|
||||
logger.error("system_config_update_failed", { error: toSafeLogError(error) });
|
||||
setResponseStatus(event, 500);
|
||||
return apiErr(500, "服务器内部错误");
|
||||
}
|
||||
});
|
||||
@@ -1,20 +1,4 @@
|
||||
// server/api/health.get.ts - 服务健康检查接口
|
||||
import { apiOk } from "~~/server/utils/response";
|
||||
import { SERVICE_VERSION } from "~~/server/utils/runtimeState";
|
||||
import { getSysConfig } from "~~/server/utils/sysConfig";
|
||||
|
||||
/**
|
||||
* 健康检查不需要鉴权
|
||||
*
|
||||
* 这个接口用于部署平台、Docker healthcheck 或人工确认服务是否启动;
|
||||
* 返回模型名,但不返回 API Key、baseURL 或其他敏感配置
|
||||
*/
|
||||
export default defineEventHandler(async () => {
|
||||
const cfg = await getSysConfig();
|
||||
return apiOk({
|
||||
status: "ok" as const,
|
||||
message: "AI题库服务运行正常",
|
||||
version: SERVICE_VERSION,
|
||||
model: cfg.openAiModel
|
||||
});
|
||||
// server/api/health.get.ts - 服务健康检查接口,无需鉴权
|
||||
export default defineEventHandler(() => {
|
||||
return { status: "ok" };
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import { prisma } from "~~/server/utils/db";
|
||||
import { createApiLogger, toSafeLogError } from "~~/server/utils/logging";
|
||||
import { askAnswerStream } from "~~/server/utils/openai";
|
||||
import { addQaRecord, lookupCachedAnswer } from "~~/server/utils/runtimeState";
|
||||
import { getUserEffectiveApiConfig } from "~~/server/utils/sysConfig";
|
||||
|
||||
/**
|
||||
* 把 query/body/form 中的值统一转成字符串
|
||||
@@ -166,6 +167,10 @@ export default defineEventHandler(async (event) => {
|
||||
return createOcsErrorResponse("请先登录或提供有效 token");
|
||||
}
|
||||
|
||||
// 读取用户生效配置:如果用户开启了自定义 API 且完整填写,则使用自定义 base+key
|
||||
// 否则完全回退到系统配置,保证系统 key 不流向用户自定义的 endpoint
|
||||
const effectiveCfg = await getUserEffectiveApiConfig(userId);
|
||||
|
||||
logger.info("read_question", {
|
||||
questionLength: params.title.length,
|
||||
type: params.type,
|
||||
@@ -196,7 +201,8 @@ export default defineEventHandler(async (event) => {
|
||||
);
|
||||
const streamResult = await askAnswerStream({
|
||||
prompt,
|
||||
systemPrompt: ANSWER_SYSTEM_PROMPT
|
||||
systemPrompt: ANSWER_SYSTEM_PROMPT,
|
||||
cfg: effectiveCfg
|
||||
});
|
||||
const processedAnswer = extractAnswer(streamResult.answer, params.type);
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
// server/api/stats.get.ts - 服务运行统计接口
|
||||
import { setResponseStatus } from "h3";
|
||||
|
||||
import { prisma } from "~~/server/utils/db";
|
||||
import { createApiLogger } from "~~/server/utils/logging";
|
||||
import { apiErr, apiOk } from "~~/server/utils/response";
|
||||
import { getRuntimeStats } from "~~/server/utils/runtimeState";
|
||||
|
||||
/**
|
||||
* 运行统计接口
|
||||
*
|
||||
* 返回进程 uptime、模型名以及当前用户的问答记录总数
|
||||
* 必须携带有效 session cookie(由 api-auth middleware 统一鉴权)
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const logger = createApiLogger(event, "api.stats");
|
||||
const userId = event.context.auth!.user.id;
|
||||
|
||||
try {
|
||||
const [runtimeStats, qa_records_count] = await Promise.all([
|
||||
getRuntimeStats(),
|
||||
prisma.qaRecord.count({ where: { userId } })
|
||||
]);
|
||||
|
||||
logger.info("finish_success");
|
||||
return apiOk({ ...runtimeStats, qa_records_count });
|
||||
} catch (err) {
|
||||
logger.error("db_error", { userId, err: String(err) });
|
||||
setResponseStatus(event, 500);
|
||||
return apiErr(500, "服务器内部错误");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// server/api/user/settings.get.ts - 读取当前用户的自定义 API 配置
|
||||
//
|
||||
// 流程:鉴权 → 读 user_config 单行 JSON 配置 → 遮蔽 key → 返回
|
||||
// 安全边界:key 只返回遮蔽预览(前4...后4),不返回明文;不涉及系统配置
|
||||
|
||||
import { prisma } from "~~/server/utils/db";
|
||||
import { apiOk } from "~~/server/utils/response";
|
||||
import { maskApiKey } from "~~/server/utils/sysConfig";
|
||||
import { normalizeUserConfigValue } from "~~/server/utils/userConfig";
|
||||
import type { IUserSettingsResponse } from "~~/shared/types/settings";
|
||||
|
||||
export default defineEventHandler(async (event): Promise<IUserSettingsResponse> => {
|
||||
// middleware 已确保此处 auth 不为空
|
||||
const userId = event.context.auth!.user.id;
|
||||
|
||||
const row = await prisma.userConfig.findUnique({
|
||||
where: { userId },
|
||||
select: { value: true }
|
||||
});
|
||||
const userConfig = normalizeUserConfigValue(row?.value);
|
||||
|
||||
return apiOk({
|
||||
useCustomConfig: userConfig.useCustomConfig,
|
||||
customApiBase: userConfig.customApiBase,
|
||||
customApiKeyPreview: maskApiKey(userConfig.customApiKey ?? ""),
|
||||
customModel: userConfig.customModel,
|
||||
customMaxTokens: userConfig.customMaxTokens,
|
||||
customTemperature: userConfig.customTemperature
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
// server/api/user/settings.post.ts - 兼容 POST 保存个人配置,复用 PUT 实现
|
||||
export { default } from "./settings.put";
|
||||
@@ -0,0 +1,138 @@
|
||||
// server/api/user/settings.put.ts - 更新当前用户的自定义 API 配置
|
||||
//
|
||||
// 流程:鉴权 → 白名单校验 → 合并并写入 user_config 单行 JSON value → 返回
|
||||
// 安全边界:
|
||||
// - customApiKey 为空字符串时跳过,不清空已有 key
|
||||
// - 只接受声明字段,拒绝未知字段
|
||||
// - 不读取或返回系统配置任何内容
|
||||
// 注意:保存时使用单条 SQL upsert,避免多次写入造成连接占用
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { readBody, setResponseStatus } from "h3";
|
||||
|
||||
import { Prisma } from "~~/prisma/generated/client";
|
||||
import { prisma } from "~~/server/utils/db";
|
||||
import { createApiLogger, toSafeLogError } from "~~/server/utils/logging";
|
||||
import { apiErr, apiOk } from "~~/server/utils/response";
|
||||
import {
|
||||
hasUserConfigMutation,
|
||||
mergeUserConfigUpdate,
|
||||
normalizeUserConfigValue,
|
||||
toUserConfigJson
|
||||
} from "~~/server/utils/userConfig";
|
||||
import type { IUserSettingsUpdateRequest } from "~~/shared/types/settings";
|
||||
|
||||
/** 是否是纯 JSON 对象 */
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
|
||||
const ALLOWED_KEYS = new Set([
|
||||
"useCustomConfig",
|
||||
"customApiBase",
|
||||
"customApiKey",
|
||||
"customModel",
|
||||
"customMaxTokens",
|
||||
"customTemperature"
|
||||
]);
|
||||
|
||||
/** 写入用户配置单行 JSON value */
|
||||
const writeUserConfigValue = async (
|
||||
userId: string,
|
||||
value: Prisma.InputJsonObject
|
||||
) => {
|
||||
const updatedAt = new Date();
|
||||
const jsonValue = JSON.stringify(value);
|
||||
|
||||
await prisma.$executeRaw(Prisma.sql`
|
||||
INSERT INTO user_config (id, userId, value, updatedAt)
|
||||
VALUES (${randomUUID()}, ${userId}, ${jsonValue}, ${updatedAt})
|
||||
ON DUPLICATE KEY UPDATE value = VALUES(value), updatedAt = VALUES(updatedAt)
|
||||
`);
|
||||
};
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const logger = createApiLogger(event, "user.settings.put");
|
||||
const userId = event.context.auth!.user.id;
|
||||
|
||||
const body = await readBody<unknown>(event).catch(() => null);
|
||||
if (!isRecord(body)) {
|
||||
setResponseStatus(event, 400);
|
||||
return apiErr(400, "请求体必须是 JSON 对象");
|
||||
}
|
||||
const unknownKeys = Object.keys(body).filter((key) => !ALLOWED_KEYS.has(key));
|
||||
if (unknownKeys.length > 0) {
|
||||
setResponseStatus(event, 400);
|
||||
return apiErr(400, "请求体包含不支持的字段");
|
||||
}
|
||||
|
||||
// 白名单字段提取
|
||||
const req: IUserSettingsUpdateRequest = {};
|
||||
if (typeof body.useCustomConfig === "boolean") {
|
||||
req.useCustomConfig = body.useCustomConfig;
|
||||
}
|
||||
if (typeof body.customApiBase === "string") {
|
||||
req.customApiBase = body.customApiBase.trim();
|
||||
}
|
||||
if (typeof body.customApiKey === "string") {
|
||||
req.customApiKey = body.customApiKey; // 空字符串 = 不更新,见下方逻辑
|
||||
}
|
||||
if (typeof body.customModel === "string") {
|
||||
req.customModel = body.customModel.trim();
|
||||
}
|
||||
if (
|
||||
typeof body.customMaxTokens === "number" &&
|
||||
Number.isFinite(body.customMaxTokens)
|
||||
) {
|
||||
req.customMaxTokens = body.customMaxTokens;
|
||||
} else if (body.customMaxTokens === null) {
|
||||
req.customMaxTokens = null;
|
||||
}
|
||||
if (
|
||||
typeof body.customTemperature === "number" &&
|
||||
Number.isFinite(body.customTemperature)
|
||||
) {
|
||||
req.customTemperature = body.customTemperature;
|
||||
} else if (body.customTemperature === null) {
|
||||
req.customTemperature = null;
|
||||
}
|
||||
|
||||
// 没有任何变更时直接返回
|
||||
if (!hasUserConfigMutation(req)) {
|
||||
return apiOk(null);
|
||||
}
|
||||
|
||||
try {
|
||||
const currentRow = await prisma.userConfig.findUnique({
|
||||
where: { userId },
|
||||
select: { value: true }
|
||||
});
|
||||
const current = normalizeUserConfigValue(currentRow?.value);
|
||||
const next = mergeUserConfigUpdate(current, req);
|
||||
const jsonValue = toUserConfigJson(next);
|
||||
|
||||
logger.info("user_settings_write_start", {
|
||||
hasExistingRow: Boolean(currentRow),
|
||||
useCustomConfig: next.useCustomConfig
|
||||
});
|
||||
|
||||
await writeUserConfigValue(userId, jsonValue);
|
||||
|
||||
logger.info("user_settings_updated", {
|
||||
useCustomConfig: next.useCustomConfig,
|
||||
hasCustomApiBase: Boolean(next.customApiBase),
|
||||
hasCustomApiKey: Boolean(next.customApiKey),
|
||||
hasCustomModel: Boolean(next.customModel),
|
||||
hasCustomMaxTokens: next.customMaxTokens !== null,
|
||||
hasCustomTemperature: next.customTemperature !== null
|
||||
});
|
||||
|
||||
return apiOk(null);
|
||||
} catch (error) {
|
||||
logger.error("user_settings_update_failed", {
|
||||
error: toSafeLogError(error)
|
||||
});
|
||||
setResponseStatus(event, 500);
|
||||
return apiErr(500, "服务器内部错误");
|
||||
}
|
||||
});
|
||||
@@ -20,8 +20,11 @@ export default defineEventHandler(async (event) => {
|
||||
// 只处理 /api/ 路径
|
||||
if (!pathname.startsWith("/api/")) return;
|
||||
|
||||
// OPTIONS 预检请求不需要鉴权,CORS headers 由 nuxt.config routeRules 统一设置
|
||||
if (method === "OPTIONS") return;
|
||||
// OPTIONS 预检请求不需要鉴权;直接返回 204,避免没有匹配 API 路由时落到 404。
|
||||
if (method === "OPTIONS") {
|
||||
setResponseStatus(event, 204);
|
||||
return "";
|
||||
}
|
||||
|
||||
// 公开路由直接放行
|
||||
if (isPublicApiRoute(pathname, method)) return;
|
||||
|
||||
@@ -17,7 +17,7 @@ const DEFAULT_CONFIGS = [
|
||||
},
|
||||
{
|
||||
key: "openai_model",
|
||||
value: "gpt-3.5-turbo",
|
||||
value: "gpt-5.2",
|
||||
description: "Chat Completions 使用的模型名"
|
||||
},
|
||||
{
|
||||
|
||||
+24
-3
@@ -4,7 +4,6 @@ import { randomBytes } from "node:crypto";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||
import type { H3Event } from "h3";
|
||||
import { toWebRequest } from "h3";
|
||||
|
||||
import { prisma } from "~~/server/utils/db";
|
||||
|
||||
@@ -36,6 +35,12 @@ export const auth = betterAuth({
|
||||
required: false,
|
||||
// 客户端(signup/updateUser)不能设置此字段,只由 databaseHooks 写入
|
||||
input: false
|
||||
},
|
||||
role: {
|
||||
type: "string",
|
||||
required: false,
|
||||
// 角色只由服务端管理员设置,不允许客户端修改
|
||||
input: false
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -60,8 +65,24 @@ export const auth = betterAuth({
|
||||
/**
|
||||
* 从 H3 事件中读取 Better Auth session
|
||||
*
|
||||
* 封装 toWebRequest 转换,避免在每个 handler 中重复导入和调用
|
||||
* 只复制 headers,不调用 toWebRequest(event)。
|
||||
* 对 POST/PUT 请求,toWebRequest 会包装 body stream;全局鉴权中调用后,
|
||||
* 后续 handler 再 readBody(event) 可能长时间挂起。
|
||||
* 未登录或 session 无效时返回 null
|
||||
*/
|
||||
const getHeaderSnapshot = (event: H3Event) => {
|
||||
const headers = new Headers();
|
||||
|
||||
for (const [key, value] of Object.entries(event.node.req.headers)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) headers.append(key, item);
|
||||
} else if (value !== undefined) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const getAuthSession = (event: H3Event) =>
|
||||
auth.api.getSession({ headers: toWebRequest(event).headers });
|
||||
auth.api.getSession({ headers: getHeaderSnapshot(event) });
|
||||
|
||||
+19
-5
@@ -26,18 +26,21 @@ const createMariaDbConfig = () => {
|
||||
database: decodeURIComponent(url.pathname.slice(1)),
|
||||
connectionLimit: 5,
|
||||
connectTimeout: 15_000,
|
||||
acquireTimeout: 20_000
|
||||
// 正常情况下连接应立即可用;5s 内拿不到连接说明池已耗尽,快速报错优于无限挂起
|
||||
acquireTimeout: 5_000
|
||||
};
|
||||
};
|
||||
|
||||
type MariaDbConfig = ReturnType<typeof createMariaDbConfig>;
|
||||
|
||||
/**
|
||||
* 创建 Prisma Client
|
||||
*
|
||||
* 连接池超时设置比默认值更宽松,是因为当前数据库是远程 MySQL,
|
||||
* 默认超时时间过短时 Node driver 可能还没建好 socket 就失败
|
||||
*/
|
||||
const prismaClientSingleton = () => {
|
||||
const adapter = new PrismaMariaDb(createMariaDbConfig());
|
||||
const prismaClientSingleton = (config: MariaDbConfig) => {
|
||||
const adapter = new PrismaMariaDb(config);
|
||||
return new PrismaClient({ adapter });
|
||||
};
|
||||
|
||||
@@ -50,10 +53,21 @@ type PrismaClientSingleton = ReturnType<typeof prismaClientSingleton>;
|
||||
*/
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClientSingleton | undefined;
|
||||
prismaConfigSignature: string | undefined;
|
||||
};
|
||||
|
||||
const mariaDbConfig = createMariaDbConfig();
|
||||
const prismaConfigSignature = JSON.stringify(mariaDbConfig);
|
||||
|
||||
/** 全项目唯一 Prisma Client 实例 */
|
||||
export const prisma = globalForPrisma.prisma ?? prismaClientSingleton();
|
||||
export const prisma =
|
||||
globalForPrisma.prisma &&
|
||||
globalForPrisma.prismaConfigSignature === prismaConfigSignature
|
||||
? globalForPrisma.prisma
|
||||
: prismaClientSingleton(mariaDbConfig);
|
||||
|
||||
// 生产环境由进程生命周期管理;开发环境缓存到全局,减少 HMR 连接泄漏
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForPrisma.prisma = prisma;
|
||||
globalForPrisma.prismaConfigSignature = prismaConfigSignature;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// server/utils/openai.ts - OpenAI Chat Completions 流式调用工具
|
||||
import { getSysConfig } from "~~/server/utils/sysConfig";
|
||||
import type { SysConfig } from "~~/server/utils/sysConfig";
|
||||
|
||||
/** 复用编码器计算 SSE data 字节长度,避免循环里频繁创建对象 */
|
||||
const textEncoder = new TextEncoder();
|
||||
@@ -46,16 +46,20 @@ export interface AskAnswerStreamResult {
|
||||
*
|
||||
* 对 OCS 客户端仍返回普通 JSON;流式只发生在服务端到 OpenAI 之间
|
||||
* 这样既能更早读取上游内容,又不破坏 AnswererWrapper 的 handler 契约
|
||||
*
|
||||
* cfg 由调用方传入(通过 getUserEffectiveApiConfig 或 getSysConfig 获取),
|
||||
* 不在此处直接读取系统配置,确保用户自定义配置可正确生效
|
||||
*/
|
||||
export const askAnswerStream = async ({
|
||||
prompt,
|
||||
systemPrompt
|
||||
systemPrompt,
|
||||
cfg
|
||||
}: {
|
||||
prompt: string;
|
||||
systemPrompt: string;
|
||||
/** 生效配置,由调用方传入;含 key 等敏感信息,不做日志 */
|
||||
cfg: SysConfig;
|
||||
}): Promise<AskAnswerStreamResult> => {
|
||||
const cfg = await getSysConfig();
|
||||
|
||||
if (!cfg.openAiApiKey) {
|
||||
throw new Error("OPENAI_API_KEY is not set.");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// server/utils/sysConfig.ts - 从数据库读取系统配置,带 60 秒 TTL 内存缓存
|
||||
// server/utils/sysConfig.ts - 从数据库读取系统配置,带 60 秒 TTL 内存缓存;支持按用户自定义配置覆盖
|
||||
// 替代原 env.ts 中的 OpenAI 配置项,配置以 DB 为准,管理员可通过后台修改
|
||||
import { prisma } from "~~/server/utils/db";
|
||||
import { normalizeUserConfigValue } from "~~/server/utils/userConfig";
|
||||
|
||||
/** 系统配置字段,对应 system_config 表中的各 key */
|
||||
export interface SysConfig {
|
||||
@@ -32,7 +33,7 @@ const CONFIG_KEYS = {
|
||||
const DEFAULTS: SysConfig = {
|
||||
openAiApiKey: "",
|
||||
openAiApiBase: "https://api.openai.com/v1",
|
||||
openAiModel: "gpt-3.5-turbo",
|
||||
openAiModel: "gpt-5.2",
|
||||
maxTokens: 500,
|
||||
temperature: 0.7,
|
||||
maxSseLineBytes: 1_048_576
|
||||
@@ -94,3 +95,70 @@ export const invalidateSysConfigCache = () => {
|
||||
_cachedConfig = null;
|
||||
_cacheExpiresAt = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前用户生效的 API 配置
|
||||
*
|
||||
* 安全规则(保证系统 key 不流向用户控制的 endpoint):
|
||||
* - useCustomConfig=true 且 customApiBase + customApiKey 都非空
|
||||
* → base 和 key 用用户自定义值;model/max_tokens/temperature 有值则各自覆盖系统值
|
||||
* - 上述条件任一不满足
|
||||
* → base 和 key 完全使用系统配置(系统 key 不进用户 base)
|
||||
* - useCustomConfig=false 或未设置
|
||||
* → 完全使用系统配置,用户覆盖项全部忽略
|
||||
*/
|
||||
export const getUserEffectiveApiConfig = async (
|
||||
userId: string
|
||||
): Promise<SysConfig> => {
|
||||
const sysCfg = await getSysConfig();
|
||||
|
||||
const row = await prisma.userConfig.findUnique({
|
||||
where: { userId },
|
||||
select: { value: true }
|
||||
});
|
||||
const userConfig = normalizeUserConfigValue(row?.value);
|
||||
const useCustom = userConfig.useCustomConfig;
|
||||
|
||||
if (!useCustom) {
|
||||
return sysCfg;
|
||||
}
|
||||
|
||||
const customBase = userConfig.customApiBase ?? "";
|
||||
const customKey = userConfig.customApiKey?.trim() ?? "";
|
||||
|
||||
// base 和 key 必须同时有值,才允许覆盖;否则继续使用系统的 base+key
|
||||
// 这样可以保证系统 key 绝不发往用户控制的 endpoint
|
||||
const useCustomEndpoint = customBase.length > 0 && customKey.length > 0;
|
||||
|
||||
const effectiveBase = useCustomEndpoint
|
||||
? normalizeBaseUrl(customBase)
|
||||
: sysCfg.openAiApiBase;
|
||||
const effectiveKey = useCustomEndpoint ? customKey : sysCfg.openAiApiKey;
|
||||
|
||||
// model / max_tokens / temperature 在自定义配置开启时可以各自独立覆盖
|
||||
const customModel = userConfig.customModel ?? "";
|
||||
|
||||
return {
|
||||
openAiApiBase: effectiveBase,
|
||||
openAiApiKey: effectiveKey,
|
||||
openAiModel: customModel.length > 0 ? customModel : sysCfg.openAiModel,
|
||||
maxTokens: userConfig.customMaxTokens ?? sysCfg.maxTokens,
|
||||
temperature: userConfig.customTemperature ?? sysCfg.temperature,
|
||||
maxSseLineBytes: sysCfg.maxSseLineBytes
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 遮蔽 API Key,只留前 4 后 4 位,用于前端展示
|
||||
*
|
||||
* 长度 > 8 → 前4 + "..." + 后4(如 `sk-a...xxxx`)
|
||||
* 长度 1~8 → 全部替换为 `****`
|
||||
* 空 → null
|
||||
*/
|
||||
export const maskApiKey = (key: string): string | null => {
|
||||
if (!key) return null;
|
||||
if (key.length > 8) {
|
||||
return `${key.slice(0, 4)}...${key.slice(-4)}`;
|
||||
}
|
||||
return "****";
|
||||
};
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// server/utils/userConfig.ts - 用户自定义配置 JSON value 的归一化与合并
|
||||
import type { Prisma } from "~~/prisma/generated/client";
|
||||
import type { IUserSettingsUpdateRequest } from "~~/shared/types/settings";
|
||||
|
||||
export interface UserConfigValue {
|
||||
useCustomConfig: boolean;
|
||||
customApiBase: string | null;
|
||||
customApiKey: string | null;
|
||||
customModel: string | null;
|
||||
customMaxTokens: number | null;
|
||||
customTemperature: number | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_USER_CONFIG_VALUE: UserConfigValue = {
|
||||
useCustomConfig: false,
|
||||
customApiBase: null,
|
||||
customApiKey: null,
|
||||
customModel: null,
|
||||
customMaxTokens: null,
|
||||
customTemperature: null
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const cleanString = (value: unknown): string | null => {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
};
|
||||
|
||||
const cleanApiKey = (value: unknown): string | null => {
|
||||
if (typeof value !== "string") return null;
|
||||
return value.length > 0 ? value : null;
|
||||
};
|
||||
|
||||
const cleanNumber = (value: unknown): number | null =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
|
||||
export const normalizeUserConfigValue = (
|
||||
value: unknown
|
||||
): UserConfigValue => {
|
||||
if (!isRecord(value)) {
|
||||
return { ...DEFAULT_USER_CONFIG_VALUE };
|
||||
}
|
||||
|
||||
return {
|
||||
useCustomConfig: value.useCustomConfig === true,
|
||||
customApiBase: cleanString(value.customApiBase),
|
||||
customApiKey: cleanApiKey(value.customApiKey),
|
||||
customModel: cleanString(value.customModel),
|
||||
customMaxTokens: cleanNumber(value.customMaxTokens),
|
||||
customTemperature: cleanNumber(value.customTemperature)
|
||||
};
|
||||
};
|
||||
|
||||
export const mergeUserConfigUpdate = (
|
||||
current: UserConfigValue,
|
||||
req: IUserSettingsUpdateRequest
|
||||
): UserConfigValue => {
|
||||
const next: UserConfigValue = { ...current };
|
||||
|
||||
if (req.useCustomConfig !== undefined) {
|
||||
next.useCustomConfig = req.useCustomConfig;
|
||||
}
|
||||
if (req.customApiBase !== undefined) {
|
||||
next.customApiBase = cleanString(req.customApiBase);
|
||||
}
|
||||
// 空字符串表示不修改已保存的 key,避免用户保存表单时误清空密钥
|
||||
if (req.customApiKey !== undefined && req.customApiKey !== "") {
|
||||
next.customApiKey = cleanApiKey(req.customApiKey);
|
||||
}
|
||||
if (req.customModel !== undefined) {
|
||||
next.customModel = cleanString(req.customModel);
|
||||
}
|
||||
if (req.customMaxTokens !== undefined) {
|
||||
next.customMaxTokens = cleanNumber(req.customMaxTokens);
|
||||
}
|
||||
if (req.customTemperature !== undefined) {
|
||||
next.customTemperature = cleanNumber(req.customTemperature);
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
export const hasUserConfigMutation = (req: IUserSettingsUpdateRequest) =>
|
||||
req.useCustomConfig !== undefined ||
|
||||
req.customApiBase !== undefined ||
|
||||
(req.customApiKey !== undefined && req.customApiKey !== "") ||
|
||||
req.customModel !== undefined ||
|
||||
req.customMaxTokens !== undefined ||
|
||||
req.customTemperature !== undefined;
|
||||
|
||||
export const toUserConfigJson = (
|
||||
value: UserConfigValue
|
||||
): Prisma.InputJsonObject => ({
|
||||
useCustomConfig: value.useCustomConfig,
|
||||
customApiBase: value.customApiBase,
|
||||
customApiKey: value.customApiKey,
|
||||
customModel: value.customModel,
|
||||
customMaxTokens: value.customMaxTokens,
|
||||
customTemperature: value.customTemperature
|
||||
});
|
||||
@@ -57,30 +57,6 @@ export interface IApiResponse<T = null> {
|
||||
msg: string;
|
||||
}
|
||||
|
||||
/** 健康检查响应,只暴露非敏感运行信息 */
|
||||
export interface IHealthResponse {
|
||||
/** 服务状态,正常时为 ok */
|
||||
status: "ok";
|
||||
/** 本地状态文案 */
|
||||
message: string;
|
||||
/** 服务版本 */
|
||||
version: string;
|
||||
/** 当前模型名,不包含 API Key 或 baseURL */
|
||||
model: string;
|
||||
}
|
||||
|
||||
/** 服务统计响应 */
|
||||
export interface IStatsResponse {
|
||||
/** 服务版本 */
|
||||
version: string;
|
||||
/** 进程运行秒数 */
|
||||
uptime: number;
|
||||
/** 当前模型名 */
|
||||
model: string;
|
||||
/** 当前用户的问答记录总数 */
|
||||
qa_records_count: number;
|
||||
}
|
||||
|
||||
/** 最近问答记录,字段来自 Prisma QaRecord */
|
||||
export interface IQaRecord {
|
||||
/** 本地可读时间 */
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// shared/types/index.ts - 前后端通用类型统一出口
|
||||
export * from "./answer";
|
||||
export * from "./settings";
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// shared/types/settings.ts - 设置相关的前后端共享契约类型
|
||||
|
||||
import type { IApiResponse } from "./answer";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 用户自定义配置(个人 API 配置)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** 用户自定义配置读取响应(key 已遮蔽,不返回明文) */
|
||||
export interface IUserSettings {
|
||||
/** 是否启用自定义配置 */
|
||||
useCustomConfig: boolean;
|
||||
/** 自定义 API base URL,例如 https://api.openai.com/v1 */
|
||||
customApiBase: string | null;
|
||||
/**
|
||||
* 自定义 API Key 遮蔽预览(如 `sk-a...xxxx`);
|
||||
* 未设置时为 null;留空提交代表"不修改"
|
||||
*/
|
||||
customApiKeyPreview: string | null;
|
||||
/** 自定义模型名,为 null 时跟随系统配置 */
|
||||
customModel: string | null;
|
||||
/** 自定义最大 token 数,为 null 时跟随系统配置 */
|
||||
customMaxTokens: number | null;
|
||||
/** 自定义温度,为 null 时跟随系统配置 */
|
||||
customTemperature: number | null;
|
||||
}
|
||||
|
||||
/** 更新用户自定义配置的请求体 */
|
||||
export interface IUserSettingsUpdateRequest {
|
||||
/** 是否启用自定义配置 */
|
||||
useCustomConfig?: boolean;
|
||||
/** 自定义 API base URL */
|
||||
customApiBase?: string;
|
||||
/**
|
||||
* 自定义 API Key 明文;
|
||||
* 留空字符串("")或 undefined 时不更新已存储的 key
|
||||
*/
|
||||
customApiKey?: string;
|
||||
/** 自定义模型名;空字符串代表清除覆盖,跟随系统 */
|
||||
customModel?: string;
|
||||
/** 自定义最大 token 数;null 代表清除覆盖 */
|
||||
customMaxTokens?: number | null;
|
||||
/** 自定义温度;null 代表清除覆盖 */
|
||||
customTemperature?: number | null;
|
||||
}
|
||||
|
||||
/** 用户自定义配置接口响应 */
|
||||
export type IUserSettingsResponse = IApiResponse<IUserSettings>;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 系统配置(仅 superadmin 可读写)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** 系统配置读取响应(key 已遮蔽,不返回明文;仅 superadmin 可访问) */
|
||||
export interface ISystemConfigPublic {
|
||||
/** OpenAI 兼容 API base URL */
|
||||
openAiApiBase: string;
|
||||
/**
|
||||
* API Key 遮蔽预览(如 `sk-a...xxxx`);
|
||||
* 未设置时为 null
|
||||
*/
|
||||
openAiApiKeyPreview: string | null;
|
||||
/** 模型名 */
|
||||
openAiModel: string;
|
||||
/** 单次最大 token 数 */
|
||||
maxTokens: number;
|
||||
/** 采样温度 */
|
||||
temperature: number;
|
||||
/** SSE 单行最大字节数 */
|
||||
maxSseLineBytes: number;
|
||||
}
|
||||
|
||||
/** 更新系统配置的请求体(仅 superadmin 可访问) */
|
||||
export interface ISystemConfigUpdateRequest {
|
||||
/** API base URL */
|
||||
openAiApiBase?: string;
|
||||
/**
|
||||
* API Key 明文;
|
||||
* 留空字符串("")或 undefined 时不更新已存储的 key
|
||||
*/
|
||||
openAiApiKey?: string;
|
||||
/** 模型名 */
|
||||
openAiModel?: string;
|
||||
/** 最大 token 数 */
|
||||
maxTokens?: number;
|
||||
/** 采样温度 */
|
||||
temperature?: number;
|
||||
/** SSE 单行最大字节数 */
|
||||
maxSseLineBytes?: number;
|
||||
}
|
||||
|
||||
/** 系统配置接口响应 */
|
||||
export type ISystemConfigResponse = IApiResponse<ISystemConfigPublic>;
|
||||
Reference in New Issue
Block a user