feat: 前端组件准备

This commit is contained in:
2026-05-22 15:02:31 +08:00
parent 397ccdca13
commit 5e05bf4f63
62 changed files with 6903 additions and 878 deletions
+22
View File
@@ -0,0 +1,22 @@
# OpenAI API 配置
OPENAI_API_KEY=your-api-key-here
OPENAI_API_BASE=https://api.openai.com/v1
# 模型设置
OPENAI_MODEL=gpt-3.5-turbo
MAX_TOKENS=500
TEMPERATURE=0.7
OPENAI_STREAM_MAX_SSE_LINE_BYTES=1048576
# 安全设置,可选
# ACCESS_TOKEN=your-access-token
# 缓存配置
ENABLE_CACHE=true
CACHE_EXPIRATION=86400
# 日志配置
LOG_LEVEL=INFO
# Prisma 配置,当前答题服务不依赖数据库,但项目保留 Prisma
DATABASE_URL="mysql://user:password@localhost:3306/database"
+3
View File
@@ -0,0 +1,3 @@
# .prettierignore
auto-imports.d.ts
components.d.ts
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"tabWidth": 2,
"singleQuote": false,
"printWidth": 80,
"trailingComma": "none",
"ignorePath": ".prettierignore"
}
+5
View File
@@ -0,0 +1,5 @@
{
"cSpell.words": [
"heroicons"
]
}
+106 -55
View File
@@ -1,75 +1,126 @@
# Nuxt Minimal Starter
# OCS Nuxt AI Answer Service
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
这是一个基于 Nuxt/Nitro 的 OCS AI 答题服务,迁移自旧版 Python Flask 服务。服务端使用 OpenAI 兼容的 Chat Completions 流式接口生成答案,对 OCS 仍返回普通 JSON。
## Setup
## 功能
Make sure to install dependencies:
- 兼容 OCS AnswererWrapper 题库接口。
- 支持 `GET`、JSON `POST`、表单 `POST` 调用 `/api/search`
- 支持单选、多选、判断、填空题提示词处理。
- 支持可选访问令牌校验。
- 支持内存缓存、健康检查、缓存清理和运行统计。
- OpenAI API Key 只在服务端读取,不返回给前端。
## 环境变量
复制 `.env.example``.env`,然后填写自己的配置:
```bash
# npm
npm install
cp .env.example .env
```
# pnpm
必填:
```env
OPENAI_API_KEY=your-api-key-here
```
常用配置:
```env
OPENAI_API_BASE=https://api.openai.com/v1
OPENAI_MODEL=gpt-3.5-turbo
MAX_TOKENS=500
TEMPERATURE=0.7
ENABLE_CACHE=true
CACHE_EXPIRATION=86400
```
如需限制访问:
```env
ACCESS_TOKEN=your-access-token
```
启用后,请求需要带 `X-Access-Token` 请求头或 `?token=...` 查询参数。
## 启动
```bash
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
默认 Nuxt dev server 地址为 `http://localhost:3000`
Build the application for production:
## OCS 配置示例
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```json
[
{
"name": "AI智能题库",
"homepage": "http://localhost:3000",
"url": "http://localhost:3000/api/search",
"method": "get",
"type": "GM_xmlhttpRequest",
"contentType": "json",
"data": {
"title": "${title}",
"type": "${type}",
"options": "${options}"
},
"handler": "return (res)=> res.code === 1 ? [res.question, res.answer] : [res.msg, undefined]"
}
]
```
Locally preview production build:
如果使用自定义域名,请按 OCS 文档把 `homepage``url` 涉及的域名加入脚本 `@connect`
```bash
# npm
npm run preview
## API
# pnpm
pnpm preview
### `GET|POST /api/search`
# yarn
yarn preview
参数:
# bun
bun run preview
| 参数 | 必填 | 说明 |
| --- | --- | --- |
| `title` | 是 | 题目内容 |
| `type` | 否 | `single``multiple``judgement``completion` |
| `options` | 否 | 选项文本 |
成功:
```json
{
"code": 1,
"question": "中国的首都是哪个城市?",
"answer": "北京"
}
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
失败:
```json
{
"code": 0,
"msg": "未提供问题内容"
}
```
### `GET /api/health`
返回服务状态、版本、缓存开关和模型名。
### `POST /api/cache/clear`
清空内存缓存。若配置了 `ACCESS_TOKEN`,需要携带访问令牌。
### `GET /api/stats`
返回 uptime、模型、缓存大小和最近问答记录数量。若配置了 `ACCESS_TOKEN`,需要携带访问令牌。
## 注意
- 多选题答案按 OCS 要求返回 `#` 分隔字符串。
- OpenAI 调用使用服务端流式请求,但接口对 OCS 返回普通 JSON。
- 内部或上游异常不会透传给 OCS,只返回本地错误文案。
+111
View File
@@ -0,0 +1,111 @@
# AI 题库服务 API 文档
## 概述
本服务是 Nuxt/Nitro 版 OCS AI 答题接口,兼容 OCS AnswererWrapper。服务端使用 OpenAI 兼容 Chat Completions 流式接口生成答案,最终返回 OCS 可直接处理的 JSON。
## 搜索接口
**URL**`/api/search`
**方法**`GET``POST`
`POST` 支持 JSON body、`application/x-www-form-urlencoded` body 和 `multipart/form-data` body。
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `title` | string | 是 | 题目内容 |
| `type` | string | 否 | `single``multiple``judgement``completion` |
| `options` | string | 否 | 选项内容 |
成功响应:
```json
{
"code": 1,
"question": "中国的首都是哪个城市?",
"answer": "北京"
}
```
失败响应:
```json
{
"code": 0,
"msg": "未提供问题内容"
}
```
## 健康检查
**URL**`/api/health`
**方法**`GET`
```json
{
"status": "ok",
"message": "AI题库服务运行正常",
"version": "1.1.0",
"cache_enabled": true,
"model": "gpt-3.5-turbo"
}
```
## 清空缓存
**URL**`/api/cache/clear`
**方法**`POST`
若配置了 `ACCESS_TOKEN`,需要通过 `X-Access-Token` 请求头或 `token` 查询参数传递。
```json
{
"success": true,
"message": "缓存已清除"
}
```
## 统计信息
**URL**`/api/stats`
**方法**`GET`
若配置了 `ACCESS_TOKEN`,需要通过 `X-Access-Token` 请求头或 `token` 查询参数传递。
```json
{
"version": "1.1.0",
"uptime": 123.45,
"model": "gpt-3.5-turbo",
"cache_enabled": true,
"cache_size": 1,
"qa_records_count": 1
}
```
## OCS 配置示例
```json
[
{
"name": "AI智能题库",
"homepage": "http://localhost:3000",
"url": "http://localhost:3000/api/search",
"method": "get",
"type": "GM_xmlhttpRequest",
"contentType": "json",
"data": {
"title": "${title}",
"type": "${type}",
"options": "${options}"
},
"handler": "return (res)=> res.code === 1 ? [res.question, res.answer] : [res.msg, undefined]"
}
]
```
多选题答案会返回 `A#B` 或答案文本 `北京#上海` 这类字符串,不返回数组。部署到自定义域名时,需要按 OCS 文档把相关域名加入脚本 `@connect`
+3 -1
View File
@@ -1,3 +1,5 @@
<template>
<NuxtPage />
<div>
<NuxtPage />
</div>
</template>
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" class="size-6">
<path class="svg-top" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor"
d="M8.25 9L12 5.25L15.75 9" />
<path class="svg-bottom" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor"
d="M8.25 15L12 18.75L15.75 15" />
</svg>

After

Width:  |  Height:  |  Size: 376 B

