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
+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
};
});