From d3cb786edbc65ccc6cdf2d87e4b852036f21f8f9 Mon Sep 17 00:00:00 2001
From: Marcus <1922576605@qq.com>
Date: Fri, 22 May 2026 16:27:35 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E6=95=B4=E4=BD=93?=
=?UTF-8?q?=E5=86=85=E5=AE=B9=E5=BC=80=E5=8F=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.vscode/settings.json | 4 +-
app/app.vue | 4 +-
app/components/Table/CellCompare.vue | 2 +-
app/components/Table/NewTablePagination.vue | 13 +-
app/components/Table/TableCom.vue | 39 +--
app/components/dashboard/QaRecordDetail.vue | 63 +++++
app/components/dashboard/QaRecordsTable.vue | 150 ++++++++++
app/components/dashboard/StatsOverview.vue | 120 ++++++++
app/components/docs/ApiDocsContent.vue | 132 +++++++++
app/components/home/AnswerForm.vue | 108 ++++++++
app/components/home/AnswerResult.vue | 80 ++++++
app/components/home/OcsConfigCard.vue | 75 +++++
app/components/layout/AppFooter.vue | 10 +
app/components/layout/AppHeader.vue | 72 +++++
app/components/ui/alert/Alert.vue | 21 ++
app/components/ui/alert/AlertAction.vue | 17 ++
app/components/ui/alert/AlertDescription.vue | 17 ++
app/components/ui/alert/AlertTitle.vue | 17 ++
app/components/ui/alert/index.ts | 21 ++
app/components/ui/badge/Badge.vue | 27 ++
app/components/ui/badge/index.ts | 24 ++
app/components/ui/card/Card.vue | 21 ++
app/components/ui/card/CardAction.vue | 17 ++
app/components/ui/card/CardContent.vue | 17 ++
app/components/ui/card/CardDescription.vue | 17 ++
app/components/ui/card/CardFooter.vue | 17 ++
app/components/ui/card/CardHeader.vue | 17 ++
app/components/ui/card/CardTitle.vue | 17 ++
app/components/ui/card/index.ts | 7 +
app/components/ui/dialog/Dialog.vue | 19 ++
app/components/ui/dialog/DialogClose.vue | 15 +
app/components/ui/dialog/DialogContent.vue | 53 ++++
.../ui/dialog/DialogDescription.vue | 23 ++
app/components/ui/dialog/DialogFooter.vue | 27 ++
app/components/ui/dialog/DialogHeader.vue | 17 ++
app/components/ui/dialog/DialogOverlay.vue | 21 ++
.../ui/dialog/DialogScrollContent.vue | 60 ++++
app/components/ui/dialog/DialogTitle.vue | 23 ++
app/components/ui/dialog/DialogTrigger.vue | 15 +
app/components/ui/dialog/index.ts | 10 +
app/components/ui/input/Input.vue | 31 +++
app/components/ui/input/index.ts | 1 +
app/components/ui/label/Label.vue | 26 ++
app/components/ui/label/index.ts | 1 +
app/components/ui/select/Select.vue | 19 ++
app/components/ui/select/SelectContent.vue | 58 ++++
app/components/ui/select/SelectGroup.vue | 21 ++
app/components/ui/select/SelectItem.vue | 45 +++
app/components/ui/select/SelectItemText.vue | 15 +
app/components/ui/select/SelectLabel.vue | 17 ++
.../ui/select/SelectScrollDownButton.vue | 27 ++
.../ui/select/SelectScrollUpButton.vue | 27 ++
app/components/ui/select/SelectSeparator.vue | 19 ++
app/components/ui/select/SelectTrigger.vue | 34 +++
app/components/ui/select/SelectValue.vue | 15 +
app/components/ui/select/index.ts | 11 +
app/components/ui/skeleton/Skeleton.vue | 17 ++
app/components/ui/skeleton/index.ts | 1 +
app/components/ui/textarea/Textarea.vue | 28 ++
app/components/ui/textarea/index.ts | 1 +
app/interfaces/answer.ts | 99 +++++++
app/interfaces/index.ts | 2 +
app/layouts/default.vue | 15 +
app/pages/dashboard.vue | 33 +++
app/pages/docs.vue | 12 +
app/pages/index.vue | 20 ++
app/services/answer_service.ts | 55 ++++
app/services/base_service.ts | 49 ++++
app/services/index.ts | 2 +
app/stores/answer.ts | 256 ++++++++++++++++++
server/api/records.get.ts | 44 +++
server/utils/runtimeState.ts | 9 +
72 files changed, 2410 insertions(+), 29 deletions(-)
create mode 100644 app/components/dashboard/QaRecordDetail.vue
create mode 100644 app/components/dashboard/QaRecordsTable.vue
create mode 100644 app/components/dashboard/StatsOverview.vue
create mode 100644 app/components/docs/ApiDocsContent.vue
create mode 100644 app/components/home/AnswerForm.vue
create mode 100644 app/components/home/AnswerResult.vue
create mode 100644 app/components/home/OcsConfigCard.vue
create mode 100644 app/components/layout/AppFooter.vue
create mode 100644 app/components/layout/AppHeader.vue
create mode 100644 app/components/ui/alert/Alert.vue
create mode 100644 app/components/ui/alert/AlertAction.vue
create mode 100644 app/components/ui/alert/AlertDescription.vue
create mode 100644 app/components/ui/alert/AlertTitle.vue
create mode 100644 app/components/ui/alert/index.ts
create mode 100644 app/components/ui/badge/Badge.vue
create mode 100644 app/components/ui/badge/index.ts
create mode 100644 app/components/ui/card/Card.vue
create mode 100644 app/components/ui/card/CardAction.vue
create mode 100644 app/components/ui/card/CardContent.vue
create mode 100644 app/components/ui/card/CardDescription.vue
create mode 100644 app/components/ui/card/CardFooter.vue
create mode 100644 app/components/ui/card/CardHeader.vue
create mode 100644 app/components/ui/card/CardTitle.vue
create mode 100644 app/components/ui/card/index.ts
create mode 100644 app/components/ui/dialog/Dialog.vue
create mode 100644 app/components/ui/dialog/DialogClose.vue
create mode 100644 app/components/ui/dialog/DialogContent.vue
create mode 100644 app/components/ui/dialog/DialogDescription.vue
create mode 100644 app/components/ui/dialog/DialogFooter.vue
create mode 100644 app/components/ui/dialog/DialogHeader.vue
create mode 100644 app/components/ui/dialog/DialogOverlay.vue
create mode 100644 app/components/ui/dialog/DialogScrollContent.vue
create mode 100644 app/components/ui/dialog/DialogTitle.vue
create mode 100644 app/components/ui/dialog/DialogTrigger.vue
create mode 100644 app/components/ui/dialog/index.ts
create mode 100644 app/components/ui/input/Input.vue
create mode 100644 app/components/ui/input/index.ts
create mode 100644 app/components/ui/label/Label.vue
create mode 100644 app/components/ui/label/index.ts
create mode 100644 app/components/ui/select/Select.vue
create mode 100644 app/components/ui/select/SelectContent.vue
create mode 100644 app/components/ui/select/SelectGroup.vue
create mode 100644 app/components/ui/select/SelectItem.vue
create mode 100644 app/components/ui/select/SelectItemText.vue
create mode 100644 app/components/ui/select/SelectLabel.vue
create mode 100644 app/components/ui/select/SelectScrollDownButton.vue
create mode 100644 app/components/ui/select/SelectScrollUpButton.vue
create mode 100644 app/components/ui/select/SelectSeparator.vue
create mode 100644 app/components/ui/select/SelectTrigger.vue
create mode 100644 app/components/ui/select/SelectValue.vue
create mode 100644 app/components/ui/select/index.ts
create mode 100644 app/components/ui/skeleton/Skeleton.vue
create mode 100644 app/components/ui/skeleton/index.ts
create mode 100644 app/components/ui/textarea/Textarea.vue
create mode 100644 app/components/ui/textarea/index.ts
create mode 100644 app/interfaces/answer.ts
create mode 100644 app/interfaces/index.ts
create mode 100644 app/layouts/default.vue
create mode 100644 app/pages/dashboard.vue
create mode 100644 app/pages/docs.vue
create mode 100644 app/pages/index.vue
create mode 100644 app/services/answer_service.ts
create mode 100644 app/services/base_service.ts
create mode 100644 app/services/index.ts
create mode 100644 app/stores/answer.ts
create mode 100644 server/api/records.get.ts
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 5a96859..9482083 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,5 +1,7 @@
{
"cSpell.words": [
- "heroicons"
+ "heroicons",
+ "lucide",
+ "pinia"
]
}
\ No newline at end of file
diff --git a/app/app.vue b/app/app.vue
index 2b1be09..f8eacfa 100644
--- a/app/app.vue
+++ b/app/app.vue
@@ -1,5 +1,5 @@
-
+
-
+
diff --git a/app/components/Table/CellCompare.vue b/app/components/Table/CellCompare.vue
index 9475b83..4c7b933 100644
--- a/app/components/Table/CellCompare.vue
+++ b/app/components/Table/CellCompare.vue
@@ -71,7 +71,7 @@ watch(
diff --git a/app/components/Table/NewTablePagination.vue b/app/components/Table/NewTablePagination.vue
index 810ed8a..386f1c2 100644
--- a/app/components/Table/NewTablePagination.vue
+++ b/app/components/Table/NewTablePagination.vue
@@ -2,6 +2,7 @@
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/vue/24/outline";
import { MoreHorizontal } from "lucide-vue-next";
+import { Button } from "@/components/ui/button";
import {
Pagination,
PaginationContent,
@@ -40,7 +41,7 @@ const pageCount = computed(() => Math.ceil(props.total / props.pageSize));
@update:page="(val: number) => emits('update:page', val)"
>
-
+
@@ -48,27 +49,27 @@ const pageCount = computed(() => Math.ceil(props.total / props.pageSize));
v-if="item.type === 'page'"
:value="item.value"
:is-active="item.value === page"
- class="w-fit min-w-5.5 h-5.5 text-xs px-1 text-[#71717A]/80"
+ class="w-fit min-w-5.5 h-5.5 text-xs px-1 text-muted-foreground"
:class="{
- 'text-[#2563EB]! border-[#2563EB]!': item.value === page
+ 'text-primary! border-primary!': item.value === page
}"
>
{{ item.value }}
-
+
diff --git a/app/components/Table/TableCom.vue b/app/components/Table/TableCom.vue
index 7070610..0782f16 100644
--- a/app/components/Table/TableCom.vue
+++ b/app/components/Table/TableCom.vue
@@ -3,21 +3,22 @@
import { PlayIcon } from "@heroicons/vue/24/solid";
import { ChevronUpDownIcon } from "@/assets/Icons";
-import { cn } from "@/lib/utils";
-import { useSortableTable } from "@/utils";
-
-import type { TableColumn, TableProps } from "./type";
-
+import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
+ TableEmpty,
TableFooter,
TableHead,
TableHeader,
- TableEmpty,
TableRow
} from "@/components/ui/table";
+import { cn } from "@/lib/utils";
+import { useSortableTable } from "@/utils";
+
+import NewTablePagination from "./NewTablePagination.vue";
+import type { TableColumn, TableProps } from "./type";
const props = defineProps>();
@@ -271,12 +272,12 @@ onMounted(() => {
:class="[
cn(
'before:absolute before:top-0 before:bottom-0 before:left-0 before:w-2',
- 'before:bg-linear-to-r before:from-blue-600/5 before:to-transparent',
+ 'before:bg-linear-to-r before:from-border before:to-transparent',
'before:pointer-events-none before:z-10 before:transition-opacity',
'before:shadow-[inset_5px_0_4px_-3px_rgba(37,99,235,0.05)]',
'after:absolute after:top-0 after:bottom-0 after:right-0 after:w-2',
- 'after:bg-linear-to-l after:from-blue-600/5 after:to-transparent',
+ 'after:bg-linear-to-l after:from-border after:to-transparent',
'after:pointer-events-none after:z-10 after:transition-opacity',
'after:shadow-[inset_-5px_0_4px_-3px_rgba(37,99,235,0.05)]',
{
@@ -294,13 +295,13 @@ onMounted(() => {
ref="tableEl"
:class="[
cn(
- 'relative bg-[#FCFDFF] text-xs 2xl:text-sm rounded-lg overflow-hidden',
+ 'relative bg-card text-xs 2xl:text-sm rounded-2xl overflow-hidden',
props.class
)
]"
>
-
-
+
+
{
{
-
+
-
+
{
) &&
(rowAppendVisible?.(row as T, rowIndex) ?? true)
"
- class="border-[#0088FE1a] overflow-hidden bg-muted! hover:bg-gray-200/50! transition-colors duration-200"
+ class="border-border overflow-hidden bg-muted! hover:bg-muted/70! transition-colors duration-200"
:class="{
'visible [&>td>div]:max-h-0 [&>td>div]:opacity-0 border-transparent ':
!isRowExpanded(row as T, rowIndex)
@@ -526,7 +527,7 @@ onMounted(() => {
-
+
diff --git a/app/components/dashboard/QaRecordDetail.vue b/app/components/dashboard/QaRecordDetail.vue
new file mode 100644
index 0000000..a5a1c03
--- /dev/null
+++ b/app/components/dashboard/QaRecordDetail.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+
diff --git a/app/components/dashboard/QaRecordsTable.vue b/app/components/dashboard/QaRecordsTable.vue
new file mode 100644
index 0000000..4bffcd6
--- /dev/null
+++ b/app/components/dashboard/QaRecordsTable.vue
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+ 最近问答记录
+ 当前进程最多保留最近 100 条。
+
+
+
+
+
+ {{ value }}
+
+
+
+
+ {{ getQuestionTypeLabel(value) }}
+
+
+
+
+
+ {{ value }}
+
+
+
+
+
+ {{ displayOptions(row) }}
+
+
+
+
+
+ {{ value }}
+
+
+
+
+
+
+
+
+
+
diff --git a/app/components/dashboard/StatsOverview.vue b/app/components/dashboard/StatsOverview.vue
new file mode 100644
index 0000000..4142f78
--- /dev/null
+++ b/app/components/dashboard/StatsOverview.vue
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
Dashboard
+
+ 当前 Nuxt 进程的健康状态、缓存和最近问答记录。
+
+
+
+
+
+
+
+
+
+
+ {{ dashboardErrorMessage }}
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
+
+
+ {{ item.value }}
+
+
+ {{ item.detail }}
+
+
+
+
+
+
+
diff --git a/app/components/docs/ApiDocsContent.vue b/app/components/docs/ApiDocsContent.vue
new file mode 100644
index 0000000..658da80
--- /dev/null
+++ b/app/components/docs/ApiDocsContent.vue
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+ API 文档
+
+ 响应保持 OCS AnswererWrapper 兼容:成功返回
+ code: 1
+ ,失败返回
+ code: 0
+ 。
+
+
+
+
+
+
+ 接口列表
+
+
+
+
+
+
+ 方法
+ 路径
+ Token
+ 说明
+
+
+
+
+ {{ item.method }}
+
+ {{ item.path }}
+
+ {{ item.auth }}
+ {{ item.description }}
+
+
+
+
+
+
+
+
+
+
+ 请求示例
+
+
+ {{ searchExample }}
+
+
+
+
+
+ 成功响应
+
+
+ {{ successExample }}
+
+
+
+
+
diff --git a/app/components/home/AnswerForm.vue b/app/components/home/AnswerForm.vue
new file mode 100644
index 0000000..753538f
--- /dev/null
+++ b/app/components/home/AnswerForm.vue
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+ 题目查询
+ 服务端流式请求模型,前端返回普通 JSON 结果。
+
+
+
+
+
+
+
diff --git a/app/components/home/AnswerResult.vue b/app/components/home/AnswerResult.vue
new file mode 100644
index 0000000..b119f2a
--- /dev/null
+++ b/app/components/home/AnswerResult.vue
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+ 查询结果
+
+ OCS JSON
+
+
+
+
+
+
+
+
+ 查询失败
+ {{ searchErrorMessage }}
+
+
+
+
+
+ 已返回答案
+
+
+
+
题目
+
+ {{ answerResult.question }}
+
+
+
+
+
答案
+
+ {{ answerResult.answer }}
+
+
+
+
+
+ 等待题目提交
+
+
+
+
diff --git a/app/components/home/OcsConfigCard.vue b/app/components/home/OcsConfigCard.vue
new file mode 100644
index 0000000..45f67f0
--- /dev/null
+++ b/app/components/home/OcsConfigCard.vue
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+ OCS 配置
+
+
+
+
+
+
+ {{ configText }}
+
+
+
diff --git a/app/components/layout/AppFooter.vue b/app/components/layout/AppFooter.vue
new file mode 100644
index 0000000..34692db
--- /dev/null
+++ b/app/components/layout/AppFooter.vue
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/components/layout/AppHeader.vue b/app/components/layout/AppHeader.vue
new file mode 100644
index 0000000..7b00826
--- /dev/null
+++ b/app/components/layout/AppHeader.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ OCS AI 答题服务
+
+
+ Nuxt API Runtime
+
+
+
+
+
+
+
+
diff --git a/app/components/ui/alert/Alert.vue b/app/components/ui/alert/Alert.vue
new file mode 100644
index 0000000..916295b
--- /dev/null
+++ b/app/components/ui/alert/Alert.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/app/components/ui/alert/AlertAction.vue b/app/components/ui/alert/AlertAction.vue
new file mode 100644
index 0000000..bafddc0
--- /dev/null
+++ b/app/components/ui/alert/AlertAction.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/app/components/ui/alert/AlertDescription.vue b/app/components/ui/alert/AlertDescription.vue
new file mode 100644
index 0000000..43b45a9
--- /dev/null
+++ b/app/components/ui/alert/AlertDescription.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/app/components/ui/alert/AlertTitle.vue b/app/components/ui/alert/AlertTitle.vue
new file mode 100644
index 0000000..b9694a6
--- /dev/null
+++ b/app/components/ui/alert/AlertTitle.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/app/components/ui/alert/index.ts b/app/components/ui/alert/index.ts
new file mode 100644
index 0000000..85416fc
--- /dev/null
+++ b/app/components/ui/alert/index.ts
@@ -0,0 +1,21 @@
+import type { VariantProps } from 'class-variance-authority'
+import { cva } from 'class-variance-authority'
+
+export { default as Alert } from './Alert.vue'
+export { default as AlertAction } from './AlertAction.vue'
+export { default as AlertDescription } from './AlertDescription.vue'
+export { default as AlertTitle } from './AlertTitle.vue'
+
+export const alertVariants = cva('grid gap-0.5 rounded-2xl border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*=size-])]:size-4 group/alert relative w-full', {
+ variants: {
+ variant: {
+ default: 'bg-card text-card-foreground',
+ destructive: 'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+})
+
+export type AlertVariants = VariantProps
diff --git a/app/components/ui/badge/Badge.vue b/app/components/ui/badge/Badge.vue
new file mode 100644
index 0000000..b2a0e5d
--- /dev/null
+++ b/app/components/ui/badge/Badge.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
diff --git a/app/components/ui/badge/index.ts b/app/components/ui/badge/index.ts
new file mode 100644
index 0000000..15ebbe8
--- /dev/null
+++ b/app/components/ui/badge/index.ts
@@ -0,0 +1,24 @@
+import type { VariantProps } from 'class-variance-authority'
+import { cva } from 'class-variance-authority'
+
+export { default as Badge } from './Badge.vue'
+
+export const badgeVariants = cva(
+ 'h-5 gap-1 rounded-3xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none',
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
+ secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
+ destructive: 'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
+ outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
+ ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+)
+export type BadgeVariants = VariantProps
diff --git a/app/components/ui/card/Card.vue b/app/components/ui/card/Card.vue
new file mode 100644
index 0000000..fa48666
--- /dev/null
+++ b/app/components/ui/card/Card.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/app/components/ui/card/CardAction.vue b/app/components/ui/card/CardAction.vue
new file mode 100644
index 0000000..c2beb20
--- /dev/null
+++ b/app/components/ui/card/CardAction.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/app/components/ui/card/CardContent.vue b/app/components/ui/card/CardContent.vue
new file mode 100644
index 0000000..88bfc48
--- /dev/null
+++ b/app/components/ui/card/CardContent.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/app/components/ui/card/CardDescription.vue b/app/components/ui/card/CardDescription.vue
new file mode 100644
index 0000000..722b203
--- /dev/null
+++ b/app/components/ui/card/CardDescription.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/app/components/ui/card/CardFooter.vue b/app/components/ui/card/CardFooter.vue
new file mode 100644
index 0000000..a1bbaa8
--- /dev/null
+++ b/app/components/ui/card/CardFooter.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/app/components/ui/card/CardHeader.vue b/app/components/ui/card/CardHeader.vue
new file mode 100644
index 0000000..64859e7
--- /dev/null
+++ b/app/components/ui/card/CardHeader.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/app/components/ui/card/CardTitle.vue b/app/components/ui/card/CardTitle.vue
new file mode 100644
index 0000000..c50dd65
--- /dev/null
+++ b/app/components/ui/card/CardTitle.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/app/components/ui/card/index.ts b/app/components/ui/card/index.ts
new file mode 100644
index 0000000..73d985f
--- /dev/null
+++ b/app/components/ui/card/index.ts
@@ -0,0 +1,7 @@
+export { default as Card } from './Card.vue'
+export { default as CardAction } from './CardAction.vue'
+export { default as CardContent } from './CardContent.vue'
+export { default as CardDescription } from './CardDescription.vue'
+export { default as CardFooter } from './CardFooter.vue'
+export { default as CardHeader } from './CardHeader.vue'
+export { default as CardTitle } from './CardTitle.vue'
diff --git a/app/components/ui/dialog/Dialog.vue b/app/components/ui/dialog/Dialog.vue
new file mode 100644
index 0000000..315b5ca
--- /dev/null
+++ b/app/components/ui/dialog/Dialog.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
diff --git a/app/components/ui/dialog/DialogClose.vue b/app/components/ui/dialog/DialogClose.vue
new file mode 100644
index 0000000..b1e1167
--- /dev/null
+++ b/app/components/ui/dialog/DialogClose.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/app/components/ui/dialog/DialogContent.vue b/app/components/ui/dialog/DialogContent.vue
new file mode 100644
index 0000000..8fa3683
--- /dev/null
+++ b/app/components/ui/dialog/DialogContent.vue
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/components/ui/dialog/DialogDescription.vue b/app/components/ui/dialog/DialogDescription.vue
new file mode 100644
index 0000000..ecec5f1
--- /dev/null
+++ b/app/components/ui/dialog/DialogDescription.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
diff --git a/app/components/ui/dialog/DialogFooter.vue b/app/components/ui/dialog/DialogFooter.vue
new file mode 100644
index 0000000..b2f1289
--- /dev/null
+++ b/app/components/ui/dialog/DialogFooter.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/components/ui/dialog/DialogHeader.vue b/app/components/ui/dialog/DialogHeader.vue
new file mode 100644
index 0000000..fceecc7
--- /dev/null
+++ b/app/components/ui/dialog/DialogHeader.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/app/components/ui/dialog/DialogOverlay.vue b/app/components/ui/dialog/DialogOverlay.vue
new file mode 100644
index 0000000..172e4d3
--- /dev/null
+++ b/app/components/ui/dialog/DialogOverlay.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/app/components/ui/dialog/DialogScrollContent.vue b/app/components/ui/dialog/DialogScrollContent.vue
new file mode 100644
index 0000000..f6b6dab
--- /dev/null
+++ b/app/components/ui/dialog/DialogScrollContent.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
+ {
+ const originalEvent = event.detail.originalEvent;
+ const target = originalEvent.target as HTMLElement;
+ if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
+ event.preventDefault();
+ }
+ }"
+ >
+
+
+
+
+ Close
+
+
+
+
+
diff --git a/app/components/ui/dialog/DialogTitle.vue b/app/components/ui/dialog/DialogTitle.vue
new file mode 100644
index 0000000..1e78510
--- /dev/null
+++ b/app/components/ui/dialog/DialogTitle.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
diff --git a/app/components/ui/dialog/DialogTrigger.vue b/app/components/ui/dialog/DialogTrigger.vue
new file mode 100644
index 0000000..44ea981
--- /dev/null
+++ b/app/components/ui/dialog/DialogTrigger.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/app/components/ui/dialog/index.ts b/app/components/ui/dialog/index.ts
new file mode 100644
index 0000000..baa7d61
--- /dev/null
+++ b/app/components/ui/dialog/index.ts
@@ -0,0 +1,10 @@
+export { default as Dialog } from './Dialog.vue'
+export { default as DialogClose } from './DialogClose.vue'
+export { default as DialogContent } from './DialogContent.vue'
+export { default as DialogDescription } from './DialogDescription.vue'
+export { default as DialogFooter } from './DialogFooter.vue'
+export { default as DialogHeader } from './DialogHeader.vue'
+export { default as DialogOverlay } from './DialogOverlay.vue'
+export { default as DialogScrollContent } from './DialogScrollContent.vue'
+export { default as DialogTitle } from './DialogTitle.vue'
+export { default as DialogTrigger } from './DialogTrigger.vue'
diff --git a/app/components/ui/input/Input.vue b/app/components/ui/input/Input.vue
new file mode 100644
index 0000000..6ec8de0
--- /dev/null
+++ b/app/components/ui/input/Input.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
diff --git a/app/components/ui/input/index.ts b/app/components/ui/input/index.ts
new file mode 100644
index 0000000..a691dd6
--- /dev/null
+++ b/app/components/ui/input/index.ts
@@ -0,0 +1 @@
+export { default as Input } from './Input.vue'
diff --git a/app/components/ui/label/Label.vue b/app/components/ui/label/Label.vue
new file mode 100644
index 0000000..9d30cbb
--- /dev/null
+++ b/app/components/ui/label/Label.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
diff --git a/app/components/ui/label/index.ts b/app/components/ui/label/index.ts
new file mode 100644
index 0000000..572c2f0
--- /dev/null
+++ b/app/components/ui/label/index.ts
@@ -0,0 +1 @@
+export { default as Label } from './Label.vue'
diff --git a/app/components/ui/select/Select.vue b/app/components/ui/select/Select.vue
new file mode 100644
index 0000000..553bf67
--- /dev/null
+++ b/app/components/ui/select/Select.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
diff --git a/app/components/ui/select/SelectContent.vue b/app/components/ui/select/SelectContent.vue
new file mode 100644
index 0000000..f05bd20
--- /dev/null
+++ b/app/components/ui/select/SelectContent.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/components/ui/select/SelectGroup.vue b/app/components/ui/select/SelectGroup.vue
new file mode 100644
index 0000000..df31028
--- /dev/null
+++ b/app/components/ui/select/SelectGroup.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/app/components/ui/select/SelectItem.vue b/app/components/ui/select/SelectItem.vue
new file mode 100644
index 0000000..0e69dda
--- /dev/null
+++ b/app/components/ui/select/SelectItem.vue
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/components/ui/select/SelectItemText.vue b/app/components/ui/select/SelectItemText.vue
new file mode 100644
index 0000000..af85394
--- /dev/null
+++ b/app/components/ui/select/SelectItemText.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/app/components/ui/select/SelectLabel.vue b/app/components/ui/select/SelectLabel.vue
new file mode 100644
index 0000000..090a321
--- /dev/null
+++ b/app/components/ui/select/SelectLabel.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/app/components/ui/select/SelectScrollDownButton.vue b/app/components/ui/select/SelectScrollDownButton.vue
new file mode 100644
index 0000000..f021132
--- /dev/null
+++ b/app/components/ui/select/SelectScrollDownButton.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/components/ui/select/SelectScrollUpButton.vue b/app/components/ui/select/SelectScrollUpButton.vue
new file mode 100644
index 0000000..442c149
--- /dev/null
+++ b/app/components/ui/select/SelectScrollUpButton.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/components/ui/select/SelectSeparator.vue b/app/components/ui/select/SelectSeparator.vue
new file mode 100644
index 0000000..4b0ae27
--- /dev/null
+++ b/app/components/ui/select/SelectSeparator.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/app/components/ui/select/SelectTrigger.vue b/app/components/ui/select/SelectTrigger.vue
new file mode 100644
index 0000000..4a8f19d
--- /dev/null
+++ b/app/components/ui/select/SelectTrigger.vue
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/components/ui/select/SelectValue.vue b/app/components/ui/select/SelectValue.vue
new file mode 100644
index 0000000..c18762e
--- /dev/null
+++ b/app/components/ui/select/SelectValue.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/app/components/ui/select/index.ts b/app/components/ui/select/index.ts
new file mode 100644
index 0000000..31b9294
--- /dev/null
+++ b/app/components/ui/select/index.ts
@@ -0,0 +1,11 @@
+export { default as Select } from './Select.vue'
+export { default as SelectContent } from './SelectContent.vue'
+export { default as SelectGroup } from './SelectGroup.vue'
+export { default as SelectItem } from './SelectItem.vue'
+export { default as SelectItemText } from './SelectItemText.vue'
+export { default as SelectLabel } from './SelectLabel.vue'
+export { default as SelectScrollDownButton } from './SelectScrollDownButton.vue'
+export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue'
+export { default as SelectSeparator } from './SelectSeparator.vue'
+export { default as SelectTrigger } from './SelectTrigger.vue'
+export { default as SelectValue } from './SelectValue.vue'
diff --git a/app/components/ui/skeleton/Skeleton.vue b/app/components/ui/skeleton/Skeleton.vue
new file mode 100644
index 0000000..36258e9
--- /dev/null
+++ b/app/components/ui/skeleton/Skeleton.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/app/components/ui/skeleton/index.ts b/app/components/ui/skeleton/index.ts
new file mode 100644
index 0000000..be21fad
--- /dev/null
+++ b/app/components/ui/skeleton/index.ts
@@ -0,0 +1 @@
+export { default as Skeleton } from './Skeleton.vue'
diff --git a/app/components/ui/textarea/Textarea.vue b/app/components/ui/textarea/Textarea.vue
new file mode 100644
index 0000000..0192487
--- /dev/null
+++ b/app/components/ui/textarea/Textarea.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
diff --git a/app/components/ui/textarea/index.ts b/app/components/ui/textarea/index.ts
new file mode 100644
index 0000000..6a7ab2a
--- /dev/null
+++ b/app/components/ui/textarea/index.ts
@@ -0,0 +1 @@
+export { default as Textarea } from './Textarea.vue'
diff --git a/app/interfaces/answer.ts b/app/interfaces/answer.ts
new file mode 100644
index 0000000..b2cc06f
--- /dev/null
+++ b/app/interfaces/answer.ts
@@ -0,0 +1,99 @@
+// app/interfaces/answer.ts - 前端和本地 API 之间使用的响应类型
+
+/** OCS 支持的题型;空字符串表示不指定题型,交给模型根据题目判断 */
+export type QuestionType = "" | "single" | "multiple" | "judgement" | "completion";
+
+/** 答题接口请求体,字段名保持旧 Python 服务和 OCS AnswererWrapper 兼容 */
+export interface ISearchRequest {
+ /** 题目正文,必填 */
+ title: string;
+ /** 题型,可能为空字符串 */
+ type: QuestionType | string;
+ /** 选项文本,多行或 JSON 字符串均按旧逻辑透传给服务端解析 */
+ options: string;
+}
+
+/** 答题成功时返回给 OCS 和前端的结构 */
+export interface ISearchSuccessResponse {
+ /** OCS 约定:1 表示找到答案 */
+ code: 1;
+ /** 原题目正文 */
+ question: string;
+ /** 最终答案;多选题继续使用 `#` 分隔 */
+ answer: string;
+}
+
+/** 答题失败时返回给 OCS 和前端的结构 */
+export interface ISearchErrorResponse {
+ /** OCS 约定:0 表示失败 */
+ code: 0;
+ /** 本地安全错误文案,不包含上游细节 */
+ msg: string;
+}
+
+/** 答题接口联合响应 */
+export type ISearchResponse = ISearchSuccessResponse | ISearchErrorResponse;
+
+/** 健康检查响应,只暴露非敏感运行信息 */
+export interface IHealthResponse {
+ /** 服务状态,正常时为 ok */
+ status: "ok";
+ /** 本地状态文案 */
+ message: string;
+ /** 服务版本 */
+ version: string;
+ /** 当前是否启用内存缓存 */
+ cache_enabled: boolean;
+ /** 当前模型名,不包含 API Key 或 baseURL */
+ model: string;
+}
+
+/** 服务统计响应,来自当前 Nuxt 进程的内存状态 */
+export interface IStatsResponse {
+ /** 服务版本 */
+ version: string;
+ /** 进程运行秒数 */
+ uptime: number;
+ /** 当前模型名 */
+ model: string;
+ /** 当前是否启用内存缓存 */
+ cache_enabled: boolean;
+ /** 当前有效缓存数量 */
+ cache_size: number;
+ /** 当前进程内问答记录数量 */
+ qa_records_count: number;
+}
+
+/** 最近问答记录,字段来自 server/utils/runtimeState.ts */
+export interface IQaRecord {
+ /** 本地可读时间 */
+ time: string;
+ /** ISO 时间,方便排序和调试 */
+ timestamp: string;
+ /** 题目正文 */
+ question: string;
+ /** 题型 */
+ type: string;
+ /** 选项文本 */
+ options: string;
+ /** 最终返回给 OCS 的答案 */
+ answer: string;
+}
+
+/** 最近问答记录接口响应 */
+export interface IRecordsResponse {
+ /** 管理接口是否成功 */
+ success: boolean;
+ /** 失败时的本地安全文案 */
+ message?: string;
+ /** 当前进程内最近问答记录,最新在前 */
+ records: IQaRecord[];
+}
+
+/** 清空缓存接口响应 */
+export interface IClearCacheResponse {
+ /** 是否清理成功;缓存关闭时为 false */
+ success: boolean;
+ /** 本地状态文案 */
+ message: string;
+}
diff --git a/app/interfaces/index.ts b/app/interfaces/index.ts
new file mode 100644
index 0000000..684a139
--- /dev/null
+++ b/app/interfaces/index.ts
@@ -0,0 +1,2 @@
+// app/interfaces/index.ts - 前端共享类型统一出口
+export * from "./answer";
diff --git a/app/layouts/default.vue b/app/layouts/default.vue
new file mode 100644
index 0000000..e374b52
--- /dev/null
+++ b/app/layouts/default.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
+
diff --git a/app/pages/dashboard.vue b/app/pages/dashboard.vue
new file mode 100644
index 0000000..53c4c47
--- /dev/null
+++ b/app/pages/dashboard.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/pages/docs.vue b/app/pages/docs.vue
new file mode 100644
index 0000000..a2eefce
--- /dev/null
+++ b/app/pages/docs.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/app/pages/index.vue b/app/pages/index.vue
new file mode 100644
index 0000000..08039f0
--- /dev/null
+++ b/app/pages/index.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+
diff --git a/app/services/answer_service.ts b/app/services/answer_service.ts
new file mode 100644
index 0000000..8de65de
--- /dev/null
+++ b/app/services/answer_service.ts
@@ -0,0 +1,55 @@
+// app/services/answer_service.ts - OCS AI 答题服务相关 API
+import type {
+ IClearCacheResponse,
+ IHealthResponse,
+ IRecordsResponse,
+ ISearchRequest,
+ ISearchResponse,
+ IStatsResponse
+} from "@/interfaces";
+
+import BaseClientService from "@/services/base_service";
+
+/** 当前 Nuxt 站点内 API 的基础路径,部署到子路径时由 Nuxt 自己处理 */
+const BaseUrl = "";
+
+/** OCS AI 答题服务请求集合 */
+export class AnswerService {
+ public static basePath = `${BaseUrl}/api`;
+
+ /** 调用答题接口;前端统一走 JSON POST,服务端仍兼容 OCS 的 GET 调用 */
+ public static search(request: ISearchRequest) {
+ return BaseClientService.post(
+ `${AnswerService.basePath}/search`,
+ request
+ );
+ }
+
+ /** 读取健康状态;无需 token */
+ public static getHealth() {
+ return BaseClientService.get(
+ `${AnswerService.basePath}/health`
+ );
+ }
+
+ /** 读取运行统计;配置 ACCESS_TOKEN 时由服务端校验 */
+ public static getStats() {
+ return BaseClientService.get(
+ `${AnswerService.basePath}/stats`
+ );
+ }
+
+ /** 读取最近问答记录,供 Dashboard 表格展示 */
+ public static getRecords() {
+ return BaseClientService.get(
+ `${AnswerService.basePath}/records`
+ );
+ }
+
+ /** 清空当前进程内的答题缓存 */
+ public static clearCache() {
+ return BaseClientService.post(
+ `${AnswerService.basePath}/cache/clear`
+ );
+ }
+}
diff --git a/app/services/base_service.ts b/app/services/base_service.ts
new file mode 100644
index 0000000..d4c53f7
--- /dev/null
+++ b/app/services/base_service.ts
@@ -0,0 +1,49 @@
+// app/services/base_service.ts - 前端请求基础封装,保持和其他项目相近的调用风格
+
+/** GET query 可接受的基础值类型 */
+type QueryValue = string | number | boolean | null | undefined;
+
+/** GET query 对象;空值会在请求前被过滤掉 */
+type QueryParams = Record;
+
+/** 把 undefined/null/空字符串从 query 中移除,避免拼出无意义参数 */
+const cleanQuery = (query?: QueryParams) => {
+ if (!query) return undefined;
+
+ return Object.fromEntries(
+ Object.entries(query).filter(([, value]) => {
+ return value !== undefined && value !== null && value !== "";
+ })
+ );
+};
+
+/**
+ * 基础请求类
+ *
+ * 其他项目里 service 方法返回 axios response,这里用 `$fetch` 包一层 `{ data }`,
+ * 让 store 层仍然可以写成 `res.data`,后续替换请求库也不会影响组件。
+ */
+export default class BaseClientService {
+ /** 发起 GET 请求 */
+ public static async get(url: string, query?: QueryParams) {
+ const data = await $fetch(url, {
+ method: "GET",
+ query: cleanQuery(query)
+ });
+
+ return { data };
+ }
+
+ /** 发起 POST 请求 */
+ public static async post(
+ url: string,
+ body?: B
+ ) {
+ const data = await $fetch(url, {
+ method: "POST",
+ body
+ });
+
+ return { data };
+ }
+}
diff --git a/app/services/index.ts b/app/services/index.ts
new file mode 100644
index 0000000..f0b5a07
--- /dev/null
+++ b/app/services/index.ts
@@ -0,0 +1,2 @@
+// app/services/index.ts - 前端 service 统一出口
+export * from "./answer_service";
diff --git a/app/stores/answer.ts b/app/stores/answer.ts
new file mode 100644
index 0000000..8b43e8f
--- /dev/null
+++ b/app/stores/answer.ts
@@ -0,0 +1,256 @@
+// app/stores/answer.ts - 答题页和 Dashboard 的统一状态管理
+import { defineStore } from "pinia";
+
+import type {
+ IHealthResponse,
+ IQaRecord,
+ ISearchSuccessResponse,
+ IStatsResponse,
+ QuestionType
+} from "@/interfaces";
+import { AnswerService } from "@/services";
+
+/** Dashboard 最近记录表格每页数量,组件直接复用,避免多个地方写死 */
+export const DASHBOARD_RECORD_PAGE_SIZE = 10;
+
+/** 题型下拉选项,value 保持 OCS / 旧 Python 服务使用的英文值 */
+export const QUESTION_TYPE_OPTIONS: Array<{
+ label: string;
+ value: QuestionType;
+}> = [
+ { label: "自动判断", value: "" },
+ { label: "单选题", value: "single" },
+ { label: "多选题", value: "multiple" },
+ { label: "判断题", value: "judgement" },
+ { label: "填空题", value: "completion" }
+];
+
+/** 题型展示文案,Dashboard 表格用它把英文值转成本地业务表达 */
+export const QUESTION_TYPE_LABELS: Record = {
+ "": "自动判断",
+ single: "单选题",
+ multiple: "多选题",
+ judgement: "判断题",
+ completion: "填空题"
+};
+
+/** 从 `$fetch` 错误里提取安全文案;服务端已经保证 message/msg 不含敏感上游细节 */
+const getClientErrorMessage = (error: unknown, fallback: string) => {
+ if (typeof error !== "object" || error === null) return fallback;
+
+ const data = "data" in error ? (error as { data?: unknown }).data : undefined;
+ if (typeof data === "object" && data !== null) {
+ const message = (data as { message?: unknown; msg?: unknown }).message;
+ const msg = (data as { message?: unknown; msg?: unknown }).msg;
+
+ if (typeof message === "string" && message) return message;
+ if (typeof msg === "string" && msg) return msg;
+ }
+
+ return fallback;
+};
+
+/** 把运行秒数格式化为 dashboard 更容易扫读的时长 */
+const formatUptime = (seconds: number) => {
+ const safeSeconds = Math.max(0, Math.floor(seconds));
+ const days = Math.floor(safeSeconds / 86_400);
+ const hours = Math.floor((safeSeconds % 86_400) / 3_600);
+ const minutes = Math.floor((safeSeconds % 3_600) / 60);
+
+ if (days > 0) return `${days} 天 ${hours} 小时`;
+ if (hours > 0) return `${hours} 小时 ${minutes} 分钟`;
+ return `${Math.max(1, minutes)} 分钟`;
+};
+
+/** OCS AI 答题服务前端状态管理 */
+export const useAnswerStore = defineStore("answer", () => {
+ // ---- 答题表单状态 ----
+ const question = ref("");
+ const questionType = ref("");
+ const options = ref("");
+ const searchLoading = ref(false);
+ const answerResult = ref();
+ const searchErrorMessage = ref("");
+
+ // ---- Dashboard 状态 ----
+ const health = ref();
+ const stats = ref();
+ const records = ref([]);
+ const dashboardErrorMessage = ref("");
+ const statsLoading = ref(false);
+ const recordsLoading = ref(false);
+ const cacheClearing = ref(false);
+ const recordsPage = ref(1);
+ const recordsPageSize = ref(DASHBOARD_RECORD_PAGE_SIZE);
+ const selectedRecord = ref();
+
+ /** Dashboard 表格当前页数据,分页只作用在前端的最近 100 条内存记录上 */
+ const pagedRecords = computed(() => {
+ const start = (recordsPage.value - 1) * recordsPageSize.value;
+ return records.value.slice(start, start + recordsPageSize.value);
+ });
+
+ /** 表格总数,用于 TableCom 分页器 */
+ const recordCount = computed(() => records.value.length);
+
+ /** Dashboard 顶部运行时长展示 */
+ const uptimeText = computed(() => {
+ return stats.value ? formatUptime(stats.value.uptime) : "-";
+ });
+
+ /** 选择表格记录,交给详情弹窗展示完整题目、选项和答案 */
+ const selectRecord = (record?: IQaRecord) => {
+ selectedRecord.value = record;
+ };
+
+ /** 切换最近记录表格页码 */
+ const setRecordsPage = (page: number) => {
+ recordsPage.value = page;
+ };
+
+ /** 调用答题 API,返回 OCS 兼容结构但只在 store 中维护 UI 状态 */
+ const searchAnswer = async () => {
+ const title = question.value.trim();
+ const requestOptions = options.value.trim();
+
+ answerResult.value = undefined;
+ searchErrorMessage.value = "";
+
+ if (!title) {
+ searchErrorMessage.value = "未提供问题内容";
+ return;
+ }
+
+ searchLoading.value = true;
+ try {
+ const res = await AnswerService.search({
+ title,
+ type: questionType.value,
+ options: requestOptions
+ });
+
+ if (res.data.code === 1) {
+ answerResult.value = res.data;
+ await Promise.allSettled([getStats(), getRecords()]);
+ return;
+ }
+
+ searchErrorMessage.value = res.data.msg || "答题失败";
+ } catch (error) {
+ searchErrorMessage.value = getClientErrorMessage(
+ error,
+ "服务器内部错误"
+ );
+ } finally {
+ searchLoading.value = false;
+ }
+ };
+
+ /** 读取健康检查,失败时只写本地错误文案 */
+ const getHealth = async () => {
+ try {
+ const res = await AnswerService.getHealth();
+ health.value = res.data;
+ } catch (error) {
+ dashboardErrorMessage.value = getClientErrorMessage(
+ error,
+ "健康检查失败"
+ );
+ }
+ };
+
+ /** 读取运行统计 */
+ const getStats = async () => {
+ statsLoading.value = true;
+ try {
+ const res = await AnswerService.getStats();
+ stats.value = res.data;
+ } catch (error) {
+ dashboardErrorMessage.value = getClientErrorMessage(
+ error,
+ "统计信息读取失败"
+ );
+ } finally {
+ statsLoading.value = false;
+ }
+ };
+
+ /** 读取最近问答记录,并在数量变少时把页码拉回合法范围 */
+ const getRecords = async () => {
+ recordsLoading.value = true;
+ try {
+ const res = await AnswerService.getRecords();
+ records.value = res.data.records;
+
+ const maxPage = Math.max(
+ 1,
+ Math.ceil(records.value.length / recordsPageSize.value)
+ );
+ if (recordsPage.value > maxPage) recordsPage.value = maxPage;
+ } catch (error) {
+ dashboardErrorMessage.value = getClientErrorMessage(
+ error,
+ "问答记录读取失败"
+ );
+ } finally {
+ recordsLoading.value = false;
+ }
+ };
+
+ /** Dashboard 首次加载时并行拉取运行状态、统计和最近记录 */
+ const loadDashboard = async () => {
+ dashboardErrorMessage.value = "";
+ await Promise.allSettled([getHealth(), getStats(), getRecords()]);
+ };
+
+ /** 清空缓存后刷新统计;问答记录不会被清空,保持旧服务语义 */
+ const clearCache = async () => {
+ cacheClearing.value = true;
+ dashboardErrorMessage.value = "";
+
+ try {
+ const res = await AnswerService.clearCache();
+ if (!res.data.success) {
+ dashboardErrorMessage.value = res.data.message;
+ }
+ await getStats();
+ } catch (error) {
+ dashboardErrorMessage.value = getClientErrorMessage(
+ error,
+ "缓存清理失败"
+ );
+ } finally {
+ cacheClearing.value = false;
+ }
+ };
+
+ return {
+ question,
+ questionType,
+ options,
+ searchLoading,
+ answerResult,
+ searchErrorMessage,
+ health,
+ stats,
+ records,
+ dashboardErrorMessage,
+ statsLoading,
+ recordsLoading,
+ cacheClearing,
+ recordsPage,
+ recordsPageSize,
+ selectedRecord,
+ pagedRecords,
+ recordCount,
+ uptimeText,
+ searchAnswer,
+ getHealth,
+ getStats,
+ getRecords,
+ loadDashboard,
+ clearCache,
+ selectRecord,
+ setRecordsPage
+ };
+});
diff --git a/server/api/records.get.ts b/server/api/records.get.ts
new file mode 100644
index 0000000..0f4f2e3
--- /dev/null
+++ b/server/api/records.get.ts
@@ -0,0 +1,44 @@
+// server/api/records.get.ts - 最近问答记录接口,供 Nuxt Dashboard 表格展示
+import { setResponseStatus } from "h3";
+
+import {
+ invalidAccessTokenResponse,
+ verifyAccessToken
+} from "~~/server/utils/auth";
+import { createApiLogger } from "~~/server/utils/logging";
+import { getQaRecords } from "~~/server/utils/runtimeState";
+
+/**
+ * 最近问答记录接口
+ *
+ * 旧 Python Dashboard 直接在服务端模板里读取内存 records;迁移到 Nuxt 后,
+ * 前端表格需要一个 JSON 数据源,所以这里暴露同样的内存记录副本。
+ *
+ * 安全边界:
+ * - 配置 ACCESS_TOKEN 时必须携带 `X-Access-Token` 或 query `token`
+ * - 这里只返回题目、选项和最终答案,不包含 OpenAI 响应体、API Key 或内部错误
+ * - 记录仅存在当前进程内,重启或多实例部署不会共享
+ */
+export default defineEventHandler((event) => {
+ const logger = createApiLogger(event, "api.records");
+
+ if (!verifyAccessToken(event)) {
+ setResponseStatus(event, 403);
+ logger.warn("invalid_access_token");
+ return {
+ success: false,
+ message: invalidAccessTokenResponse().msg,
+ records: []
+ };
+ }
+
+ const records = getQaRecords();
+ logger.info("finish_success", {
+ count: records.length
+ });
+
+ return {
+ success: true,
+ records
+ };
+});
diff --git a/server/utils/runtimeState.ts b/server/utils/runtimeState.ts
index 1f64218..4d128b0 100644
--- a/server/utils/runtimeState.ts
+++ b/server/utils/runtimeState.ts
@@ -58,6 +58,15 @@ export const addQaRecord = (record: Omit) => {
}
};
+/**
+ * 获取最近问答记录
+ *
+ * 返回副本并按“最新在前”排序,避免 API handler 或前端展示逻辑误改内存原数组
+ */
+export const getQaRecords = () => {
+ return [...qaRecords].reverse();
+};
+
/** 生成 `/api/stats` 响应,实时计算 uptime 和有效缓存数量 */
export const getRuntimeStats = () => {
return {