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
+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'