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
+1 -1
View File
@@ -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
```
+8
View File
@@ -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>
+7 -5
View File
@@ -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"
>&nbsp;</span
>
<span v-if="i > 0" class="text-muted-foreground">
&nbsp;
</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 }}
+8 -63
View File
@@ -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>
+41 -25
View File
@@ -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-onlyapiToken / 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>
+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>
+46
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
export { default as Switch } from './Switch.vue'
-2
View File
@@ -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";
+8
View File
@@ -1,2 +1,10 @@
// app/interfaces/index.ts - 前端共享类型统一出口
export * from "./answer";
export type {
ISystemConfigPublic,
ISystemConfigResponse,
ISystemConfigUpdateRequest,
IUserSettings,
IUserSettingsResponse,
IUserSettingsUpdateRequest
} from "~~/shared/types/settings";
+1 -17
View File
@@ -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>(
+13
View File
@@ -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
View File
@@ -1,2 +1,3 @@
// app/services/index.ts - 前端 service 统一出口
export * from "./answer_service";
export * from "./settings_service";
+43
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 值,跟随当前模式 */
+116
View File
@@ -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
View File
@@ -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
View File
@@ -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")
}
+33
View File
@@ -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
});
});
+122
View File
@@ -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, "服务器内部错误");
}
});
+3 -19
View File
@@ -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" };
});
+7 -1
View File
@@ -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);
-32
View File
@@ -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, "服务器内部错误");
}
});
+30
View File
@@ -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
});
});
+2
View File
@@ -0,0 +1,2 @@
// server/api/user/settings.post.ts - 兼容 POST 保存个人配置,复用 PUT 实现
export { default } from "./settings.put";
+138
View File
@@ -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, "服务器内部错误");
}
});
+5 -2
View File
@@ -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;
+1 -1
View File
@@ -17,7 +17,7 @@ const DEFAULT_CONFIGS = [
},
{
key: "openai_model",
value: "gpt-3.5-turbo",
value: "gpt-5.2",
description: "Chat Completions 使用的模型名"
},
{
+24 -3
View File
@@ -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
View File
@@ -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;
}
+8 -4
View File
@@ -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.");
}
+70 -2
View File
@@ -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 "****";
};
+103
View File
@@ -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
});
-24
View File
@@ -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
View File
@@ -1,2 +1,3 @@
// shared/types/index.ts - 前后端通用类型统一出口
export * from "./answer";
export * from "./settings";
+93
View File
@@ -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>;