feat: 完成整体内容开发
This commit is contained in:
Vendored
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
{
|
{
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"heroicons"
|
"heroicons",
|
||||||
|
"lucide",
|
||||||
|
"pinia"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<NuxtLayout>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</div>
|
</NuxtLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ watch(
|
|||||||
<div
|
<div
|
||||||
ref="cellRef"
|
ref="cellRef"
|
||||||
class="transition-colors duration-300"
|
class="transition-colors duration-300"
|
||||||
:class="{ 'bg-[#ECF2FF] h-full w-full p-4 -my-4': hasChanged }"
|
:class="{ 'bg-muted h-full w-full p-4 -my-4': hasChanged }"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/vue/24/outline";
|
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/vue/24/outline";
|
||||||
import { MoreHorizontal } from "lucide-vue-next";
|
import { MoreHorizontal } from "lucide-vue-next";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Pagination,
|
Pagination,
|
||||||
PaginationContent,
|
PaginationContent,
|
||||||
@@ -40,7 +41,7 @@ const pageCount = computed(() => Math.ceil(props.total / props.pageSize));
|
|||||||
@update:page="(val: number) => emits('update:page', val)"
|
@update:page="(val: number) => emits('update:page', val)"
|
||||||
>
|
>
|
||||||
<PaginationContent v-slot="{ items }">
|
<PaginationContent v-slot="{ items }">
|
||||||
<PaginationPrevious class="w-5.5 h-5.5 text-[#2563EB]!">
|
<PaginationPrevious class="w-5.5 h-5.5 text-primary!">
|
||||||
<ChevronLeftIcon class="size-4" />
|
<ChevronLeftIcon class="size-4" />
|
||||||
</PaginationPrevious>
|
</PaginationPrevious>
|
||||||
<template v-for="(item, index) in items" :key="index">
|
<template v-for="(item, index) in items" :key="index">
|
||||||
@@ -48,27 +49,27 @@ const pageCount = computed(() => Math.ceil(props.total / props.pageSize));
|
|||||||
v-if="item.type === 'page'"
|
v-if="item.type === 'page'"
|
||||||
:value="item.value"
|
:value="item.value"
|
||||||
:is-active="item.value === page"
|
: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="{
|
:class="{
|
||||||
'text-[#2563EB]! border-[#2563EB]!': item.value === page
|
'text-primary! border-primary!': item.value === page
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
{{ item.value }}
|
{{ item.value }}
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
<PaginationEllipsis
|
<PaginationEllipsis
|
||||||
v-else-if="item.type === 'ellipsis'"
|
v-else-if="item.type === 'ellipsis'"
|
||||||
class="text-[#71717A]/80"
|
class="text-muted-foreground"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:disabled="props.load"
|
:disabled="props.load"
|
||||||
class="w-5.5 h-5.5 p-0 text-[12px] text-[#71717A]/80 border-transparent bg-white rounded"
|
class="w-5.5 h-5.5 rounded-2xl border-transparent bg-background p-0 text-[12px] text-muted-foreground"
|
||||||
>
|
>
|
||||||
<MoreHorizontal class="size-4" />
|
<MoreHorizontal class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</PaginationEllipsis>
|
</PaginationEllipsis>
|
||||||
</template>
|
</template>
|
||||||
<PaginationNext class="w-5.5 h-5.5 text-[#2563EB]!">
|
<PaginationNext class="w-5.5 h-5.5 text-primary!">
|
||||||
<ChevronRightIcon class="size-4" />
|
<ChevronRightIcon class="size-4" />
|
||||||
</PaginationNext>
|
</PaginationNext>
|
||||||
</PaginationContent>
|
</PaginationContent>
|
||||||
|
|||||||
@@ -3,21 +3,22 @@
|
|||||||
import { PlayIcon } from "@heroicons/vue/24/solid";
|
import { PlayIcon } from "@heroicons/vue/24/solid";
|
||||||
|
|
||||||
import { ChevronUpDownIcon } from "@/assets/Icons";
|
import { ChevronUpDownIcon } from "@/assets/Icons";
|
||||||
import { cn } from "@/lib/utils";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useSortableTable } from "@/utils";
|
|
||||||
|
|
||||||
import type { TableColumn, TableProps } from "./type";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
|
TableEmpty,
|
||||||
TableFooter,
|
TableFooter,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableEmpty,
|
|
||||||
TableRow
|
TableRow
|
||||||
} from "@/components/ui/table";
|
} 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<TableProps<T>>();
|
const props = defineProps<TableProps<T>>();
|
||||||
|
|
||||||
@@ -271,12 +272,12 @@ onMounted(() => {
|
|||||||
:class="[
|
:class="[
|
||||||
cn(
|
cn(
|
||||||
'before:absolute before:top-0 before:bottom-0 before:left-0 before:w-2',
|
'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:pointer-events-none before:z-10 before:transition-opacity',
|
||||||
'before:shadow-[inset_5px_0_4px_-3px_rgba(37,99,235,0.05)]',
|
'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: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:pointer-events-none after:z-10 after:transition-opacity',
|
||||||
'after:shadow-[inset_-5px_0_4px_-3px_rgba(37,99,235,0.05)]',
|
'after:shadow-[inset_-5px_0_4px_-3px_rgba(37,99,235,0.05)]',
|
||||||
{
|
{
|
||||||
@@ -294,13 +295,13 @@ onMounted(() => {
|
|||||||
ref="tableEl"
|
ref="tableEl"
|
||||||
:class="[
|
:class="[
|
||||||
cn(
|
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
|
props.class
|
||||||
)
|
)
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<TableHeader class="bg-[#F2F9FE] select-none">
|
<TableHeader class="bg-muted select-none">
|
||||||
<TableRow class="text-[#181818] font-medium">
|
<TableRow class="text-foreground font-medium">
|
||||||
<TableHead
|
<TableHead
|
||||||
v-for="(column, index) in visibleColumns"
|
v-for="(column, index) in visibleColumns"
|
||||||
:key="getColumnKey(column, index)"
|
:key="getColumnKey(column, index)"
|
||||||
@@ -352,12 +353,12 @@ onMounted(() => {
|
|||||||
<!-- 排序指示器 icon -->
|
<!-- 排序指示器 icon -->
|
||||||
<span v-if="column.sortable" class="flex items-center ml-2">
|
<span v-if="column.sortable" class="flex items-center ml-2">
|
||||||
<ChevronUpDownIcon
|
<ChevronUpDownIcon
|
||||||
class="h-6! w-6! [&>.svg-top]:text-[#71717A4D] [&>.svg-bottom]:text-[#71717A4D]"
|
class="h-6! w-6! [&>.svg-top]:text-muted-foreground/40 [&>.svg-bottom]:text-muted-foreground/40"
|
||||||
:class="{
|
:class="{
|
||||||
'[&>.svg-top]:text-[#2563EB]! [&>.svg-bottom]:text-[#71717A4D]!':
|
'[&>.svg-top]:text-primary! [&>.svg-bottom]:text-muted-foreground/40!':
|
||||||
sortColumn === getColumnKey(column) &&
|
sortColumn === getColumnKey(column) &&
|
||||||
sortDirection === 'asc',
|
sortDirection === 'asc',
|
||||||
'[&>.svg-bottom]:text-[#2563EB]! [&>.svg-top]:text-[#71717A4D]!':
|
'[&>.svg-bottom]:text-primary! [&>.svg-top]:text-muted-foreground/40!':
|
||||||
sortColumn === getColumnKey(column) &&
|
sortColumn === getColumnKey(column) &&
|
||||||
sortDirection === 'desc'
|
sortDirection === 'desc'
|
||||||
}"
|
}"
|
||||||
@@ -368,13 +369,13 @@ onMounted(() => {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
||||||
<TableBody class="[&_tr:last-child]:border-0! text-[#71717A]">
|
<TableBody class="[&_tr:last-child]:border-0! text-muted-foreground">
|
||||||
<template
|
<template
|
||||||
v-for="(row, rowIndex) in finalData"
|
v-for="(row, rowIndex) in finalData"
|
||||||
:key="row.id || rowIndex"
|
:key="row.id || rowIndex"
|
||||||
>
|
>
|
||||||
<TableRow
|
<TableRow
|
||||||
class="relative group border-[#0088FE1a] hover:bg-[#ECF2FF] text-inherit"
|
class="relative group border-border hover:bg-muted/50 text-inherit"
|
||||||
:class="{
|
:class="{
|
||||||
'cursor-pointer':
|
'cursor-pointer':
|
||||||
rowAppendExpandable && hasRowAppend(row as T, rowIndex)
|
rowAppendExpandable && hasRowAppend(row as T, rowIndex)
|
||||||
@@ -421,7 +422,7 @@ onMounted(() => {
|
|||||||
class="flex items-center justify-center transition-transform duration-250"
|
class="flex items-center justify-center transition-transform duration-250"
|
||||||
:class="{ 'rotate-90': isRowExpanded(row as T, rowIndex) }"
|
:class="{ 'rotate-90': isRowExpanded(row as T, rowIndex) }"
|
||||||
>
|
>
|
||||||
<PlayIcon class="size-2.5! text-blue-[71717a]" />
|
<PlayIcon class="size-2.5! text-muted-foreground" />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<slot
|
<slot
|
||||||
@@ -454,7 +455,7 @@ onMounted(() => {
|
|||||||
) &&
|
) &&
|
||||||
(rowAppendVisible?.(row as T, rowIndex) ?? true)
|
(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="{
|
:class="{
|
||||||
'visible [&>td>div]:max-h-0 [&>td>div]:opacity-0 border-transparent ':
|
'visible [&>td>div]:max-h-0 [&>td>div]:opacity-0 border-transparent ':
|
||||||
!isRowExpanded(row as T, rowIndex)
|
!isRowExpanded(row as T, rowIndex)
|
||||||
@@ -526,7 +527,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<!-- 加载状态 -->
|
<!-- 加载状态 -->
|
||||||
<template v-if="props.load && displayData.length !== 0">
|
<template v-if="props.load && displayData.length !== 0">
|
||||||
<div class="absolute inset-0 bg-white/40 z-10" />
|
<div class="absolute inset-0 bg-background/40 z-10" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<TableFooter :colspan="visibleColumns?.length">
|
<TableFooter :colspan="visibleColumns?.length">
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<!-- app/components/dashboard/QaRecordDetail.vue - 最近问答记录详情弹窗 -->
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { useAnswerStore } from "@/stores/answer";
|
||||||
|
|
||||||
|
const answerStore = useAnswerStore();
|
||||||
|
const { selectedRecord } = storeToRefs(answerStore);
|
||||||
|
|
||||||
|
/** 关闭详情弹窗,只清理 store 中的选中记录 */
|
||||||
|
const close = () => {
|
||||||
|
answerStore.selectRecord(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Dialog 关闭时同步清理选中的问答记录 */
|
||||||
|
const dialogOpen = computed({
|
||||||
|
get: () => Boolean(selectedRecord.value),
|
||||||
|
set: (open: boolean) => {
|
||||||
|
if (!open) close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog v-model:open="dialogOpen">
|
||||||
|
<DialogContent class="max-h-[86vh] overflow-hidden sm:max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>问答详情</DialogTitle>
|
||||||
|
<DialogDescription>{{ selectedRecord?.time }}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div v-if="selectedRecord" class="grid max-h-[70vh] gap-4 overflow-auto pr-1">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<span class="text-xs font-medium text-muted-foreground">题目</span>
|
||||||
|
<pre
|
||||||
|
class="whitespace-pre-wrap rounded-2xl bg-muted p-3 text-sm leading-6 text-foreground"
|
||||||
|
>{{ selectedRecord.question }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<span class="text-xs font-medium text-muted-foreground">选项</span>
|
||||||
|
<pre
|
||||||
|
class="whitespace-pre-wrap rounded-2xl bg-muted p-3 text-sm leading-6 text-foreground"
|
||||||
|
>{{ selectedRecord.options || "-" }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<span class="text-xs font-medium text-muted-foreground">答案</span>
|
||||||
|
<pre
|
||||||
|
class="whitespace-pre-wrap rounded-2xl bg-muted p-3 text-base font-semibold leading-7 text-foreground"
|
||||||
|
>{{ selectedRecord.answer }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<!-- app/components/dashboard/QaRecordsTable.vue - 最近问答记录表格,基于已准备好的 TableCom -->
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Eye } from "lucide-vue-next";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
|
||||||
|
import TableCom from "@/components/Table/TableCom.vue";
|
||||||
|
import type { TableColumn } from "@/components/Table/type";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import type { IQaRecord } from "@/interfaces";
|
||||||
|
import { QUESTION_TYPE_LABELS, useAnswerStore } from "@/stores/answer";
|
||||||
|
|
||||||
|
const answerStore = useAnswerStore();
|
||||||
|
const {
|
||||||
|
pagedRecords,
|
||||||
|
recordCount,
|
||||||
|
recordsLoading,
|
||||||
|
recordsPage,
|
||||||
|
recordsPageSize
|
||||||
|
} = storeToRefs(answerStore);
|
||||||
|
|
||||||
|
/** TableCom 列配置集中在组件顶部,便于和其他项目的表格风格保持一致 */
|
||||||
|
const columns = computed<TableColumn[]>(() => [
|
||||||
|
{
|
||||||
|
label: "时间",
|
||||||
|
slot: "time",
|
||||||
|
width: 170
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "题型",
|
||||||
|
slot: "type",
|
||||||
|
width: 100,
|
||||||
|
cellAlign: "center",
|
||||||
|
headerAlign: "center"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "题目",
|
||||||
|
slot: "question",
|
||||||
|
minWidth: 280
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "选项",
|
||||||
|
slot: "options",
|
||||||
|
minWidth: 220
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "答案",
|
||||||
|
slot: "answer",
|
||||||
|
minWidth: 160
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "操作",
|
||||||
|
slot: "action",
|
||||||
|
width: 90,
|
||||||
|
cellAlign: "center",
|
||||||
|
headerAlign: "center"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** 题型英文值兜底展示原值,避免未来 OCS 扩展题型时表格为空 */
|
||||||
|
const getQuestionTypeLabel = (type: string) => {
|
||||||
|
return QUESTION_TYPE_LABELS[type] || type || "自动判断";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 空选项统一展示占位,不在表格中撑开大段空白 */
|
||||||
|
const displayOptions = (record: IQaRecord) => {
|
||||||
|
return record.options || "-";
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>最近问答记录</CardTitle>
|
||||||
|
<CardDescription>当前进程最多保留最近 100 条。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<TableCom
|
||||||
|
:data="pagedRecords"
|
||||||
|
:columns="columns"
|
||||||
|
:load="recordsLoading"
|
||||||
|
:buffer="true"
|
||||||
|
:pagination="{
|
||||||
|
currentPage: recordsPage,
|
||||||
|
total: recordCount,
|
||||||
|
pageSize: recordsPageSize,
|
||||||
|
align: 'end'
|
||||||
|
}"
|
||||||
|
cell-align="left"
|
||||||
|
header-align="start"
|
||||||
|
cell-class-name="align-top"
|
||||||
|
@page:change="answerStore.setRecordsPage"
|
||||||
|
>
|
||||||
|
<template #time="{ value }">
|
||||||
|
<span class="whitespace-nowrap text-foreground">{{ value }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #type="{ value }">
|
||||||
|
<Badge variant="secondary" class="text-center">
|
||||||
|
{{ getQuestionTypeLabel(value) }}
|
||||||
|
</Badge>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #question="{ value }">
|
||||||
|
<div class="max-w-136 truncate text-foreground" :title="value">
|
||||||
|
{{ value }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #options="{ row }">
|
||||||
|
<div
|
||||||
|
class="max-w-md truncate text-muted-foreground"
|
||||||
|
:title="displayOptions(row)"
|
||||||
|
>
|
||||||
|
{{ displayOptions(row) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #answer="{ value }">
|
||||||
|
<div
|
||||||
|
class="max-w-[20rem] truncate font-medium text-foreground"
|
||||||
|
:title="value"
|
||||||
|
>
|
||||||
|
{{ value }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #action="{ row }">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
title="查看详情"
|
||||||
|
@click.stop="answerStore.selectRecord(row)"
|
||||||
|
>
|
||||||
|
<Eye class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</TableCom>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<!-- app/components/dashboard/StatsOverview.vue - Dashboard 顶部状态卡片 -->
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { RefreshCw, Trash2 } from "lucide-vue-next";
|
||||||
|
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: stats.value?.cache_enabled ? "缓存已启用" : "缓存未启用",
|
||||||
|
loading: statsLoading.value && !stats.value
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "缓存数量",
|
||||||
|
value: stats.value?.cache_size?.toString() ?? "0",
|
||||||
|
detail: "当前进程",
|
||||||
|
loading: statsLoading.value && !stats.value
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "问答记录",
|
||||||
|
value: stats.value?.qa_records_count?.toString() ?? "0",
|
||||||
|
detail: uptimeText.value,
|
||||||
|
loading: statsLoading.value && !stats.value
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="grid gap-4">
|
||||||
|
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold text-foreground">Dashboard</h1>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
当前 Nuxt 进程的健康状态、缓存和最近问答记录。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:disabled="statsLoading"
|
||||||
|
@click="answerStore.loadDashboard"
|
||||||
|
>
|
||||||
|
<RefreshCw class="size-4" :class="{ 'animate-spin': statsLoading }" />
|
||||||
|
<span>刷新</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
:disabled="cacheClearing"
|
||||||
|
@click="answerStore.clearCache"
|
||||||
|
>
|
||||||
|
<Trash2 class="size-4" />
|
||||||
|
<span>{{ cacheClearing ? "清理中" : "清空缓存" }}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<!-- app/components/docs/ApiDocsContent.vue - 前端文档页内容,覆盖旧 Flask /docs 页面 -->
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
|
/** 文档示例保持本地接口字段名,避免把真实 token 或 key 写进前端 */
|
||||||
|
const searchExample = `GET /api/search?title=中国的首都是哪个城市?&type=single&options=A. 北京\\nB. 上海`;
|
||||||
|
|
||||||
|
const successExample = `{
|
||||||
|
"code": 1,
|
||||||
|
"question": "中国的首都是哪个城市?",
|
||||||
|
"answer": "A"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const endpoints = [
|
||||||
|
{
|
||||||
|
method: "GET/POST",
|
||||||
|
path: "/api/search",
|
||||||
|
auth: "可选",
|
||||||
|
description: "OCS AnswererWrapper 查询入口"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/health",
|
||||||
|
auth: "否",
|
||||||
|
description: "服务健康检查"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/stats",
|
||||||
|
auth: "可选",
|
||||||
|
description: "运行统计和缓存数量"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/records",
|
||||||
|
auth: "可选",
|
||||||
|
description: "最近问答记录"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/cache/clear",
|
||||||
|
auth: "可选",
|
||||||
|
description: "清空当前进程内缓存"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="grid gap-5">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-xl">API 文档</CardTitle>
|
||||||
|
<CardDescription class="leading-6">
|
||||||
|
响应保持 OCS AnswererWrapper 兼容:成功返回
|
||||||
|
<Badge variant="secondary">code: 1</Badge>
|
||||||
|
,失败返回
|
||||||
|
<Badge variant="secondary">code: 0</Badge>
|
||||||
|
。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>接口列表</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="overflow-auto">
|
||||||
|
<Table class="min-w-[720px]">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>方法</TableHead>
|
||||||
|
<TableHead>路径</TableHead>
|
||||||
|
<TableHead>Token</TableHead>
|
||||||
|
<TableHead>说明</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow v-for="item in endpoints" :key="item.path">
|
||||||
|
<TableCell>{{ item.method }}</TableCell>
|
||||||
|
<TableCell class="font-mono text-xs text-foreground">
|
||||||
|
{{ item.path }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{{ item.auth }}</TableCell>
|
||||||
|
<TableCell>{{ item.description }}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<section class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>请求示例</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<pre
|
||||||
|
class="overflow-auto rounded-2xl bg-muted p-4 text-xs leading-5 text-muted-foreground"
|
||||||
|
><code>{{ searchExample }}</code></pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>成功响应</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<pre
|
||||||
|
class="overflow-auto rounded-2xl bg-muted p-4 text-xs leading-5 text-muted-foreground"
|
||||||
|
><code>{{ successExample }}</code></pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<!-- app/components/home/AnswerForm.vue - 答题提交表单,状态全部来自 Pinia store -->
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Search } from "lucide-vue-next";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import type { QuestionType } from "@/interfaces";
|
||||||
|
import { QUESTION_TYPE_OPTIONS, useAnswerStore } from "@/stores/answer";
|
||||||
|
|
||||||
|
const answerStore = useAnswerStore();
|
||||||
|
const { question, questionType, options, searchLoading } =
|
||||||
|
storeToRefs(answerStore);
|
||||||
|
const AUTO_TYPE_VALUE = "__auto__";
|
||||||
|
|
||||||
|
/** shadcn Select 不使用空字符串作为 item value,这里只在组件边界做一次映射 */
|
||||||
|
const selectedQuestionType = computed({
|
||||||
|
get: () => questionType.value || AUTO_TYPE_VALUE,
|
||||||
|
set: (value: string) => {
|
||||||
|
questionType.value =
|
||||||
|
value === AUTO_TYPE_VALUE ? "" : (value as QuestionType);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 表单提交只触发 store action,组件不直接接触请求服务 */
|
||||||
|
const onSubmit = () => {
|
||||||
|
void answerStore.searchAnswer();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-xl">题目查询</CardTitle>
|
||||||
|
<CardDescription
|
||||||
|
>服务端流式请求模型,前端返回普通 JSON 结果。</CardDescription
|
||||||
|
>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<form class="grid gap-4" @submit.prevent="onSubmit">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="answer-question">题目</Label>
|
||||||
|
<Textarea
|
||||||
|
id="answer-question"
|
||||||
|
v-model="question"
|
||||||
|
class="min-h-32 resize-y"
|
||||||
|
placeholder="输入题目内容"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid gap-4 md:grid-cols-[220px_minmax(0,1fr)] md:items-start"
|
||||||
|
>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="answer-type">题型</Label>
|
||||||
|
<Select v-model="selectedQuestionType">
|
||||||
|
<SelectTrigger id="answer-type" class="w-full">
|
||||||
|
<SelectValue placeholder="自动判断" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem
|
||||||
|
v-for="item in QUESTION_TYPE_OPTIONS"
|
||||||
|
:key="item.value || AUTO_TYPE_VALUE"
|
||||||
|
:value="item.value || AUTO_TYPE_VALUE"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="answer-options">选项</Label>
|
||||||
|
<Textarea
|
||||||
|
id="answer-options"
|
||||||
|
v-model="options"
|
||||||
|
class="min-h-24 resize-y"
|
||||||
|
placeholder="A. 选项一 B. 选项二"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end">
|
||||||
|
<Button type="submit" :disabled="searchLoading">
|
||||||
|
<Search class="size-4" />
|
||||||
|
<span>{{ searchLoading ? "查询中" : "获取答案" }}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<!-- app/components/home/AnswerResult.vue - 答题结果展示,只展示安全业务文案 -->
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { AlertCircle, CheckCircle2, Loader2 } from "lucide-vue-next";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
AlertTitle
|
||||||
|
} from "@/components/ui/alert";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardAction,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useAnswerStore } from "@/stores/answer";
|
||||||
|
|
||||||
|
const answerStore = useAnswerStore();
|
||||||
|
const { answerResult, searchErrorMessage, searchLoading } = storeToRefs(answerStore);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>查询结果</CardTitle>
|
||||||
|
<CardAction>
|
||||||
|
<Badge variant="secondary">OCS JSON</Badge>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<div v-if="searchLoading" class="grid min-h-32 gap-3">
|
||||||
|
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 class="size-4 animate-spin" />
|
||||||
|
<span>正在获取答案</span>
|
||||||
|
</div>
|
||||||
|
<Skeleton class="h-5 w-2/3" />
|
||||||
|
<Skeleton class="h-20 w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert v-else-if="searchErrorMessage" variant="destructive" class="min-h-32">
|
||||||
|
<AlertCircle class="size-4" />
|
||||||
|
<AlertTitle>查询失败</AlertTitle>
|
||||||
|
<AlertDescription>{{ searchErrorMessage }}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div v-else-if="answerResult" class="grid gap-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||||
|
<CheckCircle2 class="size-4" />
|
||||||
|
<span>已返回答案</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2 rounded-2xl border bg-muted/40 p-4">
|
||||||
|
<span class="text-xs font-medium text-muted-foreground">题目</span>
|
||||||
|
<p class="whitespace-pre-wrap text-sm leading-6 text-foreground">
|
||||||
|
{{ answerResult.question }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2 rounded-2xl border bg-muted/40 p-4">
|
||||||
|
<span class="text-xs font-medium text-muted-foreground">答案</span>
|
||||||
|
<p class="whitespace-pre-wrap text-base font-semibold leading-7 text-foreground">
|
||||||
|
{{ answerResult.answer }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex min-h-32 items-center justify-center rounded-2xl border border-dashed text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
等待题目提交
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<!-- app/components/home/OcsConfigCard.vue - OCS AnswererWrapper 配置片段 -->
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Check, Copy } from "lucide-vue-next";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardAction,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
const origin = ref("http://localhost:3000");
|
||||||
|
const copied = ref(false);
|
||||||
|
|
||||||
|
/** 浏览器端读取当前站点地址,避免文档里写死 localhost 或生产域名 */
|
||||||
|
onMounted(() => {
|
||||||
|
origin.value = window.location.origin;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 与旧 Python 项目的 ocs_config_example.json 保持同样字段 */
|
||||||
|
const configText = computed(() => {
|
||||||
|
return JSON.stringify(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: "AI智能题库",
|
||||||
|
homepage: origin.value,
|
||||||
|
url: `${origin.value}/api/search`,
|
||||||
|
method: "get",
|
||||||
|
contentType: "json",
|
||||||
|
data: {
|
||||||
|
title: "${title}",
|
||||||
|
type: "${type}",
|
||||||
|
options: "${options}"
|
||||||
|
},
|
||||||
|
handler:
|
||||||
|
"return (res)=> res.code === 1 ? [res.question, res.answer] : [res.msg, undefined]"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 复制配置片段,失败时不打断主流程 */
|
||||||
|
const copyConfig = async () => {
|
||||||
|
await navigator.clipboard.writeText(configText.value).catch(() => undefined);
|
||||||
|
copied.value = true;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
copied.value = false;
|
||||||
|
}, 1200);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>OCS 配置</CardTitle>
|
||||||
|
<CardAction>
|
||||||
|
<Button type="button" variant="outline" size="sm" @click="copyConfig">
|
||||||
|
<Check v-if="copied" class="size-3.5" />
|
||||||
|
<Copy v-else class="size-3.5" />
|
||||||
|
<span>{{ copied ? "已复制" : "复制" }}</span>
|
||||||
|
</Button>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<pre
|
||||||
|
class="max-h-[30rem] overflow-auto rounded-2xl bg-muted p-4 text-xs leading-5 text-muted-foreground"
|
||||||
|
><code>{{ configText }}</code></pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<!-- app/components/layout/AppFooter.vue - 全局页脚,只保留服务状态类信息 -->
|
||||||
|
<template>
|
||||||
|
<footer class="border-t bg-background">
|
||||||
|
<div
|
||||||
|
class="mx-auto flex w-full max-w-7xl flex-col gap-2 px-4 py-5 text-xs text-muted-foreground md:flex-row md:items-center md:justify-between md:px-6"
|
||||||
|
>
|
||||||
|
<span>OCS AI answer service</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<!-- app/components/layout/AppHeader.vue - 顶部导航和页面切换入口 -->
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Activity, BookOpenText, LayoutDashboard, Search } from "lucide-vue-next";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
/** 顶部导航项,icon 使用 lucide,和项目里 shadcn 的图标体系保持一致 */
|
||||||
|
const navItems = [
|
||||||
|
{
|
||||||
|
label: "答题",
|
||||||
|
to: "/",
|
||||||
|
icon: Search
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Dashboard",
|
||||||
|
to: "/dashboard",
|
||||||
|
icon: LayoutDashboard
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "文档",
|
||||||
|
to: "/docs",
|
||||||
|
icon: BookOpenText
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
/** 当前路由高亮,Dashboard 的子路径也归到 Dashboard 导航项 */
|
||||||
|
const isActive = (to: string) => {
|
||||||
|
if (to === "/") return route.path === "/";
|
||||||
|
return route.path.startsWith(to);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||||
|
<div
|
||||||
|
class="mx-auto flex min-h-16 w-full max-w-7xl items-center justify-between gap-4 px-4 md:px-6"
|
||||||
|
>
|
||||||
|
<NuxtLink to="/" class="flex min-w-0 items-center gap-3">
|
||||||
|
<span
|
||||||
|
class="flex size-9 shrink-0 items-center justify-center rounded-2xl bg-primary text-primary-foreground"
|
||||||
|
>
|
||||||
|
<Activity class="size-5" />
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0">
|
||||||
|
<span class="block truncate text-base font-semibold text-foreground">
|
||||||
|
OCS AI 答题服务
|
||||||
|
</span>
|
||||||
|
<span class="block truncate text-xs text-muted-foreground">
|
||||||
|
Nuxt API Runtime
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<nav class="flex shrink-0 items-center gap-1 overflow-x-auto">
|
||||||
|
<Button
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.to"
|
||||||
|
as-child
|
||||||
|
:variant="isActive(item.to) ? 'secondary' : 'ghost'"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<NuxtLink :to="item.to" class="gap-2">
|
||||||
|
<component :is="item.icon" class="size-4" />
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</Button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import type { AlertVariants } from '.'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { alertVariants } from '.'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
variant?: AlertVariants['variant']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
:class="cn(alertVariants({ variant }), props.class)"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="alert-action"
|
||||||
|
:class="cn('absolute top-2.5 right-3', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
:class="cn('text-muted-foreground text-sm text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
:class="cn('font-medium group-has-[>svg]/alert:col-start-2 cn-font-heading [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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<typeof alertVariants>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import type { BadgeVariants } from '.'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { Primitive } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { badgeVariants } from '.'
|
||||||
|
|
||||||
|
const props = defineProps<PrimitiveProps & {
|
||||||
|
variant?: BadgeVariants['variant']
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
data-slot="badge"
|
||||||
|
:data-variant="variant"
|
||||||
|
:class="cn(badgeVariants({ variant }), props.class)"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
@@ -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<typeof badgeVariants>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
size?: 'default' | 'sm'
|
||||||
|
}>(), {
|
||||||
|
size: 'default',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
:data-size="size"
|
||||||
|
:class="cn('bg-card text-card-foreground ring-foreground/5 dark:ring-foreground/10 gap-6 overflow-hidden rounded-4xl py-6 text-sm shadow-md ring-1 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-4xl *:[img:last-child]:rounded-b-4xl group/card flex flex-col', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
:class="cn('px-6 group-data-[size=sm]/card:px-4', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
:class="cn('rounded-b-4xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4 flex items-center', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
:class="cn('gap-1.5 rounded-t-4xl px-6 group-data-[size=sm]/card:px-4 [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
:class="cn('text-base font-medium cn-font-heading', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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'
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
|
||||||
|
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DialogRootProps>()
|
||||||
|
const emits = defineEmits<DialogRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="dialog"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</DialogRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogCloseProps } from 'reka-ui'
|
||||||
|
import { DialogClose } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DialogCloseProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogClose
|
||||||
|
data-slot="dialog-close"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogClose>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { XIcon } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import DialogOverlay from './DialogOverlay.vue'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes['class'], showCloseButton?: boolean }>(), {
|
||||||
|
showCloseButton: true,
|
||||||
|
})
|
||||||
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogContent
|
||||||
|
data-slot="dialog-content"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
:class="cn('bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/5 dark:ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-6 rounded-4xl p-6 text-sm shadow-xl ring-1 duration-100 sm:max-w-md fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<DialogClose
|
||||||
|
v-if="showCloseButton"
|
||||||
|
data-slot="dialog-close"
|
||||||
|
as-child
|
||||||
|
>
|
||||||
|
<Button variant="ghost" class="absolute top-4 right-4 bg-secondary" size="icon-sm">
|
||||||
|
<XIcon />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogPortal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogDescriptionProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { DialogDescription, useForwardProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogDescription
|
||||||
|
data-slot="dialog-description"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogDescription>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { DialogClose } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}>(), {
|
||||||
|
showCloseButton: false,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<DialogClose v-if="showCloseButton" as-child>
|
||||||
|
<Button variant="outline">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
:class="cn('gap-1.5 flex flex-col', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogOverlayProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { DialogOverlay } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogOverlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-sm fixed inset-0 isolate z-50', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogOverlay>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { XIcon } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay
|
||||||
|
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
@pointer-down-outside="(event) => {
|
||||||
|
const originalEvent = event.detail.originalEvent;
|
||||||
|
const target = originalEvent.target as HTMLElement;
|
||||||
|
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<DialogClose
|
||||||
|
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||||
|
>
|
||||||
|
<XIcon class="w-4 h-4" />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogOverlay>
|
||||||
|
</DialogPortal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogTitleProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { DialogTitle, useForwardProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTitle
|
||||||
|
data-slot="dialog-title"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('text-base leading-none font-medium cn-font-heading', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogTitle>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogTriggerProps } from 'reka-ui'
|
||||||
|
import { DialogTrigger } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DialogTriggerProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTrigger
|
||||||
|
data-slot="dialog-trigger"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogTrigger>
|
||||||
|
</template>
|
||||||
@@ -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'
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { useVModel } from '@vueuse/core'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
defaultValue?: string | number
|
||||||
|
modelValue?: string | number
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
(e: 'update:modelValue', payload: string | number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modelValue = useVModel(props, 'modelValue', emits, {
|
||||||
|
passive: true,
|
||||||
|
defaultValue: props.defaultValue,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<input
|
||||||
|
v-model="modelValue"
|
||||||
|
data-slot="input"
|
||||||
|
:class="cn(
|
||||||
|
'bg-input/50 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 h-9 rounded-3xl border px-3 py-1 text-base transition-[color,box-shadow,background-color] file:h-7 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as Input } from './Input.vue'
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { LabelProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { Label } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Label
|
||||||
|
data-slot="label"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Label>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as Label } from './Label.vue'
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectRootEmits, SelectRootProps } from 'reka-ui'
|
||||||
|
import { SelectRoot, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<SelectRootProps>()
|
||||||
|
const emits = defineEmits<SelectRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="select"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</SelectRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectContentEmits, SelectContentProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import {
|
||||||
|
SelectContent,
|
||||||
|
SelectPortal,
|
||||||
|
SelectViewport,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { SelectScrollDownButton, SelectScrollUpButton } from '.'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>(),
|
||||||
|
{
|
||||||
|
position: 'item-aligned',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const emits = defineEmits<SelectContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectPortal>
|
||||||
|
<SelectContent
|
||||||
|
data-slot="select-content"
|
||||||
|
:data-align-trigger="position === 'item-aligned'"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
:class="cn(
|
||||||
|
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 dark:ring-foreground/10 min-w-36 rounded-3xl shadow-lg ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 cn-menu-translucent relative z-50 max-h-(--reka-select-content-available-height) origin-(--reka-select-content-transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none',
|
||||||
|
position === 'popper'
|
||||||
|
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectViewport
|
||||||
|
:data-position="position"
|
||||||
|
:class="cn(
|
||||||
|
'data-[position=popper]:h-[var(--reka-select-trigger-height)] data-[position=popper]:w-full data-[position=popper]:min-w-[var(--reka-select-trigger-width)]',
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectViewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectContent>
|
||||||
|
</SelectPortal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectGroupProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { SelectGroup } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<SelectGroupProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectGroup
|
||||||
|
data-slot="select-group"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('scroll-my-1.5 p-1.5', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectGroup>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectItemProps } from 'reka-ui'
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { CheckIcon } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
SelectItem,
|
||||||
|
SelectItemIndicator,
|
||||||
|
SelectItemText,
|
||||||
|
useForwardProps,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<SelectItemProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectItem
|
||||||
|
data-slot="select-item"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2.5 rounded-2xl py-2 pr-8 pl-3 text-sm font-medium [&_svg:not([class*=size-])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span class="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||||
|
<SelectItemIndicator>
|
||||||
|
<slot name="indicator-icon">
|
||||||
|
<CheckIcon class="pointer-events-none" />
|
||||||
|
</slot>
|
||||||
|
</SelectItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectItemText>
|
||||||
|
<slot />
|
||||||
|
</SelectItemText>
|
||||||
|
</SelectItem>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectItemTextProps } from 'reka-ui'
|
||||||
|
import { SelectItemText } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<SelectItemTextProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectItemText
|
||||||
|
data-slot="select-item-text"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectItemText>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectLabelProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { SelectLabel } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectLabel
|
||||||
|
data-slot="select-label"
|
||||||
|
:class="cn('text-muted-foreground px-3 py-2.5 text-xs', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectLabel>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectScrollDownButtonProps } from 'reka-ui'
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { ChevronDownIcon } from 'lucide-vue-next'
|
||||||
|
import { SelectScrollDownButton, useForwardProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<ChevronDownIcon />
|
||||||
|
</slot>
|
||||||
|
</SelectScrollDownButton>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectScrollUpButtonProps } from 'reka-ui'
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { ChevronUpIcon } from 'lucide-vue-next'
|
||||||
|
import { SelectScrollUpButton, useForwardProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<ChevronUpIcon />
|
||||||
|
</slot>
|
||||||
|
</SelectScrollUpButton>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectSeparatorProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { SelectSeparator } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectSeparator
|
||||||
|
data-slot="select-separator"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('bg-border -mx-1.5 my-1.5 h-px pointer-events-none', props.class)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectTriggerProps } from 'reka-ui'
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { ChevronDownIcon } from 'lucide-vue-next'
|
||||||
|
import { SelectIcon, SelectTrigger, useForwardProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<SelectTriggerProps & { class?: HTMLAttributes['class'], size?: 'sm' | 'default' }>(),
|
||||||
|
{ size: 'default' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class', 'size')
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectTrigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
:data-size="size"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn(
|
||||||
|
'bg-input/50 border-transparent data-placeholder:text-muted-foreground 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 gap-1.5 rounded-3xl border py-2 px-3 text-sm transition-[color,box-shadow,background-color] focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*=size-])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<SelectIcon as-child>
|
||||||
|
<ChevronDownIcon class="text-muted-foreground size-4 pointer-events-none" />
|
||||||
|
</SelectIcon>
|
||||||
|
</SelectTrigger>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectValueProps } from 'reka-ui'
|
||||||
|
import { SelectValue } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<SelectValueProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectValue
|
||||||
|
data-slot="select-value"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectValue>
|
||||||
|
</template>
|
||||||
@@ -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'
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface SkeletonProps {
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<SkeletonProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
:class="cn('bg-muted rounded-2xl animate-pulse', props.class)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as Skeleton } from './Skeleton.vue'
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { useVModel } from '@vueuse/core'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
defaultValue?: string | number
|
||||||
|
modelValue?: string | number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
(e: 'update:modelValue', payload: string | number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modelValue = useVModel(props, 'modelValue', emits, {
|
||||||
|
passive: true,
|
||||||
|
defaultValue: props.defaultValue,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<textarea
|
||||||
|
v-model="modelValue"
|
||||||
|
data-slot="textarea"
|
||||||
|
:class="cn('bg-input/50 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 resize-none rounded-2xl border px-3 py-3 text-base transition-[color,box-shadow,background-color] focus-visible:ring-3 aria-invalid:ring-3 md:text-sm flex field-sizing-content min-h-16 w-full outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', props.class)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as Textarea } from './Textarea.vue'
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// app/interfaces/index.ts - 前端共享类型统一出口
|
||||||
|
export * from "./answer";
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!-- app/layouts/default.vue - 页面外壳,负责组合导航、内容区和页脚 -->
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import AppFooter from "@/components/layout/AppFooter.vue";
|
||||||
|
import AppHeader from "@/components/layout/AppHeader.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background text-foreground">
|
||||||
|
<AppHeader />
|
||||||
|
<main class="mx-auto min-h-[calc(100vh-8rem)] w-full max-w-7xl px-4 py-6 md:px-6">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
<AppFooter />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<!-- app/pages/dashboard.vue - 运行状态与最近问答记录页面 -->
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import QaRecordDetail from "@/components/dashboard/QaRecordDetail.vue";
|
||||||
|
import QaRecordsTable from "@/components/dashboard/QaRecordsTable.vue";
|
||||||
|
import StatsOverview from "@/components/dashboard/StatsOverview.vue";
|
||||||
|
import { useAnswerStore } from "@/stores/answer";
|
||||||
|
|
||||||
|
const answerStore = useAnswerStore();
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: "Dashboard - OCS AI 答题服务"
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户端 ready 后加载 Dashboard 数据
|
||||||
|
*
|
||||||
|
* Dashboard 数据来自当前运行中的 Nitro 进程;放到客户端 ready 阶段可以避免
|
||||||
|
* `nuxt build` 在构建期等待运行态请求,同时刷新页面后仍会自动拉取真实状态。
|
||||||
|
*/
|
||||||
|
if (import.meta.client) {
|
||||||
|
onNuxtReady(() => {
|
||||||
|
void answerStore.loadDashboard();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid gap-6">
|
||||||
|
<StatsOverview />
|
||||||
|
<QaRecordsTable />
|
||||||
|
<QaRecordDetail />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!-- app/pages/docs.vue - API 文档页,替代旧 Flask /docs 模板 -->
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import ApiDocsContent from "@/components/docs/ApiDocsContent.vue";
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: "API 文档 - OCS AI 答题服务"
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ApiDocsContent />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<!-- app/pages/index.vue - 首页直接提供可用的答题工作台 -->
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import AnswerForm from "@/components/home/AnswerForm.vue";
|
||||||
|
import AnswerResult from "@/components/home/AnswerResult.vue";
|
||||||
|
import OcsConfigCard from "@/components/home/OcsConfigCard.vue";
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: "OCS AI 答题服务"
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_420px]">
|
||||||
|
<div class="grid gap-5">
|
||||||
|
<AnswerForm />
|
||||||
|
<AnswerResult />
|
||||||
|
</div>
|
||||||
|
<OcsConfigCard />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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<ISearchResponse, ISearchRequest>(
|
||||||
|
`${AnswerService.basePath}/search`,
|
||||||
|
request
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取健康状态;无需 token */
|
||||||
|
public static getHealth() {
|
||||||
|
return BaseClientService.get<IHealthResponse>(
|
||||||
|
`${AnswerService.basePath}/health`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取运行统计;配置 ACCESS_TOKEN 时由服务端校验 */
|
||||||
|
public static getStats() {
|
||||||
|
return BaseClientService.get<IStatsResponse>(
|
||||||
|
`${AnswerService.basePath}/stats`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取最近问答记录,供 Dashboard 表格展示 */
|
||||||
|
public static getRecords() {
|
||||||
|
return BaseClientService.get<IRecordsResponse>(
|
||||||
|
`${AnswerService.basePath}/records`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清空当前进程内的答题缓存 */
|
||||||
|
public static clearCache() {
|
||||||
|
return BaseClientService.post<IClearCacheResponse>(
|
||||||
|
`${AnswerService.basePath}/cache/clear`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// app/services/base_service.ts - 前端请求基础封装,保持和其他项目相近的调用风格
|
||||||
|
|
||||||
|
/** GET query 可接受的基础值类型 */
|
||||||
|
type QueryValue = string | number | boolean | null | undefined;
|
||||||
|
|
||||||
|
/** GET query 对象;空值会在请求前被过滤掉 */
|
||||||
|
type QueryParams = Record<string, QueryValue>;
|
||||||
|
|
||||||
|
/** 把 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<T>(url: string, query?: QueryParams) {
|
||||||
|
const data = await $fetch<T>(url, {
|
||||||
|
method: "GET",
|
||||||
|
query: cleanQuery(query)
|
||||||
|
});
|
||||||
|
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发起 POST 请求 */
|
||||||
|
public static async post<T, B extends object | undefined = undefined>(
|
||||||
|
url: string,
|
||||||
|
body?: B
|
||||||
|
) {
|
||||||
|
const data = await $fetch<T>(url, {
|
||||||
|
method: "POST",
|
||||||
|
body
|
||||||
|
});
|
||||||
|
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// app/services/index.ts - 前端 service 统一出口
|
||||||
|
export * from "./answer_service";
|
||||||
@@ -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<string, string> = {
|
||||||
|
"": "自动判断",
|
||||||
|
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<QuestionType>("");
|
||||||
|
const options = ref("");
|
||||||
|
const searchLoading = ref(false);
|
||||||
|
const answerResult = ref<ISearchSuccessResponse>();
|
||||||
|
const searchErrorMessage = ref("");
|
||||||
|
|
||||||
|
// ---- Dashboard 状态 ----
|
||||||
|
const health = ref<IHealthResponse>();
|
||||||
|
const stats = ref<IStatsResponse>();
|
||||||
|
const records = ref<IQaRecord[]>([]);
|
||||||
|
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<IQaRecord>();
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -58,6 +58,15 @@ export const addQaRecord = (record: Omit<QaRecord, "time" | "timestamp">) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最近问答记录
|
||||||
|
*
|
||||||
|
* 返回副本并按“最新在前”排序,避免 API handler 或前端展示逻辑误改内存原数组
|
||||||
|
*/
|
||||||
|
export const getQaRecords = () => {
|
||||||
|
return [...qaRecords].reverse();
|
||||||
|
};
|
||||||
|
|
||||||
/** 生成 `/api/stats` 响应,实时计算 uptime 和有效缓存数量 */
|
/** 生成 `/api/stats` 响应,实时计算 uptime 和有效缓存数量 */
|
||||||
export const getRuntimeStats = () => {
|
export const getRuntimeStats = () => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user