+1
View File
@@ -0,0 +1 @@
export { default as ChevronUpDownIcon } from "./ChevronUpDownIcon.svg";
+128
View File
@@ -0,0 +1,128 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans: "Inter Variable", sans-serif;
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.527 0.154 150.069);
--primary-foreground: oklch(0.982 0.018 155.826);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.871 0.15 154.449);
--chart-2: oklch(0.723 0.219 149.579);
--chart-3: oklch(0.627 0.194 149.214);
--chart-4: oklch(0.527 0.154 150.069);
--chart-5: oklch(0.448 0.119 151.328);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.627 0.194 149.214);
--sidebar-primary-foreground: oklch(0.982 0.018 155.826);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.448 0.119 151.328);
--primary-foreground: oklch(0.982 0.018 155.826);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.871 0.15 154.449);
--chart-2: oklch(0.723 0.219 149.579);
--chart-3: oklch(0.627 0.194 149.214);
--chart-4: oklch(0.527 0.154 150.069);
--chart-5: oklch(0.448 0.119 151.328);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.723 0.219 149.579);
--sidebar-primary-foreground: oklch(0.982 0.018 155.826);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
@apply font-sans;
}
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
}
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
const emit = defineEmits<{
(e: "change", changed: boolean): void;
}>();
let hideTimer: NodeJS.Timeout | null = null;
/** 是否发生变化 */
const hasChanged = ref(false);
/** 上一次的内容,用于对比变化 */
const previousContent = ref("");
/* 是否初次渲染 */
const isInitialized = ref(false);
const cellRef = useTemplateRef<HTMLElement | null>("cellRef");
const checkChange = async () => {
await nextTick();
if (!cellRef.value) return;
const currentContent = cellRef.value?.textContent?.trim() || "";
if (!isInitialized.value) {
// 首次渲染,记录原始值
previousContent.value = currentContent;
isInitialized.value = true;
hasChanged.value = false;
} else {
// 对比变化
hasChanged.value = currentContent !== previousContent.value;
emit("change", hasChanged.value);
}
};
watch(hasChanged, (newVal) => {
if (newVal) {
if (hideTimer) clearTimeout(hideTimer);
hideTimer = setTimeout(() => {
hasChanged.value = false;
emit("change", false);
previousContent.value = cellRef.value?.textContent?.trim() || "";
}, 1000);
}
});
let observer: MutationObserver | null = null;
watch(
cellRef,
(el) => {
observer?.disconnect();
if (el) {
checkChange();
observer = new MutationObserver(checkChange);
observer.observe(el, {
childList: true,
subtree: true,
characterData: true
});
}
},
{ immediate: true }
);
</script>
<template>
<div
ref="cellRef"
class="transition-colors duration-300"
:class="{ 'bg-[#ECF2FF] h-full w-full p-4 -my-4': hasChanged }"
>
<slot />
</div>
</template>
@@ -0,0 +1,76 @@
<script lang="ts" setup>
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/vue/24/outline";
import { MoreHorizontal } from "lucide-vue-next";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationNext,
PaginationPrevious
} from "@/components/ui/pagination";
import type { PaginationProps } from "./type";
const props = withDefaults(defineProps<PaginationProps & { load: boolean }>(), {
total: 0,
pageSize: 5,
currentPage: 1
});
const emits = defineEmits<{
"update:page": [page: number];
}>();
const pageCount = computed(() => Math.ceil(props.total / props.pageSize));
</script>
<template>
<!-- 分页器 -->
<Pagination
v-slot="{ page }"
:page="props.currentPage"
:items-per-page="props.pageSize"
:total="props.total"
:show-edges="true"
:disabled="props.load || pageCount <= 1"
class="select-none"
:class="`justify-${props.align || 'center'}`"
@update:page="(val: number) => emits('update:page', val)"
>
<PaginationContent v-slot="{ items }">
<PaginationPrevious class="w-5.5 h-5.5 text-[#2563EB]!">
<ChevronLeftIcon class="size-4" />
</PaginationPrevious>
<template v-for="(item, index) in items" :key="index">
<PaginationItem
v-if="item.type === 'page'"
:value="item.value"
:is-active="item.value === page"
class="w-fit min-w-5.5 h-5.5 text-xs px-1 text-[#71717A]/80"
:class="{
'text-[#2563EB]! border-[#2563EB]!': item.value === page
}"
>
{{ item.value }}
</PaginationItem>
<PaginationEllipsis
v-else-if="item.type === 'ellipsis'"
class="text-[#71717A]/80"
>
<Button
variant="outline"
:disabled="props.load"
class="w-5.5 h-5.5 p-0 text-[12px] text-[#71717A]/80 border-transparent bg-white rounded"
>
<MoreHorizontal class="size-4" />
</Button>
</PaginationEllipsis>
</template>
<PaginationNext class="w-5.5 h-5.5 text-[#2563EB]!">
<ChevronRightIcon class="size-4" />
</PaginationNext>
</PaginationContent>
</Pagination>
</template>
+559
View File
@@ -0,0 +1,559 @@
<!-- TableCom.vue -->
<script lang="ts" setup generic="T extends Record<string, any>">
import { PlayIcon } from "@heroicons/vue/24/solid";
import { ChevronUpDownIcon } from "@/assets/Icons";
import { cn } from "@/lib/utils";
import { useSortableTable } from "@/utils";
import type { TableColumn, TableProps } from "./type";
import {
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableEmpty,
TableRow
} from "@/components/ui/table";
const props = defineProps<TableProps<T>>();
const emits = defineEmits<{
"sort:change": [{ sortColumn: string; sortDirection: "asc" | "desc" | "" }];
"page:change": [page: number];
"row-expand:change": [{ row: T; index: number; expanded: boolean }];
}>();
const isMobile = useMediaQuery("(max-width: 640px)");
const $slots = useSlots();
const tableEl = useTemplateRef<HTMLDivElement>("tableEl");
const { x, arrivedState, measure } = useScroll(tableEl);
const { width } = useElementSize(tableEl);
// 显示的数据,避免加载时表格闪烁
const displayData = ref<T[]>([]);
// 监听数据加载完成,更新显示数据
watch(
[() => props.data, () => props.load],
([newData, loading], [_, oldLoading]) => {
// 如果不启用数据缓冲,直接更新显示数据
if (!props.buffer) {
displayData.value = newData;
}
// 只有当加载状态从 true 变为 false 时才更新显示数据
if (oldLoading === true && loading === false) {
displayData.value = newData;
}
// 首次加载完成
if (oldLoading === undefined && loading === false && newData.length > 0) {
displayData.value = newData;
}
}
);
const defaultSortColumn = computed(() => props.defaultSortColumn || "");
const defaultSortDirection = computed(() => props.defaultSortDirection || "");
const columnsConfig = computed(() => props.columns || []);
// 表格排序
const { sortColumn, sortDirection, handleSort, sortedData } = useSortableTable(
displayData,
defaultSortColumn,
defaultSortDirection,
columnsConfig
);
// 监听排序变化,通知父组件
watchEffect(() => {
emits("sort:change", {
sortColumn: sortColumn.value,
sortDirection: sortDirection.value
});
});
// 最终数据
const finalData = computed(() => {
if (!props.pinnedCondition) return sortedData.value;
const pinned: T[] = [];
const unpinned: T[] = [];
sortedData.value.forEach((row) => {
if (props.pinnedCondition!(row as T)) {
pinned.push(row as T);
} else {
unpinned.push(row as T);
}
});
return [...pinned, ...unpinned];
});
// 获取索引值
const getIndexValue = (column: TableColumn, rowIndex: number) => {
if (typeof column.index === "function") return column.index(rowIndex);
return typeof column.index === "number" ? column.index + rowIndex : rowIndex;
};
// 获取单元格内容
const getCellValue = (row: any, column: TableColumn, rowIndex: number) => {
// 如果是索引列
if (column.type === "index") return getIndexValue(column, rowIndex + 1);
// 优先使用 prop
if (column.prop && typeof column.prop === "string") return row[column.prop];
// 其次使用 slot 对应的字段
if (column.slot && typeof column.slot === "string") return row[column.slot];
return undefined;
};
// 获取列的字段名
const getColumnKey = (column: TableColumn, index?: number) =>
column.key ||
(column.prop as string) ||
column.slot ||
column.label ||
(index ?? "");
// 处理表头点击排序
const onHeaderClick = (column: TableColumn) => {
if (!column.sortable) return;
const columnKey = getColumnKey(column);
if (!columnKey) return;
handleSort({
key: String(columnKey),
sortable: column.sortable,
name: column.label || ""
});
};
/** 过滤隐藏列 */
const visibleColumns = computed(() =>
(props.columns || []).filter((column) => !column.hidden)
);
/** 获取列的样式 */
const getColumnStyle = (column: TableColumn) => {
const style: Record<string, string> = {};
if (column.width) {
style.width =
typeof column.width === "number" ? `${column.width}px` : column.width;
style.maxWidth =
typeof column.width === "number" ? `${column.width}px` : column.width;
}
if (column.minWidth) {
style.minWidth =
typeof column.minWidth === "number"
? `${column.minWidth}px`
: column.minWidth;
}
return style;
};
const reMeasure = () => setTimeout(() => measure(), 0);
// 尺寸变化重新计算滚动状态
useResizeObserver(tableEl, () => reMeasure());
// 显示滚动阴影
const showScrollShadow = (direction: "left" | "right") => {
if (props.hiddenShadow) return false;
const canScroll = !(arrivedState.left && arrivedState.right);
return canScroll && !arrivedState[direction] && !props.load;
};
// ---- 行折叠相关 ----
/** 存储每行的展开状态,key 为行索引或行 id */
const expandedRows = ref<Set<string | number>>(new Set());
/** 获取行的唯一标识 */
const getRowKey = (row: T, index: number): string | number => row.id ?? index;
/** 查是否有 row-append-{slot} 插槽 */
const hasSlot = computed(() =>
visibleColumns.value.some(
(col) => col.slot && $slots[`row-append-${col.slot}`]
)
);
/** 判断表格是否存在任何可展开的行 */
const hasAnyRowAppend = computed(() => {
if (!hasSlot.value) return false;
// 再检查是否有任意一行满足 rowAppendVisible 条件
return finalData.value.some((row, index) => {
if (props.rowAppendVisible) {
return props.rowAppendVisible(row as T, index);
}
return true;
});
});
/** 判断某行是否有 row-append 内容 */
const hasRowAppend = (row: T, index: number): boolean => {
if (!hasSlot.value) return false;
// 再检查 rowAppendVisible 条件
if (props.rowAppendVisible) {
return props.rowAppendVisible(row as T, index);
}
return true;
};
/** 判断某行是否展开 */
const isRowExpanded = (row: T, index: number): boolean => {
const key = getRowKey(row, index);
return expandedRows.value.has(key);
};
/** 切换行的展开状态 */
const toggleRowExpand = (row: T, index: number) => {
if (!props.rowAppendExpandable) return;
if (!hasRowAppend(row, index)) return;
const key = getRowKey(row, index);
const newExpanded = !expandedRows.value.has(key);
if (newExpanded) {
expandedRows.value.add(key);
} else {
expandedRows.value.delete(key);
}
emits("row-expand:change", { row, index, expanded: newExpanded });
};
/** 初始化展开状态 */
const initExpandedRows = () => {
expandedRows.value.clear();
// 如果默认展开,则把所有有 row-append 的行加入展开集合
if (props.rowAppendDefaultExpanded !== false) {
finalData.value.forEach((row, index: number) => {
if (hasRowAppend(row as T, index)) {
expandedRows.value.add(getRowKey(row as T, index));
}
});
}
};
// 监听数据变化,重新初始化展开状态
watch(
() => finalData.value,
() => {
initExpandedRows();
},
{ immediate: true }
);
// ---- 行折叠相关 ----
// 初始化显示数据
onMounted(() => {
if (!props.load && props.data.length > 0) {
displayData.value = props.data;
}
});
</script>
<template>
<div
class="relative [&>*]:scrollbar-hide"
:class="[
cn(
'before:absolute before:top-0 before:bottom-0 before:left-0 before:w-2',
'before:bg-linear-to-r before:from-blue-600/5 before:to-transparent',
'before:pointer-events-none before:z-10 before:transition-opacity',
'before:shadow-[inset_5px_0_4px_-3px_rgba(37,99,235,0.05)]',
'after:absolute after:top-0 after:bottom-0 after:right-0 after:w-2',
'after:bg-linear-to-l after:from-blue-600/5 after:to-transparent',
'after:pointer-events-none after:z-10 after:transition-opacity',
'after:shadow-[inset_-5px_0_4px_-3px_rgba(37,99,235,0.05)]',
{
'before:opacity-100': showScrollShadow('left'),
'before:opacity-0': !showScrollShadow('left'),
'after:opacity-100': showScrollShadow('right'),
'after:opacity-0': !showScrollShadow('right'),
'before:bottom-13 after:bottom-13':
props.pagination?.currentPage && props.pagination?.pageSize
}
)
]"
>
<Table
ref="tableEl"
:class="[
cn(
'relative bg-[#FCFDFF] text-xs 2xl:text-sm rounded-lg overflow-hidden',
props.class
)
]"
>
<TableHeader class="bg-[#F2F9FE] select-none">
<TableRow class="text-[#181818] font-medium">
<TableHead
v-for="(column, index) in visibleColumns"
:key="getColumnKey(column, index)"
:style="getColumnStyle(column)"
:class="{
'cursor-pointer': column.sortable,
'pl-8': hasAnyRowAppend && index === 0
}"
@click="onHeaderClick(column)"
>
<div
:class="
cn(
'flex items-center text-nowrap',
column.headerAlign || props.headerAlign
? `justify-${column.headerAlign || props.headerAlign}`
: '',
props.headerClassName,
column.headerClassName
)
"
>
<slot
v-if="column.slot"
:name="`header-${column.slot}`"
:label="column.label"
:column="column"
:is-sorted="sortColumn === getColumnKey(column)"
>
<slot
name="header"
:label="column.label"
:column="column"
:is-sorted="sortColumn === getColumnKey(column)"
>
{{ column.label }}
</slot>
</slot>
<template v-else>
<slot
name="header"
:label="column.label"
:column="column"
:is-sorted="sortColumn === getColumnKey(column)"
>
{{ column.label }}
</slot>
</template>
<!-- 排序指示器 icon -->
<span v-if="column.sortable" class="flex items-center ml-2">
<ChevronUpDownIcon
class="h-6! w-6! [&>.svg-top]:text-[#71717A4D] [&>.svg-bottom]:text-[#71717A4D]"
:class="{
'[&>.svg-top]:text-[#2563EB]! [&>.svg-bottom]:text-[#71717A4D]!':
sortColumn === getColumnKey(column) &&
sortDirection === 'asc',
'[&>.svg-bottom]:text-[#2563EB]! [&>.svg-top]:text-[#71717A4D]!':
sortColumn === getColumnKey(column) &&
sortDirection === 'desc'
}"
/>
</span>
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody class="[&_tr:last-child]:border-0! text-[#71717A]">
<template
v-for="(row, rowIndex) in finalData"
:key="row.id || rowIndex"
>
<TableRow
class="relative group border-[#0088FE1a] hover:bg-[#ECF2FF] text-inherit"
:class="{
'cursor-pointer':
rowAppendExpandable && hasRowAppend(row as T, rowIndex)
}"
@click="toggleRowExpand(row as T, rowIndex)"
>
<TableCell
v-for="(column, colIndex) in visibleColumns"
:key="getColumnKey(column, colIndex)"
:style="getColumnStyle(column)"
:class="
cn(
column.cellAlign || props.cellAlign
? `text-${column.cellAlign || props.cellAlign}`
: '',
props.cellClassName,
column.cellClassName,
colIndex === 0 &&
rowAppendExpandable &&
hasAnyRowAppend &&
!hasRowAppend(row as T, rowIndex)
? 'pl-8'
: ''
)
"
>
<div
class="contents"
:class="[
colIndex === 0 &&
rowAppendExpandable &&
hasRowAppend(row as T, rowIndex)
? 'flex! h-full items-center gap-1.5'
: ''
]"
>
<!-- 展开/收起指示器 -->
<span
v-if="
colIndex === 0 &&
rowAppendExpandable &&
hasRowAppend(row as T, rowIndex)
"
class="flex items-center justify-center transition-transform duration-250"
:class="{ 'rotate-90': isRowExpanded(row as T, rowIndex) }"
>
<PlayIcon class="size-2.5! text-blue-[71717a]" />
</span>
<slot
:name="column.slot"
:row="row"
:column="column"
:index="rowIndex"
:value="getCellValue(row, column, rowIndex)"
:is-sorted="sortColumn === getColumnKey(column)"
>
{{
column.formatter
? (column.formatter(
getCellValue(row, column, rowIndex),
row,
rowIndex
) ?? "-")
: (getCellValue(row, column, rowIndex) ?? "-")
}}
</slot>
</div>
</TableCell>
</TableRow>
<!-- 按列分发的行追加内容 -->
<TableRow
v-if="
visibleColumns.some(
(col) => col.slot && $slots[`row-append-${col.slot}`]
) &&
(rowAppendVisible?.(row as T, rowIndex) ?? true)
"
class="border-[#0088FE1a] overflow-hidden bg-muted! hover:bg-gray-200/50! transition-colors duration-200"
:class="{
'visible [&>td>div]:max-h-0 [&>td>div]:opacity-0 border-transparent ':
!isRowExpanded(row as T, rowIndex)
}"
>
<TableCell
v-for="(column, colIndex) in visibleColumns"
:key="'append-' + getColumnKey(column, colIndex)"
:style="getColumnStyle(column)"
:class="
cn(
'py-0 transition-all duration-300',
column.cellAlign || props.cellAlign
? `text-${column.cellAlign || props.cellAlign}`
: '',
props.cellClassName,
column.cellClassName
)
"
>
<div
class="transition-all duration-300 overflow-hidden"
:class="{
'max-h-96 opacity-100': isRowExpanded(row as T, rowIndex),
'max-h-0 opacity-0': !isRowExpanded(row as T, rowIndex)
}"
>
<slot
v-if="column.slot"
:name="`row-append-${column.slot}`"
:row="row"
:index="rowIndex"
:column="column"
:expanded="isRowExpanded(row as T, rowIndex)"
/>
</div>
</TableCell>
</TableRow>
</template>
<!-- 空状态 -->
<TableEmpty
v-if="finalData.length === 0 && !props.load"
:colspan="visibleColumns?.length"
class="relative"
>
<div
class="max-sm:absolute max-sm:flex items-center justify-center"
:style="{
left: `${x}px`,
width: isMobile ? `${width}px` : 'auto'
}"
>
<slot name="empty" :colspan="visibleColumns?.length">
<span class="text-gray-400">暂无数据</span>
</slot>
</div>
</TableEmpty>
<!-- 加载骨架 -->
<template v-if="props.load && displayData.length === 0">
<TableRow v-for="i in 5" :key="i">
<TableCell v-for="j in visibleColumns?.length" :key="j" class="p-2">
<Skeleton class="h-10 w-full" />
</TableCell>
</TableRow>
</template>
</TableBody>
<!-- 加载状态 -->
<template v-if="props.load && displayData.length !== 0">
<div class="absolute inset-0 bg-white/40 z-10" />
</template>
<TableFooter :colspan="visibleColumns?.length">
<slot
name="footer"
:data="finalData"
:columns="visibleColumns"
:total="finalData.length"
/>
</TableFooter>
</Table>
<!-- 分页器 只有当 pagination 配置了 currentPage 和 pageSize 时才显示 -->
<div
v-if="props.pagination?.currentPage && props.pagination?.pageSize"
class="w-full mt-4 h-9"
:colspan="visibleColumns?.length"
>
<NewTablePagination
:total="props.pagination.total"
:page-size="props.pagination.pageSize"
:current-page="props.pagination.currentPage"
:load="props.load"
:align="props.pagination.align"
class="h-full"
@update:page="(page: number) => emits('page:change', page)"
/>
</div>
</div>
</template>
+79
View File
@@ -0,0 +1,79 @@
import type { ClassValue } from "clsx";
import type { HTMLAttributes } from "vue";
type CommonTypes = {
/** 单元格的自定义类名 */
cellClassName?: ClassValue;
/** 表头的自定义类名 */
headerClassName?: ClassValue;
/** 表头对齐方式 */
headerAlign?: "start" | "center" | "end";
/** 单元格对齐方式 */
cellAlign?: "left" | "center" | "right";
};
export type PaginationProps = {
/** 每页显示条目个数,默认值 `5` `该属性为必填属性` */
pageSize: number;
/** 总条目数,默认值 `0` `该属性为必填属性` */
total: number;
/** 当前页数 `该属性为必填属性` */
currentPage: number;
/** 分页器对齐方式 */
align?: "start" | "center" | "end";
};
export type TableProps<T extends Record<string, any>> = CommonTypes & {
class?: HTMLAttributes["class"];
/** 表格数据 */
data: T[];
/** 表格列配置 */
columns?: TableColumn[];
/** 默认排序列 */
defaultSortColumn?: string;
/** 默认排序方向 */
defaultSortDirection?: "asc" | "desc" | "";
/** 数据缓冲,加载新数据时不立即清空旧数据,避免表格闪烁 */
buffer?: boolean;
/** 加载状态 */
load?: boolean;
/** 分页相关配置 */
pagination?: PaginationProps;
/** 是否隐藏溢出阴影 */
hiddenShadow?: boolean;
/** 置顶条件函数,返回 true 的行将被置顶(不受排序影响) */
pinnedCondition?: (row: T) => boolean;
/** 控制按列分发的 row-append 行是否可见(静态控制,优先级低于展开状态) */
rowAppendVisible?: (row: T, index: number) => boolean;
/** row-append 默认是否展开,默认 true */
rowAppendDefaultExpanded?: boolean;
/** 是否启用点击行展开/收起 row-append */
rowAppendExpandable?: boolean;
};
export type TableColumn = CommonTypes & {
/** 显示的标题 */
label?: string;
/** 字段名称,对应列内容的字段名 */
prop?: string | ((index: number) => number);
/** 唯一标识 */
key?: string;
/** 自定义列的内容插槽 */
slot?: string;
/** 对应列的类型,如果设置了 `index` 则显示该行的索引(从 `1` 开始计算) */
type?: "index";
/** 如果设置了 `type=index`,可以通过传递 `index` 属性来自定义索引 */
index?: number | ((index: number) => number | string);
/** 对应列的宽度 */
width?: string | number;
/** 对应列的最小宽度,对应列的最小宽度,与 `width` 的区别是 `width` 是固定的,`min-width` 会把剩余宽度按比例分配给设置了 `min-width` 的列 */
minWidth?: string | number;
/** 对应列是否可以排序 */
sortable?: boolean;
/** 是否隐藏该列 */
hidden?: boolean;
/** 格式化函数 */
formatter?: (value: any, row: any, index: number) => string;
/** 是否将 0 视为 null 值处理(排序时放在末尾) */
zeroAsNull?: boolean;
};
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
})
</script>
<template>
<Primitive
data-slot="button"
:data-variant="variant"
:data-size="size"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
+35
View File
@@ -0,0 +1,35 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
'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 rounded-4xl border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=size-])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
outline: 'border-border bg-background dark:bg-transparent hover:bg-muted hover:text-foreground dark:hover:bg-input/30 aria-expanded:bg-muted aria-expanded:text-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost: 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
destructive: 'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
'default': 'h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5',
'xs': 'h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*=size-])]:size-3',
'sm': 'h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
'lg': 'h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
'icon': 'size-9',
'icon-xs': 'size-6 [&_svg:not([class*=size-])]:size-3',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { PaginationRootEmits, PaginationRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { PaginationRoot, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<PaginationRootProps & {
class?: HTMLAttributes['class']
}>()
const emits = defineEmits<PaginationRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<PaginationRoot
v-slot="slotProps"
data-slot="pagination"
v-bind="forwarded"
:class="cn('mx-auto flex w-full justify-center', props.class)"
>
<slot v-bind="slotProps" />
</PaginationRoot>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { PaginationListProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { PaginationList } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<PaginationListProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<PaginationList
v-slot="slotProps"
data-slot="pagination-content"
v-bind="delegatedProps"
:class="cn('gap-1 flex items-center', props.class)"
>
<slot v-bind="slotProps" />
</PaginationList>
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { PaginationEllipsisProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { MoreHorizontalIcon } from 'lucide-vue-next'
import { PaginationEllipsis } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<PaginationEllipsisProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<PaginationEllipsis
data-slot="pagination-ellipsis"
v-bind="delegatedProps"
:class="cn('size-9 [&_svg:not([class*=size-])]:size-4 flex items-center justify-center', props.class)"
>
<slot>
<MoreHorizontalIcon />
<span class="sr-only">More pages</span>
</slot>
</PaginationEllipsis>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { PaginationFirstProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '@/components/ui/button'
import { reactiveOmit } from '@vueuse/core'
import { ChevronsLeftIcon } from 'lucide-vue-next'
import { PaginationFirst, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = withDefaults(defineProps<PaginationFirstProps & {
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}>(), {
size: 'default',
})
const delegatedProps = reactiveOmit(props, 'class', 'size')
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<PaginationFirst
data-slot="pagination-first"
:class="cn(buttonVariants({ variant: 'ghost', size }), '', props.class)"
v-bind="forwarded"
>
<slot>
<ChevronsLeftIcon data-icon="inline-start" />
<span class="hidden sm:block">First</span>
</slot>
</PaginationFirst>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { PaginationListItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '@/components/ui/button'
import { reactiveOmit } from '@vueuse/core'
import { PaginationListItem } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = withDefaults(defineProps<PaginationListItemProps & {
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
isActive?: boolean
}>(), {
size: 'icon',
})
const delegatedProps = reactiveOmit(props, 'class', 'size', 'isActive')
</script>
<template>
<PaginationListItem
data-slot="pagination-item"
v-bind="delegatedProps"
:class="cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
props.class)"
>
<slot />
</PaginationListItem>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { PaginationLastProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '@/components/ui/button'
import { reactiveOmit } from '@vueuse/core'
import { ChevronsRightIcon } from 'lucide-vue-next'
import { PaginationLast, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = withDefaults(defineProps<PaginationLastProps & {
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}>(), {
size: 'default',
})
const delegatedProps = reactiveOmit(props, 'class', 'size')
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<PaginationLast
data-slot="pagination-last"
:class="cn(buttonVariants({ variant: 'ghost', size }), '', props.class)"
v-bind="forwarded"
>
<slot>
<span class="hidden sm:block">Last</span>
<ChevronsRightIcon data-icon="inline-end" />
</slot>
</PaginationLast>
</template>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = withDefaults(defineProps<{
href?: string
size?: ButtonVariants['size']
isActive?: boolean
class?: HTMLAttributes['class']
}>(), {
size: 'icon',
isActive: false,
})
</script>
<template>
<a
:href="href"
data-slot="pagination-link"
:data-active="isActive ? '' : undefined"
:aria-current="isActive ? 'page' : undefined"
:class="cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
'',
props.class,
)"
>
<slot />
</a>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { PaginationNextProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '@/components/ui/button'
import { reactiveOmit } from '@vueuse/core'
import { ChevronRightIcon } from 'lucide-vue-next'
import { PaginationNext, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = withDefaults(defineProps<PaginationNextProps & {
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}>(), {
size: 'default',
})
const delegatedProps = reactiveOmit(props, 'class', 'size')
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<PaginationNext
data-slot="pagination-next"
:class="cn(buttonVariants({ variant: 'ghost', size }), 'pr-2!', props.class)"
v-bind="forwarded"
>
<slot>
<span class="hidden sm:block">Next</span>
<ChevronRightIcon data-icon="inline-end" class="cn-rtl-flip" />
</slot>
</PaginationNext>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { PaginationPrevProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '@/components/ui/button'
import { reactiveOmit } from '@vueuse/core'
import { ChevronLeftIcon } from 'lucide-vue-next'
import { PaginationPrev, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = withDefaults(defineProps<PaginationPrevProps & {
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}>(), {
size: 'default',
})
const delegatedProps = reactiveOmit(props, 'class', 'size')
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<PaginationPrev
data-slot="pagination-previous"
:class="cn(buttonVariants({ variant: 'ghost', size }), 'pl-2!', props.class)"
v-bind="forwarded"
>
<slot>
<ChevronLeftIcon data-icon="inline-start" class="cn-rtl-flip" />
<span class="hidden sm:block">Previous</span>
</slot>
</PaginationPrev>
</template>
+9
View File
@@ -0,0 +1,9 @@
export { default as Pagination } from './Pagination.vue'
export { default as PaginationContent } from './PaginationContent.vue'
export { default as PaginationEllipsis } from './PaginationEllipsis.vue'
export { default as PaginationFirst } from './PaginationFirst.vue'
export { default as PaginationItem } from './PaginationItem.vue'
export { default as PaginationLast } from './PaginationLast.vue'
export { default as PaginationLink } from './PaginationLink.vue'
export { default as PaginationNext } from './PaginationNext.vue'
export { default as PaginationPrevious } from './PaginationPrevious.vue'
+16
View File
@@ -0,0 +1,16 @@
<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="table-container" class="relative w-full overflow-x-auto">
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
<slot />
</table>
</div>
</template>
+17
View File
@@ -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>
<tbody
data-slot="table-body"
:class="cn('[&_tr:last-child]:border-0', props.class)"
>
<slot />
</tbody>
</template>
+17
View File
@@ -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>
<caption
data-slot="table-caption"
:class="cn('text-muted-foreground mt-4 text-sm', props.class)"
>
<slot />
</caption>
</template>
+17
View File
@@ -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>
<td
data-slot="table-cell"
:class="cn('p-3 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0', props.class)"
>
<slot />
</td>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { cn } from '@/lib/utils'
import TableCell from './TableCell.vue'
import TableRow from './TableRow.vue'
const props = withDefaults(defineProps<{
class?: HTMLAttributes['class']
colspan?: number
}>(), {
colspan: 1,
})
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<TableRow>
<TableCell
:class="
cn(
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
props.class,
)
"
v-bind="delegatedProps"
>
<div class="flex items-center justify-center py-10">
<slot />
</div>
</TableCell>
</TableRow>
</template>
+17
View File
@@ -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>
<tfoot
data-slot="table-footer"
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
>
<slot />
</tfoot>
</template>
+17
View File
@@ -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>
<th
data-slot="table-head"
:class="cn('text-foreground h-12 px-3 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0', props.class)"
>
<slot />
</th>
</template>
+17
View File
@@ -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>
<thead
data-slot="table-header"
:class="cn('[&_tr]:border-b', props.class)"
>
<slot />
</thead>
</template>
+17
View File
@@ -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>
<tr
data-slot="table-row"
:class="cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors has-aria-expanded:bg-muted/50', props.class)"
>
<slot />
</tr>
</template>
+9
View File
@@ -0,0 +1,9 @@
export { default as Table } from './Table.vue'
export { default as TableBody } from './TableBody.vue'
export { default as TableCaption } from './TableCaption.vue'
export { default as TableCell } from './TableCell.vue'
export { default as TableEmpty } from './TableEmpty.vue'
export { default as TableFooter } from './TableFooter.vue'
export { default as TableHead } from './TableHead.vue'
export { default as TableHeader } from './TableHeader.vue'
export { default as TableRow } from './TableRow.vue'
+10
View File
@@ -0,0 +1,10 @@
import type { Updater } from '@tanstack/vue-table'
import type { Ref } from 'vue'
import { isFunction } from '@tanstack/vue-table'
export function valueUpdater<T>(updaterOrValue: Updater<T>, ref: Ref<T>) {
ref.value = isFunction(updaterOrValue)
? updaterOrValue(ref.value)
: updaterOrValue
}
+7
View File
@@ -0,0 +1,7 @@
import type { ClassValue } from "clsx"
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+1
View File
@@ -0,0 +1 @@
export * from "./sortableTable";
+100
View File
@@ -0,0 +1,100 @@
export interface SortableColumn {
key?: string;
slot?: string;
prop?: string | ((index: number) => number);
name?: string; // 列的显示名称
sortable?: boolean; // 是否可排序
zeroAsNull?: boolean; // 是否将 0 视为 null 值处理
[key: string]: any;
}
// 可排序表格的函数
export const useSortableTable = <T>(
dataRef: Ref<T[]>,
defaultSortColumn: MaybeRefOrGetter<string> = "", // 默认排序列的key值,可以让某一列默认排序
defaultSortDirection: MaybeRefOrGetter<"asc" | "desc" | ""> = "asc", // 为空表示不排序
columnsRef?: Ref<SortableColumn[]> // 列配置,用于获取 zeroAsNull 等配置
) => {
// 排序状态
const sortColumn = ref<string>(toValue(defaultSortColumn));
const sortDirection = ref<"asc" | "desc" | "">(
toValue(defaultSortColumn) ? toValue(defaultSortDirection) : ""
);
// 监听默认排序参数的变化
watchEffect(() => {
const newColumn = toValue(defaultSortColumn);
sortColumn.value = newColumn;
sortDirection.value = newColumn ? toValue(defaultSortDirection) : "";
});
// 排序处理函数
const handleSort = (column: SortableColumn) => {
if (!column.sortable) return;
const columnKey = column.key || column.slot || column.prop;
if (!columnKey || typeof columnKey !== "string") return;
if (sortColumn.value === columnKey) {
// 当前列被点击:升序 -> 降序 -> 取消排序
if (sortDirection.value === "asc") {
// 如果是升序,改为降序
sortDirection.value = "desc";
} else if (sortDirection.value === "desc") {
// 如果是降序,取消排序
sortColumn.value = "";
sortDirection.value = "";
} else {
// 如果是不排序,设置为升序
sortDirection.value = "asc";
}
} else {
// 如果点击的是新列,设置为升序
sortColumn.value = columnKey;
sortDirection.value = "asc";
}
};
// 计算排序后的数据
const getSortedData = computed(() => {
const data = dataRef.value || [];
if (!sortColumn.value || !sortDirection.value) return data;
// 查找当前排序列的配置
const currentColumn = columnsRef?.value?.find(
(col) => (col.key || col.slot || col.prop) === sortColumn.value
);
return [...data].sort((a: any, b: any) => {
let aValue = a[sortColumn.value];
let bValue = b[sortColumn.value];
const factor = sortDirection.value === "asc" ? 1 : -1;
// 如果配置了 zeroAsNull,将 0 视为 null
if (currentColumn?.zeroAsNull) {
if (aValue === 0) aValue = null;
if (bValue === 0) bValue = null;
}
// 处理 null 和 undefined 值
if (aValue == null && bValue == null) return 0;
if (aValue == null) return 1;
if (bValue == null) return -1;
// 根据数据类型进行排序
if (typeof aValue === "number" && typeof bValue === "number") {
return (aValue - bValue) * factor;
} else {
// 字符串或其他类型
return String(aValue).localeCompare(String(bValue)) * factor;
}
});
});
return {
sortColumn,
sortDirection,
handleSort,
sortedData: getSortedData
};
};
+26
View File
@@ -0,0 +1,26 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "reka-luma",
"font": "inter",
"typescript": true,
"tailwind": {
"config": "",
"css": "app/assets/css/tailwind.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"pointer": true,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"composables": "@/composables"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
View File
+2144
View File
File diff suppressed because it is too large Load Diff
+52 -2
View File
@@ -1,6 +1,56 @@
// @ts-check
import tsParser from '@typescript-eslint/parser'
import simpleImportSort from 'eslint-plugin-simple-import-sort'
import vueParser from 'vue-eslint-parser'
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt(
// Your custom configs here
)
// .ts/.tsx 文件直接使用 TypeScript parser
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
},
},
// .vue 文件:顶层用 vue-eslint-parser<script> 块内用 ts parser
{
files: ['**/*.vue'],
languageOptions: {
parser: vueParser,
parserOptions: {
parser: tsParser,
ecmaVersion: 'latest',
sourceType: 'module',
},
},
},
// import 自动排序
{
plugins: {
'simple-import-sort': simpleImportSort,
},
rules: {
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error',
},
},
{
files: ['**/type.ts', '**/types.ts', '**/*.d.ts'],
rules: {
'no-unused-vars': 'off',
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
},
},
)
+12 -10
View File
@@ -1,14 +1,16 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
import tailwindcss from "@tailwindcss/vite";
import svgLoader from "vite-svg-loader";
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
compatibilityDate: "2025-07-15",
devtools: { enabled: true },
modules: [
'@nuxtjs/tailwindcss',
'@pinia/nuxt',
'@vueuse/nuxt',
'dayjs-nuxt',
'@nuxt/eslint',
'shadcn-nuxt'
]
})
css: ["~/assets/css/tailwind.css"],
vite: {
plugins: [tailwindcss(), svgLoader()]
},
modules: ["@pinia/nuxt", "@vueuse/nuxt", "@nuxt/eslint", "shadcn-nuxt"]
});
+16
View File
@@ -0,0 +1,16 @@
[
{
"name": "AI智能题库",
"homepage": "http://localhost:3000",
"url": "http://localhost:3000/api/search",
"method": "get",
"type": "GM_xmlhttpRequest",
"contentType": "json",
"data": {
"title": "${title}",
"type": "${type}",
"options": "${options}"
},
"handler": "return (res)=> res.code === 1 ? [res.question, res.answer] : [res.msg, undefined]"
}
]
+20 -3
View File
@@ -10,22 +10,39 @@
"postinstall": "prisma generate && nuxt prepare"
},
"dependencies": {
"@heroicons/vue": "^2.2.0",
"@nuxt/eslint": "1.15.2",
"@nuxtjs/tailwindcss": "6.14.0",
"@pinia/nuxt": "0.11.3",
"@prisma/adapter-mariadb": "7.8.0",
"@prisma/client": "7.8.0",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^14.3.0",
"@vueuse/nuxt": "14.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.20",
"dayjs-nuxt": "2.1.11",
"eslint": "^10.4.0",
"lucide-vue-next": "^1.0.0",
"nuxt": "^4.4.6",
"pinia": "^3.0.4",
"reka-ui": "^2.9.7",
"shadcn-nuxt": "2.7.3",
"shadcn-vue": "^2.7.3",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",
"vite-svg-loader": "^5.1.1",
"vue": "^3.5.34",
"vue-router": "^5.0.7"
},
"devDependencies": {
"@typescript-eslint/parser": "^8.59.4",
"dotenv": "^17.4.2",
"prisma": "^7.8.0"
"eslint-plugin-simple-import-sort": "^13.0.0",
"prisma": "^7.8.0",
"typescript": "^6.0.3",
"vue-eslint-parser": "^10.4.0"
}
}
}
+1758 -789
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
// server/api/cache/clear.post.ts - 清空答题缓存接口
import { setResponseStatus } from "h3";
import { invalidAccessTokenResponse, verifyAccessToken } from "~~/server/utils/auth";
import { answerCache } from "~~/server/utils/cache";
import { serverEnv } from "~~/server/utils/env";
import { createApiLogger } from "~~/server/utils/logging";
/**
* 清空内存缓存
*
* 这是一个管理类接口,配置 ACCESS_TOKEN 时必须携带正确 token
* 清理的是当前 Nuxt 进程内缓存;多实例部署时,每个实例都有自己的内存缓存
*/
export default defineEventHandler((event) => {
const logger = createApiLogger(event, "api.cache.clear");
// 没配置 token 时保持旧服务的开放行为;配置后用于简单保护管理接口
if (!verifyAccessToken(event)) {
setResponseStatus(event, 403);
logger.warn("invalid_access_token");
return {
success: false,
message: invalidAccessTokenResponse().msg
};
}
// 缓存被关闭时不报错,只明确告诉调用方当前没有缓存可清
if (!serverEnv.enableCache || !answerCache) {
return {
success: false,
message: "缓存未启用"
};
}
// 清空当前进程内缓存,不影响问答记录和运行统计中的 uptime
answerCache.clear();
logger.info("finish_success");
return {
success: true,
message: "缓存已清除"
};
});
+19
View File
@@ -0,0 +1,19 @@
// server/api/health.get.ts - 服务健康检查接口
import { serverEnv } from "~~/server/utils/env";
import { SERVICE_VERSION } from "~~/server/utils/runtimeState";
/**
* 健康检查不需要鉴权
*
* 这个接口用于部署平台、Docker healthcheck 或人工确认服务是否启动;
* 返回模型名和缓存开关,但不返回 API Key、baseURL 或其他敏感配置
*/
export default defineEventHandler(() => {
return {
status: "ok",
message: "AI题库服务运行正常",
version: SERVICE_VERSION,
cache_enabled: serverEnv.enableCache,
model: serverEnv.openAiModel
};
});
+194
View File
@@ -0,0 +1,194 @@
// server/api/search.ts - OCS AnswererWrapper 兼容搜索接口
import {
getQuery,
type H3Event,
readBody,
readFormData,
setResponseStatus} from "h3";
import {
ANSWER_SYSTEM_PROMPT,
createOcsErrorResponse,
extractAnswer,
formatAnswerForOcs,
parseQuestionAndOptions,
type SearchParams
} from "~~/server/utils/answer";
import { invalidAccessTokenResponse, verifyAccessToken } from "~~/server/utils/auth";
import { answerCache } from "~~/server/utils/cache";
import { createApiLogger, toSafeLogError } from "~~/server/utils/logging";
import { askAnswerStream } from "~~/server/utils/openai";
import { addQaRecord } from "~~/server/utils/runtimeState";
/**
* 把 query/body/form 中的值统一转成字符串
*
* OCS 传参通常是普通字符串,但 query 可能出现同名参数数组;
* 为了兼容旧 Flask 服务,这里取第一个值并把缺失值转为空字符串
*/
const toStringValue = (value: unknown) => {
if (Array.isArray(value)) return value[0]?.toString() || "";
return value?.toString() || "";
};
/** 判断 JSON body 是否是普通对象;数组和字符串都不是本接口接受的 body */
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === "object" && value !== null && !Array.isArray(value);
};
/**
* 兼容旧 Python 服务的三种入参方式
*
* 1. GET:从 query 读取 `title/type/options`
* 2. multipart/form-data:从 FormData 读取
* 3. JSON 或 urlencoded POST:通过 `readBody` 读取对象
*
* 返回 `"invalid_body"` 时,说明客户端传了数组、字符串等不合法 body,
* handler 会按安全规范返回 400 和本地错误文案
*/
const readSearchParams = async (
event: H3Event
): Promise<SearchParams | "invalid_body"> => {
const method = event.node.req.method?.toUpperCase() || "GET";
if (method === "GET") {
const query = getQuery(event);
return {
title: toStringValue(query.title).trim(),
type: toStringValue(query.type).trim(),
options: toStringValue(query.options).trim()
};
}
const contentType = event.node.req.headers["content-type"] || "";
if (contentType.includes("multipart/form-data")) {
const form = await readFormData(event);
return {
title: toStringValue(form.get("title")).trim(),
type: toStringValue(form.get("type")).trim(),
options: toStringValue(form.get("options")).trim()
};
}
const body = await readBody<unknown>(event).catch(() => null);
if (body === null || body === undefined) {
return {
title: "",
type: "",
options: ""
};
}
if (!isRecord(body)) {
return "invalid_body";
}
return {
title: toStringValue(body.title).trim(),
type: toStringValue(body.type).trim(),
options: toStringValue(body.options).trim()
};
};
/**
* OCS 答题搜索主接口
*
* 流程:
* - 校验请求方法和可选访问令牌
* - 读取题目、题型、选项,兼容 GET/JSON/form
* - 先查内存缓存,命中后不再请求 OpenAI
* - 未命中时拼提示词,服务端流式请求 Chat Completions
* - 清洗答案、写入缓存和最近问答记录,最后返回 OCS 兼容 JSON
*
* 安全边界:
* - 不向前端返回 OpenAI 错误、响应体、API Key 或内部堆栈
* - 日志只记录长度、阶段、耗时等摘要,不记录完整题目和完整 prompt
*/
export default defineEventHandler(async (event) => {
const logger = createApiLogger(event, "api.search");
const method = event.node.req.method?.toUpperCase() || "GET";
const startedAt = Date.now();
// Nitro 文件路由会匹配所有方法;这里显式限制,保持接口行为清楚
if (!["GET", "POST"].includes(method)) {
setResponseStatus(event, 405);
return createOcsErrorResponse("请求方法不支持");
}
// ACCESS_TOKEN 未配置时直接放行;配置后要求 header 或 query token 命中
if (!verifyAccessToken(event)) {
setResponseStatus(event, 403);
logger.warn("invalid_access_token");
return invalidAccessTokenResponse();
}
try {
// 读取并标准化 OCS 参数,避免后续逻辑关心请求来源
const params = await readSearchParams(event);
if (params === "invalid_body") {
setResponseStatus(event, 400);
return createOcsErrorResponse("请求体必须是 JSON 对象");
}
logger.info("read_question", {
questionLength: params.title.length,
type: params.type,
hasOptions: Boolean(params.options)
});
if (!params.title) {
return createOcsErrorResponse("未提供问题内容");
}
// 缓存 key 包含题目、题型和选项;同题不同选项不能共用答案
const cachedAnswer = answerCache?.get(
params.title,
params.type,
params.options
);
if (cachedAnswer) {
logger.info("cache_hit");
return formatAnswerForOcs(params.title, cachedAnswer);
}
// 构造和旧 Python 服务一致的提示词,再通过服务端流式请求模型
const prompt = parseQuestionAndOptions(
params.title,
params.options,
params.type
);
const streamResult = await askAnswerStream({
prompt,
systemPrompt: ANSWER_SYSTEM_PROMPT
});
const processedAnswer = extractAnswer(streamResult.answer, params.type);
// 先缓存再记录;这两步失败风险很低,且都是内存操作,不会阻塞主链路
answerCache?.set(params.title, processedAnswer, params.type, params.options);
addQaRecord({
question: params.title,
type: params.type,
options: params.options,
answer: processedAnswer
});
logger.info("finish_success", {
durationMs: Date.now() - startedAt,
chunkCount: streamResult.upstreamResponse.chunkCount,
answerLength: processedAnswer.length
});
return formatAnswerForOcs(params.title, processedAnswer);
} catch (error) {
// 按安全优先策略:详细错误只进服务端日志,OCS 端只看到本地通用文案
logger.error("finish_error", {
error: toSafeLogError(error)
});
return createOcsErrorResponse("服务器内部错误");
}
});
+33
View File
@@ -0,0 +1,33 @@
// server/api/stats.get.ts - 服务运行统计接口
import { setResponseStatus } from "h3";
import {
invalidAccessTokenResponse,
verifyAccessToken
} from "~~/server/utils/auth";
import { createApiLogger } from "~~/server/utils/logging";
import { getRuntimeStats } from "~~/server/utils/runtimeState";
/**
* 运行统计接口
*
* 统计信息包含 uptime、模型名、缓存数量、最近问答记录数量
* 因为它能反映服务使用情况,所以和旧 Python 服务一样走可选 ACCESS_TOKEN 校验
*/
export default defineEventHandler((event) => {
const logger = createApiLogger(event, "api.stats");
// 未配置 ACCESS_TOKEN 时 verifyAccessToken 会放行;配置后必须携带正确 token
if (!verifyAccessToken(event)) {
setResponseStatus(event, 403);
logger.warn("invalid_access_token");
return {
success: false,
message: invalidAccessTokenResponse().msg
};
}
// getRuntimeStats 会实时清理过期缓存,再返回有效缓存数量
logger.info("finish_success");
return getRuntimeStats();
});
-6
View File
@@ -1,6 +0,0 @@
export default defineEventHandler(async () => {
return prisma.user.findMany({
include: { posts: true },
orderBy: { id: "desc" },
});
});
-10
View File
@@ -1,10 +0,0 @@
export default defineEventHandler(async (event) => {
const body = await readBody<{ email: string; name?: string | null }>(event);
return prisma.user.create({
data: {
email: body.email,
name: body.name,
},
});
});
+123
View File
@@ -0,0 +1,123 @@
// server/utils/answer.ts - OCS 答题提示词构建、答案清洗和响应格式化
/** `/api/search` 从 OCS 请求中最终抽取出的标准参数 */
export interface SearchParams {
/** 题目正文,对应 OCS 的 `${title}` */
title: string;
/** 题型:single、multiple、judgement、completion;未知值也允许透传给提示词 */
type: string;
/** 选项文本,通常是 A/B/C/D 多行内容;没有选项时为空字符串 */
options: string;
}
/** OCS AnswererWrapper handler 期望的成功响应结构 */
export interface OcsSuccessResponse {
code: 1;
/** 返回原题,handler 会将它作为匹配题目展示 */
question: string;
/** 返回最终答案;多选题必须是 `#` 分隔字符串,而不是数组 */
answer: string;
}
/** OCS AnswererWrapper handler 期望的失败响应结构 */
export interface OcsErrorResponse {
code: 0;
/** 只放本地业务文案,不暴露上游或内部异常详情 */
msg: string;
}
/**
* 不同题型对应的补充说明
*
* 这里保留旧 Python 服务的语义:题型只影响提示词,不做硬编码答题
*/
const QUESTION_TYPE_PROMPTS: Record<string, string> = {
single: "这是一道单选题。",
multiple: "这是一道多选题,答案请用#符号分隔。",
judgement: "这是一道判断题,需要回答:正确/对/true/√ 或者 错误/错/false/×。",
completion: "这是一道填空题。"
};
/**
* 全局系统提示词
*
* 重点约束“只给答案、不解释”,因为 OCS 需要的是可直接填入的答案文本,
* 多余解释会降低自动匹配和展示效果
*/
export const ANSWER_SYSTEM_PROMPT =
"你是一个专业的考试答题助手。请直接回答答案,不要解释。选择题只回答选项的内容(如:地球);多选题用#号分隔答案,只回答选项的内容(如中国#世界#地球);判断题只回答: 正确/对/true/√ 或 错误/错/false/×;填空题直接给出答案。";
/**
* 将 OCS 请求参数整理成发给模型的用户提示词
*
* 旧 Python 服务就是在这里把题目、题型和选项拼成结构化文本;
* Nuxt 版保持同样逻辑,方便答案行为尽量一致
*/
export const parseQuestionAndOptions = (
question: string,
options: string,
questionType: string
) => {
let prompt = `问题: ${question}\n`;
const typePrompt = QUESTION_TYPE_PROMPTS[questionType];
if (typePrompt) {
prompt += `${typePrompt}\n`;
}
if (options) {
prompt += `选项:\n${options}\n`;
}
prompt += "请直接给出答案,不要解释。";
return prompt;
};
/**
* 从模型返回文本里提取最终答案
*
* 旧服务对多选题做了一个兼容处理:如果模型返回类似 “答案是 A、B、C”,
* 就尝试抽取 A-F 并改成 OCS 需要的 `A#B#C`。如果模型已经返回 `#`
* 分隔内容,或者不是多选题,则直接返回原文本
*/
export const extractAnswer = (aiResponse: string, questionType: string) => {
const trimmedResponse = aiResponse.trim();
if (questionType === "multiple") {
const lines = trimmedResponse.split("\n");
for (const line of lines) {
if (/[A-D]/iu.test(line) && !line.includes("#")) {
const options = ["A", "B", "C", "D", "E", "F"].filter((option) =>
line.toUpperCase().includes(option)
);
if (options.length > 0) {
return options.join("#");
}
}
}
}
return trimmedResponse;
};
/** 生成 OCS 兼容成功响应,集中在一个函数里避免各 handler 自己拼结构 */
export const formatAnswerForOcs = (
question: string,
answer: string
): OcsSuccessResponse => {
return {
code: 1,
question,
answer
};
};
/** 生成 OCS 兼容失败响应;错误文案由调用处决定 */
export const createOcsErrorResponse = (msg: string): OcsErrorResponse => {
return {
code: 0,
msg
};
};
+42
View File
@@ -0,0 +1,42 @@
// server/utils/auth.ts - OCS API 可选访问令牌校验
import { getQuery, type H3Event } from "h3";
import { serverEnv } from "~~/server/utils/env";
/**
* H3 的 query 值可能是字符串、数组或 undefined
* 访问令牌只接受第一个值,和多数 Web 框架读取 query 的行为保持一致
*/
const firstQueryValue = (value: unknown) => {
if (Array.isArray(value)) return value[0]?.toString() || "";
return value?.toString() || "";
};
/**
* 校验可选访问令牌
*
* - 没配置 `ACCESS_TOKEN` 时,服务保持旧 Python 项目的开放行为
* - 配置后,兼容两种传递方式:`X-Access-Token` 请求头或 `?token=...`
*/
export const verifyAccessToken = (event: H3Event) => {
if (!serverEnv.accessToken) return true;
const headerToken = event.node.req.headers["x-access-token"]?.toString();
const queryToken = firstQueryValue(getQuery(event).token);
return (
headerToken === serverEnv.accessToken ||
queryToken === serverEnv.accessToken
);
};
/**
* OCS 搜索接口使用 `{ code, msg }`,而 stats/cache 使用 `{ success, message }`
* 这里先返回最基础的 OCS 形状,其他接口复用其中的 msg 文案
*/
export const invalidAccessTokenResponse = () => {
return {
code: 0,
msg: "无效的访问令牌"
};
};
+100
View File
@@ -0,0 +1,100 @@
// server/utils/cache.ts - 简单内存缓存,按题目、题型和选项生成缓存键
import { createHash } from "node:crypto";
import { serverEnv } from "~~/server/utils/env";
/**
* 旧 Python 服务使用进程内字典缓存答案
*
* Nuxt 版保持同样的“内存缓存”语义:服务重启后缓存清空,不跨实例共享;
* 这对个人题库服务足够简单,也避免为了缓存引入数据库或 Redis
*/
export class SimpleCache {
/** Map key 是题目、题型和选项计算出的 md5;value 保存写入时间和答案 */
private readonly cache = new Map<
string,
{
timestamp: number;
value: string;
}
>();
constructor(private readonly expirationSeconds: number) {}
/**
* 根据题目、题型和选项生成缓存键
*
* 同一道题如果选项不同,不能复用旧答案,所以三个字段都参与计算
*/
private generateKey(question: string, questionType = "", options = "") {
return createHash("md5")
.update(`${question}|${questionType}|${options}`, "utf8")
.digest("hex");
}
/**
* 读取缓存答案
*
* 命中过期项时会顺手删除,避免长时间运行后堆积无效缓存
*/
get(question: string, questionType = "", options = "") {
const key = this.generateKey(question, questionType, options);
const item = this.cache.get(key);
if (!item) return undefined;
if (Date.now() - item.timestamp < this.expirationSeconds * 1000) {
return item.value;
}
this.cache.delete(key);
return undefined;
}
/** 写入答案缓存,时间戳使用当前进程时间即可 */
set(question: string, answer: string, questionType = "", options = "") {
const key = this.generateKey(question, questionType, options);
this.cache.set(key, {
timestamp: Date.now(),
value: answer
});
}
/** 清空全部缓存,对应 `/api/cache/clear` */
clear() {
this.cache.clear();
}
/**
* 批量移除过期项
*
* 目前在统计缓存大小时调用,避免 stats 返回已经过期的数量
*/
removeExpired() {
const now = Date.now();
let removedCount = 0;
for (const [key, item] of this.cache.entries()) {
if (now - item.timestamp >= this.expirationSeconds * 1000) {
this.cache.delete(key);
removedCount += 1;
}
}
return removedCount;
}
/** 获取当前有效缓存数量 */
size() {
this.removeExpired();
return this.cache.size;
}
}
/**
* 全局答案缓存实例
*
* 如果 `ENABLE_CACHE=false`,导出 undefined,调用处用可选链即可保持逻辑简洁
*/
export const answerCache = serverEnv.enableCache
? new SimpleCache(serverEnv.cacheExpiration)
: undefined;
+23 -2
View File
@@ -1,6 +1,14 @@
// server/utils/db.ts - Prisma Client 单例,当前答题 API 不依赖数据库,保留给后续业务使用
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { PrismaClient } from "../../prisma/generated/client";
import { PrismaClient } from "~~/prisma/generated/client";
/**
* 将 `DATABASE_URL` 解析为 MariaDB driver 的连接池配置
*
* Prisma 7 使用 driver adapter 后,需要显式传入 adapter
* 这里不把连接串原样传给前端或日志,只在服务端初始化连接池
*/
const createMariaDbConfig = () => {
const databaseUrl = process.env.DATABASE_URL;
@@ -18,10 +26,16 @@ const createMariaDbConfig = () => {
database: decodeURIComponent(url.pathname.slice(1)),
connectionLimit: 5,
connectTimeout: 15_000,
acquireTimeout: 20_000,
acquireTimeout: 20_000
};
};
/**
* 创建 Prisma Client
*
* 连接池超时设置比默认值更宽松,是因为当前数据库是远程 MySQL,
* 默认超时时间过短时 Node driver 可能还没建好 socket 就失败
*/
const prismaClientSingleton = () => {
const adapter = new PrismaMariaDb(createMariaDbConfig());
return new PrismaClient({ adapter });
@@ -29,10 +43,17 @@ const prismaClientSingleton = () => {
type PrismaClientSingleton = ReturnType<typeof prismaClientSingleton>;
/**
* 开发环境热更新会反复加载模块
*
* 把 Prisma Client 挂到 globalThis 上,可以避免每次 HMR 都新建连接池
*/
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClientSingleton | undefined;
};
/** 全项目唯一 Prisma Client 实例 */
export const prisma = globalForPrisma.prisma ?? prismaClientSingleton();
// 生产环境由进程生命周期管理;开发环境缓存到全局,减少 HMR 连接泄漏
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
+86
View File
@@ -0,0 +1,86 @@
// server/utils/env.ts - 服务端环境变量读取与规范化
/**
* 允许 `.env` 里保留类似 Python 旧项目的写法:
* `OPENAI_API_BASE=https://example.com/v1 # 注释`
* Nuxt/Node 读到的是整行值,所以这里主动裁掉行尾注释
*/
const stripInlineComment = (value: string) => {
return value.replace(/\s+#.*$/u, "").trim();
};
/**
* 读取字符串环境变量
*
* - 空字符串视为未配置,回退到 fallback
* - 返回值统一去掉行尾注释,避免 baseURL、model 之类配置带上注释文本
*/
const readString = (key: string, fallback = "") => {
const value = process.env[key];
if (value === undefined || value === null || value.trim() === "") {
return fallback;
}
return stripInlineComment(value);
};
/** 读取可选字符串;空值统一转成 undefined,方便后续判断是否启用某能力 */
const readOptionalString = (key: string) => {
const value = readString(key);
return value || undefined;
};
/** 兼容常见布尔写法,便于 Docker、面板和手写 `.env` 配置 */
const readBoolean = (key: string, fallback: boolean) => {
const value = readString(key);
if (!value) return fallback;
return ["1", "true", "yes", "on"].includes(value.toLowerCase());
};
/** 读取整数配置;非法值不抛错,使用默认值保证服务能启动 */
const readInteger = (key: string, fallback: number) => {
const value = Number.parseInt(readString(key), 10);
return Number.isFinite(value) ? value : fallback;
};
/** 读取小数配置,主要用于 OpenAI temperature */
const readNumber = (key: string, fallback: number) => {
const value = Number.parseFloat(readString(key));
return Number.isFinite(value) ? value : fallback;
};
/** OpenAI 兼容接口 baseURL 统一不带结尾斜杠,后续再拼 `/chat/completions` */
const normalizeBaseUrl = (url: string) => {
return url.replace(/\/+$/u, "");
};
/**
* 服务端环境配置的唯一出口
*
* 业务代码不要直接读 `process.env`,这样可以把默认值、格式修正和安全边界
* 都收敛在这里,后续迁移部署平台时也只需要检查这个文件
*/
export const serverEnv = {
/** OpenAI 或兼容中转平台 API Key,仅服务端使用,不能返回给前端 */
openAiApiKey: readString("OPENAI_API_KEY"),
/** OpenAI 兼容 API base,例如 `https://api.openai.com/v1` */
openAiApiBase: normalizeBaseUrl(
readString("OPENAI_API_BASE", "https://api.openai.com/v1")
),
/** Chat Completions 使用的模型名 */
openAiModel: readString("OPENAI_MODEL", "gpt-3.5-turbo"),
/** 单次回答最大 token 数,沿用旧 Python 服务的配置语义 */
maxTokens: readInteger("MAX_TOKENS", 500),
/** 模型采样温度,越低越稳定 */
temperature: readNumber("TEMPERATURE", 0.7),
/** 可选访问令牌;配置后 API 需要 header 或 query 携带 token */
accessToken: readOptionalString("ACCESS_TOKEN"),
/** 是否启用内存缓存 */
enableCache: readBoolean("ENABLE_CACHE", true),
/** 缓存过期时间,单位秒 */
cacheExpiration: readInteger("CACHE_EXPIRATION", 86_400),
/** 预留日志级别配置,目前日志工具只负责安全输出 */
logLevel: readString("LOG_LEVEL", "INFO"),
/** 单行 SSE data 的最大字节数,用于防止异常流无限堆内存 */
maxSseLineBytes: readInteger("OPENAI_STREAM_MAX_SSE_LINE_BYTES", 1_048_576)
};
+80
View File
@@ -0,0 +1,80 @@
// server/utils/logging.ts - 安全日志工具,避免输出密钥、cookie 和完整敏感内容
import { randomUUID } from "node:crypto";
import type { H3Event } from "h3";
/**
* 常见敏感片段匹配
*
* 这里不是完整 DLP,只处理最容易误打出来的 key、Bearer token 和 token=xxx
* 真实请求体、响应体本来就不应该进入日志
*/
const SENSITIVE_VALUE_RE =
/(sk-[A-Za-z0-9_-]+|Bearer\s+[A-Za-z0-9._-]+|token[=:]\s*[^,\s}]+)/giu;
/** 对错误摘要做最小脱敏和截断,避免日志过长或泄露密钥 */
const sanitizeLogText = (value: string) => {
return value.replace(SENSITIVE_VALUE_RE, "[redacted]").slice(0, 500);
};
/**
* 将 unknown 异常变成安全可打印对象
*
* API 对前端统一返回“服务器内部错误”,日志里只保留类型和脱敏摘要,
* 既能排查方向,又不把上游响应体或 key 打出去
*/
export const toSafeLogError = (error: unknown) => {
if (error instanceof Error) {
return {
name: error.name,
message: sanitizeLogText(error.message)
};
}
return {
name: "UnknownError",
message: sanitizeLogText(String(error))
};
};
/**
* 创建带 requestId 的 API 日志器
*
* 每个 handler 只需要记录“阶段”和少量结构化字段;这里统一补 method、path、
* 耗时等上下文,方便后续按一次请求串起来查
*/
export const createApiLogger = (event: H3Event, scope: string) => {
const requestId =
event.node.req.headers["x-request-id"]?.toString() || randomUUID();
const startedAt = Date.now();
/** 内部统一出口,避免不同 level 打出的字段不一致 */
const write = (
level: "info" | "warn" | "error",
stage: string,
extra: Record<string, unknown> = {}
) => {
const payload = {
requestId,
scope,
stage,
method: event.node.req.method,
path: event.path,
elapsedMs: Date.now() - startedAt,
...extra
};
console[level](`[${scope}] ${stage}`, JSON.stringify(payload));
};
/** 返回轻量 APIhandler 不直接接触 console,便于以后替换日志实现 */
return {
requestId,
info: (stage: string, extra?: Record<string, unknown>) =>
write("info", stage, extra),
warn: (stage: string, extra?: Record<string, unknown>) =>
write("warn", stage, extra),
error: (stage: string, extra?: Record<string, unknown>) =>
write("error", stage, extra)
};
};
+226
View File
@@ -0,0 +1,226 @@
// server/utils/openai.ts - OpenAI Chat Completions 流式调用工具
import { serverEnv } from "~~/server/utils/env";
/** 旧项目的 `OPENAI_API_BASE` 语义是完整 base,例如 `https://api.openai.com/v1` */
const CHAT_COMPLETIONS_URL = `${serverEnv.openAiApiBase}/chat/completions`;
/** 复用编码器计算 SSE data 字节长度,避免循环里频繁创建对象 */
const textEncoder = new TextEncoder();
/** Chat Completions SSE 每个 `data:` chunk 的最小结构 */
interface ChatCompletionStreamChunk {
id?: string;
object?: string;
created?: number;
model?: string;
choices?: Array<{
delta?: {
content?: string;
role?: string;
};
finish_reason?: string | null;
index?: number;
}>;
usage?: unknown;
}
/** 读取完整 SSE 后汇总出的信息;只在服务端日志或内部状态中使用 */
interface ChatCompletionStreamResult {
/** 累积的 `choices[].delta.content` 文本 */
content: string;
/** content 字符长度,方便排查模型是否返回了异常长文本 */
contentLength: number;
/** 实际解析到的 SSE JSON chunk 数量 */
chunkCount: number;
/** 模型返回的结束原因,例如 stop、length;可能不存在 */
finishReason?: string | null;
/** 上游如果返回 usage,则保留下来供内部排查 */
usage?: unknown;
}
/** 答题调用结果:对外只用 answerupstreamResponse 留给服务端排查 */
export interface AskAnswerStreamResult {
answer: string;
upstreamResponse: ChatCompletionStreamResult;
}
/**
* 通过 Chat Completions 流式接口生成答案
*
* 对 OCS 客户端仍返回普通 JSON;流式只发生在服务端到 OpenAI 之间
* 这样既能更早读取上游内容,又不破坏 AnswererWrapper 的 handler 契约
*/
export const askAnswerStream = async ({
prompt,
systemPrompt
}: {
prompt: string;
systemPrompt: string;
}): Promise<AskAnswerStreamResult> => {
if (!serverEnv.openAiApiKey) {
throw new Error("OPENAI_API_KEY is not set.");
}
// 这里不要记录 request body:其中包含完整题目和可能的选项文本
const response = await fetch(CHAT_COMPLETIONS_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${serverEnv.openAiApiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
model: serverEnv.openAiModel,
temperature: serverEnv.temperature,
max_tokens: serverEnv.maxTokens,
stream: true,
messages: [
{
role: "system",
content: systemPrompt
},
{
role: "user",
content: prompt
}
]
})
});
if (!response.ok) {
throw new Error(`OpenAI request failed with status ${response.status}.`);
}
if (!response.body) {
throw new Error("OpenAI stream body is empty.");
}
// 将 SSE delta 读完后再返回给 API handler,由 handler 统一做答案清洗和缓存
const upstreamResponse = await readChatCompletionStream(response.body);
return {
answer: upstreamResponse.content.trim(),
upstreamResponse
};
};
/**
* 读取 Chat Completions SSE 流
*
* 上游返回形如:
* `data: {"choices":[{"delta":{"content":"..."}}]}`
* `data: [DONE]`
*
* 网络 chunk 不一定按行对齐,所以用 buffer 保存尚未拼完整的一行
*/
export const readChatCompletionStream = async (
stream: ReadableStream<Uint8Array>
): Promise<ChatCompletionStreamResult> => {
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffer = "";
let content = "";
let chunkCount = 0;
let finishReason: string | null | undefined;
let usage: unknown;
/** 把一个已解析 JSON chunk 合并进最终结果 */
const collectChunk = (chunk: ChatCompletionStreamChunk) => {
chunkCount += 1;
const deltaContent = collectDeltaContent(chunk);
if (deltaContent) {
content += deltaContent;
}
if (chunk.usage) {
usage = chunk.usage;
}
finishReason =
chunk.choices?.find((choice) => choice.finish_reason)?.finish_reason ??
finishReason;
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
// stream: true 可以正确处理跨 chunk 的多字节字符,例如中文
buffer += decoder.decode(value, { stream: true });
assertSseBufferSize(buffer, serverEnv.maxSseLineBytes);
// 只处理已经遇到换行的完整 SSE 行,最后一段留到下次 chunk 再拼
const lines = buffer.split(/\r?\n/u);
buffer = lines.pop() ?? "";
for (const line of lines) {
const chunk = parseSseDataLine(line, serverEnv.maxSseLineBytes);
if (chunk) collectChunk(chunk);
}
}
// 处理流结束时仍留在 buffer 中的最后一行
const finalText = buffer + decoder.decode();
for (const line of finalText.split(/\r?\n/u)) {
const chunk = parseSseDataLine(line, serverEnv.maxSseLineBytes);
if (chunk) collectChunk(chunk);
}
return {
content,
contentLength: content.length,
chunkCount,
finishReason,
usage
};
};
/**
* 解析单行 SSE data
*
* 空行、event 行和 `[DONE]` 都不包含业务内容,直接跳过
* 超过大小限制的 data 行视为异常,防止上游或代理故障导致内存压力
*/
export const parseSseDataLine = (
line: string,
maxDataLineBytes: number
): ChatCompletionStreamChunk | null => {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) return null;
const data = trimmed.slice("data:".length).trim();
if (!data || data === "[DONE]") return null;
if (textEncoder.encode(data).byteLength > maxDataLineBytes) {
throw new Error("OpenAI stream data line is too large.");
}
return JSON.parse(data) as ChatCompletionStreamChunk;
};
/** 收集一个 chunk 中所有 choice 的增量文本 */
export const collectDeltaContent = (chunk: ChatCompletionStreamChunk) => {
return (
chunk.choices
?.map((choice) => choice.delta?.content || "")
.filter(Boolean)
.join("") || ""
);
};
/**
* 限制尚未出现换行的 SSE 缓冲区大小
*
* 正常 SSE 会频繁换行;如果一直没有换行且持续变大,说明可能不是合法 SSE
* 或上游返回了异常长单行,应该尽早失败
*/
export const assertSseBufferSize = (
buffer: string,
maxDataLineBytes: number
) => {
if (
!buffer.includes("\n") &&
textEncoder.encode(buffer).byteLength > maxDataLineBytes
) {
throw new Error("OpenAI stream buffer is too large.");
}
};
+71
View File
@@ -0,0 +1,71 @@
// server/utils/runtimeState.ts - 服务启动时间和最近问答记录的内存状态
import { answerCache } from "~~/server/utils/cache";
import { serverEnv } from "~~/server/utils/env";
/** 最近问答记录,沿用旧 Python 服务 dashboard/stats 的内存记录语义 */
export interface QaRecord {
/** 本地可读时间,方便后续如果恢复 dashboard 时直接展示 */
time: string;
/** ISO 时间,方便机器处理和排序 */
timestamp: string;
/** 题目正文 */
question: string;
/** 题型 */
type: string;
/** 选项文本 */
options: string;
/** 最终返回给 OCS 的答案 */
answer: string;
}
/** 只保留最近 100 条,避免长时间运行后内存无限增长 */
const MAX_RECORDS = 100;
/** 进程启动时间,用于 `/api/stats` 返回 uptime */
const startTime = Date.now();
/** 进程内问答记录;服务重启或多实例部署时不会共享 */
const qaRecords: QaRecord[] = [];
/** 对外展示的服务版本,和 README/API 文档保持一致 */
export const SERVICE_VERSION = "1.1.0";
/** 格式化成本地 `YYYY-MM-DD HH:mm:ss`,对齐旧 Python 服务记录格式 */
const formatLocalDateTime = (date: Date) => {
const pad = (value: number) => value.toString().padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
date.getDate()
)} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(
date.getSeconds()
)}`;
};
/**
* 追加一条问答记录
*
* 记录只用于统计和未来可能恢复的 dashboard,不作为题库持久化数据
*/
export const addQaRecord = (record: Omit<QaRecord, "time" | "timestamp">) => {
const now = new Date();
qaRecords.push({
time: formatLocalDateTime(now),
timestamp: now.toISOString(),
...record
});
if (qaRecords.length > MAX_RECORDS) {
qaRecords.shift();
}
};
/** 生成 `/api/stats` 响应,实时计算 uptime 和有效缓存数量 */
export const getRuntimeStats = () => {
return {
version: SERVICE_VERSION,
uptime: (Date.now() - startTime) / 1000,
model: serverEnv.openAiModel,
cache_enabled: serverEnv.enableCache,
cache_size: answerCache?.size() ?? 0,
qa_records_count: qaRecords.length
};
};